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:
2026-07-02 08:50:22 +02:00
parent 053160c5c9
commit 84c2d1b6a0
23 changed files with 955 additions and 431 deletions

View File

@@ -15,7 +15,7 @@ import {
} from '@shared/infrastructure/api-client';
import { Brief, BriefStatus, LetterBlock, LetterSection, LibraryPassage, PassageScope } from '@brief/domain/brief';
import { PlaceholderDef } from '@brief/domain/placeholders';
import { Mark, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
/**
* The only place brief HTTP lives (ADR-0001 anti-corruption boundary). The wire
@@ -66,6 +66,12 @@ export class BriefAdapter {
const r = await runSubmit(() => this.client.send(), BRIEF_ACTION_FAILED);
return r.ok ? parseBrief(r.value) : r;
}
/** Demo "start over" — recreate a fresh brief server-side and return the new view. */
async reset(): Promise<Result<string, BriefView>> {
const r = await runSubmit(() => this.client.briefReset(), BRIEF_ACTION_FAILED);
return r.ok ? parseBriefView(r.value) : r;
}
}
// --- parse: wire (flat) → domain (discriminated unions), validating at the boundary ---
@@ -90,7 +96,7 @@ export function parseNode(dto: RichTextNodeDto): Result<string, RichTextNode> {
export function parseBlockContent(dto: RichTextBlockDto | undefined): Result<string, RichTextBlock> {
if (!dto || !Array.isArray(dto.paragraphs)) return err('content: paragraphs not an array');
const paragraphs = [];
const paragraphs: Paragraph[] = [];
for (const p of dto.paragraphs) {
const nodes: RichTextNode[] = [];
for (const n of p.nodes ?? []) {
@@ -98,7 +104,8 @@ export function parseBlockContent(dto: RichTextBlockDto | undefined): Result<str
if (!parsed.ok) return parsed;
nodes.push(parsed.value);
}
paragraphs.push({ nodes });
const list = p.list === 'bullet' || p.list === 'number' ? p.list : undefined;
paragraphs.push(list ? { nodes, list } : { nodes });
}
return ok({ paragraphs });
}
@@ -135,7 +142,7 @@ function parseSection(dto: LetterSectionDto): Result<string, LetterSection> {
if (!parsed.ok) return parsed;
blocks.push(parsed.value);
}
return ok({ sectionKey: dto.sectionKey, title: dto.title, required: dto.required, blocks });
return ok({ sectionKey: dto.sectionKey, title: dto.title, required: dto.required, locked: dto.locked ?? false, blocks });
}
function parsePlaceholderDef(dto: PlaceholderDefDto): Result<string, PlaceholderDef> {
@@ -245,7 +252,7 @@ function nodeToDto(n: RichTextNode): RichTextNodeDto {
}
function contentToDto(content: RichTextBlock): RichTextBlockDto {
return { paragraphs: content.paragraphs.map((p) => ({ nodes: p.nodes.map(nodeToDto) })) };
return { paragraphs: content.paragraphs.map((p) => ({ nodes: p.nodes.map(nodeToDto), ...(p.list ? { list: p.list } : {}) })) };
}
function blockToDto(b: LetterBlock): LetterBlockDto {
@@ -255,5 +262,5 @@ function blockToDto(b: LetterBlock): LetterBlockDto {
}
function sectionToDto(s: LetterSection): LetterSectionDto {
return { sectionKey: s.sectionKey, title: s.title, required: s.required, blocks: s.blocks.map(blockToDto) };
return { sectionKey: s.sectionKey, title: s.title, required: s.required, locked: s.locked, blocks: s.blocks.map(blockToDto) };
}