Files
atomic-design-poc/CLAUDE.md
T
ehoandClaude Sonnet 5 e7e2f070f9 feat(brief): WP-28 demo script, e2e spec, and story gap-fill
Closes phase 6 (Brief v2): a demo script mapping shipped scenarios to
URL+click paths (no Brief v2 PRD ever existed to translate one from —
written directly against the code instead), one e2e spec covering
compose→approve→send and admin republish→drafter-sees-it, and
Storybook state gaps (rejection diff, read-only viewer, org logo,
upload rejection) that prior WPs left uncovered. Flags passage-picker
as dead code, superseded by besluit-panel.

npm run e2e is not verified green in this sandbox — see WP-28's
Deviations section; the pre-existing, unmodified smoke.spec.ts fails
identically here, pointing at a sandbox rendering issue rather than a
regression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 20:01:51 +02:00

15 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/backlog/WP-22-durable-persistence.md.

Commands

npm start          # ng serve (proxies /api → backend) → http://localhost:4200
npm test           # vitest
npm run lint       # eslint — enforces `any`-free code + import/layer boundaries
npm run build      # ng build (must stay green)
npm run storybook  # component library by atomic layer
npm run gen:api    # regenerate the typed client 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 FE + backend together (Swagger at :5000/swagger)
cd backend && dotnet test   # backend rule + endpoint tests

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; the shipped bundle audits clean.

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/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

src/app/<context>/<layer>/. Contexts: shared, auth, registratie, herregistratie, brief (letter-composition teaching slice), showcase (teaching page, not a feature; sanctioned to read every context — nothing imports it).

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; everyone may use shared; never the reverse. ui/layout never import infrastructure directly (reach data through an application store/command) — lint-enforced. Cross-context only herregistratie → registratie → shared, auth → shared, brief → shared. Imports use aliases as direction statements: @shared/* @auth/* @registratie/* @herregistratie/* @brief/*. domain/ imports nothing from Angular.

2. Atomic design: folder = layer

shared/ui atoms → molecules → organisms; shared/layout templates (shell, page-shell); context ui/ pages. Each level only uses levels below. 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. alert — the atom is a small hand-rolled surface built from the token bridge; see ADR-0003.)

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 shared/application:

  • RemoteData<E,T> (remote-data.ts) — Loading | Empty | Failure{error} | Success{value}. Combine sources with map/map2/andThen (Failure > Loading > Success). Render it via the <app-async> molecule (shared/ui/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.tscreateStore(initial, reduce)) — all state in one Model; change only by dispatch(msg)pure reduce(model, msg). Models are tagged unions (see herregistratie.machine.ts, intake.machine.ts). Templates send messages, never mutate. createStore is the one wiring idiom — a page never hand-rolls signal(model) + a local dispatch(). Naming: a top-level machine's State/Msg types are context-prefixed (ChangeRequestState, ChangeRequestMsg), never bare State/Msg; a top-level machine exports initial + reduce. A composable sub-machine embedded inside a parent model keeps prefixed value exports instead (initialUpload/reduceUpload, see upload.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 returning Result (registratie/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.

DTO lives in contracts/; a hand-written parse*/toDomain in infrastructure/ validates the untrusted shape and maps DTO → domain. Wiring a real .NET backend touches only infrastructure/ + contracts/ (see ARCHITECTURE §6). Server-owned rules stay in domain/*.policy.ts as reference impl + unit test, marked server-owned, but the FE doesn't call them.

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. Org-templates are the deliberate exception (operational per-org config in SQLite). 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. Story titles mirror the sidebar's Design System/Domein split (see src/docs/layers.mdx): a shared/ui/shared/layout component is titled Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>; a component in a context's 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 for effect()/template-ref injection).
  • Angular-native control flow @if/@for; fetch via resource({ loader }) over the generated ApiClient inside an infrastructure/*.adapter.ts (one place HTTP lives), with a parse* boundary; withViewTransitions() for page transitions (header/footer have stable view-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.
  • 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 is nl; a second locale is a translation file, not a code change (the seam). Shared/English components must not hardcode Dutch — expose copy as input()s with localizable defaults; the domain caller supplies the text (see shared/ui/async). Format-validation messages in domain/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 + a submit-* command returning Result — the same shape as the wizards, whether it's one step or many. Don't hand-roll mutable fields + ad-hoc error signals.
  • Dates: DatePipe in templates, formatDatumNl in pure TS. A template formats a date with Angular's DatePipe (| date: 'longDate'); pure TS that can't reach a pipe (a domain function, a $localize string) uses the one hand-written formatDatumNl (shared/kernel/datum.ts). Never a third hand-rolled toLocaleDateString call.
  • Routes: lazy loadComponent, persistent ShellComponent parent, canActivate: [authGuard] on protected routes (app.routes.ts).
  • Theming: CIBG Huisstijl (a customized Bootstrap 5.2 build) is vendored under public/cibg-huisstijl/ and loaded via a <link> in index.html; src/styles.scss 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|error on 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-written fetch/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 ⚙ state dev panel). Roles, how to switch, and what each unlocks: docs/reference/roles-and-access.md. admin unlocks 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) fails the build on any and on illegal imports — domain/ importing Angular, or a context importing "upward" (the herregistratie → registratie → shared, auth → shared direction). CI (.github/workflows/ci.yml) runs lint + check:tokens + test + build, backend dotnet test, and an API-client drift check.

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 shared/ui atoms, wrap async in <app-async>, dispatch messages). Worked example: the 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.