Skip to content

PA-Cockpit package

packages/pa-cockpit (@ronl/pa-cockpit) is the Public Affairs cockpit as a workspace package, imported by both the caseworker frontend and the public demo. It exists so that one cockpit renders in two applications without either one owning it.

Measured against main

89 source files โ€” 46 source, 43 test โ€” package version 2026.09.10, verified on main at 86af73e, 23 September 2026.


Its version is a release version, not a pin

The package sat at 1.0.0 for 49 commits, on the reasoning that something compiled into two applications which carry their own CalVer needs no version of its own. That was overruled in v2026.09.9, and v2026.09.10 is the first release that actually moved it โ€” a release that included a pa-cockpit change, which is exactly the case the rule predicted.

The reasoning for the bump is worth keeping, because the pin's reasoning was not wrong so much as incomplete. The package is private: true and consumed as "@ronl/pa-cockpit": "*", so its version constrains nothing at install time. What it does is record which pa-cockpit code a frontend or pa-demo release contains โ€” for the lockfile, and for the SBOM, audit and provenance tooling that reads it. Pinned at 1.0.0 it said nothing, while packages/shared moved for a single devDependency range.

So the rule is: a release version, moved only by a release that includes a packages/pa-cockpit/** change, and left to lag at the last one that did.

The scaffold test pins the rule, not a literal

src/scaffold.test.ts asserts the version matches a CalVer shape rather than equalling a string. It used to assert toBe('1.0.0'), and when the rule changed the command file was updated and this file was not โ€” so the first release to include a pa-cockpit change failed here rather than shipping. Do not re-pin the assertion to a literal; that is the change this test exists to catch.

It is also the reason /bump-release now runs the suite before it commits: format, lint and type-check were all clean, and the release was committed, pushed and opened as a pull request before anything said otherwise. Because pa-cockpit has no deploy workflow of its own, its suite runs in CI only inside Build and Deploy ACC Frontend โ€” audit, scan, build and the PA demo deploy were all green beside the one red check.


Why it is a package

The public demo was built first as a byte-identical vendored copy of the cockpit โ€” 44 files kept honest by a manifest, a sync script and a byte-level drift checker. That was deliberate rather than expedient: an extraction has to commit to an interface, and nothing outside packages/frontend had ever consumed the cockpit. Building a real second consumer first made the boundary empirical instead of imagined.

The measurement is what reframed the job. Those files left their own set through exactly five relative specifiers, and those five were precisely the five files the demo already overlaid with shims. The seam had already been discovered by construction, so the extraction had something concrete to formalise.

With the package in place, the fork, the manifest, the sync script, the drift checker and the workflow that ran it were all deleted.


The two kinds of seam

This is the load-bearing rule of the package, and the one most likely to be got wrong when adding to it:

Read at Supplied through Why
Module scope, by services configurePaCockpit({ auth, tenant }) services/pa.api.ts and services/dossierbeheer.api.ts read them at module scope and cannot consume a React context
Render time, by components the host prop on PADashboardV2 Module state feeding React components goes stale

Putting the two non-React services behind a module-scope configuration call is sound specifically because token lookup is not reactive: the value is read when a request is made, not when something renders.

Do not move a render-time seam into configurePaCockpit

Module state feeding React components is what caused the role-context defect during the demo build: a mount-effect snapshot never saw later mutations, so the UI silently kept rendering a stale value. onLogin and onLogout are plain callbacks invoked from a click handler, so they ride the host prop even though neither is a section or a component.


What a host must supply

configurePaCockpit({ auth, tenant });

PaCockpitAuth โ€” the subset of the host's auth service the cockpit touches. Not all of it mirrors keycloak-js:

Member Note
authenticated: boolean Required here, while keycloak-js declares it optional. A host adapter passes !!keycloak.authenticated
token: string \| undefined mirrors keycloak-js
updateToken(minValidity?) mirrors keycloak-js
getUser(): KeycloakUser \| null not keycloak-js's at all โ€” the host's own function, deriving a user from keycloak.tokenParsed

Ending a session is deliberately not part of this contract. A host that wants a login or logout control wires it through the onLogin / onLogout callbacks on the host prop, calling its own auth service directly.

PaCockpitTenant โ€” theme initialisation and tenant config lookup. The only field the cockpit reads from a tenant config is displayName, kept minimal on purpose so each host can pass its own richer object unchanged; the frontend's own TenantConfig carries theme, feature and contact blocks the cockpit has no business knowing about.

Auth and tenant resolve through configured getters inside functions, so a refreshed token is never captured by value and left stale.

If a host renders the cockpit without configuring it first, the package throws a named error rather than failing obscurely later.

The shell's host prop

PADashboardV2 takes its host seams as a required prop, not an optional one โ€” so a host cannot silently omit a seam and discover it at runtime. It carries the session callbacks and the components the shell renders but does not own.


PaSectionsRouter

PADashboardV2 renders no section content itself. It unconditionally delegates to a section router, so a host needs something to dispatch with.

Exporting the fourteen section components individually and asking every host to hand-write the same id-group dispatch was tried and reverted. That grammar โ€” which ids belong to Monitoring, which to Voortgang, which remount โ€” is package knowledge, not host knowledge, and hand-maintaining it per host was exactly the vendored fork's most-duplicated behaviour, merely formalised.

PaSectionsRouter is that grammar written once. A host composing its own router checks its own ids first and places this component as the unconditional tail.


The mode configuration

The shell groups work into four modes โ€” Vandaag, Dossiers, Monitoring and Voortgang โ€” plus Beheer. Static sections live in modes.config.ts; dossier rail items are data-driven, built from the dossier list at render time, so adding a dossier never touches that file.

The rail and the command palette derive from the mode set the host injects, rather than reading a module-level config. That is what lets the public demo present a curated subset without the package knowing anything about curation.

The whole cockpit is gated on the public-affairs realm role and the province org type at shell level; the per-item gates in the config are for future fine-graining.


What is deliberately not exported

The package surface was narrowed after a consumer audit, and the omissions are intentional:

  • allStaticSections and findPaModeForSection โ€” both operate on the unfiltered mode list. Re-exporting them would hand a host a second, unguarded door onto the full section list, which is precisely what the demo's allow-list exists to prevent. A host that needs them gets them from usePaModes(), narrowed to the modes it supplied.
  • isPaItemVisible and the gate context โ€” real code, but package-internal. The shell builds the gate context and applies it when rendering the rail, so a host never sees an un-gated rail item and has nothing to call these on.
  • OrgTypeGate โ€” zero consumers of this copy. The frontend appears to use it but imports a character-identical union its own caseworker config declares. Exporting it advertised a shared vocabulary that nothing shares.

Two exports have only a test as their consumer, recorded so a future audit does not read them as dead: getPaCockpitAuth and getPaCockpitTenant are read back by the frontend's host test, and SORT_SECTION_IDS by the demo's allow-list test. Both are the read side of something a host writes โ€” a host that could not read its own wiring back could not test it.


Styling

The package ships @ronl/pa-cockpit/styles.css with scoped pac-* rules rather than Tailwind utilities. Where the notifications panel was converted, the values used are the literal Tailwind-computed values rather than the nearest design token, so the rendered result stays pixel-identical.

The demo never imports the caseworker stylesheet. Every class a package-owned component renders must therefore also have a rule in the package's own sheet, or the demo renders unstyled. That duplication is required rather than decay, and a guard fails the build when the two sheets diverge โ€” see Testing.