docs: replace SHOWCASE-ROADMAP.md with docs/backlog/ (17 WPs)

Turns the prior roadmap sketch into ordered, gated work packages (enforcement
gates, FP/DDD consistency, CIBG fidelity, Storybook curriculum, a11y) from the
2026-07-02 showcase-hardening audit.
This commit is contained in:
2026-07-02 17:07:59 +02:00
parent a2ed3ae5b8
commit f769242f76
19 changed files with 1326 additions and 94 deletions

83
docs/backlog/README.md Normal file
View File

@@ -0,0 +1,83 @@
# Backlog — showcase hardening
Ordered work packages that take this POC from "good" to **reference showcase**: CIBG
design-system fidelity, DDD/FP consistency, Storybook as curriculum, and WCAG compliance
with automated gates. Source: the architecture/CIBG/a11y audit of 2026-07-02 (plan:
"Showcase hardening").
This backlog **supersedes `docs/SHOWCASE-ROADMAP.md`**.
## Session protocol
- **Switch to Opus first** (`/model opus`) before tackling any WP.
- **One WP per session.** Read `CLAUDE.md`, this README, the WP file, and the WP's
"Read first" list — then execute. Do not start the next WP in the same session.
- The **Decisions** block in each WP is pre-made — don't relitigate it.
- A WP ends **GREEN** (below) with its acceptance criteria checked off and its Status
updated to `done` (+ commit hash).
- No WP leaves a lint rule/check disabled without an inline justification comment **and**
a cross-reference to the WP that will remove it.
## GREEN (global definition of done)
```bash
npm run lint && npm run check:tokens && npm test && npm run build && npm run build-storybook
```
From WP-01 onward, additionally:
```bash
npm run test-storybook:ci
```
Backend stays untouched throughout (frontend-only backlog); `cd backend && dotnet test`
only needs re-running if a WP unexpectedly touches `backend/`.
## Order
Gates land before the work they cover; each lint rule lands in the same WP as the fixes
for its existing violations, so every WP ends green.
| WP | Title | Phase | Status |
|----|-------|-------|--------|
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | todo |
| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | todo |
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | todo |
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | todo |
| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | todo |
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | todo |
| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | todo |
| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | todo |
| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | todo |
| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | todo |
| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | todo |
| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | todo |
| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | todo |
| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | todo |
| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | todo |
| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | todo |
| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | todo |
Sequencing dependencies (stated in the WPs too): 01 before 1015 (axe covers story churn);
03/04 before 0509 (boundaries stop new violations during refactors); 06 before 07 (typed
`<app-async>` before brief adopts it); 13 defines the gap-marker format that 11/12 reference
— if 11/12 run first, they define it and 13 adopts it.
## WP template
```markdown
# WP-NN — Title
Status: todo | in-progress | done (<commit>)
Phase: N — name
## Why
## Read first
## Decisions (pre-made, don't relitigate)
## Files
## Steps
## Acceptance criteria
## Verification
## Out of scope
## Risks
```

View File

@@ -0,0 +1,80 @@
# WP-01 — Axe-on-every-story CI gate
Status: todo
Phase: 0 — enforcement & gates
## Why
The Storybook a11y addon (`@storybook/addon-a11y`, configured in `.storybook/preview.ts`
for `wcag2a, wcag2aa, wcag21a, wcag21aa`) only surfaces violations *interactively*.
Nothing gates CI. This WP turns "a panel you can look at" into "a check that fails the
build", so every story added or changed by later WPs is automatically covered.
## Read first
- `.storybook/main.ts`, `.storybook/preview.ts` (existing a11y config — reuse its tags)
- `.github/workflows/ci.yml` (job layout to extend)
- `package.json` (scripts; note `.npmrc` has `legacy-peer-deps=true`)
## Decisions (pre-made, don't relitigate)
- Mechanism: **`@storybook/test-runner` + `axe-playwright`** against the static build.
The repo uses `@storybook/angular` on the **webpack** builder, which rules out
`@storybook/addon-vitest` (Vite-only). Fallback if test-runner doesn't support
Storybook 10 yet: a hand-rolled Playwright + `axe-core` loop over `storybook-static`
(same gate semantics — document the swap in this file if taken).
- Escape hatch: `parameters: { a11y: { disable: true } }` per story, allowed **only** with
an inline justification comment and a cross-reference to the WP that will fix it
(WP-16/WP-17).
## Files
- `package.json` (devDependencies + scripts)
- `.storybook/test-runner.ts` (new)
- `.github/workflows/ci.yml` (new job)
- Possibly a handful of story/component files for triaged violations
## Steps
1. Check version fit first: `npm view @storybook/test-runner peerDependencies` and pick
the release matching the installed Storybook 10 (`legacy-peer-deps` absorbs minor lag).
2. `npm i -D @storybook/test-runner axe-playwright http-server wait-on concurrently`
(+ `npx playwright install chromium` locally).
3. `.storybook/test-runner.ts`:
- `preVisit`: `injectAxe(page)`.
- `postVisit`: `getStoryContext(page, context)`; skip when
`parameters.a11y?.disable === true`; else `checkA11y(page, '#storybook-root', …)`,
honoring `parameters.a11y.config`/`options.runOnly` from `preview.ts` (don't
duplicate the tag list — read it from the story context).
4. Scripts:
- `"test-storybook": "test-storybook"`
- `"test-storybook:ci": "concurrently -k -s first -n sb,axe \"http-server storybook-static -p 6006 --silent\" \"wait-on tcp:127.0.0.1:6006 && test-storybook --url http://127.0.0.1:6006\""`
5. CI job `storybook-a11y` in `ci.yml`: checkout → setup-node (same version as the
`frontend` job) + npm cache → `npm ci``npx playwright install --with-deps chromium`
`npm run build-storybook``npm run test-storybook:ci`.
6. Run locally against a fresh `build-storybook`; triage violations: fix trivial ones
(labels, roles, contrast via `--rhc-*` tokens); anything structural gets the escape
hatch + comment + WP cross-ref.
7. Update `docs/backlog/README.md`: GREEN now includes `npm run test-storybook:ci`.
## Acceptance criteria
- [ ] `npm run test-storybook:ci` passes locally against a fresh `storybook-static/`.
- [ ] CI has a `storybook-a11y` job; an axe violation makes it fail (verify once with a
deliberately broken story, then revert).
- [ ] Every `a11y.disable` skip carries a justification comment + WP cross-ref.
- [ ] Backlog README GREEN updated.
## Verification
GREEN + `npm run test-storybook:ci`.
## Out of scope
Fixing structural a11y issues in components (WP-16/WP-17); Playwright e2e page sweeps
(explicitly not chosen).
## Risks
- test-runner ↔ Storybook 10 peer mismatch → use the documented fallback.
- Unknown violation count on first run → the escape hatch keeps the WP bounded.

View File

