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.