Compare commits

..
Author SHA1 Message Date
ehoandClaude Opus 5 fc2a3c348b refactor: one member order for the 3 wizard containers (RD-38)
RD-22 and RD-23 brought the wizard containers under the 250-line budget, so
`max-lines` reports nothing. The files still read badly. Line count was never
the problem.

Fix three things in all three containers:

1. The member order was scrambled, and it differed per file. `registratie`
   declared `draftSync` in the middle of a run of `computed`s; `herregistratie`
   read `this.stepLabels.length` seven lines before `stepLabels` existed; the
   three files put the copy arrays in three different places. All three now use
   one nine-section order, so they compare side by side.
2. Pure logic sat in the container. Extract `digitalDocumentIds` into
   `upload.machine.ts` — the "digital and finished uploading" filter was
   written out four times, and it removes a `documentId!` assertion from both
   containers. Extract `diplomaMsg` into a sibling of the step files.
3. Comments carried archaeology. Drop the three RD-05 references and keep the
   rule. Drop "replaces sessionStorage" and the note about focus management that
   moved to the shell. Fix `intake`'s class comment, which claimed answers
   persist to sessionStorage and was contradicted 30 lines below.

`phase` deliberately stays in all three: it cannot live in `domain/`, and three
siblings plus three specs is a worse trade than 17 readable lines. The store ⇄
`draftSync` cycle also stays — both callbacks are deferred, so it is safe, and
one comment now names it.

No behaviour change. Member lists and every `private`/`protected`/`readonly`
modifier are unchanged, which the showcase depends on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 20:16:23 +02:00
ehoandClaude Opus 5 551cabce5e docs: add RD-37 — five a11y suppressions name a ticket that closed
Found while measuring RD-30, which archives the directory these references
point into.

Five stories disable the axe check with the reason "WP-11 reworks this
markup". WP-11 is done, and so is WP-13, the gap register it handed the
remainder to. No open ticket owns the defect, so the rule that a disabled
check must name the ticket that removes it holds only in letter.

The defect ships: `app-choice-link` and `app-aanvraag-block` put a component
host between the keuzelijst `<ul>` and its `<li>`, which breaks axe's
list/listitem rule. WP-11 solved the same problem for `application-link` by
making the host be the `<li>`, but `atomic-design.mdx` documents the current
split as deliberate — so whether that move fights the vendored CSS is the
question RD-37 must answer first.

Sequenced before RD-30.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 08:37:26 +02:00
ehoandClaude Sonnet 5 b6bd7eea5a refactor: enforce the atomic ladder in dependency-cruiser (RD-29)
Add three dependency-cruiser rules that forbid upward edges inside
libs/shared/src/ui: atoms-compose-nothing-above, molecules-below-organisms,
and design-system-not-layout. RD-27 made the atomic ladder expressible by
moving components into atoms/molecules/organisms folders; these rules make
it enforced, so an upward import now fails dep:check instead of shipping.

No pathNot exemption for specs and stories: measured, zero upward edges
exist anywhere in libs/shared/src/ui today, in production code, specs, and
stories alike, so the exemption PLAN proposed has nothing to protect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 08:32:52 +02:00
ehoandClaude Sonnet 5 d4c5a9450a fix: layer-tag mismatches + libs/beheer title rule (RD-28)
Two components disagreed with their own story title. async.component.ts
had no layer tag; add `Molecule:` to its class header, not to the
Convenience: comment on the ASYNC array (that comment describes an
export, not a layer, and stays as it is). breadcrumb.component.ts had
`Chrome:`; rename it to `Molecule:` to match its story title.

CLAUDE.md and layers.mdx both claimed `libs/beheer/ui` is Design System.
The code disagrees: its story title is `Domein/Beheer/...`, because
libs/beheer is a bounded context that lives under libs/ only because two
apps share it. Fix the two doc lines to match the code; the story title
does not change.

The ticket's own tag-count check asserted 68 after the edits, but adding
a new tag to async.component.ts (which had none) is a net +1 over the
Chrome-to-Molecule rename (a wash) — the true post-edit count is 69.
Corrected the number in the ticket text rather than deleting the
mandated tag to force the wrong count. Also fixed pre-existing invalid
nested-backtick markdown in the ticket's decision 4 bullets: prettier
mis-rewrapped it and dropped spaces, so the phrasing was rewritten in
valid CommonMark with no change in meaning.

