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.
@@ -0,0 +1,54 @@
import { Injectable, computed, inject } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { filter, firstValueFrom } from 'rxjs';
import { RemoteData, fromResource } from '@shared/application/remote-data';
import { Capability } from '@shared/domain/capability';
import { MeAdapter, parseMe } from '@shared/infrastructure/me.adapter';
type Err = Error | undefined;
/**
* The current principal's capabilities (PRD-0002 §6) — one root singleton, like
* `SessionStore`/`BigProfileStore`. Global capabilities load once from `GET /me`;
* a screen's own decision DTO (e.g. `BriefViewDto.decisions`) covers anything tied
* to a specific resource's live status — no extra round-trip needed for that.
*
* `can()` is deny-by-default: loading, failed, or an unrecognized capability all
* resolve to `false`. This store never derives a capability from a role — it only
* mirrors what the server already resolved.
*/
@Injectable({ providedIn: 'root' })
export class AccessStore {
private adapter = inject(MeAdapter);
private meRes = this.adapter.meResource();
private capabilities = computed<RemoteData<Err, Capability[]>>(() => {
const rd = fromResource(this.meRes);
if (rd.tag !== 'Success') return rd;
const parsed = parseMe(rd.value);
return parsed.ok
? { tag: 'Success', value: parsed.value }
: { tag: 'Failure', error: new Error(parsed.error) };
});
can(capability: Capability): boolean {
const rd = this.capabilities();
return rd.tag === 'Success' && rd.value.includes(capability);
}
/** True once `/me` has resolved (success or failure) — lets a page-level gate tell
"still loading" apart from "denied", so an admin doesn't flash the denial alert. */
readonly ready = computed(() => {
const tag = this.capabilities().tag;
return tag === 'Success' || tag === 'Failure';
});
private ready$ = toObservable(this.ready);
/** Resolves once `/me` has settled (success or failure). The `capabilityGuard` awaits
this before deciding — otherwise it reads `can()` while `/me` is still loading and
wrongly denies (deny-by-default), bouncing even an entitled user. */
async whenReady(): Promise<void> {
if (this.ready()) return;
await firstValueFrom(this.ready$.pipe(filter((r) => r)));
}
}
@@ -0,0 +1,9 @@
/** Transient state of a one-shot action (submit/approve/publish/reset/…): one tagged
union instead of a busy boolean + a nullable error sitting side by side. Shared by the
editor stores (WP-31). */
export type ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };
/** Debounced-autosave indicator, shown in a small status line near a toolbar — a separate
concern from ActionState (a stale autosave error doesn't block submit/approve), but
tag-aligned with it for one consistent idiom. */
export type SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' };
@@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createDebouncedSave } from './debounced-save';
describe('createDebouncedSave', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('flushes after the delay when canSave is true', async () => {
const flush = vi.fn().mockResolvedValue(undefined);
const d = createDebouncedSave({ delayMs: 600, canSave: () => true, flush });
d.schedule();
expect(d.hasPendingSave()).toBe(true);
expect(flush).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(600);
expect(flush).toHaveBeenCalledTimes(1);
expect(d.hasPendingSave()).toBe(false);
});
it('does not schedule when canSave is false', () => {
const flush = vi.fn().mockResolvedValue(undefined);
const d = createDebouncedSave({ canSave: () => false, flush });
d.schedule();
expect(d.hasPendingSave()).toBe(false);
});
it('coalesces rapid schedules into a single flush', async () => {
const flush = vi.fn().mockResolvedValue(undefined);
const d = createDebouncedSave({ delayMs: 100, canSave: () => true, flush });
d.schedule();
d.schedule();
d.schedule();
await vi.advanceTimersByTimeAsync(100);
expect(flush).toHaveBeenCalledTimes(1);
});
it('flushPending runs the save immediately and clears; no-op when idle', async () => {
const flush = vi.fn().mockResolvedValue(undefined);
const d = createDebouncedSave({ delayMs: 600, canSave: () => true, flush });
await d.flushPending();
expect(flush).not.toHaveBeenCalled(); // idle
d.schedule();
await d.flushPending();
expect(flush).toHaveBeenCalledTimes(1);
expect(d.hasPendingSave()).toBe(false);
});
it('cancel drops a scheduled save without running it', async () => {
const flush = vi.fn().mockResolvedValue(undefined);
const d = createDebouncedSave({ delayMs: 600, canSave: () => true, flush });
d.schedule();
d.cancel();
expect(d.hasPendingSave()).toBe(false);
await vi.advanceTimersByTimeAsync(600);
expect(flush).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,49 @@
export interface DebouncedSave {
/** (Re)arm the debounce timer; no-op when `canSave()` is false. */
schedule(): void;
/** True while a scheduled save hasn't run yet — implements `PendingSave.hasPendingSave`. */
hasPendingSave(): boolean;
/** Run a scheduled save now and await it; no-op when nothing is scheduled. */
flushPending(): Promise<void>;
/** Drop a scheduled save without running it (e.g. before an authoritative transition,
which flushes explicitly, or a reset that discards the draft). */
cancel(): void;
}
/**
* The debounced-autosave timer shared by the editor stores (WP-31). It owns ONLY the timer
* bookkeeping; the actual write + save-state transitions live in the caller's `flush`
* (store-specific — it touches that store's SaveState/ActionState + adapter). The handle is
* nulled the moment it fires, so `hasPendingSave()` means "a write is still owed". Integrates
* with the `PendingSave` seam (pending-saves.ts): a store delegates hasPendingSave/flushPending
* here so the CanDeactivate guard / beforeunload handler can flush a pending edit.
*/
export function createDebouncedSave(opts: {
delayMs?: number;
canSave: () => boolean;
flush: () => Promise<void>;
}): DebouncedSave {
const delay = opts.delayMs ?? 600;
let timer: ReturnType<typeof setTimeout> | undefined;
return {
schedule() {
if (!opts.canSave()) return;
clearTimeout(timer);
timer = setTimeout(() => {
timer = undefined;
void opts.flush();
}, delay);
},
hasPendingSave: () => timer !== undefined,
async flushPending() {
if (timer === undefined) return;
clearTimeout(timer);
timer = undefined;
await opts.flush();
},
cancel() {
clearTimeout(timer);
timer = undefined;
},
};
}
@@ -0,0 +1,58 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { RemoteData } from '@shared/application/remote-data';
import { FeatureFlag } from '@shared/domain/feature-flag';
import { FeatureFlagsAdapter, parseFlags } from '@shared/infrastructure/feature-flags.adapter';
type Err = Error | undefined;
/**
* Runtime feature-flag state (WP-47) — one root singleton, mirroring `AccessStore`. Loads the
* resolved flag set once from `GET /flags`; `enabled(key)` gates a feature (deny-by-default:
* false until loaded / unknown key). `set()` is the admin toggle (PUT + reload). The catalog is
* server-owned; the FE only mirrors + renders it.
*/
@Injectable({ providedIn: 'root' })
export class FeatureFlagStore {
private adapter = inject(FeatureFlagsAdapter);
private state = signal<RemoteData<Err, FeatureFlag[]>>({ tag: 'Loading' });
readonly flags = this.state.asReadonly();
/** The resolved list (empty until loaded) — for the admin toggle UI. */
readonly all = computed(() => {
const rd = this.state();
return rd.tag === 'Success' ? rd.value : [];
});
constructor() {
void this.load();
}
async load() {
if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' });
try {
const parsed = parseFlags(await this.adapter.list());
this.state.set(
parsed.ok
? { tag: 'Success', value: parsed.value }
: { tag: 'Failure', error: new Error(parsed.error) },
);
} catch (e) {
this.state.set({ tag: 'Failure', error: e as Error });
}
}
/** Deny-by-default: false while loading/failed or for an unknown key. Reactive (reads the signal). */
enabled(key: string): boolean {
const rd = this.state();
return rd.tag === 'Success' && (rd.value.find((f) => f.key === key)?.enabled ?? false);
}
/** Admin toggle: persist then reload so the state reflects the server. */
async set(key: string, enabled: boolean) {
try {
await this.adapter.set(key, enabled);
} finally {
await this.load();
}
}
}
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import { createHistory } from './history';
describe('createHistory', () => {
it('starts empty; undo/redo are no-ops', () => {
const h = createHistory<number>();
expect(h.canUndo()).toBe(false);
expect(h.canRedo()).toBe(false);
expect(h.undo(1)).toBeUndefined();
expect(h.redo(1)).toBeUndefined();
});
it('records pre-edit snapshots, then undoes and redoes through them', () => {
const h = createHistory<string>();
// document went a -> b (record a) -> c (record b); current is 'c'
h.record('a');
h.record('b');
expect(h.canUndo()).toBe(true);
expect(h.undo('c')).toBe('b'); // current 'c' pushed to redo
expect(h.canRedo()).toBe(true);
expect(h.undo('b')).toBe('a');
expect(h.canUndo()).toBe(false);
expect(h.redo('a')).toBe('b');
expect(h.redo('b')).toBe('c');
expect(h.canRedo()).toBe(false);
});
it('record() clears the redo stack (no dead redo after a fresh edit)', () => {
const h = createHistory<string>();
h.record('a');
h.undo('b'); // redo now holds 'b'
expect(h.canRedo()).toBe(true);
h.record('x');
expect(h.canRedo()).toBe(false);
});
it('caps the stack depth', () => {
const h = createHistory<number>(3);
for (let i = 0; i < 5; i++) h.record(i);
let undos = 0;
let cur = 99;
while (h.canUndo()) {
cur = h.undo(cur)!;
undos++;
}
expect(undos).toBe(3);
});
it('clear() empties both stacks', () => {
const h = createHistory<number>();
h.record(1);
h.undo(2);
h.clear();
expect(h.canUndo()).toBe(false);
expect(h.canRedo()).toBe(false);
});
});
+53
View File
@@ -0,0 +1,53 @@
import { Signal, computed, signal } from '@angular/core';
export interface History<T> {
readonly canUndo: Signal<boolean>;
readonly canRedo: Signal<boolean>;
/** Push a pre-edit snapshot onto the undo stack and drop the redo stack. */
record(snapshot: T): void;
/** Undo: pop the last recorded snapshot and return it (moving `current` onto the redo
stack); returns undefined and changes nothing when there's nothing to undo. */
undo(current: T): T | undefined;
/** Redo: mirror of undo. */
redo(current: T): T | undefined;
clear(): void;
}
/**
* Generic undo/redo history over an immutable "document" value `T`. Elm-store editors
* restore a returned snapshot by re-dispatching a `Seed`-style Msg — this helper only
* shuffles references, it never mutates them, so the caller must hold copy-on-write state
* (every edit produces a fresh value). Both stacks are capped so a long session can't grow
* unbounded. Extracted from BriefStore's WP-27 undo/redo (WP-31); reused by the stamdata
* editor (WP-32).
*/
export function createHistory<T>(cap = 50): History<T> {
const past = signal<readonly T[]>([]);
const future = signal<readonly T[]>([]);
return {
canUndo: computed(() => past().length > 0),
canRedo: computed(() => future().length > 0),
record(snapshot) {
past.update((p) => [...p, snapshot].slice(-cap));
future.set([]);
},
undo(current) {
const p = past();
if (p.length === 0) return undefined;
past.set(p.slice(0, -1));
future.update((f) => [...f, current].slice(-cap));
return p[p.length - 1];
},
redo(current) {
const f = future();
if (f.length === 0) return undefined;
future.set(f.slice(0, -1));
past.update((p) => [...p, current].slice(-cap));
return f[f.length - 1];
},
clear() {
past.set([]);
future.set([]);
},
};
}
@@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import { machineRemoteData } from './machine-remote-data';
describe('machineRemoteData', () => {
it('maps loading → Loading', () => {
expect(machineRemoteData({ tag: 'loading' })).toEqual({ tag: 'Loading' });
});
it('maps failed → Failure carrying an Error with the reason', () => {
const rd = machineRemoteData({ tag: 'failed', reason: 'boom' });
expect(rd.tag).toBe('Failure');
if (rd.tag === 'Failure') expect(rd.error.message).toBe('boom');
});
it('maps loaded → Success carrying the whole loaded state', () => {
const loaded = { tag: 'loaded', foo: 42 } as const;
expect(machineRemoteData(loaded)).toEqual({ tag: 'Success', value: loaded });
});
});
@@ -0,0 +1,24 @@
import { RemoteData } from '@shared/application/remote-data';
/** The standard load-lifecycle tags an editor machine exposes. */
export type LoadLifecycle =
{ tag: 'loading' } | { tag: 'failed'; reason: string } | { tag: 'loaded' };
/**
* Project an Elm-machine state onto `RemoteData` for the `<app-async>` seam. The machine
* keeps owning its own domain lifecycle (draft/submitted/…); this is purely the
* loading/failed/loaded → async mapping, which was byte-identical across BriefStore,
* OrgTemplateStore and StamdataStore (WP-31). Wrap the call in a `computed`.
*/
export function machineRemoteData<S extends LoadLifecycle>(
s: S,
): RemoteData<Error, Extract<S, { tag: 'loaded' }>> {
switch (s.tag) {
case 'loading':
return { tag: 'Loading' };
case 'failed':
return { tag: 'Failure', error: new Error(s.reason) };
default: // 'loaded'
return { tag: 'Success', value: s as Extract<S, { tag: 'loaded' }> };
}
}
@@ -0,0 +1,86 @@
import { describe, it, expect, vi } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { PendingSave, PendingSaves, flushPendingGuard } from './pending-saves';
/** A fake autosave owner whose pending-ness and flush are controllable. */
function fakeOwner(pending: boolean): PendingSave & { flushPending: ReturnType<typeof vi.fn> } {
return {
hasPendingSave: () => pending,
flushPending: vi.fn().mockResolvedValue(undefined),
};
}
describe('PendingSaves registry', () => {
it('hasPending is true only while some registered owner has a pending write', () => {
const reg = new PendingSaves();
const idle = fakeOwner(false);
reg.register(idle);
expect(reg.hasPending()).toBe(false);
const dirty = fakeOwner(true);
reg.register(dirty);
expect(reg.hasPending()).toBe(true);
});
it('unregister removes an owner so it no longer counts', () => {
const reg = new PendingSaves();
const dirty = fakeOwner(true);
const off = reg.register(dirty);
expect(reg.hasPending()).toBe(true);
off();
expect(reg.hasPending()).toBe(false);
});
it('flushAll flushes only the pending owners', async () => {
const reg = new PendingSaves();
const idle = fakeOwner(false);
const dirty = fakeOwner(true);
reg.register(idle);
reg.register(dirty);
await reg.flushAll();
expect(dirty.flushPending).toHaveBeenCalledTimes(1);
expect(idle.flushPending).not.toHaveBeenCalled();
});
it('flushAll awaits every owner and swallows a rejected flush', async () => {
const reg = new PendingSaves();
const failing = fakeOwner(true);
failing.flushPending.mockRejectedValue(new Error('save failed'));
const ok = fakeOwner(true);
reg.register(failing);
reg.register(ok);
await expect(reg.flushAll()).resolves.toBeUndefined(); // never rejects
expect(ok.flushPending).toHaveBeenCalledTimes(1);
});
});
describe('flushPendingGuard', () => {
it('flushes then allows navigation when a write is pending', async () => {
const dirty = fakeOwner(true);
TestBed.configureTestingModule({});
const reg = TestBed.inject(PendingSaves);
reg.register(dirty);
const result = TestBed.runInInjectionContext(() =>
// the guard ignores its route args
(flushPendingGuard as (...a: unknown[]) => boolean | Promise<boolean>)(),
);
await expect(result).resolves.toBe(true);
expect(dirty.flushPending).toHaveBeenCalledTimes(1);
});
it('allows navigation immediately when nothing is pending', () => {
TestBed.configureTestingModule({});
TestBed.inject(PendingSaves).register(fakeOwner(false));
const result = TestBed.runInInjectionContext(() =>
(flushPendingGuard as (...a: unknown[]) => boolean | Promise<boolean>)(),
);
expect(result).toBe(true); // synchronous, not a Promise
});
});
@@ -0,0 +1,80 @@
import { DestroyRef, ENVIRONMENT_INITIALIZER, Injectable, inject } from '@angular/core';
import { CanDeactivateFn } from '@angular/router';
/**
* A source of debounced, not-yet-flushed writes (autosave). The two autosave owners in
* this app have different lifetimes — root singleton stores (`BriefStore`,
* `OrgTemplateStore`) and per-wizard `createDraftSync` controllers living inside child
* organisms — so both register here instead of the guard/unload handler needing to know
* which page or store owns the pending write.
*/
export interface PendingSave {
/** True while a debounced edit hasn't been written to the backend yet. */
hasPendingSave(): boolean;
/** Flush that pending write now and await it. No-op when nothing is pending. */
flushPending(): Promise<void>;
}
/** Registry of every active autosave owner. The `CanDeactivate` guard and the
`beforeunload` handler flush through this — one seam, both callers. */
@Injectable({ providedIn: 'root' })
export class PendingSaves {
private readonly owners = new Set<PendingSave>();
/** Register an owner; returns an unregister function. */
register(owner: PendingSave): () => void {
this.owners.add(owner);
return () => this.owners.delete(owner);
}
hasPending(): boolean {
return [...this.owners].some((o) => o.hasPendingSave());
}
/** Flush every owner that has a pending write, awaiting all. Best-effort: a rejected
flush is swallowed (a failed autosave surfaces its own error state; navigation must
not be blocked by it). */
async flushAll(): Promise<void> {
await Promise.allSettled(
[...this.owners].filter((o) => o.hasPendingSave()).map((o) => o.flushPending()),
);
}
}
/** Register the current injection context's owner for the life of its `DestroyRef`.
Call from a constructor or field initializer (root store, or `createDraftSync`). */
export function registerPendingSave(owner: PendingSave): void {
const unregister = inject(PendingSaves).register(owner);
inject(DestroyRef).onDestroy(unregister);
}
/** `CanDeactivate` guard: flush any pending debounced write before an in-app route change,
then allow navigation. Awaitable, so the write lands before the page tears down (which
would otherwise drop a sub-debounce edit). We never block leaving — the flush is a
guarantee of effort, not a gate. */
export const flushPendingGuard: CanDeactivateFn<unknown> = () => {
const pending = inject(PendingSaves);
return pending.hasPending() ? pending.flushAll().then(() => true) : true;
};
/** Wire a `beforeunload` handler that guards the last-mile save on a hard tab-close/reload.
ponytail: the HTTP seam is Angular `HttpClient` (no `keepalive`/`sendBeacon`), so an
async flush can't be guaranteed to finish as the page tears down — we fire it best-effort
AND trigger the browser's native "unsaved changes" prompt, which lets the ~600ms debounce
land if the user stays. Upgrade path: a `sendBeacon`/keepalive last-mile if this ever
needs to be guaranteed. */
export function provideUnloadFlush() {
return {
provide: ENVIRONMENT_INITIALIZER,
multi: true,
useValue: () => {
const pending = inject(PendingSaves);
window.addEventListener('beforeunload', (e) => {
if (!pending.hasPending()) return;
void pending.flushAll();
e.preventDefault();
e.returnValue = '';
});
},
};
}
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { RemoteData, map2, map } from './remote-data';
const loading: RemoteData<string, number> = { tag: 'Loading' };
const failure: RemoteData<string, number> = { tag: 'Failure', error: 'x' };
const ok = (n: number): RemoteData<string, number> => ({ tag: 'Success', value: n });
describe('RemoteData combinators', () => {
it('map only touches Success', () => {
const times10 = (n: number) => n * 10;
expect(map(ok(2), times10)).toEqual({ tag: 'Success', value: 20 });
expect(map(loading, times10)).toEqual(loading);
});
it('map2 precedence: Failure > Loading > Success', () => {
const add = (a: number, b: number) => a + b;
expect(map2(failure, ok(1), add)).toEqual(failure); // a failed
expect(map2(ok(1), failure, add)).toEqual(failure); // b failed
expect(map2(loading, ok(1), add)).toEqual({ tag: 'Loading' });
expect(map2(ok(2), ok(3), add)).toEqual({ tag: 'Success', value: 5 });
});
});
@@ -0,0 +1,82 @@
import type { Resource } from '@angular/core';
import { assertNever } from '@shared/kernel/fp';
/**
* The four mutually-exclusive states of an async fetch, as a tagged union.
* Crucially the data lives ON the state: only `Failure` has an `error`, only
* `Success` has a `value`. "Loaded but no value" or "error with stale value"
* are unrepresentable — Richard Feldman's RemoteData.
*/
export type RemoteData<E, T> =
| { tag: 'Loading' }
| { tag: 'Empty' }
| { tag: 'Failure'; error: E }
| { tag: 'Success'; value: T };
/** Project Angular's loosely-typed Resource into a RemoteData value. */
export function fromResource<T>(
r: Resource<T>,
isEmpty: (v: T) => boolean = () => false,
): RemoteData<Error | undefined, T> {
if (r.status() === 'error') return { tag: 'Failure', error: r.error() };
if (r.status() === 'loading') return { tag: 'Loading' };
if (r.hasValue()) {
const v = r.value();
return isEmpty(v) ? { tag: 'Empty' } : { tag: 'Success', value: v };
}
return { tag: 'Loading' };
}
// #region showcase:fold
/** Exhaustive fold: you must handle every case, checked at compile time. */
export function foldRemote<E, T, R>(
rd: RemoteData<E, T>,
h: { loading: () => R; empty: () => R; failure: (e: E) => R; success: (v: T) => R },
): R {
switch (rd.tag) {
case 'Loading':
return h.loading();
case 'Empty':
return h.empty();
case 'Failure':
return h.failure(rd.error);
case 'Success':
return h.success(rd.value);
default:
return assertNever(rd); // add a variant → compile error until handled
}
}
// #endregion showcase:fold
// --- Combinators -----------------------------------------------------------
// Let several independent async sources be treated as one. When you combine
// two streams the result is: a failure if EITHER failed, still loading if
// either is loading, empty if either is empty, and only Success when BOTH
// succeeded. Precedence: Failure > Loading > Empty > Success.
/** Transform the value inside a Success; pass other states through unchanged. */
export function map<E, A, B>(rd: RemoteData<E, A>, f: (a: A) => B): RemoteData<E, B> {
return rd.tag === 'Success' ? { tag: 'Success', value: f(rd.value) } : rd;
}
/** Combine two sources into one. Use this to merge e.g. a BIG-register call
and a BRP call into a single state the page can render. */
export function map2<E, A, B, R>(
a: RemoteData<E, A>,
b: RemoteData<E, B>,
f: (a: A, b: B) => R,
): RemoteData<E, R> {
if (a.tag === 'Failure') return a;
if (b.tag === 'Failure') return b;
if (a.tag === 'Loading' || b.tag === 'Loading') return { tag: 'Loading' };
if (a.tag === 'Empty' || b.tag === 'Empty') return { tag: 'Empty' };
return { tag: 'Success', value: f(a.value, b.value) };
}
/** Chain a second source that depends on the first one's value. */
export function andThen<E, A, B>(
rd: RemoteData<E, A>,
f: (a: A) => RemoteData<E, B>,
): RemoteData<E, B> {
return rd.tag === 'Success' ? f(rd.value) : rd;
}
@@ -0,0 +1,14 @@
import { InjectionToken, Signal } from '@angular/core';
/**
* A shared seam for the chrome to show "who is logged in" + log out, WITHOUT
* shared/ depending on the auth context (the import-direction rule forbids that).
* Auth provides this token at the app root (see app.config.ts); the shared header
* injects it. SessionStore satisfies this shape structurally.
*/
export interface SessionPort {
readonly session: Signal<{ naam: string } | null>;
logout(): void;
}
export const SESSION_PORT = new InjectionToken<SessionPort>('SESSION_PORT');
+32
View File
@@ -0,0 +1,32 @@
import { ApplicationRef, effect } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { describe, expect, it } from 'vitest';
import { createStore } from './store';
describe('createStore', () => {
it('applies the pure update on dispatch', () => {
const store = createStore(0, (n: number, m: number) => n + m);
store.dispatch(5);
store.dispatch(3);
expect(store.model()).toBe(8);
});
// Regression: an effect that dispatches must NOT re-run because of its own write.
// dispatch used to read `model()` reactively (`set(update(model(), msg))`), so an
// effect calling dispatch subscribed to `model` and looped forever, livelocking the
// main thread (crashed the upload wizards). With `.update` the read is untracked.
it('dispatch from inside an effect does not self-loop', () => {
const store = createStore(0, (n: number, _m: 'inc') => n + 1);
let runs = 0;
TestBed.runInInjectionContext(() => {
effect(() => {
runs++;
if (runs < 100) store.dispatch('inc'); // bounded so the buggy version can't hang the test
});
});
TestBed.inject(ApplicationRef).tick(); // flush effects
expect(runs).toBe(1); // effect ran once; its own dispatch did not retrigger it
expect(store.model()).toBe(1);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { Signal, signal } from '@angular/core';
/**
* A tiny "Elm-style" store. The whole idea: all state lives in ONE value
* (the Model). The only way to change it is to send a message (Msg) to a PURE
* function `update(model, msg)` that returns the next Model. Nothing else
* mutates state, so to understand the app you only read the update function.
*
* Side effects (HTTP, timers) do NOT go in `update` — that stays pure and easy
* to test. Instead, effectful "command" functions call the network and then
* `dispatch` a message describing what happened (e.g. Loaded / Failed).
*/
export interface Store<Model, Msg> {
/** The current state, as a read-only Angular signal. */
readonly model: Signal<Model>;
/** Send a message; the model becomes update(model, msg). */
dispatch(msg: Msg): void;
}
export function createStore<Model, Msg>(
init: Model,
update: (model: Model, msg: Msg) => Model,
): Store<Model, Msg> {
const model = signal(init);
return {
model: model.asReadonly(),
// Use `.update` (raw current value, no tracked read) not `set(update(model(), …))`:
// dispatch is a command and must never subscribe its caller to `model`. Reading
// `model()` here inside an effect that also dispatches makes the effect depend on
// its own write and livelock the main thread (crashed the upload wizards).
dispatch: (msg) => model.update((m) => update(m, msg)),
};
}
@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { runSubmit } from './submit';
describe('runSubmit', () => {
it('folds a resolved call into ok(value)', async () => {
const r = await runSubmit(async () => 'BIG-123', 'fallback');
expect(r).toEqual({ ok: true, value: 'BIG-123' });
});
it('maps a ProblemDetails rejection to err(detail)', async () => {
const r = await runSubmit(async () => {
throw { detail: 'Aanvraag afgewezen.' };
}, 'fallback');
expect(r).toEqual({ ok: false, error: 'Aanvraag afgewezen.' });
});
it('falls back when the rejection has no detail', async () => {
const r = await runSubmit(async () => {
throw new Error('network');
}, 'fallback');
expect(r).toEqual({ ok: false, error: 'fallback' });
});
});
+28
View File
@@ -0,0 +1,28 @@
import { Result, ok, err } from '@shared/kernel/fp';
import { problemDetail } from '@shared/infrastructure/api-error';
import { withIdempotencyKey } from '@shared/infrastructure/api-client.provider';
/**
* Run a mutating API call and fold it into a `Result` — the one place the
* try/catch + ProblemDetails-mapping lives, so every `submit-*` command is just
* its own payload mapping. The backend re-validates and returns a 422
* ProblemDetails on rejection, surfaced here as the error string.
*
* Also the one place a logical submit's Idempotency-Key is minted — once per
* `runSubmit` call, not per HTTP attempt — so a retry of this same submit
* dedupes on the backend (see `withIdempotencyKey`).
*/
export async function runSubmit<T>(
fn: () => Promise<T>,
fallback: string,
): Promise<Result<string, T>> {
try {
return ok(await withIdempotencyKey(crypto.randomUUID(), fn));
} catch (e) {
return err(problemDetail(e, fallback));
}
}
// Single shared default for a failed submit; the @@id dedupes it at the
// translation layer.
export const SUBMIT_FAILED = $localize`:@@submit.failed:Het indienen is niet gelukt. Probeer het later opnieuw.`;
+12
View File
@@ -0,0 +1,12 @@
/**
* A stable, namespaced capability string (PRD-0002 §5a), e.g. `brief:approve`.
* Server-resolved and opaque to the FE — never derived from a role client-side.
*/
export type Capability =
| 'brief:approve'
| 'brief:reject'
| 'brief:send'
| 'orgtemplate:edit'
| 'stamdata:edit'
| 'cases:manage'
| 'flags:manage';
+9
View File
@@ -0,0 +1,9 @@
/** A runtime feature flag as the FE sees it (resolved: catalog default + admin override). */
export interface FeatureFlag {
key: string;
description: string;
enabled: boolean;
}
/** Known flag keys the FE gates on — must match the backend `FeatureFlags` catalog. */
export const FLAG_INSCHRIJVING_OPEN = 'inschrijving-open';
+8
View File
@@ -0,0 +1,8 @@
/**
* The letter workflow's acting role: drafter or approver for the two-person
* compose/review flow, admin for org-template management (WP-23, Brief v2).
* A pure domain type (no framework, no reading mechanism) — the `?role=` reader and
* the X-Role header live in shared/infrastructure/role.ts. Consumers (brief.store,
* letter-composer) depend on this type, not on how the role is obtained.
*/
export type Role = 'drafter' | 'approver' | 'admin';
@@ -0,0 +1,10 @@
/**
* Production environment. `apiBaseUrl` stays relative ('') for a same-origin /
* reverse-proxy deployment; set it to the API origin (e.g. 'https://api.example.nl')
* when the SPA and backend are served from different hosts. This is the single
* place the deployed API location is configured.
*/
export const environment = {
production: true,
apiBaseUrl: '',
};
@@ -0,0 +1,10 @@
/**
* Default (development) environment. `apiBaseUrl` is empty so requests are
* relative to the current origin — in dev the ng-serve proxy forwards /api to the
* backend (proxy.conf.json). Swapped for environment.prod.ts in production builds
* (angular.json fileReplacements).
*/
export const environment = {
production: false,
apiBaseUrl: '',
};
@@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest';
import { of, throwError } from 'rxjs';
import { HttpClient, HttpErrorResponse, HttpResponse } from '@angular/common/http';
import { currentIdempotencyKey, httpClientFetch, withIdempotencyKey } from './api-client.provider';
/** Minimal stand-in for HttpClient — only `.request(...)` is ever called by the
* adapter under test, so no TestBed/HttpClientTestingModule needed. */
function fakeHttpClient(
request: (method: string, url: string, options: { headers: Record<string, string> }) => unknown,
): HttpClient {
return { request } as unknown as HttpClient;
}
describe('withIdempotencyKey / currentIdempotencyKey', () => {
it('threads the key to every read made inside the wrapped fn', async () => {
const seen: string[] = [];
await withIdempotencyKey('fixed-key', async () => {
seen.push(currentIdempotencyKey());
seen.push(currentIdempotencyKey());
});
expect(seen).toEqual(['fixed-key', 'fixed-key']);
});
it('clears the key once the wrapped fn settles', async () => {
await withIdempotencyKey('fixed-key', async () => undefined);
expect(currentIdempotencyKey()).not.toBe('fixed-key');
});
it('falls back to a generated uuid-shaped key when none is pending', () => {
expect(currentIdempotencyKey()).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
);
});
});
describe('httpClientFetch', () => {
it('sends the pending idempotency key as a header for a write, not a fresh one per attempt', async () => {
let sentHeaders: Record<string, string> | undefined;
const http = fakeHttpClient((_method, _url, opts) => {
sentHeaders = opts.headers;
return of(new HttpResponse({ status: 200, body: '' }));
});
await withIdempotencyKey('logical-submit-key', () =>
httpClientFetch(http).fetch('/api/v1/change-requests', { method: 'POST' }),
);
expect(sentHeaders?.['Idempotency-Key']).toBe('logical-submit-key');
});
// `http.request(...)` itself is only called once per `fetch()` — it returns a
// cold Observable, and `retry` resubscribes to *that*, not to `.request()`
// again (exactly how Angular's real HttpClient triggers a fresh network call
// per subscription). So attempts are counted where the resubscription lands:
// the `throwError` factory, not the outer mock call.
it('retries a failing GET twice before giving up', async () => {
let attempts = 0;
const http = fakeHttpClient(() =>
throwError(() => {
attempts++;
return new HttpErrorResponse({ status: 500 });
}),
);
const res = await httpClientFetch(http).fetch('/api/v1/notes', { method: 'GET' });
expect(attempts).toBe(3); // 1 original + 2 retries
expect(res.status).toBe(500);
});
it('never retries a failing write', async () => {
let attempts = 0;
const http = fakeHttpClient(() =>
throwError(() => {
attempts++;
return new HttpErrorResponse({ status: 500 });
}),
);
const res = await httpClientFetch(http).fetch('/api/v1/change-requests', { method: 'POST' });
expect(attempts).toBe(1);
expect(res.status).toBe(500);
});
});
@@ -0,0 +1,94 @@
import { Provider } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { firstValueFrom, retry, timeout, TimeoutError } from 'rxjs';
import { ApiClient, ProblemDetails } from './api-client';
import { environment } from '@shared/environments/environment';
/** Single place every API call passes through: the seam for cross-cutting concerns. */
const REQUEST_TIMEOUT_MS = 10_000;
/**
* A stable Idempotency-Key threaded down from the command layer (one per logical
* submit — see `runSubmit`) rather than minted per HTTP attempt, so a retried
* submit dedupes on the backend instead of double-submitting. The NSwag-generated
* `ApiClient` has no per-call header hook, so `withIdempotencyKey` bridges it here:
* every non-GET call made synchronously inside `fn` picks up the same key.
* ponytail: a module-level variable, not a proper async-context primitive — holds
* up because every submit command calls its adapter synchronously (no await
* before reaching this file); swap for `AsyncLocal`-equivalent if concurrent
* submits ever become possible.
*/
let pendingIdempotencyKey: string | undefined;
export function withIdempotencyKey<T>(key: string, fn: () => Promise<T>): Promise<T> {
pendingIdempotencyKey = key;
return fn().finally(() => (pendingIdempotencyKey = undefined));
}
export function currentIdempotencyKey(): string {
return pendingIdempotencyKey ?? crypto.randomUUID();
}
/**
* Adapts Angular's HttpClient to the fetch-shaped interface the NSwag-generated
* client expects, so every API call flows through HttpClient interceptors (the
* `?scenario=` toggle) and the cross-cutting concerns below. The generated client
* is the only place HTTP shapes are known; this is the only place it meets
* Angular's HTTP stack — i.e. the one seam to add:
* - timeout (done — REQUEST_TIMEOUT_MS),
* - correlation id (done — X-Correlation-Id, echoed in backend logs),
* - idempotency key for writes (done — Idempotency-Key, stable per logical
* submit via `withIdempotencyKey`/`runSubmit`, so a retry dedupes),
* - auth: attach `Authorization: Bearer …` here (one line) when real DigiD lands,
* - retry/backoff (done — GET only, `retry({ count: 2, delay: 500 })`; writes are
* never auto-retried, which is exactly what makes the idempotency key above
* matter only for a future/manual retry, not routine traffic).
*/
export function httpClientFetch(http: HttpClient) {
return {
async fetch(url: RequestInfo, init?: RequestInit): Promise<Response> {
const method = (init?.method ?? 'GET').toUpperCase();
const headers: Record<string, string> = {
...((init?.headers ?? {}) as Record<string, string>),
'X-Correlation-Id': crypto.randomUUID(),
};
if (method !== 'GET') headers['Idempotency-Key'] = currentIdempotencyKey();
try {
const request$ = http
.request(method, url as string, {
body: init?.body as string | undefined,
headers,
observe: 'response',
responseType: 'text',
})
.pipe(timeout(REQUEST_TIMEOUT_MS));
const res = await firstValueFrom(
method === 'GET' ? request$.pipe(retry({ count: 2, delay: 500 })) : request$,
);
// 204/205/304 are null-body statuses — new Response(body, …) throws for any non-null body.
const nullBody = res.status === 204 || res.status === 205 || res.status === 304;
return new Response(nullBody ? null : (res.body ?? ''), { status: res.status || 200 });
} catch (e) {
if (e instanceof TimeoutError) return new Response('', { status: 504 });
const err = e as HttpErrorResponse;
const body = typeof err.error === 'string' ? err.error : JSON.stringify(err.error ?? {});
// ponytail: clamp to a Response-constructible status (an aborted/interceptor
// request reports status 0, which `new Response` rejects).
const status = err.status >= 200 && err.status <= 599 ? err.status : 500;
return new Response(body, { status });
}
},
};
}
/** Provide a root ApiClient that talks through HttpClient. Base URL comes from the
* environment (relative '' in dev → proxy; configurable per deployment). */
export function provideApiClient(): Provider {
return {
provide: ApiClient,
useFactory: (http: HttpClient) => new ApiClient(environment.apiBaseUrl, httpClientFetch(http)),
deps: [HttpClient],
};
}
export type { ProblemDetails };
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { problemDetail, problemFieldErrors } from './api-error';
describe('problemDetail', () => {
it('extracts the detail from an RFC-7807 ProblemDetails', () => {
expect(problemDetail({ detail: 'Afgewezen: 0 uren.', status: 422 }, 'fallback')).toBe(
'Afgewezen: 0 uren.',
);
});
it('falls back when there is no detail', () => {
expect(problemDetail(new Error('boom'), 'fallback')).toBe('fallback');
expect(problemDetail({ status: 500 }, 'fallback')).toBe('fallback');
expect(problemDetail(undefined, 'fallback')).toBe('fallback');
});
});
describe('problemFieldErrors (G4 seam)', () => {
it('maps a ValidationProblemDetails errors dict to first-message-per-field', () => {
expect(
problemFieldErrors({ errors: { straat: ['Verplicht.'], postcode: ['Ongeldig.', 'x'] } }),
).toEqual({ straat: 'Verplicht.', postcode: 'Ongeldig.' });
});
it('returns {} when there is no errors envelope (the current backend shape)', () => {
expect(problemFieldErrors({ detail: 'one banner' })).toEqual({});
expect(problemFieldErrors(new Error('boom'))).toEqual({});
expect(problemFieldErrors(undefined)).toEqual({});
});
});
@@ -0,0 +1,36 @@
import { ProblemDetails } from './api-client';
/**
* Extract a human-readable message from a rejected API call. A 4xx/5xx with a
* ProblemDetails body (RFC 7807) is thrown by the generated client as the parsed
* object; anything else falls back to the given message.
*/
export function problemDetail(e: unknown, fallback: string): string {
if (e && typeof e === 'object' && 'detail' in e) {
const detail = (e as ProblemDetails).detail;
if (typeof detail === 'string' && detail) return detail;
}
return fallback;
}
/**
* SEAM (G4): map a server validation envelope to field-level errors.
*
* ASP.NET's ValidationProblemDetails carries `errors: { field: string[] }`. The
* backend today returns only `detail` (one banner message), so this returns `{}`.
* When the backend starts sending `errors`, a machine's `SubmitFailed` handler can
* merge this into its own `errors` map — the field-keyed shape the wizards already
* render — so a rejection shows inline per field, not just as a banner. The
* consumer hook is the only thing left to wire; the contract boundary lives here.
*/
export function problemFieldErrors(e: unknown): Record<string, string> {
if (!e || typeof e !== 'object' || !('errors' in e)) return {};
const errors = (e as { errors?: unknown }).errors;
if (!errors || typeof errors !== 'object') return {};
const out: Record<string, string> = {};
for (const [field, msgs] of Object.entries(errors as Record<string, unknown>)) {
const first = Array.isArray(msgs) ? msgs[0] : msgs;
if (typeof first === 'string') out[field] = first;
}
return out;
}
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { stripDevParams } from './dev-params';
describe('stripDevParams (WP-37)', () => {
it('removes ?scenario and ?role so the stored dev value wins on reload', () => {
expect(stripDevParams('http://localhost:4200/dashboard?scenario=slow&role=admin')).toBe(
'http://localhost:4200/dashboard',
);
});
it('keeps unrelated query params and the path/hash', () => {
expect(stripDevParams('http://localhost:4200/beheer/zaken?scenario=error&tab=2#top')).toBe(
'http://localhost:4200/beheer/zaken?tab=2#top',
);
});
it('is a no-op when neither param is present', () => {
expect(stripDevParams('http://localhost:4200/dashboard')).toBe(
'http://localhost:4200/dashboard',
);
});
});
@@ -0,0 +1,14 @@
/**
* Remove the dev-only `?scenario=` and `?role=` params from a URL (WP-37). Once the
* dev switcher (debug-state) has been used, sessionStorage is the authoritative source
* for both — `currentScenario()`/`currentRole()` read the URL FIRST, so a stale param
* left in the address bar would override the switcher on reload (the "stuck on slow"
* bug). Stripping the params before reload lets the stored value win. Pure: returns the
* rewritten href, mutates nothing.
*/
export function stripDevParams(href: string): string {
const url = new URL(href);
url.searchParams.delete('scenario');
url.searchParams.delete('role');
return url.toString();
}
@@ -0,0 +1,38 @@
import { Injectable, inject } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { ApiClient } from '@shared/infrastructure/api-client';
import { FeatureFlag } from '@shared/domain/feature-flag';
/**
* Infrastructure adapter for feature flags (WP-47): `GET /flags` (resolved set, drives FE gating)
* and the admin `PUT /admin/flags/{key}`. The single place the ApiClient lives for flags; the
* store parses at the boundary.
*/
@Injectable({ providedIn: 'root' })
export class FeatureFlagsAdapter {
private client = inject(ApiClient);
list() {
return this.client.flagsAll();
}
set(key: string, enabled: boolean) {
return this.client.flags(key, { enabled });
}
}
/** Trust-boundary parse of the flag set. */
export function parseFlags(json: unknown): Result<string, FeatureFlag[]> {
if (!Array.isArray(json)) return err('flags: not an array');
const out: FeatureFlag[] = [];
for (const f of json) {
if (typeof f !== 'object' || f === null) return err('flags: row not an object');
const d = f as Partial<FeatureFlag>;
if (typeof d.key !== 'string' || typeof d.enabled !== 'boolean') return err('flags: bad shape');
out.push({
key: d.key,
description: typeof d.description === 'string' ? d.description : '',
enabled: d.enabled,
});
}
return ok(out);
}
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import { parseMe } from './me.adapter';
describe('parseMe (trust boundary)', () => {
it('parses a known capability list', () => {
const r = parseMe({ capabilities: ['brief:approve', 'brief:reject', 'brief:send'] });
expect(r).toEqual({ ok: true, value: ['brief:approve', 'brief:reject', 'brief:send'] });
});
it('parses an empty list (drafter — no capabilities)', () => {
expect(parseMe({ capabilities: [] })).toEqual({ ok: true, value: [] });
});
it('recognizes the admin org-template capability (WP-23)', () => {
expect(parseMe({ capabilities: ['orgtemplate:edit'] })).toEqual({
ok: true,
value: ['orgtemplate:edit'],
});
});
it('drops unrecognized capability strings instead of rejecting the response', () => {
const r = parseMe({ capabilities: ['brief:approve', 'unknown:future-thing'] });
expect(r).toEqual({ ok: true, value: ['brief:approve'] });
});
it('rejects malformed responses instead of trusting them', () => {
expect(parseMe(null).ok).toBe(false);
expect(parseMe({}).ok).toBe(false);
expect(parseMe({ capabilities: 'brief:approve' }).ok).toBe(false);
});
});
@@ -0,0 +1,41 @@
import { Injectable, inject, resource } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { Capability } from '@shared/domain/capability';
import { ApiClient } from '@shared/infrastructure/api-client';
const KNOWN: readonly Capability[] = [
'brief:approve',
'brief:reject',
'brief:send',
'orgtemplate:edit',
'stamdata:edit',
'cases:manage',
'flags:manage',
];
/**
* Infrastructure adapter for `GET /me` (PRD-0002 §6): the current principal's
* coarse, role-derived capabilities — nav/menu-level, not tied to any one screen's
* live status (contrast a screen's own decision DTO, e.g. `BriefViewDto.decisions`).
*/
@Injectable({ providedIn: 'root' })
export class MeAdapter {
private client = inject(ApiClient);
meResource() {
return resource({ loader: () => this.client.me() });
}
}
/**
* Trust-boundary parse. An unrecognized capability string is dropped rather than
* rejecting the whole response — deny-by-default already covers it (AccessStore.can
* returns false for anything not in the set), and it lets the backend grow the
* capability list without breaking an older FE build.
*/
export function parseMe(json: unknown): Result<string, Capability[]> {
if (typeof json !== 'object' || json === null) return err('me: not an object');
const dto = json as { capabilities?: unknown };
if (!Array.isArray(dto.capabilities)) return err('me: missing/invalid capabilities');
return ok(dto.capabilities.filter((c): c is Capability => KNOWN.includes(c as Capability)));
}
@@ -0,0 +1,54 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { roleInterceptor } from './role.interceptor';
// currentRole() reads window.location.search; set it via the real URL rather than
// vi.mock (the Angular unit-test system forbids mocking relative imports).
beforeEach(() => window.history.replaceState({}, '', '/?role=admin'));
afterEach(() => {
window.history.replaceState({}, '', '/');
sessionStorage.clear(); // currentRole() now persists the dev role; don't leak across tests
});
// Minimal stand-in for HttpRequest — the interceptor only reads `url` and calls
// `clone({ setHeaders })`. Avoids importing @angular/common/http (its XHR chunk needs
// the JIT compiler under vitest).
function fakeReq(url: string) {
const make = (headers: Map<string, string>) => ({
url,
headers,
clone(opts: { setHeaders: Record<string, string> }) {
const next = new Map(headers);
for (const [k, v] of Object.entries(opts.setHeaders)) next.set(k, v);
return make(next);
},
});
return make(new Map());
}
/** Run the interceptor and return the request it forwarded to `next`. */
function forward(url: string) {
let seen!: ReturnType<typeof fakeReq>;
const next = (r: ReturnType<typeof fakeReq>) => {
seen = r;
return undefined;
};
// Cast: the fake matches the shape the interceptor actually touches.
(roleInterceptor as unknown as (req: unknown, next: unknown) => unknown)(fakeReq(url), next);
return seen;
}
describe('roleInterceptor', () => {
it.each([
'/api/v1/brief',
'/api/v1/admin/org-template',
'/api/v1/stamdata', // WP-29: the admin stamdata reads 403 without X-Role
'/api/v1/stamdata/professions?peildatum=1999-01-01',
'/api/v1/me',
])('stamps X-Role on the role-aware endpoint %s', (url) => {
expect(forward(url).headers.get('X-Role')).toBe('admin');
});
it('leaves an unrelated endpoint untouched', () => {
expect(forward('/api/v1/duo/diplomas').headers.has('X-Role')).toBe(false);
});
});
@@ -0,0 +1,25 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { currentRole } from './role';
/**
* Dev-only: stamps role-aware requests with the current `?role=` as an `X-Role`
* header so the backend can enforce the drafter/approver/admin rules. Only the
* brief, org-template, stamdata and /me endpoints carry it (WP-23 widened the set
* /me must see the role or `AccessStore` could never learn a capability; WP-29 added
* /stamdata, whose admin-only reads 403 without it); everything else is untouched.
* A new admin-gated endpoint MUST be added here or its page silently 403s.
*/
const ROLE_AWARE = [
'/api/v1/brief',
'/api/v1/admin/org-template',
'/api/v1/admin/cases',
'/api/v1/admin/audit',
'/api/v1/admin/flags',
'/api/v1/stamdata',
'/api/v1/me',
];
export const roleInterceptor: HttpInterceptorFn = (req, next) => {
if (!ROLE_AWARE.some((prefix) => req.url.includes(prefix))) return next(req);
return next(req.clone({ setHeaders: { 'X-Role': currentRole() } }));
};
+36
View File
@@ -0,0 +1,36 @@
import { Role } from '@shared/domain/role';
/**
* Dev-only role stand-in (the reading MECHANISM; the `Role` type is domain). This
* POC has one faked self-service user and no real identities, so the two-person
* letter workflow (drafter vs approver) plus admin is driven by a `?role=` query
* param. The backend receives it as an `X-Role` header (see role.interceptor),
* resolves it into a `Principal` server-side, and is the sole authority on what that
* principal may do (PRD-0002 phase P1, `Authz.Can`) the FE only renders the
* resulting decision flags, it no longer derives permission from this value itself.
*
* **Sticky within the tab (sessionStorage):** the interceptor reads this per request,
* but navigation drops the query param (login redirects to /dashboard, RouterLinks
* don't carry it), which would silently revert an admin to drafter mid-session and
* 403 the admin endpoints. So a `?role=` seen in the URL is remembered for the tab;
* later requests use the remembered value. Set `?role=drafter` (or a fresh tab) to
* reset. Dev-only the interceptor itself is only wired under `isDevMode()`.
*/
const STORAGE_KEY = 'dev-role';
export const ROLES: readonly Role[] = ['drafter', 'approver', 'admin'];
const isRole = (v: string | null): v is Role => !!v && ROLES.includes(v as Role);
export function currentRole(): Role {
const fromUrl = new URLSearchParams(window.location.search).get('role');
if (isRole(fromUrl)) {
sessionStorage.setItem(STORAGE_KEY, fromUrl);
return fromUrl;
}
const stored = sessionStorage.getItem(STORAGE_KEY);
return isRole(stored) ? stored : 'drafter';
}
/** Dev switcher entry point: persist the chosen role for the tab (WP-33). */
export function setRole(r: Role): void {
sessionStorage.setItem(STORAGE_KEY, r);
}
@@ -0,0 +1,33 @@
import { HttpErrorResponse, HttpInterceptorFn, HttpResponse } from '@angular/common/http';
import { of, switchMap, throwError, timer } from 'rxjs';
import { delay } from 'rxjs/operators';
import { currentScenario } from './scenario';
/**
* Demo-only: rewrites the timing/outcome of API data requests based on
* ?scenario= so loading / empty / error states can be shown on demand.
* Non-API requests are untouched.
*/
export const scenarioInterceptor: HttpInterceptorFn = (req, next) => {
if (!req.url.includes('/api/')) return next(req);
switch (currentScenario()) {
case 'slow':
return next(req).pipe(delay(2500));
case 'loading':
return next(req).pipe(delay(600_000)); // effectively never resolves
case 'empty':
// '[]' so the typed client parses it to an empty array (notes → Empty state).
return of(new HttpResponse({ status: 200, body: '[]' })).pipe(delay(400));
case 'error':
return timer(400).pipe(
switchMap(() =>
throwError(
() => new HttpErrorResponse({ status: 500, statusText: 'Demo-fout', url: req.url }),
),
),
);
default:
return next(req);
}
};
@@ -0,0 +1,29 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { currentScenario, setScenario } from './scenario';
const setUrl = (search: string) => history.pushState({}, '', search || '/');
describe('scenario (dev mechanism)', () => {
beforeEach(() => {
sessionStorage.clear();
setUrl('/');
});
it('reads a valid ?scenario= from the URL and persists it for the tab', () => {
setUrl('?scenario=error');
expect(currentScenario()).toBe('error');
setUrl('/'); // navigation drops the query param — value stays sticky
expect(currentScenario()).toBe('error');
});
it('falls back to default when nothing is set or the value is invalid', () => {
expect(currentScenario()).toBe('default');
setUrl('?scenario=nonsense');
expect(currentScenario()).toBe('default');
});
it('setScenario persists the chosen scenario', () => {
setScenario('slow');
expect(currentScenario()).toBe('slow');
});
});
@@ -0,0 +1,45 @@
export type Scenario =
| 'default'
| 'slow'
| 'loading'
| 'empty'
| 'error'
// upload-only (the multipart POST is hand-written XHR, so it bypasses the HTTP
// interceptor — these are simulated in upload.adapter.ts instead):
| 'upload-slow'
| 'upload-fail';
export const SCENARIOS: readonly Scenario[] = [
'default',
'slow',
'loading',
'empty',
'error',
'upload-slow',
'upload-fail',
];
const STORAGE_KEY = 'dev-scenario';
const isScenario = (v: string | null): v is Scenario => !!v && SCENARIOS.includes(v as Scenario);
/**
* Reads the active demo scenario so a demo can force each async state.
* Sticky within the tab (sessionStorage), mirroring `role.ts`: a `?scenario=` in the
* URL sets it; later navigation (which drops the query param) keeps the remembered
* value. Set `?scenario=default`, use the dev switcher, or open a fresh tab to reset.
* Dev-only the interceptor that consumes this is wired only under `isDevMode()`.
*/
export function currentScenario(): Scenario {
const fromUrl = new URLSearchParams(window.location.search).get('scenario');
if (isScenario(fromUrl)) {
sessionStorage.setItem(STORAGE_KEY, fromUrl);
return fromUrl;
}
const stored = sessionStorage.getItem(STORAGE_KEY);
return isScenario(stored) ? stored : 'default';
}
/** Dev switcher entry point: persist the chosen scenario for the tab (WP-33). */
export function setScenario(s: Scenario): void {
sessionStorage.setItem(STORAGE_KEY, s);
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import { parseBsn } from './bsn';
describe('parseBsn (elfproef)', () => {
it('accepts a valid BSN (passes the elfproef)', () => {
const r = parseBsn('123456782'); // Σ d·w = 154, divisible by 11
expect(r.ok && r.value).toBe('123456782');
});
it('rejects a 9-digit number that fails the elfproef', () => {
expect(parseBsn('123456789').ok).toBe(false); // sum 147, not divisible
});
it('rejects wrong length / non-digits / all zeros', () => {
expect(parseBsn('12345').ok).toBe(false);
expect(parseBsn('abcdefghi').ok).toBe(false);
expect(parseBsn('000000000').ok).toBe(false);
});
});
+28
View File
@@ -0,0 +1,28 @@
import { Brand, Result, ok, err } from '@shared/kernel/fp';
/**
* Value object: a Dutch **BSN** (burgerservicenummer) art. 9 GDPR/AVG special-category
* data. "Parse, don't validate": a `Bsn` is a distinct type from a raw string, mintable only
* via `parseBsn`, so holding one is proof it passed the **elfproef** (11-test) checksum, not
* just a 9-digit shape. Format/checksum only identity is still faked in this POC (DigiD stub).
*/
export type Bsn = Brand<string, 'Bsn'>;
// Positional weights for the elfproef: 9·d1 + 8·d2 + … + 2·d8 1·d9 ≡ 0 (mod 11).
const WEIGHTS = [9, 8, 7, 6, 5, 4, 3, 2, -1];
// #region showcase:parseBsn
export function parseBsn(raw: string): Result<string, Bsn> {
const t = raw.trim();
if (!/^\d{9}$/.test(t)) {
return err($localize`:@@validation.bsn:Voer een geldig BSN van 9 cijfers in.`);
}
const sum = [...t].reduce((acc, ch, i) => acc + Number(ch) * WEIGHTS[i], 0);
if (t === '000000000' || sum % 11 !== 0) {
return err(
$localize`:@@validation.bsnElfproef:Dit is geen geldig BSN (klopt niet met de elfproef).`,
);
}
return ok(t as Bsn); // holding a Bsn is proof it passed the elfproef
}
// #endregion showcase:parseBsn
+22
View File
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { formatDatumNl } from './datum';
describe('formatDatumNl', () => {
it('formats a Date in long Dutch form', () => {
expect(formatDatumNl(new Date(2026, 6, 2))).toBe('2 juli 2026');
});
it('formats an ISO string the same way', () => {
expect(formatDatumNl('2026-07-02')).toBe('2 juli 2026');
});
it('is empty-safe: undefined, null, and empty string all yield the empty string', () => {
expect(formatDatumNl(undefined)).toBe('');
expect(formatDatumNl(null)).toBe('');
expect(formatDatumNl('')).toBe('');
});
it('returns empty for an unparseable string rather than "Invalid Date"', () => {
expect(formatDatumNl('not-a-date')).toBe('');
});
});
+16
View File
@@ -0,0 +1,16 @@
/**
* The one hand-written date formatter for pure TS (non-template) code a domain
* rule or a `$localize` string can't reach for Angular's `DatePipe`, so this covers
* that gap. Templates use `DatePipe` (`| date: 'longDate'`) instead; don't add a
* second hand-rolled formatter for either case.
*/
export function formatDatumNl(d: Date | string | undefined | null): string {
if (!d) return '';
const date = typeof d === 'string' ? new Date(d) : d;
if (Number.isNaN(date.getTime())) return '';
return new Intl.DateTimeFormat('nl-NL', {
day: 'numeric',
month: 'long',
year: 'numeric',
}).format(date);
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Tiny native-TS functional toolkit. No dependency this is the whole "library".
* Reused by every "impossible states" concept in the POC.
*/
/** Exhaustiveness guard: put in the `default` arm of a union switch. Adding a
new variant without handling it then fails to compile (x is no longer never). */
export function assertNever(x: never): never {
throw new Error('Unexpected variant: ' + JSON.stringify(x));
}
/** A computation that either succeeded with a value or failed with an error.
Plain objects (no classes) to match the signal/httpResource ergonomics. */
export type Result<E, T> =
{ readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: E };
export const ok = <T>(value: T): Result<never, T> => ({ ok: true, value });
export const err = <E>(error: E): Result<E, never> => ({ ok: false, error });
/** Nominal typing: Brand<string, 'Postcode'> is assignable from a plain string
only through an explicit cast so a smart constructor is the only minter. */
export type Brand<T, B extends string> = T & { readonly __brand: B };
/** Narrow a tagged union to one variant by its `tag`, or null. The single place
the cast lives TS can't narrow through a runtime tag argument, so callers get
`whenTag(state, 'Editing')?.foo` instead of repeating `as Extract<…>`. */
export function whenTag<U extends { tag: string }, K extends U['tag']>(
u: U,
tag: K,
): Extract<U, { tag: K }> | null {
return u.tag === tag ? (u as Extract<U, { tag: K }>) : null;
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest';
import { maskBsn, maskTail } from './pii';
describe('pii maskers', () => {
it('maskBsn keeps the last 3 digits', () => {
expect(maskBsn('123456789')).toBe('******789');
});
it('maskTail keeps the requested tail length', () => {
expect(maskTail('abcdef', 2)).toBe('****ef');
});
it('masks the whole value when it is not longer than the kept tail', () => {
expect(maskBsn('12')).toBe('**');
expect(maskBsn('')).toBe('');
});
});
+19
View File
@@ -0,0 +1,19 @@
/**
* PII masking pure functional core (WP-40). Data-minimisation helpers shared by the app
* (dev state panel, the masked-value atom, anywhere sensitive data is shown). No framework,
* no domain imports. The backend keeps a `MaskTail` twin in sync (see Program.cs).
*/
export const REDACTED = 'redacted';
// #region showcase:mask
/** Keep the last `keep` characters, mask the rest with `*`. */
export function maskTail(value: string, keep: number): string {
if (value.length <= keep) return '*'.repeat(value.length);
return '*'.repeat(value.length - keep) + value.slice(-keep);
}
/** Mask a BSN / BIG-nummer for display: keep the last 3 digits, mask the rest. */
export function maskBsn(value: string): string {
return maskTail(value, 3);
}
// #endregion showcase:mask
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import {
RichTextBlock,
deepCopyBlock,
emptyBlock,
isBlockEmpty,
placeholderKeysIn,
} from './rich-text';
const block = (): RichTextBlock => ({
paragraphs: [
{
nodes: [
{ type: 'text', text: 'Beste ' },
{ type: 'placeholder', key: 'naam' },
],
},
{
nodes: [
{ type: 'placeholder', key: 'datum' },
{ type: 'placeholder', key: 'naam' },
],
},
],
});
describe('rich-text', () => {
it('emptyBlock is one empty paragraph and reads as empty', () => {
expect(emptyBlock()).toEqual({ paragraphs: [{ nodes: [] }] });
expect(isBlockEmpty(emptyBlock())).toBe(true);
});
it('isBlockEmpty is false when any placeholder or non-blank text exists', () => {
expect(isBlockEmpty({ paragraphs: [{ nodes: [{ type: 'text', text: ' ' }] }] })).toBe(true);
expect(isBlockEmpty({ paragraphs: [{ nodes: [{ type: 'placeholder', key: 'x' }] }] })).toBe(
false,
);
expect(isBlockEmpty({ paragraphs: [{ nodes: [{ type: 'text', text: 'hoi' }] }] })).toBe(false);
});
it('placeholderKeysIn walks in document order, keeping duplicates', () => {
expect(placeholderKeysIn(block())).toEqual(['naam', 'datum', 'naam']);
});
it('deepCopyBlock is an independent value copy (frozen snapshot)', () => {
const original = block();
const copy = deepCopyBlock(original);
expect(copy).toEqual(original);
expect(copy).not.toBe(original);
expect(copy.paragraphs[0]).not.toBe(original.paragraphs[0]);
// Mutating the copy must not touch the original — proves no shared reference.
(copy.paragraphs[0].nodes as { type: 'text'; text: string }[])[0] = {
type: 'text',
text: 'CHANGED',
};
expect(placeholderKeysIn(original)).toEqual(['naam', 'datum', 'naam']);
expect((original.paragraphs[0].nodes[0] as { text: string }).text).toBe('Beste ');
});
});
+73
View File
@@ -0,0 +1,73 @@
/**
* Rich text as a *serialisable value*, not opaque HTML.
*
* A block is a node tree. Because a placeholder is a first-class NODE (not a
* `{{token}}` substring hidden inside a string), highlighting it, inserting it,
* and linting it are all pure functions over data no regex over markup. This
* is the whole reason the letter feature stays in the "impossible states" style:
* the value the app holds is always well-shaped, and the imperative editor is
* quarantined behind one component that converts to/from this tree.
*/
export type Mark = 'bold' | 'italic' | 'underline';
export type RichTextNode =
| { readonly type: 'text'; readonly text: string; readonly marks?: readonly Mark[] }
| { readonly type: 'placeholder'; readonly key: string } // resolved to a value at send
| { readonly type: 'lineBreak' };
export interface Paragraph {
readonly nodes: readonly RichTextNode[];
// A line can be a plain paragraph (undefined) or an item in a bullet/numbered list.
// Consecutive lines with the same list kind render as one <ul>/<ol>.
readonly list?: 'bullet' | 'number';
}
export interface RichTextBlock {
readonly paragraphs: readonly Paragraph[];
}
/** An empty editable block is one empty paragraph never zero paragraphs, so the
editor always has a caret line. */
export function emptyBlock(): RichTextBlock {
return { paragraphs: [{ nodes: [] }] };
}
/** True when the block carries no visible content (used for "required section empty"). */
export function isBlockEmpty(block: RichTextBlock): boolean {
return block.paragraphs.every((p) =>
p.nodes.every((n) => (n.type === 'text' ? n.text.trim() === '' : false)),
);
}
/** The frozen-snapshot primitive: a deep VALUE copy of a block. Inserting a library
passage into a letter copies its tree through here, so the letter never shares a
reference with the library later library edits can't mutate an existing letter. */
export function deepCopyBlock(block: RichTextBlock): RichTextBlock {
// ponytail: structuredClone is exactly a deep value copy of a JSON-shaped tree;
// a hand-rolled walk would be more code for the same result.
return structuredClone(block) as RichTextBlock;
}
/** All visible text of a block as one lowercased string for client-side search over
passages. Placeholders contribute their key so "naam" matches a `naam_zorgverlener` chip. */
export function textOf(block: RichTextBlock): string {
return block.paragraphs
.flatMap((p) =>
p.nodes.map((n) => (n.type === 'text' ? n.text : n.type === 'placeholder' ? n.key : '')),
)
.join(' ')
.toLowerCase();
}
/** Every placeholder key used in a block, in document order (duplicates kept the
caller dedupes when it wants a set). */
export function placeholderKeysIn(block: RichTextBlock): string[] {
const keys: string[] = [];
for (const p of block.paragraphs) {
for (const n of p.nodes) {
if (n.type === 'placeholder') keys.push(n.key);
}
}
return keys;
}
@@ -0,0 +1,38 @@
import { BreadcrumbItem } from './breadcrumb.component';
/** Route breadcrumb label + parent. The app has a small fixed route set
(see app.routes.ts), so a static map is enough no per-page wiring.
ponytail: static map, not a breadcrumb service; revisit if routes go dynamic. */
interface Crumb {
label: string;
parent?: string;
}
const ROUTES: Record<string, Crumb> = {
'/dashboard': { label: $localize`:@@crumb.dashboard:Mijn overzicht` },
'/registratie': { label: $localize`:@@crumb.registratie:Mijn gegevens`, parent: '/dashboard' },
'/registreren': { label: $localize`:@@crumb.registreren:Inschrijven`, parent: '/dashboard' },
'/herregistratie': {
label: $localize`:@@crumb.herregistratie:Herregistratie`,
parent: '/dashboard',
},
'/intake': { label: $localize`:@@crumb.intake:Herregistratie-intake`, parent: '/dashboard' },
'/concepts': { label: $localize`:@@crumb.concepts:Functionele patronen`, parent: '/dashboard' },
};
/** Build the breadcrumb trail for a router url (query/fragment stripped).
Returns [] for unknown routes (e.g. /login) so the bar can hide itself. */
export function trailFor(url: string): BreadcrumbItem[] {
const path = url.split(/[?#]/)[0];
const trail: BreadcrumbItem[] = [];
let cursor: string | undefined = path;
while (cursor) {
const node: Crumb | undefined = ROUTES[cursor];
if (!node) break;
trail.unshift({ label: node.label, link: cursor });
cursor = node.parent;
}
// The current (last) page is not a link.
if (trail.length) delete trail[trail.length - 1].link;
return trail;
}
@@ -0,0 +1,41 @@
import { Component, input } from '@angular/core';
import { RouterLink } from '@angular/router';
export interface BreadcrumbItem {
label: string;
link?: string; // omit on the current (last) page
}
/** Chrome: 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({
selector: 'app-breadcrumb',
imports: [RouterLink],
// CIBG's global "header nav" background rule matches ANY nav inside a <header>
// — including this one, wherever it's mounted. Override it so the breadcrumb
// never carries its own background (it should show whatever's behind it, e.g.
// the titlebar's robijn fill).
styles: [
`
nav {
background: none;
}
`,
],
template: `
<nav i18n-aria-label="@@breadcrumb.aria" aria-label="Kruimelpad">
<span class="visually-hidden" i18n="@@breadcrumb.hier">U bevindt zich hier:</span>
@for (item of items(); track item.label; let last = $last) {
@if (item.link && !last) {
<a [routerLink]="item.link">{{ item.label }}</a>
} @else {
<span aria-current="page">{{ item.label }}</span>
}
}
</nav>
`,
})
export class BreadcrumbComponent {
items = input.required<BreadcrumbItem[]>();
}
@@ -0,0 +1,35 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideRouter } from '@angular/router';
import { BreadcrumbComponent } from './breadcrumb.component';
const meta: Meta<BreadcrumbComponent> = {
title: 'Design System/Molecules/Breadcrumb',
component: BreadcrumbComponent,
decorators: [applicationConfig({ providers: [provideRouter([])] })],
render: (args) => ({
props: args,
// Rendered inside a mock .titlebar .title so the story reflects the real chrome.
template: `<div class="titlebar" style="padding: 1rem"><div class="title"><app-breadcrumb [items]="items" /></div></div>`,
}),
};
export default meta;
type Story = StoryObj<BreadcrumbComponent>;
export const TweeNiveaus: Story = {
args: {
items: [
{ label: 'Mijn omgeving', link: '/dashboard' },
{ label: 'Inschrijven in het BIG-register' },
],
},
};
export const DrieNiveaus: Story = {
args: {
items: [
{ label: 'Mijn omgeving', link: '/dashboard' },
{ label: 'Registratie', link: '/registratie' },
{ label: 'Inschrijven' },
],
},
};
@@ -0,0 +1,97 @@
import { Component, computed, inject, input } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router } from '@angular/router';
import { EMPTY, filter } from 'rxjs';
import { Locale, localeLinks } from './locale-links';
// CIBG-GAP EXTENSION: "Taal instellen" (designsystem.cibg.nl/componenten/taal-instellen) — no
// vendored Huisstijl class ships for it, so this is a small hand-rolled surface built from the
// token bridge. See cibg-gaps.mdx.
/**
* Organism: CIBG "Taal instellen" language switcher. A `<nav>` region (screenreader heading +
* aria-label) with one link per locale the endonym, tagged with its `lang`/`hreflang`, the
* active one marked `aria-current` and rendered as text (not a link).
*
* Compile-time $localize means each locale is a separate bundle under `/<locale>/`, so switching
* is a plain full-page navigation to the sibling bundle (not a runtime toggle). The active locale
* is read from the baked `<base href>` (`/en/` en, else nl) the deployment truth, independent
* of the app-config `LOCALE_ID`. Only functional where both locale bundles are served (the
* localized build, e.g. `npm run serve:i18n`), not under plain `ng serve` (nl-only at `/`).
*
* The shell (and this switcher within it) is a persistent parent only the routed child
* swaps so `location.pathname` must be re-read on every completed navigation (same
* `toSignal(router.events...)` idiom as `site-header.component.ts`'s breadcrumb `url`), or the
* target link freezes at whichever route was active when the switcher was first constructed.
*/
@Component({
selector: 'app-language-switcher',
styles: [
`
nav {
display: flex;
justify-content: flex-end;
gap: var(--rhc-space-max-md);
padding: var(--rhc-space-max-sm) var(--rhc-space-max-2xl);
font-size: var(--rhc-text-font-size-sm);
}
a {
color: var(--rhc-color-hemelblauw-700);
}
[aria-current] {
font-weight: var(--rhc-text-font-weight-semi-bold);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
`,
],
template: `
<nav [attr.aria-label]="navLabel">
<h2 class="sr-only">{{ heading }}</h2>
@for (l of links(); track l.locale) {
@if (l.active) {
<span [attr.lang]="l.locale" aria-current="true">{{ l.label }}</span>
} @else {
<a [attr.lang]="l.locale" [attr.hreflang]="l.locale" [href]="l.href">{{ l.label }}</a>
}
}
</nav>
`,
})
export class LanguageSwitcherComponent {
/** Override the detected locale (stories/tests); the app detects it from the base href. */
activeLocale = input<Locale | undefined>(undefined);
private readonly detected: Locale = /\/en\//.test(document.baseURI) ? 'en' : 'nl';
private readonly loc =
typeof location !== 'undefined'
? location
: ({ pathname: '/', search: '', hash: '' } as Location);
private router = inject(Router, { optional: true });
private nav = toSignal(
this.router?.events.pipe(filter((e) => e instanceof NavigationEnd)) ?? EMPTY,
{ initialValue: null },
);
protected links = computed(() => {
this.nav(); // recompute on every completed navigation — loc.pathname is read fresh below
return localeLinks(
this.loc.pathname,
this.activeLocale() ?? this.detected,
this.loc.search,
this.loc.hash,
);
});
protected navLabel = $localize`:@@lang.navLabel:Taal / Language`;
protected heading = $localize`:@@lang.heading:Kies een taal`;
}
@@ -0,0 +1,15 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { LanguageSwitcherComponent } from './language-switcher.component';
const meta: Meta<LanguageSwitcherComponent> = {
title: 'Design System/Organisms/Language Switcher',
component: LanguageSwitcherComponent,
};
export default meta;
type Story = StoryObj<LanguageSwitcherComponent>;
/** Dutch active (the source locale). */
export const NederlandsActive: Story = { args: { activeLocale: 'nl' } };
/** English active. */
export const EnglishActive: Story = { args: { activeLocale: 'en' } };
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { localeLinks } from './locale-links';
describe('localeLinks (nl at root, en under /en/)', () => {
it('an nl route (no prefix) links nl to the bare path, en under /en, marks active', () => {
const links = localeLinks('/dashboard', 'nl');
expect(links.map((l) => [l.locale, l.href, l.active])).toEqual([
['nl', '/dashboard', true],
['en', '/en/dashboard', false],
]);
});
it('an en route strips the /en prefix for the nl target (deep path, en active)', () => {
const links = localeLinks('/en/beheer/audit', 'en');
expect(links.find((l) => l.locale === 'nl')!.href).toBe('/beheer/audit');
expect(links.find((l) => l.locale === 'en')!.active).toBe(true);
});
it('keeps query + hash on both targets', () => {
const links = localeLinks('/registreren', 'nl', '?scenario=slow', '#top');
expect(links.find((l) => l.locale === 'nl')!.href).toBe('/registreren?scenario=slow#top');
expect(links.find((l) => l.locale === 'en')!.href).toBe('/en/registreren?scenario=slow#top');
});
it('the root maps nl → / and en → /en/', () => {
const links = localeLinks('/', 'nl');
expect(links.find((l) => l.locale === 'nl')!.href).toBe('/');
expect(links.find((l) => l.locale === 'en')!.href).toBe('/en/');
});
});
@@ -0,0 +1,39 @@
/** The app's two locales (Angular $localize: source `nl` + translation `en`). */
export type Locale = 'nl' | 'en';
export interface LocaleLink {
readonly locale: Locale;
/** Endonym — each language named in its own language (CIBG "Taal instellen"), not a code. */
readonly label: string;
/** Absolute path into the other locale's bundle, preserving the current route. */
readonly href: string;
readonly active: boolean;
}
const LOCALES: readonly { locale: Locale; label: string }[] = [
{ locale: 'nl', label: 'Nederlands' },
{ locale: 'en', label: 'English' },
];
/**
* Build the two language links for the switcher. Compile-time i18n serves the source locale
* (nl) at the ROOT (`subPath: ''`) and en under `/en/`, so switching is a full navigation to the
* sibling bundle at the same route. Strips a leading `/en` from the current path, then targets nl
* at the bare path and en under `/en`. Keeps query + hash. Pure no DOM (the component passes
* `location.*` in).
*/
export function localeLinks(
pathname: string,
active: Locale,
search = '',
hash = '',
): LocaleLink[] {
const rest = pathname.replace(/^\/en(?=\/|$)/, '') || '/';
const href = (locale: Locale) => `${locale === 'en' ? `/en${rest}` : rest}${search}${hash}`;
return LOCALES.map(({ locale, label }) => ({
locale,
label,
href: href(locale),
active: locale === active,
}));
}
@@ -0,0 +1,51 @@
import { Component, input } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { LinkComponent } from '@shared/ui/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
it's not repeated here. Rendered inside the persistent ShellComponent via the
router outlet, so it owns only the content (not chrome). */
@Component({
selector: 'app-page-shell',
imports: [HeadingComponent, LinkComponent],
styles: [
`
:host {
display: block;
}
.body--narrow {
max-inline-size: var(--app-form-narrow);
}
.back {
margin: 0 0 var(--rhc-space-max-lg);
}
.intro {
margin-block: var(--rhc-space-max-md) var(--rhc-space-max-2xl);
max-inline-size: 42rem;
color: var(--rhc-color-foreground-subtle);
}
`,
],
template: `
<div [class.body--narrow]="width() === 'narrow'">
@if (backLink()) {
<p class="back">
<app-link [to]="backLink()!"> {{ backLabel() }}</app-link>
</p>
}
<app-heading [level]="1">{{ heading() }}</app-heading>
@if (intro()) {
<p class="intro">{{ intro() }}</p>
}
<ng-content />
</div>
`,
})
export class PageShellComponent {
heading = input.required<string>();
intro = input<string>();
backLink = input<string>();
backLabel = input($localize`:@@pageShell.backLabel:Terug naar overzicht`);
width = input<'default' | 'narrow'>('default');
}
@@ -0,0 +1,34 @@
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';
const meta: Meta<PageShellComponent> = {
title: 'Design System/Templates/PageShell',
component: PageShellComponent,
decorators: [
applicationConfig({ providers: [provideRouter([])] }),
moduleMetadata({ imports: [ButtonComponent] }),
],
render: (args) => ({
props: args,
template: `
<app-page-shell [heading]="heading" [intro]="intro" [backLink]="backLink" [width]="width">
<p class="rhc-paragraph">Pagina-inhoud wordt hier geprojecteerd.</p>
<app-button variant="primary">Een actie</app-button>
</app-page-shell>`,
}),
};
export default meta;
type Story = StoryObj<PageShellComponent>;
export const Default: Story = {
args: { heading: 'Mijn BIG-registratie', intro: 'Overzicht van uw registratie.' },
};
export const WithBackLink: Story = {
args: { heading: 'Mijn gegevens', backLink: '/dashboard' },
};
export const Narrow: Story = {
args: { heading: 'Inloggen', width: 'narrow', intro: 'Log in op uw omgeving.' },
};
+45
View File
@@ -0,0 +1,45 @@
import {
DOCUMENT,
ENVIRONMENT_INITIALIZER,
EnvironmentInjector,
afterNextRender,
inject,
} from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
/** Template-layer wiring (not a component): on every route change after the
initial load, moves focus to the new page's `<h1>` (page-shell always
renders one) so screen-reader/keyboard users land on the new content
instead of wherever focus happened to be. Falls back to `#main` (the
shell's landmark) if a page has no heading. Deferred via `afterNextRender`
so it doesn't race Angular's view-transition DOM swap. */
export function provideRouteFocus() {
return {
provide: ENVIRONMENT_INITIALIZER,
multi: true,
useValue: () => {
const router = inject(Router);
const document = inject(DOCUMENT);
const injector = inject(EnvironmentInjector);
let isInitialLoad = true;
router.events.subscribe((event) => {
if (!(event instanceof NavigationEnd)) return;
if (isInitialLoad) {
isInitialLoad = false;
return;
}
afterNextRender(
() => {
const target =
document.querySelector<HTMLElement>('#main h1') ?? document.getElementById('main');
if (!target) return;
target.setAttribute('tabindex', '-1');
target.focus({ preventScroll: true });
},
{ injector },
);
});
},
};
}
@@ -0,0 +1,81 @@
import { Component, InjectionToken, Type, inject, isDevMode } from '@angular/core';
import { NgComponentOutlet } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import { SiteHeaderComponent } from '@shared/layout/site-header/site-header.component';
import { SiteFooterComponent } from '@shared/layout/site-footer/site-footer.component';
import { LanguageSwitcherComponent } from '@shared/layout/language-switcher/language-switcher.component';
/** Each app may register its own dev-only "show the Model" panel component here (it's
inherently app-specific it inspects that app's own root stores). No provider
no panel, which is exactly today's behaviour for an app that never had one. */
export const DEBUG_PANEL = new InjectionToken<Type<unknown> | null>('DEBUG_PANEL', {
factory: () => null,
});
/** Template: persistent app chrome. Header + footer mount once; only the routed
content inside <router-outlet> changes (and cross-fades see styles.scss). */
@Component({
selector: 'app-shell',
imports: [
RouterOutlet,
SiteHeaderComponent,
SiteFooterComponent,
LanguageSwitcherComponent,
NgComponentOutlet,
],
styles: [
`
:host {
display: block;
}
.skip {
position: absolute;
left: var(--app-skip-link-offset);
z-index: 1030;
}
.skip:focus {
left: var(--rhc-space-max-md);
top: var(--rhc-space-max-md);
background: var(--rhc-color-wit);
padding: var(--rhc-space-max-sm) var(--rhc-space-max-md);
border-radius: var(--rhc-border-radius-sm);
}
.layout {
display: flex;
flex-direction: column;
min-block-size: 100vh;
}
.main {
flex: 1;
inline-size: 100%;
box-sizing: border-box;
}
.content {
max-inline-size: var(--app-content-max);
margin-inline: auto;
padding: var(--rhc-space-max-3xl) var(--rhc-space-max-2xl);
box-sizing: border-box;
}
`,
],
template: `
<a href="#main" class="skip" i18n="@@shell.skipLink">Naar de inhoud</a>
<app-language-switcher />
<div class="layout">
<app-site-header />
<main id="main" class="main">
<div class="content">
<router-outlet />
</div>
</main>
<app-site-footer />
</div>
@if (isDev && debugPanel) {
<ng-container *ngComponentOutlet="debugPanel" />
}
`,
})
export class ShellComponent {
protected readonly isDev = isDevMode();
protected readonly debugPanel = inject(DEBUG_PANEL);
}
@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideRouter } from '@angular/router';
import { AccessStore } from '@shared/application/access.store';
import { FeatureFlagStore } from '@shared/application/feature-flags.store';
import { ShellComponent } from './shell.component';
const meta: Meta<ShellComponent> = {
title: 'Design System/Templates/Shell',
component: ShellComponent,
// The persistent header injects AccessStore (for its capability-gated admin links) and
// FeatureFlagStore (WP-47, for the Inschrijven nav gate); stub both so the story needs no
// HTTP/ApiClient. `can` false → no admin links; `enabled` true → Inschrijven stays visible.
decorators: [
applicationConfig({
providers: [
provideRouter([]),
{ provide: AccessStore, useValue: { can: () => false } },
{ provide: FeatureFlagStore, useValue: { enabled: () => true } },
],
}),
],
};
export default meta;
type Story = StoryObj<ShellComponent>;
// No route matches, so <router-outlet> renders nothing — this story is about the
// persistent chrome (skip-link, header, footer), not routed page content.
export const Default: Story = {};
@@ -0,0 +1,127 @@
import { Component } from '@angular/core';
/** Organism: Rijksoverheid-style site footer dark-blue, with the
"De Rijksoverheid. Voor Nederland." tagline, responsible-ministry attribution,
and a small "Over deze site" link column. ponytail: links point at the real
rijksoverheid.nl pages, not a fabricated dead-link forest. */
@Component({
selector: 'app-site-footer',
styles: [
`
:host {
display: block;
}
.bar {
background: var(--rhc-color-layout);
color: var(--rhc-color-foreground-on-primary);
margin-block-start: var(--rhc-space-max-5xl);
inline-size: 100%;
}
.inner {
max-inline-size: var(--app-content-max);
margin-inline: auto;
padding: var(--rhc-space-max-3xl) var(--rhc-space-max-2xl);
box-sizing: border-box;
display: flex;
gap: var(--rhc-space-max-3xl);
flex-wrap: wrap;
justify-content: space-between;
}
.tagline {
font-style: italic;
font-weight: var(--rhc-text-font-weight-semi-bold);
font-size: var(--rhc-text-font-size-lg);
max-inline-size: 18rem;
}
.ministry {
margin-block-start: var(--rhc-space-max-md);
font-size: var(--rhc-text-font-size-sm);
color: var(--rhc-color-foreground-on-primary);
}
/* CIBG's vendored h2 tag rule (dark navy, for light backgrounds) beats inherited
color regardless of specificity restate on-primary explicitly for this dark bar. */
.col h2 {
margin: 0 0 var(--rhc-space-max-md);
font-size: var(--rhc-text-font-size-sm);
font-weight: var(--rhc-text-font-weight-bold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--rhc-color-foreground-on-primary);
}
.links {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--rhc-space-max-sm);
font-size: var(--rhc-text-font-size-sm);
}
.links a {
color: var(--rhc-color-foreground-on-primary);
text-decoration: none;
}
.links a:hover {
text-decoration: underline;
}
/* CIBG's vendored .meta class (unrelated component, coincidental name) sets a
dark grey override rather than rename to keep the CIBG-mirroring class name. */
.meta {
inline-size: 100%;
border-block-start: var(--rhc-border-width-sm) solid
color-mix(in srgb, var(--rhc-color-foreground-on-primary) 25%, transparent);
margin-block-start: var(--rhc-space-max-xl);
padding-block-start: var(--rhc-space-max-lg);
font-size: var(--rhc-text-font-size-sm);
opacity: 0.85;
color: var(--rhc-color-foreground-on-primary);
}
`,
],
template: `
<footer class="bar">
<div class="inner">
<div>
<div class="tagline" i18n="@@footer.tagline">De Rijksoverheid. Voor Nederland.</div>
<div class="ministry" i18n="@@footer.ministry">
CIBG Ministerie van Volksgezondheid, Welzijn en Sport
</div>
</div>
<nav class="col" i18n-aria-label="@@footer.overSiteAria" aria-label="Over deze site">
<h2 i18n="@@footer.overSite">Over deze site</h2>
<ul class="links">
<li>
<a
href="https://www.rijksoverheid.nl/privacy"
rel="noopener"
target="_blank"
i18n="@@footer.privacy"
>Privacy</a
>
</li>
<li>
<a
href="https://www.rijksoverheid.nl/cookies"
rel="noopener"
target="_blank"
i18n="@@footer.cookies"
>Cookies</a
>
</li>
<li>
<a
href="https://www.rijksoverheid.nl/toegankelijkheid"
rel="noopener"
target="_blank"
i18n="@@footer.toegankelijkheid"
>Toegankelijkheid</a
>
</li>
</ul>
</nav>
<div class="meta" i18n="@@footer.demo">Demo / POC geen echte gegevens.</div>
</div>
</footer>
`,
})
export class SiteFooterComponent {}
@@ -0,0 +1,12 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { SiteFooterComponent } from './site-footer.component';
const meta: Meta<SiteFooterComponent> = {
title: 'Design System/Organisms/Site Footer',
component: SiteFooterComponent,
render: () => ({ template: `<app-site-footer />` }),
};
export default meta;
type Story = StoryObj<SiteFooterComponent>;
export const Default: Story = {};
@@ -0,0 +1,33 @@
import { InjectionToken } from '@angular/core';
import { Capability } from '@shared/domain/capability';
export interface HeaderNavItem {
readonly label: string;
readonly to: string;
/** Hidden when this feature flag is off (e.g. WP-47's Inschrijven gate). Omit for an
always-visible item. */
readonly flag?: string;
}
/** One admin page: its label, a short description, its route, and the capability that
gates it. Consumed by the site header's admin nav AND (per app) a dashboard's own
Beheer section, both filtered by `AccessStore.can`. Capability-gated, never
role-derived (PRD-0002 §6): the FE only mirrors server-resolved capabilities. */
export interface AdminLink {
readonly label: string;
readonly description: string;
readonly to: string;
readonly cap: Capability;
}
/** Each app supplies its own primary nav the set of top-level routes differs per app
(e.g. the SSP's "Herregistratie"/"Inschrijven" vs. behandelportal's own). */
export const HEADER_NAV_ITEMS = new InjectionToken<readonly HeaderNavItem[]>('HEADER_NAV_ITEMS', {
factory: () => [],
});
/** Each app supplies its own admin links which admin pages exist differs per app
(e.g. only the SSP has a brief/huisstijl editor). */
export const HEADER_ADMIN_LINKS = new InjectionToken<readonly AdminLink[]>('HEADER_ADMIN_LINKS', {
factory: () => [],
});
@@ -0,0 +1,127 @@
import { Component, computed, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router, RouterLink, RouterLinkActive } from '@angular/router';
import { filter, map } from 'rxjs/operators';
import { SESSION_PORT } from '@shared/application/session.port';
import { AccessStore } from '@shared/application/access.store';
import { FeatureFlagStore } from '@shared/application/feature-flags.store';
import { BreadcrumbComponent } from '@shared/layout/breadcrumb/breadcrumb.component';
import { trailFor } from '@shared/layout/breadcrumb/breadcrumb-trail';
import { HEADER_ADMIN_LINKS, HEADER_NAV_ITEMS } from './nav-config';
/** Organism: CIBG Huisstijl site header logo block, robijn titlebar (breadcrumb +
user menu), horizontal nav. ponytail: text wordmark, not the licensed Rijksoverheid
beeldmerk; no search box (no search feature yet). */
@Component({
selector: 'app-site-header',
imports: [RouterLink, RouterLinkActive, BreadcrumbComponent],
styles: [
`
.logout {
background: none;
border: 0;
padding: 0;
cursor: pointer;
text-decoration: underline;
font: inherit;
color: inherit;
}
/* CIBG's header nav has no bg by default in this build the grey bar is ours.
(.titlebar keeps its own robijn fill --ro-layout untouched; the breadcrumb
inside it has no background of its own, so the bar's colour shows through.) */
nav {
background-color: var(--rhc-color-cool-grey-200);
}
`,
],
template: `
<header>
<div class="logo">
<div class="logo__wrapper">
<a routerLink="/dashboard" class="logo__link">
<figure class="logo__figure">
<figcaption class="logo__text">
<span class="logo__sender" i18n="@@header.sender">BIG-register</span>
<span class="logo__ministry" i18n="@@header.ministry"
>Ministerie van Volksgezondheid, Welzijn en Sport</span
>
</figcaption>
</figure>
</a>
</div>
</div>
<div class="titlebar">
<div class="container">
<div class="row">
<div class="title col-md-7">
@if (trail().length) {
<app-breadcrumb [items]="trail()" />
}
</div>
<div class="user-menu col-md-5">
@if (session(); as s) {
<div>
<span class="login-name">{{ s.naam }}</span>
</div>
<div>
<button type="button" class="logout" (click)="logout()" i18n="@@header.uitloggen">
Uitloggen
</button>
</div>
}
</div>
</div>
</div>
</div>
<nav i18n-aria-label="@@header.navAria" aria-label="Hoofdnavigatie">
<div class="container">
<ul>
@for (item of navItems(); track item.to) {
<li routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">
<a [routerLink]="item.to">{{ item.label }}</a>
</li>
}
@for (item of adminItems(); track item.to) {
<li routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">
<a [routerLink]="item.to">{{ item.label }}</a>
</li>
}
</ul>
</div>
</nav>
</header>
`,
})
export class SiteHeaderComponent {
private access = inject(AccessStore);
private flags = inject(FeatureFlagStore);
private rawNavItems = inject(HEADER_NAV_ITEMS);
private rawAdminLinks = inject(HEADER_ADMIN_LINKS);
/** Hides an item whose `flag` is off (e.g. the SSP's Inschrijven gate, WP-47) which
items exist, and which carry a flag, is entirely up to the app that provided them. */
protected readonly navItems = computed(() =>
this.rawNavItems.filter((i) => !i.flag || this.flags.enabled(i.flag)),
);
private router = inject(Router);
private sessionPort = inject(SESSION_PORT, { optional: true });
/** Injecting AccessStore here also warms `/me` at app start (the header renders on
every page), so the admin routes' guard usually finds caps already resolved. */
protected adminItems = computed(() => this.rawAdminLinks.filter((i) => this.access.can(i.cap)));
readonly session = computed(() => this.sessionPort?.session() ?? null);
private url = toSignal(
this.router.events.pipe(
filter((e) => e instanceof NavigationEnd),
map(() => this.router.url),
),
{ initialValue: this.router.url },
);
protected trail = computed(() => trailFor(this.url()));
logout() {
this.sessionPort?.logout();
this.router.navigate(['/login']);
}
}
@@ -0,0 +1,57 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideRouter } from '@angular/router';
import { AccessStore } from '@shared/application/access.store';
import { FeatureFlagStore } from '@shared/application/feature-flags.store';
import { Capability } from '@shared/domain/capability';
import { HEADER_ADMIN_LINKS, HEADER_NAV_ITEMS } from './nav-config';
import { SiteHeaderComponent } from './site-header.component';
// The header injects AccessStore for the capability-gated admin links and FeatureFlagStore
// (WP-47, for the Inschrijven nav gate); stub both so the story needs no HTTP/ApiClient.
// `can` decides which admin links appear; `enabled` true keeps Inschrijven visible. Nav/admin
// links are app-provided (HEADER_NAV_ITEMS/HEADER_ADMIN_LINKS) — this story supplies a
// representative sample rather than importing a real app's config, keeping the story
// decoupled from any one app.
const withCaps = (caps: Capability[]) =>
applicationConfig({
providers: [
provideRouter([]),
{ provide: AccessStore, useValue: { can: (c: Capability) => caps.includes(c) } },
{ provide: FeatureFlagStore, useValue: { enabled: () => true } },
{
provide: HEADER_NAV_ITEMS,
useValue: [
{ label: 'Overzicht', to: '/dashboard' },
{ label: 'Mijn gegevens', to: '/registratie' },
],
},
{
provide: HEADER_ADMIN_LINKS,
useValue: [
{ label: 'Huisstijl', description: '', to: '/brief/huisstijl', cap: 'orgtemplate:edit' },
{ label: 'Stamdata', description: '', to: '/beheer/stamdata', cap: 'stamdata:edit' },
],
},
],
});
const meta: Meta<SiteHeaderComponent> = {
title: 'Design System/Organisms/Site Header',
component: SiteHeaderComponent,
decorators: [withCaps([])],
render: (args) => ({
props: args,
template: `<app-site-header />`,
}),
};
export default meta;
type Story = StoryObj<SiteHeaderComponent>;
/** Standard user — no admin links. */
export const Default: Story = {};
/** Admin — the capability-gated Huisstijl + Stamdata links appear. */
export const AsAdmin: Story = {
decorators: [withCaps(['orgtemplate:edit', 'stamdata:edit'])],
};
@@ -0,0 +1,205 @@
import { Component, ElementRef, effect, input, output, untracked, 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';
/** CIBG procesnavigatie primary-button copy for a non-final step: "Naar stap 2 - Werk".
Shared so every wizard's `primaryLabel` reads the same way. */
export const naarStapLabel = (stepNumber: number, stepLabel: string) =>
$localize`:@@wizard.naarStap:Naar stap ${stepNumber}:nummer: - ${stepLabel}:label:`;
/** A flat validation error pointing at a field: `id` matches the field's anchor. */
export interface WizardError {
readonly id: string;
readonly message: string;
}
export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
/**
* Template: the canonical shell every wizard renders into, so they cannot drift.
* It owns the consistent outline CIBG stappenindicator (title merged in) + error
* summary + the horizontal <form> + the CIBG procesnavigatie button row + the
* submitting/submitted/failed states and the a11y focus management.
*
* Presentational and unidirectional: all state stays in the wizard container
* (the Elm-style store). Inputs flow down; the container reacts to the outputs
* and dispatches messages. The step's own fields are projected as the default
* slot; the success screen is projected via [wizardSuccess].
*/
@Component({
selector: 'app-wizard-shell',
imports: [FormsModule, ButtonComponent, AlertComponent, SpinnerComponent, StepperComponent],
// CIBG-GAP EXTENSION: Foutmelding — the vendored build has no error-summary/
// Veldvalidatie list pattern (verified absent from huisstijl.min.css); the
// .es-title/.es-list rules below are the hand-rolled surface, see cibg-gaps.mdx.
// They render inside a vendored `.feedback-error` alert (app-alert).
styles: [
`
.es-title {
margin: 0 0 var(--rhc-space-max-sm);
}
.es-list {
margin: 0;
padding-inline-start: var(--rhc-space-max-xl);
}
/* Default link color doesn't meet contrast on the error-alert's light-red surface. */
.es-list a {
color: var(--rhc-color-lintblauw-700);
}
`,
],
template: `
@switch (status()) {
@case ('editing') {
<app-stepper
class="app-section"
[steps]="steps()"
[current]="current()"
[processName]="processName()"
[stepTitle]="stepTitle()"
(stepSelected)="goToStep.emit($event)"
/>
@if (errors().length) {
<div
#errorSummary
tabindex="-1"
role="alert"
aria-labelledby="wizard-error-title"
class="app-section"
>
<app-alert type="error">
<h3 id="wizard-error-title" class="es-title" i18n="@@wizard.errorTitle">
Er ging iets mis met uw invoer
</h3>
<ul class="es-list">
@for (e of errors(); track e.id) {
<li>
<a [href]="'#' + e.id" (click)="goToField($event, e.id)">{{ e.message }}</a>
</li>
}
</ul>
</app-alert>
</div>
}
<form (ngSubmit)="primary.emit()" class="form-horizontal app-section">
<div class="form-header">
<div class="form-action">
<span class="meta" i18n="@@form.verplichteVelden">* verplichte velden</span>
</div>
</div>
<!-- Wizard pages wrap their field groups in <fieldset>s; CIBG's
".form-horizontal fieldset" gives each a grey #f1f5f9 surface with a 1.25em token-ok: hex named in prose, not a style value
gap. The shell stays group-agnostic and does NOT add its own fieldset (an
outer grey fieldset would hide the white gaps between the page groups). -->
<ng-content />
<hr />
<div class="d-flex flex-column flex-sm-row-reverse">
<div class="m-0">
<app-button type="submit" variant="primary">{{ primaryLabel() }}</app-button>
</div>
@if (canGoBack()) {
<app-button
type="button"
variant="subtle"
class="me-auto"
(click)="back.emit()"
i18n="@@wizard.terugVorige"
>Terug naar vorige stap</app-button
>
}
</div>
<div class="app-section">
<app-button
type="button"
variant="subtle"
(click)="cancel.emit()"
i18n="@@wizard.annuleren"
>Annuleren</app-button
>
</div>
</form>
}
@case ('submitting') {
<app-spinner /> <span>{{ submittingLabel() }}</span>
}
@case ('submitted') {
<ng-content select="[wizardSuccess]" />
}
@case ('failed') {
<app-alert type="error">{{ errorMessage() }}</app-alert>
<div class="app-section">
<app-button variant="secondary" (click)="retry.emit()" i18n="@@wizard.opnieuwProberen"
>Opnieuw proberen</app-button
>
</div>
}
}
`,
})
export class WizardShellComponent {
steps = input.required<string[]>();
current = input.required<number>();
stepTitle = input.required<string>();
/** Overall process name, shown above the step title (e.g. "Herregistratie aanvragen"). */
processName = input('');
status = input.required<WizardStatus>();
primaryLabel = input.required<string>();
canGoBack = input(false);
errors = input<readonly WizardError[]>([]);
errorMessage = input('');
submittingLabel = input($localize`:@@wizard.submitting:Aanvraag wordt verwerkt…`);
primary = output<void>();
back = output<void>();
cancel = output<void>();
retry = output<void>();
/** A visited step number was clicked in the stepper — back-navigation only. */
goToStep = output<number>();
/** Error-summary link: focus the field instead of letting the browser navigate.
A fragment href resolves against <base href="/">, not the current route, so
a real navigation would reload to "/" and bounce to login. */
protected goToField(ev: Event, id: string) {
ev.preventDefault();
document.getElementById(id)?.focus(); // focus() scrolls the input into view
}
private stepper = viewChild(StepperComponent);
private errorSummary = viewChild<ElementRef<HTMLElement>>('errorSummary');
constructor() {
// A11y: move focus to the step title when the step changes (skip first run
// so we don't grab focus on initial load). Tracks current(), which is value-
// stable across keystrokes, so typing never steals focus.
let firstStep = true;
effect(() => {
this.current();
if (firstStep) {
firstStep = false;
return;
}
untracked(() => queueMicrotask(() => this.stepper()?.focusTitle()));
});
// A11y: when validation errors first appear (after a failed submit), move
// focus to the error summary so it's announced. Only on the rising edge
// (none → some): typing rebuilds the errors array each keystroke, and
// re-focusing then would scroll the page up mid-edit. The summary keeps
// role="alert", so content changes are still announced without the jump.
let firstErr = true;
let hadErrors = false;
effect(() => {
const has = this.errors().length > 0;
if (firstErr) {
firstErr = false;
hadErrors = has;
return;
}
if (has && !hadErrors)
untracked(() => queueMicrotask(() => this.errorSummary()?.nativeElement.focus()));
hadErrors = has;
});
}
}
@@ -0,0 +1,58 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { WizardShellComponent } from './wizard-shell.component';
const meta: Meta<WizardShellComponent> = {
title: 'Design System/Templates/WizardShell',
component: WizardShellComponent,
render: (args) => ({
props: args,
template: `
<app-wizard-shell
[steps]="steps" [current]="current" [stepTitle]="stepTitle" [processName]="processName" [status]="status"
[primaryLabel]="primaryLabel" [canGoBack]="canGoBack" [errors]="errors" [errorMessage]="errorMessage"
(goToStep)="goToStep($event)">
<p class="rhc-paragraph">Voorbeeld-stapinhoud (de stapvelden worden hier geprojecteerd).</p>
<div wizardSuccess><p class="rhc-paragraph">Uw aanvraag is ontvangen.</p></div>
</app-wizard-shell>`,
}),
parameters: {
cibgGap: true,
docs: {
description: {
component: 'CIBG-gap extension (error summary only) — see Foundations/CIBG Gap Register.',
},
},
},
};
export default meta;
type Story = StoryObj<WizardShellComponent>;
const steps = ['Adres', 'Beroep', 'Controle'];
const base = {
steps,
current: 1,
stepTitle: 'Beroep op basis van uw diploma',
processName: 'Inschrijven in het BIG-register',
primaryLabel: 'Volgende',
canGoBack: true,
errors: [],
errorMessage: '',
goToStep: () => {},
};
export const Editing: Story = { args: { ...base, status: 'editing' } };
export const EditingMetFouten: Story = {
args: {
...base,
status: 'editing',
errors: [
{ id: 'uren', message: 'Vul het aantal gewerkte uren in.' },
{ id: 'diploma', message: 'Kies een diploma.' },
],
},
};
export const Submitting: Story = { args: { ...base, status: 'submitting' } };
export const Submitted: Story = { args: { ...base, status: 'submitted' } };
export const Failed: Story = {
args: { ...base, status: 'failed', errorMessage: 'Het indienen is niet gelukt: netwerkfout.' },
};
+5
View File
@@ -0,0 +1,5 @@
// ponytail: Angular's unit-test builder always resolves a `build` target for compiler
// options, even for a project that's only ever tested, never served — there's no way to
// opt out. This is that target's sole purpose; nothing imports it. Upgrade to a real
// ng-packagr library target if this lib is ever actually built/published.
export {};
@@ -0,0 +1,50 @@
import { Component, input } from '@angular/core';
type AlertType = 'info' | 'ok' | 'warning' | 'error';
// visually-hidden alternative for the status icon (CIBG a11y requirement).
const ICON_LABELS: Record<AlertType, string> = {
info: $localize`:@@alert.icon.info:Informatie`,
ok: $localize`:@@alert.icon.ok:Gelukt`,
warning: $localize`:@@alert.icon.warning:Waarschuwing`,
error: $localize`:@@alert.icon.error:Foutmelding`,
};
/** Atom: alert/message banner the CIBG Huisstijl "melding"
(designsystem.cibg.nl/componenten/meldingen). Thin wrapper over the vendored
`.feedback feedback-*` classes: the design system owns surface + icon; we add
only the icon's a11y label and a content wrapper (`.feedback` is a flex row).
Errors are `role="alert"` (assertive interrupts) since they need immediate
attention; other variants stay `role="status"` (polite) so success/info banners
don't interrupt what the user is doing. */
@Component({
selector: 'app-alert',
styles: [
`
.feedback > div {
flex: 1 1 auto;
min-width: 0;
}
`,
],
template: `
<div
class="feedback"
[class.feedback-info]="type() === 'info'"
[class.feedback-success]="type() === 'ok'"
[class.feedback-warning]="type() === 'warning'"
[class.feedback-error]="type() === 'error'"
[attr.role]="type() === 'error' ? 'alert' : 'status'"
aria-atomic="true"
>
<span class="icon"
><span class="visually-hidden">{{ iconLabels[type()] }}</span></span
>
<div><ng-content /></div>
</div>
`,
})
export class AlertComponent {
type = input<AlertType>('info');
protected readonly iconLabels = ICON_LABELS;
}
+40
View File
@@ -0,0 +1,40 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { expect, within } from 'storybook/test';
import { AlertComponent } from './alert.component';
const meta: Meta<AlertComponent> = {
title: 'Design System/Atoms/Alert',
component: AlertComponent,
render: (args) => ({
props: args,
template: `<app-alert [type]="type">Uw wijziging is ontvangen.</app-alert>`,
}),
};
export default meta;
type Story = StoryObj<AlertComponent>;
// role assertions guard the polite/assertive split (WP-16): errors interrupt, others don't.
export const Info: Story = {
args: { type: 'info' },
play: async ({ canvasElement }) => {
await expect(within(canvasElement).getByRole('status')).toBeInTheDocument();
},
};
export const Ok: Story = {
args: { type: 'ok' },
play: async ({ canvasElement }) => {
await expect(within(canvasElement).getByRole('status')).toBeInTheDocument();
},
};
export const Warning: Story = {
args: { type: 'warning' },
play: async ({ canvasElement }) => {
await expect(within(canvasElement).getByRole('status')).toBeInTheDocument();
},
};
export const Error: Story = {
args: { type: 'error' },
play: async ({ canvasElement }) => {
await expect(within(canvasElement).getByRole('alert')).toBeInTheDocument();
},
};
@@ -0,0 +1,84 @@
import { Component, input, output } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { RouterLink } from '@angular/router';
// CIBG-GAP EXTENSION: Aanvragen (non-navigating row) — the vendored
// `.dashboard-block.applications li a` chain only styles `<a>`; `.static-row`
// mirrors it from tokens for the non-navigating case, see cibg-gaps.mdx.
/** Molecule: one row in a CIBG Huisstijl "aanvragen" list
(designsystem.cibg.nl/componenten/aanvragen) a white card-link styled by the
vendored `.dashboard-block.applications li a` chain (bg, chevron, link-blue `h3`),
with an optional `.subtitle`/`.status`/`.cta`. Used on an `<li>` so the `<ul>`'s
direct child is a native `<li>` (keeps the list axe-clean a bare custom element
between `<ul>` and its `<li>` trips axe's list rule regardless of `display:contents`).
A non-navigating row renders a `<div>` (the vendored chain only styles `<a>`, so
that surface is mirrored from tokens). A `[applicationActions]` slot projects a
sibling action after the anchor a button can't nest inside the anchor itself. */
@Component({
selector: 'li[app-application-link]',
imports: [RouterLink, NgTemplateOutlet],
styles: [
`
/* The vendored .applications li a surface only styles <a>; mirror it from tokens
for a non-navigating (informational) row so the card looks consistent. */
.static-row {
display: flex;
background: var(--rhc-color-wit);
border-block-end: 0.065rem solid var(--rhc-color-border-subtle);
padding: 0.75rem 2rem 0.75rem 1rem;
}
.content {
flex: 1 1 auto;
min-inline-size: 0;
}
.cta {
margin-inline-start: auto;
align-self: center;
}
`,
],
template: `
@if (to()) {
<a [routerLink]="to()"><ng-container [ngTemplateOutlet]="body" /></a>
} @else if (clickable()) {
<a href="#" (click)="onActivate($event)"><ng-container [ngTemplateOutlet]="body" /></a>
} @else {
<div class="static-row"><ng-container [ngTemplateOutlet]="body" /></div>
}
<ng-content select="[applicationActions]" />
<ng-template #body>
<div class="content">
<!-- Raw <h3>, not <app-heading>: the vendored ".applications li a h3" chain styles
the bare h3 (link-blue); an app-heading host wrapper would sit between and can
break that selector. Documented in atomic-design.mdx (convergence decisions). -->
<h3 class="h3">{{ heading() }}</h3>
@if (subtitle()) {
<div class="subtitle">{{ subtitle() }}</div>
}
@if (status()) {
<div class="status">{{ status() }}</div>
}
</div>
@if (cta()) {
<div class="cta">{{ cta() }}</div>
}
</ng-template>
`,
})
export class ApplicationLinkComponent {
heading = input.required<string>();
subtitle = input('');
status = input('');
cta = input('');
/** Set for a plain routerLink navigation (e.g. the "Wat wilt u doen?" actions). */
to = input('');
/** Set when the row navigates imperatively (e.g. resume with query params) the
row still renders as a clickable `<a>`, but `activate` decides what happens. */
clickable = input(false);
activate = output<void>();
protected onActivate(ev: Event) {
ev.preventDefault(); // fragment href resolves against <base href>, not the route
this.activate.emit();
}
}
@@ -0,0 +1,40 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideRouter } from '@angular/router';
import { ApplicationLinkComponent } from './application-link.component';
const meta: Meta<ApplicationLinkComponent> = {
title: 'Design System/Molecules/Application Link',
component: ApplicationLinkComponent,
decorators: [applicationConfig({ providers: [provideRouter([])] })],
render: (args) => ({
props: args,
// Rows are <li>s in the "aanvragen" list — a real <ul> gives them their layout.
template: `<div class="dashboard-block applications"><ul class="list-unstyled"><li app-application-link [heading]="heading" [subtitle]="subtitle" [status]="status" [cta]="cta" [to]="to" [clickable]="clickable"></li></ul></div>`,
}),
parameters: {
cibgGap: true,
docs: {
description: {
component:
'CIBG-gap extension (non-navigating row only, see NietInteractief) — see Foundations/CIBG Gap Register.',
},
},
},
};
export default meta;
type Story = StoryObj<ApplicationLinkComponent>;
export const Navigatie: Story = {
args: {
heading: 'Inschrijven',
subtitle: 'Schrijf u in in het BIG-register.',
to: '/registreren',
},
};
export const Actie: Story = {
args: { heading: 'Inschrijving', status: 'Stap 2 van 3', cta: 'Verder gaan', clickable: true },
};
export const NietInteractief: Story = {
args: { heading: 'Herregistratie', status: 'Referentie 2024-00123 · ingediend op 12 mei 2024' },
};
@@ -0,0 +1,17 @@
import { Component } from '@angular/core';
/** Molecule: wraps `<app-application-link>` rows in the CIBG Huisstijl "aanvragen"
dashboard block (`.dashboard-block.applications`) see
designsystem.cibg.nl/componenten/aanvragen. Used for both the "Mijn aanvragen"
list and the "Wat wilt u doen?" action list on the dashboard. */
@Component({
selector: 'app-application-list',
template: `
<div class="dashboard-block applications">
<ul class="list-unstyled">
<ng-content />
</ul>
</div>
`,
})
export class ApplicationListComponent {}
@@ -0,0 +1,26 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig, moduleMetadata } from '@storybook/angular';
import { provideRouter } from '@angular/router';
import { ApplicationListComponent } from './application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component';
const meta: Meta<ApplicationListComponent> = {
title: 'Design System/Molecules/Application List',
component: ApplicationListComponent,
decorators: [
applicationConfig({ providers: [provideRouter([])] }),
moduleMetadata({ imports: [ApplicationLinkComponent] }),
],
render: () => ({
template: `
<app-application-list>
<li app-application-link heading="Herregistratie" subtitle="Verlenging van uw BIG-registratie" status="In behandeling · Referentie 2024-00123 · ingediend op 12 mei 2024" to="/aanvraag/1"></li>
<li app-application-link heading="Inschrijving" subtitle="Inschrijving in het BIG-register" status="Goedgekeurd · Referentie 2024-00088" to="/aanvraag/2"></li>
<li app-application-link heading="Inschrijven" subtitle="Schrijf u in in het BIG-register." cta="Start inschrijving" to="/registreren"></li>
</app-application-list>`,
}),
};
export default meta;
type Story = StoryObj<ApplicationListComponent>;
export const Default: Story = {};
+161
View File
@@ -0,0 +1,161 @@
import {
Component,
Directive,
TemplateRef,
computed,
contentChild,
input,
output,
} from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import type { Resource } from '@angular/core';
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { RemoteData, fromResource, foldRemote } from '@shared/application/remote-data';
/* Slot markers. Put on <ng-template> children of <app-async>. Generic so the
$implicit context is typed as the resource's T instead of unknown see
AsyncComponent's contentChild<AsyncLoadedDirective<T>> below, which threads the
host's own T through the query result type. */
@Directive({ selector: '[appAsyncLoaded]' })
export class AsyncLoadedDirective<T = unknown> {
constructor(public tpl: TemplateRef<{ $implicit: T }>) {}
static ngTemplateContextGuard<T>(
_dir: AsyncLoadedDirective<T>,
_ctx: unknown,
): _ctx is { $implicit: T } {
return true;
}
}
@Directive({ selector: '[appAsyncLoading]' })
export class AsyncLoadingDirective {
constructor(public tpl: TemplateRef<unknown>) {}
}
@Directive({ selector: '[appAsyncEmpty]' })
export class AsyncEmptyDirective {
constructor(public tpl: TemplateRef<unknown>) {}
}
@Directive({ selector: '[appAsyncError]' })
export class AsyncErrorDirective {
constructor(public tpl: TemplateRef<{ $implicit: Error | undefined; retry: () => void }>) {}
}
/**
* Renders exactly ONE of loading / empty / error / loaded for a signal-based
* resource (e.g. httpResource). Built on a RemoteData tagged union (see
* core/remote-data.ts), so the states are mutually exclusive by construction
* the UI can never show two at once ("impossible states"). Unprovided slots
* fall back to sensible defaults.
*/
@Component({
selector: 'app-async',
imports: [NgTemplateOutlet, SpinnerComponent, AlertComponent, ButtonComponent],
template: `
<div aria-live="polite" [attr.aria-busy]="rd().tag === 'Loading' ? 'true' : null">
@switch (rd().tag) {
@case ('Loading') {
@if (loadingTpl()) {
<ng-container [ngTemplateOutlet]="loadingTpl()!.tpl" />
} @else {
<app-spinner />
}
}
@case ('Failure') {
@if (errorTpl()) {
<ng-container
[ngTemplateOutlet]="errorTpl()!.tpl"
[ngTemplateOutletContext]="{ $implicit: error(), retry: retry }"
/>
} @else {
<app-alert type="error">{{ errorText() }}</app-alert>
<div style="margin-top:1rem">
<app-button variant="secondary" (click)="retry()">{{ retryText() }}</app-button>
</div>
}
}
@case ('Empty') {
@if (emptyTpl()) {
<ng-container [ngTemplateOutlet]="emptyTpl()!.tpl" />
} @else {
<p>{{ emptyText() }}</p>
}
}
@case ('Success') {
<ng-container
[ngTemplateOutlet]="loadedTpl().tpl"
[ngTemplateOutletContext]="{ $implicit: value() }"
/>
}
}
</div>
`,
})
export class AsyncComponent<T> {
// Two ways to feed this component:
// [resource] — a raw httpResource (the common case), or
// [data] — an already-combined RemoteData (e.g. from a store via map2).
resource = input<Resource<T>>();
data = input<RemoteData<Error | undefined, T>>();
isEmpty = input<(v: T) => boolean>(() => false);
// Shared/English component: copy lives behind language-agnostic inputs. Defaults are
// localizable via $localize (source = default locale, currently nl); callers may override.
errorText = input($localize`:@@async.error:Er ging iets mis bij het laden van de gegevens.`);
retryText = input($localize`:@@async.retry:Opnieuw proberen`);
emptyText = input($localize`:@@async.empty:Geen gegevens gevonden.`);
loadedTpl = contentChild.required<AsyncLoadedDirective<T>>(AsyncLoadedDirective);
loadingTpl = contentChild(AsyncLoadingDirective);
emptyTpl = contentChild(AsyncEmptyDirective);
errorTpl = contentChild(AsyncErrorDirective);
// Single source of truth: the supplied RemoteData, or the resource projected into one.
protected rd = computed<RemoteData<Error | undefined, T>>(() => {
const data = this.data();
if (data) return data;
const r = this.resource();
return r ? fromResource(r, this.isEmpty()) : { tag: 'Loading' };
});
// value/error are pulled out via the exhaustive fold — only Success carries a
// value, only Failure carries an error, so these can't lie.
protected value = computed(() =>
foldRemote(this.rd(), {
loading: () => undefined,
empty: () => undefined,
failure: () => undefined,
success: (v) => v,
}),
);
protected error = computed(() =>
foldRemote(this.rd(), {
loading: () => undefined,
empty: () => undefined,
failure: (e) => e,
success: () => undefined,
}),
);
// [resource]-fed callers get reload() for free. [data]-fed callers (a store's
// combined RemoteData — the component doesn't own that resource) must reload
// it themselves; retryClicked is how they find out a retry was requested.
retryClicked = output<void>();
retry = () => {
const r = this.resource();
if (r && 'reload' in r && typeof (r as { reload?: unknown }).reload === 'function') {
(r as { reload: () => void }).reload();
}
this.retryClicked.emit();
};
}
/** Convenience: import this array to get the wrapper + all slot directives. */
export const ASYNC = [
AsyncComponent,
AsyncLoadedDirective,
AsyncLoadingDirective,
AsyncEmptyDirective,
AsyncErrorDirective,
] as const;
+47
View File
@@ -0,0 +1,47 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { moduleMetadata } from '@storybook/angular';
import type { Resource } from '@angular/core';
import { ASYNC } from './async.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
/** Minimal fake of a signal Resource so the wrapper can be driven through every
state in isolation (no 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>;
}
const meta: Meta = {
title: 'Design System/Molecules/Async States',
decorators: [moduleMetadata({ imports: [...ASYNC, SkeletonComponent] })],
render: (args) => ({
// isEmpty is a function — Storybook strips function args, so set it here.
props: { ...args, isEmpty: (v: string[]) => !v || v.length === 0 },
template: `
<app-async [resource]="resource" [isEmpty]="isEmpty">
<ng-template appAsyncLoaded let-items>
<ul class="rhc-unordered-list">
@for (i of items; track i) { <li>{{ i }}</li> }
</ul>
</ng-template>
<ng-template appAsyncLoading><app-skeleton [count]="3" height="1.5rem" [delay]="0" /></ng-template>
<ng-template appAsyncEmpty><p class="rhc-paragraph">Geen items gevonden.</p></ng-template>
</app-async>`,
}),
};
export default meta;
type Story = StoryObj;
export const Loaded: Story = {
args: { resource: fakeResource('resolved', ['Huisartsgeneeskunde', 'Spoedeisende hulp']) },
};
export const Loading: Story = { args: { resource: fakeResource('loading') } };
export const Empty: Story = { args: { resource: fakeResource('resolved', [] as string[]) } };
export const ErrorState: Story = {
args: { resource: fakeResource('error', undefined, new Error('Demo')) },
};
@@ -0,0 +1,27 @@
import { Component, input } from '@angular/core';
type Variant = 'primary' | 'secondary' | 'subtle' | 'danger' | 'ghost';
/** Atom: button. Thin wrapper over the CIBG/Bootstrap button CSS. */
@Component({
selector: 'app-button',
template: `
<button
[type]="type()"
[disabled]="disabled()"
class="btn"
[class.btn-primary]="variant() === 'primary'"
[class.btn-secondary]="variant() === 'secondary'"
[class.btn-link]="variant() === 'subtle'"
[class.btn-danger]="variant() === 'danger'"
[class.btn-ghost]="variant() === 'ghost'"
>
<ng-content />
</button>
`,
})
export class ButtonComponent {
variant = input<Variant>('primary');
type = input<'button' | 'submit'>('button');
disabled = input(false);
}
@@ -0,0 +1,20 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { ButtonComponent } from './button.component';
const meta: Meta<ButtonComponent> = {
title: 'Design System/Atoms/Button',
component: ButtonComponent,
render: (args) => ({
props: args,
template: `<app-button [variant]="variant" [type]="type" [disabled]="disabled">Knop</app-button>`,
}),
};
export default meta;
type Story = StoryObj<ButtonComponent>;
export const Primary: Story = { args: { variant: 'primary' } };
export const Secondary: Story = { args: { variant: 'secondary' } };
export const Subtle: Story = { args: { variant: 'subtle' } };
export const Danger: Story = { args: { variant: 'danger' } };
export const Ghost: Story = { args: { variant: 'ghost' } };
export const Disabled: Story = { args: { variant: 'primary', disabled: true } };
@@ -0,0 +1,61 @@
import { Component, computed, forwardRef, input } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
// Per-instance fallback ids, so the label's `for` always targets THIS checkbox. The
// CIBG styled checkbox hides the native input and routes clicks through the label, so a
// shared/undefined id silently makes every label toggle the first input — hence a default.
let nextCheckboxId = 0;
/** Atom: a labelled checkbox wired as a form control (ngModel/reactive). Thin
wrapper over the CIBG Huisstijl `.form-check.styled` checkbox CSS; native
input for full keyboard + screen-reader support. */
@Component({
selector: 'app-checkbox',
template: `
<div class="form-check styled">
<input
class="form-check-input"
type="checkbox"
[id]="resolvedId()"
[checked]="value"
[disabled]="disabled"
(change)="onToggle($event)"
(blur)="onTouched()"
/>
<label class="form-check-label" [for]="resolvedId()">{{ label() }}</label>
</div>
`,
providers: [
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => CheckboxComponent), multi: true },
],
})
export class CheckboxComponent implements ControlValueAccessor {
checkboxId = input<string>();
label = input('');
/** The caller's id, or a unique fallback — never undefined, so labels never collide. */
private autoId = `app-checkbox-${nextCheckboxId++}`;
protected resolvedId = computed(() => this.checkboxId() ?? this.autoId);
value = false;
disabled = false;
onChange: (v: boolean) => void = () => {};
onTouched: () => void = () => {};
onToggle(e: Event) {
this.value = (e.target as HTMLInputElement).checked;
this.onChange(this.value);
}
writeValue(v: boolean) {
this.value = !!v;
}
registerOnChange(fn: (v: boolean) => void) {
this.onChange = fn;
}
registerOnTouched(fn: () => void) {
this.onTouched = fn;
}
setDisabledState(d: boolean) {
this.disabled = d;
}
}
@@ -0,0 +1,15 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { CheckboxComponent } from './checkbox.component';
const meta: Meta<CheckboxComponent> = {
title: 'Design System/Atoms/Checkbox',
component: CheckboxComponent,
render: (args) => ({
props: args,
template: `<app-checkbox [label]="label" [checkboxId]="checkboxId"></app-checkbox>`,
}),
};
export default meta;
type Story = StoryObj<CheckboxComponent>;
export const Default: Story = { args: { label: 'Standaard aanhef', checkboxId: 'cb-1' } };
@@ -0,0 +1,79 @@
import { Component, input, output } from '@angular/core';
import { RouterLink } from '@angular/router';
/** Molecule: one choice in a CIBG Huisstijl "keuzelijst" `<li><div class="keuzelijst__link">`
with a title and optional instructions (see choice-list.component.ts). Renders a
plain (non-interactive) block when there's nothing to navigate to.
The title is a Bootstrap "stretched-link" (`.stretched-link`, vendored) rather than
the whole box being an `<a>`: a `[choiceActions]` slot needs to project a sibling
action (e.g. "Annuleren") *inside* the same card, and a `<button>` can't nest
inside an `<a>` (invalid HTML, broken a11y). stretched-link keeps the entire card
clickable via its `::after` overlay; the projected action sits above that overlay
(see its own `position:relative;z-index:2` at the call site) so it stays clickable.
Unlike the "aanvragen" pattern's `.applications li a::after` (scoped to the `a`
tag), CIBG's `.keuzelijst__link:after`/`:hover`/`:focus` rules key off the bare
class keuzelijst assumes every item IS a link so a non-interactive row would
otherwise inherit the chevron and hover accent too. The `--static` modifier below
suppresses both for that case. `:focus-within` restores the focus accent that
`:focus` would have given the (no longer directly focused) card. */
@Component({
selector: 'app-choice-link',
imports: [RouterLink],
styles: [
`
:host {
display: contents;
}
.keuzelijst__link {
position: relative;
}
.keuzelijst__link:focus-within {
background-color: var(--rhc-color-cool-grey-100);
box-shadow: inset 4px 0 0 0 var(--rhc-color-lintblauw-500);
}
.keuzelijst__link--static::after {
content: none;
}
.keuzelijst__link--static:hover {
background-color: var(--rhc-color-cool-grey-200);
box-shadow: none;
}
`,
],
template: `
<li class="keuzelijst__list-item">
<div class="keuzelijst__link" [class.keuzelijst__link--static]="!to() && !clickable()">
<h3 class="keuzelijst__header">
@if (to()) {
<a class="stretched-link" [routerLink]="to()">{{ heading() }}</a>
} @else if (clickable()) {
<a class="stretched-link" href="#" (click)="onActivate($event)">{{ heading() }}</a>
} @else {
{{ heading() }}
}
</h3>
@if (instructions()) {
<p class="keuzelijst__instructions">{{ instructions() }}</p>
}
<ng-content select="[choiceActions]" />
</div>
</li>
`,
})
export class ChoiceLinkComponent {
heading = input.required<string>();
instructions = input('');
/** Set for a plain routerLink navigation. */
to = input('');
/** Set when the choice navigates imperatively (e.g. resume with query params) the
row still renders as a clickable card, but `activate` decides what happens. */
clickable = input(false);
activate = output<void>();
protected onActivate(ev: Event) {
ev.preventDefault(); // fragment href resolves against <base href>, not the route
this.activate.emit();
}
}
@@ -0,0 +1,40 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideRouter } from '@angular/router';
import { ChoiceLinkComponent } from './choice-link.component';
const meta: Meta<ChoiceLinkComponent> = {
title: 'Design System/Molecules/Choice Link',
component: ChoiceLinkComponent,
decorators: [applicationConfig({ providers: [provideRouter([])] })],
render: (args) => ({
props: args,
// Rows are <li>s — a real list gives them their normal layout in the story.
template: `<ul class="keuzelijst__list"><app-choice-link [heading]="heading" [instructions]="instructions" [to]="to" [clickable]="clickable" /></ul>`,
}),
parameters: {
// Structural: app-choice-link's host sits between the <ul> and its <li> — axe's
// list/listitem rule requires them adjacent regardless of `display:contents`.
// WP-11 (CIBG markup fidelity) reworks this markup; see docs/project/backlog/WP-11-markup-fidelity.md.
a11y: { disable: true },
},
};
export default meta;
type Story = StoryObj<ChoiceLinkComponent>;
export const Navigatie: Story = {
args: {
heading: 'Ik heb een Nederlands diploma',
instructions: 'U kunt direct uw registratie aanvragen.',
to: '/registreren',
},
};
export const Actie: Story = {
args: { heading: 'Inschrijving', instructions: 'Stap 2 van 3', clickable: true },
};
export const NietInteractief: Story = {
args: {
heading: 'Herregistratie',
instructions: 'Referentie 2024-00123 · ingediend op 12 mei 2024',
},
};
@@ -0,0 +1,22 @@
import { Component, input } from '@angular/core';
let nextId = 0;
/** Molecule: the CIBG Huisstijl "keuzelijst" a heading semantically linked
(`aria-labelledby`) to a list of `<app-choice-link>` choices, used where a
screen offers a set of options to pick between (see
designsystem.cibg.nl/componenten/keuzelijst). Domain-free the caller
supplies the heading text and the choices. */
@Component({
selector: 'app-choice-list',
template: `
<h2 class="header header--medium" [id]="headingId">{{ heading() }}</h2>
<ul class="keuzelijst__list" [attr.aria-labelledby]="headingId">
<ng-content />
</ul>
`,
})
export class ChoiceListComponent {
heading = input.required<string>();
protected readonly headingId = `choice-list-${nextId++}`;
}
@@ -0,0 +1,31 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig, moduleMetadata } from '@storybook/angular';
import { provideRouter } from '@angular/router';
import { ChoiceListComponent } from './choice-list.component';
import { ChoiceLinkComponent } from '@shared/ui/choice-link/choice-link.component';
const meta: Meta<ChoiceListComponent> = {
title: 'Design System/Molecules/Choice List',
component: ChoiceListComponent,
decorators: [
applicationConfig({ providers: [provideRouter([])] }),
moduleMetadata({ imports: [ChoiceLinkComponent] }),
],
render: (args) => ({
props: args,
template: `
<app-choice-list [heading]="heading">
<app-choice-link heading="Ik heb een Nederlands diploma" instructions="U kunt direct uw registratie aanvragen." to="/registreren" />
<app-choice-link heading="Ik heb een buitenlands diploma" instructions="Uw diploma moet eerst officieel erkend worden." clickable="true" />
</app-choice-list>`,
}),
parameters: {
// Structural: app-choice-link's host sits between the <ul> and its <li> —
// fixed by the WP-11 markup rework. See docs/project/backlog/WP-11-markup-fidelity.md.
a11y: { disable: true },
},
};
export default meta;
type Story = StoryObj<ChoiceListComponent>;
export const Default: Story = { args: { heading: 'Maak een keuze' } };
@@ -0,0 +1,32 @@
import { Component, input } from '@angular/core';
/** Molecule: the CIBG Huisstijl "bevestiging" (confirmation) an animated green
checkmark banner shown at the end of an aanvraagproces, ONLY when the user has
nothing left to do (see designsystem.cibg.nl/componenten/bevestiging). Follow-up
content (a reference number, a restart button) is projected below the banner. */
@Component({
selector: 'app-confirmation',
template: `
<div class="confirmation">
<svg
class="confirmation__checkmark"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 52 52"
height="52"
width="52"
>
<circle class="confirmation__checkmark-circle" cx="26" cy="26" r="18" fill="none" />
<path class="confirmation__checkmark-check" fill="none" d="M14.1 27.2l7.1 7.2 16.7-16.8" />
</svg>
<div class="confirmation__title">
<span class="visually-hidden">{{ successPrefix() }}</span
>{{ title() }}
</div>
</div>
<ng-content />
`,
})
export class ConfirmationComponent {
title = input.required<string>();
successPrefix = input($localize`:@@confirmation.succes:Succes:`);
}
@@ -0,0 +1,18 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { ConfirmationComponent } from './confirmation.component';
const meta: Meta<ConfirmationComponent> = {
title: 'Design System/Molecules/Confirmation',
component: ConfirmationComponent,
render: (args) => ({
props: args,
template: `
<app-confirmation [title]="title">
<p class="app-section">Uw referentienummer is 2024-00123. Bewaar dit nummer voor uw administratie.</p>
</app-confirmation>`,
}),
};
export default meta;
type Story = StoryObj<ConfirmationComponent>;
export const Default: Story = { args: { title: 'Uw aanvraag is verstuurd' } };

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