feat(WP-67): merge behandelportal into this repo as a monorepo

Restructures into apps/ssp + apps/behandelportal (two Angular projects)
plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's
separate sibling repo. That split had already produced real drift: a
hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree
forked and silently diverging (7 files), and beheer + the styles.scss
token bridge duplicated byte-for-byte across both repos.

- git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/,
  environments/, the Storybook docs/*.mdx, and styles.scss into
  libs/shared + libs/beheer (all confirmed identical between the two
  repos before merging). auth stays deliberately duplicated per
  ADR-0002 (actor-specific, expected to diverge) - amended there.
- One generated API client (libs/shared), no more vendored swagger.json.
- .dependency-cruiser split into a base factory + one config per app,
  and Storybook into .storybook-ssp/.storybook-behandelportal - both
  forced by the @auth/* alias resolving to different directories per app.
- SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/
  HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies
  its own nav/admin-links/dev-panel instead of one being hardcoded.
- CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated;
  WP-67 backlog entry documents the full decision trail.

npm run ci green (lint, dep:check x2, 360 tests across ssp/
behandelportal/shared/beheer, both localized builds, backend tests,
snippet + api-client drift); both dev servers, both Storybook
instances, and docker compose verified working.

The old sibling repo (/home/eho/repos/behandelportal) is left
untouched, not deleted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-02 21:01:57 +02:00
co-authored by Claude Sonnet 5
parent d3f3b13345
commit e7156c5132
403 changed files with 7103 additions and 60917 deletions
+69
View File
@@ -0,0 +1,69 @@
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';
<Meta title="Foundations/Accessibility" />
# Accessibility
No single tool catches every a11y class of bug, so this repo layers four, each catching
what the ones below/above it can't.
## The layers
1. **Axe on every story** (WP-01) — `@storybook/addon-a11y` in the panel, plus
`@storybook/test-runner` + `axe-playwright` gating CI (`npm run test-storybook:ci`).
Catches structural/contrast/ARIA-shape violations on every component, automatically,
as soon as a story exists. Escape hatch: `parameters: { a11y: { disable: true } }`,
only with an inline justification comment + a cross-reference to the WP that will fix
it (see e.g. `task-list.stories.ts`).
2. **Template a11y lint** (WP-17) — `angular-eslint`'s `templateAccessibility` config
(`alt-text`, `label-has-associated-control`, `click`/`mouse-events-have-key-events`,
`interactive-supports-focus`, `valid-aria`, `no-autofocus`, …) running on every inline
template via `angular.processInlineTemplates` (this repo has no `.html` files — every
template is a string in the `@Component` decorator; the processor extracts each one
into a virtual file the template rules can lint). Catches missing alt text, unlabelled
controls, and interactive elements that can't be reached by keyboard — at lint time,
before a story even exists.
3. **Play tests** (WP-16) — Storybook stories assert the wiring axe/lint can't see:
`form-field.stories.ts`'s canonical composition asserts `aria-describedby` joins
`-desc`/`-error` in the right order; `alert.stories.ts` asserts `role="alert"` for
errors vs `role="status"` for info/ok/warning. These run as part of the same
`test-storybook:ci` gate as the axe checks, so a regression fails CI, not just a panel.
4. **Manual WCAG checklist** (`docs/reference/wcag-checklist.md`) — what none of the above can see:
tab order across a whole page, focus traps, 200%-zoom reflow, and how a real screen
reader narrates a flow. A living per-page checklist, not a one-time audit — it already
caught a real bug (a dashboard alert overflowing at 320px) that no automated layer here
would have flagged.
## Component wiring this protects
<Canvas of={FormFieldStories.WithDescriptionAndError} />
The description (`-desc`) and error (`-error`) ids are joined in a pinned order so a
screen reader announces the hint, then the error, never neither. See
`text-input.component.ts`'s `describedBy()`.
<Canvas of={AlertStories.Error} />
Errors are `role="alert"` (assertive — interrupts, because the user needs to know
_now_); info/ok/warning stay `role="status"` (polite) so they don't interrupt whatever
the user is doing. See `alert.component.ts`.
## Route-change focus
Client-side routing has no page (re)load, so a screen reader/keyboard user's focus stays
wherever it was — usually the link they just clicked, now detached from any content that
matters. `shared/layout/route-focus.ts` moves focus to the new page's `<h1>` (every page
has exactly one via `page-shell`) on every navigation after the initial load, deferred via
`afterNextRender` so it doesn't race the view-transition DOM swap. Scroll position resets
the same way (`withInMemoryScrolling`), both wired once in `app.config.ts` — not per page.
## Where the skip register lives
`npm run lint` fails the build on a real template a11y violation, and `test-storybook:ci`
fails it on a real axe violation. Both can be locally disabled — the lint rule via a
normal ESLint disable comment, axe via `parameters: { a11y: { disable: true } }` — but
only with a comment naming _why_ and a cross-reference to the WP expected to remove the
skip (see `docs/project/backlog/WP-13-cibg-gap-register.md`'s marker convention, reused here).
Grep `a11y: { disable: true }` in `*.stories.ts` for the current list.
+125
View File
@@ -0,0 +1,125 @@
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 PageShellStories from '../src/layout/page-shell/page-shell.stories';
import * as DocumentUploadStories from '../src/ui/upload/document-upload/document-upload.stories';
<Meta title="Foundations/Atomic Design" />
# Atomic design
Every screen in this app is built from a small set of layers, each composed **only from
the layer below it**. Read a screen top-down and you always land on the same handful of
atoms — that is the whole point: fewer things to understand, nothing bespoke per page.
<div style={{ display: 'grid', gap: '0.5rem', maxWidth: '32rem', margin: '1.5rem 0' }}>
{[
[
'Templates',
'shared/layout',
'shell, page-shell, wizard-shell — the page skeleton',
'#1e3a5f',
],
[
'Organisms',
'shared/ui/upload/document-upload …',
'self-contained sections that own a bit of behaviour',
'#2a5a8a',
],
['Molecules', 'shared/ui/form-field, async …', 'a label + control + error, grouped', '#3f7cb5'],
[
'Atoms',
'shared/ui/button, text-input …',
'thin wrappers over CIBG Huisstijl (Bootstrap) CSS classes',
'#6aa6d8',
],
].map(([name, where, why, bg], i) => (
<div
key={name}
style={{
background: bg,
color: '#fff',
padding: '0.75rem 1rem',
borderRadius: '6px',
marginLeft: `${i * 1.5}rem`,
}}
>
<strong>{name}</strong> <span style={{ opacity: 0.85 }}>— {why}</span>
<div
style={{ fontFamily: 'monospace', fontSize: '0.75rem', opacity: 0.8, marginTop: '0.2rem' }}
>
{where}
</div>
</div>
))}
</div>
## The rule, enforced
**Each layer only uses layers below it, and dependencies point inward.** This is not a
convention you have to remember — `eslint.config.mjs` fails the build if `domain/` imports
Angular, or if a context imports "upward". See [the FP-in-the-UI primer](?path=/docs/foundations-fp-in-the-ui--docs)
for how the same discipline shapes state and effects.
## A composition chain, live
Here is one real chain from atom → molecule → template. Each is a published Storybook
story below; click through to the sidebar entries to explore every variant.
### Atom — `button`
A thin wrapper: we own a typed `variant` input, the CIBG CSS owns the pixels.
<Canvas of={ButtonStories.Primary} />
### Molecule — `form-field`
Label + control + error text, grouped so the error is announced via `role="alert"`. It
composes atoms; it adds no new visual primitives of its own.
<Canvas of={FormFieldStories.WithError} />
### Organism — `document-upload`
`shared/ui/upload/document-upload` composes molecules (a file input, alert, progress bar,
chips) into a section that owns real upload behaviour.
<Canvas of={DocumentUploadStories.Default} />
### Template — `page-shell`
The page skeleton — title, optional back-link, content slot. Pages drop composed
organisms into it; the template never knows what they are.
<Canvas of={PageShellStories.WithBackLink} />
## Why bother
A new page should be **composition of existing blocks**. Adding a new building block is the
exception, not the reflex — if you reach for one, that is a signal to check whether an
existing atom/molecule already covers it. Fewer primitives → less to test, less to learn,
one place to fix a bug.
## Convergence decisions — pairs that look duplicated but stay separate
Periodically we audit for near-duplicate blocks. Some collapse into one; a few **look**
similar but earn their separation. This table records the "don't merge these" verdicts so
the next person doesn't spend an afternoon re-deciding. (Deliberate CIBG-specific deviations
live in [CIBG gaps](?path=/docs/foundations-cibg-gap-register--docs); the FE⇄DS "same shape, different
context" cases in [Domain-driven design](?path=/docs/foundations-domain-driven-design--docs).)
| Pair | Why kept separate |
| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `choice-link` vs `application-link` | Share the same `to`/`clickable`/`activate` navigation triad, but bind **different vendored patterns** — CIBG _Keuzelijst_ (`.keuzelijst__link`, `.stretched-link`) vs _Aanvragen_ (`.dashboard-block.applications li a`) — with different list/host semantics (`app-choice-link` renders an inner `<li>`; `application-link` **is** the `<li>`). Merging would fight the vendored CSS. Extract the shared triad into a mixin only if it grows. |
| `text-input` / `radio-group` / `checkbox` | Share only the standard Angular **ControlValueAccessor** boilerplate (the `writeValue`/`registerOn*`/`setDisabledState` block). They render genuinely different controls, so they stay three atoms. A base CVA class is the only DRY move — a refactor, not a component merge, and not worth it at three. |
| `button variant="subtle"` (`.btn-link`) vs `app-link` | A subtle button _looks_ like a link but is an **action** (`<button>`, emits click); `app-link` is **navigation** (`<a routerLink>`). Different semantics and a11y roles → keep both. |
| `shell` / `page-shell` / `wizard-shell` | Three distinct jobs that **compose**, not overlap: persistent app chrome (mounted once) → routed page body → the wizard form/step frame. |
| Raw `<h3>` in `application-link` vs the `heading` atom | The vendored `.applications li a h3` chain styles the **bare `<h3>`**; wrapping it in the `app-heading` host element would sit between the anchor and the h3 and can break that selector. This is the one sanctioned raw-heading; everywhere else uses `<app-heading [level]>`. |
Single-consumer shared blocks (e.g. `placeholder-chip`, `rich-text-editor`, `checkbox`, the
`task-list`/`choice-list`/`choice-link` family) currently have one consumer each. They stay in
`shared` as design-system primitives; relocate one into its consuming context only if it stays
single-consumer long-term. That is a watch-item, not a merge.
The last audit also **removed** a genuinely dead block — a generic white `app-card` with zero
consumers (superseded by the grey `app-data-block` as the single data surface).
+75
View File
@@ -0,0 +1,75 @@
import { Meta } from '@storybook/addon-docs/blocks';
<Meta title="Foundations/BDD" />
# Behaviour-driven tests
Tests here read as **specifications of behaviour**, not checks of implementation. A test
says what the system _does_ — in the domain's own words — so a failing test names a broken
behaviour, and the suite doubles as living documentation. This is the BDD half of the
[Testing strategy](?path=/docs/foundations-testing-strategy--docs) (which owns _what to
test, by layer_); BDD owns _how each test is phrased and scoped_.
## Three rules
### 1. `describe` = the subject, `it` = one observable behaviour
The `describe()` block names the unit under test; each `it()` states a single behaviour in
**declarative present tense** — the implicit subject is "it". No `should`, no
Given/When/Then ceremony: present-tense declaration already reads as a spec.
```ts
describe('parsePostcode', () => {
it('normalises to "1234 AB" (uppercase, single space, trimmed)', () => { … });
it('rejects malformed input', () => { … });
});
```
Read top-to-bottom it _is_ the spec: "parsePostcode — normalises to 1234 AB; rejects
malformed input."
### 2. One behaviour per test
A test asserts **one behaviour**, not one `expect()`. Several assertions that pin down the
_same_ behaviour belong together; assertions about _different_ behaviours belong apart.
| Keep together (one behaviour) | Split apart (separate behaviours) |
| -------------------------------------------------------------- | -------------------------------------------------------- |
| A `Result`'s `.ok` then its `.value` | The `ok` branch **and** the `err` branch of a transition |
| A whole-object `toEqual` | An invalid-input case **and** a valid-input case |
| A loop asserting one rule over many inputs | Two independent state transitions |
| A truth-table (`draft` → true, `approver` → false) of one rule | An authorization check **and** a rendering check |
A title that needs `/`, `;`, "then" or "and" to join two behaviours is the smell — split it,
and each half gets its own present-tense name.
### 3. Speak the ubiquitous language (the DDD tie-in)
Test names use the **domain vocabulary**, not technical jargon — the same words as the
[bounded contexts](?path=/docs/foundations-domain-driven-design--docs): a _behandelaar_
drafts, a _beoordelaar_ approves, a _herregistratie_ is _ingediend_. The test name is
readable by someone who knows the domain but not the code.
```ts
it('drafter cannot approve or reject even when submitted', …);
it('confirmed dutch proficiency requires taalvaardigheid proof', …);
```
## How it fits TDD & DDD
- **TDD** — the loop is red → green → refactor: write the behaviour as a failing `it`, make
it pass, then clean up. Because tests describe behaviour (not internals), a refactor that
preserves behaviour keeps them green. Pure domain logic is tested directly — no `TestBed`
(see [Testing strategy](?path=/docs/foundations-testing-strategy--docs)).
- **DDD** — behaviour is expressed in the ubiquitous language, so the spec and the code
share one vocabulary. Domain rules (reducers, value-object parsers, policies) are the
richest specs; the wire boundary is tested as "rejects malformed input", the UI as
Storybook stories.
## Where to look
Canonical behaviour specs in the repo: `registratie/domain/value-objects/postcode.spec.ts`
(parser behaviour), `registratie/domain/registratie-wizard.machine.spec.ts` (one transition
per test), and backend `AuthzTests.cs` (rule truth-tables). The
[Testing strategy](?path=/docs/foundations-testing-strategy--docs) page maps which layer
gets which kind of test.
+56
View File
@@ -0,0 +1,56 @@
import { Meta } from '@storybook/addon-docs/blocks';
<Meta title="Foundations/CIBG Gap Register" />
# CIBG gap register
CIBG Huisstijl (ADR-0003) is the design system of record — a component wraps a vendored class
before it hand-rolls anything. **Grep the vendored CSS
(`public/cibg-huisstijl/css/huisstijl.min.css`) before adding new surface CSS to a component.**
When no vendored pattern exists, the component is a **CIBG-gap extension**: allowed, but only
marked so every deviation from the design system is auditable.
## Marker format
```ts
// CIBG-GAP EXTENSION: <closest CIBG concept, or "n/a"> — <why hand-rolled>
```
placed above the `@Component` decorator, plus `parameters: { cibgGap: true }` and a
"CIBG-gap extension" line in the story's `docs.description.component`.
## The register
| Component | Closest CIBG concept | Why hand-rolled |
| --------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `skeleton` | Laadindicatie | No loading-skeleton class in the vendored build. |
| `spinner` | Laadindicatie | No loading-spinner class in the vendored build. |
| `rich-text-editor` | Tekstgebied | No rich-text/WYSIWYG pattern; toolbar buttons still use vendored `.btn-ghost` (WP-10). |
| `wizard-shell` (error summary only) | Foutmelding | No error-summary/Veldvalidatie list class; renders inside a vendored `.feedback-error` alert. |
| `application-link` (non-navigating row) | Aanvragen | The vendored `.dashboard-block.applications li a` chain only styles `<a>`; `.static-row` mirrors it from tokens for the informational (non-link) case. |
| `debug-state` | n/a | Dev-only tool, deliberately off-theme — see the component's own `ponytail:` note. |
| `status-badge` | n/a | Deliberate custom status dot, not Bootstrap's `.badge` (pill padding/colour don't fit). |
| `placeholder-chip` | n/a | No vendored inline-chip/tag class. |
Not a gap: `confirmation` renders entirely with vendored `.confirmation*` classes (no `styles:
[...]` block) — its header comment names the pattern, no marker needed. The `upload/` suite
renders entirely with vendored classes (`.file-picker-drop-area`, `.btn-upload`, …) — reworked
onto them rather than marked (see WP-11's correction note). `task-list`, `application-list`, and
`choice-list` each wrap a distinct vendored pattern (Keuzelijst / Aanvragen / Keuzelijst) and name
it in their own header comment — no marker needed, they don't hand-roll surface CSS.
## Hygiene
`upload-status-banner` (a 23-line near-identity wrapper over `app-alert` with one consumer) was
deleted; its consumer (`document-upload`) now uses `<app-alert>` directly.
`card` (`.app-card`, a generic white surface) was deleted — it had zero consumers; the grey
vendored **Datablock** (`app-data-block`) is the single data surface. The convergence verdicts
for the pairs we deliberately keep separate live in
[Atomic Design → Convergence decisions](?path=/docs/foundations-atomic-design--docs).
## Keeping this register honest
No automated check diffs this table against the markers in code (skipped as not worth a CI
script for a table this small — reviewed at PR time instead, same as any other doc). If markers
and this table drift, trust the code and fix the table.
+100
View File
@@ -0,0 +1,100 @@
import { Meta } from '@storybook/addon-docs/blocks';
import { useState, useLayoutEffect, useRef } from 'react';
<Meta title="Foundations/Design Tokens" />
# Design tokens
We do not hand-write colours or spacing. `src/styles.scss` defines a semantic `--rhc-*` token
vocabulary and redefines every one of those tokens onto the vendored **CIBG Huisstijl**
(Bootstrap 5.2) values — `--bs-*`/`--ro-*` custom properties where one exists, CIBG palette hex
otherwise (that one file is exempt from `npm run check:tokens`, which fails the build on any
_other_ hardcoded hex colour in atoms/molecules/chrome). The `--rhc-*` names are an internal
alias set now; the values are CIBG's.
**Prefer a CIBG class over a token where one exists** — `.btn`, `.form-control`, `.card`,
`.stepper`, `.confirmation`, `.applications`, … are already themed by the vendored CSS (see
`public/cibg-huisstijl/`). Reach for a `--rhc-*` token only where CIBG has no ready-made class
(an `alert` surface, a skeleton loader, a status badge — see ADR-0003).
> Resolved values below are read live from the running theme via `getComputedStyle`, so they
> can't drift from what ships. `body.brand--cibg` (set in `index.html` and Storybook's
> `preview.ts`) activates CIBG's robijn/lintblauw palette; no extra wrapper class is needed.
export const Resolved = ({ token }) => {
const ref = useRef(null);
const [val, setVal] = useState('');
useLayoutEffect(() => {
if (ref.current) setVal(getComputedStyle(ref.current).getPropertyValue(token).trim());
}, [token]);
return (
<span ref={ref} style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#666' }}>
{val || '…'}
</span>
);
};
## When to use which token
- **CIBG class first** (see above) — a token is for the gaps a CIBG class doesn't cover.
- **Semantic first** — reach for a role token (`--rhc-color-foreground-default`,
`--rhc-color-border-default`, `--rhc-color-foreground-link`) before a raw palette step
(`--rhc-color-lintblauw-500`). Roles survive a theme swap; palette steps don't.
- **`--rhc-space-max-*`** for all spacing/gaps — never a raw `rem`.
- **`--app-*`** (in `src/styles.scss`) only for app measures CIBG has no token for
(`--app-content-max`, `--app-form-narrow`). If you're tempted to add one, check CIBG first.
## Spacing scale — `--rhc-space-max-*`
<div style={{ display: 'grid', gap: '0.4rem', margin: '1rem 0' }}>
{['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl'].map((step) => {
const token = `--rhc-space-max-${step}`;
return (
<div key={step} style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<code style={{ width: '12rem', fontSize: '0.78rem' }}>{token}</code>
<div
style={{
height: '1rem',
width: `var(${token})`,
background: 'var(--rhc-color-lintblauw-500)',
borderRadius: '2px',
}}
/>
<Resolved token={token} />
</div>
);
})}
</div>
## Semantic colours
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(14rem, 1fr))',
gap: '0.75rem',
margin: '1rem 0',
}}
>
{[
'--rhc-color-foreground-default',
'--rhc-color-foreground-subtle',
'--rhc-color-foreground-link',
'--rhc-color-layout',
'--rhc-color-lintblauw-500',
'--rhc-color-lintblauw-700',
'--rhc-color-border-default',
'--rhc-color-border-strong',
'--rhc-color-cool-grey-100',
].map((token) => (
<div key={token} style={{ border: '1px solid #ddd', borderRadius: '6px', overflow: 'hidden' }}>
<div style={{ height: '3rem', background: `var(${token})` }} />
<div style={{ padding: '0.4rem 0.5rem' }}>
<div style={{ fontFamily: 'monospace', fontSize: '0.72rem', wordBreak: 'break-all' }}>
{token}
</div>
<Resolved token={token} />
</div>
</div>
))}
</div>
+63
View File
@@ -0,0 +1,63 @@
import { Meta, Canvas } from '@storybook/addon-docs/blocks';
import * as AsyncStories from '../src/ui/async/async.stories';
<Meta title="Foundations/FP in the UI" />
# Functional programming in the UI
The components in this library are the _view_. Behind them, three small functional tools do
the heavy lifting — all so that **illegal states can't be represented**. This page is the
Storybook front door; the full narrative lives in `docs/reference/fp-tea-atomic-design.md`, and a
side-by-side "before/after" runs at the app's **`/concepts`** route.
## 1. `RemoteData<E,T>` — async has four states, not a boolean soup
`src/app/shared/application/remote-data.ts`. Instead of juggling `loading`, `error`, and
`data` flags (which permit "loading **and** error" nonsense), one tagged union:
`Loading | Empty | Failure | Success`. You combine sources with `map`/`map2`/`andThen` and
render it through the `async` molecule — exactly one of four templates shows, by
construction:
<Canvas of={AsyncStories.Loading} />
<Canvas of={AsyncStories.ErrorState} />
## 2. The Elm-style store — all state in one Model, changed only by pure `reduce`
`src/app/shared/application/store.ts` + the `*.machine.ts` files. State is one tagged-union
value; the template never mutates it, it `dispatch`es a message and a **pure**
`reduce(model, msg)` returns the next state. Side effects live in a _command_, never in the
reducer:
```ts
// reducer = "what the new state is" — pure, testable, no I/O
function reduce(model: Model, msg: Msg): Model { … }
// command = "go do it, then say what happened"
async function submit(...) {
const res = await http(...);
dispatch(res.ok ? { tag: 'Submitted' } : { tag: 'Failed', error: res.error });
}
```
Because state is one value, the whole thing is inspectable and every transition has a spec.
## 3. Parse, don't validate — raw input becomes a branded type once
`src/app/registratie/domain/value-objects/`. A `Postcode` is a distinct type from `string`,
mintable only through `parsePostcode`, which returns a `Result`. Once you hold the type, you
never re-check it — the type _is_ the proof. Compose the parse pipeline with the `Result`
combinators in `src/app/shared/kernel/fp.ts` (`map`, `mapErr`, `andThen`, `fold`) rather than
hand-branching `r.ok ? … : …` at every step.
```ts
parsePostcode(raw) // Result<string, Postcode>
|> mapErr(toLocalizedMessage) // swap raw msg → UI copy
|> map(toDomain) // only runs on success
```
## How it connects to atomic design
Atoms and molecules are pure view functions of their inputs; pages are the TEA runtime (the
"shell") that holds the store and wires effects. Same inward-pointing discipline as the
[layer rule](?path=/docs/foundations-atomic-design--docs), applied to state and effects
instead of imports.
+93
View File
@@ -0,0 +1,93 @@
import { Meta } from '@storybook/addon-docs/blocks';
<Meta title="Foundations/Internationalization" />
# Internationalization (the locale seam)
Every user-visible string goes through Angular's first-party **`$localize`** — no
third-party i18n library. The source locale is **`nl`**; a second locale is a
**translation file, not a code change**. That's the seam: adding English touched
`src/locale/messages.en.xlf`, not the components.
## How it's wired
| Piece | Where | What |
| -------------------------- | ----------------------------------------- | ------------------------------------------------------------ |
| Source locale | `angular.json` → `i18n.sourceLocale` | `nl` — the language the code is written in |
| Locales | `angular.json` → `i18n.locales.en` | points at `src/locale/messages.en.xlf` |
| Missing-translation policy | `angular.json` → `i18nMissingTranslation` | `error` — a missing `<target>` fails the build |
| Runtime global | `angular.json` → `polyfills` | `@angular/localize/init` provides `$localize` |
| English build/serve | `angular.json` → `configurations.en` | `ng build --configuration=en`, `ng serve --configuration=en` |
Locale switching is **build-time**, not runtime: each locale is its own bundle. There is
no in-app language picker (out of scope for the POC).
## Authoring copy
Two forms, same custom-id rule. The id is **stable** and shaped `@@<context>.<key>`, so
translations survive copy edits.
**In TS logic / value objects — tagged template:**
```ts
// src/app/registratie/domain/value-objects/postcode.ts
return err($localize`:@@validation.postcode:Voer een geldige postcode in, bijv. 1234 AB.`);
```
With placeholders (named, so translators can reorder):
```ts
$localize`:@@aanvraag.row.ingediend:ingediend op ${formatDatumNl(a.submittedAt)}:datum:`;
```
**In inline component templates — the `i18n` attribute:**
```html
<!-- src/app/auth/ui/login-form/login-form.component.ts -->
<app-button type="submit" i18n="@@login.submit">Inloggen met DigiD</app-button>
```
**Shared/English components never hardcode Dutch.** They expose copy as `input()`s with
localizable defaults; the domain caller may override. See
`shared/ui/async/async.component.ts`:
```ts
errorText = input($localize`:@@async.error:Er ging iets mis bij het laden van de gegevens.`);
```
## Extract & translate loop
```bash
npm run extract-i18n # ng extract-i18n → src/locale/messages.xlf (source, nl)
```
Then a translator fills `<target>`s in `src/locale/messages.en.xlf`. Both files carry the
same trans-units (currently 690 = 690, no drift); the `.en.xlf` header is
`source-language="nl" target-language="en"`. Because `i18nMissingTranslation: error`, a
forgotten target breaks the `en` build rather than silently shipping Dutch.
## Testing languages without coupling to the strings
**Rule: never assert on rendered copy.** Copy is the thing that changes per locale and per
edit — a test that reads `"Voer een geldige postcode in"` breaks the moment a translator or
a product owner touches the wording, in every locale. Assert on what's _invariant_ instead:
- **Parsers / value objects** — assert on the `Result` discriminant and the parsed value,
not the error message. This is the existing house pattern
(`registratie/domain/value-objects/postcode.spec.ts`):
```ts
expect(parsePostcode('0234AB').ok).toBe(false); // rejects — never inspects the $localize string
```
- **The seam itself** — if you must verify that translation works, check that a known
**id flips**, not that a specific phrase appears. Build/serve the `en` configuration and
confirm the target for a stable id renders, e.g. `login.submit`: `nl` "Inloggen met
DigiD" → `en` "Log in with DigiD". You're testing the wiring, not the wording.
```bash
ng serve --configuration=en # then eyeball, or point an e2e at the en bundle
```
See [Testing strategy](?path=/docs/foundations-testing-strategy--docs) for how this fits the
rest of the test pyramid.
+85
View File
@@ -0,0 +1,85 @@
import { Meta } from '@storybook/addon-docs/blocks';
<Meta title="Foundations/Domain-Driven Design" />
# Domain-driven design: bounded contexts & layers
This project is **domain-driven**: the code is organised first by **bounded context**
(a business capability with its own language) and then by **layer** inside each context,
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 six DDD contexts). If a component lives under a context's `ui/`, it's in Domein; everything else
in `shared/ui`/`shared/layout` is Design System. See [Atomic design](?path=/docs/foundations-atomic-design--docs)
for the Atoms → Molecules → Organisms → Templates ladder inside Design System.
## Six contexts, one direction
```
src/app/<context>/<layer>/
```
Contexts: `shared` (the base layer — depends on nothing), `auth`, `registratie`,
`herregistratie`, `brief` (letter-composition teaching slice), `showcase` (teaching page,
sanctioned to read every context — nothing imports it).
**Dependencies only point inward and in one declared direction between contexts:**
```
herregistratie → registratie → shared
auth → shared
brief → shared
```
Never the other way — `registratie` may not import `herregistratie`, and no context but
`shared` is imported by everyone.
## Five layers, one direction
| Layer | Job | Angular allowed? |
| ----------------- | ----------------------------------------- | ----------------- |
| `domain/` | business rules + data types | **No — pure TS.** |
| `application/` | coordinate state/tasks (stores, commands) | yes (signals) |
| `infrastructure/` | where data comes from (HTTP adapters) | yes (HTTP) |
| `contracts/` | wire DTOs (the FE⇄BE seam) | no |
| `ui/` | how it looks (components, pages) | yes |
`ui → application → domain`; `ui`/`layout` never import `infrastructure/` directly — they
reach data through an application store or command.
## This is enforced, not just written down
`eslint.config.mjs` fails the build on every rule above:
- `domain/` importing `@angular/*` at all (any context).
- `shared/` importing a feature context (`@auth/*`, `@registratie/*`, `@herregistratie/*`,
`@brief/*`) — the base layer depends on nothing.
- `registratie/` importing `@herregistratie/*`/`@brief/*`, `auth/`/`brief/` importing a
sibling context — the cross-context direction above.
- `contracts/**` importing **anything** — not Angular, not an alias, not even a relative
path (ADR-0001's wire seam has to stay a pure DTO shape).
- `ui/**`/`layout/**` importing `*/infrastructure/*` — the anti-corruption boundary
(ADR-0001) stays behind a store/command, so a page can never bypass it and hand-recompute
a business rule the backend already decided.
- The generated `ApiClient` imported as a value outside an `infrastructure/` adapter
(type-only DTO imports are exempt — they grant no network access).
Two components get a documented exemption from the "nothing reaches across" rule:
`shared/ui/debug-state` (reads every root store, for the dev-only state panel) and
`showcase/` (reads every context, for side-by-side teaching pages). Both exemptions live
next to the rule they break, in `eslint.config.mjs`, so they can't rot silently.
## The English/Dutch seam
Shared/reusable UI is named in **English** (language-agnostic: `button`, `wizard-shell`);
domain contexts are named in **Dutch** (`registratie`, `herregistratie`, `*.machine.ts`).
Pick the language by which side of the seam the code is on — it's the same seam this
sidebar's Design System/Domein split makes visible.
## See it in the sidebar
Compare a Design System primitive with the same shape reused across contexts:
- [Design System → Molecules → Application Link](?path=/story/design-system-molecules-application-link--navigatie) —
domain-free, the caller supplies heading/subtitle/cta.
- [Domein → Registratie → Aanvraag Block](?path=/story/domein-registratie-aanvraag-block--concept) —
a context-specific organism composed from Design System atoms/molecules.
+375
View File
@@ -0,0 +1,375 @@
import { Meta } from '@storybook/addon-docs/blocks';
<Meta title="Foundations/Learning Path" />
# Learning path
A paced, hands-on route through this codebase for a developer who is a **strong
programmer but new to frontend functional programming**. The [Overview](?path=/docs/foundations-overview--docs)
is the _map_ — every idea, cross-linked. This is the _route_: what to read first, what
to **do** to make it stick, and how to know you understood it. Work through it over
roughly three days.
Each lesson has the same shape:
- **Goal** — one sentence: what you'll be able to do.
- **~time** — a rough budget so a day stays a day.
- a few paragraphs that **teach the idea** (self-contained — you can read straight
through), then
- **Do** — a hands-on exercise. Most reuse the repo's invocable skills (`/new-feature`,
`/form-machine`, …), which scaffold real code the house way.
- **Check yourself** — a question; if you can answer it, move on.
- **Go deeper** — the deep-dive page and the long-form source in `docs/reference/`.
The one idea underneath everything: **make illegal states unrepresentable.** Every rule
below is a way to stop the compiler letting you build a state that can't actually happen.
---
## Day 1 — Orient: the shape of the codebase
### 1.1 Why this exists — state that can lie · ~15 min
**Goal:** name the failure mode this whole architecture is designed to prevent.
Most UI bugs are not wrong algorithms — they're **impossible states that the types
allowed anyway**. `isLoading` true _and_ `error` set _and_ `data` present: three
booleans give eight combinations, but only four are real. The extra four are bugs
waiting to be rendered. The reflex this codebase trains: when you reach for a second or
third boolean to track one thing, model a **discriminated union** instead, so the
illegal combinations can't be typed.
The second big idea is structural. The folder layout is not filing — **the folder
structure _is_ the architecture**. Where a file lives declares what it's allowed to
depend on, and that rule is enforced by lint, not hoped for. You'll meet the same
"compose small honest pieces, forbid the illegal combinations" principle at three
scales today and tomorrow: in the domain model, in the component tree, and in state.
**Do:** open `src/app/` and read the top of `CLAUDE.md` ("The decisions"). Just get the
lay of the land — six contexts, five layers.
**Check yourself:** three booleans model how many states, and how many are real for a
"fetch"? Why is that gap the enemy?
**Go deeper:** `docs/reference/fp-tea-atomic-design.md` Part 1.
### 1.2 Domain-driven design: contexts then layers · ~25 min
**Goal:** predict which imports are legal before the linter tells you.
Code is organised first by **bounded context** — a business capability with its own
language: `shared`, `auth`, `registratie`, `herregistratie`, `brief`, `showcase`. Inside
each context are five **layers**, and dependencies only ever point **inward**:
| Layer | Job | Angular? |
| ----------------- | ----------------------------------------- | -------------------------------- |
| `domain/` | business rules + data types | **No — pure TS**, has `.spec.ts` |
| `application/` | coordinate state/tasks (stores, commands) | yes (signals) |
| `infrastructure/` | where data comes from (HTTP) | yes |
| `contracts/` | wire DTOs (the FE⇄BE seam) | no |
| `ui/` | how it looks | yes |
`ui → application → domain`, never the reverse; `ui` never touches `infrastructure`
directly. Cross-context is one-directional too: `herregistratie → registratie → shared`,
`auth → shared`, `brief → shared`. A context downstream may lean on one upstream; the
upstream never learns the downstream exists. This keeps the domain pure and testable and
stops the dependency graph rotting into a ball of mud.
**Do:** open `eslint.config.mjs` and find the import-boundary rules. Then pick any file
in `herregistratie/` and trace one import back into `registratie` or `shared`.
**Check yourself:** why may `herregistratie` import from `registratie`, but `registratie`
may **not** import from `herregistratie`? What breaks if you invert it?
**Go deeper:** [Domain-driven design](?path=/docs/foundations-domain-driven-design--docs);
`docs/reference/architecture/ARCHITECTURE.md` §1.
### 1.3 Atomic design: composition is the default · ~20 min
**Goal:** decide, for a new screen, whether to add a building block or just compose.
The design system is a ladder: **Atoms → Molecules → Organisms → Templates**, each level
built only from the level below. Atoms (`button`, `form-field`) are thin typed wrappers
over CIBG Huisstijl CSS classes; molecules compose atoms; organisms compose molecules;
templates lay out organisms; a context's `ui/` page composes templates. A new page should
be **composition of existing blocks** — adding a block is the exception, not the reflex.
Notice this is the same shape as 1.2: small honest pieces, each only allowed to reach
one level down, illegal combinations forbidden by structure. That's not a coincidence —
you'll see why tomorrow.
**Do:** trace a real composition chain in Storybook: **Atoms → Button**, then find where
it's used up through `form-field → document-upload → page-shell`. Watch each level only
reach one level down.
**Check yourself:** you need a new "application summary" screen. What's the first
question you ask before writing a component?
**Go deeper:** [Atomic design](?path=/docs/foundations-atomic-design--docs). Adding a
block (only when composition truly can't do it): the `/ui-component` skill.
---
## Day 2 — The functional core
### 2.1 FP fundamentals · ~25 min
**Goal:** read code as "functional core, imperative shell" and spot which is which.
Four tools do the heavy lifting. **Pure functions:** output depends only on input, no
side effects — trivially testable, no mocks. **Immutability:** you compute new values,
you don't mutate old ones, so nothing changes under you. **Unidirectional flow:** data
moves one way (state → view → message → new state), never a tangle of two-way bindings.
**Sum and product types:** a _product_ is "A and B" (a record); a _sum_ is "A **or** B"
(a discriminated union) — sums are how you make illegal states unrepresentable.
Put together: the **functional core** is pure logic (all of `domain/`, the reducers, the
parsers) that knows nothing about Angular or HTTP; the **imperative shell** (components,
adapters) does the messy I/O and hands data in and out of the core. Bugs hide in the
shell; the core stays provable.
**Do:** open any `domain/` file next to its `.spec.ts` and confirm the spec uses no
Angular `TestBed` — it calls the function directly. That's the core being pure.
**Check yourself:** which of these is a sum type and why — "a form field's value" vs. "a
form's submission state (idle / submitting / failed / done)"?
**Go deeper:** [FP in the UI](?path=/docs/foundations-fp-in-the-ui--docs);
`docs/reference/fp-tea-atomic-design.md` Part 2.
### 2.2 State machines — The Elm Architecture · ~30 min
**Goal:** model a form as `Model → Msg → reduce`, with effects kept out of the reducer.
Every form and wizard here is one state machine: a **Model** (a tagged union — the
current state), a **Msg** union (everything that can happen), and a **pure** `reduce(model,
msg): model`. The template never mutates state; it **dispatches a message**, `reduce`
returns the next model, the view re-renders. All wiring goes through one idiom,
`createStore(initial, reduce)` — you never hand-roll `signal(model)` + a local dispatch.
The rule that keeps `reduce` pure: **side effects live in commands, not the reducer.** A
command (`application/submit-*.ts`) does the HTTP, then dispatches a message describing
the _outcome_. Reducer = "what the new state is"; command = "go do it, then say what
happened." And **derive, don't store** anything you can compute — e.g. a wizard's visible
steps are `visibleSteps(answers)`, not a stored field.
A field's value lands in the Model on **every keystroke** (not on blur — blur only marks
the field "touched"); a separate 600 ms debounce off the model snapshot autosaves the
draft to the backend, an effect that lives _outside_ the reducer. See
`docs/reference/architecture/ARCHITECTURE.md` §2g.
**Do:** run `/form-machine` for a toy single field (say a "nickname" field with a max
length). Read the generated Model / Msg / reduce and its spec.
**Check yourself:** why can't `reduce` make the HTTP call itself? What goes wrong if it
does?
**Go deeper:** [State machines (TEA)](?path=/docs/foundations-state-machines-tea--docs);
`docs/reference/fp-tea-atomic-design.md` Parts 34.
### 2.3 RemoteData & async · ~20 min
**Goal:** replace loading/error/empty booleans with one four-state value.
`RemoteData<E,T>` is a sum type with exactly four cases: `Loading | Empty |
Failure{error} | Success{value}`. That's the four _real_ states from lesson 1.1, and no
others — you literally cannot construct "loading and error." Combine sources with
`map` / `map2` / `andThen` (precedence: Failure > Loading > Empty > Success), and render
it with the `<app-async>` molecule, which picks one of four mutually-exclusive templates
by construction. The default spinner is delay-gated (~250 ms) so fast connections don't
flash.
**Do:** open a data page in the running app with `?scenario=slow`, then `?scenario=empty`,
then `?scenario=error` (the dev-only scenario toggle). Watch `<app-async>` switch
templates without any `*ngIf` soup.
**Check yourself:** a page combines two independent fetches with `map2`. One is still
loading, the other has failed — what does the combined value show, and why that
precedence?
**Go deeper:** [RemoteData & Async](?path=/docs/foundations-remotedata-async--docs);
`docs/reference/architecture/ARCHITECTURE.md` §2.
### 2.4 Parse, don't validate · ~20 min
**Goal:** turn untrusted input into a domain type once, then trust it forever.
Raw input (`unknown`, a string, a wire DTO) becomes a **branded value object** only by
passing through a **parser** that returns `Result<E,T>` — `parsePostcode`, `parseUren`,
`parseBigNummer`. Once you hold a `Postcode`, its shape is guaranteed by the type system;
you **never re-check it**. This happens in two places: value objects (form fields) and
boundary `parse*` adapters in `infrastructure/` (the FE⇄BE seam, where untrusted JSON
becomes domain types). "Validate" scatters `if`-checks everywhere and forgets one;
"parse" concentrates the check at the door and lets the compiler enforce the rest.
**Why "brand"?** TypeScript is _structurally_ typed, so a bare `type Postcode = string`
would accept any string and lose all proof of validation. Intersecting a phantom marker —
`string & { readonly __brand: 'Postcode' }` — makes the type **nominal**: no plain string
satisfies it, so the only way to hold a `Postcode` is to go through the parser that stamps
the brand. The brand is compile-time proof the value was validated (it exists only in the
types, never at runtime). The DDD name for the concept is a _value object_; "brand" is just
the TypeScript trick that makes it enforceable.
**Do:** run `/value-object` for a small field (e.g. a Dutch phone number). Read the parser
and its spec — note it returns `Result`, not a boolean, and note the branded type.
**Check yourself:** you're three functions deep and you hold a `Postcode`. Should you
re-validate its format? Why not?
**Go deeper:** [Parse, don't validate](?path=/docs/foundations-parse-dont-validate--docs);
`docs/reference/architecture/ARCHITECTURE.md` §3.
### Day 2 closer — one principle, two scales
You've now seen it twice: **small honest pieces, each only allowed to reach one level
down, with illegal combinations forbidden by structure.** Atomic design applies it to
_components_ (atoms compose upward); The Elm Architecture applies it to _state_ (pure
`reduce` composes messages into models). They are the same principle at two scales — that
is the thesis of this codebase. Read `docs/reference/fp-tea-atomic-design.md` Part 5; it's
the "aha" that ties Day 1 and Day 2 together.
---
## Day 3 — Quality & shipping
### 3.1 Testing strategy — what to test, by layer · ~20 min
**Goal:** know where a test goes and what kind it is, given any change.
Test grain follows the layer. **Domain and pure logic must have a spec** — reducers,
combinators, `visibleSteps`, parsers, boundary `parse*` adapters — tested **directly, no
TestBed**, because they're pure. **UI is exercised via Storybook stories** (co-located
`*.stories.ts`, a11y addon on), not heavy component tests. Backend rules have their own
`dotnet test`. The GREEN gate before you push: `npm run lint && npm test && npm run build`
(plus `cd backend && dotnet test`).
**Do:** run `/test-strategy` and read where it says each layer's test belongs. Then run
`npm test` and watch the pure specs fly (no browser, no mocks).
**Check yourself:** you add a new parser and a new page. Which gets a `.spec.ts`, and
which gets a Storybook story instead?
**Go deeper:** [Testing strategy](?path=/docs/foundations-testing-strategy--docs);
`CLAUDE.md` §5.
### 3.2 BDD — one behaviour per test · ~15 min
**Goal:** write test names that read as a specification in the domain's language.
`describe` names the subject; each `it` states **one observable behaviour** in
present tense — no `should`, no Given/When/Then ceremony. One behaviour per test means one
_behaviour_, not one `expect`: assertions pinning down the same behaviour stay together
(a `Result`'s `.ok` then its `.value`); assertions about different behaviours split apart
(the ok branch **and** the err branch). If a title needs "and"/"then"/"/" to join two
things, that's the smell — split it. And speak the **ubiquitous language**: a _behandelaar_
drafts, a _beoordelaar_ approves — the same words as the bounded contexts.
**Do:** read `registratie/domain/registratie-wizard.machine.spec.ts` — one transition per
test, each named as a behaviour. (You saw this style get enforced when the specs were
recently split.)
**Check yourself:** `it('submits and then shows the reference')` — what's wrong with this
name?
**Go deeper:** [BDD](?path=/docs/foundations-bdd--docs).
### 3.3 Accessibility — four layered tools · ~15 min
**Goal:** know which a11y bug each tool catches, and what only a human catches.
Four layers, each a different bug class: **axe on every story** (CI-gated, catches
contrast/roles/labels), **template a11y lint** (catches missing alt/labels at author
time), **Storybook play tests** (catches keyboard/focus interaction), and a **manual WCAG
checklist** for what automation can't — tab order across a page, focus traps, 200% zoom,
screen-reader narration. a11y is a build gate here, not a nice-to-have.
**Do:** open any story and check the **Accessibility** tab (axe results). Then skim
`docs/reference/wcag-checklist.md` — note the honest empty "Screen reader" column: some
things only a human pass finds.
**Check yourself:** axe passes on a form. Name one real a11y bug it still can't catch.
**Go deeper:** [Accessibility](?path=/docs/foundations-accessibility--docs).
### 3.4 Internationalization — the locale seam · ~15 min
**Goal:** wrap user-facing copy so a second language is a translation file, not a code
change.
Every user-visible string is wrapped in Angular's first-party `$localize` with a stable
custom id — `` $localize`:@@context.key:Tekst` ``. Source locale is `nl`; English is a
translation file, not edited code — that's the seam. Shared/English components must **not**
hardcode Dutch: they expose copy as `input()`s with localizable defaults, and the Dutch
domain caller supplies the text.
**Do:** grep for `$localize` in a `ui/` component; note the `@@`-prefixed stable ids. Find
one `shared/ui` component that takes copy as an `input()` rather than hardcoding it.
**Check yourself:** why must a shared English atom take its label as an `input()` instead
of writing the Dutch word directly?
**Go deeper:** [Internationalization](?path=/docs/foundations-internationalization--docs).
### 3.5 The design-system track (parallel) · ~15 min
**Goal:** style via semantic tokens and the CIBG Huisstijl, never hand-written colours.
This strand is largely independent of the FP/state spine — learn it whenever. The app
speaks a semantic `--rhc-*` token vocabulary; `src/styles.scss` is a **token bridge** that
maps those onto the vendored **CIBG Huisstijl** (a customized Bootstrap 5.2) `--bs-*`
values. Rule: reach for a **CIBG class first, then a token** — no hand-written hex. Where
CIBG lacks a class (e.g. `alert`), the atom is hand-rolled from tokens and recorded in the
**CIBG gap register** so the divergence stays honest.
**Do:** open [Design tokens](?path=/docs/foundations-design-tokens--docs) and read the
live swatches; then skim the [CIBG gap register](?path=/docs/foundations-cibg-gap-register--docs).
**Check yourself:** you need a warning colour. Where does it come from, and where does it
**not**?
**Go deeper:** `docs/reference/architecture/0003-cibg-huisstijl.md` (ADR-0003).
---
## Capstone — add a feature end-to-end
**Goal:** ship one small vertical slice the house way, and name which layer owns each rule.
Two framing ideas first. **BFF-lite + decision DTOs (ADR-0001):** each screen gets one
screen-shaped endpoint returning a **decision-enriched** DTO — the backend computes the
business rules, and **the FE renders decisions, it does not recompute them.** Per rule you
pick a _decision flag_ (server sends the boolean) or a _config value_ (server sends the
threshold, FE applies it for instant feedback, server re-validates as authority). The FE
keeps only **format** validation, never as authority.
Then the house pipeline, always in this order: **domain** (types + pure rules + spec, no
Angular) → **infrastructure** (adapter with a `parse*` boundary, or a command returning
`Result`) → **application** (a store if state is shared; union + pure `reduce`) → **ui**
last (compose `shared/ui` atoms, wrap async in `<app-async>`, dispatch messages).
**Do:** build a tiny slice — e.g. a one-field "update phone number" action — using the
skills in pipeline order:
1. `/value-object` — the field's parser + branded type (domain).
2. `/bff-endpoint` — a screen-shaped read with a decision DTO + `parse*` boundary.
3. `/form-machine` — the form's Model/Msg/reduce.
4. `/mutation-command` — the write, returning `Result`, keeping the reducer pure.
5. `/ui-component` **only if** no existing block composes — otherwise just compose.
`/new-feature` walks the whole pipeline if you'd rather do it in one guided pass.
**Check yourself:** for your slice, name for each business rule whether it's a _decision
flag_ or a _config value_, and which layer owns it. If a rule lives in two layers, which
one is the **authority**?
**Go deeper:** `docs/reference/architecture/0001-bff-lite-decision-dtos.md`;
`docs/reference/fp-tea-atomic-design.md` Part 7 (the copy-paste recipes);
`docs/reference/architecture/ARCHITECTURE.md` §4 (the recipe) and §6a (the full FE⇄BE
request lifecycle, read + write, with file links). For how contexts scale to a second
app and actor-based authorization, ADR-0002 (the advanced read).
---
You've done the route. From here the [Overview](?path=/docs/foundations-overview--docs)
map is your reference, the deep-dive pages hold the detail, and the skills scaffold each
new piece the house way.
+104
View File
@@ -0,0 +1,104 @@
import { Meta } from '@storybook/addon-docs/blocks';
<Meta title="Foundations/State Machines (TEA)" />
# State machines (The Elm Architecture, in Angular)
Every form or wizard with validation or submission in this app is wired the **same
way**: one Model, one Msg union, one pure `reduce`, one command per side effect. Pick any
one — `herregistratie.machine.ts` is the fullest worked example — and the shape
transfers everywhere else.
## Model / Msg / reduce
```ts
// Model — everything the UI needs to render, as ONE tagged union
export type WizardState = { tag: 'step1'; draft: Draft } | { tag: 'step2'; valid: Valid } | …;
// Msg — every way the Model is allowed to change
export type WizardMsg = { tag: 'FieldChanged'; field: string; value: string } | { tag: 'NextStep' } | …;
// reduce — PURE: (current, message) -> next. No I/O, no Date.now(), no randomness.
export function reduce(s: WizardState, m: WizardMsg): WizardState { … }
```
Because the whole state is one value, a bug reproduces from a message log; because
`reduce` is pure, every transition is a one-line assertion in a spec — no `TestBed`, no
mocked HTTP, just `expect(reduce(state, msg)).toEqual(next)`.
## Commands: side effects stay OUT of the reducer
`reduce` only ever answers "what is the new state" — it never calls `fetch`. A
**command** (an `application/submit-*.ts` file, or a store method) does the I/O, then
dispatches a message describing the outcome:
```ts
// command = "go do it, then say what happened" — reduce never sees the HTTP call itself
async function submit(store: Store<WizardState, WizardMsg>) {
const r = await adapter.submit(toDto(store.model()));
store.dispatch(
r.ok
? { tag: 'SubmitConfirmed', referentie: r.value }
: { tag: 'SubmitFailed', error: r.error },
);
}
```
This is also how a machine receives **server-owned config** without becoming aware of
HTTP: `intake.machine.ts`'s scholing threshold has an offline fallback
(`SCHOLING_THRESHOLD_DEFAULT`) baked into the model, and a plain `SetPolicy` message
that overwrites it once the real value arrives — the machine doesn't know or care that
the value came from a `resource()` fetch.
## `createStore`: the one wiring idiom
```ts
private store = createStore<WizardState, WizardMsg>(initial, reduce);
readonly model = this.store.model; // Signal<WizardState> — template reads this
dispatch = this.store.dispatch; // template calls this, on click/input/etc — never mutates
```
A page or component **never** hand-rolls `signal(initialModel)` plus its own local
`dispatch` function that calls `reduce` inline — that's the same idea reinvented with a
worse name, and it's the thing a newcomer copies if two idioms are visible side by side.
Wire every machine through `createStore`, full stop.
`dispatch` uses `model.update(…)`, not `model.set(reduce(model(), msg))` — the latter
reads `model()` _inside_ the call, which means an `effect()` that both reads `model` and
calls `dispatch` would subscribe to its own write and livelock. `.update()`'s callback
receives the current value directly, untracked.
## Naming
- A top-level machine's types are **context-prefixed**: `ChangeRequestState`,
`ChangeRequestMsg`, `WizardState`, `WizardMsg` — never bare `State`/`Msg`. A bare name
reads fine in the one file that defines it and then collides (or forces an import
alias) the moment two machines are open side by side.
- A top-level machine exports `initial` (the starting Model) and `reduce` — unprefixed,
since the file/module already disambiguates them at the import site
(`import { initial, reduce } from './herregistratie.machine'`).
- A **composable sub-machine** — one embedded _inside_ a parent Model, like
`upload.machine.ts`'s upload-widget state living inside the registratie wizard's own
Model — keeps **prefixed value exports** instead: `initialUpload`, `reduceUpload`.
The parent machine already imports several machines' `initial`/`reduce`; prefixing the
sub-machine's exports avoids a wall of `as` import aliases at the composition site.
## Derive, don't store
If a value can be computed from the Model, it is **not** a field on the Model. The
wizard's visible steps are `visibleSteps(answers)`, a pure function of the current
answers — not a `visibleSteps: Step[]` field someone has to remember to keep in sync
every time an answer changes. The reflex: before adding a field, ask "could this just be
a function of what I already have?"
## Where RemoteData fits in
A machine owns the **domain** lifecycle of what it holds once it exists (draft →
submitted → approved, in the brief's case). It should generally _not_ also own the
**fetch** lifecycle (loading/failed) for the initial GET that produces it — that's a
generic concern `RemoteData` already models once, consistently, across the app (see
[Foundations/RemoteData & Async](?path=/docs/foundations-remotedata-async--docs)). Where
a machine's own state happens to have `loading`/`failed` tags that purely mirror that
fetch, project them onto a `RemoteData` at the store layer for `<app-async>` to render
(`BriefStore.remoteData` is the worked example) rather than teaching every consumer to
hand-roll a `@switch` over the machine's own tags.
+39
View File
@@ -0,0 +1,39 @@
import { Meta } from '@storybook/addon-docs/blocks';
<Meta title="Foundations/Overview" />
# Foundations
The **why** behind this codebase, as a short curriculum. Each page is a condensed,
cross-linked take on one idea; the long-form source lives in `docs/reference/`
(see the repo's `docs/README.md`). Read them in roughly this order.
> **New here?** Follow the [Learning Path](?path=/docs/foundations-learning-path--docs)
> for a paced 3-day route with exercises and self-checks. This page is the map; the
> Learning Path is the route through it.
## Architecture & domain
- [Domain-driven design](?path=/docs/foundations-domain-driven-design--docs) — bounded
contexts + layers, dependencies pointing inward (the folder structure _is_ the architecture).
- [Atomic design](?path=/docs/foundations-atomic-design--docs) — Atoms → Molecules →
Organisms → Templates; a new page is composition, not new building blocks.
## Functional core
- [FP in the UI](?path=/docs/foundations-fp-in-the-ui--docs) — the three functional tools behind the view.
- [State machines (TEA)](?path=/docs/foundations-state-machines-tea--docs) — every form/wizard as Model → Msg → pure `reduce`.
- [RemoteData & Async](?path=/docs/foundations-remotedata-async--docs) — the four async states as one value.
- [Parse, don't validate](?path=/docs/foundations-parse-dont-validate--docs) — narrow untrusted `unknown` at the boundary into domain types.
## Design system
- [Design tokens](?path=/docs/foundations-design-tokens--docs) — semantic `--rhc-*` tokens; no hand-written colours.
- [CIBG gap register](?path=/docs/foundations-cibg-gap-register--docs) — where we diverge from the CIBG Huisstijl (ADR-0003).
## Quality & process
- [Accessibility](?path=/docs/foundations-accessibility--docs) — four layered a11y tools, each catching a different bug class.
- [Testing strategy](?path=/docs/foundations-testing-strategy--docs) — what to test, by layer grain.
- [BDD](?path=/docs/foundations-bdd--docs) — how each test is phrased and scoped: one behaviour, in the domain's language.
- [Internationalization](?path=/docs/foundations-internationalization--docs) — `$localize` for every user-visible string; the locale seam.
+121
View File
@@ -0,0 +1,121 @@
import { Meta } from '@storybook/addon-docs/blocks';
<Meta title="Foundations/Parse, don't validate" />
# Parse, don't validate
The wire is untrusted. A `boolean`/`string` field coming back from `fetch` is typed `unknown`
until something checks it — casting it away with `as` doesn't check anything, it just tells the
compiler to stop complaining. This repo's rule: every response crosses the FE⇄BE seam through a
hand-written `parse*` function that returns a `Result<string, T>` (`src/app/shared/kernel/fp.ts`).
Once you hold the parsed value, you never re-check it — the type _is_ the proof.
## Two places this shows up
**Value objects** (`src/app/registratie/domain/value-objects/`) parse a single user-entered
field — `Postcode`, `Uren`, `BigNummer` — from a raw string into a branded type.
**Boundary parsers** (`*.adapter.ts` in every `infrastructure/`) parse a whole DTO — or one
enum-ish field inside it — from the generated `ApiClient`'s response into the domain shape the
rest of the app trusts.
```ts
parsePostcode(raw) // Result<string, Postcode>
|> mapErr(toLocalizedMessage) // swap raw msg → UI copy
|> map(toDomain) // only runs on success
```
## The failure mode this closes: the silent `as` cast
An `as SomeUnion` cast on a wire value compiles even when the value doesn't match — the tag
just gets forwarded as-is, and something far away breaks on an "impossible" case. A validated
parse turns that into an explicit `Failure` at the boundary, right where the untrusted data
enters.
### Before/after: `big-register.adapter.ts`
```ts
// before — the wire's `type` string is trusted outright
function toAantekening(n: AantekeningDto): Aantekening {
return {
type: n.type as AantekeningType,
omschrijving: n.omschrijving ?? '',
datum: n.datum ?? '',
};
}
```
```ts
// after — an unrecognized type is a Result you can spec, not a silently-wrong tag
const AANTEKENING_TYPES: readonly AantekeningType[] = ['Specialisme', 'Aantekening'];
export function parseAantekening(n: AantekeningDto): Result<string, Aantekening> {
if (!n.type || !AANTEKENING_TYPES.includes(n.type as AantekeningType))
return err(`aantekening: unknown type ${n.type}`);
return ok({
type: n.type as AantekeningType,
omschrijving: n.omschrijving ?? '',
datum: n.datum ?? '',
});
}
```
The resource loader throws on `Failure`, which Angular's `resource()` turns into its error
state — the same `Failure` a `RemoteData` consumer already renders, no new plumbing.
### Before/after: `brief.adapter.ts`
```ts
// before — `dto.scope` is checked, then re-cast anyway
if (typeof dto.passageId !== 'string' || (dto.scope !== 'global' && dto.scope !== 'beroep'))
return err('passage: bad shape');
// … scope: dto.scope as PassageScope
```
```ts
// after — split the guard so TS narrows `scope` on its own; no cast needed
if (dto.scope !== 'global' && dto.scope !== 'beroep')
return err(`passage: unknown scope ${dto.scope}`);
// … scope: dto.scope // already narrowed to PassageScope
```
Splitting a compound `if` into two single-condition guards is often enough to make the cast
disappear entirely — the compiler was already able to prove the narrowing, the `||` was just
hiding it.
### Before/after: `intake-policy.adapter.ts`
```ts
// before — the resource exposes the raw DTO; consumers reach into it with `?.`
policyResource() {
return resource({ loader: () => this.client.policy() });
}
```
```ts
// after — a domain-side type + a validated parse; the resource never surfaces raw wire shape
export interface IntakePolicy {
readonly scholingThreshold: number;
}
export function parseIntakePolicy(json: unknown): Result<string, IntakePolicy> {
if (typeof json !== 'object' || json === null) return err('intake-policy: not an object');
const dto = json as { scholingThreshold?: unknown };
if (typeof dto.scholingThreshold !== 'number')
return err('intake-policy: missing scholingThreshold');
return ok({ scholingThreshold: dto.scholingThreshold });
}
```
## The sanctioned exception
Narrowing `unknown` to `Partial<Dto>` so you can _start_ checking fields is fine — that's not a
trust decision, it's just giving the compiler a shape to probe (`const dto = json as
Partial<DashboardViewDto>`, see `dashboard-view.adapter.ts`). What's never fine is casting a
field to its final domain type without having checked it first.
## Spec every parser like a decision table
Each parser gets a spec covering: a valid shape, a missing required field, and — for
tagged/enum-ish values — an unknown tag. See `big-register.adapter.spec.ts`,
`intake-policy.adapter.spec.ts`, and the scope-rejection case in `brief.adapter.spec.ts`.
+101
View File
@@ -0,0 +1,101 @@
import { Meta, Canvas } from '@storybook/addon-docs/blocks';
import * as AsyncStories from '../src/ui/async/async.stories';
<Meta title="Foundations/RemoteData & Async" />
# RemoteData & Async
An async fetch has exactly four states: still loading, loaded-but-empty, failed, or
loaded-with-a-value. Modeling that as `loading`/`error`/`data` booleans permits nonsense
combinations ("loading **and** error", "data **and** error" — which one does the UI
believe?). `src/app/shared/application/remote-data.ts` closes that off with one tagged
union instead:
```ts
type RemoteData<E, T> =
| { tag: 'Loading' }
| { tag: 'Empty' }
| { tag: 'Failure'; error: E }
| { tag: 'Success'; value: T };
```
## Combining sources
Two or more independent fetches often need to render as ONE state (e.g. a registration
call and a BRP call feeding the same page). `map`/`map2`/`andThen` combine them with one
precedence rule: **Failure beats Loading beats Empty beats Success** — if either source
failed, the combined result is a failure; only when every source succeeded do you get a
combined value.
```ts
map2(registration, person, (reg, p) => ({ registration: reg, person: p }));
```
## Rendering it: `<app-async>`
<Canvas of={AsyncStories.Loading} />
<Canvas of={AsyncStories.ErrorState} />
`shared/ui/async` renders exactly one of the four templates — never two at once, by
construction, since the component switches on the union's tag. Feed it either:
- **`[resource]`** — a raw Angular `resource()` (the common case; the component projects
it into a `RemoteData` internally via `fromResource`), or
- **`[data]`** — an already-combined `RemoteData` (e.g. from a store's `computed()` using
`map`/`map2`).
The default loading UI is a spinner, delay-gated (~250ms) so a fast response never
flashes it; override with an `appAsyncLoading` template. `appAsyncEmpty` and
`appAsyncError` are likewise optional — omit them and you get a sensible default (a
"geen gegevens" message / an alert with a retry button).
## The `appAsyncLoaded` slot isn't generically typed to your value
This is a real Angular constraint, not an oversight: a structural directive's type
parameter can only be inferred from an **input bound on that same element** (this is how
`*ngFor="let x of items"` and `*ngIf="x as y"` work — the type comes from `ngForOf`/`ngIf`,
inputs on the very same tag). `<ng-template appAsyncLoaded let-p>` sits on a _different_
node than `<app-async [data]="…">`, so `p` cannot inherit a type from that sibling input,
even though they're nested in the same template. Angular types it `unknown`, and
`ngTemplateContextGuard` can't fix that without an input to seed it from — the shared
`AsyncComponent`/`AsyncLoadedDirective` pair is properly generic internally, but that
genericity stops at the component's own boundary.
The idiom this repo uses instead — see `brief.page.ts`, `dashboard.page.ts`,
`registration-detail.page.ts` — is a small **typed `computed()`** that unwraps the
`Success` value, narrowed locally in the template with `@if (x(); as p)`:
```ts
// in the component class
protected readonly loaded = computed(() => {
const s = this.model(); // or store.someRemoteData()
return s.tag === 'loaded' ? s : undefined;
});
```
```html
<!-- in the template, inside <ng-template appAsyncLoaded> -->
@if (loaded(); as s) {
<app-letter-composer [brief]="s.brief" ... />
}
```
No `$any()`, no cast — `loaded()` is a real, checked `T | undefined`, and `@if (…; as s)`
narrows it the same way any other nullable signal would.
## The `?scenario=` dev toggle
Any data page can be forced through all four states without touching the backend:
`?scenario=slow|loading|empty|error` (dev-only, `scenario.interceptor.ts`) rewrites the
timing/outcome of `/api/*` calls. Try it on `/brief` or `/dashboard`.
## Where the fetch ends and the domain begins
A store's own state machine (its `*.machine.ts`) should own the **domain** lifecycle of
what it holds (draft → submitted → approved, in the brief's case) — not the network
fetch's loading/failure, which is a generic concern `RemoteData` already models. Where a
machine's own `loading`/`failed` tags purely mirror the fetch (nothing extra beyond "not
loaded yet" / "the GET failed"), project them onto a `RemoteData` computed at the store
layer for `<app-async>` to render, the way `BriefStore.remoteData` does — the machine
keeps deciding what the _letter_ is doing, `RemoteData` keeps deciding what the _fetch_ is
doing.
+95
View File
@@ -0,0 +1,95 @@
import { Meta } from '@storybook/addon-docs/blocks';
<Meta title="Foundations/Testing strategy" />
# Testing strategy
Tests follow the same grain as the architecture: **push the logic down to where it's pure,
test it there directly, and keep the layers above thin.** No single tool covers everything,
so each layer gets the cheapest test that catches its class of bug. This page owns _what to
test, by layer_; how each test is **phrased and scoped** — one behaviour, in the domain's
language — is [BDD](?path=/docs/foundations-bdd--docs).
## What gets tested where
| Layer | Test kind | Tool | Rule |
| -------------------------- | ------------------------ | ---------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `domain/` | pure-function unit spec | Vitest | **Required.** Call the function directly — **no `TestBed`**. Reducers, combinators, `visibleSteps`, parsers, policies. |
| `domain/value-objects/` | parser spec | Vitest | Happy path + normalisation + **each** rejection. Assert on the `Result`, never the message. |
| `infrastructure/` `parse*` | trust-boundary spec | Vitest | Accept a valid DTO; **reject `null` / `{}` / malformed**. Name it `describe('… (trust boundary)')`. |
| `application/` | store / command spec | Vitest | Reducer purity, optimistic begin→confirm/rollback, command `Result`. |
| `ui/` | Storybook story | Storybook + a11y | Kept thin. Axe runs on every story; add a `play` only for wiring axe can't see. |
| flows | e2e smoke | Playwright | One happy path + one error state per critical journey. |
| backend | rule + endpoint + golden | xUnit | Mirror of the FE domain rules, plus `WebApplicationFactory` integration. |
## Tooling
Vitest runs through Angular's built-in `@angular/build:unit-test` builder — **there is no
`vitest.config.ts`**; config is implicit via `tsconfig.spec.json`.
```bash
npm test # ng test → Vitest, all *.spec.ts co-located next to their unit
```
Specs import `{ describe, it, expect }` from `vitest` and are co-located with the unit
they cover.
## The house style
Pure and direct. A value-object parser spec
(`registratie/domain/value-objects/postcode.spec.ts`):
```ts
import { describe, it, expect } from 'vitest';
import { parsePostcode } from './postcode';
describe('parsePostcode', () => {
it('normalises to "1234 AB"', () => {
const r = parsePostcode(' 1234ab ');
expect(r.ok).toBe(true);
if (r.ok) expect(r.value).toBe('1234 AB');
});
it('rejects malformed input', () => {
expect(parsePostcode('0234AB').ok).toBe(false); // asserts the tag, not the copy
});
});
```
A trust-boundary adapter (`registratie/infrastructure/brp.adapter.spec.ts`) additionally
proves the untrusted shape is rejected:
```ts
expect(parseBrpAddress(null).ok).toBe(false);
expect(parseBrpAddress({}).ok).toBe(false); // missing required field
```
Elm-style machines test the pure `reduce` with inline state fixtures — no Angular
(`registratie/domain/registratie-wizard.machine.spec.ts`).
## UI = Storybook, not heavy component tests
`@storybook/addon-a11y` runs the `wcag2a/2aa/21a/21aa` rule sets on **every** story;
`@storybook/test-runner` + `axe-playwright` turn that into a CI gate:
```bash
npm run test-storybook # axe over every story against a running Storybook
npm run test-storybook:ci # builds storybook-static, serves :6006, runs the gate
```
Disabling a11y on a story needs an inline justification + a WP cross-reference (see
[Accessibility](?path=/docs/foundations-accessibility--docs)).
## Don't assert on copy
Localized strings change per locale and per edit. Tests assert on the `Result`
discriminant, the value object, or the message **id** — never the rendered Dutch/English
text. Full detail in [Internationalization](?path=/docs/foundations-internationalization--docs).
## The GREEN gate
```bash
npm run lint && npm run check:tokens && npm test && npm run build && npm run build-storybook
cd backend && dotnet test
```
Everything above must pass before a work package is done.