# Make the rest of the codebase as readable as the dashboard > This is the **living** design record for the arc, committed so that the repository holds > the complete state and a fresh session needs nothing outside it. Corrections found while > executing are folded in and marked. See [`README.md`](README.md) for the ticket ledger and > the session protocol. ## Context The dashboard refactor (branch `refactor/readable-dashboard`, 3 commits, pushed) cut `dashboard.page.ts` from 340 lines to 42 by splitting it into six per-concern section components. It was built as a **reference implementation**: prove the pattern on one screen, then hold the rest of the app to the budget it establishes. This plan applies that result to the rest of the codebase. It is grounded in fresh measurement, not in the original plan's estimates — several of which turned out to be stale (see "Corrections" at the end). Two findings reframe the work: 1. **Pages are already thin.** 17 pages, median 88 lines; only the sanctioned showcase teaching page exceeds 250. The "page → sections" split is essentially done. The remaining bulk sits one layer down, in **organisms**. 2. **The worst problem is not size, it is a silent-failure idiom.** `runIfSubmitting` is copy-pasted into 5 components under 2 names and must be called by hand right after `dispatch`. Forgetting it fails silently. That is a correctness risk, not a cosmetic one. **Sequencing matters, and the phase numbers are not quite the running order:** 1. **Phase 3a first** — the `max-lines` rule plus `reportUnusedDisableDirectives: 'error'`. It is one small commit, it lands green today, and it stops every later phase from adding a new oversized file. Cheap insurance bought before the spending starts. 2. **Then Phase 0** (put the dashboard in its right context) — small, self-contained, and it settles where the section files live before anything else edits them. 3. **Then Phase 1** (idioms) — it _deletes_ code that the Phase 3 splits would otherwise have to carry: the copy-pasted submit plumbing, the `WizardStatus` switch, several `computed`s. Split first and you split code you are about to delete. 4. **Then Phase 3b–3i** (the splits), **Phase 2** (mechanical sweeps, independent — fit anywhere), **Phase 4** (layer move), **Phase 5** (docs) last, so it documents the end state once rather than tracking each step. --- ## Execution: how this survives a restart and runs on Sonnet agents This document is a **design record**. It is not executable as-is: a fresh Sonnet agent has none of the conversation that produced it, and nothing here records progress. So the first ticket converts it into the artifact this repo already uses for exactly this. ### Reuse the existing protocol, do not invent one `docs/project/backlog/README.md` is a proven mechanism — 75 work packages driven to `done` through it, and its own notes say the tickets are _"self-contained (each WP file carries its own current-state handoff) and sized for a fresh Sonnet session."_ Copy it wholesale: - **One ticket file per commit**, using that README's existing template (Status / Why / Read first / Decisions (pre-made, don't relitigate) / Files / Steps / Acceptance criteria / Verification / Out of scope / Risks). - **`Status: todo | in-progress | done`** inside each ticket file. - **A README with an Order table** carrying every ticket, its dependencies and its status. - **A runnable GREEN one-liner** as the global definition of done. New home: **`docs/project/readable-codebase/`**, prefix **`RD-NN`**. It must not extend `docs/project/backlog/`, because Phase 5 archives that directory — a finished arc gets archived, a new arc gets its own folder. `RD-` also avoids collision with the existing `WP-`/`RB-` prefixes, which matters because Phase 2 greps for those. ### The one property that makes a restart safe **Each ticket updates its own `Status:` line and the README row in the _same commit_ as its code.** Never in a follow-up commit. That makes `git log` and the ledger impossible to desync: whatever is committed is done, whatever is not is not. Recovery for a fresh session with zero context is three commands: ```bash git log --oneline -8 grep -rn '^Status:' docs/project/readable-codebase/RD-*.md | grep -v done # next work npm run ci # is HEAD green? ``` ### Making each ticket Sonnet-executable An agent reads _its own ticket_, not this whole document. So each ticket must be self-contained. Ticket files are written **just in time by the supervisor**, immediately before delegating, and land in that ticket's own commit — not all 35 up front, which would be speculative. Three rules when writing one: 1. **Copy the decision, never a pointer to it.** The `Decisions (pre-made, don't relitigate)` block carries the verdict from this plan verbatim. No agent re-derives "effect map vs full Elm" — that is settled here, with reasons, and re-opening it wastes an Opus-shaped judgment on a Sonnet-shaped task. 2. **Inline the traps that apply to _that_ ticket.** The Risks section below is global; an agent will not read it. The `Seed` exemption belongs in RD-05's Decisions block, the longest-key-first sed order in RD-27's, the parameterised-`$localize` rule in RD-25's and RD-26's. A trap left only in a global list is a trap that fires. 3. **State acceptance as a command, not a sentence.** `npm run lint` exits non-zero, or the file is under 250 rule-lines, or `npx eslint --report-unused-disable-directives` is clean. "Lands ~230 lines" is a design estimate and is not checkable; do not put it in Acceptance. ### GREEN for this arc ```bash npm run ci ``` Plus, for any ticket touching a story, an `.mdx`, or `libs/shared/src/ui/**`: ```bash npm run ci --full # the only thing that builds Storybook and catches a broken MDX import ``` Phase 4's move commit **must** run `--full`. So must anything in Phase 3 that moves a template. ### The agent loop One supervisor session drives it; one `developer` agent (Sonnet) executes each ticket. Per iteration: 1. Read the README Order table. Pick the first `todo` whose dependencies are all `done`. 2. Spawn **one** `developer` agent with a fixed prompt: _"Read CLAUDE.md, then `docs/project/readable-codebase/README.md`, then `RD-NN.md` and its Read-first list. Execute it. End GREEN. Update the ticket's Status and the README row in the same commit. Do not start another ticket."_ 3. Verify with a `task-runner` agent (Haiku): `npm run ci`, `git log -1 --stat`, and that the `Status:` line now reads `done`. 4. Green → next iteration. Red → stop and surface. Never mark a ticket done on an agent's word alone; the check is the exit code. For unattended running, `/loop` with that iteration as its prompt works — the ledger is the state, so a loop that dies mid-arc resumes from the ledger with nothing lost. ### Sequential by default — and why parallel is worse here Every ticket must end `npm run ci` green **on the branch**, and three properties make concurrent writes to one branch actively hostile: - `libs/shared/docs/behaviour-spec.mdx` and `showcase/snippets.generated.ts` are regenerated and **drift-checked** by CI. Two agents both regenerating conflict by construction. - Every ticket writes the same README Order row table — contention on literally every iteration. - The file sets overlap heavily: the three wizards appear in RD-07, RD-08, RD-20, RD-22 and RD-23. **Where parallelism does pay:** genuinely disjoint tickets, in separate git worktrees (`isolation: "worktree"` on the Agent tool), merged deliberately. Good candidates: the ticket sweep (RD-18/RD-19 — 186 files, semantically touching nothing), and the Phase 5 doc tickets. Cap it at two at a time. Evidence for caution: `.claude/worktrees/` currently holds **22 abandoned agent checkouts at 4.7 GB** (RD-09 deletes them). Parallel worktree agents have been used in this repo before and left the debris behind. Use them deliberately, and clean up. ### The ticket table (RD-01 materialises this verbatim as the README Order table) Each row becomes one `RD-NN-.md` and one commit. "Deps" must all be `done` before a ticket is picked. The phase sections below this table are the source for each ticket's `Decisions` block. | ID | Ticket | Deps | Source | `--full`? | | ----- | ------------------------------------------------------------------------------------ | ---------- | --------- | --------- | | RD-01 | Scaffold `docs/project/readable-codebase/` — README + all RD files | — | Execution | | | RD-02 | `max-lines` rule + `reportUnusedDisableDirectives: 'error'` + 7 disables | 01 | 3a | | | RD-03 | `overzicht` context: page + 2 nav sections, dep-cruiser edge, `HEADER_ADMIN_LINKS` | 02 | 0 | yes | | RD-04 | Story titles → `Domein//`; add stories only where >1 state | 03 | 0 | yes | | RD-05 | `createStore` gains the effect map + 5 specs | 02 | 1a | | | RD-06 | **Bug fix:** 2 single-step forms → effect map + retry affordance | 05 | 1a | yes | | RD-07 | Add `Primary` to the 3 wizard machines + specs | 05 | 1a | | | RD-08 | Migrate the 3 wizards to the effect map + `Primary` | 07 | 1a | yes | | RD-09 | **Docs + generator:** `plop-templates/form-machine.hbs`, ARCHITECTURE, fp-tea, skill | 08 | 1c | | | RD-10 | `WizardStatus` → `WizardPhase` (payload-carrying) | 08 | 1b#4 | yes | | RD-11 | Fold the projection into `remote-data.ts`; PascalCase the 3 machines | 01 | 1b#3 | | | RD-12 | `ActionState` → `action` on `BriefState.Loaded` | 11 | 1b#2a | | | RD-13 | Same for org-template, folding `pendingPublish` in | 12 | 1b#2a,#6 | | | RD-14 | Move `SaveState` to `debounced-save.ts`; delete `action-state.ts` | 13 | 1b#2b | | | RD-15 | Delete `.claude/worktrees/` (22 checkouts, 4.7 GB) | 01 | 2.1 | | | RD-16 | ~~`parseDashboardView` returns `BigProfile`~~ DROPPED — would discard decisions | 01 | 2.2 | | | RD-17 | `successOf`/`successOr` sweep — 10 sites, 8 files | 01 | 2.3 | | | RD-18 | Ticket sweep, frontend — 181 refs / 100 files | 01 | 2.4 | | | RD-19 | Ticket sweep, backend — 370 refs / 86 files | 01 | 2.4 | | | RD-20 | `wizard-errors.ts` + spec, adopted by all 3 wizards | 02 | 3c | | | RD-21 | `rich-text-dom.ts` helpers + spec cases | 02 | 3h | yes | | RD-22 | `intake-wizard` → 3 step components | 08, 20 | 3c | yes | | RD-23 | `registratie-wizard` → 3 steps + upload-controller move | 08, 20 | 3c | yes | | RD-24 | `concepts.page` → 6 sections + `concept-card` + 2 globals + `--app-code-*` | 02 | 3g | yes | | RD-25 | `org-template-editor` → `sample-letter.ts` + labels + 2 children | 02 | 3e, 3f | yes | | RD-26 | `letter-canvas` → labels + `letter-line`; keep its disable | 02 | 3d, 3e | yes | | RD-27 | **The layer move:** 33 `git mv` + 28 specifiers + 8 MDX imports | 21 | 4a | yes | | RD-28 | Layer-tag fixes (`async` missing, `breadcrumb` Chrome) + beheer doc rule | 27 | 4a, 4b | | | RD-29 | The 3 ladder rules in `.dependency-cruiser.base.js` | 27 | 4c | | | RD-30 | Archive `backlog/` + `refactor-backlog-setup/` (16,300 lines) + archive README | 01 | 5.1 | | | RD-31 | `ARCHITECTURE.md` §6a — symbols not lines, 2 dead paths, new section names | 03, 08, 16 | 5.2 | | | RD-32 | `fp-tea-atomic-design.md` — 11 broken paths + the broken anchor | 27 | 5.3 | | | RD-33 | CLAUDE.md + `atomic-design.mdx` + `ui-component` skill | 03, 27, 29 | 5.4-5 | yes | | RD-34 | _(optional)_ `NO_SUBORGS`/`NO_TABLES` → `RemoteData.Empty` | 11 | 1b | | | RD-35 | _(optional, last, alone)_ upload `type:` → `tag:` | 27 | 1b#5 | | Recommended running order is the ID order; it already respects every dependency. RD-15 through RD-19 are independent of everything and can be pulled forward whenever a short session needs filling — RD-15 in particular makes every later search faster and should go early. Two ordering traps the table encodes but an agent should be told outright: - **RD-01 must precede RD-30**, because RD-01 copies its ticket template _out of_ the very directory RD-30 archives. - **Four tickets edit the same two doc files in different sections** — RD-09 rewrites the submit-idiom teaching (`ARCHITECTURE.md` §2d area, `fp-tea` §338-350), while RD-31 rewrites `ARCHITECTURE.md` §6a and RD-32 fixes `fp-tea`'s paths. Sequential is fine; never run these two pairs in parallel worktrees. --- ## Phase 0 — Put the dashboard in the right context The dashboard is the portal home, but it lives inside `registratie`, a context that `.dependency-cruiser.ssp.js` declares as `registratie: []` — permitted to import no other context. Three concrete symptoms: - Its six sections span four concerns: registratie data (3), aanvragen (1), cross-context action links to `/herregistratie` `/intake` `/brief` `/concepts` (1), admin links to `/beheer/*` (1). - The cross-context coupling is **invisible to the linter**, because `wat-wilt-u-doen.section.ts` links by route _string_. `dep:check` passes and gives false assurance on exactly this file. - `beheer-links.section.ts:6` imports `ADMIN_LINKS` from `../../../shell/nav.config` — a context reaching into the app frame. `app.config.ts:72` **already** provides that same constant to the shared site header through the `HEADER_ADMIN_LINKS` token. ### Steps 1. Scaffold a context with `npm run gen:context` (`plop context`) named **`overzicht`** (Dutch, per CLAUDE.md: domain contexts are Dutch; the page's own title is "Mijn overzicht"). 2. Move the **page** and the two **portal-level navigation** sections into it: - `overzicht/ui/overzicht.page.ts` (was `registratie/ui/dashboard.page.ts`) - `overzicht/ui/wat-wilt-u-doen.section.ts` - `overzicht/ui/beheer-links.section.ts` 3. **Leave the four data sections in `registratie/ui/dashboard/`** — `mijn-aanvragen`, `wat-moet-ik-regelen`, `mijn-registratie`, `specialismen` render registratie data and belong beside their store. This separation is only possible _because_ of the split; it is the refactor's first real payoff. 4. Declare the edge in `.dependency-cruiser.ssp.js`: `overzicht: ['registratie']` — the second sanctioned cross-feature edge, mirroring `herregistratie: ['registratie']`. 5. `beheer-links.section.ts` **injects `HEADER_ADMIN_LINKS`** instead of importing `shell/nav.config`, removing the context→shell reach-in. 6. Consider giving `wat-wilt-u-doen`'s action list the same treatment — a token beside `NAV_ITEMS`/`ADMIN_LINKS` in `shell/nav.config`. Route strings on a landing page are legitimate, but the list is app-frame copy, not registratie's. 7. Update `app.routes.ts:19` to `loadComponent: () => import('@overzicht/ui/overzicht.page')`, and add the `@overzicht/*` alias to `apps/ssp/tsconfig.json`. **Keep the `/dashboard` route** — it is user-visible and in the e2e specs. Renaming it to `/overzicht` is a separate, optional change needing a redirect. Also settle the two deviations the refactor left behind: - **Story titles — fix.** They are `Domein/Registratie/Dashboard/`; CLAUDE.md specifies `Domein//`, "full stop", and all 41 other story files comply. Retitle to `Domein/Registratie/` for the four that stay and `Domein/Overzicht/` for those that move. Safe: only `layers.mdx` deep-links a story id, and not one of these three. - **8 imports — accept, do not fix.** CLAUDE.md has no import-count rule; ≤6 was a proxy metric, and one `import` per rendered section is exactly right. The only way down is a `DASHBOARD_SECTIONS` const spread into `imports:`, which trades a self-documenting array for an indirection and adds a barrel-shaped thing to a repo that deliberately has none. - Three of six sections have no story (`beheer-links`, `wat-moet-ik-regelen`, `wat-wilt-u-doen`). Add one only where the section has more than one visual state. --- ## Phase 1 — One submit idiom, and two state encodings instead of six > **This phase fixes two live bugs.** It is not only hygiene. ### 1a. Submit `runIfSubmitting` is a `private async` method duplicated across 5 components — named `runIfIndienen` in the registratie wizard — invoked at 8 call sites, always as: ```ts this.dispatch({ tag: 'Submit' }); // the reducer decides this.runIfSubmitting(); // then re-read state() and re-check the tag it hoped for ``` - `behandeling/ui/besluit-form/besluit-form.component.ts:129` - `registratie/ui/change-request-form/change-request-form.component.ts:175` - `herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts:275` - `herregistratie/ui/intake-wizard/intake-wizard.component.ts:386` - `registratie/ui/registratie-wizard/registratie-wizard.component.ts:634` #### The two bugs (fix these regardless of the rest) `besluit-form.component.ts` and `change-request-form.component.ts` never dispatch `Retry`, although `besluit.machine.ts:64` and `change-request.machine.ts:50` both export it. On a failed submit, `Failed` falls into the `@else` branch that renders the form, but `editing()` is `null` there, so `besluit()`/`toelichting()` return `''` — **the user sees an emptied form**. The still-enabled submit button dispatches `Submit`, which the reducer no-ops from `Failed`. Unrecoverable dead end, reachable from any failed submit, in both components. Other defects to close in the same pass: - Step-boundary logic lives in the component (`s.step < 3 ? Next : Submit`), beside the reducer's own exported `next`/`submit`. - The two herregistratie wizards interleave optimistic store calls the reducer knows nothing about (`herregistratie-wizard.component.ts:277,282,285`; `intake-wizard.component.ts:388,396,399`). `registratie-wizard` has no optimistic flag. #### Design: `createStore` gains an effect map Constraint: **`reduce` stays pure** (CLAUDE.md). The fix lands in the `dispatch` wrapper in `libs/shared/src/application/store.ts` — inside the one sanctioned wiring idiom, not beside it. ```ts export type StoreEffects = Model extends { tag: string } ? { [K in Model['tag']]?: ( state: Extract, store: Store, ) => unknown; } : never; export function createStore( init: Model, update: (model: Model, msg: Msg) => Model, effects?: StoreEffects, ): Store; ``` Four load-bearing choices: - **A conditional type, not a `Model extends {tag}` constraint** — the constraint would break `store.spec.ts:8`'s `createStore(0, (n, m) => n + m)`. With the conditional, `Model = number` resolves to `never`, so effects are a compile error there and omitting them stays legal. - **Keys are `Model['tag']`** — a renamed or typo'd state tag becomes a compile error. - **The narrowed state is argument one** — this is what deletes the `const s = this.state(); if (s.tag !== 'Submitting') return;` preamble at all 8 sites. The body cannot run in the wrong state, so it cannot re-guess. - **The store is argument two** — the effect needs `dispatch`, but `createStore(...)` runs in a field initializer before `this.store` exists. **The trigger rule is where the design lives.** Run `effects[next.tag]` when **both**: 1. `prev.tag !== next.tag` — the store _entered_ the tag. A `Submit` that fails validation is `Editing → Editing`: no fire. A second `Submit` while `Submitting` is a reducer no-op: no fire, so **double-submit protection falls out of the rule**. `Retry` is `Failed → Submitting`: fires, so `onRetry` needs no special case. 2. **the msg tag is not `Seed`.** This single line is what stops the five `Submitting` Storybook stories from firing real HTTP (see Risks), and stops `draftSync.onResume` → `Seed` from re-submitting a resumed draft. Implementation note for whoever writes it: capture `prev`/`next` inside the `model.update(...)` callback and invoke the effect **after** `update` returns. Do not read `model()` inside `dispatch` — the comment at `store.ts:24-28` explains the livelock, and `store.spec.ts:16-30` exists because that bug already happened once. **Effect bodies do not move.** They stay as private component methods, registered in the map; the `begin*`/`confirm*`/`rollback*` calls stay inside them. The effect slot _is_ the sanctioned place for effects, so "side effects stay out of the reducer" holds unchanged. Per-site diff: delete two guard lines, take the narrowed state, register one map entry. ```ts private store = createStore(initial, reduce, { Submitting: (s, store) => this.submitBesluit(s, store), }); ``` **`onPrimary` → a `Primary` msg** on the 3 wizard machines: `primary(s) = isLastStep(s) ? submit(s) : next(s)`, composed from each machine's own already- exported `next`/`submit`/`currentStep`. Keep `Next` and `Submit` in the unions (templates, specs and the showcase use them). Then `onPrimary()` is one dispatch, `onRetry()` is one dispatch, and the shell's outputs map 1:1 onto messages — which is what `.claude/skills/form-machine/SKILL.md:74-78` already claims. **Retry affordance:** reuse the existing id `@@wizard.opnieuwProberen` with byte-identical source text. It already has an English target in **both** `messages.en.xlf` files, so no new translation is needed. #### Rejected alternatives (recorded, not re-litigated) - **An Angular `effect()` watching state** — reject. `store.spec.ts:16-30` exists because this exact pattern livelocked the app. Worse, a signal effect is a latest-value notification, not an event stream: two dispatches in one tick coalesce, so a transient `Submitting` can be observed as never having happened — a silently dropped submit, the very failure being fixed. - **Full Elm `reduce -> [state, Cmd]`** — the honest end state and the only option with statically exhaustive effect coverage, but disproportionate here: 9 machines, 9 specs, every dispatch site. And `domain/` may not import Angular (lint-enforced), so a `Cmd` cannot carry `BigProfileStore.beginHerregistratie` — it becomes a symbolic tag plus a `switch` in the component, which is the guard being deleted, relocated. Keep as the documented upgrade path. ### 1b. Six encodings of "in flight / ok / failed" → two Two survive: **`RemoteData`** for "data I fetched", and **the machine's own state union** for "where this thing is". Everything else either folds into one of those or is an honest exception with a reason. | # | Encoding | Where | Disposition | | --- | ------------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 1 | `RemoteData` | `application/remote-data.ts` (91 lines), 19 files | **Keep**, and absorb #3 so one file is where a lifecycle becomes async state | | 2a | `ActionState` | `application/action-state.ts`, 2 producers | **Delete** — both stores collapse it to `busy` + `lastError` byte-identically; zero consumers keep the union. Becomes an `action` field on each machine's `Loaded` variant | | 2b | `SaveState` | same file | **Keep** — it has **2 genuine 4-way consumers** (`brief.page.ts:149-160`, `org-template.page.ts:102-113`). Move it to `debounced-save.ts`, beside its only producer, then delete `action-state.ts` | | 3 | `LoadLifecycle` + `machineRemoteData` | `application/machine-remote-data.ts` (24 lines), 3 identical call sites | **Relocate, not delete** — fold into `remote-data.ts` as `fromLoadLifecycle`, keyed PascalCase, beside the existing `fromResource` | | 4 | `WizardStatus` | `layout/wizard-shell/wizard-shell.component.ts:19` | **Replace with a payload-carrying `WizardPhase`** — the 3 switches drop the error payload, which then travels as a second `errorMessage` input | | 5 | `UploadStatus` | `domain/upload.machine.ts:11-18` | **Keep** — it _is_ encoding #2 for a sub-machine, and its payloads (`progressPct`, `documentId`, `reason`) are consumed by 3 UI components. Only the `type:` dialect is off; optional, last, alone | | 6 | 5 ad-hoc flags | see below | **1 folds, 4 keep** | **Why #3 relocates rather than deletes:** the mapping has to exist somewhere, because `` takes `RemoteData`. Deleting the module re-inlines a 6-line switch in 3 stores — recreating the duplication WP-31 removed. Relocating still wins the whole prize: the lowercase constraint dies, the dialect drift resolves, and the survivor sits beside `fromResource` as what it actually is — **a `RemoteData` constructor, not a sixth encoding**. **The causal chain that makes the dialect fix free:** `brief`, `org-template` and `stamdata-editor` are the only three state unions in the repo with lowercase tags, their Msg tags are PascalCase in the same file, and the _only_ thing pinning them is `machineRemoteData`'s structural `S extends LoadLifecycle`. Relocate the projection with PascalCase keys and the drift resolves itself — no separate renaming pass. **#4 — the shell genuinely needs the payload.** Replace `WizardStatus` + `errorMessage` with one input: `{ tag:'Editing' } | { tag:'Submitting' } | { tag:'Submitted' } | { tag:'Failed'; message: string }`. The 3 mapping `computed`s stay — `Answering` and registratie's Dutch `Invullen/Indienen/Ingediend/Mislukt` are not the shell's vocabulary — but now carry the message, so the 3 duplicated `errorMessage` computeds fold in and the second input disappears. `@switch` cannot narrow, so read `Failed` via the existing `whenTag` (`kernel/fp.ts:27`). `wizard-shell.stories.ts` must change in the same commit. **#6 — one folds, four keep.** Only `org-template.store.ts:59` `pendingPublish` is a genuine illegal-state pair (`pendingPublish && busy` is representable and meaningless) — it becomes a fourth `action` variant. The other four are **correctly modelled as they are**: `big-profile.store.ts:61` `pending` is a lone boolean the dashboard reads _after_ the wizard is destroyed, so it cannot be derived from the machine; and `aanvragen.store.ts:28`, `admin-cases.store.ts:26`, `feature-flags.page.ts:96` are one-shot **action** errors sitting beside a successfully-loaded list. Folding those into the list's `RemoteData` would make the error _replace_ the list, since `Failure` carries no value — precisely the RB-20 behaviour those comments exist to prevent. Add a comment saying so, and leave them. **A win this reveals:** `org-template.store.ts:29,129` (`NO_SUBORGS`) and `stamdata.store.ts` (`NO_TABLES`) dispatch `LoadFailed` for what is semantically **`Empty`**. Once the projection is explicit, a distinct state → `RemoteData.Empty` is a few lines, and `` already renders it via `emptyText`. Optional, own commit, needs one new `$localize` id. ### 1c. Commit order Ticket mapping: **A1=RD-05, A2=RD-06, A3=RD-07, A4=RD-08, A5=RD-09, B4=RD-10, B3=RD-11, B2a=RD-12, B2b=RD-13, B2c=RD-14, B6=RD-34, B5=RD-35.** Submit first — it settles the final shape of the 5 components, and 1b#4 touches 3 of them. | # | Commit | Notes | | ----- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | A1 | `createStore` gains the effect map + specs | No call sites change. Behaviour-neutral, so risk is isolated to one commit | | A2 | Migrate the 2 single-step forms; **add the retry affordance** | Closes both bugs. Reuses `@@wizard.opnieuwProberen`, no new xlf target | | A3 | Add `Primary` to the 3 wizard machines + specs | Pure domain. `check:seam` greps `BESLUIT_TAGS` and `SCHOLING_THRESHOLD_DEFAULT` — keep both greppable | | A4 | Migrate the 3 wizards to the effect map + `Primary` | `runIfSubmitting`/`runIfIndienen` become registered effects | | A5 | **Docs and the generator, same diff** | See below — non-optional | | B4 | `WizardStatus` → `WizardPhase` | Drops the `errorMessage` input; update `wizard-shell.stories.ts` | | B3 | Fold the projection into `remote-data.ts`; PascalCase the 3 machines | Widest mechanical commit; 77 lowercase literals | | B2a/b | `ActionState` → `action` on `Loaded` (brief, then org-template + `pendingPublish`) | Must follow B3 or the same tags get renamed twice | | B2c | Move `SaveState` to `debounced-save.ts`; delete `action-state.ts` | Type-only | | B6 | _(optional)_ `NO_SUBORGS`/`NO_TABLES` → `Empty` | Needs a new `$localize` id per app that renders it | | B5 | _(optional, last, alone)_ upload `type:` → `tag:` | ~20 files; see the sed hazard in Risks | **A5 — corrected while executing RD-09.** The original claim here was that `plop-templates/form-machine.hbs` **generates** `runIfSubmitting`, and that `.claude/skills/form-machine/SKILL.md:65-78` teaches it, so that the next scaffolded form would recreate the bug. **Both are false**, verified by grep: - `plop-templates/form-machine.hbs` is **machine-only** — 74 lines, no `@Component`, no `runIfSubmitting`. No generator recreates the bug. - `.claude/skills/form-machine/SKILL.md` never mentions it. Two real sites remain, and both teach the deleted idiom verbatim in a code block: `docs/reference/architecture/ARCHITECTURE.md:314` and `:574`, and `docs/reference/fp-tea-atomic-design.md:342`. So A5/RD-09 is a two-document fix, still worth doing — a teaching document that teaches a deleted idiom is precisely the rot this arc targets — but it is not the urgent generator fix this plan originally claimed. **Scope discipline:** A1 + A2 alone deliver "dispatching cannot silently skip the effect" and fix both bugs. If the budget shrinks, stop after A5; B3 and B2 are hygiene, not correctness. --- ## Phase 2 — Mechanical sweeps (no behaviour change) 1. **Remove `.claude/worktrees/`** — 22 abandoned `agent-` checkouts, **4.7 GB**, gitignored (`.gitignore:64`). They are why unqualified repo-wide `grep`/`find` return ~23× inflated counts, which taxes every future search by a human or an agent. **Correction made while executing RD-01:** these are **live registered git worktrees**, not orphaned directories. Each has a `worktree-agent-` branch carrying real RB-xx commits. So `rm -rf` is wrong — it leaves 22 broken worktree registrations behind. Use `git worktree remove` per worktree, then delete each branch, then `git worktree prune`. Verified during RD-01: the commits are already reachable from `main` (spot-checked `95bb773`, `80de261`, `dfc6c41`, `ce95294` with `git merge-base --is-ancestor`), because `637d500` merged the whole RB-01..RB-33 arc. **RD-15 must re-verify all 22 before removing any** — check every branch tip is an ancestor of `main`, and stop if one is not. 2. ~~**Finish Step 2's name collapse.**~~ **DROPPED while executing RD-16 — the instruction was wrong, and following it would have introduced a bug.** This plan claimed `DashboardViewDto → DashboardView → BigProfile` was "three names for one payload" and that `parseDashboardView` should return `BigProfile` directly. Reading the type disproves it: ```ts export interface DashboardView { profile: BigProfile; decisions: HerregistratieDecisions; } ``` `DashboardView` is a **pair**, and `BigProfile` is `{ registration, person }` — one _member_ of that pair, with nowhere to put `decisions`. Returning `BigProfile` directly would silently discard the server-computed herregistratie eligibility, which is exactly what ADR-0001 says the front end must render rather than recompute. The store's two `map` calls are not a redundant hop either: they project one aggregate into two independently-consumed signals, and six files consume them separately — for example `mijn-registratie.section.ts` takes `profile` while `wat-moet-ik-regelen.section.ts` takes `decisions`. So the three names are a wire DTO, a screen-shaped aggregate, and a component of that aggregate. Three different things, correctly named. **The other half of Step 2 was already done correctly:** `HerregistratieDecisions` lives in `registratie/domain/registration.ts:40`, not in `contracts/`, and only one hand-written contracts file remains (`duo-diplomas.dto.ts`, a different endpoint). Commit `42e7a1e` did the parts that were right and correctly left alone the part that would have been wrong. 3. **`successOf` / `successOr` sweep** — 10 inline unwraps remain in 8 files. They do not all want the same helper: - `undefined` fallback → existing `successOf`: `beoordeling.page.ts:78` - `[]` / `null` fallback → add `successOr(rd, fallback)`: `werkvoorraad.page.ts:61`, `admin-cases.page.ts:83`, `audit.page.ts:99`, `feature-flags.store.ts:27`, `registratie-wizard.component.ts:509`, `mijn-aanvragen.section.ts:93` - boolean predicates → leave as-is: `access.store.ts:36`, `feature-flags.store.ts:51` - `big-profile.store.ts:57` hand-rolls `map` — **use the existing `map`** from `remote-data.ts` 4. **Ticket-comment sweep, frontend and backend** (user-selected scope): **551 refs across 186 files** — 181 in `apps`+`libs` (100 files), 370 in `backend/` (86 files: 70 `.cs`, plus `Dockerfile`, 4 `.sh`, 4 `.yml`, 2 `.md`). Strip `WP-`/`RB-` and keep the surrounding sentence; git blame holds the provenance. **Keep all 90 `ADR-000x` refs** across 68 files — those point at documents that exist. Note `CD-` appears nowhere in the repo. --- ## Phase 3 — The guard first, then 7 splits ### 3a. The guard goes FIRST, not last Putting the rule ahead of the splits stops the refactor itself from adding a new 300-line file. In `eslint.config.mjs` (which today has no per-folder rules at all): ```js { files: ['{apps,libs}/**/*.{page,component,section,step}.ts'], rules: { 'max-lines': ['error', { max: 250, skipBlankLines: true, skipComments: true }] }, }, ``` Two corrections to the approved plan's Step 8, both load-bearing: - **The glob must include `section` and `step`.** `*.{page,component}.ts` does **not** match `*.section.ts` — the file kind the dashboard refactor invented, and the kind Phase 3 creates most of, would escape the guard entirely. - **Add `linterOptions: { reportUnusedDisableDirectives: 'error' }`** in the same commit. ESLint 9 only _warns_ by default and `npm run lint` does not fail on warnings. At `error`, **every split commit is forced to delete its own `eslint-disable` or lint fails** — the exemption list cannot rot into permanent debt. The repo has zero disables outside the generated `api-client.ts` and is clean under this flag today, so it lands green. ### 3b. Seven offenders, not nine Measured with the real rule (`skipBlankLines`, `skipComments`), not `wc -l`: | `wc -l` | rule | File | Axis | | ------- | ------- | ------------------------------------ | ---------------------------------------------- | | 644 | **574** | `registratie-wizard.component.ts` | **per step** | | 496 | **472** | `showcase/concepts.page.ts` | **per teaching section** | | 463 | **414** | `letter-canvas.component.ts` | **labels + one extraction, then stays exempt** | | 406 | **368** | `intake-wizard.component.ts` | **per step** | | 353 | **329** | `org-template-editor.component.ts` | **per output cluster** | | 293 | **253** | `rich-text-editor.component.ts` | over by 3 — move 2 helpers | | 288 | **252** | `herregistratie-wizard.component.ts` | over by 2 — one shared helper | | 267 | 232 | `behandel-scherm.component.ts` | **already compliant — leave alone** | | 262 | 236 | `stamdata-table-editor.component.ts` | **already compliant — leave alone** | ### 3c. The step contract already exists — do not invent one For the wizards, the pattern to copy is **not** the dashboard. It is `registratie/ui/address-fields/address-fields.component.ts`, which this very wizard already composes and whose header comment _is_ the contract, verbatim: _"Pure & presentational — values in via `value`, errors in via `errors`, every keystroke out via `fieldChange`. No store, no services, no internal state; the container owns the Model and decides what a change means."_ Two containers already reuse it. So: **inputs down, one narrow output up, `dispatch` never passed down.** - `registratie-wizard` → `adres.step.ts` (~90), `beroep.step.ts` (~130), `controle.step.ts` (~110); parent lands ~230. `RegistratieLookupStore` is `providedIn: 'root'`, so the beroep step injects the same instance and owns its own `` over the DUO lookup — this is the one place the dashboard's axis _does_ apply. **Moving the upload controller is what gets the parent under 250**: `createUploadController` takes a `dispatch` callback, so the step creates its own with `dispatch: (msg) => this.uploadMsg.emit(msg)` — one output, not five. The BRP prefill `effect` **stays in the parent**, because it writes to the machine. - `intake-wizard` → `buitenland.step.ts`, `werk.step.ts`, `review.step.ts`; parent ~200. `scholingZichtbaar` is **not** an input — each step takes the threshold and calls the pure `lageUren(answers, threshold)` itself ("derive, don't store"). - `herregistratie-wizard` is over by **two lines**. Do not split its steps for symmetry — its whole template is ~100 lines. Extract one shared pure helper instead: `layout/wizard-shell/wizard-errors.ts` with `toWizardErrors()` + spec, beside the existing `naarStapLabel` that lives there for exactly this reason. Removes ~6 lines from all three wizards. **Do not** touch the three `shellStatus` switches — the tags genuinely differ per machine, and an exhaustive switch is the house style. - `wizard-shell` (205 lines) **already provides the whole frame** — stepper, error summary, `
`, navigation, submitting/submitted/failed, a11y focus. The steps slot into its existing default ``. **Nothing new in `libs/shared`.** **Corollary: give the new steps no stories.** Each wizard's existing story already mounts every step by seeding the machine. The dashboard got this right too — 3 stories for 6 sections, only where there was async state to show. ### 3d. `letter-canvas` — a misapplied rule, not a split 20 of its 28 `input()`s are pure `$localize` labels and **no caller overrides a single one** across all 4 call sites. The CLAUDE.md rule they were built for — _"Shared/English components must not hardcode Dutch — expose copy as `input()`s"_ — governs `libs/shared`, **not** a Dutch domain component in `brief/ui/`. Inline them as `i18n="@@id"` in the template; same id, same source text means **zero `messages.en.xlf` edits**. Do **not** collapse them into a config object or an injection token. `HEADER_NAV_ITEMS`/`DEBUG_PANEL` exist because two apps genuinely differ; here nothing differs, so a token adds a provider and an indirection to solve a problem nobody has. One extraction earns its keep: `letter-line.component.ts` (~70 lines out) — the `#line` template plus the sample/diff helpers. It replaces six 4-line `ngTemplateOutlet` incantations with three one-line tags and is the only part with logic worth a spec. That leaves ~329: 77 lines of CSS and 204 lines of _one letter_. Splitting it into letterhead/body/signature/footer makes "what does the letter look like" a five-file question for no behavioural seam. **Keep one `/* eslint-disable max-lines */`** with an honest reason. It becomes the only disable in the repo, and `reportUnusedDisableDirectives` keeps it honest. ### 3e. The `$localize` boundary that governs 3d and 3f **Plain messages move to the template; parameterised ones stay in TS.** The xlf stores interpolations as ``; moving such a message into a template renames the placeholder to `INTERPOLATION` and **breaks the translation merge**, so `ng build --localize` fails. Only 4 messages are affected: `orgTemplate.margins`, `orgTemplate.invalid`, `orgTemplate.publish.impact`, `wizard.naarStap`. ### 3f. `org-template-editor` — split by output cluster The 11 `output()`s are the tell; each child takes one mutation family: - `SAMPLE_LETTER_BRIEF` (44 lines) → `brief/domain/sample-letter.ts`. It is a **dead export** (used only in its own file) and it is production content, not a fixture — so it must **not** go near `brief.testing.ts`, or dependency-cruiser's `no-testing-in-production` rule fails. - 11 of 13 label inputs → template `i18n` (they are declared `protected`, so they were never bindable — constants wearing `input()` ceremony). The two parameterised ones stay per 3e. - `logo-upload.component.ts` (~34 out) and `version-history.component.ts` (~18 out). - Parent drops 11 outputs → 5 and lands ~222. No exemption. ### 3g. `concepts.page.ts` — split by section, but decompose the CSS by owner A per-section split does **not** fix the 142-line `styles:` block, because Angular scopes styles per component: the page's `.card` cannot style a child's DOM. So: - `.section` → **delete**, use the existing global `.app-section`. - `.lead`, `.cols` → two new globals beside `.app-text-subtle`/`.app-stack` in `libs/shared/styles.scss`, whose own comment says it exists to centralise these idioms. - `.card`, `.tag*`, `.note`, `pre` (~70 lines) → owned once by `concept-card.component.ts`, used ~11 times, which also deletes ~11 copies of the card boilerplate. **Watch the colour guard.** `scripts/check-tokens.sh` greps only `--include='*.component.ts'` — which is why this page currently gets away with `#1e2430`, `#fff`, `#e5e5e5`. Moving that CSS into a `*.component.ts` brings it under the guard **for the first time**, so in the same commit: drop the `var(--rhc-x, #hex)` fallbacks (all 16 tokens are defined) and add `--app-code-bg/-fg/-keyword/-string/-comment` for the `pre` palette, following the existing `--app-devpanel-*` precedent added for this exact reason. ### 3h. `rich-text-editor` — over by three `rich-text-dom.ts` already exists beside it with its own spec, so the seam is built. Move the selection/range surgery out of `deleteAdjacentChip`/`insert` into it. Cheapest of the seven, and it converts two untested imperative-DOM branches into spec cases. ### 3i. Order 1. the rule + `reportUnusedDisableDirectives` + 7 disables 2. `wizard-errors.ts` + spec, adopted by all three wizards → delete that disable 3. `rich-text-dom` helpers + spec → delete that disable 4. `intake-wizard` → 3 steps 5. `registratie-wizard` → 3 steps + the upload-controller move 6. `concepts.page` → 6 sections + `concept-card` + 2 globals + `--app-code-*` tokens 7. `org-template-editor` → `sample-letter.ts` + labels + 2 children 8. `letter-canvas` → labels + `letter-line`; **keep** its disable, rewrite the reason Steps 2–8 are independent; only 2 must precede 4 and 5. --- ## Phase 4 — Make the folder equal the layer, then enforce the ladder ### 4a. Move (`libs/shared/src/ui/` only) ``` ui/atoms/ 12 flat + upload/{delivery-channel-toggle,document-chip,file-input, upload-progress-bar,upload-status-icon} (17) ui/molecules/ 13 flat + upload/single-upload (14) ui/organisms/ upload/{document-category,document-upload} (2) ``` `upload/` **splits by layer but keeps its feature subfolder inside each layer**. It satisfies decision #2 literally, costs the same 6 relative-import rewrites as a flat split, keeps a genuinely cohesive group together, and makes "5 atoms + 1 molecule + 2 organisms" visible in the tree instead of hidden in story titles. No barrel — the repo has none and does not need one. **`layout/` does not move.** CLAUDE.md §5 _explicitly_ enumerates `libs/shared/layout` components getting Atoms…Templates buckets, so `layout/` is sanctioned to hold several layers; its organisms are chrome only its own templates compose. Instead, fix the two mislabels: `async.component.ts` is missing its `/** Molecule: */` tag, and `breadcrumb.component.ts` says `/** Chrome: */` where its title says Molecules. **`libs/beheer/src/ui/` — fix the doc, not the code.** Its story title is `Domein/Beheer/Stamdata Table Editor` while CLAUDE.md §5 and `layers.mdx` say `Design System/…`. The code is right: `libs/beheer` _is_ a bounded context that lives in `libs/` only to be shared by two apps. Amend the two doc lines. That dissolves the "two taxonomies" oddity and beheer correctly needs no layer folders. **Mechanics, one commit for all of `ui/`:** 33 whole-directory `git mv`s break zero relative imports except the 6 inside `upload/`; then rewrite the 28 distinct specifier strings (179 occurrences, 59 files) **longest-key-first**, so `upload//` is processed before any bare `upload/`. Confirmed: no edits to `angular.json`, `eslint.config.mjs`, `plopfile.mjs`, `e2e/`, or any tsconfig; both Storybook globs are recursive; dependency-cruiser's `ui-not-infrastructure` pattern still matches a nested path; and `check-tokens.sh`'s CIBG-GAP check keys on the **directory basename**, which a parent-folder move preserves. Verify with `npm run typecheck` (4 tsconfigs — catches every missed specifier), `dep:check`, `test`, then **`npm run ci --full`**. `git diff --stat -M` should show only renames plus one-line import edits. ### 4b. Keep all 78 layer-tag comments Reversal of my earlier claim. The tag prefixes a real one-line description (`/** Atom: thin wrapper over CIBG .btn — typed variant API. */`); deleting the word leaves the sentence and buys nothing. And only the 32 in `ui/` are made redundant by folders — the 25 organisms and 9 pages in `apps/**/ui/` have no layer folder and a title that deliberately omits the layer, so there the comment is the **sole** carrier. A three-way redundancy that has never once disagreed is cheap documentation. ### 4c. The ladder rules are the real prize The folder move is what makes this _expressible_; this is what makes it _enforced_. Today nothing stops an atom importing an organism. Add to `.dependency-cruiser.base.js`: - `atoms-compose-nothing-above`: `ui/atoms/` → `ui/(molecules|organisms)/` forbidden - `molecules-below-organisms`: `ui/molecules/` → `ui/organisms/` forbidden - `design-system-not-layout`: `ui/` → `layout/` forbidden Two details that matter: **forbid upward only, never "atoms are leaves"** — same-layer edges are legitimate and exist today (`masked-value → button`, `review-section → data-block`, `task-list → choice-link`); and `pathNot` must exempt `\.(spec|stories)\.ts$`, because `async.stories.ts` composes `skeleton` and a story may legitimately reach for context. Zero upward edges exist today, so all three land green immediately. Dependency-cruiser rather than ESLint: it is where every other boundary rule lives and it emits the architecture graph. --- ## Phase 5 — Fix the docs that describe this flow 1. **Archive the finished backlog.** `git mv docs/project/backlog` and `docs/project/refactor-backlog-setup` under `docs/project/archive/`. Verified: **all 74 WP files are `Status: done`**; the two trees are 6,982 + 9,318 = **16,300 of the docs tree's 20,317 lines**. Add a ~15-line `archive/README.md`: this is historical, git holds the rest. 2. **`ARCHITECTURE.md` §6a** ("The request lifecycle today", line 542) is the best onboarding artifact in the repo and has rotted: - 15 `L` line citations, now wrong — `Program.cs` L80 lands on a `// WP-60:` comment about client timeouts, not `/dashboard-view`; L120 lands mid-expression. **Cite symbols, not lines.** - Two cited paths do not exist: `src/environments/environment.ts` (pre-monorepo) and `proxy.conf.json`. - Its read-walkthrough shows `` on the dashboard page; after the split that markup lives in `mijn-registratie.section.ts`. - It states the boundary yields `RemoteData` — wrong once Phase 2.2 lands. 3. **`docs/reference/fp-tea-atomic-design.md`** — 11 pre-monorepo `src/app/…` paths, all broken; one (`submit-herregistratie.ts`) points at a deleted file; and a broken anchor at line 427 (`#1-the-big-picture-three-contexts-four-layers` vs the actual "two apps, cross-app libraries"). 4. Update CLAUDE.md for the new `overzicht` context, the `max-lines` budget, the `libs/beheer` title rule (4a), and the step-component contract (3c). 5. `libs/shared/docs/atomic-design.mdx` gains the step contract and the layer table; also fix its stale claim that `eslint.config.mjs` enforces the layer rules — dependency-cruiser does. Fix the stale `src/app/shared/ui/...` paths in `.claude/skills/ui-component/SKILL.md`. --- ## Risks 1. **The Storybook trap (highest).** All 5 components mount their `Submitting`/`Indienen` state via `Seed` in stories that use a real `provideHttpClient()` with **no request mocking** (`besluit-form.stories.ts:33`, `herregistratie-wizard.stories.ts:70`, `intake-wizard.stories.ts:38`, `change-request-form.stories.ts:34`, `registratie-wizard.stories.ts:88`). Without the `Seed` exemption in the trigger rule they fire real network calls, flip to `Failed`, and red the `storybook-a11y` job (`npm run ci --full`). Verifying those 5 stories still show a spinner **is** the exemption's acceptance test. 2. **`dispatch` becomes effectful, and two dispatch sites sit inside Angular `effect()`s** — `intake-wizard.component.ts:363` (`SetPolicy`) and `registratie-wizard.component.ts:~596` (`PrefillAdres`). Both land on an unchanged tag, so nothing fires, and both are already `untracked`. **Never key an effect on the editing tag** — that is the livelock. 3. **The `type:` → `tag:` sed hazard (B5).** `type` is also a legitimate _field_ name in that neighbourhood — `rejectReason(cat, { type: file.type, sizeMb })` — and `FileRejected.reason` has the literal value `'type'`. A blind rename breaks upload validation silently. File-by-file with the type-checker, or defer. 4. **The ticket sweep is 186 files, including `backend/`'s `Dockerfile`, 4 `.sh` and 4 `.yml`.** Do not blanket-`sed`. A `WP-`/`RB-` token could appear in a string that matters (seed data, a test name, a migration id) rather than a comment. Review the diff per file group, and keep all 90 `ADR-000x` refs. 5. **CI drift gates bite mechanically.** `scripts/ci-local.sh:33-34` diffs `showcase/snippets.generated.ts` (fed by `// #region showcase:` markers — `remote-data.ts:30` carries `showcase:fold`, `intake.machine.ts:59` carries `showcase:steps`) and `libs/shared/docs/behaviour-spec.mdx`. Run `gen:snippets` / `gen:behaviour-spec` in the **same commit** as any change that moves a region or a spec title. 6. **`action` inside `Loaded` narrows where "busy" can exist.** Re-check every current `actionState.set({tag:'Busy'})` site for reachability from a non-loaded state. Note a `BriefLoaded` reload then resets `action` to `Idle`, which is a behaviour _improvement_: a stale error can no longer outlive a reload. 7. **Highest risk in Phase 4: a broken MDX story import passes `npm run ci` and fails CI.** `libs/shared/docs/*.mdx` has **8 relative story imports** across `a11y.mdx`, `atomic-design.mdx`, `remote-data.mdx` and `fp-in-ui.mdx` of the form `../src/ui//.stories`. These break on the move, and only `build-storybook` catches them — which is **not** in the default `npm run ci`, only `--full`. Fix all 8 in the move commit and run `npm run ci --full` before pushing it. 8. **`check-tokens.sh` has blind spots in both directions.** It greps only `--include='*.component.ts'`. Moving CSS from a `*.page.ts` into a `*.component.ts` newly _exposes_ it (see 3g); moving CSS to a `.styles.ts` or `.scss` newly _hides_ it. Decide deliberately, and widen the glob in the same commit if you move CSS out. 9. **`styles: [importedConst]` is unproven in this repo** — 47 of 47 components use inline literals and only 2 `.scss` files exist. If the `letter-canvas` style extraction is attempted, prove it with `ng build` first; the fallback is the single `eslint-disable`. 10. **Behaviour drift while moving 200+ template lines.** Move template text byte-identically and let `git diff -M` prove it. The wizards' specs cover `reduce`, not the markup, so the markup's only guards are review and the axe run in `ci --full`. 11. **A step component reaching for the store.** Passing `dispatch` down is tempting and would let a step dispatch `Submit`. Use outputs, per `address-fields`. If overridden, type the input as `Extract` so an illegal dispatch is unrepresentable. ## Verification Per commit: 1. `npm run ci` — lint (incl. the new `max-lines`), typecheck, `dep:check`, format, tokens, both apps' tests, `ng build --localize` (catches a missing `messages.en.xlf` target for any moved `$localize` string), audit, backend `dotnet test`, API-client drift. 2. `cd backend && dotnet test` for anything touching the wire. End to end, after Phase 0 and Phase 3: 3. `npm start`, open `http://localhost:4200/dashboard`. All six sections render as before. 4. `?scenario=loading`, `=empty`, `=error`, `=slow` — each section shows its **own** state, not one page-wide spinner. 5. `?role=admin` — the Beheer section appears; without it, absent. 6. Resume and cancel a concept aanvraag — optimistic update and error path both work. 7. Walk each wizard end to end after Phase 1: submit, retry after a failure, and the step-boundary transitions. 8. `npm run storybook` and `npm run storybook:behandelportal` — moved and new stories render, a11y addon clean. --- ## Corrections to the original plan's claims Measured against the current tree, not assumed: - ~~**Step 2 did not fully land.**~~ **This correction was itself wrong, and is withdrawn.** The chain is intact because it _should_ be: `DashboardView` is a pair of `BigProfile` and `HerregistratieDecisions`, not a third name for either. Collapsing it would discard the server-computed decisions. The claim was made by reading the parse signature without reading the type it returns. See Phase 2.2, now dropped. - **7 files exceed 250 lines, not 8.** The plan counted by `wc -l`; the rule as specified uses `skipBlankLines` + `skipComments`. `concepts.page.ts` (472) was missing from its list, but `behandel-scherm` (232) and `stamdata-table-editor` (236) were on it and already pass. - **551 ticket refs across 186 files**, not 478/170. **`CD-` does not exist** anywhere. - **Only 2 of the 4 named adapters make no HTTP call.** `letter-preview.adapter.ts` and `reveal-bignummer.adapter.ts` both `fetch` for real (deliberately hand-written, not the generated client). Only `MedewerkerAdapter` and `DigidAdapter` are pure stand-ins, and both already carry a `// ponytail: fake …` label. Renaming those two is cosmetic — low priority. - **`registratie-wizard`'s Dutch tags (`Invullen`/`Indienen`/`Ingediend`/`Mislukt`) are correct**, not drift — CLAUDE.md requires Dutch domain contexts. Do not "fix" them. - **Storybook titles and header comments agree in all but two cases** across the 41 story files, so the folder=layer move is mechanical, not a taxonomy debate. The two: `async.component.ts` has no tag at all, and `breadcrumb.component.ts` says `/** Chrome: */`. **A live CI-gate defect, found while executing RD-06 (now fixed).** `scripts/ci-local.sh` gated its storybook + axe steps on `[[ "${1:-}" == "--full" ]]`, but CLAUDE.md documents `npm run ci --full` — and npm parses that flag itself, exporting `npm_config_full=true` instead of passing `--full` through as `$1`. Proven with `npm run env --full`. So the documented command **skipped both steps and still printed "local CI passed"**: a gate reporting success without running. The script now accepts either form, which makes every existing doc correct rather than requiring them all to change. This matters directly for RD-27, whose highest risk is a broken `.mdx` story import that **only** `build-storybook` catches. And four corrections to claims made **earlier in this same investigation**, caught by reading the consumers and the rule semantics rather than the definitions: - **"~62 of the 77 layer-tag comments become deletable" — withdrawn.** Keep all 78 (the count was also one short). The tag prefixes a real description, so deleting the word leaves the sentence; and only the 32 in `libs/shared/src/ui/` are made redundant by folders. Elsewhere the comment is the sole carrier of the layer. - **`libs/shared/src/ui/` holds 17 atoms, not 16.** - **`SaveState` is not redundant.** It has 2 genuine consumers that keep all four cases (`brief.page.ts:149-160`, `org-template.page.ts:102-113`). Only `ActionState` collapses, so `action-state.ts` must be **split, not deleted**. - **"Five ad-hoc boolean/nullable-string pairs" was overstated.** Only `org-template.store.ts:59` is a genuine illegal-state pair. Three of the five (`aanvragen.store.ts:28`, `admin-cases.store.ts:26`, `feature-flags.page.ts:96`) are a lone `signal` with **no boolean partner** — an action error beside a loaded list, on a different axis from the fetch, correctly modelled as it stands. ## Deliberately out of scope - Renaming the `/dashboard` route to `/overzicht` (needs a redirect; user-visible). - Splitting `libs/shared/src/layout/` by layer (see 4a for why not). Cost if you disagree: 10 distinct specifiers, 34 occurrences, 28 files. - Splitting `herregistratie-wizard`'s three steps for symmetry with the other two wizards. It is over budget by two lines and its template is ~100 lines. Worth noting as a consistency follow-up, not as work. - `behandel-scherm.component.ts` and `stamdata-table-editor.component.ts` — both already pass the rule. Leave them alone. - The 7 non-component files over 250 lines (`brief.adapter.ts` 408, `upload.machine.spec.ts` 364, `brief.store.spec.ts` 357, …). The glob deliberately does not reach them. - A `scripts/check-layers.sh` asserting folder == tag == story title. Cheap (~15 lines, modelled on the CIBG-GAP check) but it only catches doc typos. - The admin `Case`/`Zaak` vocabulary rename — a separate read model. - NgRx, real auth, runtime DTO validation on every endpoint (CLAUDE.md "out of scope").