feat(brief): letter composition + two-person approval (teaching slice)
CI / backend (push) Failing after 22s
CI / frontend (push) Successful in 1m26s
CI / api-client-drift (push) Successful in 1m45s

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:
eho
2026-07-01 21:32:22 +02:00
co-authored by Claude Opus 4.8
parent 0aada9037e
commit 053160c5c9
49 changed files with 13963 additions and 573 deletions
@@ -0,0 +1,83 @@
import { Component, computed, input, output, signal } from '@angular/core';
import { RichTextBlock } from '@shared/kernel/rich-text';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { LetterSection, LibraryPassage } from '@brief/domain/brief';
import { BriefMsg } from '@brief/domain/brief.machine';
import { LetterBlockComponent } from '@brief/ui/letter-block/letter-block.component';
import { PassagePickerComponent } from '@brief/ui/passage-picker/passage-picker.component';
/** Organism: one template section — its ordered blocks plus (when editable) the
add-passages and add-free-text actions. Maps child events to `BriefMsg`s; sections
themselves can never be added/removed/reordered (no message exists for it). */
@Component({
selector: 'app-letter-section',
imports: [ButtonComponent, HeadingComponent, LetterBlockComponent, PassagePickerComponent],
styles: [`
:host{display:block}
.blocks{display:grid;gap:var(--rhc-space-max-lg);margin-block:var(--rhc-space-max-md)}
.actions{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-sm)}
.required{color:var(--rhc-color-foreground-subtle)}
.empty{color:var(--rhc-color-foreground-subtle);font-style:italic}
`],
template: `
<app-heading [level]="3">
{{ section().title }}
@if (section().required) { <span class="required">· {{ requiredLabel() }}</span> }
</app-heading>
<div class="blocks">
@for (block of section().blocks; track block.blockId) {
<app-letter-block
[block]="block"
[placeholders]="placeholders()"
[editable]="editable()"
(contentChanged)="onContent(block.blockId, $event)"
(removed)="edit.emit({ tag: 'BlockRemoved', blockId: block.blockId })"
(moved)="onMove(block.blockId, $event)" />
} @empty {
<p class="empty">{{ emptyLabel() }}</p>
}
</div>
@if (editable()) {
<div class="actions">
<app-button variant="secondary" (click)="pickerOpen.set(!pickerOpen())">{{ addPassageLabel() }}</app-button>
<app-button variant="subtle" (click)="edit.emit({ tag: 'FreeTextBlockAdded', sectionKey: section().sectionKey })">{{ addFreeLabel() }}</app-button>
</div>
@if (pickerOpen()) {
<app-passage-picker [passages]="sectionPassages()" (insert)="onInsert($event)" />
}
}
`,
})
export class LetterSectionComponent {
section = input.required<LetterSection>();
availablePassages = input<readonly LibraryPassage[]>([]);
placeholders = input<readonly PlaceholderOption[]>([]);
editable = input(false);
edit = output<BriefMsg>();
requiredLabel = input($localize`:@@brief.section.required:verplicht`);
emptyLabel = input($localize`:@@brief.section.empty:Nog geen tekst in deze sectie.`);
addPassageLabel = input($localize`:@@brief.section.addPassage:Standaardtekst toevoegen`);
addFreeLabel = input($localize`:@@brief.section.addFree:Vrije tekst toevoegen`);
protected pickerOpen = signal(false);
protected sectionPassages = computed(() => this.availablePassages().filter((p) => p.sectionKey === this.section().sectionKey));
protected onContent(blockId: string, content: RichTextBlock) {
this.edit.emit({ tag: 'BlockContentEdited', blockId, content });
}
protected onMove(blockId: string, direction: -1 | 1) {
const i = this.section().blocks.findIndex((b) => b.blockId === blockId);
this.edit.emit({ tag: 'BlockMovedWithinSection', blockId, toIndex: i + direction });
}
protected onInsert(passages: LibraryPassage[]) {
this.edit.emit({ tag: 'PassagesInserted', sectionKey: this.section().sectionKey, passages });
this.pickerOpen.set(false);
}
}