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
ehoandClaude Sonnet 5 7a8eab917b refactor: inline letter-canvas's 19 pure labels, extract letter-line (RD-26)
Twenty of letter-canvas's twenty-eight input()s are $localize labels that no
caller ever binds. Inline nineteen of them as template i18n, using the same
ids and source text so messages.en.xlf does not change. recipientText stays
an input() because its message embeds a literal \n, which as template text
becomes a different source string to Angular's extractor.

Extract letter-line.component.ts for the #line template plus the
label/auto/state/sample helpers it needs, replacing letter-canvas's three
ngTemplateOutlet incantations with one tag each. Its helpers are exported
pure functions with a spec, no TestBed.

The file stays over the 250-line budget (77 lines of CSS plus one letter's
markup), so the eslint-disable max-lines directive stays too, with its
reason rewritten to say so plainly — the only such disable left in the repo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:25:35 +02:00
ehoandClaude Opus 5 bcac3789d3 docs: fix RD-25's decision 4, and name the pattern behind three misses
Decision 4 said the parent keeps seven outputs while decision 5, four lines
below, said all eleven remain and are re-emitted from children. Seven is how
many the parent raises; eleven is how many it declares. The acceptance line
copied the wrong one, and satisfying it would have broken
`org-template.page.ts`, which binds all eleven.

RD-08, RD-23 and RD-25 are the same mistake: a decision describes the design
in one vocabulary, and the acceptance line counts something else that shares a
word. Name what the command counts before writing the number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 00:03:30 +02:00
ehoandClaude Sonnet 5 cf1f641534 refactor: split org-template-editor by output cluster (RD-25)
org-template-editor.component.ts carried an eslint-disable for max-lines,
padded by a dead sample-letter constant, 13 label inputs that were never
bindable, and two self-contained mutation clusters. Split all three out:

- SAMPLE_LETTER_BRIEF moves to brief/domain/sample-letter.ts. It is
  production content (the letter the admin previews), not a test fixture,
  so it stays out of brief.testing.ts (no-testing-in-production forbids
  production code from reaching a *.testing.ts file).
- 11 of the 13 label inputs become inline i18n template text. The two
  that interpolate MARGIN_MIN_MM/MARGIN_MAX_MM (marginsLegend,
  invalidHint) stay in TS, because moving an interpolated $localize call
  into a template renames the xlf placeholder and breaks the translation
  merge. Every id is preserved; messages.en.xlf is unchanged.
- logo-upload.component.ts and version-history.component.ts each take
  one output cluster. The parent still declares and re-emits all 11
  outputs — org-template.page.ts binds them directly on
  <app-org-template-editor> and is out of this ticket's file scope, so
  the parent's public surface cannot shrink.

Correction to the ticket while executing it: its acceptance check for
"= output" on the parent read "MUST be 7", copying decision 4's cluster
count instead of decision 5's (and the ticket's own Risks section's)
explicit requirement that the parent keep all 11 declarations. Fixed the
ticket's acceptance section to the correct number.

npm run ci --full is green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:02:30 +02:00
ehoandClaude Opus 5 5aed15bb98 docs: record the supervisor-as-second-writer mistake from RD-24
A task notification fires every time an agent stops with no live children,
including a pause mid-task. RD-24's agent paused and notified; the supervisor
read that as abandonment, took over the tree, ran the gate and edited the
ticket Status while the agent was still working. The agent resumed, correctly
detected a second writer, and refused to commit.

This is the mirror image of the RD-17 race the section above records: there a
fresh supervisor met a running agent, here a running agent met its own
supervisor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 23:44:17 +02:00
ehoandClaude Sonnet 5 630d68045f refactor: split concepts.page into 6 sections, fix dead highlighting (RD-24)
The page held six teaching sections and a 142-line `styles:` block, at 471
effective lines against a limit of 250. It is now 36 lines of composition.

Angular scopes a component's CSS to markup that component rendered, so the
split had to move each rule to its owner. `concept-card` owns the card
vocabulary and renders it. `.app-code`, `.app-lead`, `.app-cols` and
`.app-note` become globals, because their targets are projected or arrive
through `[innerHTML]`.

That constraint exposed a live bug. The syntax-highlighting rules compiled to
`pre[_ngcontent-%COMP%] .k[_ngcontent-%COMP%]`, but `highlight-ts` injects the
`.k`/`.s`/`.c` spans through `[innerHTML]`, so they carry no scope attribute
and the rule never matched. Keywords, strings and comments have always
rendered in the plain foreground colour. The rules are global now, on five new
`--app-code-*` tokens.

Widen the colour guard while here: it scanned only `*.component.ts`, so every
`*.page.ts`, `*.section.ts` and `*.step.ts` was invisible to it. That is how
this page collected 21 hardcoded colours. One other file needed a fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 23:44:01 +02:00
ehoandClaude Opus 5 d5a7a25a78 docs: record RD-23's two acceptance misses as recurrences
Both repeat a habit this README already documents, so they belong beside it
rather than as new entries.

`git grep -c` counts lines: a symbol that a file imports and then calls is two
lines, never one. And a check must not grep for a word that the ticket's own
mandated snippet contains — RD-23 required the upload controller's
`dispatch:` property and then asserted zero occurrences of `dispatch`.

The agent hit both, refused to distort the code, and corrected the ticket text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 23:18:26 +02:00
ehoandClaude Sonnet 5 11e3191099 refactor: split registratie-wizard into three steps (RD-23)
Move the adres, beroep and controle cases out of registratie-wizard.component.ts
into adres.step.ts, beroep.step.ts and controle.step.ts, matching RD-22's
*.step.ts convention. The parent drops from ~568 to 274 lines and loses its
`eslint-disable max-lines`.

The upload controller moves into beroep.step.ts and emits `uploadMsg` instead of
dispatching directly; the parent maps that back onto the machine's `Upload`
message. `onDiplomaKeuze` stays in the parent (message construction from the DUO
payload belongs in the container) and now takes only the chosen id, reading its
own `duoData` computed instead of receiving the DUO payload as an argument. Each
step injects `RegistratieLookupStore` directly for its own async presentation
(adresStatus, the DUO lookup, samenvattingVragen) — the sanctioned exception,
since it is a root singleton. Markup moved verbatim; the `@@` id count across
the directory stays 43.

Two of the ticket's acceptance numbers do not hold against correct code and are
corrected in the ticket file: `createUploadController` is 2 lines (import +
call), not 1 — `git grep -c` counts lines, and the same shape gives 2 for
`createStore` and 3 for `createDraftSync` elsewhere in this codebase. `dispatch`
is 1, not 0 — decision 4's mandated `UploadControllerDeps.dispatch` property
name is that string even though it is not the machine's dispatch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 23:17:25 +02:00
ehoandClaude Sonnet 5 8e1de38c68 refactor: split intake-wizard into three step components (RD-22)
The parent held one @switch with three @case blocks — three screens'
markup in one file. Each case is independent and needs only the
answers, the errors, and (for two of them) the scholing threshold.

Extract buitenland.step.ts, werk.step.ts, and review.step.ts as pure,
presentational steps: inputs down, one narrow output up, dispatch
never passed down. The parent keeps the store, the shell, and
draftSync, and maps each step's output back to a machine message.

This is the first *.step.ts in the repo, so it sets the naming
convention that RD-23 does the same job with.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 22:58:52 +02:00
ehoandClaude Sonnet 5 4b3e6a6cfd refactor: move selection surgery into rich-text-dom.ts (RD-21)
deleteAdjacentChip and insert did getSelection()/Range work inside the
component, which pushed it over the max-lines budget under a disable
comment. rich-text-dom.ts already owns the DOM boundary, so the surgery
moves there as two new exports, chipAtCaret and insertChipAtCaret, and
the component keeps only its event-handling and output concerns.

adjacentChip stays exported with its own spec case. The component
disable comment is gone, since the file is now under the line budget.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 22:42:21 +02:00
ehoandClaude Opus 5 8a3e42015b docs: add RD-36 — rename the stale ui/dashboard/ folder
RD-03 moved the dashboard page into the `overzicht` context and RD-04 left the
folder name alone, recording it as stale. It stays stale: the folder is named
after a page that lives somewhere else, so a reader looking for the dashboard
finds four sections that are not it.

