feat(fp): WP-27 — brief UX layer (undo/redo, standaardbrief, passage search, diff badges)
CI / frontend (push) Failing after 1m15s
CI / storybook-a11y (push) Failing after 4m43s
CI / backend (push) Successful in 1m24s
CI / codeql (csharp) (push) Failing after 2m51s
CI / e2e (push) Failing after 3h4m8s
CI / codeql (javascript-typescript) (push) Failing after 1m30s
CI / api-client-drift (push) Successful in 1m53s

Brief letter-composition UX improvements:
- undo/redo history in the brief store (snapshot stacks, Ctrl/Cmd+Z) + retry-save
- "Standaardbrief invoegen" starter for empty sections; isDefault library passages
  (backend DTO/seed + adapter parse)
- passage-picker client-side search (rich-text textOf helper)
- rejection diff badges on the letter canvas + show/hide changes toggle
  (pure brief-diff domain fns + spec)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-20 14:36:32 +02:00
co-authored by Claude Opus 4.8
parent 950fb5f0b2
commit 5e36d68f11
21 changed files with 3537 additions and 1907 deletions
+48 -2
View File
@@ -12,6 +12,7 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
@Component({
selector: 'app-brief-page',
imports: [PageShellComponent, AlertComponent, ButtonComponent, ...ASYNC, LetterComposerComponent],
host: { '(document:keydown)': 'onKey($event)' },
styles: [
`
.brief-toolbar {
@@ -21,6 +22,11 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
gap: var(--rhc-space-max-md);
margin-block-end: var(--rhc-space-max-lg);
}
.toolbar-start {
display: flex;
align-items: center;
gap: var(--rhc-space-max-md);
}
.save {
color: var(--rhc-color-foreground-subtle);
font-size: 0.9em;
@@ -42,7 +48,30 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
@if (loaded(); as s) {
@if (store.orgTemplate(); as orgTemplate) {
<div class="brief-toolbar">
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
<div class="toolbar-start">
@if (store.canEdit()) {
<app-button
variant="subtle"
[disabled]="!store.canUndo()"
[attr.aria-label]="undoLabel"
(click)="store.undo()"
>{{ undoLabel }}</app-button
>
<app-button
variant="subtle"
[disabled]="!store.canRedo()"
[attr.aria-label]="redoLabel"
(click)="store.redo()"
>{{ redoLabel }}</app-button
>
}
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
@if (store.saveState().tag === 'Error') {
<app-button variant="secondary" (click)="store.retrySave()">{{
retrySaveLabel
}}</app-button>
}
</div>
<app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{
resetLabel
}}</app-button>
@@ -53,6 +82,8 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
[logoUrl]="store.logoUrl()"
[availablePassages]="s.availablePassages"
[diagnostics]="store.diagnostics()"
[blockDiffs]="store.blockDiffs()"
[removedCount]="store.removedSinceReject()"
[canEdit]="store.canEdit()"
[canApprove]="store.canApprove()"
[canReject]="store.canReject()"
@@ -83,10 +114,13 @@ export class BriefPage {
protected failedText = $localize`:@@brief.page.failed:De brief kon niet worden geladen.`;
protected retryText = $localize`:@@brief.page.retry:Opnieuw proberen`;
protected resetLabel = $localize`:@@brief.page.reset:Opnieuw beginnen (demo)`;
protected undoLabel = $localize`:@@brief.page.undo:Ongedaan maken`;
protected redoLabel = $localize`:@@brief.page.redo:Opnieuw uitvoeren`;
protected retrySaveLabel = $localize`:@@brief.page.retrySave:Opnieuw proberen`;
private savingText = $localize`:@@brief.page.saving:Concept opslaan…`;
private savedText = $localize`:@@brief.page.saved:Concept opgeslagen`;
private saveErrorText = $localize`:@@brief.page.saveError:Opslaan mislukt`;
private saveErrorText = $localize`:@@brief.page.saveError:Niet opgeslagen — opnieuw proberen`;
/** Debounced-save state, surfaced in a polite live region. */
protected saveText = computed(() => {
@@ -121,4 +155,16 @@ export class BriefPage {
protected reload() {
void this.store.load();
}
/** Ctrl/Cmd+Z = undo, Ctrl/Cmd+Shift+Z = redo (WP-27). Ignored while focus is in the
rich-text editor or a form control, so the browser's own text undo keeps working
there — our shell-level undo is for structural edits (add/remove/reorder blocks). */
protected onKey(e: KeyboardEvent) {
if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'z' || !this.store.canEdit()) return;
const t = e.target as HTMLElement | null;
if (t && (t.isContentEditable || t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) return;
e.preventDefault();
if (e.shiftKey) this.store.redo();
else this.store.undo();
}
}
@@ -6,6 +6,7 @@ import {
effect,
inject,
input,
linkedSignal,
output,
signal,
viewChild,
@@ -21,6 +22,7 @@ import { OrgTemplate } from '@brief/domain/org-template';
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
import { Diagnostic } from '@brief/domain/placeholders';
import { BriefMsg } from '@brief/domain/brief.machine';
import { BlockDiffKind } from '@brief/domain/brief-diff';
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */
@@ -66,9 +68,40 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
}
.toolbar {
display: flex;
justify-content: flex-end;
justify-content: space-between;
align-items: center;
gap: var(--rhc-space-max-sm);
margin-block-end: var(--rhc-space-max-sm);
}
.zoom {
display: flex;
align-items: center;
gap: var(--rhc-space-max-sm);
}
.zoom-pct {
min-width: 3.5ch;
text-align: center;
color: var(--rhc-color-foreground-subtle);
font-variant-numeric: tabular-nums;
}
/* Rejection-diff badge (WP-27): a small pill above a changed/added block. */
.diff-block.diff-changed {
border-inline-start: 3px solid var(--rhc-color-oranje-500);
padding-inline-start: var(--rhc-space-max-sm);
}
.diff-badge {
display: inline-block;
margin-block-end: 1mm;
padding: 0 1.5mm;
border-radius: var(--rhc-border-radius-sm);
font-size: 7.5pt;
/* dark text, not white: oranje-500/groen-500 fail 4.5:1 contrast with white (WP-27 axe). */
color: var(--rhc-color-foreground-default);
background: var(--rhc-color-oranje-500);
}
.diff-badge.added {
background: var(--rhc-color-groen-500);
}
/* Portal-side chrome around the letter surface (not part of the contract file). */
.surface {
background: var(--rhc-color-grijs-100);
@@ -139,20 +172,40 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
}
</ng-template>
@if (editableRegions() === 'none') {
@if (editableRegions() !== 'template') {
<div class="toolbar">
<app-button
variant="subtle"
(click)="showSample.set(!showSample())"
[attr.aria-pressed]="showSample()"
>
{{ showSample() ? hideSampleLabel() : showSampleLabel() }}
</app-button>
<div class="zoom" role="group" [attr.aria-label]="zoomGroupLabel()">
<app-button
variant="subtle"
[disabled]="zoomLevel() <= 0.5"
[attr.aria-label]="zoomOutLabel()"
(click)="zoomBy(-0.1)"
>−</app-button
>
<span class="zoom-pct">{{ zoomPct() }}</span>
<app-button
variant="subtle"
[disabled]="zoomLevel() >= 1.5"
[attr.aria-label]="zoomInLabel()"
(click)="zoomBy(0.1)"
>+</app-button
>
<app-button variant="subtle" (click)="zoomLevel.set(1)">{{ zoomResetLabel() }}</app-button>
</div>
@if (editableRegions() === 'none') {
<app-button
variant="subtle"
(click)="showSample.set(!showSample())"
[attr.aria-pressed]="showSample()"
>
{{ showSample() ? hideSampleLabel() : showSampleLabel() }}
</app-button>
}
</div>
}
<div class="surface">
<div class="letter" #page [style]="marginStyle()" [style.zoom]="zoom()">
<div class="letter" #page [style]="marginStyle()" [style.zoom]="zoomLevel()">
<!-- div, not <header>/<footer>: the CIBG huisstijl styles those bare elements
(robijn footer background) — the letter surface must stay letter.css-only. -->
<div class="letter__letterhead" [class.from-template]="tintTemplate()">
@@ -211,7 +264,14 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
<section>
<h3>{{ section.title }}</h3>
@for (block of section.blocks; track block.blockId) {
@for (seg of segmentsOf(block); track $index) {
@let diffKind = showDiff() ? blockDiffs().get(block.blockId) : undefined;
<div class="diff-block" [class.diff-changed]="!!diffKind">
@if (diffKind) {
<span class="diff-badge" [class.added]="diffKind === 'added'">{{
diffLabel(diffKind)
}}</span>
}
@for (seg of segmentsOf(block); track $index) {
@if (seg.list === 'bullet') {
<ul>
@for (para of seg.items; track $index) {
@@ -242,7 +302,8 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
/>
</p>
}
}
}
</div>
}
</section>
}
@@ -315,7 +376,13 @@ export class LetterCanvasComponent {
availablePassages = input<readonly LibraryPassage[]>([]);
placeholders = input<readonly PlaceholderOption[]>([]);
diagnostics = input<readonly Diagnostic[]>([]);
/** Initial zoom; the in-canvas controls take over from here (WP-27). */
zoom = input(1);
/** Blocks changed/added/removed since the letter was rejected (WP-27); badged when
`showDiff` is on. Removed blocks aren't in the map's rendered set — they no longer
exist in the letter — the composer surfaces them as a count. */
blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map());
showDiff = input(false);
/** The org logo's content URL (letterhead), or null when none is set. */
logoUrl = input<string | null>(null);
edit = output<BriefMsg>();
@@ -343,10 +410,26 @@ export class LetterCanvasComponent {
signatureRoleLabel = input($localize`:@@brief.canvas.signatureRole:Functie ondertekenaar`);
footerContactLabel = input($localize`:@@brief.canvas.footerContact:Contactgegevens (voettekst)`);
footerLegalLabel = input($localize`:@@brief.canvas.footerLegal:Juridische voettekst`);
zoomGroupLabel = input($localize`:@@brief.canvas.zoom:Zoomniveau`);
zoomInLabel = input($localize`:@@brief.canvas.zoomIn:Inzoomen`);
zoomOutLabel = input($localize`:@@brief.canvas.zoomOut:Uitzoomen`);
zoomResetLabel = input($localize`:@@brief.canvas.zoomReset:100%`);
addedLabel = input($localize`:@@brief.diff.added:nieuw`);
changedLabel = input($localize`:@@brief.diff.changed:gewijzigd sinds afwijzing`);
protected showSample = signal(false);
protected letterDate = formatDatumNl(new Date());
/** Zoom seeded from the input; the +/−/reset controls drive it from there. */
protected zoomLevel = linkedSignal(() => this.zoom());
protected zoomPct = computed(() => `${Math.round(this.zoomLevel() * 100)}%`);
protected zoomBy(delta: number) {
// clamp 0.5–1.5; round to avoid float drift accumulating on repeated clicks.
this.zoomLevel.update((z) => Math.round(Math.min(1.5, Math.max(0.5, z + delta)) * 10) / 10);
}
protected diffLabel = (kind: BlockDiffKind) =>
kind === 'added' ? this.addedLabel() : this.changedLabel();
/** The letterhead/signature/footer are tinted "not yours" only while composing —
in 'none' the whole surface is read-only, in 'template' they ARE the editable focus. */
protected tintTemplate = computed(() => this.editableRegions() === 'content');
@@ -148,6 +148,19 @@ export const TemplateMode: Story = { args: { editableRegions: 'template' } };
export const Zoomed: Story = { args: { editableRegions: 'none', zoom: 0.6 } };
/** Approver's "Toon wijzigingen": blocks changed/added since rejection are badged (WP-27). */
export const WithDiff: Story = {
args: {
editableRegions: 'none',
diagnostics: [],
showDiff: true,
blockDiffs: new Map([
['local-1', 'added'],
['local-2', 'changed'],
]),
},
};
/** Long letter: the approximate ±page-break marks appear per A4 interval. */
export const PageBreak: Story = {
args: { editableRegions: 'none', brief: longBrief, diagnostics: [] },
@@ -1,4 +1,4 @@
import { Component, computed, input, output } from '@angular/core';
import { Component, computed, input, output, signal } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
@@ -8,6 +8,7 @@ import { Brief, LibraryPassage } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template';
import { Diagnostic } from '@brief/domain/placeholders';
import { BriefMsg } from '@brief/domain/brief.machine';
import { BlockDiffKind } from '@brief/domain/brief-diff';
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
import { DiagnosticsPanelComponent } from '@brief/ui/diagnostics-panel/diagnostics-panel.component';
import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejection-comments.component';
@@ -62,6 +63,14 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
<div class="head">
<app-heading [level]="2">{{ title() }}</app-heading>
<div class="head-end">
@if (hasRejectionDiff()) {
<app-button
variant="subtle"
[attr.aria-pressed]="showDiff()"
(click)="showDiff.set(!showDiff())"
>{{ showDiff() ? hideDiffLabel() : showDiffLabel() }}</app-button
>
}
<app-button variant="subtle" [disabled]="busy()" (click)="preview.emit()">{{
previewLabel()
}}</app-button>
@@ -73,6 +82,10 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
<app-rejection-comments mode="show" [comments]="rejectComments()" />
}
@if (showDiff() && removedCount() > 0) {
<app-alert type="info">{{ removedText() }}</app-alert>
}
<app-letter-canvas
[brief]="brief()"
[orgTemplate]="orgTemplate()"
@@ -81,6 +94,8 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
[availablePassages]="availablePassages()"
[placeholders]="menu()"
[diagnostics]="diagnostics()"
[blockDiffs]="blockDiffs()"
[showDiff]="showDiff()"
(edit)="edit.emit($event)"
/>
@@ -149,6 +164,12 @@ export class LetterComposerComponent {
canSend = input(false);
canSubmit = input(false);
busy = input(false);
/** Rejection diff (WP-27): the changed/added/removed blocks and their count. The
"Toon wijzigingen" toggle only appears when there's something to show. */
blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map());
removedCount = input(0);
protected hasRejectionDiff = computed(() => this.blockDiffs().size > 0);
protected showDiff = signal(false);
edit = output<BriefMsg>();
submit = output<void>();
@@ -160,6 +181,12 @@ export class LetterComposerComponent {
title = input($localize`:@@brief.title:Brief aan de zorgverlener`);
previewLabel = input($localize`:@@brief.preview.open:Voorbeeld`);
showDiffLabel = input($localize`:@@brief.diff.show:Toon wijzigingen`);
hideDiffLabel = input($localize`:@@brief.diff.hide:Verberg wijzigingen`);
removedText = computed(
() =>
$localize`:@@brief.diff.removed:${this.removedCount()}:count: blok(ken) verwijderd sinds afwijzing.`,
);
submitLabel = input($localize`:@@brief.submit:Indienen ter beoordeling`);
resubmitLabel = input($localize`:@@brief.resubmit:Opnieuw indienen`);
submitHint = input(
@@ -63,6 +63,11 @@ import { PassagePickerComponent } from '@brief/ui/passage-picker/passage-picker.
@if (editable()) {
<div class="actions">
@if (showStandardLetter()) {
<app-button variant="primary" (click)="insertStandardLetter()">{{
standardLetterLabel()
}}</app-button>
}
<app-button variant="secondary" (click)="pickerOpen.set(!pickerOpen())">{{
addPassageLabel()
}}</app-button>
@@ -89,11 +94,27 @@ export class LetterSectionComponent {
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`);
standardLetterLabel = input($localize`:@@brief.section.standardLetter:Standaardbrief invoegen`);
protected pickerOpen = signal(false);
protected sectionPassages = computed(() =>
this.availablePassages().filter((p) => p.sectionKey === this.section().sectionKey),
);
/** The "standaardbrief" starter set for this section (server-flagged defaults). */
protected defaultPassages = computed(() => this.sectionPassages().filter((p) => p.isDefault));
/** One-click starter, offered only while the section is still empty and defaults exist. */
protected showStandardLetter = computed(
() => this.section().blocks.length === 0 && this.defaultPassages().length > 0,
);
protected insertStandardLetter() {
// One Msg → one undo step (see brief.store `edit`).
this.edit.emit({
tag: 'PassagesInserted',
sectionKey: this.section().sectionKey,
passages: this.defaultPassages(),
});
}
protected onContent(blockId: string, content: RichTextBlock) {
this.edit.emit({ tag: 'BlockContentEdited', blockId, content });
@@ -36,6 +36,7 @@ const passages: LibraryPassage[] = [
sectionKey: 'kern',
label: 'Toelichting arts',
version: 1,
isDefault: true, // part of the standaardbrief starter set (WP-27)
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Als arts ...' }] }] },
},
];
@@ -52,4 +53,5 @@ type Story = StoryObj<LetterSectionComponent>;
export const ReadOnly: Story = { args: { editable: false } };
export const Editable: Story = { args: { editable: true } };
/** Empty section: the one-click "Standaardbrief invoegen" starter appears (WP-27). */
export const EditableEmpty: Story = { args: { section: emptySection, editable: true } };
@@ -1,7 +1,9 @@
import { Component, input, output, signal } from '@angular/core';
import { Component, computed, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { textOf } from '@shared/kernel/rich-text';
import { LibraryPassage } from '@brief/domain/brief';
/** Molecule: multi-select list of the section's library passages. One "Voeg toe"
@@ -9,7 +11,7 @@ import { LibraryPassage } from '@brief/domain/brief';
single-insert path. Presentational: emits the chosen passages in list order. */
@Component({
selector: 'app-passage-picker',
imports: [FormsModule, CheckboxComponent, ButtonComponent],
imports: [FormsModule, CheckboxComponent, ButtonComponent, TextInputComponent],
styles: [
`
:host {
@@ -19,6 +21,9 @@ import { LibraryPassage } from '@brief/domain/brief';
border-radius: var(--rhc-border-radius-md);
padding: var(--rhc-space-max-md);
}
.search {
margin-block-end: var(--rhc-space-max-md);
}
ul {
list-style: none;
margin: 0 0 var(--rhc-space-max-md);
@@ -29,11 +34,24 @@ import { LibraryPassage } from '@brief/domain/brief';
.scope {
color: var(--rhc-color-foreground-subtle);
}
.empty {
color: var(--rhc-color-foreground-subtle);
font-style: italic;
margin: 0 0 var(--rhc-space-max-md);
}
`,
],
template: `
<div class="search">
<app-text-input
[placeholder]="searchLabel()"
[attr.aria-label]="searchLabel()"
[ngModel]="query()"
(ngModelChange)="query.set($event)"
/>
</div>
<ul>
@for (p of passages(); track p.passageId) {
@for (p of filtered(); track p.passageId) {
<li>
<app-checkbox
[checkboxId]="'passage-' + p.passageId"
@@ -43,6 +61,8 @@ import { LibraryPassage } from '@brief/domain/brief';
/>
<span class="scope"> · {{ p.scope === 'beroep' ? beroepLabel() : globalLabel() }}</span>
</li>
} @empty {
<li class="empty">{{ noMatchLabel() }}</li>
}
</ul>
<app-button variant="secondary" [disabled]="count() === 0" (click)="add()"
@@ -57,8 +77,20 @@ export class PassagePickerComponent {
addLabel = input($localize`:@@brief.picker.add:Voeg toe`);
globalLabel = input($localize`:@@brief.picker.global:algemeen`);
beroepLabel = input($localize`:@@brief.picker.beroep:beroepsspecifiek`);
searchLabel = input($localize`:@@brief.picker.search:Zoek in standaardteksten…`);
noMatchLabel = input($localize`:@@brief.picker.noMatch:Geen standaardteksten gevonden.`);
protected checked = signal<Record<string, boolean>>({});
protected query = signal('');
/** Client-side filter on label + rendered content text — the library is small, so no
server search (WP-27). Placeholder keys are searchable too (see `textOf`). */
protected filtered = computed(() => {
const q = this.query().trim().toLowerCase();
if (!q) return this.passages();
return this.passages().filter(
(p) => p.label.toLowerCase().includes(q) || textOf(p.content).includes(q),
);
});
protected count = () => Object.values(this.checked()).filter(Boolean).length;
protected set(id: string, on: boolean) {