# 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.
