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
+73 -1
View File
@@ -10,6 +10,7 @@ import {
unresolvedPlaceholders,
} from '@brief/domain/brief';
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
import { BlockDiffKind, changedBlocks, diffBlocks } from '@brief/domain/brief-diff';
import { OrgTemplate } from '@brief/domain/org-template';
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
@@ -52,6 +53,35 @@ export class BriefStore {
/** Surfaced autosave state for the indicator + aria-live region. */
readonly saveState = signal<SaveState>({ tag: 'Idle' });
/** Undo/redo is SHELL state, not machine state (WP-27): a stack of past/future
`Brief` snapshots. Each is a deep-frozen immutable value, so sharing is safe.
Only CONTENT edits are recorded (they flow through `edit()`); status transitions
never enter history, or undo would replay workflow state. Capped so a long session
can't grow unbounded. Restore re-dispatches the existing `Seed` Msg — zero machine
changes. */
private static readonly HISTORY_CAP = 50;
private past = signal<readonly Brief[]>([]);
private future = signal<readonly Brief[]>([]);
readonly canUndo = computed(() => this.past().length > 0);
readonly canRedo = computed(() => this.future().length > 0);
/** The letter as it stood when it was REJECTED, captured shell-side (WP-27). The
approver diffs it against the resubmitted letter. POC limit: in-memory only, so a
full page reload loses it — a real system would persist the rejected revision. */
private rejectionSnapshot = signal<Brief | null>(null);
/** Changed/added/removed blocks since rejection — a pure fold over two snapshots. */
readonly blockDiffs = computed<ReadonlyMap<string, BlockDiffKind>>(() => {
const before = this.rejectionSnapshot();
const after = this.brief();
return before && after ? changedBlocks(diffBlocks(before, after)) : new Map();
});
/** Count of blocks removed since rejection — badged as a summary, since a removed
block no longer renders inline. */
readonly removedSinceReject = computed(
() => [...this.blockDiffs().values()].filter((k) => k === 'removed').length,
);
readonly hasRejectionDiff = computed(() => this.blockDiffs().size > 0);
/** The org template the letter renders with (WP-24). Server-owned appearance data,
not letter state — held beside the machine, never inside it (`brief.machine.ts`
stays untouched by design). Set from every server view that carries it. */
@@ -104,18 +134,50 @@ export class BriefStore {
const r = await this.adapter.load();
if (r.ok) {
this.orgTemplate.set(r.value.orgTemplate);
this.clearHistory();
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
} else {
this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
}
}
/** An edit: apply it optimistically in the pure reducer, then debounce-save. */
/** An edit: apply it optimistically in the pure reducer, then debounce-save. Records
an undo step only when the reducer actually changed the brief (a no-op edit — e.g.
a locked section — returns the same value and leaves no dead history step). */
edit(msg: BriefMsg) {
const before = this.brief();
this.store.dispatch(msg);
const after = this.brief();
if (before && after && after !== before) {
this.past.update((p) => [...p, before].slice(-BriefStore.HISTORY_CAP));
this.future.set([]);
}
this.scheduleSave();
}
/** Undo: restore the previous snapshot via the existing `Seed` Msg, push the current
onto the redo stack, then autosave. Redo is the mirror image. */
undo() {
this.step(this.past, this.future);
}
redo() {
this.step(this.future, this.past);
}
private step(from: typeof this.past, to: typeof this.future) {
const s = this.model();
const target = from().at(-1);
if (s.tag !== 'loaded' || !target) return;
from.update((x) => x.slice(0, -1));
to.update((x) => [...x, s.brief].slice(-BriefStore.HISTORY_CAP));
this.store.dispatch({ tag: 'Seed', state: { ...s, brief: target } });
this.scheduleSave();
}
private clearHistory() {
this.past.set([]);
this.future.set([]);
}
private saveTimer?: ReturnType<typeof setTimeout>;
private scheduleSave() {
if (!this.canEdit()) return;
@@ -136,6 +198,11 @@ export class BriefStore {
}
}
/** Retry a failed autosave — reuses the existing flush path, no new state (WP-27). */
retrySave() {
void this.flushSave();
}
/** Demo "start over": recreate the brief server-side and load the fresh view. */
async resetDemo() {
this.actionState.set({ tag: 'Busy' });
@@ -145,6 +212,8 @@ export class BriefStore {
if (r.ok) {
this.actionState.set({ tag: 'Idle' });
this.orgTemplate.set(r.value.orgTemplate);
this.clearHistory();
this.rejectionSnapshot.set(null);
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
} else {
this.actionState.set({ tag: 'Failed', error: r.error });
@@ -198,6 +267,9 @@ export class BriefStore {
this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions });
break;
case 'rejected':
// Capture the letter as-rejected for the resubmission diff (WP-27). This is the
// "before" snapshot the approver later compares against.
this.rejectionSnapshot.set(brief);
this.store.dispatch({
tag: 'Rejected',
by: s.rejectedBy,