feat(brief): locked sections, list formatting, auto/manual placeholder chips

- brief.machine: reducer refuses edits to locked (predefined) sections as
  defense-in-depth; LetterSection gains a `locked` flag
- rich-text: paragraphs gain optional `list` kind; editor gets bullet/numbered
  list buttons, keyboard shortcuts, and backspace-deletes-adjacent-chip
- placeholder chips distinguish auto-resolvable (grey) vs manual (yellow), in
  both the editor and the read-only preview
- fix: preview chip now renders matching {…} braces (was a one-sided ⌗ glyph),
  aligned with the editor's chip styling

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-02 08:50:22 +02:00
co-authored by Claude Opus 4.8
parent 053160c5c9
commit 84c2d1b6a0
23 changed files with 955 additions and 431 deletions
+20 -2
View File
@@ -27,8 +27,8 @@ function briefWith(status: BriefStatus, sections?: Brief['sections']): Brief {
templateId: 't1',
placeholders,
sections: sections ?? [
{ sectionKey: 'aanhef', title: 'Aanhef', required: true, blocks: [] },
{ sectionKey: 'slot', title: 'Slot', required: false, blocks: [] },
{ sectionKey: 'aanhef', title: 'Aanhef', required: true, locked: false, blocks: [] },
{ sectionKey: 'slot', title: 'Slot', required: false, locked: false, blocks: [] },
],
status,
drafterId: 'u1',
@@ -92,6 +92,24 @@ describe('brief.machine reduce', () => {
expect(sectionBlocks(s, 'aanhef').map((b) => b.blockId)).toEqual(['local-1']);
});
it('edits to a locked section are no-ops (insert, free-text, content, remove, move)', () => {
const lockedSections: Brief['sections'] = [
{ sectionKey: 'aanhef', title: 'Aanhef', required: true, locked: true, blocks: [{ type: 'freeText', blockId: 'local-1', content: text('vast') }] },
{ sectionKey: 'kern', title: 'Kern', required: true, locked: false, blocks: [] },
];
const s = loaded({ tag: 'draft' }, lockedSections);
// The brief value is left untouched (withEdit reallocates state, but the guard returns
// the same brief), so assert on deep equality of the section contents.
expect(reduce(s, { tag: 'PassagesInserted', sectionKey: 'aanhef', passages: [libPassage('p1', 'aanhef')] })).toEqual(s);
expect(reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'aanhef' })).toEqual(s);
expect(reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('gehackt') })).toEqual(s);
expect(reduce(s, { tag: 'BlockRemoved', blockId: 'local-1' })).toEqual(s);
expect(reduce(s, { tag: 'BlockMovedWithinSection', blockId: 'local-1', toIndex: 0 })).toEqual(s);
// the unlocked section still accepts edits
const edited = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
expect(sectionBlocks(edited, 'kern')).toHaveLength(1);
});
it('edits are no-ops once submitted (status invariant)', () => {
const s = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
expect(reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' })).toBe(s);
+28 -7
View File
@@ -63,6 +63,17 @@ function mapSection(brief: Brief, sectionKey: string, f: (s: LetterSection) => L
return { ...brief, sections: brief.sections.map((s) => (s.sectionKey === sectionKey ? f(s) : s)) };
}
/** The section a block currently lives in, or undefined if the block is gone. */
function sectionKeyOfBlock(brief: Brief, blockId: string): string | undefined {
return brief.sections.find((s) => s.blocks.some((b) => b.blockId === blockId))?.sectionKey;
}
/** A section accepts edits only when it is not a locked (predefined) template section. */
function isSectionEditable(brief: Brief, sectionKey: string | undefined): boolean {
const section = brief.sections.find((s) => s.sectionKey === sectionKey);
return !!section && !section.locked;
}
function mapBlocks(brief: Brief, f: (blocks: readonly LetterBlock[]) => LetterBlock[]): Brief {
return { ...brief, sections: brief.sections.map((s) => ({ ...s, blocks: f(s.blocks) })) };
}
@@ -126,19 +137,29 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
case 'Seed':
return m.state;
// Section-level guard (defense-in-depth): locked sections never accept edits, even if a
// Msg reaches the reducer. The UI already hides controls for locked sections.
case 'PassagesInserted':
return withEdit(s, (b) => insertPassages(b, m.sectionKey, m.passages));
return withEdit(s, (b) => (isSectionEditable(b, m.sectionKey) ? insertPassages(b, m.sectionKey, m.passages) : b));
case 'FreeTextBlockAdded':
return withEdit(s, (b) => addFreeText(b, m.sectionKey));
return withEdit(s, (b) => (isSectionEditable(b, m.sectionKey) ? addFreeText(b, m.sectionKey) : b));
case 'BlockContentEdited':
return withEdit(s, (b) => editBlockContent(b, m.blockId, m.content));
return withEdit(s, (b) =>
isSectionEditable(b, sectionKeyOfBlock(b, m.blockId)) ? editBlockContent(b, m.blockId, m.content) : b,
);
case 'BlockRemoved':
return withEdit(s, (b) => mapBlocks(b, (blocks) => blocks.filter((x) => x.blockId !== m.blockId)));
return withEdit(s, (b) =>
isSectionEditable(b, sectionKeyOfBlock(b, m.blockId))
? mapBlocks(b, (blocks) => blocks.filter((x) => x.blockId !== m.blockId))
: b,
);
case 'BlockMovedWithinSection':
return withEdit(s, (b) =>
mapBlocks(b, (blocks) =>
blocks.some((x) => x.blockId === m.blockId) ? moveWithinSection(blocks, m.blockId, m.toIndex) : [...blocks],
),
isSectionEditable(b, sectionKeyOfBlock(b, m.blockId))
? mapBlocks(b, (blocks) =>
blocks.some((x) => x.blockId === m.blockId) ? moveWithinSection(blocks, m.blockId, m.toIndex) : [...blocks],
)
: b,
);
case 'Submitted':
+6 -6
View File
@@ -25,15 +25,15 @@ function brief(sections: Brief['sections']): Brief {
describe('brief selectors', () => {
it('unresolvedPlaceholders returns deduped manual keys only (auto excluded)', () => {
const b = brief([
{ sectionKey: 's1', title: 'S1', required: true, blocks: [passage('local-1', 'naam', 'reden')] },
{ sectionKey: 's2', title: 'S2', required: false, blocks: [passage('local-2', 'reden')] },
{ sectionKey: 's1', title: 'S1', required: true, locked: false, blocks: [passage('local-1', 'naam', 'reden')] },
{ sectionKey: 's2', title: 'S2', required: false, locked: false, blocks: [passage('local-2', 'reden')] },
]);
expect(unresolvedPlaceholders(b)).toEqual(['reden']); // 'naam' is auto; 'reden' deduped
});
it('allDiagnostics flattens across sections and blocks', () => {
const b = brief([
{ sectionKey: 's1', title: 'S1', required: true, blocks: [passage('local-1', 'reden', 'onbekend')] },
{ sectionKey: 's1', title: 'S1', required: true, locked: false, blocks: [passage('local-1', 'reden', 'onbekend')] },
]);
const codes = allDiagnostics(b).map((d) => d.code);
expect(codes).toContain('unresolved-at-send'); // reden
@@ -42,8 +42,8 @@ describe('brief selectors', () => {
});
it('canSubmit is false when a required section is empty, true otherwise', () => {
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: true, blocks: [] }]))).toBe(false);
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: false, blocks: [] }]))).toBe(true);
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: true, blocks: [passage('local-1')] }]))).toBe(true);
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: true, locked: false, blocks: [] }]))).toBe(false);
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: false, locked: false, blocks: [] }]))).toBe(true);
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: true, locked: false, blocks: [passage('local-1')] }]))).toBe(true);
});
});
+3
View File
@@ -47,6 +47,9 @@ export interface LetterSection {
readonly sectionKey: string;
readonly title: string;
readonly required: boolean;
// Predefined template sections (aanhef, slot) arrive locked and prefilled — the drafter
// composes only the unlocked section(s). The reducer refuses edits to locked sections.
readonly locked: boolean;
readonly blocks: readonly LetterBlock[];
}