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,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));
}