Angular 22.1.x emits `var(--%NS%name)` for every CSS custom property in a component `styles:` block. No `@angular/core` release substitutes the placeholder, so all `--rhc-*` tokens resolve to nothing and the UI breaks. `npm run ci` does not catch it; only the Storybook axe job does. Pin every `@angular*` entry to the exact version 22.0.5, so a plain `npm install` cannot pull 22.1.x back in. Holding at 22.0.5 leaves three moderate advisories open, which made the audit step fail: GHSA-p297-fm68-3q8c and GHSA-hh8m-fm6v-7cvg. Neither is reachable. The app calls no `withRequestsMadeViaParent` and no `provideClientHydration`, and binds no untrusted value into a directive host binding. The audit gate therefore runs at `--audit-level=high`. A high advisory still fails the build. Restore the default audit level together with the upgrade, after an Angular release substitutes the placeholder. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
22 KiB
CLAUDE.md
Agent guide for this repo. The why lives in docs/reference/architecture/ARCHITECTURE.md,
docs/reference/architecture/0001-bff-lite-decision-dtos.md, and the learning guide
docs/reference/fp-tea-atomic-design.md (FP + The Elm Architecture + atomic design); this
file is the rules. When a decision below and those docs disagree, the docs win —
update this file.
POC of a Dutch BIG-register self-service portal (healthcare professionals log in,
view their registration, apply for re-registration). Angular 22, standalone,
signals. Auth is faked; data and business rules are served by a minimal ASP.NET
Core backend (backend/, see its README) and consumed through an NSwag-generated
typed client. The FE renders the backend's decisions. Reference data mimicking
BRP/DUO (Data/SeedData.cs) is in-memory; applications, documents and the brief
persist to a SQLite file via EF Core (WP-22) — docs/project/archive/backlog/WP-22-durable-persistence.md.
Monorepo (WP-67): two Angular projects share one backend + one shared library —
apps/ssp (Zorgverlener self-service, this doc's main subject) and apps/behandelportal
(Behandelaar backoffice, ADR-0002). Both import libs/shared (design system + kernel +
generated API client) and libs/beheer (the admin/stamdata context, used identically by
both). backend/ is unowned by either — a genuinely shared dependency.
Commands
npm start # ng serve ssp (proxies /api → backend) → http://localhost:4200
npm run start:behandelportal # ng serve behandelportal → http://localhost:4201
npm test # vitest — both apps + both shared libraries (ssp, behandelportal, shared, beheer)
npm run lint # eslint — enforces `any`-free code + import/layer boundaries
npm run build # ng build ssp && ng build behandelportal (must stay green)
npm run storybook # ssp's component library by atomic layer
npm run storybook:behandelportal # behandelportal's own instance (see "Monorepo" note below)
npm run gen:api # regenerate the ONE typed client (libs/shared) from the backend OpenAPI doc
npm run ci # run the CI gate locally BEFORE pushing (mirrors ci.yml); `npm run ci --full` adds storybook-a11y
docker compose up # run both FE apps + backend together (Swagger at :5000/swagger)
cd backend && dotnet test # backend rule + endpoint tests
task # list every task (a thin facade over the commands above)
Two Storybook instances, not one: apps/ssp and apps/behandelportal each have their own
auth context at the same @auth/* alias pointing at different physical directories — a single
merged tsconfig can't resolve both at once, so .storybook-ssp/ and .storybook-behandelportal/
are separate config dirs (npm run storybook[:behandelportal] / build-storybook[:behandelportal]),
each globbing its own app's stories + both shared libraries'.
Run npm run ci before every push (scripts/ci-local.sh) — it runs the same jobs
Gitea CI does (lint, format:check, check:tokens, test, ng build --localize, audit, backend
format+test, api-client drift), so a red build is caught locally. Two ways to make it
automatic: npm run ci by hand, or enable the opt-in hook with
git config core.hooksPath scripts/githooks (runs it on git push; bypass once with
--no-verify). The e2e + storybook-a11y jobs need a browser/servers — run --full for
storybook-a11y; e2e separately (the script prints how).
Second-locale gate: messages.en.xlf is a hand-maintained translation of every
$localize id; ng build --localize fails (via i18nMissingTranslation: error) if any id
lacks an English <target>. Add one whenever you add a $localize string — npm run ci
catches a miss before CI does.
.npmrc sets legacy-peer-deps=true (Storybook's peer range lags Angular 22).
Do not run npm audit fix --force — it downgrades Angular 22→21. Dev-only
advisories are pinned via package.json overrides. Angular is pinned to the exact
version 22.0.5: 22.1.x emits var(--%NS%name) and breaks every --rhc-* token, so the
two moderate advisories it fixes stay open. Neither is reachable, so the audit gate runs
at --audit-level=high (see the comment in ci.yml).
Model routing for agent delegation
Three custom agents in .claude/agents/ pin the model to the step, not the whole session —
so this doesn't depend on a human remembering to run /model at the right moment:
planner(Opus) — design/approach work: a WP's Decisions block, an ambiguous bug's root cause, sequencing a multi-file change. No Edit/Write access; hands back a plan.developer(Sonnet) — implementation once the approach is settled: routine code against a pre-made plan, ending green (npm run ci).task-runner(Haiku) — simple, read-only, mechanical checks: running a test suite,git status/grep, verifying a file exists. No Edit/Write access.
Delegate to the matching agent only when the current session isn't already on that
model — don't add indirection for its own sake. docs/project/archive/backlog/README.md's
session protocol is the worked example of this in practice.
The decisions (non-negotiable working agreements)
1. DDD: contexts then layers, dependencies point inward
apps/<app>/src/app/<context>/<layer>/ for an app-local context; libs/<lib>/src/<layer>/
for a cross-app library (WP-67). Two apps today: apps/ssp (Zorgverlener self-service —
contexts auth, overzicht (the portal home; composes registratie's dashboard sections
plus its own cross-context nav sections), registratie, herregistratie, brief
(letter-composition teaching slice), showcase (teaching page, not a feature; sanctioned
to read every context in its own app — nothing imports it)) and apps/behandelportal
(Behandelaar backoffice, ADR-0002 — contexts auth, behandeling). Two cross-app libraries: libs/shared (the
design system + kernel + generated API client — no business logic) and libs/beheer (the
admin/stamdata context, identical for both apps today — WP-67 folded a silently-diverging
duplicate copy back into one). auth is deliberately not shared even though today it's
near-identical in both apps — ADR-0002 models Zorgverlener/Medewerker as different
Principal variants with different login flows; the two copies are expected to diverge.
| Layer | Job | Angular allowed? |
|---|---|---|
domain/ |
business rules + data types | No — pure TS. Has .spec.ts |
application/ |
coordinate state/tasks (stores, commands) | yes (signals) |
infrastructure/ |
where data comes from (HTTP adapters) | yes (HTTP) |
contracts/ |
wire DTOs (the FE⇄BE seam) | no |
ui/ |
how it looks (components, pages) | yes |
Dependencies only point inward: ui → application → domain; every context in either
app may use libs/shared and libs/beheer; never the reverse (libs/shared may not
depend on libs/beheer either — it stays the base). ui/layout never import
infrastructure directly (reach data through an application store/command) —
lint-enforced (per app, since each app is cruised against its own tsconfig — WP-67's
.dependency-cruiser.base.js + one thin .dependency-cruiser.<app>.js per app). An app
may not import the other app's source directly. Cross-context only
overzicht → registratie → libs/shared|beheer, herregistratie → registratie → libs/shared|beheer, auth → libs/shared|beheer, brief → libs/shared|beheer (ssp);
behandeling → libs/shared|beheer, auth → libs/shared|beheer (behandelportal). Imports use
aliases as direction statements:
@shared/* @beheer/* @auth/* @overzicht/* @registratie/* @herregistratie/* @brief/* (ssp) —
@shared/* @beheer/* @auth/* @behandeling/* (behandelportal); each app's own
tsconfig.json declares its full map (the root tsconfig.json intentionally has no
paths — see its comment). domain/ imports nothing from Angular.
2. Atomic design: folder = layer
libs/shared/ui atoms → molecules → organisms; libs/shared/layout templates (shell,
page-shell); each app's own context ui/ pages. Each level only uses levels below,
and a shared component takes nav/copy as input()s or an injection token (e.g.
HEADER_NAV_ITEMS/HEADER_ADMIN_LINKS, DEBUG_PANEL in shell.component.ts) rather
than hardcoding one app's content — the two apps' primary nav genuinely differs. A new page
should be composition of existing blocks — adding building blocks is the
exception, not the default. Atoms are thin wrappers over CIBG Huisstijl (Bootstrap 5.2)
CSS classes (btn, form-control, card, …); we own only a small typed input() API,
the design system does the visuals. (Where CIBG lacks a class — e.g. skeleton,
spinner — the atom is a small hand-rolled surface built from the token bridge and carries a
// CIBG-GAP EXTENSION: marker; see ADR-0003. alert is not such a case: it wraps the
vendored .feedback feedback-* classes.)
The step-component contract. A wizard step follows the same rule as
address-fields.component.ts: values in, events out, no internal state. Three clauses:
- Inputs down. A step reads its data only from
input()s the container passes it. - One narrow output up. A step emits one specific event, not the container's whole
dispatch. dispatchis never passed down. The container owns the Model and decides what a step's event means; a step never callsdispatchitself.
Corollary: a step gets no story of its own. The wizard's own story already mounts every step, because it seeds the machine.
3. State: make illegal states unrepresentable
Default reflex — if you're about to add a second/third boolean to track state,
model a discriminated union instead. Three tools, all in libs/shared/src/application:
RemoteData<E,T>(remote-data.ts) —Loading | Empty | Failure{error} | Success{value}. Combine sources withmap/map2/andThen(Failure > Loading > Success). Render it via the<app-async>molecule (libs/shared/src/ui/molecules/async) — one of four templates, mutually exclusive by construction. Default loading spinner/skeleton is delay-gated (~250ms) so fast connections don't flash.- Elm-style store (
store.ts→createStore(initial, reduce)) — all state in one Model; change only bydispatch(msg)→ purereduce(model, msg). Models are tagged unions (seeherregistratie.machine.ts,intake.machine.ts). Templates send messages, never mutate.createStoreis the one wiring idiom — a page never hand-rollssignal(model)+ a localdispatch(). Naming: a top-level machine's State/Msg types are context-prefixed (ChangeRequestState,ChangeRequestMsg), never bareState/Msg; a top-level machine exportsinitial+reduce. A composable sub-machine embedded inside a parent model keeps prefixed value exports instead (initialUpload/reduceUpload, seeupload.machine.ts) — prefixing there avoids alias noise at the composition site. Result<E,T>+ value objects ("parse, don't validate") — raw input becomes a branded type only via a parser returningResult(ssp'sregistratie/domain/value-objects/:Postcode,Uren,BigNummer). Once you hold the type, never re-check it.
Derive, don't store what you can compute — e.g. the wizard's visible steps are
visibleSteps(answers), not a stored field (intake.machine.ts).
Side effects stay out of the reducer. A command (application/submit-*.ts)
does the HTTP, then dispatches a message describing the outcome. Reducer = "what the
new state is"; command = "go do it, then say what happened."
Shared cross-page state = one root singleton. Stores are providedIn: 'root'
(BigProfileStore, SessionStore). That single instance is the shared state — no
NgRx, no extra lib. Optimistic update pattern: begin* (flip pending) →
confirm* (clear + resource.reload()) / rollback* (undo).
4. BFF-lite + decision DTOs (ADR-0001)
infrastructure/ is the only layer that touches the network — the
anti-corruption boundary. Each screen gets one screen-shaped endpoint returning a
decision-enriched DTO; the FE renders decisions, it does not recompute business
rules. Per rule, pick: decision flag (server computes the boolean — e.g.
herregistratie eligibility) or config value (server sends threshold, FE applies
for instant feedback, server re-validates as authority — e.g. scholing threshold).
FE keeps only format validation, never as authority.
The generated client
(libs/shared/src/infrastructure/api-client.ts, npm run gen:api, drift-checked in CI) is
the wire contract — consume its types directly, as 19 of the 20 adapters do. A hand-written
contracts/*.dto.ts is the exception, only where codegen does not reach the endpoint or types
it too loosely (the four survivors are all the latter — the generator emits every property as
optional and flattens unions); such a file must still import nothing. Either way a hand-written
parse*/toDomain in infrastructure/ validates the untrusted shape and maps DTO → domain —
a generated type is a compile-time claim about the wire, not a runtime guarantee. Wiring a real .NET backend
touches only infrastructure/ + contracts/ (see ARCHITECTURE §6). Server-owned
rules live only on the server, with no FE mirror to drift from it — the FE may
mirror a server-supplied value (a threshold, a bound) for instant feedback, but
never reimplements the algorithm.
Business-tunable reference data ("stamdata") is config-as-code, not a DB. Tables the
business controls (profession↔diploma map, thresholds, policy-question text) live as typed
C# in backend/.../Stamdata/, validated at build by StamdataValidationTests (a bad edit
fails CI, never prod) — never runtime-editable. Operational configuration is the deliberate
exception, and ADR-0004 states it as a four-part test rather than a list: the catalog lives in
code, an unknown key fails closed, the value is operational rather than a shared business rule,
and writes are admin-capability-gated and audited. Two surfaces pass it today —
OrgTemplateStore (per-org letterhead) and FeatureFlagStore (rollout switches), both in
SQLite. A third surface must pass the same test, not argue by analogy. UI copy is $localize. See ADR-0004.
5. Testing
Vitest. Co-locate *.spec.ts next to the unit. Domain and pure logic must have a
spec (reducers, combinators, visibleSteps, parsers, boundary parse* adapters).
Test the pure function directly — no Angular TestBed for domain. UI is exercised via
Storybook stories (*.stories.ts co-located, a11y addon on), not heavy component tests —
each app has its own Storybook instance (.storybook-ssp/, .storybook-behandelportal/,
WP-67 — a single merged tsconfig can't resolve both apps' @auth/* at once), each globbing
its own app's stories plus both shared libraries'. Story titles mirror the sidebar's
Design System/Domein split (see libs/shared/docs/layers.mdx): a libs/shared/ui|layout
component is titled Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>;
a component in an app context's ui/, or in libs/beheer/ui, is titled
Domein/<Context>/<Name> — full stop, regardless of which atomic layer it is (a context
organism doesn't get its own Organisms/ bucket).
Conventions
- Standalone components only; no NgModules. Signal inputs (
input()),inject()over constructor DI (constructor only foreffect()/template-ref injection). - Angular-native control flow
@if/@for; fetch viaresource({ loader })over the generatedApiClientinside aninfrastructure/*.adapter.ts(one place HTTP lives), with aparse*boundary;withViewTransitions()for page transitions (header/footer have stableview-transition-name, excluded from the fade). - Naming: shared/reusable UI is English (language-agnostic:
button,wizard-shell); domain contexts are Dutch (registratie,herregistratie,*.machine.ts). Pick the language by which side of the seam the code is on. - English prose uses Simplified Technical English (STE). This covers documentation,
code comments, commit messages, ADRs, and the backlog notes. One idea per sentence;
20 words or fewer in a procedure, 25 in a description. Active voice, present tense.
One word for one meaning — pick a term and repeat it, do not vary it for style. Keep
articles ("the test fails"). Three nouns together at most. No idioms and no humour.
Six sentences per paragraph at most. Write a procedure as numbered steps, one action
per step.
STE governs form, not content. Split a long sentence; never drop a caveat, a
measurement, or a precise term to make it shorter.
STE does not apply to Dutch identifiers,
$localizecopy, quoted output, or existing documents you are not already editing. - User-facing copy =
$localize. Every user-visible string is wrapped in Angular's first-party$localize(no third-party i18n lib), with a stable custom id ($localize`:@@context.key:Tekst`). Source locale isnl; a second locale is a translation file, not a code change (the seam). Shared/English components must not hardcode Dutch — expose copy asinput()s with localizable defaults; the domain caller supplies the text (seelibs/shared/src/ui/molecules/async). Format-validation messages indomain/value-objects/stay co-located but are still$localize-wrapped. - Forms = one idiom. Any form with validation or submission uses a
*.machine.ts(Model/Msg/reduce) + value objects + asubmit-*command returningResult— the same shape as the wizards, whether it's one step or many. Don't hand-roll mutable fields + ad-hoc error signals. - Dates:
DatePipein templates,formatDatumNlin pure TS. A template formats a date with Angular'sDatePipe(| date: 'longDate'); pure TS that can't reach a pipe (a domain function, a$localizestring) uses the one hand-writtenformatDatumNl(libs/shared/src/kernel/datum.ts). Never a third hand-rolledtoLocaleDateStringcall. - Routes: lazy
loadComponent, persistentShellComponentparent (libs/shared),canActivate: [authGuard]on protected routes (each app's ownapp.routes.ts). - Theming: CIBG Huisstijl (a customized Bootstrap 5.2 build) is vendored under
public/cibg-huisstijl/and loaded via a<link>in each app'sindex.html;libs/shared/styles.scss(one copy, both apps'angular.jsonpoint at it — WP-67) holds a token bridge mapping the app's--rhc-*token vocabulary onto CIBG/--bs-*values (so components keep referencing tokens). System-font stack (licensed RO/Rijks fonts not shipped). See ADR-0003. - Scenario toggle (dev-only, not wired in prod builds):
?scenario=slow|loading|empty|erroron data pages (scenario.interceptor.ts) to see every async state — sticky per tab (change it via a full navigation, not an in-app link). Hand-writtenfetch/XHR calls (uploads,/brief/preview,/admin/org-template/*/preview,/brief/reveal-bignummer) bypass the interceptor. - Dev role stand-in (dev-only):
?role=drafter|approver|admin(or the⚙ statedev panel). Roles, how to switch, and what each unlocks:docs/reference/roles-and-access.md.adminunlocks the capability-gated pages:/brief/huisstijl(org-template editor),/beheer/stamdata,/beheer/zaken,/beheer/audit,/beheer/functies. - Prettier;
.editorconfig. tsconfig:noImplicitReturns,noPropertyAccessFromIndexSignature,noFallthroughCasesInSwitch,isolatedModules. - Enforced, not just hoped-for:
npm run lint(eslint.config.mjs, scoped to{apps,libs}/**) fails the build onany; the same config'smax-linesrule caps every{apps,libs}/**/*.{page,component,section,step}.tsfile at 250 lines (skipBlankLines: true,skipComments: true). 250 is reachable, not a style-guide default — the dashboard page lands at 42 lines.npm run dep:check(.dependency-cruiser.base.js+ one.dependency-cruiser.<app>.jsper app, WP-67) fails on illegal imports —domain/importing Angular, a context importing "upward" (theherregistratie → registratie → shared,auth → shareddirection), an app importing the other app's source, orlibs/shareddepending onlibs/beheer. CI (.github/workflows/ci.yml) runs lint +dep:check+check:tokens+ test (both apps + both libraries) + build (both apps), backenddotnet test, and an API-client drift check (one generated client,libs/shared/src/infrastructure/api-client.ts).
Adding a feature (recipe)
Domain first (types + pure rules + spec, no Angular) → infrastructure (adapter:
httpResource or command returning Result) → application (store if shared state;
union + pure reduce) → UI last (compose libs/shared/ui atoms, wrap async in
<app-async>, dispatch messages). Worked example: the SSP's intake wizard (herregistratie/).
The recipes are also invocable skills in .claude/skills/: new-feature,
new-context, value-object, form-machine, bff-endpoint, mutation-command,
ui-component, new-ssp (bootstrap a new portal from this template),
document-feature (ship/update docs in the same diff as the code).
Out of scope (POC, don't build unprompted)
Real auth/DigiD, NgRx, licensed RO/Rijks fonts + logo (system-font stack; text wordmark), runtime DTO validation on every endpoint, multi-tab session sync.