@@ -0,0 +1,67 @@
# WP-02 — Harden `check:tokens` + fix what it then catches
Status: todo
Phase: 0 — enforcement & gates
## Why
The token guard (`check:tokens` in `package.json`) only greps for hex colors and only
scans `registratie/ui`, `shared/ui`, `shared/layout`. It misses `rgb()`/`hsl()` values
and skips `brief/`, `auth/`, `herregistratie/`, `showcase/` entirely. The guard must
cover the whole app before the CIBG work (WP-10…13) leans on it.
## Read first
- `package.json` (`check:tokens` script)
- `src/styles.scss` (the token bridge — the vocabulary fixes must come from)
- `CLAUDE.md` theming section
## Decisions (pre-made, don't relitigate)
- Raw `px` values are **not** grepped (too many false positives); border-width fixes are
done manually in this WP and the script documents why px is out of scope.
- A `/* token-ok */` inline marker suppresses a justified false positive (e.g. `rgb` in a
comment or data-URI) — each use needs a reason in the comment.
## Files
- `package.json` → move logic to `scripts/check-tokens.sh` (new), keep the npm script name
- `src/app/shared/layout/site-footer/site-footer.component.ts``rgb(255 255 255 / 0.25)`
- `src/app/shared/ui/debug-state/debug-state.component.ts` — raw rgba/hex; tokenize and
drop its exclusion (it's dev-only chrome but the rule should have no holes)
- `src/app/brief/ui/letter-block/letter-block.component.ts` — raw `3px` border
- `src/app/brief/ui/letter-preview/letter-preview.component.ts` — raw `1px` border
- `src/app/brief/ui/passage-picker/passage-picker.component.ts` — raw `1px` border
## Steps
1. Create `scripts/check-tokens.sh`; `package.json` `check:tokens` calls it.
2. Extend the regex to `#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(` (skip lines containing
`token-ok`).
3. Extend the scanned set to **all** `src/app/**/*.component.ts`.
4. Run it; fix every hit using tokens from the `src/styles.scss` bridge (add a bridge
token only if no existing one fits — keep the vocabulary small).
5. Fix the raw border widths in the three brief components (`--rhc-border-width-*`),
even though px isn't grepped.
6. Plant a violation, confirm the script fails, remove it.
## Acceptance criteria
- [ ] Script scans all contexts and matches hex + rgb()/hsl().
- [ ] Zero exclusions; any `token-ok` marker has a reason.
- [ ] site-footer, debug-state, and the three brief components are tokenized.
- [ ] A planted violation provably fails `npm run check:tokens`.
## Verification
GREEN + `npm run test-storybook:ci`.
## Out of scope
Grepping px/rem values; scss files (`styles.scss` is the one place palette values are
allowed — it IS the bridge).
## Risks
Regex false positives in strings/URLs — that's what the `token-ok` marker is for; keep
its bar high.

View File

@@ -0,0 +1,76 @@
# WP-03 — Boundaries I: contracts purity + ApiClient confinement
Status: todo
Phase: 0 — enforcement & gates
## Why
`contracts/` is the FE⇄BE wire seam and must import **nothing** (CLAUDE.md §1,
ADR-0001) — but `registratie/contracts/dashboard-view.dto.ts` imports domain types, and
`change-request-form.component.ts` injects `ApiClient` straight into UI. Neither rule is
lint-enforced. Rule + fixes land together so this WP ends green.
## Read first
- `CLAUDE.md` §1 + §4 (layers, BFF-lite)
- `eslint.config.mjs` (existing zone style to extend)
- `src/app/registratie/contracts/dashboard-view.dto.ts`
- `src/app/registratie/infrastructure/dashboard-view.adapter.ts`
- `src/app/registratie/ui/change-request-form/change-request-form.component.ts` (~line 53)
- `src/app/registratie/application/submit-change-request.ts`
- `src/app/shared/infrastructure/role.ts` and `src/app/brief/ui/letter-composer/letter-composer.component.ts` (line 2)
## Decisions (pre-made, don't relitigate)
- Wire DTOs express enums as plain string-literal unions **inlined in the DTO file**
they describe the wire, not the domain. The adapter's `parse*` maps them to domain types.
- The `Role` **type** moves out of infrastructure (to `shared/domain` or
`shared/application` — pick where its current consumers point most naturally); the
role-reading mechanism (`role.interceptor.ts` etc.) stays infrastructure.
## Files
- `eslint.config.mjs` — two new restrictions
- `src/app/registratie/contracts/dashboard-view.dto.ts` — remove domain imports
- `src/app/registratie/infrastructure/dashboard-view.adapter.ts` — absorb the mapping
- `src/app/registratie/ui/change-request-form/change-request-form.component.ts` — stop
injecting `ApiClient`; the component calls the application command, which owns the
client dependency (move the `inject` into `submit-change-request.ts` or a thin
registratie adapter, matching how other commands get the client)
- `src/app/shared/infrastructure/role.ts` + new home for the `Role` type + the
`letter-composer.component.ts` / `brief.store.ts` import sites
## Steps
1. Rewrite `dashboard-view.dto.ts` as pure wire shapes (no imports at all); update the
adapter's `parse*`/`toDomain` to map wire → domain; run the adapter spec.
2. Move the `Role` type; update import sites (mechanical; lint will find them).
3. Refactor change-request-form: UI keeps dispatching to the machine; the submit command
receives/owns the `ApiClient` (pattern: other `submit-*.ts` commands).
4. Add eslint zones:
- `src/app/**/contracts/**` may import nothing from `@angular/*`, any `@*` context
alias, or relative app code.
- `@shared/infrastructure/api-client` importable only from `**/infrastructure/**`
(+ the app config/provider and the generated file itself).
5. Lint the repo; there must be zero new disables.
## Acceptance criteria
- [ ] `dashboard-view.dto.ts` has no import statements.
- [ ] `grep -rn "api-client" src/app --include="*.ts" | grep -v infrastructure` returns
only the generated client itself (and its provider wiring, if outside infra).
- [ ] Both eslint rules active; a planted violation fails lint.
- [ ] Change-request flow works (existing machine/command specs pass).
## Verification
GREEN + `npm run test-storybook:ci`. Manual smoke: `npm start` → Gegevens wijzigen →
submit a change request.
## Out of scope
The `ui ↛ infrastructure` rule for adapter injections in wizards — that's WP-04.
## Risks
Role-type move ripples through brief imports — mechanical; run tests before/after.

View File

@@ -0,0 +1,75 @@
# WP-04 — Boundaries II: `ui ↛ infrastructure` + showcase sanction
Status: todo
Phase: 0 — enforcement & gates
## Why
The documented rule `ui → application → domain` (CLAUDE.md §1) is not lint-enforced, and
three components inject infrastructure adapters directly. This is the biggest refactor of
Phase 0 — a pure move of wiring, no behavior change.
## Read first
- `CLAUDE.md` §1, `docs/ARCHITECTURE.md`
- `eslint.config.mjs`
- `src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts` (lines
~18-19: `BrpAdapter`/`parseBrpAddress`, `DuoAdapter`/`parseDuoLookup`)
- `src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts` (~line 28:
`IntakePolicyAdapter`)
- `src/app/registratie/application/big-profile.store.ts` (the facade pattern to copy)
- `src/app/showcase/ui/concepts.page.ts`
## Decisions (pre-made, don't relitigate)
- **Showcase is sanctioned, not fixed**: `showcase/` is a teaching page whose point is
showing multiple contexts side by side. Add an explicit eslint override allowing
`@registratie/*` / `@herregistratie/*` from `src/app/showcase/**`, with a message
comment — same precedent as the existing `debug-state` exemption. Document in
ARCHITECTURE.md: "showcase may read every context; nothing imports showcase."
- Move wiring only — do not redesign adapter signatures. Existing machine specs are the
safety net.
## Files
- `eslint.config.mjs` — forbid `**/ui/**` and `**/layout/**``**/infrastructure/**`;
add the showcase sanction
- `src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts` — BRP/DUO
lookups move behind a registratie `application/` facade (command or store methods)
- `src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts` — policy resource
moves behind **new** `src/app/herregistratie/application/` (folder doesn't exist yet)
- `docs/ARCHITECTURE.md` — fix "three contexts, four layers" → six contexts
(shared, auth, registratie, herregistratie, brief, showcase), five layers (+ contracts);
add the showcase sanction
- `CLAUDE.md` §1 — add `brief` to the context list; note the showcase sanction
## Steps
1. Create the application-layer facades (pattern: `big-profile.store.ts` /
`submit-*.ts` commands); components inject those instead of adapters.
2. Add the eslint restriction; add the showcase override with rationale message.
3. Fix any further hits lint finds (`letter-composer`'s infra import is handled in
WP-03; if it slipped, fix here).
4. Update both docs' context/layer inventory.
## Acceptance criteria
- [ ] Rule active; lint green; no disables beyond the documented showcase + debug-state
exemptions.
- [ ] No `**/ui/**` file imports from `**/infrastructure/**`.
- [ ] Both wizards behave unchanged (specs pass; manual smoke).
- [ ] CLAUDE.md and ARCHITECTURE.md list 6 contexts / 5 layers.
## Verification
GREEN + `npm run test-storybook:ci`. Manual smoke: `npm start` → run the registratie
wizard (BRP prefill + DUO lookup steps) and the intake wizard end-to-end.
## Out of scope
Redesigning the adapters or resource loading; parse boundaries (WP-05).
## Risks
Resource wiring moving layers can subtly change injection timing — keep facades
`providedIn: 'root'` like existing stores and hand the resource through unchanged.

View File

@@ -0,0 +1,66 @@
# WP-05 — Parse-don't-validate closure + MDX
Status: todo
Phase: 1 — FP/DDD core
## Why
Three adapters trust the wire, contradicting the repo's flagship "parse, don't validate"
principle (every response through a hand-written `parse*` returning `Result`).
## Read first
- `CLAUDE.md` §3 + §4; `docs/architecture/0001-bff-lite-decision-dtos.md`
- `src/app/registratie/infrastructure/applications.adapter.ts` (+ its spec — the pattern
to copy)
- The three offenders below
## Decisions (pre-made, don't relitigate)
- Unknown enum-ish values from the wire map to an explicit `Failure` (never a silent
fallback); specs pin that behavior.
- The MDX curriculum page ships in this WP (the WP that establishes an invariant teaches
it).
## Files
- `src/app/herregistratie/infrastructure/intake-policy.adapter.ts` — returns the raw DTO
resource with no parse; add `parseIntakePolicy(raw: unknown): Result<string, …>` and a
domain-side type
- `src/app/registratie/infrastructure/big-register.adapter.ts` (~line 25) —
`n.type as AantekeningType` → validated parse
- `src/app/brief/infrastructure/brief.adapter.ts` (~line 189) — `dto.scope as
PassageScope` → validated parse (the file is otherwise parse-heavy; this one field skips)
- New co-located specs: `intake-policy.adapter.spec.ts`, extend
`big-register.adapter.spec.ts` / `brief.adapter.spec.ts` (create if missing)
- New `src/docs/parse-dont-validate.mdx` — title `Foundations/Parse, don't validate`
## Steps
1. For each hole: write the small parser (pattern: `parseAanvraagStatus` in
`applications.adapter.ts`), wire it in, delete the cast.
2. Spec each parser: valid shape, missing field, unknown tag/enum value → `err`.
3. MDX page: the principle, value objects (`registratie/domain/value-objects/`), boundary
parsers, linking the three real call sites just fixed (before/after snippet).
## Acceptance criteria
- [ ] No unvalidated `as <DomainType>` casts in `**/infrastructure/**` (the sanctioned
"narrow unknown to `Partial<Dto>` then parse" entry-cast is fine).
- [ ] Each new parser has a spec including a rejection case.
- [ ] MDX renders under Foundations in Storybook.
## Verification
GREEN + `npm run test-storybook:ci`. Smoke: intake wizard still loads its policy
(`npm start` → /intake).
## Out of scope
Runtime validation on *every* endpoint (explicitly out of scope for the POC per
CLAUDE.md); `digid.adapter.ts` (faked auth, sanctioned).
## Risks
Seeded backend data containing values the new parsers reject → surfaces as `Failure` in
the UI; check the backend seed while writing the parsers and align the accepted set.

View File

@@ -0,0 +1,62 @@
# WP-06 — Generic async template contexts: kill `$any()` (18×)
Status: todo
Phase: 1 — FP/DDD core
## Why
18 `$any()` casts in templates defeat strict template checking. Root cause for ~9 of
them: `AsyncLoadedDirective` types its template context as `{ $implicit: unknown }`
(`src/app/shared/ui/async/async.component.ts`), so every `<ng-template appAsyncLoaded
let-p>` consumer must cast. The rest are template union-narrowing workarounds.
## Read first
- `src/app/shared/ui/async/async.component.ts` (component + directives)
- Consumers with `$any`: `src/app/registratie/ui/dashboard.page.ts`,
`registration-detail.page.ts`, `registration-summary/registration-summary.component.ts`
(×5, union peeking), `registratie-wizard.component.ts` (×4, step data),
`src/app/showcase/ui/concepts.page.ts` (×2)
## Decisions (pre-made, don't relitigate)
- Fix the root cause with generics + `static ngTemplateContextGuard`, not per-consumer
casts.
- Fallback (only if Angular's inference fights the `RemoteData<E,T> | Resource<T>` union
input): split into two typed inputs (`data` / `resource`) — record the swap here.
## Files
- `src/app/shared/ui/async/async.component.ts``AsyncComponent<T>`;
`AsyncLoadedDirective<T>` with
`static ngTemplateContextGuard<T>(dir, ctx): ctx is { $implicit: T }` (same for the
failure directive's error type if applicable)
- Every `$any()` call site (grep `-rn '\$any(' src/app`)
## Steps
1. Make the async component/directives generic; keep the public API otherwise identical.
2. Remove the now-unneeded `$any()`s in async consumers.
3. Remaining union narrowing: replace with `@switch` on the status tag
(registration-summary) or small typed `computed()` getters (wizard step data,
showcase fake resource).
4. `npm run build` (strict templates) is the real check here.
## Acceptance criteria
- [ ] `grep -rn '\$any(' src/app` → zero hits.
- [ ] No `as` casts added to compensate in component classes (typed getters are fine).
- [ ] Build green with strict template checking.
## Verification
GREEN + `npm run test-storybook:ci`. Smoke: dashboard + registration detail render.
## Out of scope
Brief page's `<app-async>` adoption (WP-07 — it depends on this WP's typing).
## Risks
Angular generic-component inference edge cases — the documented fallback keeps the WP
bounded.

View File

@@ -0,0 +1,70 @@
# WP-07 — Brief on the shared idioms + RemoteData MDX
Status: todo
Phase: 1 — FP/DDD core
Depends on: WP-06 (typed `<app-async>`)
## Why
The brief context drifted from the repo's own reflexes: `brief.store.ts` (~lines 22-25)
holds `busy: signal<boolean>` + `lastError: signal<string|null>` + a separate `saveState`
union side by side — representable illegal combos, the exact "second boolean" smell
CLAUDE.md §3 bans. And `brief.page.ts` hand-renders its load lifecycle with `@switch` +
spinner/alert instead of `RemoteData` + `<app-async>` — the only async flow in the app
bypassing the shared molecule.
## Read first
- `CLAUDE.md` §3; `src/app/shared/application/remote-data.ts`
- `src/app/brief/application/brief.store.ts`, `src/app/brief/ui/brief.page.ts`,
`src/app/brief/domain/brief.machine.ts` (+ spec)
- `src/app/registratie/application/applications.store.ts` (a store doing it right)
## Decisions (pre-made, don't relitigate)
- Transient submit/save state becomes **one** tagged union
(`Idle | Busy | Failed{error}`), replacing `busy`+`lastError`. `saveState` keeps its
union shape (align tag style).
- Load lifecycle → `RemoteData` + `<app-async>`; the machine keeps owning the letter's
*domain* lifecycle (loading tags move out of the machine only if they purely mirror
the fetch — keep the seam: RemoteData = fetch, machine = letter).
- Keep the debounced-save sequencing identical; only re-type the state.
## Files
- `src/app/brief/application/brief.store.ts`
- `src/app/brief/ui/brief.page.ts`
- `src/app/brief/domain/brief.machine.ts` + `brief.machine.spec.ts`
- New `src/docs/remote-data.mdx` — title `Foundations/RemoteData & Async`
## Steps
1. Replace the signal trio with one union signal; update consumers (letter-composer bar,
autosave status line).
2. Route the page's load through `RemoteData` + `<app-async>` (typed via WP-06); wire the
existing loading/empty/failure templates.
3. Update machine/store specs for the union transitions.
4. MDX page: the four states, `map2`/`andThen`, the delay-gated spinner, and the
`?scenario=` dev toggle — linking `brief.page.ts` and `dashboard.page.ts` as live
examples.
## Acceptance criteria
- [ ] No boolean-plus-error signal pairs in `brief/`.
- [ ] `/brief` renders all four async states (check with `?scenario=slow|empty|error`).
- [ ] Specs cover the transition union (Busy→Failed, Busy→Idle).
- [ ] MDX renders under Foundations.
## Verification
GREEN + `npm run test-storybook:ci`. Manual: `npm start``/brief` with
`?scenario=slow`, `?scenario=error`; exercise autosave + submit + rejection flow.
## Out of scope
Brief component stories (WP-15); machine renaming conventions (WP-08).
## Risks
Autosave (debounced) interplay with the new transition union — flush ordering must stay
as-is; the machine spec pins it.

View File

@@ -0,0 +1,70 @@
# WP-08 — One store idiom + machine naming + TEA MDX
Status: todo
Phase: 1 — FP/DDD core
## Why
Docs (`docs/fp-tea-atomic-design.md`, ARCHITECTURE §2c) teach `createStore` as THE
wiring, but the wizard pages hand-wire `signal(model)` + local `dispatch()` — juniors see
two idioms and copy the wrong one. Machine naming also drifts:
`change-request.machine.ts` exports bare `State`/`Msg`; `upload.machine.ts` exports
`reduceUpload`/`initialUpload`.
## Read first
- `src/app/shared/application/store.ts` (`createStore`) + its spec
- `docs/fp-tea-atomic-design.md` Part on TEA-in-Angular
- All `*.machine.ts` files (6) and their UI wiring sites (wizard components/pages)
## Decisions (pre-made, don't relitigate)
- **`createStore` is THE idiom** — migrate the hand-wired pages to it. One shape
everywhere is the teachability win; the migration is ~5 lines per page.
- Naming convention (goes into CLAUDE.md §3):
- State/Msg types are context-prefixed: `ChangeRequestState`, `ChangeRequestMsg` (rename
the bare ones; other machines already comply).
- A top-level machine exports `initial` + `reduce`.
- A **composable sub-machine** (embedded in a parent model) keeps prefixed value
exports: `initialUpload`/`reduceUpload` **stay** — prefixing avoids alias noise at
the composition site.
## Files
- `src/app/registratie/domain/change-request.machine.ts` (+ spec + consumers) — rename
- Wizard UI wiring: `registratie-wizard.component.ts`, `intake-wizard.component.ts`,
`herregistratie-wizard.component.ts` (whichever hand-wire `signal`+dispatch) →
`createStore`
- `CLAUDE.md` §3 — the naming + wiring convention
- New `src/docs/machines.mdx` — title `Foundations/State Machines (TEA)`
## Steps
1. Rename `State`/`Msg` in change-request (mechanical; lint/build finds consumers).
2. Swap hand-wired model signals for `createStore(initial, reduce)` in the wizard
components; templates keep dispatching messages — no template changes expected.
3. Write the convention into CLAUDE.md §3 (including the sub-machine rule).
4. MDX page: Model/Msg/reduce, commands-outside-reducers, `createStore` wiring, the
naming rules — linking `herregistratie.machine.ts` as the worked example.
## Acceptance criteria
- [ ] `grep -rn "export type State\b\|export type Msg\b" src/app` → empty.
- [ ] Every machine consumer wires via `createStore`; no local `signal(model)` +
hand-rolled dispatch remains.
- [ ] Convention documented in CLAUDE.md; MDX renders.
- [ ] All machine specs pass unchanged (reducers untouched).
## Verification
GREEN + `npm run test-storybook:ci`. Smoke: all three wizards step forward/back and
submit.
## Out of scope
Changing reducer logic or messages; store specs for root stores (they're covered where
logic exists).
## Risks
Rename ripple — purely mechanical; reducer specs are the net.

View File

@@ -0,0 +1,72 @@
# WP-09 — Pure-logic closure: dates + missing command specs
Status: todo
Phase: 1 — FP/DDD core
## Why
Three hand-rolled copies of the same `nl-NL {day: numeric, month: long, year: numeric}`
formatter exist with divergent signatures, while other components use the Angular `date`
pipe — two mechanisms plus drift risk. And two application commands with real logic have
no spec despite "domain and pure logic must have a spec" (CLAUDE.md §5).
## Read first
- `src/app/registratie/domain/tasks.ts` (~line 15, `formatNL(d: Date)`)
- `src/app/registratie/ui/aanvraag-block/aanvraag-block.component.ts` (~line 96,
`formatNL(iso?: string)`)
- `src/app/brief/ui/letter-preview/letter-preview.component.ts` (~line 96, inline copy)
- `src/app/registratie/application/draft-sync.ts` (debounce logic)
- `src/app/registratie/application/submit-change-request.ts`
- `src/app/app.config.ts` (LOCALE_ID `nl` is already set)
## Decisions (pre-made, don't relitigate)
- **Templates use `DatePipe`; pure TS uses one `formatDatumNl`** in
`src/app/shared/kernel/datum.ts` (Intl.DateTimeFormat, accepts ISO string | Date,
empty-safe). Rule goes into CLAUDE.md conventions.
- Kernel naming is Dutch (`datum.ts`) — it formats for the Dutch UI; if that clashes with
the "shared = English" rule during implementation, `format-date-nl.ts` is the
alternative — pick one and note it here.
## Files
- New `src/app/shared/kernel/datum.ts` + `datum.spec.ts`
- The three formatter copies (delete, redirect to pipe or kernel fn)
- New `src/app/registratie/application/draft-sync.spec.ts`
- New `src/app/registratie/application/submit-change-request.spec.ts`
- `CLAUDE.md` conventions (one line: DatePipe in templates, `formatDatumNl` in pure TS)
## Steps
1. Write `formatDatumNl` + spec (pin exact expected strings, e.g. `2 juli 2026`;
undefined/empty → `''`).
2. Replace the three copies; templates that can use the pipe use the pipe.
3. `draft-sync.spec.ts` with vitest fake timers: coalescing (n rapid changes → 1 sync),
trailing call, flush-on-submit if applicable.
4. `submit-change-request.spec.ts`: ok path, error path (stubbed client), Result shape.
5. Also sweep the tiny dead exports flagged in the audit: remove unused `map3` from
`shared/kernel/fp.ts` (verify unused first) and the never-set `variant` input on
`confirmation.component.ts`.
## Acceptance criteria
- [ ] Exactly one hand-written date formatter in the repo; `grep -rn "toLocaleDateString" src/app`
hits only `datum.ts`.
- [ ] Both command specs exist; debounce coalescing + error path covered.
- [ ] `map3` / unused `variant` input removed (or a note here why kept).
- [ ] CLAUDE.md rule added.
## Verification
GREEN + `npm run test-storybook:ci`.
## Out of scope
`Result` combinators (map/andThen/fold) — nice-to-have from the old roadmap, not in this
backlog.
## Risks
Node ICU vs browser locale output differences — the spec pins strings; if CI's Node lacks
full ICU (it shouldn't on Node 24), pin via `Intl.DateTimeFormat('nl-NL', …)` explicitly.

View File

@@ -0,0 +1,64 @@
# WP-10 — CIBG button fidelity
Status: todo
Phase: 2 — CIBG fidelity
## Why
The vendored CIBG build ships `.btn-primary / .btn-secondary / .btn-danger / .btn-ghost /
.btn-link / .btn-link--underline / .btn-inline` — and **no** `.btn-outline-primary`,
`.btn-outline-secondary`, or `.btn-sm` (verified absent from
`public/cibg-huisstijl/css/huisstijl.min.css`). Three components emit those dead classes
and render as unstyled Bootstrap defaults instead of CIBG buttons.
## Read first
- https://designsystem.cibg.nl/componenten/knoppen/ (or grep the vendored CSS)
- `src/app/shared/ui/button/button.component.ts` (~line 14)
- `src/app/shared/ui/upload/file-input/file-input.component.ts` (~line 38)
- `src/app/shared/ui/rich-text-editor/rich-text-editor.component.ts` (toolbar, ~lines 55-60)
## Decisions (pre-made, don't relitigate)
- `variant='secondary'``.btn-secondary` (CIBG's documented secondary).
- RTE toolbar buttons → `.btn-ghost`; drop `.btn-sm` — if the toolbar needs a smaller
size, that's token-bridge CSS carrying the CIBG-gap marker (format per WP-13; define it
inline if WP-13 hasn't run).
- Add `'ghost'` to the atom's variant union (CIBG documents it; the RTE needs it).
- Icon affordances (chevron/pijl): implement **only if** icon classes exist in the
vendored CSS — inspect first; otherwise record as a gap in the WP-13 register and skip.
## Files
- `src/app/shared/ui/button/button.component.ts` + `button.stories.ts`
- `src/app/shared/ui/upload/file-input/file-input.component.ts`
- `src/app/shared/ui/rich-text-editor/rich-text-editor.component.ts`
- Any story/snapshot referencing the old classes
## Steps
1. Fix the variant map in the button atom; add `ghost`.
2. file-input label-button → `btn btn-secondary`.
3. RTE toolbar → `btn btn-ghost` (+ marked size CSS if needed).
4. Grep-check `btn-outline`/`btn-sm` gone repo-wide.
5. Storybook visual review of Button (all variants), FileInput, RichTextEditor against
designsystem.cibg.nl/componenten/knoppen.
## Acceptance criteria
- [ ] `grep -rn "btn-outline\|btn-sm" src/app` → empty.
- [ ] Button story shows all CIBG variants incl. ghost; visuals match the design system.
- [ ] Axe still green (contrast can change with real button styles).
## Verification
GREEN + `npm run test-storybook:ci`. Manual Storybook review of the three components.
## Out of scope
Other invented classes (WP-11); datablock (WP-12).
## Risks
Visual regressions where outline-secondary "worked by accident" via Bootstrap base rules
— the story review step is the catch.

View File

@@ -0,0 +1,76 @@
# WP-11 — CIBG markup fidelity: application-link + absent-class triage
Status: todo
Phase: 2 — CIBG fidelity
## Why
`application-link.component.ts` invents `.application` / `.application-title` — absent
from the vendored CSS. The real "aanvragen" pattern styles
`.dashboard-block.applications li a` with `h3`/`.h3`, and `.subtitle/.status/.cta`
scoped **inside** `.applications`. The invented classes mean the rows only look right by
accident of parent scoping, and `.application-title` is dead. A few other components use
classes that don't exist in the vendored build.
## Read first
- https://designsystem.cibg.nl/componenten/aanvragen/
- `public/cibg-huisstijl/css/huisstijl.min.css` (grep `.applications`, `.breadcrumb`,
error-summary candidates)
- `src/app/shared/ui/application-link/application-link.component.ts` +
`application-list/application-list.component.ts`
- `src/app/shared/layout/page-shell/page-shell.component.ts` (`.back`),
`wizard-shell/wizard-shell.component.ts` (`.es-list`/`.es-title`),
`src/app/shared/ui/upload/delivery-channel-toggle/delivery-channel-toggle.component.ts`
(`.radio-option`), `shared/layout/breadcrumb/breadcrumb.component.ts`
## Decisions (pre-made, don't relitigate)
- application-list/link: rework markup to the documented `.dashboard-block.applications`
scoping chain; keep the component API (`heading/subtitle/cta/to`) unchanged.
- For each remaining absent class: **adopt a vendored pattern if one exists, else mark as
CIBG-gap extension** (marker format per WP-13; define inline if WP-13 hasn't run).
This fallback is explicit so the WP can't stall on a missing upstream pattern.
- Wizard error summary: check whether CIBG's Foutmelding/Veldvalidatie pattern classes
are vendored; adopt if so, else mark as gap (the current behavior/a11y stays —
`role="alert"`, focus management).
- Breadcrumb: `.breadcrumb` IS vendored — verify usage against CIBG Kruimelpad and
annotate only; no rework expected.
## Files
- `application-link.component.ts`, `application-list.component.ts` (+ stories)
- `page-shell.component.ts`, `wizard-shell.component.ts`,
`delivery-channel-toggle.component.ts`, `breadcrumb.component.ts` (annotations/markers
or adopted patterns)
- `src/app/registratie/ui/dashboard.page.ts` (visual consumer — no code change expected)
## Steps
1. Rework application-list/link markup; delete `.application`/`.application-title`.
2. Grep the vendored CSS for Foutmelding/Veldvalidatie-ish classes; adopt or mark.
3. Triage `.back` and `.radio-option`: adopt or mark.
4. Verify breadcrumb; add the "wraps vendored `.breadcrumb` (CIBG Kruimelpad)" comment.
5. Eyeball the dashboard — a visual diff on "Wat wilt u doen?" is intended.
## Acceptance criteria
- [ ] `grep -rn '"application"\|application-title' src/app` → empty (component selector
aside).
- [ ] Every invented class either replaced by a vendored pattern or carries the gap
marker.
- [ ] Dashboard + wizard stories reviewed; axe green.
## Verification
GREEN + `npm run test-storybook:ci`. Manual: `npm start` → dashboard ("Wat wilt u
doen?" list) + a wizard validation error (error summary).
## Out of scope
Datablock adoption (WP-12); the full gap register (WP-13).
## Risks
The intended dashboard visual diff needs human eyes — flag it in the session summary for
the user.

View File

@@ -0,0 +1,79 @@
# WP-12 — CIBG Datablock for application data
Status: todo
Phase: 2 — CIBG fidelity
## Why
CIBG documents **Datablock** (designsystem.cibg.nl/componenten/datablock/) as THE way to
display user/application data: a grey `dl/dt/dd` container, `.data-block` +
`.block-wrapper`, Bootstrap grid for label/value widths, a `--stacked` variant, and
optional expandable help text (`.dt-item`, `.dt-help-btn-open/-close`, `.dt-help-panel`).
All classes are **verified present** in `public/cibg-huisstijl/css/huisstijl.min.css`.
The app currently renders application data with bare `data-row`s inside a custom card —
adopting Datablock makes the data views design-system-native.
## Read first
- https://designsystem.cibg.nl/componenten/datablock/
- `public/cibg-huisstijl/css/huisstijl.min.css` (grep `.data-block`)
- `src/app/shared/ui/data-row/data-row.component.ts`
- `src/app/shared/ui/review-section/review-section.component.ts` (already uses
`.data-block`/`.block-wrapper` ad hoc)
- Consumers: `src/app/registratie/ui/dashboard.page.ts` ("Persoonsgegevens (BRP)" card),
`registration-summary/registration-summary.component.ts`,
`registration-detail.page.ts`
## Decisions (pre-made, don't relitigate)
- New **`data-block` molecule** in `shared/ui` wrapping the vendored classes: inputs for
optional heading and `stacked` variant; content = projected `data-row`s (or a typed
rows input — match how `review-section` projects today; prefer projection for
composability).
- `data-row` stays as the row primitive **inside** the datablock (document that role in
its header comment) — don't fork a second row component.
- `review-section` aligns onto the new molecule instead of hand-carrying the classes.
- Expandable help text (`.dt-item` + help button/panel) only if a real field needs it —
if built, follow CIBG's focus management (focus into panel on open, back to trigger on
close) and the documented `aria-*` state on the trigger.
## Files
- New `src/app/shared/ui/data-block/data-block.component.ts` + `data-block.stories.ts`
- `src/app/shared/ui/review-section/review-section.component.ts`
- `src/app/registratie/ui/dashboard.page.ts` (Persoonsgegevens card → datablock)
- `registration-summary.component.ts`, `registration-detail.page.ts`
- `src/app/shared/ui/data-row/data-row.component.ts` (header comment; possibly minor
class alignment)
## Steps
1. Build the molecule (standard + stacked variants), stories for both (axe-gated).
2. Migrate the three data views; keep copy/i18n ids untouched.
3. Fold review-section onto the molecule; delete its duplicated class carrying.
4. Optional `aria-label` on the `dl` where the block has no visible heading.
5. Visual review dashboard + registration pages.
## Acceptance criteria
- [ ] Application data (BRP card, registration summary/detail, wizard review) renders in
CIBG datablocks.
- [ ] `data-row` documented as the datablock row primitive; no duplicate dl styling
anywhere.
- [ ] Stories for standard + stacked; axe green.
## Verification
GREEN + `npm run test-storybook:ci`. Manual: `npm start` → dashboard + /registratie
detail; wizard review step still renders.
## Out of scope
The help-panel variant unless a real field needs it; table views (registration-table
stays a CIBG Tabel).
## Risks
The grey datablock surface may double up visually inside the custom `.app-card` — decide
per view whether the card wrapper drops (datablock replaces it) and note the choice in
the component comment.

View File

@@ -0,0 +1,79 @@
# WP-13 — CIBG-gap register + hygiene + MDX
Status: todo
Phase: 2 — CIBG fidelity
## Why
User decision: hand-rolled token-bridge components are allowed **only if explicitly
marked** as CIBG-gap extensions in code + Storybook, so every deviation from the design
system is auditable. Plus two hygiene items from the audit: a dead wrapper to delete and
the list-family rationale to document.
## Read first
- `docs/architecture/0003-*.md` (ADR-0003 — the token-bridge rationale this extends)
- The audit's gap list (below)
## Decisions (pre-made, don't relitigate)
- Marker format (header comment in the component):
`// CIBG-GAP EXTENSION: <closest CIBG concept> — <why hand-rolled>` plus
`parameters: { cibgGap: true }` and a "CIBG-gap extension" line in the story
description.
- **Keep all three list families** — each maps a distinct CIBG pattern: `task-list`
Actieblok, `application-list` → Aanvragen, `choice-list` → Keuzelijst. Header comment
in each names its pattern. Consolidation would blur exactly the design-system mapping
this showcase demonstrates.
- **Delete `upload-status-banner`** (`shared/ui/upload/upload-status-banner/`): a
23-line near-identity wrapper over `alert` with one consumer — inline `<app-alert>`
there.
## Files
Components to mark (closest CIBG concept in parens):
- `skeleton`, `spinner` (Laadindicatie — no vendored class, verified)
- `upload/` suite (Bestand-upload)
- `rich-text-editor` (Tekstgebied)
- `wizard-shell` (Wizard; error summary → Foutmelding, if WP-11 marked it)
- `confirmation` (Bevestiging — verify: it wraps vendored classes; if so it needs no
marker, only the pattern comment)
- `debug-state` (devtool, no CIBG concept)
- `status-badge` (deliberate custom, documented in code), `card` (`.app-card`),
`placeholder-chip`
Plus:
- Delete `upload-status-banner` + its story; inline alert at its consumer
- Header comments on `task-list`/`application-list`/`choice-list`
- New `src/docs/cibg-gaps.mdx` — title `Foundations/CIBG Gap Register`
- ADR-0003 gets a cross-link to the register
## Steps
1. Add the marker to each gap component + `cibgGap` story parameter.
2. Delete the banner; migrate its one consumer; run its former story's states through
the consumer's story if coverage would drop.
3. Write the MDX register: a table (component → closest CIBG concept → why) + the rule
"grep the vendored CSS before hand-rolling anything".
4. Optional but cheap: a `check:cibg-gaps` script that greps markers and diffs against
the MDX table, so the register can't silently rot — add to `check:tokens`' CI step if
trivial, else note as skipped.
## Acceptance criteria
- [ ] Every component with hand-rolled surface CSS either wraps vendored classes or
carries the marker (spot-check with a grep for `styles: [` vs markers).
- [ ] Register MDX complete, linked from ADR-0003.
- [ ] `upload-status-banner` gone; consumer green; no story coverage lost.
- [ ] List trio documented.
## Verification
GREEN + `npm run test-storybook:ci`.
## Out of scope
Rebuilding any gap component against the design system (they're sanctioned by decision).
## Risks
Low — mostly annotation; the deletion is the only behavior-touching change.

View File

@@ -0,0 +1,79 @@
# WP-14 — Storybook taxonomy reorg + Layers MDX
Status: todo
Phase: 3 — Storybook as curriculum
## Why
49 stories sit in a flat `Atoms/Molecules/Organisms/Templates/Layout` scheme with domain
outliers (`Registratie/…` wizards, one stray `Shared UI/Status Badge`). The sidebar
should tell the repo's actual story: a reusable design system on one side, domain
contexts on the other — the DDD seam made visible. The sidebar becomes the curriculum's
table of contents.
## Read first
- `.storybook/preview.ts`, `.storybook/main.ts`
- `src/docs/*.mdx` (three existing Foundations pages)
- `CLAUDE.md` naming rule (shared = English, domain = Dutch)
## Decisions (pre-made, don't relitigate)
Sidebar scheme (respects the English/Dutch seam):
```
Foundations/ ← MDX curriculum (all concept pages)
Design System/
Atoms/… Molecules/… Organisms/… Templates/… ← everything in shared/ui + shared/layout
Domein/
Registratie/… Herregistratie/… Brief/… Auth/… ← everything in a context's ui/
```
- Rule: **a component in a context folder gets a `Domein/<Context>/` title, full stop**
(context organisms like `aanvraag-block` move from `Organisms/` to
`Domein/Registratie/`). Component display names stay as authored (the folder tells the
context).
- `Devtools/` (debug-state) files under `Design System/Devtools/`.
- Order pinned via `parameters.options.storySort`:
`['Foundations', 'Design System', ['Atoms','Molecules','Organisms','Templates','Devtools'], 'Domein']`.
## Files
- All `*.stories.ts` `title:` fields (~49, mechanical)
- `.storybook/preview.ts` (`storySort`)
- Existing `src/docs/*.mdx` (fix any story references broken by retitling)
- New `src/docs/layers.mdx` — title `Foundations/Layers & Contexts`
- `CLAUDE.md` — the story-title convention (one paragraph in §2 or Conventions)
## Steps
1. Retitle all stories per the scheme (fix `Shared UI/Status Badge`).
2. Add `storySort`; build Storybook and check the sidebar.
3. `layers.mdx`: 6 contexts, 5 layers, the inward dependency rule, "this is enforced" (link
the eslint rules from WP-03/04), the English/Dutch seam — i.e. the sidebar's own
rationale, with links into real stories on both sides.
4. Check every MDX `<Canvas of={…}>`/story import still resolves (build-storybook
catches broken refs — do an explicit pass anyway).
5. Convention into CLAUDE.md.
## Acceptance criteria
- [ ] Sidebar shows exactly Foundations → Design System → Domein with the sub-order
pinned.
- [ ] Zero story titles outside the scheme (grep `title:` and eyeball).
- [ ] `layers.mdx` renders; existing MDX pages unbroken.
- [ ] Convention in CLAUDE.md.
## Verification
GREEN + `npm run test-storybook:ci` (the axe run also re-visits every retitled story).
## Out of scope
New component stories (WP-15); the remaining curriculum pages (they ship with their
invariant-WPs: 05, 07, 08, 13, 17).
## Risks
MDX story references breaking on retitle — `build-storybook` is the catch; fix-forward
in the same session.

View File

@@ -0,0 +1,64 @@
# WP-15 — Missing stories: shell + brief components
Status: todo
Phase: 3 — Storybook as curriculum
Depends on: WP-14 (titles), WP-01 (axe gate covers the new stories automatically)
## Why
"UI is exercised via Storybook stories" (CLAUDE.md §5) — but 7 components have none:
`shared/layout/shell` and six of seven brief components. Unstoried components are also
invisible to the axe gate.
## Read first
- `src/app/brief/ui/letter-composer/letter-composer.stories.ts` (the one brief story —
fixture + decorator pattern to reuse)
- The seven components below
## Decisions (pre-made, don't relitigate)
- Titles per WP-14 scheme: `Design System/Templates/Shell`, `Domein/Brief/<Name>`.
- If fixture duplication across brief stories bites, extract a shared
`src/app/brief/ui/brief.fixtures.ts` — otherwise keep fixtures co-located.
## Files (new)
- `src/app/shared/layout/shell/shell.stories.ts`
- `src/app/brief/ui/rejection-comments/rejection-comments.stories.ts`
- `src/app/brief/ui/diagnostics-panel/diagnostics-panel.stories.ts`
- `src/app/brief/ui/letter-block/letter-block.stories.ts`
- `src/app/brief/ui/letter-preview/letter-preview.stories.ts`
- `src/app/brief/ui/letter-section/letter-section.stories.ts`
- `src/app/brief/ui/passage-picker/passage-picker.stories.ts`
## Steps
1. One story file per component: default state + at least one meaningful variant
(e.g. letter-block locked/editable, diagnostics with findings/empty, rejection-comments
filled, preview with sample data toggle).
2. Provide router/HTTP/store context via `applicationConfig`/`moduleMetadata` decorators
like the wizard stories do.
3. Run the axe gate; fix trivial violations in the components (label/role level); anything
structural → escape hatch + cross-ref WP-16.
## Acceptance criteria
- [ ] Every component in `src/app` has ≥1 story (verify: list components without a
co-located `*.stories.ts`; expect zero, pages excepted if that's the existing
norm — note the norm in this file when checked).
- [ ] All new stories pass the axe gate (or carry a justified skip).
- [ ] Titles follow WP-14.
## Verification
GREEN + `npm run test-storybook:ci`.
## Out of scope
Play-test assertions (WP-16 adds those where they enforce a contract).
## Risks
Brief components may lean on `BriefStore` state — stub via the store's public API in a
decorator rather than reaching into internals.

View File

@@ -0,0 +1,76 @@
# WP-16 — Component a11y: description wiring + alert role
Status: todo
Phase: 4 — a11y
## Why
Audit findings axe can't (fully) catch:
- `form-field` renders a description `<div [id]="fieldId()+'-desc'">` that **no control
ever references** — `text-input` sets `aria-describedby` only to `…-error` and only
when invalid. Screen readers never announce field descriptions (e.g. the BSN hint on
the login form).
- The field↔control id pairing is manual (`fieldId` must equal the input's `name`/`id`)
with nothing enforcing it.
- The `alert` atom is always `role="status"` — error alerts are announced politely while
other errors in the app use `role="alert"`; urgency is inconsistent.
## Read first
- `src/app/shared/ui/form-field/form-field.component.ts` (~line 15)
- `src/app/shared/ui/text-input/text-input.component.ts` (~lines 17-18)
- `src/app/shared/ui/radio-group/radio-group.component.ts`, `checkbox/checkbox.component.ts`
- `src/app/shared/ui/alert/alert.component.ts`
- `src/app/auth/ui/login-form/login-form.component.ts` (a live desc that's never wired)
## Decisions (pre-made, don't relitigate)
- `aria-describedby` = space-joined ids: `-desc` **always when a description exists** +
`-error` **when invalid**; order pinned (desc first, error second).
- Pairing contract: form-field exposes its `fieldId`; the composition contract
(`fieldId === control id/name`) is documented in both components and **enforced by a
story play test** on the canonical form-field+text-input composition
(`expect(input).toHaveAttribute('aria-describedby', 'x-desc')`, flip validity, assert
`'x-desc x-error'`). Play tests run in the WP-01 test-runner for free.
- DI-based auto-wiring (form-field providing the id via injection) is **out of scope**
more clever than this POC needs; the play test catches drift. Revisit only if the
manual contract actually breaks in practice.
- Alert: `type === 'error'``role="alert"`; others keep `role="status"`. Rationale
comment in the atom.
## Files
- `form-field.component.ts`, `text-input.component.ts`, `radio-group.component.ts`,
`checkbox.component.ts` (describedby joins; only where the component takes a hint)
- `alert.component.ts` (+ story asserting the role per variant)
- `form-field.stories.ts` (or a composition story) with the play test
- Call sites that pass descriptions (verify login-form BSN hint is now announced)
## Steps
1. Implement the describedby join in the input atoms; form-field renders `-desc` only
when a description input is set (it already does — verify).
2. Write the play tests (form-field composition + alert roles).
3. Alert role switch + rationale comment.
4. Manual screen-reader spot check (optional but recommended — note result here).
## Acceptance criteria
- [ ] Description text is programmatically associated in the canonical composition;
login-form BSN hint announced.
- [ ] `-error` id appended exactly when invalid; order stable.
- [ ] Error alerts are `role="alert"`; play tests assert both behaviors and run in the
CI gate.
## Verification
GREEN + `npm run test-storybook:ci` (includes the new play tests).
## Out of scope
Route-change focus and template lint (WP-17); rewriting form-field's layout.
## Risks
`aria-describedby` churn on validity flips re-announcing content — pinned order + play
test coverage keeps it deterministic.

View File

@@ -0,0 +1,82 @@
# WP-17 — App-level a11y: route focus/scroll, template lint, WCAG checklist + MDX
Status: todo
Phase: 4 — a11y
## Why
Three app-level gaps close the WCAG story:
- **No route-change focus/scroll management** — `app.config.ts` has only
`provideRouter(routes, withViewTransitions())`; after navigation, focus stays wherever
it was and scroll position is unmanaged. (The wizard manages focus *within* steps; the
skip link is the only cross-page mechanism.)
- **No template a11y linting** — `@angular-eslint` is entirely absent.
- User decision: a **manual WCAG checklist** documents what automation can't test.
## Read first
- `src/app/app.config.ts`
- `eslint.config.mjs`, `package.json`
- `src/app/shared/layout/shell/shell.component.ts` (`#main` target),
`page-shell/page-shell.component.ts` (candidate focus target)
## Decisions (pre-made, don't relitigate)
- Scroll: `withInMemoryScrolling({ scrollPositionRestoration: 'enabled' })`.
- Focus: on `NavigationEnd`, move focus to the new page's `h1` (or `#main` fallback,
`tabindex="-1"`), **deferred via `afterNextRender`** so it coexists with view
transitions; skip the initial load. Implement as a small root service/effect wired in
`app.config.ts` — not per page.
- Lint: `angular-eslint` at the version matching Angular 22 (~v21+ — check
`npm view angular-eslint peerDependencies`), with `processInlineTemplates` (this repo
has **no** `.html` templates). Rules: `alt-text`, `label-has-associated-control`,
`interactive-supports-focus`, `click-events-have-key-events`, `valid-aria`,
`elements-content`. **Verify the processor fires** by planting a known-bad pattern
before trusting green.
- The checklist is a living doc with per-page status columns — not prose.
## Files
- `src/app/app.config.ts` + a new small `shared/` focus-on-navigation service
- `eslint.config.mjs`, `package.json` (angular-eslint)
- Any template the new lint rules flag
- New `docs/wcag-checklist.md`
- New `src/docs/a11y.mdx` — title `Foundations/Accessibility`
## Steps
1. Router scroll + focus service; manual keyboard smoke (tab through, navigate, confirm
focus lands on the new page's heading).
2. Add angular-eslint; enable the template rules on inline templates; plant a bad
pattern, see it fail, remove it; fix real hits.
3. `docs/wcag-checklist.md`: manual checks per page (dashboard, wizards, brief, login) —
keyboard walk & focus order, no traps, 200% zoom/reflow, NVDA or VoiceOver pass,
visible focus, error announcement; status columns (page × check).
4. `src/docs/a11y.mdx`: the layered approach — axe gate (WP-01) + template lint + play
tests (WP-16) + manual checklist; the alert-role rationale; where the
`a11y.disable` skip register lives and its rules.
## Acceptance criteria
- [ ] Navigating between routes moves focus to the new page's heading; scroll resets;
view transitions still play.
- [ ] Template a11y rules active and *proven* to fire; lint green.
- [ ] Checklist checked in with an initial pass filled in for the dashboard at minimum.
- [ ] `a11y.mdx` renders; links to checklist and WP-01 skip rules.
## Verification
GREEN + `npm run test-storybook:ci`. Manual: keyboard-only walk of
login → dashboard → wizard → brief.
## Out of scope
Full manual audit of every page (the checklist enables it; filling it beyond the
dashboard is ongoing work, not this WP).
## Risks
- angular-eslint's inline-template processor silently not running → the planted-violation
step is mandatory.
- Focus-move vs `withViewTransitions` timing → `afterNextRender` + test in a real browser
(`npm start`), not just unit-level.