Files
atomic-design-poc/CLAUDE.md
Edwin van den Houdt 44eb2d2186
Some checks failed
CI / frontend (push) Successful in 1m46s
CI / storybook-a11y (push) Successful in 4m23s
CI / backend (push) Successful in 1m14s
CI / codeql (csharp) (push) Has been cancelled
CI / codeql (javascript-typescript) (push) Has been cancelled
CI / api-client-drift (push) Has been cancelled
CI / e2e (push) Has been cancelled
chore(deps): update npm packages within declared ranges; reformat for prettier 3.9.4
npm update brought every package to the latest version its existing package.json
range allows (Angular tooling 22.0.2/22.0.4 -> 22.0.5, prettier 3.8.4 -> 3.9.4,
typescript-eslint 8.62.0 -> 8.62.1); package.json itself needed no range changes.

Auditing actual deprecation warnings (not just outdated versions) found nothing
further to fix: @angular/platform-browser-dynamic and @angular-devkit/build-angular
are deprecated by Angular but still required peer dependencies of the latest
published @storybook/angular (10.4.6 — peer range still `>=18.0.0 < 22.0.0`,
already why .npmrc sets legacy-peer-deps); jest-process-manager/expect-playwright
are transitive-only through @storybook/test-runner's latest stable (0.24.4). No
newer version of either Storybook package exists yet that drops them. The
remaining npm audit advisory (@babel/core, low severity) is the same
already-documented, deliberately-left issue in README.md (fixing it downgrades
Angular). Left package.json's overrides untouched.

The prettier bump alone changed formatting opinions on files this session didn't
otherwise touch (a stale markdown italics marker, a few object-literal wrap
points) — reformatted everything so `format:check` (part of CI) doesn't regress.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 10:29:36 +02:00

190 lines
12 KiB
Markdown

# CLAUDE.md
Agent guide for this repo. The _why_ lives in `docs/ARCHITECTURE.md`,
`docs/architecture/0001-bff-lite-decision-dtos.md`, and the learning guide
`docs/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/backlog/WP-22-durable-persistence.md`.
## Commands
```bash
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
docker compose up # run FE + backend together (Swagger at :5000/swagger)
cd backend && dotnet test # backend rule + endpoint tests
```
`.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.
## 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.ts``createStore(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.
### 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.
- 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).
## 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.