Files
atomic-design-poc/docs/project/readable-codebase/RD-07-primary-message.md
T
ehoandClaude Sonnet 5 be1fcb4b40 feat: add Primary message to the 3 wizard machines (RD-07)
Each wizard component re-derives the step-boundary decision the reducer
already owns: advance on a middle step, submit on the last step. This
ticket moves that decision into the machine, so RD-08 can replace the
component's guard with one dispatch.

Add a `Primary` message to each Msg union, and export a `primary(s)`
function next to the existing `next`/`submit` pair. `primary` is a
three-line branch that delegates to `next`/`submit` and writes no new
validation. Each machine tests "last step" in its own vocabulary, per
the ticket's Decisions block: `herregistratie` checks `step === 3`,
`intake` checks `currentStep(s) === 'review'`, `registratie` checks
`currentStep(s) === 'controle'`. `Next` and `Submit` stay in every
union and every reducer — `Primary` is purely additive.

Add 3 spec cases per machine (9 total): Primary advances from a
non-final step, Primary submits from the final step, and Primary is a
no-op outside the editing state. Each case also asserts the
equivalence the ticket requires for RD-08's migration:
`reduce(s, Primary)` equals `reduce(s, Next)` at a non-final step, and
equals `reduce(s, Submit)` at the final step.

Regenerate `behaviour-spec.mdx` for the 9 new `it()` titles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 17:06:02 +02:00

7.3 KiB

RD-07 — Move the step-boundary decision into the 3 wizard machines

Status: done Source: PLAN.md 1a

Why

Each wizard component decides in the UI whether the primary button means "next step" or "submit", duplicating a rule the reducer already owns:

Component Line Body
herregistratie-wizard.component.ts 256-259 if (s.tag !== 'Editing') return; then s.step < 3 ? Next : Submit
intake-wizard.component.ts 368-371 if (s.tag !== 'Answering') return; then step() === 'review' ? Submit : Next
registratie-wizard.component.ts 613-616 if (s.tag !== 'Invullen') return; then step() === 'controle' ? Submit : Next

Each already-exported next/submit pair holds the real transition, so the component is re-deriving a decision the machine can make. After this ticket, onPrimary() in RD-08 becomes a single dispatch({ tag: 'Primary' }), and the wizard shell's primary/back/retry outputs map 1:1 onto messages — which is what .claude/skills/form-machine/SKILL.md:74-78 already claims they do.

This ticket is domain-only: machines and their specs. No component changes.

Read first

  • apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts — next at 87, submit at 118, reduce's Next/Submit cases at 165/171
  • apps/ssp/src/app/herregistratie/domain/intake.machine.ts — currentStep at 86, next at 173, submit at 197, cases at 226/232
  • apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts — currentStep at 96, next at 265, submit at 284, cases at 337/343
  • The three matching *.machine.spec.ts files
  • CLAUDE.md decision 3 (state, and the naming rule for machines)

