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,39 @@
import { Component, forwardRef, input } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
/** Atom: a labelled checkbox wired as a form control (ngModel/reactive). Native
input for full keyboard + screen-reader support; the label is the click target. */
@Component({
selector: 'app-checkbox',
styles: [`
:host{display:block}
label{display:inline-flex;align-items:center;gap:var(--rhc-space-max-md);cursor:pointer}
input{inline-size:1.1rem;block-size:1.1rem}
`],
template: `
<label>
<input type="checkbox" [id]="checkboxId()" [checked]="value" [disabled]="disabled"
(change)="onToggle($event)" (blur)="onTouched()" />
<span>{{ label() }}</span>
</label>
`,
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => CheckboxComponent), multi: true }],
})
export class CheckboxComponent implements ControlValueAccessor {
checkboxId = input<string>();
label = input('');
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; }
}

View File

@@ -0,0 +1,15 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { CheckboxComponent } from './checkbox.component';
const meta: Meta<CheckboxComponent> = {
title: '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' } };

View File

@@ -0,0 +1,45 @@
import { Component, computed, input } from '@angular/core';
/** Atom: a highlighted, non-editable placeholder chip for READ-ONLY rendering
(preview, diagnostics). Distinct styling for auto-resolvable vs manual fields and
for linter error/warning states. Domain-free and presentational — the caller
passes label/state; a11y label announces the field name + its resolution status.
(The editor renders its own inline chips inside contenteditable; this atom is for
everywhere the letter is shown, not edited.) */
@Component({
selector: 'app-placeholder-chip',
styles: [`
.chip{
display:inline-flex;align-items:center;gap:0.2em;
border-radius:var(--rhc-border-radius-sm);
padding:0 0.35em;line-height:1.6;border:1px solid transparent;white-space:nowrap;
}
.chip::before{content:'⌗';opacity:0.6;font-weight:700}
.chip--auto{background:var(--rhc-color-cool-grey-100);color:var(--rhc-color-foreground-default)}
.chip--manual{background:var(--rhc-color-geel-100);color:var(--rhc-color-foreground-default)}
.chip--warning{background:var(--rhc-color-geel-100);border-color:var(--rhc-color-border-default);color:var(--rhc-color-foreground-default)}
.chip--error{background:var(--rhc-color-rood-100);border-color:var(--rhc-color-border-default);color:var(--rhc-color-foreground-default)}
`],
template: `<span class="chip" [class]="'chip--' + variant()" [attr.aria-label]="ariaLabel()">{{ label() }}</span>`,
})
export class PlaceholderChipComponent {
label = input.required<string>();
autoResolvable = input(false);
state = input<'ok' | 'warning' | 'error'>('ok');
// Copy is localizable-by-default per the shared-UI convention (like <app-async>).
autoText = input($localize`:@@placeholderChip.auto:wordt automatisch ingevuld`);
manualText = input($localize`:@@placeholderChip.manual:handmatig in te vullen`);
warningText = input($localize`:@@placeholderChip.warning:let op`);
errorText = input($localize`:@@placeholderChip.error:fout`);
protected variant = computed(() => {
const s = this.state();
return s !== 'ok' ? s : this.autoResolvable() ? 'auto' : 'manual';
});
protected ariaLabel = computed(() => {
const status = { auto: this.autoText(), manual: this.manualText(), warning: this.warningText(), error: this.errorText() }[this.variant()];
return $localize`:@@placeholderChip.aria:Veld ${this.label()}:label:, ${status}:status:`;
});
}

View File

@@ -0,0 +1,18 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { PlaceholderChipComponent } from './placeholder-chip.component';
const meta: Meta<PlaceholderChipComponent> = {
title: 'Atoms/Placeholder Chip',
component: PlaceholderChipComponent,
render: (args) => ({
props: args,
template: `<app-placeholder-chip [label]="label" [autoResolvable]="autoResolvable" [state]="state"></app-placeholder-chip>`,
}),
};
export default meta;
type Story = StoryObj<PlaceholderChipComponent>;
export const AutoResolvable: Story = { args: { label: 'Naam zorgverlener', autoResolvable: true, state: 'ok' } };
export const Manual: Story = { args: { label: 'Reden besluit', autoResolvable: false, state: 'ok' } };
export const Warning: Story = { args: { label: 'Oud kenmerk', autoResolvable: true, state: 'warning' } };
export const Error: Story = { args: { label: 'Onbekend veld', autoResolvable: false, state: 'error' } };

View File

