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
ehoandClaude Opus 5 57fc72f254 docs: correct RD-27's occurrence count, the fourth time in one pattern
Decision 3 said the `@shared/ui/` occurrence count must not change at 200,
while decision 4, nine lines below, mandated converting five relative imports
into `@shared/ui/` aliases — five new occurrences by construction. The answer
is 205, and the agent verified by hand that the 21 occurrences now inside
`libs/shared/src/ui/` are the 16 from before plus exactly those five.

This is the fourth instance of the pattern named in the previous docs commit,
and the first ticket written after naming it. Recorded as such: when two
decisions both touch the thing you are counting, add them up first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 08:15:20 +02:00
ehoandClaude Sonnet 5 43dc3210cd refactor: move libs/shared/src/ui/ into atoms/molecules/organisms (RD-27)
The folder now equals the layer, as CLAUDE.md decision 2 requires. 33
directories move by git mv (25 flat, plus upload/'s 8 subfolders split
across all three layers). 28 distinct @shared/ui/* specifiers rewrite
across 73 files, longest-first. Five relative imports inside upload/
become @shared/ui aliases because their sibling now lives in a
different layer; two stay relative because both ends stay in the same
layer. Four .mdx docs get their seven broken story imports fixed;
atomic-design.mdx's page-shell import is untouched, because layout/
does not move.

No component, template, story title, or layer-tag comment changes.
That is RD-28's job.

Verified against the ticket's acceptance commands: the 26 flat
directories become exactly 3 layer folders with the counts the ticket
names, only three @shared/ui/* prefixes remain (atoms, molecules,
organisms), the .mdx import count holds at 7, and the relative-import
count inside ui/ drops from 7 to 2 as decision 4 requires. The
@shared/ui/ occurrence count moves from 200 to 205: decision 4
mandates turning 5 of those 7 relative imports into @shared/ui/*
aliases, which decision 3's "200 before, 200 after" check does not
account for. The 5-occurrence gap is exactly the 5 conversions decision
4 names, not a lost or duplicated specifier.

npm run ci --full passes: lint, typecheck, dep:check, format, tokens,
seam, both apps' + both libraries' tests, both apps' localized build,
audit, backend tests, all three generated-artifact drift checks, and
both Storybook instances' build + axe-core a11y suite (67+45 suites,
198+112 tests, all green).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 08:14:10 +02:00
150 changed files with 1156 additions and 336 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
@@ -1,5 +1,5 @@
import { Component, output } from '@angular/core'; import { Component, output } from '@angular/core';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
/** /**
* Organism: employee-SSO-style mock login (ADR-C-004). No real auth — and, * Organism: employee-SSO-style mock login (ADR-C-004). No real auth — and,
@@ -2,12 +2,12 @@ import { Component, computed, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { successOf } from '@shared/application/remote-data'; import { successOf } from '@shared/application/remote-data';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component'; import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { BeoordelingStore } from '@behandeling/application/beoordeling.store'; import { BeoordelingStore } from '@behandeling/application/beoordeling.store';
import { detailRows } from '@behandeling/domain/beoordeling-view'; import { detailRows } from '@behandeling/domain/beoordeling-view';
import { BeoordelingDocumentenComponent } from '@behandeling/ui/beoordeling-documenten/beoordeling-documenten.component'; import { BeoordelingDocumentenComponent } from '@behandeling/ui/beoordeling-documenten/beoordeling-documenten.component';
@@ -1,13 +1,16 @@
import { Component, computed, input, output } from '@angular/core'; import { Component, computed, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { RadioGroupComponent, RadioOption } from '@shared/ui/radio-group/radio-group.component'; import {
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component'; RadioGroupComponent,
import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; RadioOption,
} from '@shared/ui/atoms/radio-group/radio-group.component';
import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp'; import { whenTag } from '@shared/kernel/fp';
import { BesluitState, BesluitMsg, initial, reduce } from '@behandeling/domain/besluit.machine'; import { BesluitState, BesluitMsg, initial, reduce } from '@behandeling/domain/besluit.machine';
@@ -1,6 +1,6 @@
import { Component, input } from '@angular/core'; import { Component, input } from '@angular/core';
import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component'; import { ApplicationListComponent } from '@shared/ui/molecules/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component'; import { ApplicationLinkComponent } from '@shared/ui/molecules/application-link/application-link.component';
import { WerkvoorraadItem } from '@behandeling/domain/werkvoorraad-item'; import { WerkvoorraadItem } from '@behandeling/domain/werkvoorraad-item';
import { werkvoorraadRow } from '@behandeling/domain/werkvoorraad-item-view'; import { werkvoorraadRow } from '@behandeling/domain/werkvoorraad-item-view';
@@ -1,9 +1,9 @@
import { Component, computed, effect, inject } from '@angular/core'; import { Component, computed, effect, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { AccessStore } from '@shared/application/access.store'; import { AccessStore } from '@shared/application/access.store';
import { successOr } from '@shared/application/remote-data'; import { successOr } from '@shared/application/remote-data';
import { WerkvoorraadStore } from '@behandeling/application/werkvoorraad.store'; import { WerkvoorraadStore } from '@behandeling/application/werkvoorraad.store';
@@ -1,8 +1,8 @@
import { Component, output } from '@angular/core'; import { Component, output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
/** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */ /** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */
@Component({ @Component({
+1 -1
View File
@@ -1,7 +1,7 @@
import { Component, inject, signal } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { LoginFormComponent } from '@auth/ui/login-form/login-form.component'; import { LoginFormComponent } from '@auth/ui/login-form/login-form.component';
import { SessionStore } from '@auth/application/session.store'; import { SessionStore } from '@auth/application/session.store';
@@ -1,10 +1,10 @@
import { Component, ElementRef, computed, input, output, viewChild } from '@angular/core'; import { Component, ElementRef, computed, input, output, viewChild } from '@angular/core';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component'; import { PlaceholderOption } from '@shared/ui/molecules/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { MaskedValueComponent } from '@shared/ui/masked-value/masked-value.component'; import { MaskedValueComponent } from '@shared/ui/atoms/masked-value/masked-value.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { StepperComponent } from '@shared/ui/stepper/stepper.component'; import { StepperComponent } from '@shared/ui/molecules/stepper/stepper.component';
import { Besluit, Brief, CaseContext, LibraryPassage } from '@brief/domain/brief'; import { Besluit, Brief, CaseContext, LibraryPassage } from '@brief/domain/brief';
import { besluitGuidance, inferSelection } from '@brief/domain/besluit'; import { besluitGuidance, inferSelection } from '@brief/domain/besluit';
import { Diagnostic } from '@brief/domain/placeholders'; import { Diagnostic } from '@brief/domain/placeholders';
@@ -1,8 +1,11 @@
import { Component, computed, input, linkedSignal, output } from '@angular/core'; import { Component, computed, input, linkedSignal, output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component'; import { CheckboxComponent } from '@shared/ui/atoms/checkbox/checkbox.component';
import { RadioGroupComponent, RadioOption } from '@shared/ui/radio-group/radio-group.component'; import {
import { HeadingComponent } from '@shared/ui/heading/heading.component'; RadioGroupComponent,
RadioOption,
} from '@shared/ui/atoms/radio-group/radio-group.component';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { Besluit, LibraryPassage } from '@brief/domain/brief'; import { Besluit, LibraryPassage } from '@brief/domain/brief';
import { redenenFor } from '@brief/domain/besluit'; import { redenenFor } from '@brief/domain/besluit';
+3 -3
View File
@@ -1,8 +1,8 @@
import { Component, computed, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { BriefStore } from '@brief/application/brief.store'; import { BriefStore } from '@brief/application/brief.store';
import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component'; import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component';
import { BehandelSchermComponent } from '@brief/ui/behandel-scherm/behandel-scherm.component'; import { BehandelSchermComponent } from '@brief/ui/behandel-scherm/behandel-scherm.component';
@@ -1,5 +1,5 @@
import { Component, computed, input, output } from '@angular/core'; import { Component, computed, input, output } from '@angular/core';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { Diagnostic } from '@brief/domain/placeholders'; import { Diagnostic } from '@brief/domain/placeholders';
/** Molecule: lists all letter diagnostics grouped by severity. Errors block /** Molecule: lists all letter diagnostics grouped by severity. Errors block
@@ -3,8 +3,8 @@ import { RichTextBlock } from '@shared/kernel/rich-text';
import { import {
RichTextEditorComponent, RichTextEditorComponent,
PlaceholderOption, PlaceholderOption,
} from '@shared/ui/rich-text-editor/rich-text-editor.component'; } from '@shared/ui/molecules/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { LetterBlock } from '@brief/domain/brief'; import { LetterBlock } from '@brief/domain/brief';
/** Molecule: one block in a section — its editor plus provenance + block controls. /** Molecule: one block in a section — its editor plus provenance + block controls.
@@ -14,7 +14,7 @@ import {
signal, signal,
viewChild, viewChild,
} from '@angular/core'; } from '@angular/core';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { formatDatumNl } from '@shared/kernel/datum'; import { formatDatumNl } from '@shared/kernel/datum';
import { Paragraph } from '@shared/kernel/rich-text'; import { Paragraph } from '@shared/kernel/rich-text';
import { Brief, LetterBlock } from '@brief/domain/brief'; import { Brief, LetterBlock } from '@brief/domain/brief';
@@ -1,5 +1,5 @@
import { Component, computed, input } from '@angular/core'; import { Component, computed, input } from '@angular/core';
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component'; import { PlaceholderChipComponent } from '@shared/ui/atoms/placeholder-chip/placeholder-chip.component';
import { RichTextNode } from '@shared/kernel/rich-text'; import { RichTextNode } from '@shared/kernel/rich-text';
import { Diagnostic, PlaceholderDef } from '@brief/domain/placeholders'; import { Diagnostic, PlaceholderDef } from '@brief/domain/placeholders';
@@ -1,8 +1,8 @@
import { Component, computed, input, output, signal } from '@angular/core'; import { Component, computed, input, output, signal } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component'; import { StatusBadgeComponent } from '@shared/ui/atoms/status-badge/status-badge.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { Brief } from '@brief/domain/brief'; import { Brief } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template'; import { OrgTemplate } from '@brief/domain/org-template';
import { Diagnostic } from '@brief/domain/placeholders'; import { Diagnostic } from '@brief/domain/placeholders';
@@ -1,5 +1,5 @@
import { Component, computed, input, output } from '@angular/core'; import { Component, computed, input, output } from '@angular/core';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component'; import { PlaceholderOption } from '@shared/ui/molecules/rich-text-editor/rich-text-editor.component';
import { Brief } from '@brief/domain/brief'; import { Brief } from '@brief/domain/brief';
import { BriefMsg } from '@brief/domain/brief.machine'; import { BriefMsg } from '@brief/domain/brief.machine';
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component'; import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
@@ -1,8 +1,8 @@
import { Component, input, output } from '@angular/core'; import { Component, input, output } from '@angular/core';
import { RichTextBlock } from '@shared/kernel/rich-text'; import { RichTextBlock } from '@shared/kernel/rich-text';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component'; import { PlaceholderOption } from '@shared/ui/molecules/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { LetterSection } from '@brief/domain/brief'; import { LetterSection } from '@brief/domain/brief';
import { BriefMsg } from '@brief/domain/brief.machine'; import { BriefMsg } from '@brief/domain/brief.machine';
import { LetterBlockComponent } from '@brief/ui/letter-block/letter-block.component'; import { LetterBlockComponent } from '@brief/ui/letter-block/letter-block.component';
@@ -1,8 +1,8 @@
import { Component, computed, input, output } from '@angular/core'; import { Component, computed, input, output } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { FileInputComponent } from '@shared/ui/upload/file-input/file-input.component'; import { FileInputComponent } from '@shared/ui/atoms/upload/file-input/file-input.component';
import { SingleUploadComponent } from '@shared/ui/upload/single-upload/single-upload.component'; import { SingleUploadComponent } from '@shared/ui/molecules/upload/single-upload/single-upload.component';
import { UploadState } from '@shared/domain/upload.machine'; import { UploadState } from '@shared/domain/upload.machine';
const LOGO_CATEGORY = 'org-logo'; const LOGO_CATEGORY = 'org-logo';
@@ -1,6 +1,6 @@
import { Component, computed, input, output } from '@angular/core'; import { Component, computed, input, output } from '@angular/core';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { UploadState } from '@shared/domain/upload.machine'; import { UploadState } from '@shared/domain/upload.machine';
import { Brief } from '@brief/domain/brief'; import { Brief } from '@brief/domain/brief';
import { SAMPLE_LETTER_BRIEF } from '@brief/domain/sample-letter'; import { SAMPLE_LETTER_BRIEF } from '@brief/domain/sample-letter';
@@ -1,7 +1,7 @@
import { Component, input, output } from '@angular/core'; import { Component, input, output } from '@angular/core';
import { DatePipe } from '@angular/common'; import { DatePipe } from '@angular/common';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { OrgTemplateVersion } from '@brief/domain/org-template'; import { OrgTemplateVersion } from '@brief/domain/org-template';
/** /**
@@ -1,8 +1,8 @@
import { Component, computed, effect, inject } from '@angular/core'; import { Component, computed, effect, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { AccessStore } from '@shared/application/access.store'; import { AccessStore } from '@shared/application/access.store';
import { OrgTemplateStore } from '@brief/application/org-template.store'; import { OrgTemplateStore } from '@brief/application/org-template.store';
import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-template-editor.component'; import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-template-editor.component';
@@ -1,8 +1,8 @@
import { Component, computed, input, output, signal } from '@angular/core'; import { Component, computed, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component'; import { CheckboxComponent } from '@shared/ui/atoms/checkbox/checkbox.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { textOf } from '@shared/kernel/rich-text'; import { textOf } from '@shared/kernel/rich-text';
import { LibraryPassage } from '@brief/domain/brief'; import { LibraryPassage } from '@brief/domain/brief';
@@ -1,7 +1,7 @@
import { Component, input, output, signal } from '@angular/core'; import { Component, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
/** Molecule: shows the rejection comments (drafter view) or collects them from the /** Molecule: shows the rejection comments (drafter view) or collects them from the
approver. The approver rejects WITH comments; they never edit the letter. */ approver. The approver rejects WITH comments; they never edit the letter. */
@@ -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
); );
} }
@@ -1,8 +1,8 @@
import { Component, computed, inject, input } from '@angular/core'; import { Component, computed, inject, input } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { import {
WizardShellComponent, WizardShellComponent,
WizardError, WizardError,
@@ -10,7 +10,7 @@ import {
naarStapLabel, naarStapLabel,
} from '@shared/layout/wizard-shell/wizard-shell.component'; } from '@shared/layout/wizard-shell/wizard-shell.component';
import { toWizardErrors } from '@shared/layout/wizard-shell/wizard-errors'; import { toWizardErrors } from '@shared/layout/wizard-shell/wizard-errors';
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component'; import { ConfirmationComponent } from '@shared/ui/molecules/confirmation/confirmation.component';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp'; import { whenTag } from '@shared/kernel/fp';
import { BigProfileStore } from '@registratie/application/big-profile.store'; import { BigProfileStore } from '@registratie/application/big-profile.store';
@@ -23,9 +23,9 @@ import {
hasProgress, hasProgress,
} from '@herregistratie/domain/herregistratie.machine'; } from '@herregistratie/domain/herregistratie.machine';
import { createDraftSync } from '@registratie/application/draft-sync'; import { createDraftSync } from '@registratie/application/draft-sync';
import { DocumentUploadComponent } from '@shared/ui/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.
@@ -1,7 +1,7 @@
import { Component, computed, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { map } from '@shared/application/remote-data'; import { map } from '@shared/application/remote-data';
import { BigProfileStore } from '@registratie/application/big-profile.store'; import { BigProfileStore } from '@registratie/application/big-profile.store';
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component'; import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
@@ -1,8 +1,8 @@
import { Component, input, output } from '@angular/core'; import { Component, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { RadioGroupComponent, JA_NEE } from '@shared/ui/radio-group/radio-group.component'; import { RadioGroupComponent, JA_NEE } from '@shared/ui/atoms/radio-group/radio-group.component';
import { Answers, Errors } from '@herregistratie/domain/intake.machine'; import { Answers, Errors } from '@herregistratie/domain/intake.machine';
/** Step: the intake wizard's first screen (foreign work in the last 5 years). /** Step: the intake wizard's first screen (foreign work in the last 5 years).
@@ -1,6 +1,6 @@
import { Component, computed, effect, inject, input, untracked } from '@angular/core'; import { Component, computed, effect, inject, input, untracked } from '@angular/core';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component'; import { ConfirmationComponent } from '@shared/ui/molecules/confirmation/confirmation.component';
import { import {
WizardShellComponent, WizardShellComponent,
WizardError, WizardError,
@@ -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`;
@@ -1,7 +1,7 @@
import { Component, computed, input, output } from '@angular/core'; import { Component, computed, input, output } from '@angular/core';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component'; import { ReviewSectionComponent } from '@shared/ui/molecules/review-section/review-section.component';
import { Answers, lageUren } from '@herregistratie/domain/intake.machine'; import { Answers, lageUren } from '@herregistratie/domain/intake.machine';
/** Step: the intake wizard's review screen. Pure & presentational — values in via /** Step: the intake wizard's review screen. Pure & presentational — values in via
@@ -1,8 +1,8 @@
import { Component, computed, input, output } from '@angular/core'; import { Component, computed, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { RadioGroupComponent, JA_NEE } from '@shared/ui/radio-group/radio-group.component'; import { RadioGroupComponent, JA_NEE } from '@shared/ui/atoms/radio-group/radio-group.component';
import { Answers, Errors, lageUren } from '@herregistratie/domain/intake.machine'; import { Answers, Errors, lageUren } from '@herregistratie/domain/intake.machine';
/** Step: the intake wizard's second screen (work experience in the Netherlands, /** Step: the intake wizard's second screen (work experience in the Netherlands,
@@ -1,6 +1,6 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component'; import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component';
/** Page: the branching intake questionnaire. Built entirely from existing /** Page: the branching intake questionnaire. Built entirely from existing
@@ -1,7 +1,7 @@
import { Component, computed, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component'; import { ApplicationListComponent } from '@shared/ui/molecules/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component'; import { ApplicationLinkComponent } from '@shared/ui/molecules/application-link/application-link.component';
import { AccessStore } from '@shared/application/access.store'; import { AccessStore } from '@shared/application/access.store';
import { HEADER_ADMIN_LINKS } from '@shared/layout/site-header/nav-config'; import { HEADER_ADMIN_LINKS } from '@shared/layout/site-header/nav-config';
@@ -1,7 +1,7 @@
import { Component, computed, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component'; import { ApplicationListComponent } from '@shared/ui/molecules/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component'; import { ApplicationLinkComponent } from '@shared/ui/molecules/application-link/application-link.component';
import { FeatureFlagStore } from '@shared/application/feature-flags.store'; import { FeatureFlagStore } from '@shared/application/feature-flags.store';
import { FLAG_INSCHRIJVING_OPEN } from '@shared/domain/feature-flag'; import { FLAG_INSCHRIJVING_OPEN } from '@shared/domain/feature-flag';
@@ -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
); );
} }
@@ -1,7 +1,7 @@
import { Component, computed, input, output } from '@angular/core'; import { Component, computed, input, output } from '@angular/core';
import { formatDatumNl } from '@shared/kernel/datum'; import { formatDatumNl } from '@shared/kernel/datum';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { Aanvraag } from '@registratie/domain/aanvraag'; import { Aanvraag } from '@registratie/domain/aanvraag';
import { TYPE_LABELS } from '@registratie/domain/aanvraag-view'; import { TYPE_LABELS } from '@registratie/domain/aanvraag-view';
import { blockActions } from '@registratie/domain/block-actions'; import { blockActions } from '@registratie/domain/block-actions';
@@ -2,11 +2,11 @@ import { Component, computed, inject } from '@angular/core';
import { successOf } from '@shared/application/remote-data'; import { successOf } from '@shared/application/remote-data';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component'; import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { AanvragenStore } from '@registratie/application/aanvragen.store'; import { AanvragenStore } from '@registratie/application/aanvragen.store';
import { Aanvraag } from '@registratie/domain/aanvraag'; import { Aanvraag } from '@registratie/domain/aanvraag';
import { detailRows } from '@registratie/domain/aanvraag-view'; import { detailRows } from '@registratie/domain/aanvraag-view';
@@ -1,7 +1,7 @@
import { Component, input, output } from '@angular/core'; import { Component, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
export interface AdresValue { export interface AdresValue {
straat: string; straat: string;
@@ -1,10 +1,10 @@
import { Component, computed, effect, inject } from '@angular/core'; import { Component, computed, effect, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component'; import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { AccessStore } from '@shared/application/access.store'; import { AccessStore } from '@shared/application/access.store';
import { successOr } from '@shared/application/remote-data'; import { successOr } from '@shared/application/remote-data';
import { formatDatumNl } from '@shared/kernel/datum'; import { formatDatumNl } from '@shared/kernel/datum';
@@ -1,12 +1,12 @@
import { Component, computed, input } from '@angular/core'; import { Component, computed, input } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component'; import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { Adres } from '@registratie/domain/person'; import { Adres } from '@registratie/domain/person';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp'; import { whenTag } from '@shared/kernel/fp';
@@ -1,11 +1,11 @@
import { Component, computed, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component'; import { ApplicationListComponent } from '@shared/ui/molecules/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component'; import { ApplicationLinkComponent } from '@shared/ui/molecules/application-link/application-link.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { successOr } from '@shared/application/remote-data'; import { successOr } from '@shared/application/remote-data';
import { AanvragenStore } from '@registratie/application/aanvragen.store'; import { AanvragenStore } from '@registratie/application/aanvragen.store';
import { Aanvraag, AanvraagType } from '@registratie/domain/aanvraag'; import { Aanvraag, AanvraagType } from '@registratie/domain/aanvraag';
@@ -1,10 +1,10 @@
import { Component, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { successOf } from '@shared/application/remote-data'; import { successOf } from '@shared/application/remote-data';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component'; import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { BigProfileStore } from '@registratie/application/big-profile.store'; import { BigProfileStore } from '@registratie/application/big-profile.store';
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component'; import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
@@ -1,8 +1,8 @@
import { Component, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { successOf } from '@shared/application/remote-data'; import { successOf } from '@shared/application/remote-data';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { BigProfileStore } from '@registratie/application/big-profile.store'; import { BigProfileStore } from '@registratie/application/big-profile.store';
import { RegistrationTableComponent } from '@registratie/ui/registration-table/registration-table.component'; import { RegistrationTableComponent } from '@registratie/ui/registration-table/registration-table.component';
@@ -1,10 +1,10 @@
import { Component, computed, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { successOf } from '@shared/application/remote-data'; import { successOf } from '@shared/application/remote-data';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { TaskListComponent } from '@shared/ui/task-list/task-list.component'; import { TaskListComponent } from '@shared/ui/molecules/task-list/task-list.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { BigProfileStore } from '@registratie/application/big-profile.store'; import { BigProfileStore } from '@registratie/application/big-profile.store';
import { tasksFromProfile } from '@registratie/domain/tasks'; import { tasksFromProfile } from '@registratie/domain/tasks';
@@ -1,10 +1,10 @@
import { Component, inject, input, output } from '@angular/core'; import { Component, inject, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { RadioGroupComponent } from '@shared/ui/radio-group/radio-group.component'; import { RadioGroupComponent } from '@shared/ui/atoms/radio-group/radio-group.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component'; import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component';
import { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store'; import { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store';
import { Draft, DraftField, Errors } from '@registratie/domain/registratie-wizard.machine'; import { Draft, DraftField, Errors } from '@registratie/domain/registratie-wizard.machine';
@@ -1,14 +1,14 @@
import { Component, computed, inject, input, output } from '@angular/core'; import { Component, computed, inject, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { RadioGroupComponent, JA_NEE } from '@shared/ui/radio-group/radio-group.component'; import { RadioGroupComponent, JA_NEE } from '@shared/ui/atoms/radio-group/radio-group.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component'; import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { DocumentUploadComponent } from '@shared/ui/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 { UploadMsg, UploadState } from '@shared/domain/upload.machine'; import { UploadMsg, UploadState } from '@shared/domain/upload.machine';
import { RemoteData, successOr } from '@shared/application/remote-data'; import { RemoteData, successOr } from '@shared/application/remote-data';
@@ -1,7 +1,7 @@
import { Component, computed, inject, input, output } from '@angular/core'; import { Component, computed, inject, input, output } from '@angular/core';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component'; import { ReviewSectionComponent } from '@shared/ui/molecules/review-section/review-section.component';
import { successOr } from '@shared/application/remote-data'; import { successOr } from '@shared/application/remote-data';
import { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store'; import { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store';
import { DuoLookupDto } from '@registratie/contracts/duo-diplomas.dto'; import { DuoLookupDto } from '@registratie/contracts/duo-diplomas.dto';
@@ -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),
};
}
@@ -1,6 +1,6 @@
import { Component, computed, effect, inject, input, untracked } from '@angular/core'; import { Component, computed, effect, inject, input, untracked } from '@angular/core';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component'; import { ConfirmationComponent } from '@shared/ui/molecules/confirmation/confirmation.component';
import { import {
WizardShellComponent, WizardShellComponent,
WizardError, WizardError,
@@ -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,12 +255,10 @@ 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
re-prefills, keeping the form and the"vooraf ingevuld" note consistent. */ re-prefills, keeping the form and the "vooraf ingevuld" note consistent. */
restart() { restart() {
this.draftSync.reset(); // discard the current Concept; a fresh one starts on next progress this.draftSync.reset(); // discard the current Concept; a fresh one starts on next progress
this.dispatch({ tag: 'Seed', state: initial }); this.dispatch({ tag: 'Seed', state: initial });
@@ -1,6 +1,6 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { RegistratieWizardComponent } from '@registratie/ui/registratie-wizard/registratie-wizard.component'; import { RegistratieWizardComponent } from '@registratie/ui/registratie-wizard/registratie-wizard.component';
/** Page: register in the BIG-register. Built entirely from existing building /** Page: register in the BIG-register. Built entirely from existing building
@@ -1,8 +1,8 @@
import { Component, computed, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { successOf } from '@shared/application/remote-data'; import { successOf } from '@shared/application/remote-data';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component'; import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
import { ChangeRequestFormComponent } from '@registratie/ui/change-request-form/change-request-form.component'; import { ChangeRequestFormComponent } from '@registratie/ui/change-request-form/change-request-form.component';
import { BigProfileStore } from '@registratie/application/big-profile.store'; import { BigProfileStore } from '@registratie/application/big-profile.store';
@@ -2,9 +2,9 @@ import { Component, input } from '@angular/core';
import { DatePipe } from '@angular/common'; import { DatePipe } from '@angular/common';
import { Registration } from '@registratie/domain/registration'; import { Registration } from '@registratie/domain/registration';
import { statusColor, statusLabel } from '@registratie/domain/registration.policy'; import { statusColor, statusLabel } from '@registratie/domain/registration.policy';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component'; import { StatusBadgeComponent } from '@shared/ui/atoms/status-badge/status-badge.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component'; import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
/** Organism: registration summary in a CIBG Datablock. Composes data-row rows + /** Organism: registration summary in a CIBG Datablock. Composes data-row rows +
status-badge atom (no card wrapper — the datablock carries its own surface). */ status-badge atom (no card wrapper — the datablock carries its own surface). */
@@ -1,5 +1,5 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component'; import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
import { ConceptCardComponent } from './concept-card.component'; import { ConceptCardComponent } from './concept-card.component';
import { SNIPPETS } from './snippets.generated'; import { SNIPPETS } from './snippets.generated';
+2 -2
View File
@@ -1,7 +1,7 @@
import { Component, computed, signal } from '@angular/core'; import { Component, computed, signal } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { parsePostcode } from '@registratie/domain/value-objects/postcode'; import { parsePostcode } from '@registratie/domain/value-objects/postcode';
import { ConceptCardComponent } from './concept-card.component'; import { ConceptCardComponent } from './concept-card.component';
import { SNIPPETS } from './snippets.generated'; import { SNIPPETS } from './snippets.generated';
+3 -3
View File
@@ -1,8 +1,8 @@
import { Component, computed, signal } from '@angular/core'; import { Component, computed, signal } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { MaskedValueComponent } from '@shared/ui/masked-value/masked-value.component'; import { MaskedValueComponent } from '@shared/ui/atoms/masked-value/masked-value.component';
import { parseBsn } from '@shared/kernel/bsn'; import { parseBsn } from '@shared/kernel/bsn';
import { maskBsn } from '@shared/kernel/pii'; import { maskBsn } from '@shared/kernel/pii';
import { ConceptCardComponent } from './concept-card.component'; import { ConceptCardComponent } from './concept-card.component';
@@ -1,8 +1,8 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import type { Resource } from '@angular/core'; import type { Resource } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { ConceptCardComponent } from './concept-card.component'; import { ConceptCardComponent } from './concept-card.component';
import { SNIPPETS } from './snippets.generated'; import { SNIPPETS } from './snippets.generated';
import { highlightTs } from './highlight-ts'; import { highlightTs } from './highlight-ts';
+1 -1
View File
@@ -1,5 +1,5 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component'; import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
import { Registration } from '@registratie/domain/registration'; import { Registration } from '@registratie/domain/registration';
import { ConceptCardComponent } from './concept-card.component'; import { ConceptCardComponent } from './concept-card.component';
@@ -1,5 +1,5 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component'; import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component';
import { ConceptCardComponent } from './concept-card.component'; import { ConceptCardComponent } from './concept-card.component';
import { SNIPPETS } from './snippets.generated'; import { SNIPPETS } from './snippets.generated';
+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,227 @@
# RD-27 — Make the folder equal the layer in `libs/shared/src/ui/`
Status: done
Source: PLAN.md 4a, Phase 4
## Why
CLAUDE.md decision 2 says "folder = layer": `libs/shared/ui` atoms → molecules → organisms.
Today `libs/shared/src/ui/` is 26 flat directories, and the only record of a component's layer
is its story title and a header comment. Nothing stops an atom importing an organism.
This ticket makes the structure say what the rule says. It is a **pure move**: no component
changes, no story title changes, no behaviour. RD-29 then adds the dependency-cruiser rules
that the folders make expressible.
**This is the highest-risk ticket in the arc**, because a broken `.mdx` story import compiles
fine and only fails when Storybook builds. The README says it must not be pushed without
`npm run ci --full`.
## Read first
- `libs/shared/src/ui/` — 25 flat component directories plus `upload/` with 8 of its own.
- `libs/shared/docs/atomic-design.mdx:2-5` — three of the seven `.mdx` story imports that break.
- PLAN.md 4a — the design record, including why `layout/` does not move.
- `.storybook-ssp/main.ts:11-14` — the globs, which are recursive and need no edit.
## Decisions (pre-made, don't relitigate)
1. **Three layer folders, and the full 33-directory mapping. This table is the ticket.**
`libs/shared/src/ui/atoms/` — 12 flat:
```
alert button checkbox heading link masked-value
placeholder-chip radio-group skeleton spinner status-badge text-input
```
`libs/shared/src/ui/molecules/` — 13 flat:
```
application-link application-list async choice-link choice-list confirmation
data-block data-row form-field review-section rich-text-editor stepper task-list
```
`upload/` **keeps its feature subfolder inside each layer** — 8 directories:
| New location | Directories |
| ---------------------- | ----------------------------------------------------------------------------------------------------- |
| `ui/atoms/upload/` | `delivery-channel-toggle`, `document-chip`, `file-input`, `upload-progress-bar`, `upload-status-icon` |
| `ui/molecules/upload/` | `single-upload` |
| `ui/organisms/upload/` | `document-category`, `document-upload` |
`ui/organisms/` holds nothing but `upload/`. That is correct and worth seeing.
The mapping is derived from each component's own story title, not invented — every one of
the 33 already declares its layer as `Design System/<Layer>/…`.
2. **Use `git mv` per directory, so the diff reads as renames.** `git diff --stat -M` must show
renames plus one-line import edits, nothing else.
3. **Rewrite the 28 distinct specifiers, longest key first.** All 28 are listed by the mapping
in decision 1; 25 are flat and 3 are the upload components imported from outside
(`upload/document-upload``organisms/upload/document-upload`, `upload/file-input`
`atoms/upload/file-input`, `upload/single-upload``molecules/upload/single-upload`).
Verified: **no specifier is a prefix of another**, so ordering cannot corrupt a rewrite here.
Do it longest-first anyway — it costs nothing and the property is not guaranteed to hold if
this is ever repeated.
There are 200 occurrences across 73 files, and this rewrite alone changes none of them.
**Corrected after the ticket ran: the total lands at 205, not 200.** Decision 4 converts five
relative imports into `@shared/ui/…` aliases, and each of those is itself a new occurrence.
This decision's "must not change" governs _this_ rewrite; decision 4 adds five on top. The
two decisions were written as if they counted different things, and they do not.
The honest check is per-source: 200 from the specifier rewrite, plus exactly the 5 that
decision 4 names.
4. **Five of the seven relative imports inside `upload/` become aliases; two stay relative.**
A `../sibling/` import only breaks when the sibling lands in a different layer:
| File (new location) | Import | Becomes |
| ------------------------------------ | ---------------------------- | ------------------------------------------------- |
| `organisms/upload/document-category` | `../delivery-channel-toggle` | `@shared/ui/atoms/upload/delivery-channel-toggle` |
| `organisms/upload/document-category` | `../file-input` | `@shared/ui/atoms/upload/file-input` |
| `organisms/upload/document-category` | `../single-upload` | `@shared/ui/molecules/upload/single-upload` |
| `molecules/upload/single-upload` | `../document-chip` | `@shared/ui/atoms/upload/document-chip` |
| `molecules/upload/single-upload` | `../upload-progress-bar` | `@shared/ui/atoms/upload/upload-progress-bar` |
Unchanged, because both ends stay in the same layer: `atoms/upload/document-chip`
`../upload-status-icon`, and `organisms/upload/document-upload``../document-category`.
**Use the alias, not `../../../`.** Cross-directory imports inside `libs/shared` already use
`@shared/…` (see `wizard-shell.component.ts`), and a three-level relative path is exactly the
thing a later move breaks silently.
5. **Seven `.mdx` story imports break. All seven are real imports, not prose.** The Order table
says eight; measured, it is seven:
```
a11y.mdx:2 ../src/ui/alert/alert.stories -> ui/atoms/alert/…
a11y.mdx:3 ../src/ui/form-field/form-field.stories -> ui/molecules/form-field/…
atomic-design.mdx:2 ../src/ui/button/button.stories -> ui/atoms/button/…
atomic-design.mdx:3 ../src/ui/form-field/form-field.stories -> ui/molecules/form-field/…
atomic-design.mdx:5 ../src/ui/upload/document-upload/… -> ui/organisms/upload/document-upload/…
fp-in-ui.mdx:2 ../src/ui/async/async.stories -> ui/molecules/async/…
remote-data.mdx:2 ../src/ui/async/async.stories -> ui/molecules/async/…
```
`atomic-design.mdx:4` imports `../src/layout/page-shell/…`. **Leave it alone**`layout/`
does not move.
6. **`layout/` does not move, and neither does `libs/beheer/src/ui/`.** CLAUDE.md §5 explicitly
sanctions `libs/shared/layout` holding several layers, and `libs/beheer` is a bounded context
that lives in `libs/` only because two apps share it. Both are settled; do not revisit them.
7. **Change no story title, no layer-tag comment, and no component code.** The titles already
say the right thing, which is what made decision 1's mapping derivable. The two mislabelled
tags (`async` has `/** Convenience: */`, `breadcrumb` says `/** Chrome: */`) and the two
missing ones (`masked-value`, `rich-text-editor`) are **RD-28's** job, not this ticket's.
8. **No barrel file.** The repository has none and does not need one. A barrel would also hide
exactly the layer boundary this ticket exists to expose.
## Files
- 33 directories moved under `libs/shared/src/ui/`
- ~73 files with a one-line specifier edit
- 4 `.mdx` files (7 import lines)
No changes to `angular.json`, `eslint.config.mjs`, `plopfile.mjs`, any `tsconfig.json`,
`.dependency-cruiser.*`, `scripts/check-tokens.sh`, or `e2e/`. Verified: `@shared/*` maps to
`libs/shared/src/*`, so a deeper path resolves unchanged; both Storybook globs are recursive;
dependency-cruiser's `ui-not-infrastructure` pattern is `(/ui/|/layout/)`, which still matches a
nested path; and `check-tokens.sh`'s CIBG-GAP check keys on the **directory basename**, which a
parent-folder move preserves.
## Steps
1. `mkdir` the three layer folders, then `git mv` all 33 directories per decision 1.
2. Rewrite the 28 specifiers across `apps/` and `libs/` (decision 3).
3. Fix the five relative imports inside `upload/` (decision 4).
4. Fix the seven `.mdx` imports (decision 5).
5. `npm run typecheck` — four tsconfigs, and the fastest way to catch a missed specifier.
6. `git add -A`, then run the acceptance commands.
7. Update this ticket's `Status:` to `done` and the README's RD-27 row to `done`.
8. Commit all of it together.
## Acceptance criteria
Measured against the tree before handover. Run after `git add -A`.
The structure is three folders, and every directory landed:
```bash
ls -d libs/shared/src/ui/*/ | wc -l # is 26 -> MUST be 3
ls -d libs/shared/src/ui/atoms/*/ | wc -l # MUST be 13 (12 + upload)
ls -d libs/shared/src/ui/atoms/upload/*/ | wc -l # MUST be 5
ls -d libs/shared/src/ui/molecules/*/ | wc -l # MUST be 14 (13 + upload)
ls -d libs/shared/src/ui/molecules/upload/*/ | wc -l # MUST be 1
ls -d libs/shared/src/ui/organisms/*/ | wc -l # MUST be 1 (upload)
ls -d libs/shared/src/ui/organisms/upload/*/ | wc -l # MUST be 2
```
**The single strongest check in this ticket** — every specifier now names a layer, so only three
distinct values may remain:
```bash
git grep -ho "@shared/ui/[a-z0-9-]*" -- apps libs | sort -u # is 26 values -> MUST be exactly these 3:
# @shared/ui/atoms
# @shared/ui/molecules
# @shared/ui/organisms
```
Nothing was lost or duplicated in the rewrite:
```bash
git grep -ho "@shared/ui/" -- apps libs | wc -l # is 200 -> MUST be 205 (200 + decision 4's 5)
git grep -c "src/ui/" -- '*.mdx' | awk -F: '{s+=$NF} END {print s+0}' # is 7 -> MUST still be 7
git grep -c "from '\.\./" -- libs/shared/src/ui/ | awk -F: '{s+=$NF} END {print s+0}' # is 7 -> MUST be 2
```
The move reads as a move (decision 2):
```bash
git diff --cached --stat -M | tail -1 # inspect: renames + one-line edits, no rewritten files
```
```bash
npm run ci --full # exits 0 — REQUIRED, see Verification
```
## Verification
**`npm run ci --full` is mandatory and non-negotiable for this ticket.** Plain `npm run ci` does
not build Storybook, and a `.mdx` importing a moved story path is invisible to the type-checker,
the linter and every unit test. It fails only when `build-storybook` runs. The README calls out
RD-27 by name for this reason.
Run `npm run typecheck` first anyway (step 5): it covers four tsconfigs and catches a missed
`@shared/ui/*` specifier in seconds rather than at the end of a full gate.
`libs/shared/docs/layers.mdx` deep-links two story ids
(`design-system-molecules-application-link--navigatie`,
`domein-registratie-aanvraag-block--concept`). Story ids derive from **titles**, and decision 7
changes no title, so both links survive. Do not "fix" them.
## Out of scope
- The dependency-cruiser ladder rules. RD-29 adds them, and it depends on this ticket.
- Layer-tag comments and the `libs/beheer` title rule — RD-28.
- `layout/` (decision 6).
- Any component's code, template, styles or story title.
## Risks
- **A broken `.mdx` import is invisible until Storybook builds.** This is the whole reason the
ticket carries `--full`. Decision 5 lists all seven; check each one after the move.
- **`git mv`, not `mv` + `git add`.** Both produce the same tree, but only the first keeps the
diff readable as renames — and this diff is 33 directories wide.
- **Do not flatten `upload/`** (decision 1). Its subfolder survives inside each layer.
- **Do not reach for `../../../`** (decision 4). Use the alias.
- **The occurrence count is the tripwire for a bad `sed`.** 200 before, 200 after. A rewrite
that accidentally matches twice, or drops a line, moves that number.
- **`atomic-design.mdx` has four story imports and only three of them move.** The fourth is
`page-shell`, in `layout/`.
@@ -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.
+18 -3
View File
@@ -121,9 +121,9 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di
| RD-24 | `concepts.page` to 6 sections + `concept-card` + globals + code tokens | 02 | yes | done | | RD-24 | `concepts.page` to 6 sections + `concept-card` + globals + code tokens | 02 | yes | done |
| 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 | todo | | 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
@@ -209,6 +217,13 @@ Three rules when you write a ticket file, because the agent reads its ticket and
number, say out loud what the command counts — declarations, call sites, or matching lines number, say out loud what the command counts — declarations, call sites, or matching lines
— and check that the decisions use that same meaning. — and check that the decisions use that same meaning.
RD-27 then did it a fourth time, in the very next ticket written after that rule was
recorded. Its decision 3 said the `@shared/ui/` occurrence count "must not change" at 200,
while decision 4, nine lines below, mandated converting five relative imports into
`@shared/ui/` aliases — five new occurrences by construction. The answer is 205.
**When two decisions both touch the thing you are counting, add them up before you write
the number.** Naming a habit does not retire it.
- RD-09 grepped `docs/ apps/ libs/ .claude/`, which also matched this backlog's own ticket - RD-09 grepped `docs/ apps/ libs/ .claude/`, which also matched this backlog's own ticket
files (they name the deleted method as the history of `done` work) and 22 gitignored files (they name the deleted method as the history of `done` work) and 22 gitignored
abandoned worktrees. Satisfying it literally would have corrupted completed-ticket abandoned worktrees. Satisfying it literally would have corrupted completed-ticket
+3 -3
View File
@@ -1,9 +1,9 @@
import { Component, computed, effect, inject } from '@angular/core'; import { Component, computed, effect, inject } from '@angular/core';
import { DatePipe } from '@angular/common'; import { DatePipe } from '@angular/common';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { AccessStore } from '@shared/application/access.store'; import { AccessStore } from '@shared/application/access.store';
import { successOr } from '@shared/application/remote-data'; import { successOr } from '@shared/application/remote-data';
import { AuditStore } from '@beheer/application/audit.store'; import { AuditStore } from '@beheer/application/audit.store';
+3 -3
View File
@@ -1,8 +1,8 @@
import { Component, computed, inject, signal } from '@angular/core'; import { Component, computed, inject, signal } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { AccessStore } from '@shared/application/access.store'; import { AccessStore } from '@shared/application/access.store';
import { FeatureFlagStore } from '@shared/application/feature-flags.store'; import { FeatureFlagStore } from '@shared/application/feature-flags.store';
@@ -1,5 +1,5 @@
import { Component, computed, input, output } from '@angular/core'; import { Component, computed, input, output } from '@angular/core';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { ChangeCounts, StamColumn, StamRow, StamTable, activeOn } from '@beheer/domain/stamdata'; import { ChangeCounts, StamColumn, StamRow, StamTable, activeOn } from '@beheer/domain/stamdata';
interface DisplayRow { interface DisplayRow {
+3 -3
View File
@@ -1,8 +1,8 @@
import { Component, computed, effect, inject } from '@angular/core'; import { Component, computed, effect, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { AccessStore } from '@shared/application/access.store'; import { AccessStore } from '@shared/application/access.store';
import { StamdataStore } from '@beheer/application/stamdata.store'; import { StamdataStore } from '@beheer/application/stamdata.store';
import { StamdataTableEditorComponent } from '@beheer/ui/stamdata-table-editor/stamdata-table-editor.component'; import { StamdataTableEditorComponent } from '@beheer/ui/stamdata-table-editor/stamdata-table-editor.component';
+2 -2
View File
@@ -1,6 +1,6 @@
import { Meta, Canvas } from '@storybook/addon-docs/blocks'; import { Meta, Canvas } from '@storybook/addon-docs/blocks';
import * as AlertStories from '../src/ui/alert/alert.stories'; import * as AlertStories from '../src/ui/atoms/alert/alert.stories';
import * as FormFieldStories from '../src/ui/form-field/form-field.stories'; import * as FormFieldStories from '../src/ui/molecules/form-field/form-field.stories';
<Meta title="Foundations/Accessibility" /> <Meta title="Foundations/Accessibility" />
+3 -3
View File
@@ -1,8 +1,8 @@
import { Meta, Canvas } from '@storybook/addon-docs/blocks'; import { Meta, Canvas } from '@storybook/addon-docs/blocks';
import * as ButtonStories from '../src/ui/button/button.stories'; import * as ButtonStories from '../src/ui/atoms/button/button.stories';
import * as FormFieldStories from '../src/ui/form-field/form-field.stories'; import * as FormFieldStories from '../src/ui/molecules/form-field/form-field.stories';
import * as PageShellStories from '../src/layout/page-shell/page-shell.stories'; import * as PageShellStories from '../src/layout/page-shell/page-shell.stories';
import * as DocumentUploadStories from '../src/ui/upload/document-upload/document-upload.stories'; import * as DocumentUploadStories from '../src/ui/organisms/upload/document-upload/document-upload.stories';
<Meta title="Foundations/Atomic Design" /> <Meta title="Foundations/Atomic Design" />
+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
+1 -1
View File
@@ -1,5 +1,5 @@
import { Meta, Canvas } from '@storybook/addon-docs/blocks'; import { Meta, Canvas } from '@storybook/addon-docs/blocks';
import * as AsyncStories from '../src/ui/async/async.stories'; import * as AsyncStories from '../src/ui/molecules/async/async.stories';
<Meta title="Foundations/FP in the UI" /> <Meta title="Foundations/FP in the UI" />
+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
+1 -1
View File
@@ -1,5 +1,5 @@
import { Meta, Canvas } from '@storybook/addon-docs/blocks'; import { Meta, Canvas } from '@storybook/addon-docs/blocks';
import * as AsyncStories from '../src/ui/async/async.stories'; import * as AsyncStories from '../src/ui/molecules/async/async.stories';
<Meta title="Foundations/RemoteData & Async" /> <Meta title="Foundations/RemoteData & Async" />
@@ -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({
@@ -1,6 +1,6 @@
import { Component, input } from '@angular/core'; import { Component, input } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { LinkComponent } from '@shared/ui/link/link.component'; import { LinkComponent } from '@shared/ui/atoms/link/link.component';
/** Template: standard page body optional back-link, a heading, optional intro, /** Template: standard page body optional back-link, a heading, optional intro,
and projected content. The breadcrumb lives in the site header (blue bar), so and projected content. The breadcrumb lives in the site header (blue bar), so
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig, moduleMetadata } from '@storybook/angular'; import { applicationConfig, moduleMetadata } from '@storybook/angular';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { PageShellComponent } from './page-shell.component'; import { PageShellComponent } from './page-shell.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
const meta: Meta<PageShellComponent> = { const meta: Meta<PageShellComponent> = {
title: 'Design System/Templates/PageShell', title: 'Design System/Templates/PageShell',
@@ -9,10 +9,10 @@ import {
viewChild, viewChild,
} from '@angular/core'; } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component'; import { SpinnerComponent } from '@shared/ui/atoms/spinner/spinner.component';
import { StepperComponent } from '@shared/ui/stepper/stepper.component'; import { StepperComponent } from '@shared/ui/molecules/stepper/stepper.component';
import { whenTag } from '@shared/kernel/fp'; import { whenTag } from '@shared/kernel/fp';
/** CIBG procesnavigatie primary-button copy for a non-final step: "Naar stap 2 - Werk". /** CIBG procesnavigatie primary-button copy for a non-final step: "Naar stap 2 - Werk".
@@ -1,5 +1,5 @@
import { Component, computed, input, output } from '@angular/core'; import { Component, computed, input, output } from '@angular/core';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
/** /**
* Atom: a possibly-masked sensitive value (BSN, BIG-nummer, ) with an optional, audited * Atom: a possibly-masked sensitive value (BSN, BIG-nummer, ) with an optional, audited

Some files were not shown because too many files have changed in this diff Show More