Decisions (pre-made, don't relitigate)

  1. Add { tag: 'Primary' } to each of the three Msg unions, plus an exported primary(s) function, plus a case 'Primary' in each reduce that delegates to it. Same shape as the existing Next/Submit cases.

  2. Express "last step" in each machine's own vocabulary. Do NOT invent a shared isLastStep helper. The three state shapes genuinely differ:

    • herregistratie: Editing carries step: 1 | 2 | 3 and there is no STEPS array → the test is s.step === 3.
    • intake: Answering carries cursor: number against STEPS = ['buitenland','werk','review'] → the test is currentStep(s) === 'review'.
    • registratie: Invullen carries cursor: number against STEPS = ['adres','beroep','controle'] → the test is currentStep(s) === 'controle'.

    Two of the three could share a cursor === STEPS.length - 1 form, but the third cannot. A helper covering two of three, plus a special case, is more to read than three plain expressions.

  3. primary delegates to the existing exported next and submit. Write no new validation and duplicate no transition logic. The whole function is a branch:

    export function primary(s: WizardState): WizardState {
      if (s.tag !== 'Editing') return s;
      return s.step === 3 ? submit(s) : next(s);
    }
    

    (…and the equivalent, in its own vocabulary, for the other two.)

  4. The guard moves into the machine. primary returns s unchanged when the state is not the editing state, so RD-08 can delete the component preamble. Note the editing tag differs per machine: Editing, Answering, and Invullen. Invullen/Indienen/Ingediend/ Mislukt are correct Dutch domain tags per CLAUDE.md — do not "fix" them to English.

  5. KEEP Next and Submit in all three Msg unions. Verified: they are dispatched across 9 spec files, including intake.acceptance.spec.ts, which uses them as a readable behaviour narrative, and the three *-has-progress.spec.ts files. Removing them would rewrite dozens of spec lines for no gain. Primary is purely additive.

  6. Do not touch any component. RD-08 migrates the three wizards. If you edit a *.component.ts in this ticket, it is out of scope.

  7. Keep SCHOLING_THRESHOLD_DEFAULT greppable. npm run check:seam greps for it as a top-level export const in intake.machine.ts:43. Do not move or inline it.

Files

  • apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts + .spec.ts
  • apps/ssp/src/app/herregistratie/domain/intake.machine.ts + .spec.ts
  • apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts + .spec.ts

Steps

  1. In each machine: add { tag: 'Primary' } to the Msg union, export primary(s) next to next/submit, and add the case 'Primary' to reduce.
  2. In each machine spec: add the three cases from Acceptance below.
  3. Run npm run gen:behaviour-spec — new it() titles otherwise fail the drift check.
  4. Update this ticket's Status: to done and the README's RD-07 row to done.
  5. Commit all of it together.

Acceptance criteria

Three cases per machine spec (9 total), pure reduce calls, no TestBed:

- Primary advances to the next step from a non-final step
- Primary submits from the final step
- Primary is a no-op from a non-editing state

The third case is what lets RD-08 delete the component guard, so do not skip it.

npm test          # exits 0
npm run ci        # exits 0

Prove Primary produces exactly what the components produce today, so the migration in RD-08 is behaviour-preserving. For each machine, these must be equal:

reduce(s, { tag: 'Primary' })  ===  reduce(s, { tag: 'Next' })      // at a non-final step
reduce(s, { tag: 'Primary' })  ===  reduce(s, { tag: 'Submit' })    // at the final step

Prove nothing was removed:

grep -c "tag: 'Next'\|tag: 'Submit'" apps/ssp/src/app/herregistratie/domain/intake.machine.ts
# Next and Submit must still be in the union and still handled in reduce

Verification

npm run ci. This ticket touches no story, no .mdx and no component, so --full is not required.

Out of scope

  • The three components. RD-08.
  • besluit.machine.ts and change-request.machine.ts — single-step forms with no step boundary, so Primary would mean nothing there. RD-06 handles those two.
  • Removing Next/Submit (decision 5).
  • Renaming the Dutch tags in registratie-wizard.machine.ts (decision 4).

Risks

  • behaviour-spec.mdx drift. 9 new it() titles across 3 spec files. scripts/ci-local.sh regenerates libs/shared/docs/behaviour-spec.mdx from a path-sorted walk of spec titles and fails on any drift. Run npm run gen:behaviour-spec in the same commit.
  • check:seam greps SCHOLING_THRESHOLD_DEFAULT (decision 7) and besluit.machine.ts's BESLUIT_TAGS. Neither should move, but if check:seam fails, that is why.
  • snippets.generated.ts drift. intake.machine.ts:59 carries a // #region showcase:steps marker around STEPS. If your edit moves or splits that region, run npm run gen:snippets in the same commit.
  • Do not make primary clever. It is a three-line branch delegating to two existing functions. If it grows validation, error mapping, or a cursor calculation, the transition logic has been duplicated instead of reused.