Measured cost: 8 `git mv`s and four import lines in one file. Fold in two doc
paths that still name the moved page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:31:55 +02:00
163 changed files with 4533 additions and 1652 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',
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
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`
or `libs/beheer/ui` 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,
regardless of which atomic layer it is (a context organism doesn't get its own
`Organisms/` bucket).
component is titled `Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>`;
a component in an app context's `ui/`, or in `libs/beheer/ui`, is titled
`Domein/<Context>/<Name>` — full stop, regardless of which atomic layer it is (a context
organism doesn't get its own `Organisms/` bucket).
## Conventions
@@ -1,5 +1,5 @@
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,
@@ -2,12 +2,12 @@ import { Component, computed, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { successOf } from '@shared/application/remote-data';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { BeoordelingStore } from '@behandeling/application/beoordeling.store';
import { detailRows } from '@behandeling/domain/beoordeling-view';
import { BeoordelingDocumentenComponent } from '@behandeling/ui/beoordeling-documenten/beoordeling-documenten.component';
@@ -1,13 +1,16 @@
import { Component, computed, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { RadioGroupComponent, RadioOption } from '@shared/ui/radio-group/radio-group.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import {
RadioGroupComponent,
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 { whenTag } from '@shared/kernel/fp';
import { BesluitState, BesluitMsg, initial, reduce } from '@behandeling/domain/besluit.machine';
@@ -1,6 +1,6 @@
import { Component, input } from '@angular/core';
import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component';
import { ApplicationListComponent } from '@shared/ui/molecules/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/molecules/application-link/application-link.component';
import { WerkvoorraadItem } from '@behandeling/domain/werkvoorraad-item';
import { werkvoorraadRow } from '@behandeling/domain/werkvoorraad-item-view';
@@ -1,9 +1,9 @@
import { Component, computed, effect, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { AccessStore } from '@shared/application/access.store';
import { successOr } from '@shared/application/remote-data';
import { WerkvoorraadStore } from '@behandeling/application/werkvoorraad.store';
@@ -1,8 +1,8 @@
import { Component, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
/** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */
@Component({
+1 -1
View File
@@ -1,7 +1,7 @@
import { Component, inject, signal } from '@angular/core';
import { Router } from '@angular/router';
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 { SessionStore } from '@auth/application/session.store';
@@ -0,0 +1,51 @@
import { Brief } from './brief';
/** A minimal read-only sample letter, so the admin sees the org identity in context
while editing (content itself is not the admin's to change). Production content —
the letter the org-template editor previews — not a test fixture, so it lives here
rather than in `brief.testing.ts` (dependency-cruiser's no-testing-in-production
rule forbids production code from reaching any `*.testing.ts`). */
export const SAMPLE_LETTER_BRIEF: Brief = {
briefId: 'VOORBEELD-0001',
beroep: 'arts',
templateId: 'sample',
drafterId: 'sample',
status: { tag: 'draft' },
placeholders: [
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
{ key: 'datum', label: 'Datum', autoResolvable: true },
],
sections: [
{
sectionKey: 'body',
title: 'Voorbeeldinhoud',
required: true,
locked: true,
blocks: [
{
type: 'freeText',
blockId: 'sample-1',
content: {
paragraphs: [
{
nodes: [
{ type: 'text', text: 'Geachte ' },
{ type: 'placeholder', key: 'naam_zorgverlener' },
{ type: 'text', text: ',' },
],
},
{
nodes: [
{
type: 'text',
text: 'Dit is voorbeeldinhoud. Alleen de huisstijl-onderdelen (logo, afzender, ondertekening en voettekst) zijn hier bewerkbaar.',
},
],
},
],
},
},
],
},
],
};
@@ -1,10 +1,10 @@
import { Component, ElementRef, computed, input, output, viewChild } from '@angular/core';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { MaskedValueComponent } from '@shared/ui/masked-value/masked-value.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { StepperComponent } from '@shared/ui/stepper/stepper.component';
import { PlaceholderOption } from '@shared/ui/molecules/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { MaskedValueComponent } from '@shared/ui/atoms/masked-value/masked-value.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { StepperComponent } from '@shared/ui/molecules/stepper/stepper.component';
import { Besluit, Brief, CaseContext, LibraryPassage } from '@brief/domain/brief';
import { besluitGuidance, inferSelection } from '@brief/domain/besluit';
import { Diagnostic } from '@brief/domain/placeholders';
@@ -1,8 +1,11 @@
import { Component, computed, input, linkedSignal, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component';
import { RadioGroupComponent, RadioOption } from '@shared/ui/radio-group/radio-group.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { CheckboxComponent } from '@shared/ui/atoms/checkbox/checkbox.component';
import {
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 { redenenFor } from '@brief/domain/besluit';
+3 -3
View File
@@ -1,8 +1,8 @@
import { Component, computed, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { BriefStore } from '@brief/application/brief.store';
import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component';
import { BehandelSchermComponent } from '@brief/ui/behandel-scherm/behandel-scherm.component';
@@ -1,5 +1,5 @@
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';
/** Molecule: lists all letter diagnostics grouped by severity. Errors block
@@ -3,8 +3,8 @@ import { RichTextBlock } from '@shared/kernel/rich-text';
import {
RichTextEditorComponent,
PlaceholderOption,
} from '@shared/ui/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
} from '@shared/ui/molecules/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { LetterBlock } from '@brief/domain/brief';
/** Molecule: one block in a section — its editor plus provenance + block controls.
@@ -1,4 +1,6 @@
/* eslint-disable max-lines */ // 77 lines are CSS, the rest is one letter — RD-26 rewrites this reason, keeps the disable
/* eslint-disable max-lines */ // 77 lines of CSS + one letter's markup; splitting it into
// letterhead/body/signature/footer makes "what does the letter look like" a five-file
// question for no behavioural seam. Deliberate, not deferred.
import {
Component,
DestroyRef,
@@ -12,9 +14,7 @@ import {
signal,
viewChild,
} from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { formatDatumNl } from '@shared/kernel/datum';
import { Paragraph } from '@shared/kernel/rich-text';
import { Brief, LetterBlock } from '@brief/domain/brief';
@@ -22,6 +22,7 @@ import { OrgTemplate } from '@brief/domain/org-template';
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
import { Diagnostic } from '@brief/domain/placeholders';
import { BlockDiffKind } from '@brief/domain/brief-diff';
import { LetterLineComponent } from './letter-line.component';
/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */
type PreviewSegment = {
@@ -40,12 +41,6 @@ function groupParagraphs(paras: readonly Paragraph[]): PreviewSegment[] {
return out;
}
// Illustrative values for the "Voorbeeld" toggle — what send resolves server-side.
const SAMPLE_VALUES: Record<string, string> = {
naam_zorgverlener: 'J. Jansen',
big_nummer: '12345678901',
};
/** A4 height in CSS px (1in = 96px = 25.4mm) — for the approximate page-break marks. */
const A4_HEIGHT_PX = (297 * 96) / 25.4;
@@ -55,10 +50,12 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
renders everything read-only (approver/locked, absorbs the old letter-preview),
`'template'` reserves the org-identity regions for the admin editor.
Letter typography/geometry come from the shared `public/letter.css` contract —
the same file the backend preview renderer inlines. */
the same file the backend preview renderer inlines. Each rendered line is its own
`app-letter-line` (RD-26), replacing the outlet-template indirection that stood
in for it. */
@Component({
selector: 'app-letter-canvas',
imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent],
imports: [ButtonComponent, LetterLineComponent],
styles: [
`
:host {
@@ -137,37 +134,19 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
`,
],
template: `
<ng-template #line let-nodes>
@for (node of nodes; track $index) {
@switch (node.type) {
@case ('text') {
<span>{{ node.text }}</span>
}
@case ('lineBreak') {
<br />
}
@case ('placeholder') {
@if (showSample() && autoFor(node.key)) {
<span>{{ sampleFor(node.key) }}</span>
} @else {
<app-placeholder-chip
[label]="labelFor(node.key)"
[autoResolvable]="autoFor(node.key)"
[state]="stateFor(node.key)"
/>
}
}
}
}
</ng-template>
@if (editableRegions() !== 'template') {
<div class="toolbar">
<div class="zoom" role="group" [attr.aria-label]="zoomGroupLabel()">
<div
class="zoom"
role="group"
aria-label="Zoomniveau"
i18n-aria-label="@@brief.canvas.zoom"
>
<app-button
variant="subtle"
[disabled]="zoomLevel() <= 0.5"
[attr.aria-label]="zoomOutLabel()"
aria-label="Uitzoomen"
i18n-aria-label="@@brief.canvas.zoomOut"
(click)="zoomBy(-0.1)"
></app-button
>
@@ -175,13 +154,14 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
<app-button
variant="subtle"
[disabled]="zoomLevel() >= 1.5"
[attr.aria-label]="zoomInLabel()"
aria-label="Inzoomen"
i18n-aria-label="@@brief.canvas.zoomIn"
(click)="zoomBy(0.1)"
>+</app-button
>
<app-button variant="subtle" (click)="zoomLevel.set(1)">{{
zoomResetLabel()
}}</app-button>
<app-button variant="subtle" (click)="zoomLevel.set(1)" i18n="@@brief.canvas.zoomReset"
>100%</app-button
>
</div>
@if (editableRegions() === 'none') {
<app-button
@@ -189,7 +169,13 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
(click)="showSample.set(!showSample())"
[attr.aria-pressed]="showSample()"
>
{{ showSample() ? hideSampleLabel() : showSampleLabel() }}
@if (showSample()) {
<ng-container i18n="@@brief.preview.hideSample">Testwaarden verbergen</ng-container>
} @else {
<ng-container i18n="@@brief.preview.showSample"
>Voorbeeld met testwaarden</ng-container
>
}
</app-button>
}
</div>
@@ -201,20 +187,27 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
(robijn footer background) — the letter surface must stay letter.css-only. -->
<div class="letter__letterhead">
@if (logoUrl()) {
<img class="org-logo" [src]="logoUrl()" [alt]="logoAlt()" />
<img
class="org-logo"
[src]="logoUrl()"
alt="Logo van de organisatie"
i18n-alt="@@brief.canvas.logoAlt"
/>
}
@if (editing()) {
<input
class="tmpl-input org-wordmark"
[value]="orgTemplate().orgName"
[attr.aria-label]="orgNameLabel()"
aria-label="Organisatienaam"
i18n-aria-label="@@brief.canvas.orgName"
(input)="emitEdit('orgName', $event)"
/>
<textarea
class="tmpl-textarea return-address"
rows="2"
[value]="orgTemplate().returnAddress"
[attr.aria-label]="returnAddressLabel()"
aria-label="Retouradres"
i18n-aria-label="@@brief.canvas.returnAddress"
(input)="emitEdit('returnAddress', $event)"
></textarea>
} @else {
@@ -224,11 +217,11 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
<address class="address-window">{{ recipientText() }}</address>
<dl class="reference">
<div>
<dt>{{ referenceLabel() }}</dt>
<dt i18n="@@brief.canvas.reference">Ons kenmerk</dt>
<dd>{{ brief().briefId }}</dd>
</div>
<div>
<dt>{{ dateLabel() }}</dt>
<dt i18n="@@brief.canvas.date">Datum</dt>
<dd>{{ letterDate }}</dd>
</div>
</dl>
@@ -242,18 +235,27 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
@let diffKind = showDiff() ? blockDiffs().get(block.blockId) : undefined;
<div class="diff-block" [class.diff-changed]="!!diffKind">
@if (diffKind) {
<span class="diff-badge" [class.added]="diffKind === 'added'">{{
diffLabel(diffKind)
}}</span>
<span class="diff-badge" [class.added]="diffKind === 'added'">
@if (diffKind === 'added') {
<ng-container i18n="@@brief.diff.added">nieuw</ng-container>
} @else {
<ng-container i18n="@@brief.diff.changed"
>gewijzigd sinds afwijzing</ng-container
>
}
</span>
}
@for (seg of segmentsOf(block); track $index) {
@if (seg.list === 'bullet') {
<ul>
@for (para of seg.items; track $index) {
<li>
<ng-container
[ngTemplateOutlet]="line"
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
<app-letter-line
[nodes]="para.nodes"
[showSample]="showSample()"
[placeholders]="brief().placeholders"
[diagnostics]="diagnostics()"
[sampleDate]="letterDate"
/>
</li>
}
@@ -262,18 +264,24 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
<ol>
@for (para of seg.items; track $index) {
<li>
<ng-container
[ngTemplateOutlet]="line"
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
<app-letter-line
[nodes]="para.nodes"
[showSample]="showSample()"
[placeholders]="brief().placeholders"
[diagnostics]="diagnostics()"
[sampleDate]="letterDate"
/>
</li>
}
</ol>
} @else {
<p>
<ng-container
[ngTemplateOutlet]="line"
[ngTemplateOutletContext]="{ $implicit: seg.items[0].nodes }"
<app-letter-line
[nodes]="seg.items[0].nodes"
[showSample]="showSample()"
[placeholders]="brief().placeholders"
[diagnostics]="diagnostics()"
[sampleDate]="letterDate"
/>
</p>
}
@@ -289,19 +297,22 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
<input
class="tmpl-input"
[value]="orgTemplate().signatureClosing"
[attr.aria-label]="signatureClosingLabel()"
aria-label="Afsluiting"
i18n-aria-label="@@brief.canvas.signatureClosing"
(input)="emitEdit('signatureClosing', $event)"
/>
<input
class="tmpl-input signature-name"
[value]="orgTemplate().signatureName"
[attr.aria-label]="signatureNameLabel()"
aria-label="Naam ondertekenaar"
i18n-aria-label="@@brief.canvas.signatureName"
(input)="emitEdit('signatureName', $event)"
/>
<input
class="tmpl-input"
[value]="orgTemplate().signatureRole"
[attr.aria-label]="signatureRoleLabel()"
aria-label="Functie ondertekenaar"
i18n-aria-label="@@brief.canvas.signatureRole"
(input)="emitEdit('signatureRole', $event)"
/>
} @else {
@@ -317,13 +328,15 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
class="tmpl-textarea footer-contact"
rows="2"
[value]="orgTemplate().footerContact"
[attr.aria-label]="footerContactLabel()"
aria-label="Contactgegevens (voettekst)"
i18n-aria-label="@@brief.canvas.footerContact"
(input)="emitEdit('footerContact', $event)"
></textarea>
<input
class="tmpl-input footer-legal"
[value]="orgTemplate().footerLegal"
[attr.aria-label]="footerLegalLabel()"
aria-label="Juridische voettekst"
i18n-aria-label="@@brief.canvas.footerLegal"
(input)="emitEdit('footerLegal', $event)"
/>
} @else {
@@ -334,7 +347,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
@for (top of pageBreaks(); track $index) {
<div class="letter__page-break" [style.top.px]="top" aria-hidden="true">
<span>{{ pageBreakCaption() }}</span>
<span i18n="@@brief.canvas.pageBreak">±pagina-einde — afdrukvoorbeeld is leidend</span>
</div>
}
</div>
@@ -360,30 +373,13 @@ export class LetterCanvasComponent {
/** An in-place edit to an org-identity field (only in `editableRegions='template'`). */
templateEdit = output<{ field: OrgTemplateTextField; value: string }>();
showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`);
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
pageBreakCaption = input(
$localize`:@@brief.canvas.pageBreak:±pagina-einde — afdrukvoorbeeld is leidend`,
);
/** The one label kept as an `input()` rather than inlined `i18n` (RD-26, decision 2):
its message embeds a literal `\n`. As template text that `\n` becomes a source
line break, a different string to Angular's extractor — so inlining it would
change the extracted source text, unlike the other 19 labels this ticket inlines. */
recipientText = input(
$localize`:@@brief.canvas.recipient:Adres van de geadresseerde\n(wordt ingevuld bij verzending)`,
);
referenceLabel = input($localize`:@@brief.canvas.reference:Ons kenmerk`);
dateLabel = input($localize`:@@brief.canvas.date:Datum`);
logoAlt = input($localize`:@@brief.canvas.logoAlt:Logo van de organisatie`);
orgNameLabel = input($localize`:@@brief.canvas.orgName:Organisatienaam`);
returnAddressLabel = input($localize`:@@brief.canvas.returnAddress:Retouradres`);
signatureClosingLabel = input($localize`:@@brief.canvas.signatureClosing:Afsluiting`);
signatureNameLabel = input($localize`:@@brief.canvas.signatureName:Naam ondertekenaar`);
signatureRoleLabel = input($localize`:@@brief.canvas.signatureRole:Functie ondertekenaar`);
footerContactLabel = input($localize`:@@brief.canvas.footerContact:Contactgegevens (voettekst)`);
footerLegalLabel = input($localize`:@@brief.canvas.footerLegal:Juridische voettekst`);
zoomGroupLabel = input($localize`:@@brief.canvas.zoom:Zoomniveau`);
zoomInLabel = input($localize`:@@brief.canvas.zoomIn:Inzoomen`);
zoomOutLabel = input($localize`:@@brief.canvas.zoomOut:Uitzoomen`);
zoomResetLabel = input($localize`:@@brief.canvas.zoomReset:100%`);
addedLabel = input($localize`:@@brief.diff.added:nieuw`);
changedLabel = input($localize`:@@brief.diff.changed:gewijzigd sinds afwijzing`);
protected showSample = signal(false);
protected letterDate = formatDatumNl(new Date());
@@ -395,9 +391,6 @@ export class LetterCanvasComponent {
// clamp 0.51.5; round to avoid float drift accumulating on repeated clicks.
this.zoomLevel.update((z) => Math.round(Math.min(1.5, Math.max(0.5, z + delta)) * 10) / 10);
}
protected diffLabel = (kind: BlockDiffKind) =>
kind === 'added' ? this.addedLabel() : this.changedLabel();
/** Admin edit-in-place: the org-identity regions render as controls. */
protected editing = computed(() => this.editableRegions() === 'template');
@@ -418,25 +411,9 @@ export class LetterCanvasComponent {
};
});
// --- read-only rendering helpers (migrated from the superseded letter-preview) ---
private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p])));
private worst = computed(() => {
const m = new Map<string, 'error' | 'warning'>();
for (const d of this.diagnostics()) {
if (!d.placeholderKey) continue;
if (d.severity === 'error') m.set(d.placeholderKey, 'error');
else if (!m.has(d.placeholderKey)) m.set(d.placeholderKey, 'warning');
}
return m;
});
// --- read-only rendering helper (migrated from the superseded letter-preview) ---
protected segmentsOf = (block: LetterBlock) => groupParagraphs(block.content.paragraphs);
protected labelFor = (key: string) => this.defs().get(key)?.label ?? key;
protected autoFor = (key: string) => this.defs().get(key)?.autoResolvable ?? false;
protected stateFor = (key: string): 'ok' | 'warning' | 'error' => this.worst().get(key) ?? 'ok';
protected sampleFor = (key: string) =>
SAMPLE_VALUES[key] ?? (key === 'datum' ? this.letterDate : this.labelFor(key));
// --- approximate page-break marks (PRD §2b: honest "±", print preview is leading) ---
@@ -0,0 +1,104 @@
import { Component, computed, input } from '@angular/core';
import { PlaceholderChipComponent } from '@shared/ui/atoms/placeholder-chip/placeholder-chip.component';
import { RichTextNode } from '@shared/kernel/rich-text';
import { Diagnostic, PlaceholderDef } from '@brief/domain/placeholders';
// Illustrative values for the "Voorbeeld" toggle — what send resolves server-side.
const SAMPLE_VALUES: Record<string, string> = {
naam_zorgverlener: 'J. Jansen',
big_nummer: '12345678901',
};
/** `brief().placeholders` keyed by `key` for O(1) lookup from a node. */
export function placeholderDefs(
placeholders: readonly PlaceholderDef[],
): Map<string, PlaceholderDef> {
return new Map(placeholders.map((p) => [p.key, p]));
}
/** The worst (error over warning) diagnostic severity per placeholder key. */
export function worstSeverities(
diagnostics: readonly Diagnostic[],
): Map<string, 'error' | 'warning'> {
const m = new Map<string, 'error' | 'warning'>();
for (const d of diagnostics) {
if (!d.placeholderKey) continue;
if (d.severity === 'error') m.set(d.placeholderKey, 'error');
else if (!m.has(d.placeholderKey)) m.set(d.placeholderKey, 'warning');
}
return m;
}
export function resolveLabel(defs: ReadonlyMap<string, PlaceholderDef>, key: string): string {
return defs.get(key)?.label ?? key;
}
export function resolveAuto(defs: ReadonlyMap<string, PlaceholderDef>, key: string): boolean {
return defs.get(key)?.autoResolvable ?? false;
}
export function resolveState(
worst: ReadonlyMap<string, 'error' | 'warning'>,
key: string,
): 'ok' | 'warning' | 'error' {
return worst.get(key) ?? 'ok';
}
/** The "Voorbeeld" toggle's stand-in for an auto-resolvable placeholder: a canned
sample, the caller's sample date for `datum`, or the field's own label. */
export function resolveSample(
defs: ReadonlyMap<string, PlaceholderDef>,
sampleDate: string,
key: string,
): string {
return SAMPLE_VALUES[key] ?? (key === 'datum' ? sampleDate : resolveLabel(defs, key));
}
/** Organism: one rendered line of letter content — text runs, line breaks and
placeholder chips (an auto-resolvable one swaps to a sample value when
`showSample` is on). Extracted from `letter-canvas` (RD-26): the `#line`
template plus the label/auto/state/sample helpers it needs, so the canvas's
three `ngTemplateOutlet` incantations become one tag each. */
@Component({
selector: 'app-letter-line',
imports: [PlaceholderChipComponent],
template: `
@for (node of nodes(); track $index) {
@switch (node.type) {
@case ('text') {
<span>{{ node.text }}</span>
}
@case ('lineBreak') {
<br />
}
@case ('placeholder') {
@if (showSample() && autoFor(node.key)) {
<span>{{ sampleFor(node.key) }}</span>
} @else {
<app-placeholder-chip
[label]="labelFor(node.key)"
[autoResolvable]="autoFor(node.key)"
[state]="stateFor(node.key)"
/>
}
}
}
}
`,
})
export class LetterLineComponent {
nodes = input.required<readonly RichTextNode[]>();
showSample = input(false);
placeholders = input<readonly PlaceholderDef[]>([]);
diagnostics = input<readonly Diagnostic[]>([]);
/** The canvas's `formatDatumNl(new Date())`, passed down so every line agrees. */
sampleDate = input('');
private defs = computed(() => placeholderDefs(this.placeholders()));
private worst = computed(() => worstSeverities(this.diagnostics()));
protected labelFor = (key: string) => resolveLabel(this.defs(), key);
protected autoFor = (key: string) => resolveAuto(this.defs(), key);
protected stateFor = (key: string) => resolveState(this.worst(), key);
protected sampleFor = (key: string) => resolveSample(this.defs(), this.sampleDate(), key);
}
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest';
import { PlaceholderDef, Diagnostic } from '@brief/domain/placeholders';
import {
placeholderDefs,
resolveAuto,
resolveLabel,
resolveSample,
resolveState,
worstSeverities,
} from './letter-line.component';
const DEFS: readonly PlaceholderDef[] = [
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false },
];
const LOCATION = { blockId: 'b1', paragraphIndex: 0, nodeIndex: 0 };
function diagnostic(placeholderKey: string, severity: 'error' | 'warning'): Diagnostic {
return { severity, code: 'unresolved-at-send', message: 'x', placeholderKey, location: LOCATION };
}
describe('placeholderDefs', () => {
it('keys the placeholder list by its key', () => {
const defs = placeholderDefs(DEFS);
expect(defs.get('naam_zorgverlener')?.label).toBe('Naam zorgverlener');
expect(defs.get('unknown')).toBeUndefined();
});
});
describe('worstSeverities', () => {
it('ignores a diagnostic with no placeholder key', () => {
const worst = worstSeverities([
{ severity: 'error', code: 'malformed', message: 'x', location: LOCATION },
]);
expect(worst.size).toBe(0);
});
it('keeps error over a warning already recorded for the same key', () => {
const worst = worstSeverities([
diagnostic('naam_zorgverlener', 'warning'),
diagnostic('naam_zorgverlener', 'error'),
]);
expect(worst.get('naam_zorgverlener')).toBe('error');
});
it('does not let a later warning downgrade an error', () => {
const worst = worstSeverities([
diagnostic('naam_zorgverlener', 'error'),
diagnostic('naam_zorgverlener', 'warning'),
]);
expect(worst.get('naam_zorgverlener')).toBe('error');
});
});
describe('resolveLabel', () => {
it('returns the field label for a known key', () => {
expect(resolveLabel(placeholderDefs(DEFS), 'reden_besluit')).toBe('Reden besluit');
});
it('falls back to the bare key when the field is unknown', () => {
expect(resolveLabel(placeholderDefs(DEFS), 'unknown')).toBe('unknown');
});
});
describe('resolveAuto', () => {
it('reads autoResolvable off the field', () => {
const defs = placeholderDefs(DEFS);
expect(resolveAuto(defs, 'naam_zorgverlener')).toBe(true);
expect(resolveAuto(defs, 'reden_besluit')).toBe(false);
});
it('defaults to false for an unknown key', () => {
expect(resolveAuto(placeholderDefs(DEFS), 'unknown')).toBe(false);
});
});
describe('resolveState', () => {
it('defaults to ok when the key has no diagnostic', () => {
expect(resolveState(worstSeverities([]), 'naam_zorgverlener')).toBe('ok');
});
it('surfaces the worst recorded severity', () => {
const worst = worstSeverities([diagnostic('naam_zorgverlener', 'warning')]);
expect(resolveState(worst, 'naam_zorgverlener')).toBe('warning');
});
});
describe('resolveSample', () => {
it('prefers the canned sample value over the label', () => {
expect(resolveSample(placeholderDefs(DEFS), '4 september 2026', 'naam_zorgverlener')).toBe(
'J. Jansen',
);
});
it('resolves datum to the caller-supplied sample date', () => {
expect(resolveSample(placeholderDefs(DEFS), '4 september 2026', 'datum')).toBe(
'4 september 2026',
);
});
it('falls back to the field label for anything else', () => {
expect(resolveSample(placeholderDefs(DEFS), '4 september 2026', 'reden_besluit')).toBe(
'Reden besluit',
);
});
});
@@ -1,8 +1,8 @@
import { Component, computed, input, output, signal } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { StatusBadgeComponent } from '@shared/ui/atoms/status-badge/status-badge.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { Brief } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template';
import { Diagnostic } from '@brief/domain/placeholders';
@@ -1,5 +1,5 @@
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 { BriefMsg } from '@brief/domain/brief.machine';
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
@@ -1,8 +1,8 @@
import { Component, input, output } from '@angular/core';
import { RichTextBlock } from '@shared/kernel/rich-text';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { PlaceholderOption } from '@shared/ui/molecules/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { LetterSection } from '@brief/domain/brief';
import { BriefMsg } from '@brief/domain/brief.machine';
import { LetterBlockComponent } from '@brief/ui/letter-block/letter-block.component';
@@ -0,0 +1,77 @@
import { Component, computed, input, output } from '@angular/core';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { FileInputComponent } from '@shared/ui/atoms/upload/file-input/file-input.component';
import { SingleUploadComponent } from '@shared/ui/molecules/upload/single-upload/single-upload.component';
import { UploadState } from '@shared/domain/upload.machine';
const LOGO_CATEGORY = 'org-logo';
/**
* Organism: the org-template editor's logo-upload block, split out of
* `org-template-editor.component.ts` (RD-25) — one of its two self-contained
* mutation clusters. Presentational: every mutation is an output the parent
* re-emits unchanged.
*/
@Component({
selector: 'app-logo-upload',
imports: [HeadingComponent, AlertComponent, FileInputComponent, SingleUploadComponent],
styles: [
`
:host {
display: block;
}
.section {
margin-block-start: var(--rhc-space-max-xl);
}
`,
],
template: `
<section class="section">
<app-heading [level]="3" i18n="@@orgTemplate.logo">Logo</app-heading>
@if (logoCategory()) {
<app-file-input
inputId="org-logo-input"
[accept]="logoCategory()!.acceptedTypes"
[maxSizeMb]="logoCategory()!.maxSizeMb"
i18n-label="@@orgTemplate.logo"
label="Logo"
(filesSelected)="logoSelected.emit($event)"
/>
}
@if (logoRejection()) {
<app-alert type="error">{{ logoRejection() }}</app-alert>
}
@if (logoUploads().length) {
<ul class="file-list">
@for (u of logoUploads(); track u.localId) {
<li
app-single-upload
[upload]="u"
[previewUrlFor]="previewUrlFor()"
(remove)="logoRemoved.emit(u.localId)"
(retry)="logoRetry.emit(u.localId)"
></li>
}
</ul>
}
</section>
`,
})
export class LogoUploadComponent {
logoUrl = input<string | null>(null);
uploadState = input.required<UploadState>();
previewUrlFor = input<(documentId: string) => string | undefined>();
logoSelected = output<File[]>();
logoRemoved = output<string>();
logoRetry = output<string>();
protected logoCategory = computed(() =>
this.uploadState().categories.find((c) => c.categoryId === LOGO_CATEGORY),
);
protected logoUploads = computed(() =>
this.uploadState().uploads.filter((u) => u.categoryId === LOGO_CATEGORY),
);
protected logoRejection = computed(() => this.uploadState().rejections[LOGO_CATEGORY]);
}
@@ -1,13 +1,9 @@
/* eslint-disable max-lines */ // sample letter + labels + editor in one file — removed by RD-25
import { Component, computed, input, output } from '@angular/core';
import { DatePipe } from '@angular/common';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { FileInputComponent } from '@shared/ui/upload/file-input/file-input.component';
import { SingleUploadComponent } from '@shared/ui/upload/single-upload/single-upload.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { UploadState } from '@shared/domain/upload.machine';
import { Brief } from '@brief/domain/brief';
import { SAMPLE_LETTER_BRIEF } from '@brief/domain/sample-letter';
import {
MARGIN_MAX_MM,
MARGIN_MIN_MM,
@@ -18,74 +14,29 @@ import {
} from '@brief/domain/org-template';
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
import { LogoUploadComponent } from './logo-upload.component';
import { VersionHistoryComponent } from './version-history.component';
const LOGO_CATEGORY = 'org-logo';
const EDGES: readonly (keyof Margins)[] = ['topMm', 'rightMm', 'bottomMm', 'leftMm'];
/** A minimal read-only sample letter, so the admin sees the org identity in context
while editing (content itself is not the admin's to change). */
export const SAMPLE_LETTER_BRIEF: Brief = {
briefId: 'VOORBEELD-0001',
beroep: 'arts',
templateId: 'sample',
drafterId: 'sample',
status: { tag: 'draft' },
placeholders: [
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
{ key: 'datum', label: 'Datum', autoResolvable: true },
],
sections: [
{
sectionKey: 'body',
title: 'Voorbeeldinhoud',
required: true,
locked: true,
blocks: [
{
type: 'freeText',
blockId: 'sample-1',
content: {
paragraphs: [
{
nodes: [
{ type: 'text', text: 'Geachte ' },
{ type: 'placeholder', key: 'naam_zorgverlener' },
{ type: 'text', text: ',' },
],
},
{
nodes: [
{
type: 'text',
text: 'Dit is voorbeeldinhoud. Alleen de huisstijl-onderdelen (logo, afzender, ondertekening en voettekst) zijn hier bewerkbaar.',
},
],
},
],
},
},
],
},
],
};
/**
* Organism: the admin org-template editor. The mirror of the drafter's
* composer — the letter canvas runs in `editableRegions='template'` so the
* letterhead/signature/footer are edited in place, while the content is a read-only
* sample. Margins, logo upload, version history and the publish bar sit around it.
* Presentational: every mutation is an output the store turns into a command.
* sample. Margins and the publish bar sit around it; the logo uploader and version
* history are their own children (`app-logo-upload`, `app-version-history`, RD-25) —
* each a self-contained mutation cluster. Presentational: every mutation is an
* output the store turns into a command, whether sourced here or re-emitted from
* a child.
*/
@Component({
selector: 'app-org-template-editor',
imports: [
DatePipe,
HeadingComponent,
ButtonComponent,
AlertComponent,
FileInputComponent,
SingleUploadComponent,
LetterCanvasComponent,
LogoUploadComponent,
VersionHistoryComponent,
],
styles: [
`
@@ -123,22 +74,6 @@ export const SAMPLE_LETTER_BRIEF: Brief = {
.margins input {
width: 6rem;
}
.history-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--rhc-space-max-sm);
}
.history-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--rhc-space-max-md);
border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
padding-block-end: var(--rhc-space-max-sm);
}
.bar {
display: flex;
flex-wrap: wrap;
@@ -154,7 +89,7 @@ export const SAMPLE_LETTER_BRIEF: Brief = {
template: `
<div class="toolbar">
<label class="field">
<span>{{ subOrgLabel() }}</span>
<span i18n="@@orgTemplate.subOrg">Organisatieonderdeel</span>
<select class="form-select" (change)="onSelectSubOrg($event)">
@for (o of subOrgs(); track o.subOrgId) {
<option [value]="o.subOrgId" [selected]="o.subOrgId === selectedSubOrgId()">
@@ -191,80 +126,62 @@ export const SAMPLE_LETTER_BRIEF: Brief = {
}
</fieldset>
<section class="section">
<app-heading [level]="3">{{ logoHeading() }}</app-heading>
@if (logoCategory()) {
<app-file-input
inputId="org-logo-input"
[accept]="logoCategory()!.acceptedTypes"
[maxSizeMb]="logoCategory()!.maxSizeMb"
[label]="logoHeading()"
(filesSelected)="logoSelected.emit($event)"
/>
}
@if (logoRejection()) {
<app-alert type="error">{{ logoRejection() }}</app-alert>
}
@if (logoUploads().length) {
<ul class="file-list">
@for (u of logoUploads(); track u.localId) {
<li
app-single-upload
[upload]="u"
[previewUrlFor]="previewUrlFor()"
(remove)="logoRemoved.emit(u.localId)"
(retry)="logoRetry.emit(u.localId)"
></li>
}
</ul>
}
</section>
<app-logo-upload
[logoUrl]="logoUrl()"
[uploadState]="uploadState()"
[previewUrlFor]="previewUrlFor()"
(logoSelected)="logoSelected.emit($event)"
(logoRemoved)="logoRemoved.emit($event)"
(logoRetry)="logoRetry.emit($event)"
/>
<section class="section">
<app-heading [level]="3">{{ historyHeading() }}</app-heading>
@if (history().length === 0) {
<p class="published">{{ noHistory() }}</p>
} @else {
<ul class="history-list">
@for (v of history(); track v.version) {
<li class="history-row">
<span
>{{ versionLabel() }} {{ v.version }} · {{ v.publishedAt | date: 'longDate' }}</span
>
<app-button variant="subtle" [disabled]="busy()" (click)="rollback.emit(v.version)">
{{ rollbackLabel() }}
</app-button>
</li>
}
</ul>
}
</section>
<app-version-history
[history]="history()"
[publishedVersion]="publishedVersion()"
[busy]="busy()"
(rollback)="rollback.emit($event)"
/>
<div class="bar">
<span class="published">{{ publishedLabel() }} {{ publishedVersion() }}</span>
<span class="published"
><span i18n="@@orgTemplate.published">Gepubliceerde versie:</span>
{{ publishedVersion() }}</span
>
@if (pendingPublish()) {
<app-alert type="warning">{{ impactText() }}</app-alert>
<app-button variant="primary" [disabled]="busy()" (click)="confirmPublish.emit()">
{{ confirmLabel() }}
</app-button>
<app-button variant="subtle" [disabled]="busy()" (click)="cancelPublish.emit()">
{{ cancelLabel() }}
</app-button>
<app-button
variant="primary"
[disabled]="busy()"
(click)="confirmPublish.emit()"
i18n="@@orgTemplate.publish.confirm"
>Bevestigen</app-button
>
<app-button
variant="subtle"
[disabled]="busy()"
(click)="cancelPublish.emit()"
i18n="@@orgTemplate.publish.cancel"
>Annuleren</app-button
>
} @else {
<app-button
variant="primary"
[disabled]="!draftValid() || busy()"
(click)="requestPublish.emit()"
i18n="@@orgTemplate.publish"
>Publiceren</app-button
>
{{ publishLabel() }}
</app-button>
@if (!draftValid()) {
<span class="published">{{ invalidHint() }}</span>
}
}
<app-button variant="secondary" [disabled]="busy()" (click)="proefbrief.emit()">
{{ proefbriefLabel() }}
</app-button>
<app-button
variant="secondary"
[disabled]="busy()"
(click)="proefbrief.emit()"
i18n="@@orgTemplate.proefbrief"
>Proefbrief</app-button
>
</div>
`,
})
@@ -300,14 +217,6 @@ export class OrgTemplateEditorComponent {
protected readonly MIN = MARGIN_MIN_MM;
protected readonly MAX = MARGIN_MAX_MM;
protected logoCategory = computed(() =>
this.uploadState().categories.find((c) => c.categoryId === LOGO_CATEGORY),
);
protected logoUploads = computed(() =>
this.uploadState().uploads.filter((u) => u.categoryId === LOGO_CATEGORY),
);
protected logoRejection = computed(() => this.uploadState().rejections[LOGO_CATEGORY]);
protected onSelectSubOrg(event: Event) {
this.selectSubOrg.emit((event.target as HTMLSelectElement).value);
}
@@ -334,20 +243,9 @@ export class OrgTemplateEditorComponent {
$localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`,
);
protected subOrgLabel = input($localize`:@@orgTemplate.subOrg:Organisatieonderdeel`);
protected marginsLegend = input(
$localize`:@@orgTemplate.margins:Marges (mm, tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max:)`,
);
protected logoHeading = input($localize`:@@orgTemplate.logo:Logo`);
protected historyHeading = input($localize`:@@orgTemplate.history:Versiegeschiedenis`);
protected noHistory = input($localize`:@@orgTemplate.history.none:Nog niets gepubliceerd.`);
protected versionLabel = input($localize`:@@orgTemplate.version:Versie`);
protected rollbackLabel = input($localize`:@@orgTemplate.rollback:Terugzetten in concept`);
protected publishedLabel = input($localize`:@@orgTemplate.published:Gepubliceerde versie:`);
protected publishLabel = input($localize`:@@orgTemplate.publish:Publiceren`);
protected confirmLabel = input($localize`:@@orgTemplate.publish.confirm:Bevestigen`);
protected cancelLabel = input($localize`:@@orgTemplate.publish.cancel:Annuleren`);
protected proefbriefLabel = input($localize`:@@orgTemplate.proefbrief:Proefbrief`);
protected invalidHint = input(
$localize`:@@orgTemplate.invalid:Vul organisatienaam en ondertekenaar in; marges tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max: mm.`,
);
@@ -0,0 +1,78 @@
import { Component, input, output } from '@angular/core';
import { DatePipe } from '@angular/common';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { OrgTemplateVersion } from '@brief/domain/org-template';
/**
* Organism: the org-template editor's version-history block, split out of
* `org-template-editor.component.ts` (RD-25) — one of its two self-contained
* mutation clusters. Presentational: `rollback` is the parent's own output,
* re-emitted unchanged.
*/
@Component({
selector: 'app-version-history',
imports: [DatePipe, HeadingComponent, ButtonComponent],
styles: [
`
:host {
display: block;
}
.section {
margin-block-start: var(--rhc-space-max-xl);
}
.history-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--rhc-space-max-sm);
}
.history-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--rhc-space-max-md);
border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
padding-block-end: var(--rhc-space-max-sm);
}
.published {
color: var(--rhc-color-foreground-subtle);
}
`,
],
template: `
<section class="section">
<app-heading [level]="3" i18n="@@orgTemplate.history">Versiegeschiedenis</app-heading>
@if (history().length === 0) {
<p class="published" i18n="@@orgTemplate.history.none">Nog niets gepubliceerd.</p>
} @else {
<ul class="history-list">
@for (v of history(); track v.version) {
<li class="history-row">
<span
><span i18n="@@orgTemplate.version">Versie</span> {{ v.version }} ·
{{ v.publishedAt | date: 'longDate' }}</span
>
<app-button
variant="subtle"
[disabled]="busy()"
(click)="rollback.emit(v.version)"
i18n="@@orgTemplate.rollback"
>Terugzetten in concept</app-button
>
</li>
}
</ul>
}
</section>
`,
})
export class VersionHistoryComponent {
history = input<readonly OrgTemplateVersion[]>([]);
publishedVersion = input(0);
busy = input(false);
rollback = output<number>();
}
@@ -1,8 +1,8 @@
import { Component, computed, effect, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { AccessStore } from '@shared/application/access.store';
import { OrgTemplateStore } from '@brief/application/org-template.store';
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 { FormsModule } from '@angular/forms';
import { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { CheckboxComponent } from '@shared/ui/atoms/checkbox/checkbox.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { textOf } from '@shared/kernel/rich-text';
import { LibraryPassage } from '@brief/domain/brief';
@@ -1,7 +1,7 @@
import { Component, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
/** Molecule: shows the rejection comments (drafter view) or collects them from the
approver. The approver rejects WITH comments; they never edit the letter. */
@@ -7,6 +7,7 @@ import {
reduceUpload,
requiredCategoriesSatisfied,
deliveryRefs,
digitalDocumentIds,
} from '@shared/domain/upload.machine';
/** 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.jaren ||
!!s.draft.punten ||
deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId)
digitalDocumentIds(s.upload).length > 0
);
}
@@ -60,7 +60,7 @@ export const STEPS: StepId[] = ['buitenland', 'werk', 'review'];
// #endregion showcase:steps
/** Per-field error map: one message per question, since a step holds several. */
type Errors = Partial<Record<keyof Answers, string>>;
export type Errors = Partial<Record<keyof Answers, string>>;
export type IntakeState =
| {
@@ -1,8 +1,8 @@
import { Component, computed, inject, input } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import {
WizardShellComponent,
WizardError,
@@ -10,7 +10,7 @@ import {
naarStapLabel,
} from '@shared/layout/wizard-shell/wizard-shell.component';
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 { whenTag } from '@shared/kernel/fp';
import { BigProfileStore } from '@registratie/application/big-profile.store';
@@ -23,9 +23,9 @@ import {
hasProgress,
} from '@herregistratie/domain/herregistratie.machine';
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 { 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
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 {
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, {
Submitting: async (s, store) => {
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
protected dispatch = this.store.dispatch;
// Backend draft-sync (new persistence for this wizard): create a Concept on first
// 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).
// --- Static copy: stepper labels and per-step headings ---------------------
readonly stepLabels = [
$localize`:@@herregWizard.step.werkervaring:Werkervaring`,
$localize`:@@herregWizard.step.nascholing:Nascholing`,
@@ -204,6 +184,7 @@ export class HerregistratieWizardComponent {
$localize`:@@herregWizard.title.documenten:Documenten aanleveren`,
];
// --- State projections: one narrow, then read-only views of it -------------
private editing = computed(() => whenTag(this.state(), 'Editing'));
protected step = computed(() => this.editing()?.step ?? 1);
protected draft = computed<Draft>(
@@ -214,11 +195,35 @@ export class HerregistratieWizardComponent {
protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');
protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
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({
wizardId: 'herregistratie',
getUpload: () => this.upload(),
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 ---------------------
protected stepTitle = computed(() => this.stepTitles[this.step() - 1]);
@@ -228,11 +233,6 @@ export class HerregistratieWizardComponent {
? naarStapLabel(step + 1, this.stepLabels[step])
: $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,
composing the localized failure prefix so the `Failed` message arrives intact. */
protected phase = computed<WizardPhase>(() => {
@@ -254,6 +254,12 @@ export class HerregistratieWizardComponent {
/** Current step's field errors, flattened for the shell's error summary. */
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() {
// An explicit seed (stories/tests) wins; otherwise resume the backend draft
// (`?aanvraag=<id>`) or start fresh. Persistence is the draftSync controller's job.
@@ -1,7 +1,7 @@
import { Component, computed, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { map } from '@shared/application/remote-data';
import { BigProfileStore } from '@registratie/application/big-profile.store';
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
@@ -0,0 +1,77 @@
import { Component, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { RadioGroupComponent, JA_NEE } from '@shared/ui/atoms/radio-group/radio-group.component';
import { Answers, Errors } from '@herregistratie/domain/intake.machine';
/** Step: the intake wizard's first screen (foreign work in the last 5 years).
Pure & presentational — values in via `answers`/`errors`, every keystroke out
via `answerChange`. No store, no services, no internal state; the parent owns
the Model and decides what a change means. */
@Component({
selector: 'app-intake-buitenland-step',
imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent],
template: `
<fieldset>
<app-form-field
i18n-label="@@intake.q.buitenland"
label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?"
fieldId="buitenlandGewerkt"
required
[error]="err('buitenlandGewerkt')"
>
<app-radio-group
name="buitenlandGewerkt"
[options]="jaNee"
[ngModel]="answers().buitenlandGewerkt ?? ''"
(ngModelChange)="answerChange.emit({ key: 'buitenlandGewerkt', value: $event })"
/>
</app-form-field>
</fieldset>
@if (answers().buitenlandGewerkt === 'ja') {
<fieldset>
<app-form-field
i18n-label="@@intake.q.land"
label="In welk land?"
fieldId="land"
required
[error]="err('land')"
>
<app-text-input
inputId="land"
[ngModel]="answers().land ?? ''"
(ngModelChange)="answerChange.emit({ key: 'land', value: $event })"
name="land"
i18n-placeholder="@@intake.q.landPlaceholder"
placeholder="bijv. België"
/>
</app-form-field>
<app-form-field
i18n-label="@@intake.q.buitenlandseUren"
label="Hoeveel uur heeft u daar gewerkt?"
fieldId="buitenlandseUren"
required
[error]="err('buitenlandseUren')"
>
<app-text-input
inputId="buitenlandseUren"
[ngModel]="answers().buitenlandseUren ?? ''"
(ngModelChange)="answerChange.emit({ key: 'buitenlandseUren', value: $event })"
name="buitenlandseUren"
i18n-placeholder="@@intake.q.buitenlandseUrenPlaceholder"
placeholder="bijv. 800"
/>
</app-form-field>
</fieldset>
}
`,
})
export class BuitenlandStep {
answers = input.required<Answers>();
errors = input.required<Errors>();
answerChange = output<{ key: keyof Answers; value: string }>();
readonly jaNee = JA_NEE;
protected err = (k: keyof Answers) => this.errors()[k] ?? '';
}
@@ -1,14 +1,6 @@
/* eslint-disable max-lines */ // one wizard shell for the intake steps — removed by RD-22
import { Component, computed, effect, inject, input, untracked } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { RadioGroupComponent, JA_NEE } from '@shared/ui/radio-group/radio-group.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component';
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { ConfirmationComponent } from '@shared/ui/molecules/confirmation/confirmation.component';
import {
WizardShellComponent,
WizardError,
@@ -23,35 +15,35 @@ import {
IntakeState,
IntakeMsg,
Answers,
Errors,
StepId,
initial,
reduce,
STEPS,
lageUren,
hasProgress,
SCHOLING_THRESHOLD_DEFAULT,
} from '@herregistratie/domain/intake.machine';
import { createDraftSync } from '@registratie/application/draft-sync';
import { IntakePolicyStore } from '@herregistratie/application/intake-policy.store';
import { BuitenlandStep } from './buitenland.step';
import { WerkStep } from './werk.step';
import { ReviewStep } from './review.step';
/** Organism: a BRANCHING intake questionnaire. All state lives in one signal
driven by the pure `reduce` (intake.machine.ts). Which step renders is derived
from the answers via `visibleSteps`, never stored — so editing an earlier
answer immediately changes the remaining steps. Answers are persisted to
sessionStorage so a page reload keeps the user's progress (cleared on tab close). */
answer immediately changes the remaining steps. The draft persists to the
backend as a Concept aanvraag (createDraftSync), so a reload — or a "Verder
gaan" from the dashboard via `?aanvraag=<id>` — resumes progress. */
@Component({
selector: 'app-intake-wizard',
imports: [
FormsModule,
FormFieldComponent,
TextInputComponent,
RadioGroupComponent,
ButtonComponent,
AlertComponent,
DataRowComponent,
ReviewSectionComponent,
ConfirmationComponent,
WizardShellComponent,
BuitenlandStep,
WerkStep,
ReviewStep,
],
template: `
<app-wizard-shell
@@ -72,180 +64,26 @@ import { IntakePolicyStore } from '@herregistratie/application/intake-policy.sto
>
@switch (step()) {
@case ('buitenland') {
<fieldset>
<app-form-field
i18n-label="@@intake.q.buitenland"
label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?"
fieldId="buitenlandGewerkt"
required
[error]="err('buitenlandGewerkt')"
>
<app-radio-group
name="buitenlandGewerkt"
[options]="jaNee"
[ngModel]="answers().buitenlandGewerkt ?? ''"
(ngModelChange)="set('buitenlandGewerkt', $event)"
/>
</app-form-field>
</fieldset>
@if (answers().buitenlandGewerkt === 'ja') {
<fieldset>
<app-form-field
i18n-label="@@intake.q.land"
label="In welk land?"
fieldId="land"
required
[error]="err('land')"
>
<app-text-input
inputId="land"
[ngModel]="answers().land ?? ''"
(ngModelChange)="set('land', $event)"
name="land"
i18n-placeholder="@@intake.q.landPlaceholder"
placeholder="bijv. België"
/>
</app-form-field>
<app-form-field
i18n-label="@@intake.q.buitenlandseUren"
label="Hoeveel uur heeft u daar gewerkt?"
fieldId="buitenlandseUren"
required
[error]="err('buitenlandseUren')"
>
<app-text-input
inputId="buitenlandseUren"
[ngModel]="answers().buitenlandseUren ?? ''"
(ngModelChange)="set('buitenlandseUren', $event)"
name="buitenlandseUren"
i18n-placeholder="@@intake.q.buitenlandseUrenPlaceholder"
placeholder="bijv. 800"
/>
</app-form-field>
</fieldset>
}
<app-intake-buitenland-step
[answers]="answers()"
[errors]="errors()"
(answerChange)="dispatch({ tag: 'SetAnswer', key: $event.key, value: $event.value })"
/>
}
@case ('werk') {
<fieldset>
<app-form-field
i18n-label="@@intake.q.urenNl"
label="Gewerkte uren in Nederland (afgelopen 5 jaar)"
fieldId="uren"
required
[error]="err('uren')"
>
<app-text-input
inputId="uren"
[ngModel]="answers().uren ?? ''"
(ngModelChange)="set('uren', $event)"
name="uren"
i18n-placeholder="@@intake.q.urenNlPlaceholder"
placeholder="bijv. 4160"
/>
</app-form-field>
</fieldset>
@if (scholingZichtbaar()) {
<fieldset>
<app-form-field
i18n-label="@@intake.q.scholing"
label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?"
fieldId="scholingGevolgd"
required
[error]="err('scholingGevolgd')"
>
<app-radio-group
name="scholingGevolgd"
[options]="jaNee"
[ngModel]="answers().scholingGevolgd ?? ''"
(ngModelChange)="set('scholingGevolgd', $event)"
/>
</app-form-field>
</fieldset>
}
@if (answers().scholingGevolgd === 'ja') {
<fieldset>
<app-form-field
i18n-label="@@intake.q.punten"
label="Behaalde nascholingspunten"
fieldId="punten"
required
[error]="err('punten')"
>
<app-text-input
inputId="punten"
[ngModel]="answers().punten ?? ''"
(ngModelChange)="set('punten', $event)"
name="punten"
i18n-placeholder="@@intake.q.puntenPlaceholder"
placeholder="bijv. 200"
/>
</app-form-field>
</fieldset>
}
<app-intake-werk-step
[answers]="answers()"
[errors]="errors()"
[scholingThreshold]="scholingThreshold()"
(answerChange)="dispatch({ tag: 'SetAnswer', key: $event.key, value: $event.value })"
/>
}
@case ('review') {
<app-alert type="info" i18n="@@intake.review.controleer"
>Controleer uw antwoorden en dien de aanvraag in.</app-alert
>
<app-review-section
i18n-heading="@@intake.sectie.buitenland"
heading="Buitenland"
i18n-editAriaLabel="@@intake.buitenlandWijzigenAria"
editAriaLabel="Wijzigen buitenland"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 0 })"
>
<div
app-data-row
i18n-key="@@intake.review.buitenNl"
key="Buiten NL gewerkt"
[value]="answers().buitenlandGewerkt ?? '—'"
></div>
@if (answers().buitenlandGewerkt === 'ja') {
<div
app-data-row
i18n-key="@@intake.review.land"
key="Land"
[value]="answers().land ?? ''"
></div>
<div
app-data-row
i18n-key="@@intake.review.buitenlandseUren"
key="Buitenlandse uren"
[value]="answers().buitenlandseUren ?? ''"
></div>
}
</app-review-section>
<app-review-section
class="app-section"
i18n-heading="@@intake.sectie.werk"
heading="Werk in Nederland"
i18n-editAriaLabel="@@intake.werkWijzigenAria"
editAriaLabel="Wijzigen werk in Nederland"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 1 })"
>
<div
app-data-row
i18n-key="@@intake.review.urenNl"
key="Uren NL"
[value]="answers().uren ?? ''"
></div>
@if (scholingZichtbaar()) {
<div
app-data-row
i18n-key="@@intake.review.scholing"
key="Aanvullende scholing"
[value]="answers().scholingGevolgd ?? ''"
></div>
}
@if (answers().scholingGevolgd === 'ja') {
<div
app-data-row
i18n-key="@@intake.review.punten"
key="Nascholingspunten"
[value]="answers().punten ?? ''"
></div>
}
</app-review-section>
<app-intake-review-step
[answers]="answers()"
[scholingThreshold]="scholingThreshold()"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
/>
}
}
@@ -269,8 +107,14 @@ export class IntakeWizardComponent {
// Server-owned policy (scholing threshold): fetched from the backend via the
// application facade, not hardcoded. The backend stays the authority on submit.
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, {
Submitting: async (s, store) => {
this.profile.beginHerregistratie();
@@ -292,14 +136,37 @@ export class IntakeWizardComponent {
},
});
/** Optional seed so Storybook / the showcase can mount any state directly. */
seed = input<IntakeState>(initial);
readonly jaNee = JA_NEE;
readonly state = this.store.model;
readonly dispatch = this.store.dispatch;
// Backend draft-sync (replaces sessionStorage); the intake has no uploads.
// --- Static copy: stepper labels and per-step headings ---------------------
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`,
};
// --- State projections: one narrow, then read-only views of it -------------
private answering = computed(() => whenTag(this.state(), 'Answering'));
/** Public so the showcase can render the (fixed) step list next to the wizard. */
readonly steps = STEPS;
protected cursor = computed(() => this.answering()?.cursor ?? 0);
protected answers = computed<Answers>(() => this.answering()?.answers ?? {});
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
/** Server-owned threshold from the policy endpoint (mirrored into machine state). */
protected scholingThreshold = computed(
() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT,
);
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: () => {
@@ -311,30 +178,7 @@ export class IntakeWizardComponent {
enabled: () => this.seed() === initial,
});
private answering = computed(() => whenTag(this.state(), 'Answering'));
/** Public so the showcase can render the (fixed) step list next to the wizard. */
readonly steps = STEPS;
protected cursor = computed(() => this.answering()?.cursor ?? 0);
protected answers = computed<Answers>(() => this.answering()?.answers ?? {});
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
/** Server-owned threshold from the policy endpoint (mirrored into machine state). */
protected scholingThreshold = computed(
() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT,
);
/** Whether the inline scholing question is shown (and required) in the 'werk' step. */
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
// --- 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 primaryLabel = computed(() => {
if (this.step() === 'review') return $localize`:@@intake.indienen:Aanvraag indienen`;
@@ -365,10 +209,6 @@ export class IntakeWizardComponent {
toWizardErrors(this.answering()?.errors ?? {}),
);
protected err = (k: keyof Answers) => this.answering()?.errors[k] ?? '';
protected set = (key: keyof Answers, value: string) =>
this.dispatch({ tag: 'SetAnswer', key, value });
constructor() {
// An explicit seed (stories/tests) wins; otherwise resume the backend draft
// (`?aanvraag=<id>`) or start fresh. Persistence is the draftSync controller's job.
@@ -0,0 +1,85 @@
import { Component, computed, input, output } from '@angular/core';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { ReviewSectionComponent } from '@shared/ui/molecules/review-section/review-section.component';
import { Answers, lageUren } from '@herregistratie/domain/intake.machine';
/** Step: the intake wizard's review screen. Pure & presentational — values in via
`answers`/`scholingThreshold`, the cursor to jump back to out via `edit`. No
store, no services, no internal state; the parent maps the cursor onto its own
`GaNaarStap` message. */
@Component({
selector: 'app-intake-review-step',
imports: [AlertComponent, DataRowComponent, ReviewSectionComponent],
template: `
<app-alert type="info" i18n="@@intake.review.controleer"
>Controleer uw antwoorden en dien de aanvraag in.</app-alert
>
<app-review-section
i18n-heading="@@intake.sectie.buitenland"
heading="Buitenland"
i18n-editAriaLabel="@@intake.buitenlandWijzigenAria"
editAriaLabel="Wijzigen buitenland"
(edit)="edit.emit(0)"
>
<div
app-data-row
i18n-key="@@intake.review.buitenNl"
key="Buiten NL gewerkt"
[value]="answers().buitenlandGewerkt ?? '—'"
></div>
@if (answers().buitenlandGewerkt === 'ja') {
<div
app-data-row
i18n-key="@@intake.review.land"
key="Land"
[value]="answers().land ?? ''"
></div>
<div
app-data-row
i18n-key="@@intake.review.buitenlandseUren"
key="Buitenlandse uren"
[value]="answers().buitenlandseUren ?? ''"
></div>
}
</app-review-section>
<app-review-section
class="app-section"
i18n-heading="@@intake.sectie.werk"
heading="Werk in Nederland"
i18n-editAriaLabel="@@intake.werkWijzigenAria"
editAriaLabel="Wijzigen werk in Nederland"
(edit)="edit.emit(1)"
>
<div
app-data-row
i18n-key="@@intake.review.urenNl"
key="Uren NL"
[value]="answers().uren ?? ''"
></div>
@if (scholingZichtbaar()) {
<div
app-data-row
i18n-key="@@intake.review.scholing"
key="Aanvullende scholing"
[value]="answers().scholingGevolgd ?? ''"
></div>
}
@if (answers().scholingGevolgd === 'ja') {
<div
app-data-row
i18n-key="@@intake.review.punten"
key="Nascholingspunten"
[value]="answers().punten ?? ''"
></div>
}
</app-review-section>
`,
})
export class ReviewStep {
answers = input.required<Answers>();
scholingThreshold = input.required<number>();
edit = output<number>();
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
}
@@ -0,0 +1,84 @@
import { Component, computed, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { RadioGroupComponent, JA_NEE } from '@shared/ui/atoms/radio-group/radio-group.component';
import { Answers, Errors, lageUren } from '@herregistratie/domain/intake.machine';
/** Step: the intake wizard's second screen (work experience in the Netherlands,
with the inline scholing follow-up). Pure & presentational — values in via
`answers`/`errors`/`scholingThreshold`, every keystroke out via `answerChange`.
No store, no services, no internal state; the parent owns the Model and
decides what a change means. */
@Component({
selector: 'app-intake-werk-step',
imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent],
template: `
<fieldset>
<app-form-field
i18n-label="@@intake.q.urenNl"
label="Gewerkte uren in Nederland (afgelopen 5 jaar)"
fieldId="uren"
required
[error]="err('uren')"
>
<app-text-input
inputId="uren"
[ngModel]="answers().uren ?? ''"
(ngModelChange)="answerChange.emit({ key: 'uren', value: $event })"
name="uren"
i18n-placeholder="@@intake.q.urenNlPlaceholder"
placeholder="bijv. 4160"
/>
</app-form-field>
</fieldset>
@if (scholingZichtbaar()) {
<fieldset>
<app-form-field
i18n-label="@@intake.q.scholing"
label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?"
fieldId="scholingGevolgd"
required
[error]="err('scholingGevolgd')"
>
<app-radio-group
name="scholingGevolgd"
[options]="jaNee"
[ngModel]="answers().scholingGevolgd ?? ''"
(ngModelChange)="answerChange.emit({ key: 'scholingGevolgd', value: $event })"
/>
</app-form-field>
</fieldset>
}
@if (answers().scholingGevolgd === 'ja') {
<fieldset>
<app-form-field
i18n-label="@@intake.q.punten"
label="Behaalde nascholingspunten"
fieldId="punten"
required
[error]="err('punten')"
>
<app-text-input
inputId="punten"
[ngModel]="answers().punten ?? ''"
(ngModelChange)="answerChange.emit({ key: 'punten', value: $event })"
name="punten"
i18n-placeholder="@@intake.q.puntenPlaceholder"
placeholder="bijv. 200"
/>
</app-form-field>
</fieldset>
}
`,
})
export class WerkStep {
answers = input.required<Answers>();
errors = input.required<Errors>();
scholingThreshold = input.required<number>();
answerChange = output<{ key: keyof Answers; value: string }>();
readonly jaNee = JA_NEE;
protected err = (k: keyof Answers) => this.errors()[k] ?? '';
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
}
@@ -1,6 +1,6 @@
import { Component } from '@angular/core';
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';
/** Page: the branching intake questionnaire. Built entirely from existing
@@ -1,7 +1,7 @@
import { Component, computed, inject } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { ApplicationListComponent } from '@shared/ui/molecules/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/molecules/application-link/application-link.component';
import { AccessStore } from '@shared/application/access.store';
import { HEADER_ADMIN_LINKS } from '@shared/layout/site-header/nav-config';
@@ -1,7 +1,7 @@
import { Component, computed, inject } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { ApplicationListComponent } from '@shared/ui/molecules/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/molecules/application-link/application-link.component';
import { FeatureFlagStore } from '@shared/application/feature-flags.store';
import { FLAG_INSCHRIJVING_OPEN } from '@shared/domain/feature-flag';
@@ -9,6 +9,7 @@ import {
reduceUpload,
requiredCategoriesSatisfied,
deliveryRefs,
digitalDocumentIds,
} from '@shared/domain/upload.machine';
/**
@@ -109,7 +110,7 @@ export function hasProgress(s: Extract<RegistratieState, { tag: 'Invullen' }>):
!!d.email ||
!!d.diplomaId ||
!!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 { formatDatumNl } from '@shared/kernel/datum';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { Aanvraag } from '@registratie/domain/aanvraag';
import { TYPE_LABELS } from '@registratie/domain/aanvraag-view';
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 { ActivatedRoute } from '@angular/router';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { AanvragenStore } from '@registratie/application/aanvragen.store';
import { Aanvraag } from '@registratie/domain/aanvraag';
import { detailRows } from '@registratie/domain/aanvraag-view';
@@ -1,7 +1,7 @@
import { Component, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
export interface AdresValue {
straat: string;
@@ -1,10 +1,10 @@
import { Component, computed, effect, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { AccessStore } from '@shared/application/access.store';
import { successOr } from '@shared/application/remote-data';
import { formatDatumNl } from '@shared/kernel/datum';
@@ -1,12 +1,12 @@
import { Component, computed, input } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { Adres } from '@registratie/domain/person';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
@@ -1,11 +1,11 @@
import { Component, computed, inject } from '@angular/core';
import { Router } from '@angular/router';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { ApplicationListComponent } from '@shared/ui/molecules/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/molecules/application-link/application-link.component';
import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { successOr } from '@shared/application/remote-data';
import { AanvragenStore } from '@registratie/application/aanvragen.store';
import { Aanvraag, AanvraagType } from '@registratie/domain/aanvraag';
@@ -1,10 +1,10 @@
import { Component, inject } from '@angular/core';
import { successOf } from '@shared/application/remote-data';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { BigProfileStore } from '@registratie/application/big-profile.store';
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
@@ -1,8 +1,8 @@
import { Component, inject } from '@angular/core';
import { successOf } from '@shared/application/remote-data';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { BigProfileStore } from '@registratie/application/big-profile.store';
import { RegistrationTableComponent } from '@registratie/ui/registration-table/registration-table.component';
@@ -1,10 +1,10 @@
import { Component, computed, inject } from '@angular/core';
import { successOf } from '@shared/application/remote-data';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { TaskListComponent } from '@shared/ui/task-list/task-list.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { TaskListComponent } from '@shared/ui/molecules/task-list/task-list.component';
import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { BigProfileStore } from '@registratie/application/big-profile.store';
import { tasksFromProfile } from '@registratie/domain/tasks';
@@ -0,0 +1,122 @@
import { Component, inject, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { RadioGroupComponent } from '@shared/ui/atoms/radio-group/radio-group.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component';
import { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store';
import { Draft, DraftField, Errors } from '@registratie/domain/registratie-wizard.machine';
const KANALEN = [
{ value: 'email', label: $localize`:@@registratie.kanaalEmail:E-mail` },
{ value: 'post', label: $localize`:@@registratie.kanaalPost:Post` },
];
/** Step: the registratie wizard's first screen (adres + correspondentievoorkeur).
Injects RegistratieLookupStore directly for the BRP lookup banner — the
sanctioned exception (it is `providedIn: 'root'`, so every injection is the
same instance): the step owns its own async presentation rather than making
the parent a pass-through for it. Values otherwise in via `draft`/`errors`,
every change out via `fieldChange`/`kanaalChange`. No internal state; the
parent owns the Model and decides what a change means. */
@Component({
selector: 'app-reg-adres-step',
imports: [
FormsModule,
FormFieldComponent,
TextInputComponent,
RadioGroupComponent,
AlertComponent,
SkeletonComponent,
AddressFieldsComponent,
],
template: `
@if (adresStatus() === 'laden') {
<app-skeleton height="2.5rem" [count]="4" />
} @else {
@switch (adresStatus()) {
@case ('gevonden') {
<app-alert type="info" i18n="@@regWizard.brpGevonden"
>Vooraf ingevuld op basis van de BRP. Controleer en pas zo nodig aan.</app-alert
>
}
@case ('geen') {
<app-alert type="warning" i18n="@@regWizard.brpGeen"
>We vonden geen adres in de BRP. Vul uw adres hieronder handmatig in.</app-alert
>
}
@case ('fout') {
<app-alert type="warning" i18n="@@regWizard.brpFout"
>We konden de BRP nu niet bereiken. Vul uw adres hieronder handmatig in.</app-alert
>
}
}
<app-address-fields
[value]="{
straat: draft().straat ?? '',
postcode: draft().postcode ?? '',
woonplaats: draft().woonplaats ?? '',
}"
[errors]="{
straat: err('straat'),
postcode: err('postcode'),
woonplaats: err('woonplaats'),
}"
(fieldChange)="fieldChange.emit($event)"
/>
<fieldset>
<app-form-field
i18n-label="@@regWizard.correspondentieLabel"
label="Hoe wilt u correspondentie ontvangen?"
fieldId="correspondentie"
required
[error]="err('correspondentie')"
>
<app-radio-group
name="correspondentie"
[options]="kanalen"
[invalid]="!!err('correspondentie')"
[ngModel]="draft().correspondentie ?? ''"
(ngModelChange)="kanaalChange.emit($event)"
/>
</app-form-field>
</fieldset>
@if (draft().correspondentie === 'email') {
<fieldset>
<app-form-field
i18n-label="@@regWizard.emailLabel"
label="E-mailadres"
fieldId="email"
required
[error]="err('email')"
>
<app-text-input
inputId="email"
type="email"
[invalid]="!!err('email')"
[ngModel]="draft().email ?? ''"
(ngModelChange)="fieldChange.emit({ key: 'email', value: $event })"
name="email"
i18n-placeholder="@@regWizard.emailPlaceholder"
placeholder="naam@voorbeeld.nl"
/>
</app-form-field>
</fieldset>
}
}
`,
})
export class AdresStep {
private lookup = inject(RegistratieLookupStore);
draft = input.required<Draft>();
errors = input.required<Errors>();
fieldChange = output<{ key: DraftField; value: string }>();
kanaalChange = output<string>();
protected adresStatus = this.lookup.adresStatus;
readonly kanalen = KANALEN;
protected err = (k: DraftField | 'correspondentie') => this.errors()[k] ?? '';
}
@@ -0,0 +1,223 @@
import { Component, computed, inject, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { RadioGroupComponent, JA_NEE } from '@shared/ui/atoms/radio-group/radio-group.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { DocumentUploadComponent } from '@shared/ui/organisms/upload/document-upload/document-upload.component';
import { createUploadController } from '@shared/application/upload-controller';
import { UploadMsg, UploadState } from '@shared/domain/upload.machine';
import { RemoteData, successOr } from '@shared/application/remote-data';
import { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store';
import { DuoLookupDto, PolicyQuestionDto } from '@registratie/contracts/duo-diplomas.dto';
import { Draft, Errors } from '@registratie/domain/registratie-wizard.machine';
/** The server-owned geldigheidsvraag whose "ja" answer requires a Dutch-taalvaardigheid
upload (proof of the confirmed B2 level). Stable id shared with the backend. */
const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
/** Sentinel option: "my diploma isn't listed". Exported so the parent's
`onDiplomaKeuze` (registratie-wizard.component.ts) can recognize it too. */
export const HANDMATIG = '__handmatig__';
/** Step: the registratie wizard's second screen (beroep op basis van diploma).
Injects RegistratieLookupStore directly for the DUO lookup — the sanctioned
exception (it is `providedIn: 'root'`, so every injection is the same
instance) — and owns its own `<app-async>` over it. Also owns the upload
controller, moved here from the parent: this is what gets the parent under
the line limit. Values in via `draft`/`errors`/`upload`; every user intent
leaves as one of four outputs. No store beyond the lookup, and no machine
message built here; the parent maps each output onto its own message. */
@Component({
selector: 'app-reg-beroep-step',
imports: [
FormsModule,
FormFieldComponent,
TextInputComponent,
RadioGroupComponent,
AlertComponent,
SkeletonComponent,
DataRowComponent,
DataBlockComponent,
DocumentUploadComponent,
...ASYNC,
],
template: `
<app-async [data]="lookupRd()">
<ng-template appAsyncLoaded>
@if (duoData(); as data) {
<fieldset>
<app-form-field
i18n-label="@@regWizard.diplomaLabel"
label="Kies het diploma waarmee u zich wilt registreren"
fieldId="diploma"
required
[error]="err('diploma')"
>
<app-radio-group
name="diploma"
[options]="diplomaOptions(data)"
[invalid]="!!err('diploma')"
[ngModel]="diplomaKeuze()"
(ngModelChange)="diplomaChosen.emit($event)"
/>
</app-form-field>
</fieldset>
@if (handmatigActief()) {
<app-alert type="warning" i18n="@@regWizard.handmatigWaarschuwing"
>Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies uw
beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna handmatig
beoordeeld.</app-alert
>
<fieldset>
<app-form-field
i18n-label="@@regWizard.beroepLabel"
label="Voor welk beroep wilt u zich registreren?"
fieldId="hm-beroep"
[error]="err('diploma')"
>
<app-radio-group
name="hm-beroep"
[options]="beroepOptions(data)"
[invalid]="!!err('diploma')"
[ngModel]="draft().beroep ?? ''"
(ngModelChange)="beroepDeclared.emit($event)"
/>
</app-form-field>
</fieldset>
} @else if (draft().beroep) {
<app-data-block class="app-section">
<div
app-data-row
i18n-key="@@regWizard.beroepAfgeleid"
key="Beroep (afgeleid uit diploma)"
[value]="draft().beroep ?? ''"
></div>
</app-data-block>
}
@if (actieveVragen(data).length) {
<fieldset>
@for (q of actieveVragen(data); track q.id) {
<app-form-field
[label]="q.vraag"
[fieldId]="'vraag-' + q.id"
[error]="vraagErr(q.id)"
>
@if (q.type === 'ja-nee') {
<app-radio-group
[name]="'vraag-' + q.id"
[options]="jaNee"
[invalid]="!!vraagErr(q.id)"
[ngModel]="antwoord(q.id)"
(ngModelChange)="antwoordChange.emit({ vraagId: q.id, value: $event })"
[ngModelOptions]="{ standalone: true }"
/>
} @else {
<app-text-input
[inputId]="'vraag-' + q.id"
[invalid]="!!vraagErr(q.id)"
[ngModel]="antwoord(q.id)"
(ngModelChange)="antwoordChange.emit({ vraagId: q.id, value: $event })"
[ngModelOptions]="{ standalone: true }"
/>
}
</app-form-field>
}
</fieldset>
}
}
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="3" />
</ng-template>
</app-async>
<app-document-upload
class="app-section"
[state]="upload()"
[previewUrlFor]="previewUrlFor"
(fileSelected)="uploadCtl.onFileSelected($event.categoryId, $event.files)"
(removeUpload)="uploadCtl.onRemove($event)"
(retryUpload)="uploadCtl.onRetry($event)"
(deleteUpload)="uploadCtl.onDelete($event)"
(channelChange)="uploadCtl.onChannelChange($event.categoryId, $event.channel)"
/>
@if (err('documenten')) {
<app-alert type="warning">{{ err('documenten') }}</app-alert>
}
`,
})
export class BeroepStep {
private lookup = inject(RegistratieLookupStore);
draft = input.required<Draft>();
errors = input.required<Errors>();
upload = input.required<UploadState>();
uploadMsg = output<UploadMsg>();
antwoordChange = output<{ vraagId: string; value: string }>();
diplomaChosen = output<string>();
beroepDeclared = output<string>();
/** 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);
protected uploadCtl = createUploadController({
wizardId: 'registratie',
getUpload: () => this.upload(),
dispatch: (msg) => this.uploadMsg.emit(msg),
// Required documents depend on answers (server decides): a diploma upload only for a
// manual diploma; a Dutch-taalvaardigheid upload only once the applicant confirms
// ("ja") the B2 language requirement.
getCategoryParams: () => ({
diplomaHerkomst: this.draft().diplomaHerkomst,
taalvaardigheid: this.draft().antwoorden[NL_TAALVAARDIGHEID_VRAAG],
}),
});
/** Parsed DUO lookup (validated at the trust boundary by the application
facade — the step renders, it does not fetch/parse). */
protected lookupRd: () => RemoteData<Error | undefined, DuoLookupDto> = this.lookup.duoLookup;
protected duoData = computed<DuoLookupDto | null>(() => successOr(this.lookupRd(), null));
readonly jaNee = JA_NEE;
protected err = (k: 'diploma' | 'documenten') => this.errors()[k] ?? '';
protected vraagErr = (id: string) => this.errors().antwoorden?.[id] ?? '';
protected antwoord = (id: string) => this.draft().antwoorden[id] ?? ''; // runtime guard: missing key → undefined
/** True while the user is entering a diploma manually (not in the DUO list). */
protected handmatigActief = computed(() => this.draft().diplomaHerkomst === 'handmatig');
/** The radio selection: a diploma id, or the "not listed" sentinel in manual mode. */
protected diplomaKeuze = computed(() =>
this.handmatigActief() ? HANDMATIG : (this.draft().diplomaId ?? ''),
);
protected diplomaOptions = (data: DuoLookupDto) => [
...data.diplomas.map((d) => ({
value: d.id,
label: `${d.naam}${d.instelling} (${d.jaar})`,
})),
{
value: HANDMATIG,
label: $localize`:@@regWizard.diplomaNietBij:Mijn diploma staat er niet bij`,
},
];
protected beroepOptions = (data: DuoLookupDto) =>
data.handmatig.beroepen.map((b) => ({ value: b, label: b }));
/** The policy questions that apply to the current choice (server-decided). */
protected actieveVragen = (data: DuoLookupDto): PolicyQuestionDto[] => {
if (this.handmatigActief()) return data.handmatig.policyQuestions;
return data.diplomas.find((d) => d.id === this.draft().diplomaId)?.policyQuestions ?? [];
};
}
@@ -0,0 +1,136 @@
import { Component, computed, inject, input, output } from '@angular/core';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { ReviewSectionComponent } from '@shared/ui/molecules/review-section/review-section.component';
import { successOr } from '@shared/application/remote-data';
import { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store';
import { DuoLookupDto } from '@registratie/contracts/duo-diplomas.dto';
import { Draft } from '@registratie/domain/registratie-wizard.machine';
/** Step: the registratie wizard's review screen (controle & indienen). Injects
RegistratieLookupStore directly to build `samenvattingVragen` — the
sanctioned exception (it is `providedIn: 'root'`, so every injection is the
same instance). Values otherwise in via `draft`, the cursor to jump back to
out via `edit`. No internal state; the parent maps the cursor onto its own
`GaNaarStap` message. */
@Component({
selector: 'app-reg-controle-step',
imports: [AlertComponent, DataRowComponent, ReviewSectionComponent],
template: `
<app-alert type="info" i18n="@@regWizard.controleer"
>Controleer uw gegevens en dien de registratie in.</app-alert
>
<app-review-section
i18n-heading="@@regWizard.sectie.adres"
heading="Adres en correspondentie"
i18n-editAriaLabel="@@regWizard.adresWijzigenAria"
editAriaLabel="Wijzigen adresgegevens"
(edit)="edit.emit(0)"
>
<div
app-data-row
i18n-key="@@regWizard.summary.adres"
key="Adres"
[value]="adresSamenvatting()"
></div>
<div
app-data-row
i18n-key="@@regWizard.summary.herkomstAdres"
key="Herkomst adres"
[value]="adresHerkomstLabel()"
></div>
<div
app-data-row
i18n-key="@@regWizard.summary.correspondentie"
key="Correspondentie"
[value]="correspondentieLabel()"
></div>
@if (draft().correspondentie === 'email') {
<div
app-data-row
i18n-key="@@regWizard.summary.email"
key="E-mailadres"
[value]="draft().email ?? ''"
></div>
}
</app-review-section>
<app-review-section
class="app-section"
i18n-heading="@@regWizard.sectie.beroep"
heading="Beroep en diploma"
i18n-editAriaLabel="@@regWizard.diplomaWijzigenAria"
editAriaLabel="Wijzigen beroep en diploma"
(edit)="edit.emit(1)"
>
<div
app-data-row
i18n-key="@@regWizard.summary.beroep"
key="Beroep"
[value]="draft().beroep ?? ''"
></div>
<div
app-data-row
i18n-key="@@regWizard.summary.herkomstDiploma"
key="Herkomst diploma"
[value]="diplomaHerkomstLabel()"
></div>
@for (item of samenvattingVragen(); track item.vraag) {
<div app-data-row [key]="item.vraag" [value]="item.antwoord"></div>
}
</app-review-section>
`,
})
export class ControleStep {
private lookup = inject(RegistratieLookupStore);
draft = input.required<Draft>();
edit = output<number>();
/** Parsed DUO lookup as a plain value (or null), needed here only to resolve
the answered policy questions' text for the summary. */
protected duoData = computed<DuoLookupDto | null>(() => successOr(this.lookup.duoLookup(), null));
protected adresSamenvatting = computed(() => {
const d = this.draft();
return [d.straat, [d.postcode, d.woonplaats].filter(Boolean).join(' ')]
.filter(Boolean)
.join(', ');
});
// Readable labels for the controle summary (instead of raw enum values).
protected adresHerkomstLabel = computed(
() =>
({
brp: $localize`:@@regWizard.herkomst.adresBrp:Automatisch uit de BRP`,
handmatig: $localize`:@@regWizard.herkomst.adresHandmatig:Handmatig ingevoerd`,
})[this.draft().adresHerkomst ?? 'handmatig'],
);
protected correspondentieLabel = computed(
() =>
({
email: $localize`:@@regWizard.corr.email:Per e-mail`,
post: $localize`:@@regWizard.corr.post:Per post`,
})[this.draft().correspondentie ?? 'post'],
);
protected diplomaHerkomstLabel = computed(
() =>
({
duo: $localize`:@@regWizard.herkomst.diplomaDuo:Geverifieerd via DUO`,
handmatig: $localize`:@@regWizard.herkomst.diplomaHandmatig:Handmatig ingevoerd (wordt beoordeeld)`,
})[this.draft().diplomaHerkomst ?? 'handmatig'],
);
/** Answered policy questions for the controle summary (question text + answer). */
protected samenvattingVragen = computed(() => {
const data = this.duoData();
const d = this.draft();
if (!data) return [] as { vraag: string; antwoord: string }[];
const alle = [
...data.diplomas.flatMap((x) => x.policyQuestions),
...data.handmatig.policyQuestions,
];
return (d.vraagIds ?? []).map((id) => ({
vraag: alle.find((q) => q.id === id)?.vraag ?? id,
antwoord: d.antwoorden[id] ?? '',
}));
});
}
@@ -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,16 +1,6 @@
/* eslint-disable max-lines */ // one wizard shell for 3 steps + upload — removed by RD-23
import { Component, computed, effect, inject, input, untracked } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { RadioGroupComponent, JA_NEE } from '@shared/ui/radio-group/radio-group.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
import { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component';
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { ConfirmationComponent } from '@shared/ui/molecules/confirmation/confirmation.component';
import {
WizardShellComponent,
WizardError,
@@ -18,19 +8,17 @@ import {
naarStapLabel,
} from '@shared/layout/wizard-shell/wizard-shell.component';
import { toWizardErrors } from '@shared/layout/wizard-shell/wizard-errors';
import { ASYNC } from '@shared/ui/async/async.component';
import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { RemoteData, successOr } from '@shared/application/remote-data';
import { successOr } from '@shared/application/remote-data';
import { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store';
import { DuoLookupDto, PolicyQuestionDto } from '@registratie/contracts/duo-diplomas.dto';
import { DuoLookupDto } from '@registratie/contracts/duo-diplomas.dto';
import {
RegistratieState,
RegistratieMsg,
Draft,
DraftField,
Correspondentie,
Errors,
StepId,
initial,
reduce,
@@ -38,44 +26,28 @@ import {
STEPS,
} from '@registratie/domain/registratie-wizard.machine';
import { createDraftSync } from '@registratie/application/draft-sync';
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
import { createUploadController } from '@shared/application/upload-controller';
import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.machine';
const KANALEN = [
{ value: 'email', label: $localize`:@@registratie.kanaalEmail:E-mail` },
{ value: 'post', label: $localize`:@@registratie.kanaalPost:Post` },
];
const HANDMATIG = '__handmatig__'; // sentinel option:"my diploma isn't listed"
/** The server-owned geldigheidsvraag whose"ja" answer requires a Dutch-taalvaardigheid
upload (proof of the confirmed B2 level). Stable id shared with the backend. */
const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
import { UploadState, initialUpload, digitalDocumentIds } from '@shared/domain/upload.machine';
import { AdresStep } from './adres.step';
import { BeroepStep } from './beroep.step';
import { ControleStep } from './controle.step';
import { diplomaMsg } from './diploma-msg';
/** 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
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
backend as a Concept aanvraag (createDraftSync) so a reload — or a"Verder gaan"
from the dashboard via `?aanvraag=<id>` — resumes progress. Built from existing
a diploma reveals its server-derived beroep. The draft persists to the backend
as a Concept aanvraag (createDraftSync), so a reload — or a "Verder gaan" from
the dashboard via `?aanvraag=<id>` — resumes progress. Built from existing
atoms/molecules. */
@Component({
selector: 'app-registratie-wizard',
imports: [
FormsModule,
FormFieldComponent,
TextInputComponent,
RadioGroupComponent,
ButtonComponent,
AlertComponent,
SkeletonComponent,
DataRowComponent,
DataBlockComponent,
ReviewSectionComponent,
ConfirmationComponent,
WizardShellComponent,
AddressFieldsComponent,
DocumentUploadComponent,
...ASYNC,
AdresStep,
BeroepStep,
ControleStep,
],
template: `
<app-wizard-shell
@@ -98,253 +70,31 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
>
@switch (step()) {
@case ('adres') {
@if (adresStatus() === 'laden') {
<app-skeleton height="2.5rem" [count]="4" />
} @else {
@switch (adresStatus()) {
@case ('gevonden') {
<app-alert type="info" i18n="@@regWizard.brpGevonden"
>Vooraf ingevuld op basis van de BRP. Controleer en pas zo nodig aan.</app-alert
>
}
@case ('geen') {
<app-alert type="warning" i18n="@@regWizard.brpGeen"
>We vonden geen adres in de BRP. Vul uw adres hieronder handmatig in.</app-alert
>
}
@case ('fout') {
<app-alert type="warning" i18n="@@regWizard.brpFout"
>We konden de BRP nu niet bereiken. Vul uw adres hieronder handmatig
in.</app-alert
>
}
}
<app-address-fields
[value]="{
straat: draft().straat ?? '',
postcode: draft().postcode ?? '',
woonplaats: draft().woonplaats ?? '',
}"
[errors]="{
straat: err('straat'),
postcode: err('postcode'),
woonplaats: err('woonplaats'),
}"
(fieldChange)="set($event.key, $event.value)"
/>
<fieldset>
<app-form-field
i18n-label="@@regWizard.correspondentieLabel"
label="Hoe wilt u correspondentie ontvangen?"
fieldId="correspondentie"
required
[error]="err('correspondentie')"
>
<app-radio-group
name="correspondentie"
[options]="kanalen"
[invalid]="!!err('correspondentie')"
[ngModel]="draft().correspondentie ?? ''"
(ngModelChange)="setKanaal($event)"
/>
</app-form-field>
</fieldset>
@if (draft().correspondentie === 'email') {
<fieldset>
<app-form-field
i18n-label="@@regWizard.emailLabel"
label="E-mailadres"
fieldId="email"
required
[error]="err('email')"
>
<app-text-input
inputId="email"
type="email"
[invalid]="!!err('email')"
[ngModel]="draft().email ?? ''"
(ngModelChange)="set('email', $event)"
name="email"
i18n-placeholder="@@regWizard.emailPlaceholder"
placeholder="naam@voorbeeld.nl"
/>
</app-form-field>
</fieldset>
}
}
<app-reg-adres-step
[draft]="draft()"
[errors]="errors()"
(fieldChange)="dispatch({ tag: 'SetField', key: $event.key, value: $event.value })"
(kanaalChange)="onKanaalChange($event)"
/>
}
@case ('beroep') {
<app-async [data]="lookupRd()">
<ng-template appAsyncLoaded>
@if (duoData(); as data) {
<fieldset>
<app-form-field
i18n-label="@@regWizard.diplomaLabel"
label="Kies het diploma waarmee u zich wilt registreren"
fieldId="diploma"
required
[error]="err('diploma')"
>
<app-radio-group
name="diploma"
[options]="diplomaOptions(data)"
[invalid]="!!err('diploma')"
[ngModel]="diplomaKeuze()"
(ngModelChange)="onDiplomaKeuze(data, $event)"
/>
</app-form-field>
</fieldset>
@if (handmatigActief()) {
<app-alert type="warning" i18n="@@regWizard.handmatigWaarschuwing"
>Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies
uw beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna
handmatig beoordeeld.</app-alert
>
<fieldset>
<app-form-field
i18n-label="@@regWizard.beroepLabel"
label="Voor welk beroep wilt u zich registreren?"
fieldId="hm-beroep"
[error]="err('diploma')"
>
<app-radio-group
name="hm-beroep"
[options]="beroepOptions(data)"
[invalid]="!!err('diploma')"
[ngModel]="draft().beroep ?? ''"
(ngModelChange)="dispatch({ tag: 'DeclareerBeroep', beroep: $event })"
/>
</app-form-field>
</fieldset>
} @else if (draft().beroep) {
<app-data-block class="app-section">
<div
app-data-row
i18n-key="@@regWizard.beroepAfgeleid"
key="Beroep (afgeleid uit diploma)"
[value]="draft().beroep ?? ''"
></div>
</app-data-block>
}
@if (actieveVragen(data).length) {
<fieldset>
@for (q of actieveVragen(data); track q.id) {
<app-form-field
[label]="q.vraag"
[fieldId]="'vraag-' + q.id"
[error]="vraagErr(q.id)"
>
@if (q.type === 'ja-nee') {
<app-radio-group
[name]="'vraag-' + q.id"
[options]="jaNee"
[invalid]="!!vraagErr(q.id)"
[ngModel]="antwoord(q.id)"
(ngModelChange)="
dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })
"
[ngModelOptions]="{ standalone: true }"
/>
} @else {
<app-text-input
[inputId]="'vraag-' + q.id"
[invalid]="!!vraagErr(q.id)"
[ngModel]="antwoord(q.id)"
(ngModelChange)="
dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })
"
[ngModelOptions]="{ standalone: true }"
/>
}
</app-form-field>
}
</fieldset>
}
}
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="3" />
</ng-template>
</app-async>
<app-document-upload
class="app-section"
[state]="upload()"
[previewUrlFor]="previewUrlFor"
(fileSelected)="uploadCtl.onFileSelected($event.categoryId, $event.files)"
(removeUpload)="uploadCtl.onRemove($event)"
(retryUpload)="uploadCtl.onRetry($event)"
(deleteUpload)="uploadCtl.onDelete($event)"
(channelChange)="uploadCtl.onChannelChange($event.categoryId, $event.channel)"
<app-reg-beroep-step
[draft]="draft()"
[errors]="errors()"
[upload]="upload()"
(uploadMsg)="dispatch({ tag: 'Upload', msg: $event })"
(antwoordChange)="
dispatch({ tag: 'SetAntwoord', vraagId: $event.vraagId, value: $event.value })
"
(diplomaChosen)="onDiplomaKeuze($event)"
(beroepDeclared)="dispatch({ tag: 'DeclareerBeroep', beroep: $event })"
/>
@if (err('documenten')) {
<app-alert type="warning">{{ err('documenten') }}</app-alert>
}
}
@case ('controle') {
<app-alert type="info" i18n="@@regWizard.controleer"
>Controleer uw gegevens en dien de registratie in.</app-alert
>
<app-review-section
i18n-heading="@@regWizard.sectie.adres"
heading="Adres en correspondentie"
i18n-editAriaLabel="@@regWizard.adresWijzigenAria"
editAriaLabel="Wijzigen adresgegevens"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 0 })"
>
<div
app-data-row
i18n-key="@@regWizard.summary.adres"
key="Adres"
[value]="adresSamenvatting()"
></div>
<div
app-data-row
i18n-key="@@regWizard.summary.herkomstAdres"
key="Herkomst adres"
[value]="adresHerkomstLabel()"
></div>
<div
app-data-row
i18n-key="@@regWizard.summary.correspondentie"
key="Correspondentie"
[value]="correspondentieLabel()"
></div>
@if (draft().correspondentie === 'email') {
<div
app-data-row
i18n-key="@@regWizard.summary.email"
key="E-mailadres"
[value]="draft().email ?? ''"
></div>
}
</app-review-section>
<app-review-section
class="app-section"
i18n-heading="@@regWizard.sectie.beroep"
heading="Beroep en diploma"
i18n-editAriaLabel="@@regWizard.diplomaWijzigenAria"
editAriaLabel="Wijzigen beroep en diploma"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 1 })"
>
<div
app-data-row
i18n-key="@@regWizard.summary.beroep"
key="Beroep"
[value]="draft().beroep ?? ''"
></div>
<div
app-data-row
i18n-key="@@regWizard.summary.herkomstDiploma"
key="Herkomst diploma"
[value]="diplomaHerkomstLabel()"
></div>
@for (item of samenvattingVragen(); track item.vraag) {
<div app-data-row [key]="item.vraag" [value]="item.antwoord"></div>
}
</app-review-section>
<app-reg-controle-step
[draft]="draft()"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
/>
}
}
@@ -368,8 +118,14 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
})
export class RegistratieWizardComponent {
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, {
Indienen: async (s, store) => {
const r = await this.draftSync.submit({
@@ -380,68 +136,56 @@ export class RegistratieWizardComponent {
else store.dispatch({ tag: 'SubmitFailed', error: r.error });
},
});
readonly state = this.store.model;
readonly dispatch = this.store.dispatch;
/** 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 / tests can mount any state directly. */
seed = input<RegistratieState>(initial);
readonly kanalen = KANALEN;
// --- Static copy: stepper labels and per-step headings ---------------------
readonly stepLabels = [
$localize`:@@regWizard.step.adres:Adres`,
$localize`:@@regWizard.step.beroep:Beroep`,
$localize`:@@regWizard.step.controle:Controle`,
]; // short labels for the stepper
];
private stepTitles = [
$localize`:@@regWizard.title.adres:Adres en correspondentievoorkeur`,
$localize`:@@regWizard.title.beroep:Beroep op basis van uw diploma`,
$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'));
protected cursor = computed(() => this.invullen()?.cursor ?? 0);
protected draft = computed<Draft>(() => this.invullen()?.draft ?? { antwoorden: {} });
protected errors = computed<Errors>(() => this.invullen()?.errors ?? {});
protected upload = computed<UploadState>(() => this.invullen()?.upload ?? initialUpload);
protected uploadCtl = createUploadController({
wizardId: 'registratie',
getUpload: () => this.upload(),
dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }),
// Required documents depend on answers (server decides): a diploma upload only for a
// manual diploma; a Dutch-taalvaardigheid upload only once the applicant confirms
// ("ja") the B2 language requirement.
getCategoryParams: () => ({
diplomaHerkomst: this.draft().diplomaHerkomst,
taalvaardigheid: this.draft().antwoorden[NL_TAALVAARDIGHEID_VRAAG],
}),
});
// Backend draft-sync (replaces sessionStorage): create a Concept once the user has
// made progress, then debounced-sync the whole machine snapshot; resume by `?aanvraag`.
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
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({
type: 'registratie',
snapshot: () => {
const s = this.state();
if (s.tag !== 'Invullen' || !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.cursor, stepCount: STEPS.length, documentIds };
return {
draft: s,
stepIndex: s.cursor,
stepCount: STEPS.length,
documentIds: digitalDocumentIds(s.upload),
};
},
onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as RegistratieState }),
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(
() => 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(() => {
if (this.step() === 'controle') return $localize`:@@regWizard.indienen:Registratie indienen`;
const next = this.cursor() + 1;
@@ -471,116 +215,19 @@ export class RegistratieWizardComponent {
const e = this.invullen()?.errors ?? {};
return [...toWizardErrors(e), ...toWizardErrors(e.antwoorden ?? {}, 'vraag-')];
});
protected adresSamenvatting = computed(() => {
const d = this.draft();
return [d.straat, [d.postcode, d.woonplaats].filter(Boolean).join(' ')]
.filter(Boolean)
.join(', ');
});
// Readable labels for the controle summary (instead of raw enum values).
protected adresHerkomstLabel = computed(
() =>
({
brp: $localize`:@@regWizard.herkomst.adresBrp:Automatisch uit de BRP`,
handmatig: $localize`:@@regWizard.herkomst.adresHandmatig:Handmatig ingevoerd`,
})[this.draft().adresHerkomst ?? 'handmatig'],
);
protected correspondentieLabel = computed(
() =>
({
email: $localize`:@@regWizard.corr.email:Per e-mail`,
post: $localize`:@@regWizard.corr.post:Per post`,
})[this.draft().correspondentie ?? 'post'],
);
protected diplomaHerkomstLabel = computed(
() =>
({
duo: $localize`:@@regWizard.herkomst.diplomaDuo:Geverifieerd via DUO`,
handmatig: $localize`:@@regWizard.herkomst.diplomaHandmatig:Handmatig ingevoerd (wordt beoordeeld)`,
})[this.draft().diplomaHerkomst ?? 'handmatig'],
);
/** BRP lookup outcome (laden/gevonden/geen/fout) and the parsed DUO lookup, both
served by the application facade — the wizard renders, it does not fetch/parse. */
protected adresStatus = this.lookup.adresStatus;
protected lookupRd: () => RemoteData<Error | undefined, DuoLookupDto> = this.lookup.duoLookup;
/** Parsed lookup as a plain value (or null) — used outside the beroep step (the
controle summary) where the <app-async> template variable isn't in scope, and
inside it too: `<ng-template appAsyncLoaded>`'s own context can't inherit a
generic from the sibling [data] input (Angular only infers a structural
directive's type parameter from an input on that same node). */
protected duoData = computed<DuoLookupDto | null>(() => successOr(this.lookupRd(), null));
readonly jaNee = JA_NEE;
protected err = (k: DraftField | 'correspondentie' | 'diploma' | 'documenten') =>
this.invullen()?.errors[k] ?? '';
protected vraagErr = (id: string) => this.invullen()?.errors.antwoorden?.[id] ?? '';
protected antwoord = (id: string) => this.draft().antwoorden[id] ?? ''; // runtime guard: missing key → undefined
protected set = (key: DraftField, value: string) =>
this.dispatch({ tag: 'SetField', key, value });
protected setKanaal = (value: string) =>
this.dispatch({ tag: 'SetCorrespondentie', value: value as Correspondentie });
/** True while the user is entering a diploma manually (not in the DUO list). */
protected handmatigActief = computed(() => this.draft().diplomaHerkomst === 'handmatig');
/** The radio selection: a diploma id, or the"not listed" sentinel in manual mode. */
protected diplomaKeuze = computed(() =>
this.handmatigActief() ? HANDMATIG : (this.draft().diplomaId ?? ''),
);
protected diplomaOptions = (data: DuoLookupDto) => [
...data.diplomas.map((d) => ({
value: d.id,
label: `${d.naam}${d.instelling} (${d.jaar})`,
})),
{
value: HANDMATIG,
label: $localize`:@@regWizard.diplomaNietBij:Mijn diploma staat er niet bij`,
},
];
protected beroepOptions = (data: DuoLookupDto) =>
data.handmatig.beroepen.map((b) => ({ value: b, label: b }));
/** The policy questions that apply to the current choice (server-decided). */
protected actieveVragen = (data: DuoLookupDto): PolicyQuestionDto[] => {
if (this.handmatigActief()) return data.handmatig.policyQuestions;
return data.diplomas.find((d) => d.id === this.draft().diplomaId)?.policyQuestions ?? [];
};
/** Answered policy questions for the controle summary (question text + answer). */
protected samenvattingVragen = computed(() => {
// --- Event handlers: narrow a child event into a message -------------------
protected onDiplomaKeuze(id: string) {
const data = this.duoData();
const d = this.draft();
if (!data) return [] as { vraag: string; antwoord: string }[];
const alle = [
...data.diplomas.flatMap((x) => x.policyQuestions),
...data.handmatig.policyQuestions,
];
return (d.vraagIds ?? []).map((id) => ({
vraag: alle.find((q) => q.id === id)?.vraag ?? id,
antwoord: d.antwoorden[id] ?? '',
}));
});
if (!data) return;
const msg = diplomaMsg(data, id);
if (msg) this.dispatch(msg);
}
protected onDiplomaKeuze(data: DuoLookupDto, id: string) {
if (id === HANDMATIG) {
this.dispatch({
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
`Correspondentie` union before dispatching. */
protected onKanaalChange(value: string) {
this.dispatch({ tag: 'SetCorrespondentie', value: value as Correspondentie });
}
constructor() {
@@ -608,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
re-prefills, keeping the form and the"vooraf ingevuld" note consistent. */
re-prefills, keeping the form and the "vooraf ingevuld" note consistent. */
restart() {
this.draftSync.reset(); // discard the current Concept; a fresh one starts on next progress
this.dispatch({ tag: 'Seed', state: initial });
@@ -1,6 +1,6 @@
import { Component } from '@angular/core';
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';
/** Page: register in the BIG-register. Built entirely from existing building
@@ -1,8 +1,8 @@
import { Component, computed, inject } from '@angular/core';
import { successOf } from '@shared/application/remote-data';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
import { ChangeRequestFormComponent } from '@registratie/ui/change-request-form/change-request-form.component';
import { BigProfileStore } from '@registratie/application/big-profile.store';
@@ -2,9 +2,9 @@ import { Component, input } from '@angular/core';
import { DatePipe } from '@angular/common';
import { Registration } from '@registratie/domain/registration';
import { statusColor, statusLabel } from '@registratie/domain/registration.policy';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { StatusBadgeComponent } from '@shared/ui/atoms/status-badge/status-badge.component';
import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
/** Organism: registration summary in a CIBG Datablock. Composes data-row rows +
status-badge atom (no card wrapper — the datablock carries its own surface). */
@@ -0,0 +1,105 @@
import { Component, input } from '@angular/core';
/** Molecule-shaped teaching card: the showcase's "before/after" box. Renders its own
tag pill and, when `code` is set, the highlighted snippet — everything else comes in
via `<ng-content />`. Angular does not style projected content from the receiving
component, so any other card-shaped block inside a section (a sub-label, a nested
ok/err result) nests another `<app-concept-card>` rather than writing raw `.tag`
markup — that markup would carry the SECTION's scope, not this component's, and the
rule here would never match it. */
@Component({
selector: 'app-concept-card',
imports: [],
styles: [
`
.card {
border: 1px solid var(--rhc-color-grijs-200);
border-radius: 10px;
padding: 1.25rem;
background: var(--rhc-color-wit);
}
.card--bad {
border-color: var(--rhc-color-rood-300);
}
.card--good {
border-color: var(--rhc-color-groen-300);
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-weight: 700;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 0.75rem;
}
.tag::before {
content: '';
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
}
.tag.bad {
color: var(--rhc-color-rood-600);
}
.tag.bad::before {
background: var(--rhc-color-rood-500);
}
.tag.good {
color: var(--rhc-color-groen-700);
}
.tag.good::before {
background: var(--rhc-color-groen-500);
}
.tag.plain {
color: var(--rhc-color-grijs-700);
}
.tag.plain::before {
display: none;
}
.linked {
margin: 0 0 1rem;
}
.linked .src {
font-size: 0.72rem;
color: var(--rhc-color-grijs-700);
margin: 0.35rem 0 0;
font-family: monospace;
}
`,
],
template: `
<div
class="card"
[class.card--good]="variant() === 'good'"
[class.card--bad]="variant() === 'bad'"
>
<p
class="tag"
[class.good]="variant() === 'good'"
[class.bad]="variant() === 'bad'"
[class.plain]="variant() === 'plain'"
>
{{ tag() }}
</p>
@if (code(); as c) {
@if (src(); as s) {
<figure class="linked">
<pre class="app-code" [innerHTML]="c"></pre>
<figcaption class="src">↳ {{ s }}</figcaption>
</figure>
} @else {
<pre class="app-code" [innerHTML]="c"></pre>
}
}
<ng-content />
</div>
`,
})
export class ConceptCardComponent {
variant = input<'good' | 'bad' | 'plain'>('plain');
tag = input.required<string>();
code = input<string | undefined>();
src = input<string | undefined>();
}
+23 -482
View File
@@ -1,497 +1,38 @@
/* eslint-disable max-lines */ // teaching page covering every concept — removed by RD-24
import { Component, computed, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import type { Resource } from '@angular/core';
import { Component } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component';
import { Registration } from '@registratie/domain/registration';
import { parsePostcode } from '@registratie/domain/value-objects/postcode';
import { parseBsn } from '@shared/kernel/bsn';
import { maskBsn } from '@shared/kernel/pii';
import { MaskedValueComponent } from '@shared/ui/masked-value/masked-value.component';
import { SNIPPETS } from './snippets.generated';
import { highlightTs } from './highlight-ts';
import { UnionsSection } from './unions.section';
import { RemoteDataSection } from './remote-data.section';
import { ParseSection } from './parse.section';
import { FormMachineSection } from './form-machine.section';
import { VragenlijstSection } from './vragenlijst.section';
import { PiiSection } from './pii.section';
/** Minimal fake Resource so <app-async> can be driven through every state without HTTP. */
function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T> {
return {
value: () => value as T,
status: () => status,
error: () => error,
hasValue: () => value !== undefined,
reload: () => {},
} as unknown as Resource<T>;
}
/** Teaching showcase: each section pairs the impossible-state-permitting"before"
with the"after" where the type system rules it out. Composition-only. */
/** Teaching showcase: each section pairs the impossible-state-permitting "before"
with the "after" where the type system rules it out. Composition-only. */
@Component({
selector: 'app-concepts-page',
imports: [
FormsModule,
PageShellComponent,
HeadingComponent,
TextInputComponent,
...ASYNC,
SkeletonComponent,
RegistrationSummaryComponent,
HerregistratieWizardComponent,
IntakeWizardComponent,
MaskedValueComponent,
],
styles: [
`
.section {
margin: 0 0 3rem;
}
.lead {
color: var(--rhc-color-grijs-700);
max-width: 46rem;
margin: 0.25rem 0 1.25rem;
}
.cols {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
gap: 1.5rem;
align-items: start;
}
.card {
border: 1px solid var(--rhc-color-grijs-200, #e5e5e5);
border-radius: 10px;
padding: 1.25rem;
background: #fff;
}
.card--bad {
border-color: var(--rhc-color-rood-300, #f0b4b4);
}
.card--good {
border-color: var(--rhc-color-groen-300, #b4e0b4);
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-weight: 700;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 0.75rem;
}
.tag::before {
content: '';
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
}
.tag.bad {
color: var(--rhc-color-rood-600, #a30000);
}
.tag.bad::before {
background: var(--rhc-color-rood-500, #d52b1e);
}
.tag.good {
color: var(--rhc-color-groen-700, #277337);
}
.tag.good::before {
background: var(--rhc-color-groen-500, #39870c);
}
.tag.plain {
color: var(--rhc-color-grijs-700);
}
.tag.plain::before {
display: none;
}
pre {
background: #1e2430;
color: #e6e9ef;
padding: 1rem;
border-radius: 8px;
overflow: auto;
font-size: 0.82rem;
line-height: 1.55;
margin: 0;
}
pre .k {
color: #c792ea;
}
pre .s {
color: #c3e88d;
}
pre .c {
color: #7e8aa0;
font-style: italic;
}
.note {
font-size: 0.9rem;
color: var(--rhc-color-grijs-700);
margin: 0.75rem 0 0;
}
/* live state diagram */
.machine {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0 0 1rem;
}
.node {
padding: 0.4rem 0.8rem;
border-radius: 999px;
border: 1px solid var(--rhc-color-grijs-300, #ccc);
font-size: 0.82rem;
color: var(--rhc-color-grijs-700);
transition: all 0.15s;
}
.node.on {
background: var(--rhc-color-hemelblauw-100, #e5f1fb);
border-color: var(--rhc-color-hemelblauw-500, #007bc7);
color: var(--rhc-color-hemelblauw-700, #00567d);
font-weight: 700;
/* teaching motion: the active state pops as the wizard transitions (the .node
transition above animates it; reduced-motion is handled globally). */
transform: scale(1.06);
}
.linked {
margin: 0 0 1rem;
}
.linked .src {
font-size: 0.72rem;
color: var(--rhc-color-grijs-700);
margin: 0.35rem 0 0;
font-family: monospace;
}
.steplist {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
margin: 0 0 1rem;
}
.pill {
padding: 0.3rem 0.7rem;
border-radius: 8px;
background: var(--rhc-color-grijs-100, #f3f3f3);
font-size: 0.8rem;
}
.pill.extra {
background: var(--rhc-color-geel-100, #fff6d6);
border: 1px dashed var(--rhc-color-geel-600, #c79a00);
}
.arrow {
color: var(--rhc-color-grijs-400, #999);
}
`,
UnionsSection,
RemoteDataSection,
ParseSection,
FormMachineSection,
VragenlijstSection,
PiiSection,
],
template: `
<app-page-shell heading="Onmogelijke toestanden onmogelijk maken" backLink="/dashboard">
<p class="lead">
<p class="app-lead">
Vijf functionele patronen die atomic design makkelijker maakt om te tonen — telkens "fout"
(de oude vorm liet het toe) naast"goed" (het type maakt het onmogelijk).
</p>
<!-- 1. Discriminated unions -->
<section class="section">
<app-heading [level]="2">1 · Discriminated unions</app-heading>
<p class="lead">Laat elke variant precies de gegevens dragen die kloppen — niets meer.</p>
<div class="cols">
<div class="card card--bad">
<p class="tag bad">Fout — vlakke interface</p>
<pre [innerHTML]="code['unionBad']"></pre>
<p class="note">
Een doorgehaalde registratie houdt tóch een herregistratiedatum: onmogelijke toestand.
</p>
</div>
<div class="card card--good">
<p class="tag good">Goed — sum type</p>
<figure class="linked">
<pre [innerHTML]="code['union']"></pre>
<figcaption class="src">↳ {{ src['union'] }}</figcaption>
</figure>
<app-registration-summary [reg]="doorgehaald" />
<p class="note">
De variant <code>Doorgehaald</code> kent geen herregistratiedatum, dus de rij bestaat
simpelweg niet.
</p>
</div>
</div>
</section>
<!-- 2. RemoteData fold -->
<section class="section">
<app-heading [level]="2">2 · RemoteData fold</app-heading>
<p class="lead">
Eén waarde met vier elkaar uitsluitende toestanden in plaats van drie losse booleans.
</p>
<div class="cols">
<div class="card card--good">
<p class="tag good">Vier toestanden, één molecuul</p>
<p class="tag plain">Loading</p>
<app-async [resource]="loadingRes"
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template
><ng-template appAsyncLoading
><app-skeleton [count]="2" height="1.2rem" [delay]="0" /></ng-template
></app-async>
<p class="tag plain">Empty</p>
<app-async [resource]="emptyRes" [isEmpty]="isEmpty"
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async
>
<p class="tag plain">Failure</p>
<app-async [resource]="errorRes"
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async
>
<p class="tag plain">Success</p>
<app-async [resource]="successRes" [isEmpty]="isEmpty"
><ng-template appAsyncLoaded
><ul>
@for (i of successRes.value(); track i) {
<li>{{ i }}</li>
}
</ul></ng-template
></app-async
>
</div>
<div class="card card--good">
<p class="tag good">De exhaustieve fold</p>
<figure class="linked">
<pre [innerHTML]="code['fold']"></pre>
<figcaption class="src">↳ {{ src['fold'] }}</figcaption>
</figure>
<p class="note">
Een nieuwe variant toevoegen breekt de compile via <code>assertNever</code> tot je hem
afhandelt.
</p>
</div>
</div>
</section>
<!-- 3. Parse, don't validate -->
<section class="section">
<app-heading [level]="2">3 · Parse, don't validate</app-heading>
<p class="lead">Na het parsen onthoudt het <em>type</em> dat de waarde geldig is.</p>
<div class="cols">
<div class="card">
<p class="tag good">Smart constructor → Result</p>
<figure class="linked">
<pre [innerHTML]="code['parse']"></pre>
<figcaption class="src">↳ {{ src['parse'] }}</figcaption>
</figure>
<app-text-input
inputId="pc"
[ngModel]="raw()"
(ngModelChange)="raw.set($event)"
name="pc"
placeholder="Typ een postcode, bijv. 1234 AB"
/>
</div>
@let r = parsed();
<div class="card" [class.card--good]="r.ok" [class.card--bad]="!r.ok">
@if (r.ok) {
<div animate.enter="app-item-enter">
<p class="tag good">ok</p>
<pre>Postcode ="{{ r.value }}"</pre>
<p class="note">
Een gevalideerde <code>Postcode</code> is een ander type dan een ruwe string.
</p>
</div>
} @else {
<div animate.enter="app-item-enter">
<p class="tag bad">err</p>
<pre>{{ r.error }}</pre>
</div>
}
</div>
</div>
</section>
<!-- 4. State machine / wizard (live state diagram) -->
<section class="section">
<app-heading [level]="2">4 · Form als state machine</app-heading>
<p class="lead">
Eén tagged union stuurt de UI. Speel met de wizard — de gemarkeerde toestand is de
huidige.
</p>
<div class="cols">
<div class="card card--bad">
<p class="tag bad">Fout — losse booleans</p>
<pre [innerHTML]="code['machineBad']"></pre>
<p class="note">
Niets verhindert"submitting" mét validatiefouten of een successcherm met errors.
</p>
</div>
<div class="card card--good">
<p class="tag good">Goed — één tagged union</p>
<figure class="linked">
<pre [innerHTML]="code['machine']"></pre>
<figcaption class="src">↳ {{ src['machine'] }}</figcaption>
</figure>
<div class="machine">
@for (n of ['Editing', 'Submitting', 'Submitted', 'Failed']; track n) {
<span class="node" [class.on]="w.state().tag === n">{{ n }}</span>
}
</div>
<app-herregistratie-wizard #w />
</div>
</div>
</section>
<!-- 5. Fixed steps, questions revealed inline -->
<section class="section">
<app-heading [level]="2"
>5 · Vragenlijst met vaste stappen —"vragen tonen, niet stappen toevoegen"</app-heading
>
<p class="lead">
Het aantal stappen ligt vast (<code>STEPS</code>); vervolgvragen verschijnen
<em>binnen</em> een stap op basis van eerdere antwoorden. Antwoord"ja" op buitenland of
vul weinig uren in, en er komt een extra vraag bij in dezelfde stap — de voortgang"van N"
blijft gelijk.
</p>
<div class="cols">
<div class="card card--good">
<p class="tag good">Vaste stappen</p>
<figure class="linked">
<pre [innerHTML]="code['steps']"></pre>
<figcaption class="src">↳ {{ src['steps'] }}</figcaption>
</figure>
<div class="steplist">
@for (s of iw.steps; track s; let last = $last) {
<span class="pill">{{ s }}</span>
@if (!last) {
<span class="arrow">→</span>
}
}
</div>
<p class="note">
De stappen zijn altijd dezelfde; alleen de vragen <em>binnen</em> een stap verschijnen
of verdwijnen.
</p>
</div>
<div class="card card--good">
<p class="tag good">De wizard</p>
<app-intake-wizard #iw />
</div>
</div>
</section>
<!-- 6. PII: mask + parse -->
<section class="section">
<app-heading [level]="2">6 · PII — maskeren & parsen</app-heading>
<p class="lead">
Een BSN is bijzondere persoonsgegevens (AVG art. 9). Dataminimalisatie: standaard
gemaskeerd tonen, alleen tonen na een vastgelegde handeling; en "parse, don't validate" op
het gevoeligste veld — een pure functie die de <em>elfproef</em> afdwingt.
</p>
<div class="cols">
<div class="card card--good">
<p class="tag good">Maskeren — atom</p>
<p>
BSN:
<app-masked-value
[value]="bsnShown()"
[canReveal]="true"
revealLabel="Toon BSN"
(reveal)="bsnRevealed.set(true)"
/>
</p>
<figure class="linked">
<pre [innerHTML]="code['mask']"></pre>
<figcaption class="src">↳ {{ src['mask'] }}</figcaption>
</figure>
<p class="note">
Standaard gemaskeerd; het echte tonen is step-up-geverifieerd én vastgelegd (zie het
behandelscherm). De atom bevat de maskeer-detectie — geen los <code>*</code>-gesnuffel
bij elke gebruiker.
</p>
</div>
<div class="card">
<p class="tag good">Parse (elfproef) → Result</p>
<app-text-input
inputId="bsn"
[ngModel]="bsnRaw()"
(ngModelChange)="bsnRaw.set($event)"
name="bsn"
placeholder="Typ een BSN, bijv. 123456782"
/>
@let b = bsnParsed();
@if (bsnRaw()) {
<div animate.enter="app-item-enter">
@if (b.ok) {
<p class="tag good">ok</p>
<pre>Bsn ="{{ b.value }}"</pre>
} @else {
<p class="tag bad">err</p>
<pre>{{ b.error }}</pre>
}
</div>
}
<figure class="linked">
<pre [innerHTML]="code['parseBsn']"></pre>
<figcaption class="src">↳ {{ src['parseBsn'] }}</figcaption>
</figure>
</div>
</div>
</section>
<app-concepts-unions-section />
<app-concepts-remote-data-section />
<app-concepts-parse-section />
<app-concepts-form-machine-section />
<app-concepts-vragenlijst-section />
<app-concepts-pii-section />
</app-page-shell>
`,
})
export class ConceptsPage {
isEmpty = (v: string[]) => !v || v.length === 0;
doorgehaald: Registration = {
bigNummer: '19012345601',
naam: 'Dr. A. (Anna) de Vries',
beroep: 'Arts',
registratiedatum: '2012-09-01',
geboortedatum: '1985-03-14',
status: { tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'Op eigen verzoek' },
};
loadingRes = fakeResource<string[]>('loading');
emptyRes = fakeResource<string[]>('resolved', []);
errorRes = fakeResource<string[]>('error', undefined, new Error('Demo'));
successRes = fakeResource<string[]>('resolved', ['Huisartsgeneeskunde', 'Spoedeisende hulp']);
raw = signal('');
parsed = computed(() => parsePostcode(this.raw()));
// 6 · PII demo. Masked-by-default value that reveals locally (the real reveal is
// step-up-gated + audited elsewhere); plus a live elfproef parse mirroring the postcode demo.
demoBsn = '123456782';
bsnRevealed = signal(false);
bsnShown = computed(() => (this.bsnRevealed() ? this.demoBsn : maskBsn(this.demoBsn)));
bsnRaw = signal('');
bsnParsed = computed(() => parseBsn(this.bsnRaw()));
// Deliberately-wrong illustrations (no real source to link — they show the anti-pattern).
private readonly illustrations: Record<string, string> = {
unionBad: `interface Registration {
status: 'Geregistreerd' | 'Doorgehaald';
herregistratieDatum: string; // altijd aanwezig 😬
}`,
machineBad: `submitting = signal(false);
submitted = signal(false);
errors = signal<...>({});
// submitting === true && errors.size > 0 ? 🤷`,
};
/** Highlighted HTML per snippet: the real ones come from SNIPPETS (extracted from source
by gen:snippets — they can't drift), the illustrations are authored above. */
protected readonly code: Record<string, string> = Object.fromEntries(
Object.entries({ ...SNIPPETS, ...this.illustrations }).map(([k, v]) => [k, highlightTs(v)]),
);
/** The real file each linked snippet is extracted from (shown as a caption). */
protected readonly src: Record<string, string> = {
union: 'registratie/domain/registration.ts',
fold: 'shared/application/remote-data.ts',
parse: 'registratie/domain/value-objects/postcode.ts',
machine: 'registratie/domain/change-request.machine.ts',
steps: 'herregistratie/domain/intake.machine.ts',
parseBsn: 'shared/kernel/bsn.ts',
mask: 'shared/kernel/pii.ts',
};
}
export class ConceptsPage {}
@@ -0,0 +1,87 @@
import { Component } from '@angular/core';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
import { ConceptCardComponent } from './concept-card.component';
import { SNIPPETS } from './snippets.generated';
import { highlightTs } from './highlight-ts';
/** Section 4: form as a state machine, shown as a live state diagram. One tagged union
drives the UI — the marked state below is the wizard's current one. Composition-only. */
@Component({
selector: 'app-concepts-form-machine-section',
imports: [HeadingComponent, HerregistratieWizardComponent, ConceptCardComponent],
styles: [
`
.machine {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0 0 1rem;
}
.node {
padding: 0.4rem 0.8rem;
border-radius: 999px;
border: 1px solid var(--rhc-color-grijs-300);
font-size: 0.82rem;
color: var(--rhc-color-grijs-700);
transition: all 0.15s;
}
.node.on {
background: var(--rhc-color-hemelblauw-100);
border-color: var(--rhc-color-hemelblauw-500);
color: var(--rhc-color-hemelblauw-700);
font-weight: 700;
/* teaching motion: the active state pops as the wizard transitions (the .node
transition above animates it; reduced-motion is handled globally). */
transform: scale(1.06);
}
`,
],
template: `
<section class="app-section">
<app-heading [level]="2">4 · Form als state machine</app-heading>
<p class="app-lead">
Eén tagged union stuurt de UI. Speel met de wizard — de gemarkeerde toestand is de huidige.
</p>
<div class="app-cols">
<app-concept-card variant="bad" tag="Fout — losse booleans" [code]="code['machineBad']">
<p class="app-note">
Niets verhindert"submitting" mét validatiefouten of een successcherm met errors.
</p>
</app-concept-card>
<app-concept-card
variant="good"
tag="Goed — één tagged union"
[code]="code['machine']"
[src]="src['machine']"
>
<div class="machine">
@for (n of ['Editing', 'Submitting', 'Submitted', 'Failed']; track n) {
<span class="node" [class.on]="w.state().tag === n">{{ n }}</span>
}
</div>
<app-herregistratie-wizard #w />
</app-concept-card>
</div>
</section>
`,
})
export class FormMachineSection {
// Deliberately-wrong illustration (no real source to link — it shows the anti-pattern).
private readonly machineBad = `submitting = signal(false);
submitted = signal(false);
errors = signal<...>({});
// submitting === true && errors.size > 0 ? 🤷`;
/** Highlighted HTML per snippet: `machine` comes from SNIPPETS (extracted from source by
gen:snippets — it can't drift), `machineBad` is authored above. */
protected readonly code: Record<string, string> = {
machineBad: highlightTs(this.machineBad),
machine: highlightTs(SNIPPETS['machine']),
};
/** The real file the linked snippet is extracted from (shown as a caption). */
protected readonly src: Record<string, string> = {
machine: 'registratie/domain/change-request.machine.ts',
};
}
@@ -0,0 +1,60 @@
import { Component, computed, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { parsePostcode } from '@registratie/domain/value-objects/postcode';
import { ConceptCardComponent } from './concept-card.component';
import { SNIPPETS } from './snippets.generated';
import { highlightTs } from './highlight-ts';
/** Section 3: parse, don't validate. After parsing, the TYPE remembers the value is valid.
Composition-only; owns its own postcode demo. */
@Component({
selector: 'app-concepts-parse-section',
imports: [FormsModule, HeadingComponent, TextInputComponent, ConceptCardComponent],
template: `
<section class="app-section">
<app-heading [level]="2">3 · Parse, don't validate</app-heading>
<p class="app-lead">Na het parsen onthoudt het <em>type</em> dat de waarde geldig is.</p>
<div class="app-cols">
<app-concept-card
tag="Smart constructor → Result"
[code]="code['parse']"
[src]="src['parse']"
>
<app-text-input
inputId="pc"
[ngModel]="raw()"
(ngModelChange)="raw.set($event)"
name="pc"
placeholder="Typ een postcode, bijv. 1234 AB"
/>
</app-concept-card>
@let r = parsed();
<app-concept-card [variant]="r.ok ? 'good' : 'bad'" [tag]="r.ok ? 'ok' : 'err'">
@if (r.ok) {
<div animate.enter="app-item-enter">
<pre class="app-code">Postcode ="{{ r.value }}"</pre>
<p class="app-note">
Een gevalideerde <code>Postcode</code> is een ander type dan een ruwe string.
</p>
</div>
} @else {
<div animate.enter="app-item-enter">
<pre class="app-code">{{ r.error }}</pre>
</div>
}
</app-concept-card>
</div>
</section>
`,
})
export class ParseSection {
raw = signal('');
parsed = computed(() => parsePostcode(this.raw()));
protected readonly code: Record<string, string> = { parse: highlightTs(SNIPPETS['parse']) };
protected readonly src: Record<string, string> = {
parse: 'registratie/domain/value-objects/postcode.ts',
};
}
+102
View File
@@ -0,0 +1,102 @@
import { Component, computed, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { MaskedValueComponent } from '@shared/ui/atoms/masked-value/masked-value.component';
import { parseBsn } from '@shared/kernel/bsn';
import { maskBsn } from '@shared/kernel/pii';
import { ConceptCardComponent } from './concept-card.component';
import { SNIPPETS } from './snippets.generated';
import { highlightTs } from './highlight-ts';
/** Section 6: PII — masking and parsing. A BSN (Dutch citizen service number) is
special-category personal data (GDPR art. 9): masked by default, revealed only after
a logged action; "parse, don't validate" on the most sensitive field — a pure function
enforces the checksum. Composition-only. The ok/err result nests its own
`<app-concept-card>` for its label, for the same reason section 2 does. */
@Component({
selector: 'app-concepts-pii-section',
imports: [
FormsModule,
HeadingComponent,
TextInputComponent,
MaskedValueComponent,
ConceptCardComponent,
],
template: `
<section class="app-section">
<app-heading [level]="2">6 · PII — maskeren & parsen</app-heading>
<p class="app-lead">
Een BSN is bijzondere persoonsgegevens (AVG art. 9). Dataminimalisatie: standaard gemaskeerd
tonen, alleen tonen na een vastgelegde handeling; en"parse, don't validate" op het
gevoeligste veld — een pure functie die de <em>elfproef</em> afdwingt.
</p>
<div class="app-cols">
<app-concept-card
variant="good"
tag="Maskeren — atom"
[code]="code['mask']"
[src]="src['mask']"
>
<p>
BSN:
<app-masked-value
[value]="bsnShown()"
[canReveal]="true"
revealLabel="Toon BSN"
(reveal)="bsnRevealed.set(true)"
/>
</p>
<p class="app-note">
Standaard gemaskeerd; het echte tonen is step-up-geverifieerd én vastgelegd (zie het
behandelscherm). De atom bevat de maskeer-detectie — geen los <code>*</code>-gesnuffel
bij elke gebruiker.
</p>
</app-concept-card>
<app-concept-card
tag="Parse (elfproef) → Result"
[code]="code['parseBsn']"
[src]="src['parseBsn']"
>
<app-text-input
inputId="bsn"
[ngModel]="bsnRaw()"
(ngModelChange)="bsnRaw.set($event)"
name="bsn"
placeholder="Typ een BSN, bijv. 123456782"
/>
@let b = bsnParsed();
@if (bsnRaw()) {
<div animate.enter="app-item-enter">
<app-concept-card [variant]="b.ok ? 'good' : 'bad'" [tag]="b.ok ? 'ok' : 'err'">
@if (b.ok) {
<pre class="app-code">Bsn ="{{ b.value }}"</pre>
} @else {
<pre class="app-code">{{ b.error }}</pre>
}
</app-concept-card>
</div>
}
</app-concept-card>
</div>
</section>
`,
})
export class PiiSection {
// Masked-by-default value that reveals locally (the real reveal is step-up-gated +
// audited elsewhere); plus a live elfproef parse mirroring the postcode demo.
demoBsn = '123456782';
bsnRevealed = signal(false);
bsnShown = computed(() => (this.bsnRevealed() ? this.demoBsn : maskBsn(this.demoBsn)));
bsnRaw = signal('');
bsnParsed = computed(() => parseBsn(this.bsnRaw()));
protected readonly code: Record<string, string> = {
mask: highlightTs(SNIPPETS['mask']),
parseBsn: highlightTs(SNIPPETS['parseBsn']),
};
protected readonly src: Record<string, string> = {
mask: 'shared/kernel/pii.ts',
parseBsn: 'shared/kernel/bsn.ts',
};
}
@@ -0,0 +1,92 @@
import { Component } from '@angular/core';
import type { Resource } from '@angular/core';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { ConceptCardComponent } from './concept-card.component';
import { SNIPPETS } from './snippets.generated';
import { highlightTs } from './highlight-ts';
/** Minimal fake Resource so <app-async> can be driven through every state without HTTP. */
function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T> {
return {
value: () => value as T,
status: () => status,
error: () => error,
hasValue: () => value !== undefined,
reload: () => {},
} as unknown as Resource<T>;
}
/** Section 2: RemoteData fold. One value with four mutually exclusive states, instead of
three loose booleans. Composition-only; owns its own fake resources. Each of the four
demo states nests its own `<app-concept-card>` for its label — a plain `.tag` element
written here would carry this section's scope, not the card's, and stay unstyled. */
@Component({
selector: 'app-concepts-remote-data-section',
imports: [HeadingComponent, ...ASYNC, SkeletonComponent, ConceptCardComponent],
template: `
<section class="app-section">
<app-heading [level]="2">2 · RemoteData fold</app-heading>
<p class="app-lead">
Eén waarde met vier elkaar uitsluitende toestanden in plaats van drie losse booleans.
</p>
<div class="app-cols">
<app-concept-card variant="good" tag="Vier toestanden, één molecuul">
<div class="app-stack">
<app-concept-card variant="plain" tag="Loading">
<app-async [resource]="loadingRes"
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template
><ng-template appAsyncLoading
><app-skeleton [count]="2" height="1.2rem" [delay]="0" /></ng-template
></app-async>
</app-concept-card>
<app-concept-card variant="plain" tag="Empty">
<app-async [resource]="emptyRes" [isEmpty]="isEmpty"
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async
>
</app-concept-card>
<app-concept-card variant="plain" tag="Failure">
<app-async [resource]="errorRes"
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async
>
</app-concept-card>
<app-concept-card variant="plain" tag="Success">
<app-async [resource]="successRes" [isEmpty]="isEmpty"
><ng-template appAsyncLoaded
><ul>
@for (i of successRes.value(); track i) {
<li>{{ i }}</li>
}
</ul></ng-template
></app-async
>
</app-concept-card>
</div>
</app-concept-card>
<app-concept-card
variant="good"
tag="De exhaustieve fold"
[code]="code['fold']"
[src]="src['fold']"
>
<p class="app-note">
Een nieuwe variant toevoegen breekt de compile via <code>assertNever</code> tot je hem
afhandelt.
</p>
</app-concept-card>
</div>
</section>
`,
})
export class RemoteDataSection {
isEmpty = (v: string[]) => !v || v.length === 0;
loadingRes = fakeResource<string[]>('loading');
emptyRes = fakeResource<string[]>('resolved', []);
errorRes = fakeResource<string[]>('error', undefined, new Error('Demo'));
successRes = fakeResource<string[]>('resolved', ['Huisartsgeneeskunde', 'Spoedeisende hulp']);
protected readonly code: Record<string, string> = { fold: highlightTs(SNIPPETS['fold']) };
protected readonly src: Record<string, string> = { fold: 'shared/application/remote-data.ts' };
}
@@ -0,0 +1,67 @@
import { Component } from '@angular/core';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
import { Registration } from '@registratie/domain/registration';
import { ConceptCardComponent } from './concept-card.component';
import { SNIPPETS } from './snippets.generated';
import { highlightTs } from './highlight-ts';
/** Section 1: discriminated unions. Each variant carries exactly the data that fits it —
nothing more. Composition-only; owns its own demo data. */
@Component({
selector: 'app-concepts-unions-section',
imports: [HeadingComponent, RegistrationSummaryComponent, ConceptCardComponent],
template: `
<section class="app-section">
<app-heading [level]="2">1 · Discriminated unions</app-heading>
<p class="app-lead">Laat elke variant precies de gegevens dragen die kloppen — niets meer.</p>
<div class="app-cols">
<app-concept-card variant="bad" tag="Fout — vlakke interface" [code]="code['unionBad']">
<p class="app-note">
Een doorgehaalde registratie houdt tóch een herregistratiedatum: onmogelijke toestand.
</p>
</app-concept-card>
<app-concept-card
variant="good"
tag="Goed — sum type"
[code]="code['union']"
[src]="src['union']"
>
<app-registration-summary [reg]="doorgehaald" />
<p class="app-note">
De variant <code>Doorgehaald</code> kent geen herregistratiedatum, dus de rij bestaat
simpelweg niet.
</p>
</app-concept-card>
</div>
</section>
`,
})
export class UnionsSection {
doorgehaald: Registration = {
bigNummer: '19012345601',
naam: 'Dr. A. (Anna) de Vries',
beroep: 'Arts',
registratiedatum: '2012-09-01',
geboortedatum: '1985-03-14',
status: { tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'Op eigen verzoek' },
};
// Deliberately-wrong illustration (no real source to link — it shows the anti-pattern).
private readonly unionBad = `interface Registration {
status: 'Geregistreerd' | 'Doorgehaald';
herregistratieDatum: string; // altijd aanwezig 😬
}`;
/** Highlighted HTML per snippet: `union` comes from SNIPPETS (extracted from source by
gen:snippets — it can't drift), `unionBad` is authored above. */
protected readonly code: Record<string, string> = {
unionBad: highlightTs(this.unionBad),
union: highlightTs(SNIPPETS['union']),
};
/** The real file the linked snippet is extracted from (shown as a caption). */
protected readonly src: Record<string, string> = {
union: 'registratie/domain/registration.ts',
};
}
@@ -0,0 +1,81 @@
import { Component } from '@angular/core';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component';
import { ConceptCardComponent } from './concept-card.component';
import { SNIPPETS } from './snippets.generated';
import { highlightTs } from './highlight-ts';
/** Section 5: a questionnaire with a fixed step count — "show questions, don't add
steps". The step count never changes; follow-up questions reveal inline based on
earlier answers. Composition-only. */
@Component({
selector: 'app-concepts-vragenlijst-section',
imports: [HeadingComponent, IntakeWizardComponent, ConceptCardComponent],
styles: [
`
.steplist {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
margin: 0 0 1rem;
}
.pill {
padding: 0.3rem 0.7rem;
border-radius: 8px;
background: var(--rhc-color-grijs-100);
font-size: 0.8rem;
}
.pill.extra {
background: var(--rhc-color-geel-100);
border: 1px dashed var(--rhc-color-geel-600);
}
.arrow {
color: var(--rhc-color-grijs-400);
}
`,
],
template: `
<section class="app-section">
<app-heading [level]="2"
>5 · Vragenlijst met vaste stappen —"vragen tonen, niet stappen toevoegen"</app-heading
>
<p class="app-lead">
Het aantal stappen ligt vast (<code>STEPS</code>); vervolgvragen verschijnen
<em>binnen</em> een stap op basis van eerdere antwoorden. Antwoord"ja" op buitenland of vul
weinig uren in, en er komt een extra vraag bij in dezelfde stap — de voortgang"van N" blijft
gelijk.
</p>
<div class="app-cols">
<app-concept-card
variant="good"
tag="Vaste stappen"
[code]="code['steps']"
[src]="src['steps']"
>
<div class="steplist">
@for (s of iw.steps; track s; let last = $last) {
<span class="pill">{{ s }}</span>
@if (!last) {
<span class="arrow">→</span>
}
}
</div>
<p class="app-note">
De stappen zijn altijd dezelfde; alleen de vragen <em>binnen</em> een stap verschijnen
of verdwijnen.
</p>
</app-concept-card>
<app-concept-card variant="good" tag="De wizard">
<app-intake-wizard #iw />
</app-concept-card>
</div>
</section>
`,
})
export class VragenlijstSection {
protected readonly code: Record<string, string> = { steps: highlightTs(SNIPPETS['steps']) };
protected readonly src: Record<string, string> = {
steps: 'herregistratie/domain/intake.machine.ts',
};
}
+43
View File
@@ -264,6 +264,21 @@ Also settle the two deviations the refactor left behind:
an indirection and adds a barrel-shaped thing to a repo that deliberately has none.
- Three of six sections have no story (`beheer-links`, `wat-moet-ik-regelen`,
`wat-wilt-u-doen`). Add one only where the section has more than one visual state.
- **The folder name — fix it (RD-36, added after RD-04 shipped).** RD-04 recorded
`registratie/ui/dashboard/` as a stale label and left it alone. It stays stale: the folder is
named after a page that now lives in `overzicht/`, so a reader looking for the dashboard finds
four sections that are not it, and the page is somewhere else. Measured cost: 8 `git mv`s and
**four import lines in one file** (`overzicht.page.ts`). The alias does not change.
Two names were considered. `overzicht-secties/` says what the four files are — registratie's
sections for the overzicht page — and keeps the Dutch that CLAUDE.md requires of a domain
context. Flattening the files into `registratie/ui/` was rejected: that directory holds one
folder per component, so eight loose files would break its shape.
Fold in two stale paths the same move left behind, both naming a file that no longer exists:
`docs/reference/feature-flags.md:55` and `.claude/skills/new-ssp/SKILL.md:65` still say
`registratie/ui/dashboard.page`. The flag logic they describe lives in
`overzicht/ui/wat-wilt-u-doen.section.ts` now.
---
@@ -772,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
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
`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
@@ -0,0 +1,166 @@
# RD-21 — Move the selection surgery into `rich-text-dom.ts`, and delete the disable
Status: done
Source: PLAN.md 3h, order step 3
## Why
`rich-text-editor.component.ts` measures ~256 effective lines against a limit of 250, so it
carries `/* eslint-disable max-lines */`. The lines that put it over are not component
concerns: `deleteAdjacentChip` and `insert` do `getSelection()`/`Range` surgery inside a
component whose job is the toolbar and the `contenteditable` host.
`rich-text-dom.ts` already exists beside it, already owns the DOM boundary, and already has a
spec. The seam is built. This is the cheapest of Phase 3's seven splits, and it converts two
untested imperative branches into spec cases.
## Read first
- `libs/shared/src/ui/rich-text-editor/rich-text-dom.ts` — the four exports today
(`renderInto`, `createChip`, `readBlock`, `adjacentChip`) and the file's header comment,
which already states the contract this ticket extends.
- `rich-text-editor.component.ts:257-293``deleteAdjacentChip` and `insert`, the two bodies
that move.
- `rich-text-dom.spec.ts` — 8 cases, plain jsdom, no TestBed. The new cases join it.
## Decisions (pre-made, don't relitigate)
1. **Two new exports in `rich-text-dom.ts`, both taking the editor root:**
```ts
/** The chip a collapsed caret sits next to, or null. `direction` is -1 for
Backspace and 1 for Delete. Returns null when the selection is absent, is a
range rather than a caret, or sits outside `root`. */
export function chipAtCaret(root: HTMLElement, direction: -1 | 1): HTMLElement | null;
/** Insert `chip` at the caret when the selection is inside `root`, and leave the
caret after it. With no usable selection, append to the last line instead. */
export function insertChipAtCaret(root: HTMLElement, chip: HTMLElement): void;
```
2. **`chipAtCaret` wraps `adjacentChip`; it does not replace it.** `adjacentChip` stays
exported and keeps its own spec case, which tests the node/offset arithmetic directly.
`chipAtCaret` adds the selection guards around it. The component stops importing
`adjacentChip` and imports `chipAtCaret` instead.
3. **The two component methods reduce to their component concerns:**
```ts
private deleteAdjacentChip(e: KeyboardEvent) {
const el = this.editorEl()?.nativeElement;
if (!el) return;
const chip = chipAtCaret(el, e.key === 'Backspace' ? -1 : 1);
if (!chip) return;
e.preventDefault();
chip.remove();
this.emit();
}
protected insert(key: string) {
const el = this.editorEl()?.nativeElement;
if (!key || !el) return;
el.focus();
insertChipAtCaret(el, createChip(el.ownerDocument, key, this.labelFor(key), this.autoFor(key)));
this.emit();
}
```
`e.preventDefault()`, `chip.remove()`, `el.focus()` and `this.emit()` stay in the component:
they are event handling and output, not DOM boundary work.
4. **Delete `/* eslint-disable max-lines */` from line 1 of the component.** This is not
optional bookkeeping — `reportUnusedDisableDirectives` is `error`, so leaving a directive
that is no longer needed **fails the build**. The two checks pin each other: if the file is
still over budget, lint fails on `max-lines`; if it is under and the directive stays, lint
fails on the unused directive.
5. **No new file, no new folder.** `rich-text-dom.ts` is the right home and already carries the
header comment that describes exactly this responsibility.
6. **No story changes.** `rich-text-editor.stories.ts` exercises the component through the same
public surface; nothing it renders changes.
## Files
- `libs/shared/src/ui/rich-text-editor/rich-text-dom.ts` — two new exports
- `libs/shared/src/ui/rich-text-editor/rich-text-dom.spec.ts` — new cases
- `libs/shared/src/ui/rich-text-editor/rich-text-editor.component.ts` — two shrunken methods,
changed import, and the disable deleted
- `libs/shared/docs/behaviour-spec.mdx` (regenerated, never hand-edited)
## Steps
1. Add `chipAtCaret` and `insertChipAtCaret` per decision 1, moving the bodies out of the
component rather than rewriting them.
2. Add spec cases. Cover, at minimum: `chipAtCaret` returns the chip before a Backspace caret;
returns null for a non-collapsed selection; returns null for a caret outside `root`;
`insertChipAtCaret` splices at the caret and leaves the caret after the chip;
`insertChipAtCaret` appends when there is no selection inside `root`.
3. Rewrite the two component methods per decision 3 and fix the import line.
4. Delete the disable (decision 4).
5. Run `npm run gen:behaviour-spec`.
6. `git add -A`, then run the acceptance commands.
7. Update this ticket's `Status:` to `done` and the README's RD-21 row to `done`.
8. Commit all of it together.
## Acceptance criteria
Measured against the tree before handover.
```bash
git grep -c "^export function" libs/shared/src/ui/rich-text-editor/rich-text-dom.ts # is 4 -> MUST be 6
git grep -c "eslint-disable max-lines" -- libs/shared/src/ui/rich-text-editor/rich-text-editor.component.ts # is 1 -> MUST be 0
```
The selection surgery has left the component entirely:
```bash
git grep -c "getSelection" -- libs/shared/src/ui/rich-text-editor/rich-text-editor.component.ts # is 2 -> MUST be 0
git grep -c "getSelection" -- libs/shared/src/ui/rich-text-editor/rich-text-dom.ts # is 0 -> MUST be 2
git grep -c "adjacentChip" -- libs/shared/src/ui/rich-text-editor/rich-text-editor.component.ts # is 2 -> MUST be 0
```
`adjacentChip` survives with its spec case (decision 2):
```bash
git grep -c "export function adjacentChip" libs/shared/src/ui/rich-text-editor/rich-text-dom.ts # MUST be 1
git grep -c " it(" libs/shared/src/ui/rich-text-editor/rich-text-dom.spec.ts # is 8 -> MUST be >= 13
```
```bash
npm run ci --full # exits 0
```
## Verification
**Do not add a line-count command.** `npm run lint`, inside the gate, is the exact check and a
hand-rolled `grep -v | wc -l` is not: it cannot reproduce eslint's `skipComments` for a trailing
comment or for the component's inline template. Decision 4 explains why lint alone pins both
directions.
**`--full` is required.** This edits `libs/shared/src/ui/**`, which the README's rule names
explicitly.
jsdom supports `document.getSelection()`, `Range.deleteContents()`, `insertNode`,
`removeAllRanges` and `addRange`, so every new case runs in the existing plain-vitest setup. No
TestBed, no browser.
## Out of scope
- Splitting the component further. It is over by a handful of lines, not structurally wrong.
- Touching `renderInto`, `readBlock` or `createChip`.
- The `ponytail:` note in `rich-text-dom.ts`'s header about exotic pasted markup. That is a
recorded limitation, not this ticket's work.
## Risks
- **Deleting the disable is mandatory, not cosmetic** (decision 4). Forgetting it fails lint
with `Unused eslint-disable directive`, which reads like an unrelated error.
- **Keep `adjacentChip` exported.** Its spec case imports it directly; folding it into
`chipAtCaret` deletes a test that covers node/offset arithmetic the wrapper does not.
- **Move the bodies, do not rewrite them.** The caret placement after insert
(`setStartAfter``collapse(true)``removeAllRanges``addRange`) is the part users feel;
a "cleaner" rewrite is where a regression hides, and no story catches it.
- **`behaviour-spec.mdx` drift** from the new spec titles. Regenerate in the same commit. A name
used in a `describe` or `it` title also lands in that generated file — count it if you add a
grep for one.
@@ -0,0 +1,180 @@
# RD-22 — Split `intake-wizard` into three step components
Status: done
Source: PLAN.md 3c, order step 4
## Why
`intake-wizard.component.ts` measures ~362 effective lines against a limit of 250, and carries
`/* eslint-disable max-lines */`. Nearly all of the excess is one `@switch` with three `@case`
blocks — three screens' worth of markup in one file, where reading any one of them means
scrolling past the other two.
The three cases are already independent. Each reads only the answers, the errors and (for two of
them) the scholing threshold. None needs the store.
## Read first
- `apps/ssp/src/app/registratie/ui/address-fields/address-fields.component.ts:13-18` — **the
contract to copy, verbatim.** "Pure & presentational — values in via `value`, errors in via
`errors`, every keystroke out via `fieldChange`. No store, no services, no internal state; the
container owns the Model and decides what a change means." Two containers already reuse it.
- `intake-wizard.component.ts:72-249` — the `@switch` and its three cases.
- `intake.machine.ts``Answers` (21), `lageUren` (52), `SCHOLING_THRESHOLD_DEFAULT` (43), and
`Errors` at line 63, which decision 2 exports.
- `libs/shared/src/layout/wizard-shell/wizard-shell.component.ts:103-113` — the `<form>` and the
`<ng-content />` the steps are projected into. Relevant to the first risk.
## Decisions (pre-made, don't relitigate)
1. **Three new files, beside the parent, named `*.step.ts`:**
| File | Class | Selector |
| -------------------- | ---------------- | ---------------------------- |
| `buitenland.step.ts` | `BuitenlandStep` | `app-intake-buitenland-step` |
| `werk.step.ts` | `WerkStep` | `app-intake-werk-step` |
| `review.step.ts` | `ReviewStep` | `app-intake-review-step` |
These are the repository's **first** `*.step.ts` files, so this ticket sets the convention
that RD-23 follows. The `max-lines` glob already includes `step`, so they are guarded from
the moment they exist.
2. **Inputs down, one narrow output up, `dispatch` never passed down.**
| Step | Inputs | Output |
| ------------ | ---------------------------------------- | ----------------------------------------------------- |
| `buitenland` | `answers`, `errors` | `answerChange: { key: keyof Answers; value: string }` |
| `werk` | `answers`, `errors`, `scholingThreshold` | `answerChange` (same shape) |
| `review` | `answers`, `scholingThreshold` | `edit: number` (the cursor to jump to) |
All inputs are `input.required<T>()`. The parent maps the outputs back to messages:
`(answerChange)="dispatch({ tag: 'SetAnswer', key: $event.key, value: $event.value })"` and
`(edit)="dispatch({ tag: 'GaNaarStap', cursor: $event })"`.
3. **`scholingZichtbaar` is not an input — each step derives it.** `werk` and `review` both call
the pure `lageUren(this.answers(), this.scholingThreshold())` themselves. "Derive, don't
store" (CLAUDE.md decision 3). The parent's `scholingZichtbaar` computed is deleted; it has
exactly three references today, all of them in the two blocks that move.
4. **Export `Errors` from `intake.machine.ts:63.`** It is `type Errors = …` without `export`
today, so a step cannot name its own input type. One word. Do not redeclare the type in the
step files, and do not widen the input to `Record<string, string>`.
5. **The parent keeps the shell, the store, and everything that touches them.** After the split
it holds: the store and its effect map, `draftSync`, `IntakePolicyStore`, `restart()`,
`phase`, `primaryLabel`, `stepTitle`, `stepLabels`, `errorList`, and the `wizardSuccess`
block. It loses `err`, `set`, `jaNee` and `scholingZichtbaar`, and gains one computed:
```ts
protected errors = computed<Errors>(() => this.answering()?.errors ?? {});
```
6. **Prune the parent's `imports:` array.** After the move it needs only `ButtonComponent`,
`ConfirmationComponent`, `WizardShellComponent` and the three steps. `FormsModule`,
`FormFieldComponent`, `TextInputComponent`, `RadioGroupComponent`, `AlertComponent`,
`DataRowComponent` and `ReviewSectionComponent` all move into the steps that use them. A
stale entry is not an error, so nothing fails if you forget — check the list by hand.
7. **Delete `/* eslint-disable max-lines */` from the parent.** Mandatory, not bookkeeping:
`reportUnusedDisableDirectives` is `error`, so the two rules pin each other. Still over
budget → `max-lines` fails. Under budget with the directive left in → unused-directive fails.
8. **No stories for the new steps.** PLAN's corollary: each wizard's existing story already
mounts every step by seeding the machine. `intake-wizard.stories.ts` is unchanged, and the
parent's public API does not move.
9. **Move the markup, do not improve it.** Copy each `@case` body into its step's template and
change only what decisions 2 and 3 require: `err('x')` becomes `errors()['x'] ?? ''` through a
local helper, `set('x', $event)` becomes an `answerChange.emit(…)`, and `dispatch(GaNaarStap)`
becomes `edit.emit(n)`. Every `i18n` id, label, placeholder and `fieldId` stays byte-identical.
## Files
- `apps/ssp/src/app/herregistratie/ui/intake-wizard/buitenland.step.ts` (new)
- `apps/ssp/src/app/herregistratie/ui/intake-wizard/werk.step.ts` (new)
- `apps/ssp/src/app/herregistratie/ui/intake-wizard/review.step.ts` (new)
- `apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts`
- `apps/ssp/src/app/herregistratie/domain/intake.machine.ts` (decision 4, one word)
## Steps
1. Export `Errors` (decision 4).
2. Write the three step components, moving each `@case` body verbatim per decision 9.
3. Replace the `@switch` in the parent with the three elements, wire the outputs per decision 2.
4. Delete `err`, `set`, `jaNee`, `scholingZichtbaar`; add the `errors` computed (decision 5).
5. Prune `imports:` (decision 6) and delete the disable (decision 7).
6. `git add -A`, then run the acceptance commands.
7. Update this ticket's `Status:` to `done` and the README's RD-22 row to `done`.
8. Commit all of it together.
## Acceptance criteria
Measured against the tree before handover. Run after `git add -A``git ls-files` does not see
an unstaged new file.
```bash
git ls-files 'apps/ssp/src/app/herregistratie/ui/intake-wizard/*.step.ts' | wc -l # is 0 -> MUST be 3
```
The markup left the parent, and the store did not follow it into the steps:
```bash
P=apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts
git grep -c "ngModel" -- $P # is 12 -> MUST be 0
git grep -c "eslint-disable max-lines" -- $P # is 1 -> MUST be 0
git grep -c "dispatch" -- 'apps/ssp/src/app/herregistratie/ui/intake-wizard/*.step.ts' | awk -F: '{s+=$NF} END {print s+0}' # MUST be 0
```
Decisions 3 and 4 landed:
```bash
git grep -c "export type Errors" -- apps/ssp/src/app/herregistratie/domain/intake.machine.ts # is 0 -> MUST be 1
git grep -c "scholingZichtbaar" -- $P # is 3 -> MUST be 0
git grep -c "lageUren" -- 'apps/ssp/src/app/herregistratie/ui/intake-wizard/*.step.ts' | awk -F: '{s+=$NF} END {print s+0}' # MUST be >= 2
```
The copy did not drift (decision 9) — the `i18n` ids are the same set, only in different files:
```bash
git grep -ho "@@intake\.[a-zA-Z.]*" -- apps/ssp/src/app/herregistratie/ui/intake-wizard/ | sort -u | wc -l # is 31 -> MUST still be 31
```
```bash
npm run ci --full # exits 0
```
## Verification
The `@@intake.*` id count is **31** today, measured across the whole `intake-wizard/` directory
so the three new files are included. A dropped or renamed id breaks the second locale, and
`ng build --localize` inside the gate fails on a missing translation — but only for an id that
is _added_, never for one silently _lost_. The count is the only check that catches a loss.
**`--full` is required.** The steps render inside the existing story, and the axe run over that
story is what proves the projected markup still has its labels and error wiring.
**Do not add a line-count command.** `npm run lint` is the exact check; decision 7 explains why.
## Out of scope
- `registratie-wizard`. RD-23 does the same job there, and follows this ticket's naming.
- `herregistratie-wizard`. PLAN: do not split it for symmetry — it is ~248 effective lines with a
~100-line template.
- Adding stories for the steps (decision 8).
- Changing any validation, message or `i18n` id.
## Risks
- **`ngModel` and the projected `<form>`.** The shell renders `<form>` and `<ng-content />` in
its own view, so today's `ngModel` elements are projected into it from the parent's template.
Angular resolves a directive's injector by the **declaration** site, not the DOM position, so
those controls already do not register with the shell's `NgForm` — the bindings are one-way
`[ngModel]` plus `(ngModelChange)`. Moving them one level deeper changes nothing about that.
**Keep the bindings exactly as they are.** If a form-control warning or error appears, stop and
report it rather than adding `ngModelOptions` or an `[ngModelGroup]` to silence it.
- **Deleting the disable is mandatory** (decision 7), and its failure message
("Unused eslint-disable directive") reads like an unrelated error.
- **The `review` step needs a cursor, not a `dispatch`.** Its two edit buttons jump to cursor 0
and 1. Emit the number; let the parent build the message.
- **Three `@case` blocks, three files — do not merge them.** `buitenland` and `werk` look
similar; they are not the same screen and share no markup worth extracting.
@@ -0,0 +1,235 @@
# RD-23 — Split `registratie-wizard` into three steps, and move the upload controller
Status: done
Source: PLAN.md 3c, order step 5
## Why
`registratie-wizard.component.ts` measures ~568 effective lines against a limit of 250 — the
largest file in the arc, and more than twice the budget. It carries
`/* eslint-disable max-lines */`.
It is the same shape as RD-22's intake wizard: one `@switch`, three `@case` blocks, three
screens in one file. It is harder in one way that PLAN calls out — **moving the upload
controller is what gets the parent under 250**, and the controller is a stateful thing, not
markup.
RD-22 already set the `*.step.ts` convention. Follow it.
## Read first
- `apps/ssp/src/app/herregistratie/ui/intake-wizard/buitenland.step.ts` and `review.step.ts`
**the shape to copy.** RD-22 built them one ticket ago; match their header comments, their
`input.required` style and their output naming.
- `registratie-wizard.component.ts:100-348` — the three `@case` blocks.
- `registratie-wizard.component.ts:411-422``createUploadController`, and the `dispatch`
callback that decision 4 rewires.
- `registratie-wizard.component.ts:568-585``onDiplomaKeuze`, which decision 5 reshapes.
- `apps/ssp/src/app/registratie/ui/address-fields/address-fields.component.ts:13-18` — the
contract the whole family follows.
## Decisions (pre-made, don't relitigate)
1. **Three new files beside the parent, named as RD-22 named its own:**
| File | Class | Selector | Case |
| ------------------ | -------------- | ----------------------- | ------------- |
| `adres.step.ts` | `AdresStep` | `app-reg-adres-step` | lines 100-175 |
| `beroep.step.ts` | `BeroepStep` | `app-reg-beroep-step` | lines 176-285 |
| `controle.step.ts` | `ControleStep` | `app-reg-controle-step` | lines 286-348 |
2. **A step may inject `RegistratieLookupStore` directly. This is the sanctioned exception.**
It is `providedIn: 'root'`, so every injection is the same instance, and PLAN names this "the
one place the dashboard's axis does apply": the step owns its own async presentation rather
than making the parent a pass-through for four lookup signals.
- `adres` injects it for `adresStatus` (the BRP lookup banner).
- `beroep` injects it for the DUO lookup, and owns its own `<app-async>` over it.
- `controle` injects it to build `samenvattingVragen`.
The parent keeps its own injection too — the BRP prefill effect needs it (decision 6).
3. **Inputs down, narrow outputs up, `dispatch` never passed down:**
| Step | Inputs | Outputs |
| ---------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `adres` | `draft`, `errors` | `fieldChange: { key: DraftField; value: string }`, `kanaalChange: string` |
| `beroep` | `draft`, `errors`, `upload` | `uploadMsg: UploadMsg`, `antwoordChange: { vraagId: string; value: string }`, `diplomaChosen: string`, `beroepDeclared: string` |
| `controle` | `draft` | `edit: number` |
Four outputs on `beroep` is correct: they are four distinct user intents, and each maps to one
message in the parent. That is not the same thing as handing the step a `dispatch`.
4. **The upload controller moves into `beroep.step.ts` and emits instead of dispatching.**
`createUploadController` takes a `dispatch` callback, so the step builds its own:
```ts
protected uploadCtl = createUploadController({
wizardId: 'registratie',
getUpload: () => this.upload(),
dispatch: (msg) => this.uploadMsg.emit(msg),
getCategoryParams: () => ({ … }), // unchanged, reads this.draft()
});
```
The parent maps it back with `(uploadMsg)="dispatch({ tag: 'Upload', msg: $event })"`. This
is what collapses five template handlers into one output. `previewUrlFor` moves with the
controller — it is `uploadCtl.previewUrlFor` and the child takes it as a function reference.
5. **`onDiplomaKeuze` stays in the parent, and loses its `data` parameter.** The step emits only
the chosen id (`diplomaChosen`). The parent keeps its `duoData` computed and reads it inside
the method instead of receiving it as an argument:
```ts
protected onDiplomaKeuze(id: string) {
const data = this.duoData();
if (!data) return;
… // body otherwise unchanged
}
```
Building a `KiesDiploma`/`KiesHandmatig` message needs the DUO payload to map an id to a
beroep and its question ids. That is machine-message construction, and it belongs in the
container.
6. **The BRP prefill `effect` stays in the parent**, exactly as written, including its
`untracked` call. It writes to the machine, so it belongs where the machine lives. Do not move
it into `adres.step.ts`.
7. **The parent keeps** the shell wiring, the store and its effect map, `draftSync`, the seed
constructor, `phase`, `primaryLabel`, `stepTitle`, `stepLabels`, `errorList`, `referentie`,
`cursor`, `step`, `draft`, `upload`, `duoData`, `onDiplomaKeuze`, and the `wizardSuccess`
block. It gains one computed, as RD-22's parent did:
```ts
protected errors = computed<Errors>(() => this.invullen()?.errors ?? {});
```
Everything else in the list below moves out with the markup that used it: `uploadCtl`,
`previewUrlFor`, `kanalen`, `err`, `vraagErr`, `antwoord`, `set`, `setKanaal`,
`handmatigActief`, `diplomaKeuze`, `diplomaOptions`, `beroepOptions`, `actieveVragen`,
`samenvattingVragen`, `adresSamenvatting`, `adresHerkomstLabel`, `correspondentieLabel`,
`diplomaHerkomstLabel`, `adresStatus`, `lookupRd`.
8. **Delete `/* eslint-disable max-lines */` from the parent.** Mandatory:
`reportUnusedDisableDirectives` is `error`, so the two rules pin each other in both
directions.
9. **No stories for the new steps** (PLAN's corollary). `registratie-wizard.stories.ts` already
mounts every step by seeding the machine, and the parent's public API does not move.
10. **Move the markup, do not improve it.** Every `i18n` id, label, placeholder, `fieldId` and
`aria` string stays byte-identical. `Errors` is already exported from the machine — RD-20
made it a type alias — so no machine change is needed this time.
## Files
- `apps/ssp/src/app/registratie/ui/registratie-wizard/adres.step.ts` (new)
- `apps/ssp/src/app/registratie/ui/registratie-wizard/beroep.step.ts` (new)
- `apps/ssp/src/app/registratie/ui/registratie-wizard/controle.step.ts` (new)
- `apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts`
No machine file changes. No shared-library changes.
## Steps
1. Write `adres.step.ts` — the smallest, and the one that proves the injection pattern.
2. Write `controle.step.ts` — read-only markup plus one output.
3. Write `beroep.step.ts` last: it carries the async lookup, the policy questions and the upload
controller.
4. Replace the `@switch` with the three elements and wire the outputs per decision 3.
5. Delete the members listed in decision 7, add the `errors` computed, reshape `onDiplomaKeuze`
per decision 5, prune `imports:`.
6. Delete the disable (decision 8).
7. `git add -A`, then run the acceptance commands.
8. Update this ticket's `Status:` to `done` and the README's RD-23 row to `done`.
9. Commit all of it together.
## Acceptance criteria
Measured against the tree before handover. Run after `git add -A`.
```bash
D=apps/ssp/src/app/registratie/ui/registratie-wizard
P=$D/registratie-wizard.component.ts
git ls-files "$D/*.step.ts" | wc -l # is 0 -> MUST be 3
git grep -c "eslint-disable max-lines" -- $P # is 1 -> MUST be 0
```
The upload controller moved, and the store did not follow the markup down:
```bash
git grep -c "uploadCtl" -- $P # is 7 -> MUST be 0
git grep -c "createUploadController" -- $D/beroep.step.ts # MUST be 2
git grep -c "dispatch" -- "$D/*.step.ts" | awk -F: '{s+=$NF} END {print s+0}' # MUST be 1
```
Two corrections found while running these before handover (recorded here per the README's
rule 4 on ticket-writing misses):
- `createUploadController` is **2**, not 1: `git grep -c` counts matching lines, and an
import plus its one call site are always two lines (same shape as `createStore` in
`intake-wizard.component.ts`, which is 2, and `createDraftSync` in this same parent, which
is 3). A count of 1 is unreachable without an import alias that would exist only to dodge
the check.
- `dispatch` is **1**, not 0: decision 4's mandated snippet is
`dispatch: (msg) => this.uploadMsg.emit(msg),` — the `UploadControllerDeps.dispatch`
property name is not the machine's `dispatch`, but it is the same string. Satisfying
decision 4 verbatim and satisfying a target of 0 are mutually exclusive.
The three per-step concerns left the parent:
```bash
git grep -c "adresStatus" -- $P # is 3 -> MUST be 0
git grep -c "samenvattingVragen" -- $P # is 2 -> MUST be 0
git grep -c "previewUrlFor" -- $P # is 3 -> MUST be 0
```
The copy did not drift (decision 10):
```bash
git grep -ho "@@[a-zA-Z0-9_.]*" -- $D/ | sort -u | wc -l # is 43 -> MUST still be 43
```
```bash
npm run ci --full # exits 0
```
## Verification
The `@@` id count is **43** across the whole `registratie-wizard/` directory, so the three new
files are included. `ng build --localize` fails on an id that is _added_ without a translation
but never on one silently _lost_; the count is the only check that catches a loss.
**`--full` is required.** The existing story mounts all three steps, and the axe run over it is
what proves the projected markup kept its labels, its error wiring and its `aria` strings.
**Do not add a line-count command.** `npm run lint` is the exact check (decision 8).
If `dotnet test` fails with `SQLite Error 1: 'no such table: …'`, that is the stale-database
trap, not your change. See the README's Troubleshooting section — RD-22 hit it.
## Out of scope
- `herregistratie-wizard`. PLAN: do not split it for symmetry.
- Changing the upload controller itself, or `upload.machine.ts`.
- Moving the BRP prefill effect (decision 6).
- Adding stories (decision 9).
- Any validation, message or `i18n` change.
## Risks
- **The upload controller is the hard part, and the reason this ticket exists.** Five template
handlers become one `uploadMsg` output. Get the `dispatch: (msg) => this.uploadMsg.emit(msg)`
wiring right and the rest is markup movement.
- **`previewUrlFor` is passed to a child as a function reference**, not called in the template.
Keep it an arrow property on the step, or the binding silently loses its `this`.
- **`onDiplomaKeuze` must not move into the step** (decision 5). It builds machine messages from
the DUO payload.
- **Four outputs on `beroep` is the design, not a smell** (decision 3). Do not collapse them into
a single message-shaped output — that is passing `dispatch` up under another name, and it
moves message construction into the step.
- **Deleting the disable is mandatory** (decision 8); its failure message reads like an
unrelated error.
- **This is the largest single diff in the arc.** Work step by step in the order given, and let
the type-checker confirm each before moving on.
@@ -0,0 +1,230 @@
# RD-24 — Split `concepts.page.ts`, and fix the highlighting it has never rendered
Status: done
Source: PLAN.md 3g, order step 6
## Why
`concepts.page.ts` measures ~471 effective lines against a limit of 250, and carries
`/* eslint-disable max-lines */`. It is one template with six teaching sections and a 142-line
`styles:` block.
A per-section split alone does not fix the styles, because Angular scopes a component's CSS to
its own template. Splitting without moving the CSS by owner would leave every section unstyled.
**And measuring that constraint turned up a live bug** (decision 1).
## Read first
- `apps/ssp/src/app/showcase/concepts.page.ts` — the whole file: styles at 48-189, template at
190-439.
- `libs/shared/styles.scss:105-130` — the `--app-devpanel-*` tokens and the existing
`.app-stack` / `.app-section` / `.app-text-subtle` globals. The new globals go beside them,
and the file's own comment says it exists to centralise exactly these idioms.
- `apps/ssp/src/app/showcase/highlight-ts.ts:42-45` — the `<span class="k|s|c">` markup whose
colours decision 1 restores.
- `scripts/check-tokens.sh:14` — the guard, and its `--include` glob.
## Decisions (pre-made, don't relitigate)
1. **The syntax highlighting is dead today. Fix it by making those rules global.** Verified
against the built output, not inferred:
```
pre[_ngcontent-%COMP%] .k[_ngcontent-%COMP%]{color:#c792ea}
```
The `.k`/`.s`/`.c` spans arrive through `[innerHTML]`, so they never carry an `_ngcontent`
attribute, and the rule cannot match. `highlight-ts.ts` computes the spans, its spec passes,
and every keyword, string and comment renders in the plain foreground colour. No component in
this repository uses `ViewEncapsulation.None`, and there is no global rule for `.k`, `.s` or
`.c`.
So `.app-code .k|.s|.c` becomes **global**, in `libs/shared/styles.scss`. A component cannot
own a rule that targets markup it did not render.
2. **Five new tokens, beside `--app-devpanel-*`**, which exist for this same reason:
```scss
--app-code-bg: #1e2430;
--app-code-fg: #e6e9ef;
--app-code-keyword: #c792ea;
--app-code-string: #c3e88d;
--app-code-comment: #7e8aa0;
```
`styles.scss` is the token bridge and the guard's one exempt file, so these literals belong
here and nowhere else.
3. **Four new globals in `libs/shared/styles.scss`**, named with the existing `.app-` prefix:
| Global | Replaces | Why not a component |
| ----------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `.app-code` | `pre` | must style `[innerHTML]` children (decision 1) |
| `.app-lead` | `.lead` | a page-level typography idiom, used by all six sections |
| `.app-cols` | `.cols` | same |
| `.app-note` | `.note` | its content includes markup (`<code>`), so it must be projected, and projected content keeps the _declaring_ component's scope |
**Delete `.section` entirely** — the global `.app-section` already exists and does the job.
4. **`concept-card.component.ts` owns the card vocabulary and renders it.** New component in
`apps/ssp/src/app/showcase/`. It owns `.card`, `.card--good`, `.card--bad`, `.tag`, its three
modifiers and both `::before` rules, plus `.linked` and `.linked .src`.
Its API, driven by what the 12 current usages need:
```ts
variant = input<'good' | 'bad' | 'plain'>('plain'); // card--good / card--bad / tag colour
tag = input.required<string>(); // the uppercase label
code = input<string | undefined>(); // pre [innerHTML], optional
src = input<string | undefined>(); // figcaption; wraps code in figure.linked
```
Everything else is projected through `<ng-content />`. The card **renders the `<pre>` itself**
when `code` is set — that is what keeps `.app-code`'s box styling working without relying on
projection.
5. **Six section components, one per `<section>`**, in `apps/ssp/src/app/showcase/`:
| File | Class | Heading | Own CSS |
| ------------------------- | -------------------- | --------------------------- | --------------------------------------------- |
| `unions.section.ts` | `UnionsSection` | 1 · Discriminated unions | none |
| `remote-data.section.ts` | `RemoteDataSection` | 2 · RemoteData fold | none |
| `parse.section.ts` | `ParseSection` | 3 · Parse, don't validate | none |
| `form-machine.section.ts` | `FormMachineSection` | 4 · Form als state machine | `.machine`, `.node`, `.node.on` |
| `vragenlijst.section.ts` | `VragenlijstSection` | 5 · Vragenlijst | `.steplist`, `.pill`, `.pill.extra`, `.arrow` |
| `pii.section.ts` | `PiiSection` | 6 · PII — maskeren & parsen | none |
The "Own CSS" column is measured: those selectors appear in exactly one section each. Every
other selector is now a global or lives in the card.
6. **The page keeps only what composes.** After the split `concepts.page.ts` holds its heading,
its intro, and six elements. It keeps no `styles:` block. `code` and `src` (the generated
snippets) move to whichever sections use them — each section imports
`snippets.generated.ts` directly.
7. **Widen the colour guard, and fix the one file that widening catches.**
`scripts/check-tokens.sh:14` greps `--include='*.component.ts'`, so **every `*.page.ts`,
`*.section.ts` and `*.step.ts` in the repository is invisible to it** — including the six
sections this ticket creates and the six `*.step.ts` files RD-22 and RD-23 just added. That
is why this page accumulated 21 hardcoded colours unnoticed.
Change the include to `--include='*.ts'` and exclude specs and stories, which legitimately
show colour swatches:
```bash
hits=$(grep -rnE '#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(' apps libs --include='*.ts' \
| grep -vE '\.(spec|stories)\.ts:' | grep -v 'token-ok' || true)
```
Measured: this newly catches exactly one other line, `libs/beheer/src/ui/audit.page.ts:39`
(`var(--rhc-color-rood-600, #a30000)`). Fix it by dropping the fallback, as decision 8 does
for this page. **Leave the CIBG-GAP marker check at `*.component.ts`** — a gap extension is a
component concept (ADR-0003).
8. **Drop every `var(--rhc-…, #hex)` fallback.** All the referenced tokens are defined in the
bridge, so the fallback is dead weight that also trips the widened guard. `.card`'s
`background: #fff` becomes `var(--rhc-color-wit)` — verified: that token is defined in
`styles.scss`.
9. **No stories.** `showcase` is a teaching page, not a feature, and it has no story today.
Adding six is not this ticket's job.
10. **Delete `/* eslint-disable max-lines */` from the page.** Mandatory — the rules pin each
other in both directions.
## Files
- `libs/shared/styles.scss` — 5 tokens, 4 globals
- `scripts/check-tokens.sh` — one line (decision 7)
- `libs/beheer/src/ui/audit.page.ts` — one fallback (decision 7)
- `apps/ssp/src/app/showcase/concept-card.component.ts` (new)
- `apps/ssp/src/app/showcase/{unions,remote-data,parse,form-machine,vragenlijst,pii}.section.ts` (new)
- `apps/ssp/src/app/showcase/concepts.page.ts`
## Steps
1. Add the tokens and the four globals to `libs/shared/styles.scss` (decisions 2 and 3).
2. Write `concept-card.component.ts` (decision 4).
3. Move each `<section>` into its own file, replacing every `<div class="card …">` with
`<app-concept-card>`, `class="lead|cols|note"` with the `.app-*` names, and `<pre>` with
either the card's `code` input or `<pre class="app-code">` for the four dynamic result blocks.
4. Reduce the page to composition, with no `styles:` block.
5. Widen the guard and fix `audit.page.ts` (decision 7).
6. Delete the disable (decision 10).
7. `git add -A`, then run the acceptance commands.
8. Update this ticket's `Status:` to `done` and the README's RD-24 row to `done`.
9. Commit all of it together.
## Acceptance criteria
Measured against the tree before handover. Run after `git add -A`.
```bash
D=apps/ssp/src/app/showcase
git ls-files "$D/*.section.ts" | wc -l # is 0 -> MUST be 6
git ls-files "$D/concept-card.component.ts" | wc -l # is 0 -> MUST be 1
git grep -c "eslint-disable max-lines" -- $D/concepts.page.ts # is 1 -> MUST be 0
git grep -c "styles:" -- $D/concepts.page.ts # is 1 -> MUST be 0
```
The colours left the page, and the guard now covers it:
```bash
git grep -cE "#[0-9a-fA-F]{3,6}" -- $D/concepts.page.ts # is 21 -> MUST be 0
git grep -c "include='\*\.component\.ts'" -- scripts/check-tokens.sh # is 2 -> MUST be 1 (the CIBG-GAP check keeps it)
npm run check:tokens # exits 0
```
The highlighting rules are global, where innerHTML children can reach them (decision 1):
```bash
git grep -c "app-code" -- libs/shared/styles.scss # MUST be >= 4
git grep -c "app-code" -- $D/concepts.page.ts # MUST be 0
```
The teaching content did not change while being moved:
```bash
git grep -ho "code\['[a-zA-Z]*'\]" -- $D/ | sort -u | wc -l # is 9 -> MUST still be 9
```
```bash
npm run ci --full # exits 0
```
## Verification
**`--full` is required** — this edits `libs/shared/styles.scss`, which every story renders
against.
**Look at the page.** This is the one ticket in the arc whose main fix is invisible to every
automated check: no test asserts a computed colour. Run `npm start`, open `/concepts`, and
confirm that keywords, strings and comments in the code blocks are now coloured — purple, green
and grey-italic against the dark background. If they are still monochrome, the rules are still
scoped to a component.
**Do not add a line-count command.** `npm run lint` is the exact check.
## Out of scope
- Changing any teaching copy, snippet or demo. This is a move, not a rewrite.
- `highlight-ts.ts` itself. Its output is correct; only the CSS was unreachable.
- Adding stories (decision 9).
- The `--app-devpanel-*` tokens, and any other page's colours.
## Risks
- **Angular does not style projected or `[innerHTML]` content from the receiving component.**
This is the constraint that shapes decisions 1, 3 and 4. If you find yourself moving a rule
into a component and its markup comes from somewhere else, the rule belongs in the global
sheet.
- **The `.app-note` case is subtle**: a note's text contains `<code>` markup, so it must be
projected — which is exactly why it cannot be styled by the card. Global it is.
- **The four dynamic `<pre>` blocks** (the ok/err demo output in sections 3 and 6) are not code
snippets and have no `src`. Give them `class="app-code"` directly rather than forcing them
through the card's `code` input.
- **Widening the guard is a two-line change with a measured blast radius of one other file**
(decision 7). If it catches more than `audit.page.ts:39`, stop and report — something landed
since this ticket was written.
- **Deleting the disable is mandatory** (decision 10).
@@ -0,0 +1,183 @@
# RD-25 — Split `org-template-editor` by output cluster
Status: done
Source: PLAN.md 3f, order step 7
## Why
`org-template-editor.component.ts` measures ~330 effective lines against a limit of 250, and
carries `/* eslint-disable max-lines */`. Three separate things pad it:
- a 44-line sample letter constant, exported but used only in this file;
- 13 label `input()`s that are all declared `protected`, so nothing can ever bind them — they
are constants wearing input ceremony;
- two self-contained blocks, the logo uploader and the version history.
The **11 `output()`s are the tell**: each cluster is a mutation family, and two of them lift out
whole.
## Read first
- `org-template-editor.component.ts:27``SAMPLE_LETTER_BRIEF`, and line 284, its only use.
- `org-template-editor.component.ts:272-297` — the inputs and the 11 outputs.
- `org-template-editor.component.ts:337-353` — the 13 label inputs.
- `org-template-editor.component.ts:195-235` — the logo block and the history block, the two
that become children.
- `.dependency-cruiser.base.js:113-120``no-testing-in-production`. Decision 1 depends on it.
- PLAN.md 3e — the `$localize` boundary, which decision 2 applies.
## Decisions (pre-made, don't relitigate)
1. **`SAMPLE_LETTER_BRIEF` moves to `apps/ssp/src/app/brief/domain/sample-letter.ts`.** It is
production content — the letter the admin previews — not a test fixture.
**It must not go into `brief.testing.ts`, and nothing may import it from there.**
`.dependency-cruiser.base.js`'s `no-testing-in-production` rule forbids production code
reaching any `*.testing.ts`, so putting it there fails `npm run dep:check`. A new
`domain/sample-letter.ts` beside `brief.ts` is the right home; `domain/` is pure TS, and this
is data.
2. **11 of the 13 label inputs become inline `i18n` attributes in the template. Two stay.**
The two that stay are parameterised, and PLAN 3e explains why moving them breaks the build:
| Keep in TS | Id | Why |
| --------------- | ----------------------- | -------------------------------------------- |
| `marginsLegend` | `@@orgTemplate.margins` | interpolates `MARGIN_MIN_MM`/`MARGIN_MAX_MM` |
| `invalidHint` | `@@orgTemplate.invalid` | same two interpolations |
The `.xlf` stores an interpolation as `<x id="min" equiv-text="MARGIN_MIN_MM"/>`. Moving such
a message into a template renames the placeholder to `INTERPOLATION`, the translation merge
no longer matches, and `ng build --localize` fails.
**Every id is preserved.** A plain `protected foo = input($localize`:@@id:Text`)` used as
`{{ foo() }}` becomes the literal text in the template with an `i18n="@@id"` attribute, or
`i18n-label="@@id"` when it feeds an attribute. Neither `messages.en.xlf` may change.
3. **Two new children, each taking one output cluster:**
| File | Class | Inputs | Outputs |
| ------------------------------ | ---------------- | ----------------------------------------- | ------------------------------------------ |
| `logo-upload.component.ts` | `LogoUpload` | `logoUrl`, `uploadState`, `previewUrlFor` | `logoSelected`, `logoRemoved`, `logoRetry` |
| `version-history.component.ts` | `VersionHistory` | `history`, `publishedVersion` | `rollback` |
They live beside the parent, in `apps/ssp/src/app/brief/ui/org-template-editor/`.
4. **Seven of the eleven outputs are raised by the parent's own markup; four are re-emitted
from a child.** The parent's own: `selectSubOrg`, `templateEdit`, `marginEdit`,
`requestPublish`, `confirmPublish`, `cancelPublish`, `proefbrief`. The publish trio stays
with the parent because publishing acts on the whole draft, not on the version list; only
`rollback` is history's own verb.
**Corrected while the ticket ran. This decision first read "the parent keeps seven outputs",
and its acceptance line demanded seven `output()` declarations — which contradicts decision
5 in the same block.** Seven is how many outputs the parent _raises itself_. All eleven are
still _declared_ on the parent, because a child's output is re-emitted, not removed:
`org-template.page.ts` binds all eleven directly, and that file is out of scope. PLAN's
"→ 5" was a pre-measurement estimate of the same classification; its ~222-line estimate is
the part that matches (the split lands at 229).
5. **The parent's public surface does not change.** All 11 outputs still exist on the parent and
still fire; two clusters are simply re-emitted from children. `org-template.page.ts` and the
story bind exactly what they bind today.
6. **Delete `/* eslint-disable max-lines */`.** Mandatory — `reportUnusedDisableDirectives` is
`error`, so the two rules pin each other in both directions.
7. **No new stories.** `org-template-editor.stories.ts` already renders both blocks through the
parent. The 13 label inputs were `protected`, so no story could bind them and none does.
## Files
- `apps/ssp/src/app/brief/domain/sample-letter.ts` (new)
- `apps/ssp/src/app/brief/ui/org-template-editor/logo-upload.component.ts` (new)
- `apps/ssp/src/app/brief/ui/org-template-editor/version-history.component.ts` (new)
- `apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts`
## Steps
1. Move `SAMPLE_LETTER_BRIEF` to `domain/sample-letter.ts` and import it in the parent
(decision 1).
2. Inline the 11 plain labels, keeping every id (decision 2).
3. Extract `logo-upload.component.ts`, then `version-history.component.ts` (decision 3), wiring
each child's outputs to the parent's existing ones.
4. Delete the disable (decision 6).
5. `git add -A`, then run the acceptance commands.
6. Update this ticket's `Status:` to `done` and the README's RD-25 row to `done`.
7. Commit all of it together.
## Acceptance criteria
Measured against the tree before handover. Run after `git add -A`.
```bash
P=apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts
git ls-files 'apps/ssp/src/app/brief/ui/org-template-editor/*.component.ts' | wc -l # is 1 -> MUST be 3
git ls-files apps/ssp/src/app/brief/domain/sample-letter.ts | wc -l # is 0 -> MUST be 1
git grep -c "eslint-disable max-lines" -- $P # is 1 -> MUST be 0
```
The constant moved, and the parent still uses it (import line plus use line is two lines, so 2
is the correct number here):
```bash
git grep -c "export const SAMPLE_LETTER_BRIEF" -- apps/ssp/src/app/brief/domain/sample-letter.ts # MUST be 1
git grep -c "export const SAMPLE_LETTER_BRIEF" -- $P # is 1 -> MUST be 0
git grep -c "SAMPLE_LETTER_BRIEF" -- $P # is 2 -> MUST still be 2
```
The ceremony is gone and the clusters left (decisions 2, 3, 4):
```bash
git grep -c "protected .* = input(" -- $P # is 13 -> MUST be 2
git grep -c "= output" -- $P # is 11 -> MUST still be 11 (decision 5, see below)
```
**Correction found while executing this ticket.** This check originally read `MUST be 7`,
copying decision 4's output count. That count is decision 4's classification of which cluster
owns each output, not the count of `output()` declarations on the parent class. Decision 5 and
this ticket's own Risks section both require the parent to keep declaring all 11 — a child's
output is re-emitted, not removed, and `org-template.page.ts` (out of scope, Files list excludes
it) binds all 11 directly on `<app-org-template-editor>`. Removing 4 declarations would break
that binding. Verified: `npx ng build ssp --localize` and `npm run dep:check` both pass with all
11 outputs present, and no other acceptance number changes.
The translation seam did not move (decision 2):
```bash
git grep -ho "@@orgTemplate[a-zA-Z0-9_.]*" -- apps/ssp/src/app/brief/ui/org-template-editor/ | sort -u | wc -l # is 18 -> MUST still be 18
git status --short -- '*.xlf' | wc -l # MUST be 0
```
```bash
npm run ci --full # exits 0
```
## Verification
`ng build --localize` inside the gate is the real check on decision 2: a renamed placeholder or
a lost id fails it. The `.xlf` files are hand-maintained, so **if you find yourself editing one,
you have changed an id and should undo it instead**.
`npm run dep:check` inside the gate is the real check on decision 1.
**Do not add a line-count command.** `npm run lint` is the exact check (decision 6).
## Out of scope
- `letter-canvas`. RD-26 owns it, and it keeps its disable.
- Changing any label text, any id, or any output name.
- The `orgTemplate.publish.impact` message. It is not one of the 13 labels and does not move.
- Reworking the upload controller or the publish flow.
## Risks
- **Moving a parameterised `$localize` into a template breaks the build** (decision 2). The two
named messages stay in TS. If a third turns out to interpolate, leave it in TS too and say so
in the commit message.
- **`brief.testing.ts` is the wrong home for the sample letter** (decision 1), and the failure
is a dependency-cruiser error rather than a type error, so it will not show up until
`dep:check`.
- **The parent must keep all 11 outputs** (decision 5). A child's output is re-emitted, not
removed — `org-template.page.ts` binds them.
- **Deleting the disable is mandatory** (decision 6).
@@ -0,0 +1,171 @@
# RD-26 — `letter-canvas`: inline the labels, extract `letter-line`, keep the disable
Status: done
Source: PLAN.md 3d, order step 8
## Why
`letter-canvas.component.ts` is 418 effective lines against a limit of 250. Unlike the other
six offenders, **it is not badly structured** — 77 lines are CSS and the rest is one letter.
Splitting it into letterhead, body, signature and footer would make "what does the letter look
like" a five-file question and buy no behavioural seam.
Two things do pad it without earning their place:
- **20 of its 28 `input()`s are pure `$localize` labels, and no caller binds a single one.**
Verified against all three call sites: `behandel-scherm`, `letter-composer` and
`org-template-editor` bind only data (`brief`, `orgTemplate`, `logoUrl`, `editableRegions`,
`diagnostics`, `blockDiffs`, `showDiff`).
- Six lines of `ngTemplateOutlet` ceremony around one `#line` template.
The CLAUDE.md rule those labels were built for — "Shared/English components must not hardcode
Dutch — expose copy as `input()`s" — governs `libs/shared`. This is a Dutch domain component in
`brief/ui/`. The rule does not apply here.
## Read first
- `letter-canvas.component.ts:1` — the disable, whose reason this ticket rewrites.
- `letter-canvas.component.ts:140` — the `#line` template, and lines 255-276, its three
`ngTemplateOutlet` uses.
- `letter-canvas.component.ts:345-372` — the inputs: 8 data, 20 labels.
- PLAN.md 3d and 3e.
## Decisions (pre-made, don't relitigate)
1. **Inline 19 of the 20 label inputs as `i18n` in the template.** Same id, same source text, so
**neither `messages.en.xlf` changes**. A label read as `{{ foo() }}` becomes its literal text
plus `i18n="@@id"`; one feeding an attribute becomes `i18n-<attr>="@@id"` (`logoAlt` is the
`alt` case).
The template is inline in this same file, so the 20 `@@brief.canvas.*` ids stay in the file —
they move from a TS declaration into an `i18n` attribute. The id count does not change.
2. **`recipientText` stays in TS. It is the one exception, and it is not the parameterised case
RD-25 hit.** Its message embeds a newline escape:
```ts
$localize`:@@brief.canvas.recipient:Adres van de geadresseerde\n(wordt ingevuld bij verzending)`;
```
In TS that `\n` is one character of the message. Written as template text it becomes a source
line break, which Angular's extractor treats differently — so "same source text", the
property that makes decision 1 free, does not hold for this one. Leave it as an `input()` and
put a one-line comment on it saying why. Verified: no other label interpolates or escapes
anything.
3. **Do not collapse the labels into a config object or an injection token.**
`HEADER_NAV_ITEMS` and `DEBUG_PANEL` exist because two apps genuinely differ. Here nothing
differs, so a token would add a provider and an indirection to solve a problem nobody has.
4. **Extract `letter-line.component.ts` beside the canvas, with a spec.** It takes the `#line`
template plus the sample and diff helpers it needs, and replaces the three `ngTemplateOutlet`
incantations with three one-line tags. It is the only part of this file with logic worth
testing, so it gets a `*.spec.ts` — a pure spec over the helpers, no TestBed.
Drop the `NgTemplateOutlet` import from the canvas once the last outlet is gone.
5. **Keep `/* eslint-disable max-lines */`, and rewrite its reason.** This is the one ticket in
the arc that keeps a disable. After RD-21 through RD-25 removed theirs, **this is the only
one left in the repository** — verified. The new reason must state the honest fact rather
than promising a future removal:
```ts
/* eslint-disable max-lines */ // 77 lines of CSS + one letter's markup; splitting it into
// letterhead/body/signature/footer makes "what does the letter look like" a five-file
// question for no behavioural seam. Deliberate, not deferred.
```
The README requires a disable to name the ticket that removes it. This one names no ticket
**because none will**, and the reason says so in those words. `reportUnusedDisableDirectives`
still keeps it honest: if the file ever drops under 250, lint fails on the unused directive.
6. **No new stories.** `letter-canvas.stories.ts` renders the canvas, and `letter-line` is
exercised through it.
## Files
- `apps/ssp/src/app/brief/ui/letter-canvas/letter-line.component.ts` (new)
- `apps/ssp/src/app/brief/ui/letter-canvas/letter-line.spec.ts` (new)
- `apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.component.ts`
## Steps
1. Extract `letter-line.component.ts` and its spec (decision 4).
2. Replace the three `ngTemplateOutlet` uses and drop the `NgTemplateOutlet` import.
3. Inline the 19 labels (decision 1), leaving `recipientText` alone (decision 2).
4. Rewrite the disable's reason (decision 5). Do not delete the directive.
5. Run `npm run gen:behaviour-spec` — the new spec adds titles.
6. `git add -A`, then run the acceptance commands.
7. Update this ticket's `Status:` to `done` and the README's RD-26 row to `done`.
8. Commit all of it together.
## Acceptance criteria
Measured against the tree before handover. Run after `git add -A`.
```bash
P=apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.component.ts
git grep -c "= input" -- $P # is 28 -> MUST be 9 (8 data + recipientText)
grep -c 'localize' $P # is 20 -> MUST be 1 (recipientText only)
```
The ids stayed in the file and the translation seam did not move (decision 1):
```bash
grep -o "@@[a-zA-Z0-9_.]*" $P | sort -u | wc -l # is 20 -> MUST still be 20
git status --short -- '*.xlf' | wc -l # MUST be 0
```
The outlet ceremony is gone and the child exists (decision 4):
```bash
D=apps/ssp/src/app/brief/ui/letter-canvas
git grep -c "ngTemplateOutlet" -- $P # is 6 -> MUST be 0
git grep -c "NgTemplateOutlet" -- $P # is 2 -> MUST be 0
git ls-files $D/letter-line.component.ts $D/letter-line.spec.ts | wc -l # is 0 -> MUST be 2
```
The disable survives, with a new reason, and is the only one in the repository (decision 5):
```bash
git grep -c "eslint-disable max-lines" -- $P # is 1 -> MUST still be 1
git grep -c "eslint-disable max-lines" -- apps libs | wc -l # MUST be 1 (one file matches)
git grep -c "RD-26 rewrites this reason" -- $P # is 1 -> MUST be 0
```
```bash
npm run ci --full # exits 0
```
## Verification
**`npm run lint` is what proves decision 5 both ways.** If the extraction takes the file under
250 lines, the kept directive becomes unused and lint fails — which would mean the disable
should go after all. Report that rather than deleting it silently: it changes the arc's
conclusion that one file legitimately stays over budget.
`ng build --localize` inside the gate is the check on decisions 1 and 2. **If you find yourself
editing a `.xlf`, you have changed an id or a source string — undo it instead.**
**`--full` is required** (the Order table says so): `letter-canvas.stories.ts` renders this
component, and the axe pass over it is what proves the inlined `i18n` markup kept its `alt`
text and its labels.
## Out of scope
- Splitting the letter into region components. PLAN 3d rejects it explicitly.
- The 77 lines of CSS.
- `recipientText` (decision 2).
- A config object or token for the labels (decision 3).
## Risks
- **`recipientText`'s `\n` is the trap** (decision 2). Inlining it is the one change here that
can silently alter an extracted source string, and the `.xlf` files are hand-maintained.
- **Keep the directive** (decision 5). Every other ticket in Phase 3 deleted one; this ticket is
the exception, and deleting it here would fail `max-lines` instead.
- **A label bound by no caller is still a public input.** Removing it is safe only because all
three call sites were checked. Do not extend the same reasoning to the 8 data inputs.
- **`logoAlt` feeds an attribute**, so it needs `i18n-alt`, not `i18n`. An `i18n` attribute on
the element localises its content, not its `alt`, and the a11y check will not catch the
difference because the text is still present.
@@ -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.
+65 -11
View File
@@ -115,27 +115,41 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di
| RD-18 | Ticket-reference sweep, frontend — 181 refs, 100 files | 01 | yes | done |
| RD-19 | Ticket-reference sweep, backend — 370 refs, 86 files | 01 | | done |
| RD-20 | `wizard-errors.ts` + spec, adopted by all 3 wizards | 02 | | done |
| RD-21 | `rich-text-dom.ts` helpers + spec cases | 02 | yes | todo |
| RD-22 | `intake-wizard` to 3 step components | 08, 20 | yes | todo |
| RD-23 | `registratie-wizard` to 3 steps + the upload-controller move | 08, 20 | yes | todo |
| RD-24 | `concepts.page` to 6 sections + `concept-card` + globals + code tokens | 02 | yes | todo |
| RD-25 | `org-template-editor` to `sample-letter.ts` + labels + 2 children | 02 | yes | todo |
| RD-26 | `letter-canvas`: inline the labels + `letter-line`; keep one disable | 02 | yes | todo |
| RD-27 | **The layer move:** 33 `git mv` + 28 specifiers + 8 MDX imports | 21 | yes | todo |
| RD-28 | Layer-tag fixes + the `libs/beheer` title rule | 27 | | todo |
| RD-29 | The 3 atomic-ladder rules in dependency-cruiser | 27 | | todo |
| RD-21 | `rich-text-dom.ts` helpers + spec cases | 02 | yes | done |
| RD-22 | `intake-wizard` to 3 step components | 08, 20 | yes | done |
| RD-23 | `registratie-wizard` to 3 steps + the upload-controller move | 08, 20 | 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-26 | `letter-canvas`: inline the labels + `letter-line`; keep one disable | 02 | yes | done |
| RD-27 | **The layer move:** 33 `git mv` + 28 specifiers + 8 MDX imports | 21 | yes | done |
| RD-28 | Layer-tag fixes + the `libs/beheer` title rule | 27 | yes | done |
| 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-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-33 | CLAUDE.md + `atomic-design.mdx` + the `ui-component` skill | 03, 27, 29 | yes | 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-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.
**Independent tickets.** RD-15 through RD-19 depend only on RD-01. Pull them forward to fill
a short session. Take RD-15 early: it makes every later repository search faster.
**RD-36 is last by number, not by dependency.** It needs only RD-04, it is 8 `git mv`s plus
four import lines, and it collides with nothing else in the table — RD-27's move is confined to
`libs/shared/src/ui/`. Pull it forward into any short session. It is numbered last only because
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
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
@@ -187,7 +201,29 @@ Three rules when you write a ticket file, because the agent reads its ticket and
`uploadCtl.onRetry` exists in `upload-controller.ts`.
- RD-08 said "no machine changes" while also requiring a repo-wide grep to come back
clean, which forced comment edits in three machines. The two instructions contradicted
each other.
each other. RD-23 repeated it exactly: its Decisions block mandated the line
`dispatch: (msg) => this.uploadMsg.emit(msg)` — the upload controller's own property name —
while its acceptance demanded zero occurrences of `dispatch` in the step files. **Grep the
text your own mandated snippet contains, and you have written a check that cannot pass.**
Anchor on what you actually forbid: here, `this.dispatch` or `store.dispatch`, not the bare
word. RD-25 made it a third time, and the clearest one: its decision 4 said "the parent
keeps seven outputs" while decision 5, four lines below, said all eleven still exist and
are re-emitted from children. Seven was the count of outputs the parent _raises_; eleven is
the count it _declares_. The acceptance line copied the wrong one, and satisfying it would
have broken `org-template.page.ts`, which binds all eleven.
**The pattern in all three: a decision describes a design in one vocabulary, and the
acceptance line counts something else that happens to share a word.** Before writing a
number, say out loud what the command counts — declarations, call sites, or matching lines
— 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
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
@@ -228,7 +264,10 @@ Three rules when you write a ticket file, because the agent reads its ticket and
live on one line of a single-line type declaration, so the honest answer is `1`. The
agent correctly refused to reformat the type across four lines to satisfy the number.
When you want occurrences, use `grep -o … | wc -l`; when a line count is what you mean,
say so.
say so. **A symbol you import and then use is two lines, never one.** RD-23 repeated the
mistake in the other direction, asserting `git grep -c "createUploadController"` would be
1 in the file that both imports and calls it. The only way to reach 1 is an import alias
that exists solely to satisfy the check.
- **Scope every acceptance command to the ticket's Files list, never to a parent
directory.** This is the habit most often broken, including by the supervisor in RD-12:
the check `git grep "ActionState" -- apps/ssp/src/app/brief` cannot pass, because
@@ -280,3 +319,18 @@ git log --oneline -1 # did an agent already commit the ticket?
If the tree is dirty and this session did not dirty it, find the agent before you write a
ticket around the evidence it leaves.
**The mirror-image mistake, made during RD-24: the supervisor became the second writer.** A
task notification fires every time an agent stops with **no live children — including a pause
mid-task**. RD-24's agent paused, said it was waiting on a check, and notified. The supervisor
read that as an abandoned task, took over the working tree, ran the gate and edited the ticket
`Status:` — while the agent was still running. The agent then resumed, correctly detected a
second process writing its tree, and refused to commit.
Nothing was lost, because the agent stopped instead of committing. Two rules follow:
- **A notification is not proof the work is finished.** A finished agent hands back a report
with acceptance numbers. "I am waiting for X" is a pause.
- **Before taking over an agent's tree, confirm the agent is gone**: `ps aux | grep "npm run"`,
and check whether its own result has arrived. Taking over is a real option — RD-24 was
finished that way — but it must be a decision, not an assumption.
+4 -4
View File
@@ -1,9 +1,9 @@
import { Component, computed, effect, inject } from '@angular/core';
import { DatePipe } from '@angular/common';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { AccessStore } from '@shared/application/access.store';
import { successOr } from '@shared/application/remote-data';
import { AuditStore } from '@beheer/application/audit.store';
@@ -36,7 +36,7 @@ import { AuditStore } from '@beheer/application/audit.store';
font-weight: var(--rhc-text-font-weight-semi-bold);
}
.deny {
color: var(--rhc-color-rood-600, #a30000);
color: var(--rhc-color-rood-600);
font-weight: var(--rhc-text-font-weight-semi-bold);
}
`,
+3 -3
View File
@@ -1,8 +1,8 @@
import { Component, computed, inject, signal } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { AccessStore } from '@shared/application/access.store';
import { FeatureFlagStore } from '@shared/application/feature-flags.store';
@@ -1,5 +1,5 @@
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';
interface DisplayRow {
+3 -3
View File
@@ -1,8 +1,8 @@
import { Component, computed, effect, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { AccessStore } from '@shared/application/access.store';
import { StamdataStore } from '@beheer/application/stamdata.store';
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 * as AlertStories from '../src/ui/alert/alert.stories';
import * as FormFieldStories from '../src/ui/form-field/form-field.stories';
import * as AlertStories from '../src/ui/atoms/alert/alert.stories';
import * as FormFieldStories from '../src/ui/molecules/form-field/form-field.stories';
<Meta title="Foundations/Accessibility" />
+3 -3
View File
@@ -1,8 +1,8 @@
import { Meta, Canvas } from '@storybook/addon-docs/blocks';
import * as ButtonStories from '../src/ui/button/button.stories';
import * as FormFieldStories from '../src/ui/form-field/form-field.stories';
import * as ButtonStories from '../src/ui/atoms/button/button.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 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" />
+52 -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
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
**is** the suite, reshaped for a business reader. 538 frontend behaviours across
**is** the suite, reshaped for a business reader. 562 frontend behaviours across
9 contexts; 261 backend behaviours across 42 test
classes.
@@ -337,6 +337,10 @@ classes.
- preserves library order (= reading order)
- never offers non-kern passages
#### placeholderDefs
- keys the placeholder list by its key
#### proefbriefErrorMessage (TE-002 trust boundary)
- surfaces the ProblemDetails detail when present
@@ -348,6 +352,33 @@ classes.
- derives reason checkboxes (code + label) from the negatief reason passages
- positief has no reason-specific redenen
#### resolveAuto
- reads autoResolvable off the field
- defaults to false for an unknown key
#### resolveLabel
- returns the field label for a known key
- falls back to the bare key when the field is unknown
#### resolveSample
- prefers the canned sample value over the label
- resolves datum to the caller-supplied sample date
- falls back to the field label for anything else
#### resolveState
- defaults to ok when the key has no diagnostic
- surfaces the worst recorded severity
#### worstSeverities
- ignores a diagnostic with no placeholder key
- keeps error over a warning already recorded for the same key
- does not let a later warning downgrade an error
### herregistratie
#### IntakeWizardComponent
@@ -514,6 +545,12 @@ classes.
- lists soort/waarvoor/status/referentie/ingediend, plus reason when rejected
- 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
- returns the id of the existing Concept of the given type
@@ -758,6 +795,14 @@ classes.
- redirects an anonymous user to /login without waiting for caps
- reads authentication through the port, not an app-local store
#### chipAtCaret / insertChipAtCaret (selection surgery)
- chipAtCaret returns the chip before a Backspace caret
- chipAtCaret returns null for a non-collapsed selection
- chipAtCaret returns null for a caret outside root
- insertChipAtCaret splices at the caret and leaves the caret after the chip
- insertChipAtCaret appends when there is no selection inside root
#### createDebouncedSave
- flushes after the delay when canSave is true
@@ -818,6 +863,12 @@ classes.
- emits documentId for completed digital uploads and channel for post
- 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
- 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 * 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" />
+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
architecture, not just document it: **Foundations** (this curriculum) → **Design System**
(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`
(or `libs/beheer/ui`) is Design System. See [Atomic design](?path=/docs/foundations-atomic-design--docs)
under a context's `ui/`, or under `libs/beheer/ui`, it's in Domein; everything else in
`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.
## Two apps, two shared libraries
+1 -1
View File
@@ -1,5 +1,5 @@
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" />
@@ -8,6 +8,7 @@ import {
categorySatisfied,
requiredCategoriesSatisfied,
deliveryRefs,
digitalDocumentIds,
inFlight,
rejectReason,
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', () => {
it('rejects a disallowed type', () => {
expect(
+8
View File
@@ -312,6 +312,14 @@ export function deliveryRefs(
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. */
export const inFlight = (s: UploadState): Upload[] =>
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
}
/** 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
unlinked, bold span. Domain-free the caller supplies the trail. */
@Component({
@@ -1,6 +1,6 @@
import { Component, input } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { LinkComponent } from '@shared/ui/link/link.component';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { LinkComponent } from '@shared/ui/atoms/link/link.component';
/** Template: standard page body optional back-link, a heading, optional intro,
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 { provideRouter } from '@angular/router';
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> = {
title: 'Design System/Templates/PageShell',
@@ -9,10 +9,10 @@ import {
viewChild,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
import { StepperComponent } from '@shared/ui/stepper/stepper.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { SpinnerComponent } from '@shared/ui/atoms/spinner/spinner.component';
import { StepperComponent } from '@shared/ui/molecules/stepper/stepper.component';
import { whenTag } from '@shared/kernel/fp';
/** CIBG procesnavigatie primary-button copy for a non-final step: "Naar stap 2 - Werk".

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