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
+41
View File
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { Brief, LetterBlock } from './brief';
import { diffBlocks, changedBlocks } from './brief-diff';
function block(id: string, text: string): LetterBlock {
return { type: 'freeText', blockId: id, content: { paragraphs: [{ nodes: [{ type: 'text', text }] }] } };
}
function brief(blocks: LetterBlock[]): Brief {
return {
briefId: 'b1',
beroep: 'arts',
templateId: 't1',
placeholders: [],
sections: [{ sectionKey: 'kern', title: 'Kern', required: true, locked: false, blocks }],
status: { tag: 'draft' },
drafterId: 'u1',
};
}
describe('diffBlocks', () => {
it('marks added, removed, changed and unchanged by blockId', () => {
const before = brief([block('local-1', 'a'), block('local-2', 'b'), block('local-3', 'c')]);
const after = brief([block('local-1', 'a'), block('local-2', 'B!'), block('local-4', 'd')]);
const diffs = diffBlocks(before, after);
const byId = new Map(diffs.map((d) => [d.blockId, d.kind]));
expect(byId.get('local-1')).toBe('unchanged');
expect(byId.get('local-2')).toBe('changed');
expect(byId.get('local-3')).toBe('removed'); // gone from after
expect(byId.get('local-4')).toBe('added'); // new in after
});
it('changedBlocks drops unchanged and keeps added/removed/changed', () => {
const before = brief([block('local-1', 'a'), block('local-2', 'b')]);
const after = brief([block('local-1', 'a'), block('local-2', 'B'), block('local-3', 'c')]);
const map = changedBlocks(diffBlocks(before, after));
expect(map.has('local-1')).toBe(false);
expect(map.get('local-2')).toBe('changed');
expect(map.get('local-3')).toBe('added');
});
});
+50
View File
@@ -0,0 +1,50 @@
import { Brief, LetterBlock, allBlocks } from './brief';
/**
* The rejection diff as a PURE function over two immutable `Brief` values — the whole
* teaching payload of WP-27: because state is one value, "what changed since the letter
* was rejected" is just a fold over two snapshots, no change-tracking bookkeeping.
*
* Blocks are matched by `blockId` (stable `local-N`/seed ids):
* - in `after` but not `before` → `added`
* - in `before` but not `after` → `removed`
* - in both, different content → `changed`
* - in both, same content → `unchanged`
*/
export type BlockDiffKind = 'added' | 'removed' | 'changed' | 'unchanged';
export interface BlockDiff {
readonly blockId: string;
readonly kind: BlockDiffKind;
}
/** Content equality by value. Blocks are JSON-shaped immutable trees, so a canonical
stringify is an honest deep-equal here (no functions, no cycles). */
function contentEqual(a: LetterBlock, b: LetterBlock): boolean {
return JSON.stringify(a.content) === JSON.stringify(b.content);
}
export function diffBlocks(before: Brief, after: Brief): BlockDiff[] {
const beforeById = new Map(allBlocks(before).map((b) => [b.blockId, b]));
const afterById = new Map(allBlocks(after).map((b) => [b.blockId, b]));
const out: BlockDiff[] = [];
for (const a of afterById.values()) {
const b = beforeById.get(a.blockId);
out.push({
blockId: a.blockId,
kind: !b ? 'added' : contentEqual(a, b) ? 'unchanged' : 'changed',
});
}
for (const b of beforeById.values()) {
if (!afterById.has(b.blockId)) out.push({ blockId: b.blockId, kind: 'removed' });
}
return out;
}
/** Lookup of only the blocks that changed since rejection (drops `unchanged`), for
badging the canvas. Keyed by `blockId`; removed ids are present too (the caller
surfaces them as a count — a removed block no longer renders inline). */
export function changedBlocks(diffs: readonly BlockDiff[]): ReadonlyMap<string, BlockDiffKind> {
return new Map(diffs.filter((d) => d.kind !== 'unchanged').map((d) => [d.blockId, d.kind]));
}
+1
View File
@@ -25,6 +25,7 @@ export interface LibraryPassage {
readonly label: string;
readonly content: RichTextBlock;
readonly version: number; // library version, for provenance only
readonly isDefault?: boolean; // part of the "standaardbrief" (kern) starter set
}
/** A block inside a letter section: a frozen passage snapshot, or free text. */