npm run ci --full passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 08:26:47 +02:00
20 changed files with 703 additions and 123 deletions
+23
View File
@@ -109,6 +109,29 @@ module.exports = function buildConfig(contextAllowed, appName, tsConfigFileName)
}, },
}, },
// --- Atomic ladder within libs/shared/src/ui (folder = layer, CLAUDE.md decision 2) ---
{
name: 'atoms-compose-nothing-above',
comment: 'An atom composes nothing above it — no molecule or organism. See CLAUDE.md §2.',
severity: 'error',
from: { path: '^libs/shared/src/ui/atoms/' },
to: { path: '^libs/shared/src/ui/(molecules|organisms)/' },
},
{
name: 'molecules-below-organisms',
comment: 'A molecule composes nothing above it — no organism. See CLAUDE.md §2.',
severity: 'error',
from: { path: '^libs/shared/src/ui/molecules/' },
to: { path: '^libs/shared/src/ui/organisms/' },
},
{
name: 'design-system-not-layout',
comment: 'The design system (ui/) does not depend on layout/ templates. See CLAUDE.md §2.',
severity: 'error',
from: { path: '^libs/shared/src/ui/' },
to: { path: '^libs/shared/src/layout/' },
},
{ {
name: 'no-testing-in-production', name: 'no-testing-in-production',
comment: comment:
+4 -4
View File
@@ -210,10 +210,10 @@ each app has its **own Storybook instance** (`.storybook-ssp/`, `.storybook-beha
WP-67 — a single merged tsconfig can't resolve both apps' `@auth/*` at once), each globbing WP-67 — a single merged tsconfig can't resolve both apps' `@auth/*` at once), each globbing
its own app's stories plus both shared libraries'. **Story titles mirror the sidebar's its own app's stories plus both shared libraries'. **Story titles mirror the sidebar's
Design System/Domein split** (see `libs/shared/docs/layers.mdx`): a `libs/shared/ui|layout` Design System/Domein split** (see `libs/shared/docs/layers.mdx`): a `libs/shared/ui|layout`
or `libs/beheer/ui` component is titled `Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>`; component is titled `Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>`;
a component in an app context's `ui/` is titled `Domein/<Context>/<Name>` — full stop, a component in an app context's `ui/`, or in `libs/beheer/ui`, is titled
regardless of which atomic layer it is (a context organism doesn't get its own `Domein/<Context>/<Name>` — full stop, regardless of which atomic layer it is (a context
`Organisms/` bucket). organism doesn't get its own `Organisms/` bucket).
## Conventions ## Conventions
@@ -7,6 +7,7 @@ import {
reduceUpload, reduceUpload,
requiredCategoriesSatisfied, requiredCategoriesSatisfied,
deliveryRefs, deliveryRefs,
digitalDocumentIds,
} from '@shared/domain/upload.machine'; } from '@shared/domain/upload.machine';
/** What the user is typing (raw, possibly invalid). */ /** What the user is typing (raw, possibly invalid). */
@@ -53,7 +54,7 @@ export function hasProgress(s: Extract<WizardState, { tag: 'Editing' }>): boolea
!!s.draft.uren || !!s.draft.uren ||
!!s.draft.jaren || !!s.draft.jaren ||
!!s.draft.punten || !!s.draft.punten ||
deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId) digitalDocumentIds(s.upload).length > 0
); );
} }
@@ -25,7 +25,7 @@ import {
import { createDraftSync } from '@registratie/application/draft-sync'; import { createDraftSync } from '@registratie/application/draft-sync';
import { DocumentUploadComponent } from '@shared/ui/organisms/upload/document-upload/document-upload.component'; import { DocumentUploadComponent } from '@shared/ui/organisms/upload/document-upload/document-upload.component';
import { createUploadController } from '@shared/application/upload-controller'; import { createUploadController } from '@shared/application/upload-controller';
import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.machine'; import { UploadState, initialUpload, digitalDocumentIds } from '@shared/domain/upload.machine';
/** Organism: multi-step herregistratie wizard. ALL state lives in one signal /** Organism: multi-step herregistratie wizard. ALL state lives in one signal
driven by the pure `reduce` function (see herregistratie.machine.ts) via an driven by the pure `reduce` function (see herregistratie.machine.ts) via an
@@ -148,8 +148,14 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.
}) })
export class HerregistratieWizardComponent { export class HerregistratieWizardComponent {
private profile = inject(BigProfileStore); private profile = inject(BigProfileStore);
// Effect fires once, on Editing -> Submitting (RD-05's tag-transition rule; `Seed` is
// exempt, so a story mounting straight into `Submitting` does not call the network). /** Optional seed so Storybook / the showcase can mount any state directly. */
seed = input<WizardState>(initial);
// --- The store: all state in one signal, changed only by a pure reduce -----
// The effect fires once, on the `Editing -> Submitting` transition. `Seed` is exempt,
// so a story that mounts straight into `Submitting` does not call the network.
// `draftSync` is declared below (both callbacks are deferred, so the cycle is safe).
private store = createStore<WizardState, WizardMsg>(initial, reduce, { private store = createStore<WizardState, WizardMsg>(initial, reduce, {
Submitting: async (s, store) => { Submitting: async (s, store) => {
this.profile.beginHerregistratie(); this.profile.beginHerregistratie();
@@ -163,36 +169,10 @@ export class HerregistratieWizardComponent {
} }
}, },
}); });
/** Preview/download link for a completed upload; delegates to the upload
controller (application layer), which knows the dev-simulation `demo-*` ids
have no stored bytes and returns no link for them. */
protected previewUrlFor = (documentId: string): string | undefined =>
this.uploadCtl.previewUrlFor(documentId);
/** Optional seed so Storybook / the showcase can mount any state directly. */
seed = input<WizardState>(initial);
readonly state = this.store.model; // public so the showcase can highlight the live state readonly state = this.store.model; // public so the showcase can highlight the live state
protected dispatch = this.store.dispatch; protected dispatch = this.store.dispatch;
// Backend draft-sync (new persistence for this wizard): create a Concept on first // --- Static copy: stepper labels and per-step headings ---------------------
// progress, debounced-sync the snapshot, resume by `?aanvraag=<id>`.
private draftSync = createDraftSync({
type: 'herregistratie',
snapshot: () => {
const s = this.state();
if (s.tag !== 'Editing' || !hasProgress(s)) return null;
const documentIds = deliveryRefs(s.upload)
.filter((r) => r.channel === 'digital' && r.documentId)
.map((r) => r.documentId!);
return { draft: s, stepIndex: s.step - 1, stepCount: this.stepLabels.length, documentIds };
},
onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as WizardState }),
enabled: () => this.seed() === initial,
});
// Stepper labels + per-step heading titles (presentational only).
readonly stepLabels = [ readonly stepLabels = [
$localize`:@@herregWizard.step.werkervaring:Werkervaring`, $localize`:@@herregWizard.step.werkervaring:Werkervaring`,
$localize`:@@herregWizard.step.nascholing:Nascholing`, $localize`:@@herregWizard.step.nascholing:Nascholing`,
@@ -204,6 +184,7 @@ export class HerregistratieWizardComponent {
$localize`:@@herregWizard.title.documenten:Documenten aanleveren`, $localize`:@@herregWizard.title.documenten:Documenten aanleveren`,
]; ];
// --- State projections: one narrow, then read-only views of it -------------
private editing = computed(() => whenTag(this.state(), 'Editing')); private editing = computed(() => whenTag(this.state(), 'Editing'));
protected step = computed(() => this.editing()?.step ?? 1); protected step = computed(() => this.editing()?.step ?? 1);
protected draft = computed<Draft>( protected draft = computed<Draft>(
@@ -214,11 +195,35 @@ export class HerregistratieWizardComponent {
protected errJaren = computed(() => this.editing()?.errors.jaren ?? ''); protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');
protected errPunten = computed(() => this.editing()?.errors.punten ?? ''); protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
protected errDocumenten = computed(() => this.editing()?.errors.documenten ?? ''); protected errDocumenten = computed(() => this.editing()?.errors.documenten ?? '');
// --- Controllers: persistence and uploads ----------------------------------
// Create a Concept on first progress, then debounced-sync the snapshot.
// `?aanvraag=<id>` resumes it.
private draftSync = createDraftSync({
type: 'herregistratie',
snapshot: () => {
const s = this.state();
if (s.tag !== 'Editing' || !hasProgress(s)) return null;
return {
draft: s,
stepIndex: s.step - 1,
stepCount: this.stepLabels.length,
documentIds: digitalDocumentIds(s.upload),
};
},
onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as WizardState }),
enabled: () => this.seed() === initial,
});
protected uploadCtl = createUploadController({ protected uploadCtl = createUploadController({
wizardId: 'herregistratie', wizardId: 'herregistratie',
getUpload: () => this.upload(), getUpload: () => this.upload(),
dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }), dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }),
}); });
/** Preview/download link for a completed upload; delegates to the upload
controller (application layer), which knows the dev-simulation `demo-*` ids
have no stored bytes and returns no link for them. */
protected previewUrlFor = (documentId: string): string | undefined =>
this.uploadCtl.previewUrlFor(documentId);
// --- Presentational wiring for the shared wizard shell --------------------- // --- Presentational wiring for the shared wizard shell ---------------------
protected stepTitle = computed(() => this.stepTitles[this.step() - 1]); protected stepTitle = computed(() => this.stepTitles[this.step() - 1]);
@@ -228,11 +233,6 @@ export class HerregistratieWizardComponent {
? naarStapLabel(step + 1, this.stepLabels[step]) ? naarStapLabel(step + 1, this.stepLabels[step])
: $localize`:@@herregWizard.indienen:Herregistratie aanvragen`; : $localize`:@@herregWizard.indienen:Herregistratie aanvragen`;
}); });
/** Stepper emits a 0-based index for an earlier (visited) step. */
protected goToStep(index: number) {
this.dispatch({ tag: 'GaNaarStap', step: (index + 1) as 1 | 2 | 3 });
}
/** Maps this machine's own tags onto the shell's `WizardPhase` vocabulary, /** Maps this machine's own tags onto the shell's `WizardPhase` vocabulary,
composing the localized failure prefix so the `Failed` message arrives intact. */ composing the localized failure prefix so the `Failed` message arrives intact. */
protected phase = computed<WizardPhase>(() => { protected phase = computed<WizardPhase>(() => {
@@ -254,6 +254,12 @@ export class HerregistratieWizardComponent {
/** Current step's field errors, flattened for the shell's error summary. */ /** Current step's field errors, flattened for the shell's error summary. */
protected errorList = computed<WizardError[]>(() => toWizardErrors(this.editing()?.errors ?? {})); protected errorList = computed<WizardError[]>(() => toWizardErrors(this.editing()?.errors ?? {}));
// --- Event handlers: narrow a child event into a message -------------------
/** Stepper emits a 0-based index for an earlier (visited) step. */
protected goToStep(index: number) {
this.dispatch({ tag: 'GaNaarStap', step: (index + 1) as 1 | 2 | 3 });
}
constructor() { constructor() {
// An explicit seed (stories/tests) wins; otherwise resume the backend draft // An explicit seed (stories/tests) wins; otherwise resume the backend draft
// (`?aanvraag=<id>`) or start fresh. Persistence is the draftSync controller's job. // (`?aanvraag=<id>`) or start fresh. Persistence is the draftSync controller's job.
@@ -32,8 +32,9 @@ import { ReviewStep } from './review.step';
/** Organism: a BRANCHING intake questionnaire. All state lives in one signal /** Organism: a BRANCHING intake questionnaire. All state lives in one signal
driven by the pure `reduce` (intake.machine.ts). Which step renders is derived driven by the pure `reduce` (intake.machine.ts). Which step renders is derived
from the answers via `visibleSteps`, never stored — so editing an earlier from the answers via `visibleSteps`, never stored — so editing an earlier
answer immediately changes the remaining steps. Answers are persisted to answer immediately changes the remaining steps. The draft persists to the
sessionStorage so a page reload keeps the user's progress (cleared on tab close). */ backend as a Concept aanvraag (createDraftSync), so a reload — or a "Verder
gaan" from the dashboard via `?aanvraag=<id>` — resumes progress. */
@Component({ @Component({
selector: 'app-intake-wizard', selector: 'app-intake-wizard',
imports: [ imports: [
@@ -106,8 +107,14 @@ export class IntakeWizardComponent {
// Server-owned policy (scholing threshold): fetched from the backend via the // Server-owned policy (scholing threshold): fetched from the backend via the
// application facade, not hardcoded. The backend stays the authority on submit. // application facade, not hardcoded. The backend stays the authority on submit.
private policyStore = inject(IntakePolicyStore); private policyStore = inject(IntakePolicyStore);
// Effect fires once, on Answering -> Submitting (RD-05's tag-transition rule; `Seed` is
// exempt, so a story mounting straight into `Submitting` does not call the network). /** Optional seed so Storybook / the showcase can mount any state directly. */
seed = input<IntakeState>(initial);
// --- The store: all state in one signal, changed only by a pure reduce -----
// The effect fires once, on the `Answering -> Submitting` transition. `Seed` is exempt,
// so a story that mounts straight into `Submitting` does not call the network.
// `draftSync` is declared below (both callbacks are deferred, so the cycle is safe).
private store = createStore<IntakeState, IntakeMsg>(initial, reduce, { private store = createStore<IntakeState, IntakeMsg>(initial, reduce, {
Submitting: async (s, store) => { Submitting: async (s, store) => {
this.profile.beginHerregistratie(); this.profile.beginHerregistratie();
@@ -129,24 +136,22 @@ export class IntakeWizardComponent {
}, },
}); });
/** Optional seed so Storybook / the showcase can mount any state directly. */
seed = input<IntakeState>(initial);
readonly state = this.store.model; readonly state = this.store.model;
readonly dispatch = this.store.dispatch; readonly dispatch = this.store.dispatch;
// Backend draft-sync (replaces sessionStorage); the intake has no uploads. // --- Static copy: stepper labels and per-step headings ---------------------
private draftSync = createDraftSync({ readonly stepLabels = [
type: 'intake', $localize`:@@intake.step.buitenland:Buitenland`,
snapshot: () => { $localize`:@@intake.step.werk:Werk`,
const s = this.state(); $localize`:@@intake.step.controle:Controle`,
if (s.tag !== 'Answering' || !hasProgress(s)) return null; ];
return { draft: s, stepIndex: s.cursor, stepCount: STEPS.length, documentIds: [] }; private stepTitles: Record<StepId, string> = {
}, buitenland: $localize`:@@intake.title.buitenland:Werken in het buitenland`,
onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as IntakeState }), werk: $localize`:@@intake.title.werk:Werkervaring in Nederland`,
enabled: () => this.seed() === initial, review: $localize`:@@intake.title.review:Controleren en indienen`,
}); };
// --- State projections: one narrow, then read-only views of it -------------
private answering = computed(() => whenTag(this.state(), 'Answering')); private answering = computed(() => whenTag(this.state(), 'Answering'));
/** Public so the showcase can render the (fixed) step list next to the wizard. */ /** Public so the showcase can render the (fixed) step list next to the wizard. */
readonly steps = STEPS; readonly steps = STEPS;
@@ -159,17 +164,21 @@ export class IntakeWizardComponent {
); );
protected errors = computed<Errors>(() => this.answering()?.errors ?? {}); protected errors = computed<Errors>(() => this.answering()?.errors ?? {});
// --- Controllers: persistence and uploads ----------------------------------
// Create a Concept on first progress, then debounced-sync the snapshot.
// `?aanvraag=<id>` resumes it. The intake has no uploads.
private draftSync = createDraftSync({
type: 'intake',
snapshot: () => {
const s = this.state();
if (s.tag !== 'Answering' || !hasProgress(s)) return null;
return { draft: s, stepIndex: s.cursor, stepCount: STEPS.length, documentIds: [] };
},
onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as IntakeState }),
enabled: () => this.seed() === initial,
});
// --- Presentational wiring for the shared wizard shell --------------------- // --- Presentational wiring for the shared wizard shell ---------------------
readonly stepLabels = [
$localize`:@@intake.step.buitenland:Buitenland`,
$localize`:@@intake.step.werk:Werk`,
$localize`:@@intake.step.controle:Controle`,
];
private stepTitles: Record<StepId, string> = {
buitenland: $localize`:@@intake.title.buitenland:Werken in het buitenland`,
werk: $localize`:@@intake.title.werk:Werkervaring in Nederland`,
review: $localize`:@@intake.title.review:Controleren en indienen`,
};
protected stepTitle = computed(() => this.stepTitles[this.step()]); protected stepTitle = computed(() => this.stepTitles[this.step()]);
protected primaryLabel = computed(() => { protected primaryLabel = computed(() => {
if (this.step() === 'review') return $localize`:@@intake.indienen:Aanvraag indienen`; if (this.step() === 'review') return $localize`:@@intake.indienen:Aanvraag indienen`;
@@ -9,6 +9,7 @@ import {
reduceUpload, reduceUpload,
requiredCategoriesSatisfied, requiredCategoriesSatisfied,
deliveryRefs, deliveryRefs,
digitalDocumentIds,
} from '@shared/domain/upload.machine'; } from '@shared/domain/upload.machine';
/** /**
@@ -109,7 +110,7 @@ export function hasProgress(s: Extract<RegistratieState, { tag: 'Invullen' }>):
!!d.email || !!d.email ||
!!d.diplomaId || !!d.diplomaId ||
!!d.beroep || !!d.beroep ||
deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId) digitalDocumentIds(s.upload).length > 0
); );
} }
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { DuoLookupDto } from '@registratie/contracts/duo-diplomas.dto';
import { diplomaMsg } from './diploma-msg';
import { HANDMATIG } from './beroep.step';
const data: DuoLookupDto = {
diplomas: [
{
id: 'd1',
naam: 'Verpleegkunde',
instelling: 'Hogeschool Utrecht',
jaar: 2019,
beroep: 'Verpleegkundige',
policyQuestions: [
{ id: 'q1', vraag: 'Vraag 1', type: 'ja-nee' },
{ id: 'q2', vraag: 'Vraag 2', type: 'tekst' },
],
},
],
handmatig: {
beroepen: ['Verpleegkundige', 'Arts'],
policyQuestions: [{ id: 'm1', vraag: 'Handmatige vraag', type: 'ja-nee' }],
},
};
describe('diplomaMsg', () => {
it('resolves a known diploma into KiesDiploma with the server-derived beroep', () => {
expect(diplomaMsg(data, 'd1')).toEqual({
tag: 'KiesDiploma',
diplomaId: 'd1',
beroep: 'Verpleegkundige',
vraagIds: ['q1', 'q2'],
});
});
it('resolves the manual sentinel into KiesHandmatig with the maximal question set', () => {
expect(diplomaMsg(data, HANDMATIG)).toEqual({ tag: 'KiesHandmatig', vraagIds: ['m1'] });
});
it('returns null for an unknown diploma id', () => {
expect(diplomaMsg(data, 'onbekend')).toBeNull();
});
});
@@ -0,0 +1,28 @@
import { DuoLookupDto } from '@registratie/contracts/duo-diplomas.dto';
import { RegistratieMsg } from '@registratie/domain/registratie-wizard.machine';
import { HANDMATIG } from './beroep.step';
/**
* Resolve the diploma that the user picked into the machine message it implies.
*
* The DUO payload maps a diploma id onto a server-derived beroep and the policy
* questions that apply to it. Reading that map is message construction, so it
* belongs beside the container, not in the beroep step. The backend stays the
* authority on both values (ADR-0001) — this function only selects them.
*
* Returns null when the id matches no known diploma. The caller then dispatches
* nothing and the wizard keeps its current state.
*/
export function diplomaMsg(data: DuoLookupDto, id: string): RegistratieMsg | null {
if (id === HANDMATIG) {
return { tag: 'KiesHandmatig', vraagIds: data.handmatig.policyQuestions.map((q) => q.id) };
}
const diploma = data.diplomas.find((d) => d.id === id);
if (!diploma) return null;
return {
tag: 'KiesDiploma',
diplomaId: diploma.id,
beroep: diploma.beroep,
vraagIds: diploma.policyQuestions.map((q) => q.id),
};
}
@@ -26,17 +26,18 @@ import {
STEPS, STEPS,
} from '@registratie/domain/registratie-wizard.machine'; } from '@registratie/domain/registratie-wizard.machine';
import { createDraftSync } from '@registratie/application/draft-sync'; import { createDraftSync } from '@registratie/application/draft-sync';
import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.machine'; import { UploadState, initialUpload, digitalDocumentIds } from '@shared/domain/upload.machine';
import { AdresStep } from './adres.step'; import { AdresStep } from './adres.step';
import { BeroepStep, HANDMATIG } from './beroep.step'; import { BeroepStep } from './beroep.step';
import { ControleStep } from './controle.step'; import { ControleStep } from './controle.step';
import { diplomaMsg } from './diploma-msg';
/** Organism: the BIG-registration wizard. All state lives in one signal driven by /** Organism: the BIG-registration wizard. All state lives in one signal driven by
the pure `reduce` (registratie-wizard.machine.ts). The BRP address prefills the the pure `reduce` (registratie-wizard.machine.ts). The BRP address prefills the
draft via an effect; the DUO diploma list renders through <app-async>; choosing draft via an effect; the DUO diploma list renders through <app-async>; choosing
a diploma reveals its server-derived beroep. The draft is persisted to the a diploma reveals its server-derived beroep. The draft persists to the backend
backend as a Concept aanvraag (createDraftSync) so a reload — or a"Verder gaan" as a Concept aanvraag (createDraftSync), so a reload — or a "Verder gaan" from
from the dashboard via `?aanvraag=<id>` — resumes progress. Built from existing the dashboard via `?aanvraag=<id>` — resumes progress. Built from existing
atoms/molecules. */ atoms/molecules. */
@Component({ @Component({
selector: 'app-registratie-wizard', selector: 'app-registratie-wizard',
@@ -117,8 +118,14 @@ import { ControleStep } from './controle.step';
}) })
export class RegistratieWizardComponent { export class RegistratieWizardComponent {
private lookup = inject(RegistratieLookupStore); private lookup = inject(RegistratieLookupStore);
// Effect fires once, on Invullen -> Indienen (RD-05's tag-transition rule; `Seed` is
// exempt, so a story mounting straight into `Indienen` does not call the network). /** Optional seed so Storybook / tests can mount any state directly. */
seed = input<RegistratieState>(initial);
// --- The store: all state in one signal, changed only by a pure reduce -----
// The effect fires once, on the `Invullen -> Indienen` transition. `Seed` is exempt,
// so a story that mounts straight into `Indienen` does not call the network.
// `draftSync` is declared below (both callbacks are deferred, so the cycle is safe).
private store = createStore<RegistratieState, RegistratieMsg>(initial, reduce, { private store = createStore<RegistratieState, RegistratieMsg>(initial, reduce, {
Indienen: async (s, store) => { Indienen: async (s, store) => {
const r = await this.draftSync.submit({ const r = await this.draftSync.submit({
@@ -129,50 +136,56 @@ export class RegistratieWizardComponent {
else store.dispatch({ tag: 'SubmitFailed', error: r.error }); else store.dispatch({ tag: 'SubmitFailed', error: r.error });
}, },
}); });
readonly state = this.store.model;
readonly dispatch = this.store.dispatch;
/** Optional seed so Storybook / tests can mount any state directly. */ // --- Static copy: stepper labels and per-step headings ---------------------
seed = input<RegistratieState>(initial);
readonly stepLabels = [ readonly stepLabels = [
$localize`:@@regWizard.step.adres:Adres`, $localize`:@@regWizard.step.adres:Adres`,
$localize`:@@regWizard.step.beroep:Beroep`, $localize`:@@regWizard.step.beroep:Beroep`,
$localize`:@@regWizard.step.controle:Controle`, $localize`:@@regWizard.step.controle:Controle`,
]; // short labels for the stepper ];
private stepTitles = [ private stepTitles = [
$localize`:@@regWizard.title.adres:Adres en correspondentievoorkeur`, $localize`:@@regWizard.title.adres:Adres en correspondentievoorkeur`,
$localize`:@@regWizard.title.beroep:Beroep op basis van uw diploma`, $localize`:@@regWizard.title.beroep:Beroep op basis van uw diploma`,
$localize`:@@regWizard.title.controle:Controleren en indienen`, $localize`:@@regWizard.title.controle:Controleren en indienen`,
]; ];
readonly state = this.store.model;
readonly dispatch = this.store.dispatch;
// --- State projections: one narrow, then read-only views of it -------------
private invullen = computed(() => whenTag(this.state(), 'Invullen')); private invullen = computed(() => whenTag(this.state(), 'Invullen'));
protected cursor = computed(() => this.invullen()?.cursor ?? 0); protected cursor = computed(() => this.invullen()?.cursor ?? 0);
protected draft = computed<Draft>(() => this.invullen()?.draft ?? { antwoorden: {} }); protected draft = computed<Draft>(() => this.invullen()?.draft ?? { antwoorden: {} });
protected errors = computed<Errors>(() => this.invullen()?.errors ?? {}); protected errors = computed<Errors>(() => this.invullen()?.errors ?? {});
protected upload = computed<UploadState>(() => this.invullen()?.upload ?? initialUpload); protected upload = computed<UploadState>(() => this.invullen()?.upload ?? initialUpload);
// Backend draft-sync (replaces sessionStorage): create a Concept once the user has protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
// made progress, then debounced-sync the whole machine snapshot; resume by `?aanvraag`. protected referentie = computed(() => whenTag(this.state(), 'Ingediend')?.referentie ?? '');
/** From the lookup store, not the machine: the beroep step renders it, and
`onDiplomaKeuze` reads it to resolve the picked id into a message. */
protected duoData = computed<DuoLookupDto | null>(() => successOr(this.lookup.duoLookup(), null));
// --- Controllers: persistence and uploads ----------------------------------
// Create a Concept once the user has made progress, then debounced-sync the whole
// machine snapshot. `?aanvraag=<id>` resumes it.
private draftSync = createDraftSync({ private draftSync = createDraftSync({
type: 'registratie', type: 'registratie',
snapshot: () => { snapshot: () => {
const s = this.state(); const s = this.state();
if (s.tag !== 'Invullen' || !hasProgress(s)) return null; if (s.tag !== 'Invullen' || !hasProgress(s)) return null;
const documentIds = deliveryRefs(s.upload) return {
.filter((r) => r.channel === 'digital' && r.documentId) draft: s,
.map((r) => r.documentId!); stepIndex: s.cursor,
return { draft: s, stepIndex: s.cursor, stepCount: STEPS.length, documentIds }; stepCount: STEPS.length,
documentIds: digitalDocumentIds(s.upload),
};
}, },
onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as RegistratieState }), onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as RegistratieState }),
enabled: () => this.seed() === initial, enabled: () => this.seed() === initial,
}); });
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
// --- Presentational wiring for the shared wizard shell ---------------------
protected stepTitle = computed( protected stepTitle = computed(
() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)], () => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)],
); );
protected referentie = computed(() => whenTag(this.state(), 'Ingediend')?.referentie ?? '');
// --- Presentational wiring for the shared wizard shell ---------------------
protected primaryLabel = computed(() => { protected primaryLabel = computed(() => {
if (this.step() === 'controle') return $localize`:@@regWizard.indienen:Registratie indienen`; if (this.step() === 'controle') return $localize`:@@regWizard.indienen:Registratie indienen`;
const next = this.cursor() + 1; const next = this.cursor() + 1;
@@ -203,30 +216,12 @@ export class RegistratieWizardComponent {
return [...toWizardErrors(e), ...toWizardErrors(e.antwoorden ?? {}, 'vraag-')]; return [...toWizardErrors(e), ...toWizardErrors(e.antwoorden ?? {}, 'vraag-')];
}); });
/** Parsed lookup as a plain value (or null) — needed here only to resolve // --- Event handlers: narrow a child event into a message -------------------
`onDiplomaKeuze`'s message from an id (the DUO payload maps an id to a
beroep and its question ids; that is machine-message construction, and it
belongs in the container, not the beroep step). */
protected duoData = computed<DuoLookupDto | null>(() => successOr(this.lookup.duoLookup(), null));
protected onDiplomaKeuze(id: string) { protected onDiplomaKeuze(id: string) {
const data = this.duoData(); const data = this.duoData();
if (!data) return; if (!data) return;
if (id === HANDMATIG) { const msg = diplomaMsg(data, id);
this.dispatch({ if (msg) this.dispatch(msg);
tag: 'KiesHandmatig',
vraagIds: data.handmatig.policyQuestions.map((q) => q.id),
});
return;
}
const d = data.diplomas.find((x) => x.id === id);
if (d)
this.dispatch({
tag: 'KiesDiploma',
diplomaId: d.id,
beroep: d.beroep,
vraagIds: d.policyQuestions.map((q) => q.id),
});
} }
/** Narrows the beroep step's plain-string `kanaalChange` into the machine's /** Narrows the beroep step's plain-string `kanaalChange` into the machine's
@@ -260,8 +255,6 @@ export class RegistratieWizardComponent {
}); });
}); });
}); });
// A11y: focus management (step heading on step change, error summary on a
// failed submit) now lives in the shared WizardShellComponent.
} }
/** Reset the wizard to a fresh start. Reload the BRP lookup so the address /** Reset the wizard to a fresh start. Reload the BRP lookup so the address
+28
View File
@@ -787,6 +787,34 @@ than ESLint: it is where every other boundary rule lives and it emits the archit
## Phase 5 — Fix the docs that describe this flow ## Phase 5 — Fix the docs that describe this flow
0. **First, RD-37 — five accessibility suppressions point at a ticket that closed.** Found
while measuring RD-30, because archiving `backlog/` would bury the reference.
Five stories carry `a11y: { disable: true }` whose reason reads "WP-11 (CIBG markup
fidelity) reworks this markup" — future tense. **WP-11 is `Status: done`**, and so is
WP-13, the gap register it hands the remainder to. No open ticket owns the defect, so the
README's rule ("no check disabled without a reference to the ticket that removes it") holds
only in letter.
The defect is real and shipped, not story-only: `app-choice-link` and `app-aanvraag-block`
render a component host between the keuzelijst `<ul>` and its `<li>`, which breaks axe's
`list`/`listitem` rule for assistive technology. `display: contents` does not fix it.
WP-11 solved exactly this for `application-link` by making the host **be** the `<li>`
(`selector: 'li[app-application-link]'`), which is axe-clean today. The same move is
available here — but it is **not** obviously correct, and that is why this is a ticket
rather than a one-line change: `atomic-design.mdx:113` documents the current split as
deliberate, "different list/host semantics … Merging would fight the vendored CSS".
So RD-37 must decide one question before it writes any code: **does giving `choice-link` and
`aanvraag-block` an `li[…]` attribute host still match the vendored CIBG keuzelijst CSS?**
If yes, convert both, delete the five suppressions, and correct `atomic-design.mdx`'s claim.
If no, the honest outcome is a new open ticket named in five rewritten reasons — not a
pointer into an archive.
**Sequence RD-37 before RD-30**, so the archive move does not have to rewrite five paths
that are about to disappear.
1. **Archive the finished backlog.** `git mv docs/project/backlog` and 1. **Archive the finished backlog.** `git mv docs/project/backlog` and
`docs/project/refactor-backlog-setup` under `docs/project/archive/`. Verified: **all 74 `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 WP files are `Status: done`**; the two trees are 6,982 + 9,318 = **16,300 of the docs
@@ -0,0 +1,154 @@
# RD-28 — Two layer-tag fixes, and the `libs/beheer` title rule
Status: done
Source: PLAN.md 4a (the two mislabels) and 4b (keep the tags)
## Why
RD-27 made the folder equal the layer. Three statements about a component's layer now exist —
the folder, the header comment's tag, and the story title — and a three-way agreement that has
never once disagreed is cheap documentation.
It disagrees in exactly two places. And one repository-wide rule in CLAUDE.md is simply wrong
about `libs/beheer`.
## Read first
- `libs/shared/src/ui/molecules/async/async.component.ts:45` — the class header, with no layer
tag; and line 154, the `Convenience:` comment on the `ASYNC` array, which is **correct and
stays**.
- `libs/shared/src/layout/breadcrumb/breadcrumb.component.ts:9` — `/** Chrome: …`, against a
story titled `Design System/Molecules/Breadcrumb`.
- `CLAUDE.md:213` and `libs/shared/docs/layers.mdx:13` — the two lines that claim
`libs/beheer/ui` is Design System.
- PLAN.md 4a's third paragraph and 4b.
## Decisions (pre-made, don't relitigate)
1. **Exactly two components are mislabelled. Verified by scanning all 36**, comparing each
component's first layer tag against its own story title:
| File | Story title | Tag today | Becomes |
| ------------------------------------------- | ----------- | --------- | ----------- |
| `ui/molecules/async/async.component.ts` | Molecules | _(none)_ | `Molecule:` |
| `layout/breadcrumb/breadcrumb.component.ts` | Molecules | `Chrome:` | `Molecule:` |
Every other component already agrees. Do not "tidy" any other tag.
2. **`async.component.ts` gets the tag added to its class header at line 45, not to line 154.**
The `Convenience:` comment at 154 describes the `ASYNC` array export — "import this array to
get the wrapper + all slot directives" — which is an accurate description of a convenience
export, not a layer claim. Leave it exactly as it is.
3. **Keep all 68 layer-tag comments. Delete none.** PLAN 4b reversed an earlier proposal to
strip them: the tag prefixes a real one-line description, so removing the word leaves the
sentence and buys nothing. Only the tags in `libs/shared/src/ui/` are made redundant by the
new folders; the ones in `apps/**/ui/` are the sole carrier of the layer, because a context
component's title deliberately omits it.
4. **`libs/beheer` is Domein, not Design System. The docs are wrong; the code is right.**
`stamdata-table-editor.stories.ts` is titled `Domein/Beheer/Stamdata Table Editor`, and that
is correct: `libs/beheer` **is** a bounded context, which lives in `libs/` only because two
apps share it. It is not part of the design system, and it correctly has no layer folders.
Amend the two doc lines that say otherwise:
- `CLAUDE.md:213` — drop the words "or `libs/beheer/ui`" from the Design System title rule,
and say that a `libs/beheer/ui` component is titled `Domein/Beheer/<Name>`.
- `libs/shared/docs/layers.mdx:13` — drop the parenthetical "(or `libs/beheer/ui`)", and put
`libs/beheer` on the Domein side of the same sentence.
**Do not change the story title.** This ticket fixes the documentation to match the code.
5. **No code behaviour changes anywhere.** Two comment words and two doc sentences.
## Files
- `libs/shared/src/ui/molecules/async/async.component.ts`
- `libs/shared/src/layout/breadcrumb/breadcrumb.component.ts`
- `CLAUDE.md`
- `libs/shared/docs/layers.mdx`
## Steps
1. Add `Molecule: ` to `async.component.ts`'s class header (decision 2).
2. Change `Chrome:` to `Molecule:` in `breadcrumb.component.ts`.
3. Amend `CLAUDE.md:213` and `layers.mdx:13` per decision 4.
4. `git add -A`, then run the acceptance commands.
5. Update this ticket's `Status:` to `done`, the README's RD-28 row to `done`, and that row's
`--full`? column to `yes` (see Verification).
6. Commit all of it together.
## Acceptance criteria
Measured against the tree before handover.
**The real check is the scan itself.** Run it; it must print nothing but `--- done`:
```bash
for f in $(git ls-files 'libs/shared/src/ui/**/*.component.ts' 'libs/shared/src/layout/**/*.component.ts'); do
d=$(dirname $f)
title=$(grep -ho "title: 'Design System/[A-Za-z]*" $d/*.stories.ts 2>/dev/null | head -1 | sed "s/.*Design System\///")
tag=$(grep -oE "\b(Atom|Molecule|Organism|Template|Chrome|Convenience|Devtool)s?:" $f | head -1 | tr -d ':')
exp=$(echo "$title" | sed 's/s$//')
[ "$tag" = "$exp" ] || echo "MISMATCH $f | title=${title:-NONE} | tag=${tag:-NONE}"
done
echo "--- done"
```
The two edits, and the comment that must survive (decisions 1 and 2):
```bash
git grep -c "Chrome:" -- libs/shared/src/layout/breadcrumb/breadcrumb.component.ts # is 1 -> MUST be 0
git grep -c "Molecule:" -- libs/shared/src/ui/molecules/async/async.component.ts # is 0 -> MUST be 1
git grep -c "Convenience:" -- libs/shared/src/ui/molecules/async/async.component.ts # is 1 -> MUST still be 1
```
Nothing was stripped (decision 3):
```bash
git grep -ohE "\b(Atom|Molecule|Organism|Template|Chrome|Convenience|Devtool)s?:" -- libs apps | wc -l # is 68 before the edits -> MUST be 69 after
```
Note: the pre-edit tree measures 68. Decision 2 adds a brand-new `Molecule:` tag to
`async.component.ts` (which had no tag at all), a net +1; the `Chrome:` → `Molecule:`
rename in `breadcrumb.component.ts` is a wash. 68 unchanged would mean decision 2 was
not applied. This is the same class of miss the README's ticket-writing rules log for
RD-11/RD-14/RD-20/RD-27: add up every decision that touches the counted thing before
writing the number.
The docs changed and the code did not (decision 4):
```bash
grep -c 'or `libs/beheer/ui` component is titled' CLAUDE.md # is 1 -> MUST be 0
grep -c '(or `libs/beheer/ui`)' libs/shared/docs/layers.mdx # is 1 -> MUST be 0
git grep -c "Domein/Beheer/Stamdata Table Editor" -- libs/beheer # is 1 -> MUST still be 1
```
```bash
npm run ci --full # exits 0
```
## Verification
**`--full` is required, although the README's Order table leaves that column blank.** This
ticket edits `layers.mdx`, and the README's own rule says an `.mdx` edit needs `--full`. Fix the
column to `yes` in the same commit — the same correction RD-18 needed.
The tag count of 68 is the guard against a well-meant tidy-up. Decision 3 says keep every one.
## Out of scope
- Every other layer tag (decision 1). The scan found no other disagreement.
- The `libs/beheer` story title (decision 4). The code is right.
- Layer folders for `libs/beheer`. It is a context; it does not want them.
- The dependency-cruiser ladder rules — RD-29, which depends on this ticket only by ordering.
## Risks
- **`async.component.ts` has two comments that look like tags.** Line 154's `Convenience:` is
about the `ASYNC` array export and is accurate. Edit line 45's class header instead, and leave
154 alone — the acceptance commands check both.
- **Do not "fix" the beheer story title.** The instinct is to make the code match CLAUDE.md; here
CLAUDE.md is the thing that is wrong.
- **Do not delete a tag** (decision 3). The count must hold at 68.
@@ -0,0 +1,143 @@
# RD-29 — Enforce the atomic ladder in dependency-cruiser
Status: done
Source: PLAN.md 4c
## Why
RD-27 made the ladder **expressible**: `libs/shared/src/ui/` is now `atoms/`, `molecules/`,
`organisms/`. Nothing yet makes it **enforced** — an atom importing an organism compiles, lints,
tests and ships.
This ticket adds the three rules. It is the payoff for the move, and PLAN calls it "the real
prize".
## Read first
- `.dependency-cruiser.base.js:38` — `anyRoot`, and the atomic-layer rules that follow it. The
new rules go beside them.
- `.dependency-cruiser.base.js:93-99` — `ui-not-infrastructure`, the closest existing rule in
shape.
- PLAN.md 4c.
## Decisions (pre-made, don't relitigate)
1. **Three rules, in `.dependency-cruiser.base.js`, forbidding upward edges only:**
| Rule name | from | to |
| ----------------------------- | -------------------------------- | --------------------------------------------- |
| `atoms-compose-nothing-above` | `^libs/shared/src/ui/atoms/` | `^libs/shared/src/ui/(molecules\|organisms)/` |
| `molecules-below-organisms` | `^libs/shared/src/ui/molecules/` | `^libs/shared/src/ui/organisms/` |
| `design-system-not-layout` | `^libs/shared/src/ui/` | `^libs/shared/src/layout/` |
`severity: 'error'`, each with a `comment` naming CLAUDE.md decision 2, matching the house
style of every other rule in that file.
Literal `libs/shared/src/...` paths, not `anyRoot`: only `libs/shared` has layer folders. The
rules are evaluated once per app cruise, which is harmless — the same tree, the same answer.
2. **Never "atoms are leaves". Same-layer edges are legitimate and four exist today:**
```
atoms/masked-value -> atoms/button
molecules/review-section -> molecules/data-block
molecules/task-list -> molecules/choice-list
molecules/task-list -> molecules/choice-link
```
A rule forbidding an atom from importing any atom would fail on the first of these. Forbid
the layers **above**, nothing else.
3. **No `pathNot` exemption for specs and stories. This corrects PLAN.**
PLAN says the exemption is needed "because `async.stories.ts` composes `skeleton`".
Measured: `async` is a molecule and `skeleton` is an atom, so that edge points **downward**
and is legal under decision 1. The example does not justify an exemption.
Measured further: **zero upward edges exist anywhere in `libs/shared/src/ui/`, in production
code, specs and stories alike.** Nothing needs the exemption today.
So leave it out. An exemption that nothing needs is dead flexibility, and it silently widens
the rule the moment someone does write an upward import in a story. If a story ever earns one
— an atom's story demonstrating it inside a molecule is the plausible case — add the
exemption then, with that real example in the comment.
4. **All three land green immediately.** This ticket adds enforcement and changes no application
code. If `dep:check` fails after adding them, the rule is written wrongly — do not "fix" the
application to satisfy it without saying so.
5. **Dependency-cruiser, not ESLint.** It is where every other boundary rule in this repository
lives, and it emits the architecture graph. Do not add an ESLint variant.
## Files
- `.dependency-cruiser.base.js`
Nothing else. No application code changes.
## Steps
1. Add the three rules per decision 1, beside the existing atomic-layer rules.
2. Run `npm run dep:check` — it must pass.
3. **Prove each rule bites** (see Verification). This is the point of the ticket.
4. `git add -A`, then run the acceptance commands.
5. Update this ticket's `Status:` to `done` and the README's RD-29 row to `done`.
6. Commit all of it together.
## Acceptance criteria
Measured against the tree before handover.
```bash
git grep -c "atoms-compose-nothing-above" -- .dependency-cruiser.base.js # is 0 -> MUST be 1
git grep -c "molecules-below-organisms" -- .dependency-cruiser.base.js # is 0 -> MUST be 1
git grep -c "design-system-not-layout" -- .dependency-cruiser.base.js # is 0 -> MUST be 1
```
The rules pass on the current tree, and no application file changed (decisions 4 and 5):
```bash
npm run dep:check # exits 0
git diff --cached --name-only | grep -v '^docs/' # MUST list only .dependency-cruiser.base.js
```
```bash
npm run ci # exits 0
```
`--full` is not required: no story, no `.mdx`, and nothing under `libs/shared/src/ui/**` is
edited. The Order table's blank column is correct here.
## Verification
**A rule that matches nothing is worse than no rule, because it reads as protection.** Prove
each of the three actually fires, one at a time:
1. Add a temporary import that violates it — for example, in
`libs/shared/src/ui/atoms/button/button.component.ts`, import
`@shared/ui/molecules/data-row/data-row.component`.
2. Run `npm run dep:check` and confirm it fails, naming that rule.
3. Revert the temporary import.
Do this for all three. Report which rule name each violation produced. **Do not commit any
temporary import** — `git status` must be clean of them before you commit, and the acceptance
command above checks that only the config file changed.
## Out of scope
- Layer folders or ladder rules for `libs/beheer`. It is a bounded context, not a design system
(RD-28 settled this).
- `layout/`'s internal structure. It is sanctioned to hold several layers.
- Any rule about app contexts' own `ui/` folders. They have no layer folders by design — a
context organism does not get its own bucket.
- Changing an existing dependency-cruiser rule.
## Risks
- **Do not add the spec/stories exemption out of habit** (decision 3). Two other rules in the
file have one; these three do not need it, and the ticket explains why.
- **Forbid upward, not sideways** (decision 2). Four same-layer edges exist and are correct.
- **`from` must not match the layer it forbids.** `design-system-not-layout` starts at
`^libs/shared/src/ui/`, which covers all three layer folders; that is intended.
- **If `dep:check` goes red, suspect the rule, not the code** (decision 4). Nothing in the tree
violates the ladder today.
@@ -0,0 +1,100 @@
# RD-38 — One member order for the three wizard containers
Status: done
Source: user report — "the registratie-wizard component still doesn't look readable"
## Why
RD-22 and RD-23 split the intake and registratie wizards into step components. That brought
every container under the 250-line budget, so `max-lines` reports nothing. The user read the
result and still called it unreadable. Line count was never the problem.
Three problems remained, and the user named all three:
1. **The member order is scrambled, and it differs per file.** You bounce up and down to
follow one thread, and the three containers cannot be compared side by side.
2. **Pure logic sits in the container**, where it has no test.
3. **Comments carry archaeology** — ticket numbers, history, and one comment that describes
code which is no longer in the file.
RD-02 measures a file. This ticket is about what a file reads like at a fixed size.
## Evidence, before the change
- `registratie` declared `stepLabels`/`stepTitles` between the `seed` input and
`state`/`dispatch`; `intake` declared them near the bottom; `herregistratie` declared them
after `draftSync`. Three files, three orders for the same three roles.
- `registratie` declared `draftSync` in the middle of a run of `computed`s.
- `herregistratie`'s `snapshot` read `this.stepLabels.length` seven lines before
`stepLabels` was declared.
- `intake`'s class comment claimed answers persist to `sessionStorage`. Thirty lines below,
another comment said draft-sync replaced it. The class comment was false.
- All three cited "RD-05's tag-transition rule" in an identical sentence.
## Decisions (pre-made, don't relitigate)
1. **One nine-section member order, identical in all three files.** Injected stores → inputs
→ the store (`createStore`, `state`, `dispatch`) → static copy → state projections →
controllers → shell wiring → event handlers → constructor and `restart`. Each section
carries a `// --- <name> ---` header. Section 7's header already existed in all three
files, so it is reused byte for byte.
2. **The store ⇄ `draftSync` cycle stays.** The store's effect map calls `this.draftSync`,
and `draftSync`'s `snapshot` calls `this.state()`. Both are arrow functions that run after
construction, so there is no temporal-dead-zone hazard; the cycle cannot be removed by
reordering. One comment at the effect map names it.
3. **Extract `digitalDocumentIds` into `libs/shared/src/domain/upload.machine.ts`.** The
"digital and finished uploading" filter was written out four times, in two shapes: mapped
to ids in the two container snapshots, and as `.some(...)` inside two machines'
`hasProgress`. One function serves all four, and it removes the `r.documentId!` non-null
assertion from both containers. It joins an existing file beside `deliveryRefs`, and its
spec joins the existing `deliveryRefs` block.
4. **Extract `diplomaMsg` into a new sibling of the step files.** `onDiplomaKeuze` was the
fattest member in the three containers and had no test. It is pure: a `DuoLookupDto` and a
selected id in, a machine message or `null` out. `HANDMATIG` does not move — the new file
is its sibling and imports it exactly as the container did.
5. **`phase` stays in all three containers.** It maps this machine's tags onto the shell's
`WizardPhase` vocabulary and composes a `$localize` failure message. That is a container's
job. It cannot move to `domain/`: `WizardPhase` comes from an Angular component, and
`domain/` points inward only. Moving it to a per-wizard sibling would add three files and
three specs to relocate 17 readable lines each. Decision 1 already fixes what was wrong
with it — it belongs in the shell-wiring section, and only two of three files had it there.
6. **The four `err*` computeds in `herregistratie` stay.** They are one-line projections
feeding four template bindings. Folding them into one `errors()` would edit the template,
which is behaviour-shaped work this ticket does not do.
7. **Comment policy, four rules.** Delete ticket references and keep the sentence (RD-18
decision 1 stripped `WP-`/`RB-` for the same reason; `RD-` is the same debt). Delete
history — a comment says what the code does now. Delete a comment that describes code
which is not in the file. Keep a comment that states a current why: the `untracked`
loop-avoidance notes, the `IntakePolicy.RejectIncompleteScholing` seam pointer, and the
`demo-*` preview note all stay.
## Traps
- **`messages.en.xlf`.** Every `$localize` id in these files is translated. The reorder moves
the copy arrays; it must not touch an id or its source text.
- **`enabled: () => this.seed() === initial`** is a reference-identity check against the
exported `initial` singleton. Never clone or rebuild it — breaking the identity turns
draft-sync on inside Storybook and the tests.
- **Public members are load-bearing.** `showcase/vragenlijst.section.ts` reads
`IntakeWizardComponent.steps`; `showcase/form-machine.section.ts` reads
`HerregistratieWizardComponent.state`. No `private`/`protected`/`readonly` modifier changes.
- **`intake.machine.ts` carries `#region showcase:steps` markers** that feed `gen:snippets`.
This ticket does not touch them.
- **New specs change a generated document.** `scripts/ci-local.sh` regenerates
`libs/shared/docs/behaviour-spec.mdx` and diffs it. Four new `it()` titles land there, so
the regenerated file belongs in the same commit.
## Acceptance
- `npm run ci` passes.
- Each container's member list and every modifier are unchanged:
`diff <(git show HEAD:$f | grep -oE '^ (private|protected|readonly)? ?[a-zA-Z]+ *[=(]' | sort) <(...)`
reports no difference for all three files.
- `git grep -nE "\bRD-[0-9]+" -- 'apps/ssp/src/app/*/ui/*wizard*'` returns nothing.
- `git grep -n "sessionStorage" -- 'apps/ssp/src/app/herregistratie/ui'` returns nothing.
+10 -2
View File
@@ -122,8 +122,8 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di
| RD-25 | `org-template-editor` to `sample-letter.ts` + labels + 2 children | 02 | yes | done | | RD-25 | `org-template-editor` to `sample-letter.ts` + labels + 2 children | 02 | yes | done |
| RD-26 | `letter-canvas`: inline the labels + `letter-line`; keep one disable | 02 | yes | done | | RD-26 | `letter-canvas`: inline the labels + `letter-line`; keep one disable | 02 | yes | done |
| RD-27 | **The layer move:** 33 `git mv` + 28 specifiers + 8 MDX imports | 21 | yes | done | | RD-27 | **The layer move:** 33 `git mv` + 28 specifiers + 8 MDX imports | 21 | yes | done |
| RD-28 | Layer-tag fixes + the `libs/beheer` title rule | 27 | | todo | | RD-28 | Layer-tag fixes + the `libs/beheer` title rule | 27 | yes | done |
| RD-29 | The 3 atomic-ladder rules in dependency-cruiser | 27 | | todo | | RD-29 | The 3 atomic-ladder rules in dependency-cruiser | 27 | | done |
| RD-30 | Archive the finished backlogs (16,300 lines) + an archive README | 01 | | todo | | RD-30 | Archive the finished backlogs (16,300 lines) + an archive README | 01 | | todo |
| RD-31 | `ARCHITECTURE.md` section 6a: symbols not lines, 2 dead paths, new names | 03, 08, 16 | | todo | | RD-31 | `ARCHITECTURE.md` section 6a: symbols not lines, 2 dead paths, new names | 03, 08, 16 | | todo |
| RD-32 | `fp-tea-atomic-design.md`: 11 broken paths + the broken anchor | 27 | | todo | | RD-32 | `fp-tea-atomic-design.md`: 11 broken paths + the broken anchor | 27 | | todo |
@@ -131,6 +131,8 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di
| RD-34 | _(optional)_ `NO_SUBORGS`/`NO_TABLES` become `RemoteData.Empty` | 11 | | todo | | RD-34 | _(optional)_ `NO_SUBORGS`/`NO_TABLES` become `RemoteData.Empty` | 11 | | todo |
| RD-35 | _(optional, last, alone)_ upload `type:` discriminant to `tag:` | 27 | | todo | | RD-35 | _(optional, last, alone)_ upload `type:` discriminant to `tag:` | 27 | | todo |
| RD-36 | `ui/dashboard/` → `ui/overzicht-secties/` + 2 stale `dashboard.page` paths | 04 | yes | todo | | RD-36 | `ui/dashboard/` → `ui/overzicht-secties/` + 2 stale `dashboard.page` paths | 04 | yes | todo |
| RD-37 | **a11y:** 5 suppressions name a closed ticket — decide the `li[…]` host | 01 | yes | todo |
| RD-38 | One member order for the 3 wizard containers + 2 pure extractions | 22, 23 | | done |
The ID order already respects every dependency, so it is the recommended running order. The ID order already respects every dependency, so it is the recommended running order.
@@ -142,6 +144,12 @@ four import lines, and it collides with nothing else in the table — RD-27's mo
`libs/shared/src/ui/`. Pull it forward into any short session. It is numbered last only because `libs/shared/src/ui/`. Pull it forward into any short session. It is numbered last only because
it was added after RD-04 shipped. it was added after RD-04 shipped.
**RD-37 must run before RD-30**, despite its number. RD-30 archives `docs/project/backlog/`,
and five of the paths it would have to rewrite point at `WP-11-markup-fidelity.md` from
accessibility suppressions that RD-37 either deletes or re-aims. Doing RD-30 first means
rewriting five paths that are about to change again — and enshrining a promise nobody owns.
See PLAN.md Phase 5, item 0.
**Two ordering traps the table encodes.** RD-01 must precede RD-30, because RD-01 copies its **Two ordering traps the table encodes.** RD-01 must precede RD-30, because RD-01 copies its
ticket template out of the directory that RD-30 archives. And four tickets edit the same two ticket template out of the directory that RD-30 archives. And four tickets edit the same two
documents in different sections — RD-09 rewrites the submit-idiom teaching, while RD-31 and documents in different sections — RD-09 rewrites the submit-idiom teaching, while RD-31 and
+13 -1
View File
@@ -20,7 +20,7 @@ tested where._
Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test
method name (backend), read as a sentence. Nothing here is hand-written prose: this page method name (backend), read as a sentence. Nothing here is hand-written prose: this page
**is** the suite, reshaped for a business reader. 556 frontend behaviours across **is** the suite, reshaped for a business reader. 562 frontend behaviours across
9 contexts; 261 backend behaviours across 42 test 9 contexts; 261 backend behaviours across 42 test
classes. classes.
@@ -545,6 +545,12 @@ classes.
- lists soort/waarvoor/status/referentie/ingediend, plus reason when rejected - lists soort/waarvoor/status/referentie/ingediend, plus reason when rejected
- reference falls back to em dash for a Concept - reference falls back to em dash for a Concept
#### diplomaMsg
- resolves a known diploma into KiesDiploma with the server-derived beroep
- resolves the manual sentinel into KiesHandmatig with the maximal question set
- returns null for an unknown diploma id
#### findConcept #### findConcept
- returns the id of the existing Concept of the given type - returns the id of the existing Concept of the given type
@@ -857,6 +863,12 @@ classes.
- emits documentId for completed digital uploads and channel for post - emits documentId for completed digital uploads and channel for post
- omits digital categories with no completed upload - omits digital categories with no completed upload
#### digitalDocumentIds
- returns the id of a completed digital upload
- omits an upload that is still in flight
- omits a category that the user delivers by post
#### flushPendingGuard #### flushPendingGuard
- flushes then allows navigation when a write is pending - flushes then allows navigation when a write is pending
+2 -2
View File
@@ -9,8 +9,8 @@ This project is **domain-driven**: the code is organised first by **bounded cont
with dependencies pointing inward. The Storybook sidebar is laid out to **be** that with dependencies pointing inward. The Storybook sidebar is laid out to **be** that
architecture, not just document it: **Foundations** (this curriculum) → **Design System** architecture, not just document it: **Foundations** (this curriculum) → **Design System**
(reusable, domain-free) → **Domein** (the app-local DDD contexts). If a component lives (reusable, domain-free) → **Domein** (the app-local DDD contexts). If a component lives
under a context's `ui/`, it's in Domein; everything else in `libs/shared/ui`/`layout` under a context's `ui/`, or under `libs/beheer/ui`, it's in Domein; everything else in
(or `libs/beheer/ui`) is Design System. See [Atomic design](?path=/docs/foundations-atomic-design--docs) `libs/shared/ui`/`layout` is Design System. See [Atomic design](?path=/docs/foundations-atomic-design--docs)
for the Atoms → Molecules → Organisms → Templates ladder inside Design System. for the Atoms → Molecules → Organisms → Templates ladder inside Design System.
## Two apps, two shared libraries ## Two apps, two shared libraries
@@ -8,6 +8,7 @@ import {
categorySatisfied, categorySatisfied,
requiredCategoriesSatisfied, requiredCategoriesSatisfied,
deliveryRefs, deliveryRefs,
digitalDocumentIds,
inFlight, inFlight,
rejectReason, rejectReason,
planFileSelection, planFileSelection,
@@ -284,6 +285,28 @@ describe('deliveryRefs', () => {
}); });
}); });
describe('digitalDocumentIds', () => {
it('returns the id of a completed digital upload', () => {
let s = select(stateWith([cat({ categoryId: 'a' })]), 'a', 'u1');
s = reduceUpload(s, { type: 'UploadComplete', localId: 'u1', documentId: 'doc1' });
expect(digitalDocumentIds(s)).toEqual(['doc1']);
});
it('omits an upload that is still in flight', () => {
const s = select(stateWith([cat({ categoryId: 'a' })]), 'a', 'u1'); // still queued
expect(digitalDocumentIds(s)).toEqual([]);
});
it('omits a category that the user delivers by post', () => {
let s = stateWith([cat({ categoryId: 'a' }), cat({ categoryId: 'b' })], {
deliveryChannel: { b: 'post' },
});
s = select(s, 'a', 'u1');
s = reduceUpload(s, { type: 'UploadComplete', localId: 'u1', documentId: 'doc1' });
expect(digitalDocumentIds(s)).toEqual(['doc1']);
});
});
describe('rejectReason', () => { describe('rejectReason', () => {
it('rejects a disallowed type', () => { it('rejects a disallowed type', () => {
expect( expect(
+8
View File
@@ -312,6 +312,14 @@ export function deliveryRefs(
return refs; return refs;
} }
/** Ids of the documents that the user delivers digitally and that finished uploading.
A category set to post, or one whose upload is still in flight, contributes nothing. */
export function digitalDocumentIds(s: UploadState): string[] {
return deliveryRefs(s)
.filter((r) => r.channel === 'digital' && r.documentId)
.map((r) => r.documentId as string);
}
/** Used by the shell to find what to poll on return: still-in-flight uploads. */ /** Used by the shell to find what to poll on return: still-in-flight uploads. */
export const inFlight = (s: UploadState): Upload[] => export const inFlight = (s: UploadState): Upload[] =>
s.uploads.filter((u) => u.status.type === 'queued' || u.status.type === 'uploading'); s.uploads.filter((u) => u.status.type === 'queued' || u.status.type === 'uploading');
@@ -6,7 +6,7 @@ export interface BreadcrumbItem {
link?: string; // omit on the current (last) page link?: string; // omit on the current (last) page
} }
/** Chrome: breadcrumb navigation, styled for the CIBG titlebar (`.titlebar .title`) — /** Molecule: breadcrumb navigation, styled for the CIBG titlebar (`.titlebar .title`) —
plain links with a chevron `::after` from the CIBG Icons font, current page as an plain links with a chevron `::after` from the CIBG Icons font, current page as an
unlinked, bold span. Domain-free — the caller supplies the trail. */ unlinked, bold span. Domain-free — the caller supplies the trail. */
@Component({ @Component({
@@ -43,7 +43,7 @@ export class AsyncErrorDirective {
} }
/** /**
* Renders exactly ONE of loading / empty / error / loaded for a signal-based * Molecule: renders exactly ONE of loading / empty / error / loaded for a signal-based
* resource (e.g. httpResource). Built on a RemoteData tagged union (see * resource (e.g. httpResource). Built on a RemoteData tagged union (see
* core/remote-data.ts), so the states are mutually exclusive by construction — * core/remote-data.ts), so the states are mutually exclusive by construction —
* the UI can never show two at once ("impossible states"). Unprovided slots * the UI can never show two at once ("impossible states"). Unprovided slots