feat(brief): letter composition + two-person approval (teaching slice)

New `brief` context — a letter-composition feature with a drafter/approver
approval workflow, built as a teaching vertical slice on the repo's existing
FP + Elm + atomic-design patterns (see plan in ~/.claude/plans).

Domain (pure):
- Rich text as a serialisable value tree (placeholders are first-class nodes),
  moved to @shared/kernel/rich-text.ts so the shared editor can use it.
- lintPlaceholders: a pure, total content -> Diagnostic[] linter, derived never stored.
- brief.machine.ts: status sum-type with guarded transitions; frozen-snapshot =
  deep value copy; derived diagnostics/editability. Full specs.

Backend (.NET stub):
- BriefStore + seed, GET/PUT /brief and submit/approve/reject/send endpoints,
  role via X-Role header (mirrors X-Admin), transition + approver!=drafter guards,
  audit logging. Regenerated typed client via gen:api. +6 backend tests.

Seam:
- brief.adapter.ts maps flat wire unions <-> domain discriminated unions at the
  parse boundary (+ spec).

UI (atomic):
- shared atoms: checkbox, placeholder-chip; molecule: rich-text-editor (no-dep
  contenteditable, DOM<->RichTextBlock round-trip tested).
- brief/ui: letter-block, passage-picker, diagnostics-panel, rejection-comments,
  letter-section, letter-composer, letter-preview, brief.page + /brief route.
- Dev-only ?role=drafter|approver toggle + roleInterceptor; dashboard nav link.

Enforcement: @brief/* alias + eslint layer boundary (brief depends only on shared).

Also included (same session):
- Value-object specs (postcode/uren/big-nummer) — closes the "domain must have a spec" gap.
- src/docs/ Storybook MDX foundation pages (atomic design, tokens, FP-in-UI).
- .storybook/tsconfig.json: add @angular/localize to types (Storybook was fully
  broken — $localize unresolved — dev + build).

Verified: 168 FE tests, 68 backend tests, lint/build/check:tokens green,
Storybook boots, end-to-end HTTP smoke (self-approve 403, approver 200, full flow).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 21:32:22 +02:00
parent 0aada9037e
commit 053160c5c9
49 changed files with 13963 additions and 573 deletions

View File

@@ -0,0 +1,71 @@
import { Meta, Canvas } from '@storybook/addon-docs/blocks';
import * as ButtonStories from '../app/shared/ui/button/button.stories';
import * as FormFieldStories from '../app/shared/ui/form-field/form-field.stories';
import * as PageShellStories from '../app/shared/layout/page-shell/page-shell.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 Utrecht/RHC 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 RHC 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, status banner,
progress bar, chips) into a section that owns real upload behaviour. It has no story yet
(Track A backlog); read it at `src/app/shared/ui/upload/document-upload/`.
### 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.

View File

@@ -0,0 +1,76 @@
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. The Rijkshuisstijl (RHC) design system ships them
as CSS custom properties; `src/styles.scss` imports them and adds a thin `--app-*` layer for
the few measures RHC has no token for. `npm run check:tokens` fails the build on any
hardcoded hex colour in atoms/molecules/chrome — so these tokens are the *only* source of
colour.
> Resolved values below are read live from the running theme via `getComputedStyle`, so they
> can't drift from what ships. All demos are wrapped in `.rhc-theme.lintblauw`, the same scope
> the app runs under.
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
- **Semantic first** — reach for a role token (`--rhc-color-foreground-default`,
`--rhc-color-border-default`, `--rhc-color-core`) before a raw palette step. 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 RHC has no token for
(`--app-content-max`, `--app-form-max`). If you're tempted to add one, check RHC first.
<div className="rhc-theme lintblauw">
## Spacing scale — `--rhc-space-max-*`
<div style={{ display: 'grid', gap: '0.4rem', margin: '1rem 0' }}>
{['2xs','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-core)', 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-core',
'--rhc-color-core-hover',
'--rhc-color-border-default',
'--rhc-color-border-strong',
'--rhc-color-bg-document',
].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>
</div>

63
src/docs/fp-in-ui.mdx Normal file
View File

@@ -0,0 +1,63 @@
import { Meta, Canvas } from '@storybook/addon-docs/blocks';
import * as AsyncStories from '../app/shared/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/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.