RD-16 was to make parseDashboardView return BigProfile directly, on the
plan's claim that DashboardViewDto, DashboardView and BigProfile were
three names for one payload. Reading the type disproves it.
DashboardView is a pair of BigProfile and HerregistratieDecisions.
BigProfile is { registration, person } and has nowhere to put decisions,
so returning it directly would silently drop the server-computed
herregistratie eligibility — the value 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 read
them separately.
Also withdraw the earlier correction that "Step 2 did not fully land".
That claim came from reading the parse signature without reading the type
it returns. Commit 42e7a1e did the right parts, including moving
HerregistratieDecisions into domain, and correctly left alone the part
that would have been wrong.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
61 KiB
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.mdfor 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:
- 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.
- The worst problem is not size, it is a silent-failure idiom.
runIfSubmittingis copy-pasted into 5 components under 2 names and must be called by hand right afterdispatch. 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:
- Phase 3a first — the
max-linesrule plusreportUnusedDisableDirectives: '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. - 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.
- Then Phase 1 (idioms) — it deletes code that the Phase 3 splits would otherwise have
to carry: the copy-pasted submit plumbing, the
WizardStatusswitch, severalcomputeds. Split first and you split code you are about to delete. - 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 | doneinside 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:
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:
- 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. - Inline the traps that apply to that ticket. The Risks section below is global; an
agent will not read it. The
Seedexemption belongs in RD-05's Decisions block, the longest-key-first sed order in RD-27's, the parameterised-$localizerule in RD-25's and RD-26's. A trap left only in a global list is a trap that fires. - State acceptance as a command, not a sentence.
npm run lintexits non-zero, or the file is under 250 rule-lines, ornpx eslint --report-unused-disable-directivesis clean. "Lands ~230 lines" is a design estimate and is not checkable; do not put it in Acceptance.
GREEN for this arc
npm run ci
Plus, for any ticket touching a story, an .mdx, or libs/shared/src/ui/**:
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:
- Read the README Order table. Pick the first
todowhose dependencies are alldone. - Spawn one
developeragent with a fixed prompt: "Read CLAUDE.md, thendocs/project/readable-codebase/README.md, thenRD-NN.mdand 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." - Verify with a
task-runneragent (Haiku):npm run ci,git log -1 --stat, and that theStatus:line now readsdone. - 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.mdxandshowcase/snippets.generated.tsare 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-<slug>.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/<Context>/<Name>; 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 |
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 rewritesARCHITECTURE.md§6a and RD-32 fixesfp-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.tslinks by route string.dep:checkpasses and gives false assurance on exactly this file. beheer-links.section.ts:6importsADMIN_LINKSfrom../../../shell/nav.config— a context reaching into the app frame.app.config.ts:72already provides that same constant to the shared site header through theHEADER_ADMIN_LINKStoken.
Steps
- Scaffold a context with
npm run gen:context(plop context) namedoverzicht(Dutch, per CLAUDE.md: domain contexts are Dutch; the page's own title is "Mijn overzicht"). - Move the page and the two portal-level navigation sections into it:
overzicht/ui/overzicht.page.ts(wasregistratie/ui/dashboard.page.ts)overzicht/ui/wat-wilt-u-doen.section.tsoverzicht/ui/beheer-links.section.ts
- Leave the four data sections in
registratie/ui/dashboard/—mijn-aanvragen,wat-moet-ik-regelen,mijn-registratie,specialismenrender registratie data and belong beside their store. This separation is only possible because of the split; it is the refactor's first real payoff. - Declare the edge in
.dependency-cruiser.ssp.js:overzicht: ['registratie']— the second sanctioned cross-feature edge, mirroringherregistratie: ['registratie']. beheer-links.section.tsinjectsHEADER_ADMIN_LINKSinstead of importingshell/nav.config, removing the context→shell reach-in.- Consider giving
wat-wilt-u-doen's action list the same treatment — a token besideNAV_ITEMS/ADMIN_LINKSinshell/nav.config. Route strings on a landing page are legitimate, but the list is app-frame copy, not registratie's. - Update
app.routes.ts:19toloadComponent: () => import('@overzicht/ui/overzicht.page'), and add the@overzicht/*alias toapps/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/<Name>; CLAUDE.md specifiesDomein/<Context>/<Name>, "full stop", and all 41 other story files comply. Retitle toDomein/Registratie/<Name>for the four that stay andDomein/Overzicht/<Name>for those that move. Safe: onlylayers.mdxdeep-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
importper rendered section is exactly right. The only way down is aDASHBOARD_SECTIONSconst spread intoimports:, 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:
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:129registratie/ui/change-request-form/change-request-form.component.ts:175herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts:275herregistratie/ui/intake-wizard/intake-wizard.component.ts:386registratie/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 exportednext/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-wizardhas 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.
export type StoreEffects<Model, Msg> = Model extends { tag: string }
? {
[K in Model['tag']]?: (
state: Extract<Model, { tag: K }>,
store: Store<Model, Msg>,
) => unknown;
}
: never;
export function createStore<Model, Msg>(
init: Model,
update: (model: Model, msg: Msg) => Model,
effects?: StoreEffects<Model, Msg>,
): Store<Model, Msg>;
Four load-bearing choices:
- A conditional type, not a
Model extends {tag}constraint — the constraint would breakstore.spec.ts:8'screateStore(0, (n, m) => n + m). With the conditional,Model = numberresolves tonever, 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, butcreateStore(...)runs in a field initializer beforethis.storeexists.
The trigger rule is where the design lives. Run effects[next.tag] when both:
prev.tag !== next.tag— the store entered the tag. ASubmitthat fails validation isEditing → Editing: no fire. A secondSubmitwhileSubmittingis a reducer no-op: no fire, so double-submit protection falls out of the rule.RetryisFailed → Submitting: fires, soonRetryneeds no special case.- the msg tag is not
Seed. This single line is what stops the fiveSubmittingStorybook stories from firing real HTTP (see Risks), and stopsdraftSync.onResume→Seedfrom 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.
private store = createStore<BesluitState, BesluitMsg>(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-30exists 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 transientSubmittingcan 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. Anddomain/may not import Angular (lint-enforced), so aCmdcannot carryBigProfileStore.beginHerregistratie— it becomes a symbolic tag plus aswitchin 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<E,T> 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<E,T> |
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
<app-async> 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 computeds 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 <app-async> 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.hbsis machine-only — 74 lines, no@Component, norunIfSubmitting. No generator recreates the bug..claude/skills/form-machine/SKILL.mdnever 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)
-
Remove
.claude/worktrees/— 22 abandonedagent-<hex>checkouts, 4.7 GB, gitignored (.gitignore:64). They are why unqualified repo-widegrep/findreturn ~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-<hex>branch carrying real RB-xx commits. Sorm -rfis wrong — it leaves 22 broken worktree registrations behind. Usegit worktree removeper worktree, then delete each branch, thengit worktree prune.Verified during RD-01: the commits are already reachable from
main(spot-checked95bb773,80de261,dfc6c41,ce95294withgit merge-base --is-ancestor), because637d500merged the whole RB-01..RB-33 arc. RD-15 must re-verify all 22 before removing any — check every branch tip is an ancestor ofmain, and stop if one is not. -
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 → BigProfilewas "three names for one payload" and thatparseDashboardViewshould returnBigProfiledirectly. Reading the type disproves it:export interface DashboardView { profile: BigProfile; decisions: HerregistratieDecisions; }DashboardViewis a pair, andBigProfileis{ registration, person }— one member of that pair, with nowhere to putdecisions. ReturningBigProfiledirectly 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
mapcalls are not a redundant hop either: they project one aggregate into two independently-consumed signals, and six files consume them separately — for examplemijn-registratie.section.tstakesprofilewhilewat-moet-ik-regelen.section.tstakesdecisions.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:
HerregistratieDecisionslives inregistratie/domain/registration.ts:40, not incontracts/, and only one hand-written contracts file remains (duo-diplomas.dto.ts, a different endpoint). Commit42e7a1edid the parts that were right and correctly left alone the part that would have been wrong. -
successOf/successOrsweep — 10 inline unwraps remain in 8 files. They do not all want the same helper:undefinedfallback → existingsuccessOf:beoordeling.page.ts:78[]/nullfallback → addsuccessOr(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:57hand-rollsmap— use the existingmapfromremote-data.ts
-
Ticket-comment sweep, frontend and backend (user-selected scope): 551 refs across 186 files — 181 in
apps+libs(100 files), 370 inbackend/(86 files: 70.cs, plusDockerfile, 4.sh, 4.yml, 2.md). StripWP-/RB-and keep the surrounding sentence; git blame holds the provenance. Keep all 90ADR-000xrefs across 68 files — those point at documents that exist. NoteCD-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):
{
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
sectionandstep.*.{page,component}.tsdoes 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 andnpm run lintdoes not fail on warnings. Aterror, every split commit is forced to delete its owneslint-disableor lint fails — the exemption list cannot rot into permanent debt. The repo has zero disables outside the generatedapi-client.tsand 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.RegistratieLookupStoreisprovidedIn: 'root', so the beroep step injects the same instance and owns its own<app-async>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:createUploadControllertakes adispatchcallback, so the step creates its own withdispatch: (msg) => this.uploadMsg.emit(msg)— one output, not five. The BRP prefilleffectstays in the parent, because it writes to the machine.intake-wizard→buitenland.step.ts,werk.step.ts,review.step.ts; parent ~200.scholingZichtbaaris not an input — each step takes the threshold and calls the purelageUren(answers, threshold)itself ("derive, don't store").herregistratie-wizardis 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.tswithtoWizardErrors()+ spec, beside the existingnaarStapLabelthat lives there for exactly this reason. Removes ~6 lines from all three wizards. Do not touch the threeshellStatusswitches — 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,<form>, navigation, submitting/submitted/failed, a11y focus. The steps slot into its existing default<ng-content>. Nothing new inlibs/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 <x id="min" equiv-text="MARGIN_MIN_MM"/>; 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 nearbrief.testing.ts, or dependency-cruiser'sno-testing-in-productionrule fails.- 11 of 13 label inputs → template
i18n(they are declaredprotected, so they were never bindable — constants wearinginput()ceremony). The two parameterised ones stay per 3e. logo-upload.component.ts(~34 out) andversion-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-stackinlibs/shared/styles.scss, whose own comment says it exists to centralise these idioms..card,.tag*,.note,pre(~70 lines) → owned once byconcept-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
- the rule +
reportUnusedDisableDirectives+ 7 disables wizard-errors.ts+ spec, adopted by all three wizards → delete that disablerich-text-domhelpers + spec → delete that disableintake-wizard→ 3 stepsregistratie-wizard→ 3 steps + the upload-controller moveconcepts.page→ 6 sections +concept-card+ 2 globals +--app-code-*tokensorg-template-editor→sample-letter.ts+ labels + 2 childrenletter-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 mvs 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/<sub>/ 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)/forbiddenmolecules-below-organisms:ui/molecules/→ui/organisms/forbiddendesign-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
- Archive the finished backlog.
git mv docs/project/backloganddocs/project/refactor-backlog-setupunderdocs/project/archive/. Verified: all 74 WP files areStatus: done; the two trees are 6,982 + 9,318 = 16,300 of the docs tree's 20,317 lines. Add a ~15-linearchive/README.md: this is historical, git holds the rest. ARCHITECTURE.md§6a ("The request lifecycle today", line 542) is the best onboarding artifact in the repo and has rotted:- 15
L<n>line citations, now wrong —Program.csL80 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) andproxy.conf.json. - Its read-walkthrough shows
<app-async [data]="store.profile()">on the dashboard page; after the split that markup lives inmijn-registratie.section.ts. - It states the boundary yields
RemoteData<DashboardView>— wrong once Phase 2.2 lands.
- 15
docs/reference/fp-tea-atomic-design.md— 11 pre-monoreposrc/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-layersvs the actual "two apps, cross-app libraries").- Update CLAUDE.md for the new
overzichtcontext, themax-linesbudget, thelibs/beheertitle rule (4a), and the step-component contract (3c). libs/shared/docs/atomic-design.mdxgains the step contract and the layer table; also fix its stale claim thateslint.config.mjsenforces the layer rules — dependency-cruiser does. Fix the stalesrc/app/shared/ui/...paths in.claude/skills/ui-component/SKILL.md.
Risks
- The Storybook trap (highest). All 5 components mount their
Submitting/Indienenstate viaSeedin stories that use a realprovideHttpClient()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 theSeedexemption in the trigger rule they fire real network calls, flip toFailed, and red thestorybook-a11yjob (npm run ci --full). Verifying those 5 stories still show a spinner is the exemption's acceptance test. dispatchbecomes effectful, and two dispatch sites sit inside Angulareffect()s —intake-wizard.component.ts:363(SetPolicy) andregistratie-wizard.component.ts:~596(PrefillAdres). Both land on an unchanged tag, so nothing fires, and both are alreadyuntracked. Never key an effect on the editing tag — that is the livelock.- The
type:→tag:sed hazard (B5).typeis also a legitimate field name in that neighbourhood —rejectReason(cat, { type: file.type, sizeMb })— andFileRejected.reasonhas the literal value'type'. A blind rename breaks upload validation silently. File-by-file with the type-checker, or defer. - The ticket sweep is 186 files, including
backend/'sDockerfile, 4.shand 4.yml. Do not blanket-sed. AWP-/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 90ADR-000xrefs. - CI drift gates bite mechanically.
scripts/ci-local.sh:33-34diffsshowcase/snippets.generated.ts(fed by// #region showcase:markers —remote-data.ts:30carriesshowcase:fold,intake.machine.ts:59carriesshowcase:steps) andlibs/shared/docs/behaviour-spec.mdx. Rungen:snippets/gen:behaviour-specin the same commit as any change that moves a region or a spec title. actioninsideLoadednarrows where "busy" can exist. Re-check every currentactionState.set({tag:'Busy'})site for reachability from a non-loaded state. Note aBriefLoadedreload then resetsactiontoIdle, which is a behaviour improvement: a stale error can no longer outlive a reload.- Highest risk in Phase 4: a broken MDX story import passes
npm run ciand fails CI.libs/shared/docs/*.mdxhas 8 relative story imports acrossa11y.mdx,atomic-design.mdx,remote-data.mdxandfp-in-ui.mdxof the form../src/ui/<name>/<name>.stories. These break on the move, and onlybuild-storybookcatches them — which is not in the defaultnpm run ci, only--full. Fix all 8 in the move commit and runnpm run ci --fullbefore pushing it. check-tokens.shhas blind spots in both directions. It greps only--include='*.component.ts'. Moving CSS from a*.page.tsinto a*.component.tsnewly exposes it (see 3g); moving CSS to a.styles.tsor.scssnewly hides it. Decide deliberately, and widen the glob in the same commit if you move CSS out.styles: [importedConst]is unproven in this repo — 47 of 47 components use inline literals and only 2.scssfiles exist. If theletter-canvasstyle extraction is attempted, prove it withng buildfirst; the fallback is the singleeslint-disable.- Behaviour drift while moving 200+ template lines. Move template text byte-identically
and let
git diff -Mprove it. The wizards' specs coverreduce, not the markup, so the markup's only guards are review and the axe run inci --full. - A step component reaching for the store. Passing
dispatchdown is tempting and would let a step dispatchSubmit. Use outputs, peraddress-fields. If overridden, type the input asExtract<RegistratieMsg, {tag: 'SetField' | …}>so an illegal dispatch is unrepresentable.
Verification
Per commit:
npm run ci— lint (incl. the newmax-lines), typecheck,dep:check, format, tokens, both apps' tests,ng build --localize(catches a missingmessages.en.xlftarget for any moved$localizestring), audit, backenddotnet test, API-client drift.cd backend && dotnet testfor anything touching the wire.
End to end, after Phase 0 and Phase 3:
npm start, openhttp://localhost:4200/dashboard. All six sections render as before.?scenario=loading,=empty,=error,=slow— each section shows its own state, not one page-wide spinner.?role=admin— the Beheer section appears; without it, absent.- Resume and cancel a concept aanvraag — optimistic update and error path both work.
- Walk each wizard end to end after Phase 1: submit, retry after a failure, and the step-boundary transitions.
npm run storybookandnpm 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:DashboardViewis a pair ofBigProfileandHerregistratieDecisions, 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 usesskipBlankLines+skipComments.concepts.page.ts(472) was missing from its list, butbehandel-scherm(232) andstamdata-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.tsandreveal-bignummer.adapter.tsbothfetchfor real (deliberately hand-written, not the generated client). OnlyMedewerkerAdapterandDigidAdapterare 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.tshas no tag at all, andbreadcrumb.component.tssays/** 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. -
SaveStateis not redundant. It has 2 genuine consumers that keep all four cases (brief.page.ts:149-160,org-template.page.ts:102-113). OnlyActionStatecollapses, soaction-state.tsmust be split, not deleted. -
"Five ad-hoc boolean/nullable-string pairs" was overstated. Only
org-template.store.ts:59is 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 lonesignal<string | null>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
/dashboardroute 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.tsandstamdata-table-editor.component.ts— both already pass the rule. Leave them alone.- The 7 non-component files over 250 lines (
brief.adapter.ts408,upload.machine.spec.ts364,brief.store.spec.ts357, …). The glob deliberately does not reach them. - A
scripts/check-layers.shasserting folder == tag == story title. Cheap (~15 lines, modelled on the CIBG-GAP check) but it only catches doc typos. - The admin
Case/Zaakvocabulary rename — a separate read model. - NgRx, real auth, runtime DTO validation on every endpoint (CLAUDE.md "out of scope").