@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
import { RichTextBlock } from '@shared/kernel/rich-text';
import { readBlock, renderInto } from './rich-text-dom';
const labelFor = (key: string) => ({ naam: 'Naam', datum: 'Datum' })[key] ?? key;
function roundTrip(block: RichTextBlock): RichTextBlock {
const root = document.createElement('div');
renderInto(root, block, labelFor);
return readBlock(root);
}
describe('rich-text DOM boundary', () => {
it('round-trips text, marks, placeholders, line breaks and multiple paragraphs', () => {
const block: RichTextBlock = {
paragraphs: [
{
nodes: [
{ type: 'text', text: 'Beste ' },
{ type: 'placeholder', key: 'naam' },
{ type: 'text', text: ' vet', marks: ['bold'] },
{ type: 'lineBreak' },
{ type: 'text', text: 'nieuwe regel' },
],
},
{ nodes: [{ type: 'text', text: 'Op ' }, { type: 'placeholder', key: 'datum' }] },
],
};
expect(roundTrip(block)).toEqual(block);
});
it('round-trips an empty paragraph (filler <br> is not a line break)', () => {
const empty: RichTextBlock = { paragraphs: [{ nodes: [] }] };
expect(roundTrip(empty)).toEqual(empty);
});
it('renders a placeholder as a non-editable chip carrying its key and label', () => {
const root = document.createElement('div');
renderInto(root, { paragraphs: [{ nodes: [{ type: 'placeholder', key: 'naam' }] }] }, labelFor);
const chip = root.querySelector('.rte-chip') as HTMLElement;
expect(chip.getAttribute('contenteditable')).toBe('false');
expect(chip.dataset['phKey']).toBe('naam');
expect(chip.textContent).toBe('Naam');
});
it('reads combined marks in canonical order regardless of nesting', () => {
const root = document.createElement('div');
root.innerHTML = '<p><em><strong>x</strong></em></p>'; // italic wrapping bold
expect(readBlock(root)).toEqual({ paragraphs: [{ nodes: [{ type: 'text', text: 'x', marks: ['bold', 'italic'] }] }] });
});
});

View File

