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