@@ -0,0 +1,114 @@
import { Mark, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
/**
* The quarantined boundary between the imperative `contenteditable` DOM and the
* serialisable `RichTextBlock` value. Pure functions (given a DOM they render /
* read) so they round-trip losslessly and can be unit-tested without Angular. The
* rest of the app only ever sees `RichTextBlock` — this is the one place DOM leaks.
*
* ponytail: mark detection covers the tags/styles a browser's execCommand emits
* (strong/b, em/i, u, and inline font-weight/style/decoration); exotic pasted markup
* degrades to plain text rather than crashing.
*/
const ORDER: readonly Mark[] = ['bold', 'italic', 'underline'];
const MARK_TAG: Record<Mark, string> = { bold: 'strong', italic: 'em', underline: 'u' };
export function renderInto(root: HTMLElement, block: RichTextBlock, labelFor: (key: string) => string): void {
const doc = root.ownerDocument;
root.replaceChildren();
for (const para of block.paragraphs) {
const p = doc.createElement('p');
p.className = 'rte-para';
if (para.nodes.length === 0) {
p.appendChild(doc.createElement('br')); // keep the empty line focusable
} else {
for (const node of para.nodes) p.appendChild(renderNode(node, labelFor, doc));
}
root.appendChild(p);
}
}
/** Build one non-editable placeholder chip element (shared by initial render and
live caret insertion). setAttribute reflects reliably in jsdom + browsers. */
export function createChip(doc: Document, key: string, label: string): HTMLElement {
const span = doc.createElement('span');
span.dataset['phKey'] = key;
span.setAttribute('contenteditable', 'false');
span.className = 'rte-chip';
span.textContent = label;
return span;
}
function renderNode(node: RichTextNode, labelFor: (key: string) => string, doc: Document): Node {
if (node.type === 'lineBreak') return doc.createElement('br');
if (node.type === 'placeholder') return createChip(doc, node.key, labelFor(node.key));
let el: Node = doc.createTextNode(node.text);
// Nest marks in a canonical order so read-back is deterministic.
for (const m of ORDER.filter((x) => node.marks?.includes(x))) {
const wrap = doc.createElement(MARK_TAG[m]);
wrap.appendChild(el);
el = wrap;
}
return el;
}
export function readBlock(root: HTMLElement): RichTextBlock {
const paragraphs: { nodes: RichTextNode[] }[] = [];
const blockEls = Array.from(root.children).filter((c) => c.tagName === 'P' || c.tagName === 'DIV');
const containers = blockEls.length ? blockEls : [root];
for (const el of containers) {
const kids = Array.from(el.childNodes);
const nodes: RichTextNode[] = [];
// A lone <br> is the empty-line filler, not a content line break.
if (!(kids.length === 1 && kids[0].nodeName === 'BR')) {
for (const child of kids) collect(child, [], nodes);
}
paragraphs.push({ nodes });
}
return { paragraphs: paragraphs.length ? paragraphs : [{ nodes: [] }] };
}
function collect(node: Node, marks: Mark[], out: RichTextNode[]): void {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent ?? '';
if (text !== '') out.push(marks.length ? { type: 'text', text, marks: canonical(marks) } : { type: 'text', text });
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
const el = node as HTMLElement;
if (el.tagName === 'BR') {
out.push({ type: 'lineBreak' });
return;
}
const key = el.dataset?.['phKey'];
if (key != null) {
out.push({ type: 'placeholder', key });
return;
}
const m = markOf(el);
const next = m ? [...marks, m] : marks;
for (const child of Array.from(el.childNodes)) collect(child, next, out);
}
function markOf(el: HTMLElement): Mark | null {
switch (el.tagName) {
case 'STRONG':
case 'B':
return 'bold';
case 'EM':
case 'I':
return 'italic';
case 'U':
return 'underline';
}
const s = el.style;
if (s.fontWeight === 'bold' || Number(s.fontWeight) >= 600) return 'bold';
if (s.fontStyle === 'italic') return 'italic';
if (s.textDecoration.includes('underline')) return 'underline';
return null;
}
function canonical(marks: Mark[]): Mark[] {
return ORDER.filter((m) => marks.includes(m));
}

View File

@@ -0,0 +1,137 @@
import { Component, ElementRef, computed, effect, input, output, viewChild } from '@angular/core';
import { RichTextBlock, emptyBlock } from '@shared/kernel/rich-text';
import { createChip, readBlock, renderInto } from './rich-text-dom';
/** A menu entry for the insert-placeholder control — a plain {key,label}, so the
editor stays domain-free (it never sees the brief's PlaceholderDef). */
export interface PlaceholderOption {
readonly key: string;
readonly label: string;
}
/**
* Molecule: a minimal no-dependency WYSIWYG editor over a `RichTextBlock`.
*
* It is the single quarantined boundary to the imperative `contenteditable` DOM:
* `content` in, `contentChanged` (a `RichTextBlock`) out, holding NO letter state.
* Placeholders render as non-editable chips and can only be inserted from the menu
* (valid keys only) — never typed as raw braces. Swapping in a real editor library
* later (TipTap) means replacing only this component; nothing else sees the DOM.
*/
@Component({
selector: 'app-rich-text-editor',
styles: [`
:host{display:block}
.rte-toolbar{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-sm);align-items:center;margin-block-end:var(--rhc-space-max-sm)}
.rte-toolbar button{min-inline-size:2.2rem}
.rte-sep{inline-size:1px;align-self:stretch;background:var(--rhc-color-border-default)}
.rte-editable{
border:1px solid var(--rhc-color-border-default);border-radius:var(--rhc-border-radius-sm);
padding:var(--rhc-space-max-md);min-block-size:4rem;
}
.rte-editable[contenteditable='false']{background:var(--rhc-color-cool-grey-100)}
.rte-editable :is(p){margin:0 0 var(--rhc-space-max-sm)}
/* Placeholder chips inside the editor: one neutral highlight (the read-only
preview distinguishes auto/manual/error via app-placeholder-chip). */
.rte-editable .rte-chip{
background:var(--rhc-color-cool-grey-100);border-radius:var(--rhc-border-radius-sm);
padding:0 0.35em;white-space:nowrap;
}
.rte-editable .rte-chip::before{content:'⌗';opacity:0.6;font-weight:700;margin-inline-end:0.15em}
`],
template: `
@if (editable()) {
<div class="rte-toolbar" role="toolbar" [attr.aria-label]="toolbarLabel()">
<button type="button" class="utrecht-button utrecht-button--subtle" (mousedown)="$event.preventDefault()" (click)="format('bold')" [attr.aria-label]="boldLabel()"><b>B</b></button>
<button type="button" class="utrecht-button utrecht-button--subtle" (mousedown)="$event.preventDefault()" (click)="format('italic')" [attr.aria-label]="italicLabel()"><i>I</i></button>
<button type="button" class="utrecht-button utrecht-button--subtle" (mousedown)="$event.preventDefault()" (click)="format('underline')" [attr.aria-label]="underlineLabel()"><u>U</u></button>
@if (placeholders().length) {
<span class="rte-sep" aria-hidden="true"></span>
<label>
<span class="app-text-subtle">{{ insertLabel() }}</span>
<select #ins (change)="insert(ins.value); ins.value = ''">
<option value="" selected>{{ insertPrompt() }}</option>
@for (p of placeholders(); track p.key) {
<option [value]="p.key">{{ p.label }}</option>
}
</select>
</label>
}
</div>
}
<div #editor class="rte-editable" [attr.contenteditable]="editable()" (input)="emit()"
role="textbox" aria-multiline="true" [attr.aria-label]="fieldLabel()"></div>
`,
})
export class RichTextEditorComponent {
content = input<RichTextBlock>(emptyBlock());
placeholders = input<readonly PlaceholderOption[]>([]);
editable = input(true);
contentChanged = output<RichTextBlock>();
// Localizable-by-default copy (shared-UI convention).
fieldLabel = input($localize`:@@richTextEditor.field:Tekst`);
toolbarLabel = input($localize`:@@richTextEditor.toolbar:Opmaak`);
boldLabel = input($localize`:@@richTextEditor.bold:Vet`);
italicLabel = input($localize`:@@richTextEditor.italic:Cursief`);
underlineLabel = input($localize`:@@richTextEditor.underline:Onderstreept`);
insertLabel = input($localize`:@@richTextEditor.insert:Veld invoegen:`);
insertPrompt = input($localize`:@@richTextEditor.insertPrompt:Kies…`);
private editorEl = viewChild<ElementRef<HTMLElement>>('editor');
private lastEmitted = '';
private labelFor = (key: string) => this.placeholders().find((p) => p.key === key)?.label ?? key;
constructor() {
// Render when content arrives/changes from OUTSIDE. Skip our own emitted value
// flowing back (structural compare) so the caret isn't reset while typing.
effect(() => {
const content = this.content();
const el = this.editorEl()?.nativeElement;
if (!el) return;
const serialized = JSON.stringify(content);
if (serialized === this.lastEmitted) return;
renderInto(el, content, this.labelFor);
this.lastEmitted = serialized;
});
}
protected emit() {
const el = this.editorEl()?.nativeElement;
if (!el) return;
const block = readBlock(el);
this.lastEmitted = JSON.stringify(block);
this.contentChanged.emit(block);
}
protected format(cmd: 'bold' | 'italic' | 'underline') {
const el = this.editorEl()?.nativeElement;
if (!el) return;
el.focus();
// ponytail: execCommand is deprecated but universally supported and zero-dependency;
// if a browser drops it, this component is the one place to swap in a range-based impl.
el.ownerDocument.execCommand(cmd);
this.emit();
}
protected insert(key: string) {
const el = this.editorEl()?.nativeElement;
if (!key || !el) return;
el.focus();
const chip = createChip(el.ownerDocument, key, this.labelFor(key));
const sel = el.ownerDocument.getSelection();
if (sel && sel.rangeCount && el.contains(sel.anchorNode)) {
const range = sel.getRangeAt(0);
range.deleteContents();
range.insertNode(chip);
range.setStartAfter(chip);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
} else {
(el.lastElementChild ?? el).appendChild(chip);
}
this.emit();
}
}

View File

@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { RichTextEditorComponent } from './rich-text-editor.component';
import { RichTextBlock } from '@shared/kernel/rich-text';
const sample: RichTextBlock = {
paragraphs: [
{
nodes: [
{ type: 'text', text: 'Geachte heer/mevrouw ' },
{ type: 'placeholder', key: 'naam_zorgverlener' },
{ type: 'text', text: ',' },
],
},
{ nodes: [{ type: 'text', text: 'Op ' }, { type: 'placeholder', key: 'datum' }, { type: 'text', text: ' hebben wij besloten.' }] },
],
};
const placeholders = [
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener' },
{ key: 'datum', label: 'Datum' },
{ key: 'big_nummer', label: 'BIG-nummer' },
];
const meta: Meta<RichTextEditorComponent> = {
title: 'Molecules/Rich Text Editor',
component: RichTextEditorComponent,
render: (args) => ({
props: args,
template: `<app-rich-text-editor [content]="content" [placeholders]="placeholders" [editable]="editable"></app-rich-text-editor>`,
}),
};
export default meta;
type Story = StoryObj<RichTextEditorComponent>;
export const Editing: Story = { args: { content: sample, placeholders, editable: true } };
export const Empty: Story = { args: { content: { paragraphs: [{ nodes: [] }] }, placeholders, editable: true } };
export const ReadOnly: Story = { args: { content: sample, placeholders, editable: false } };