From c7013f4902272473dfa07dbf0ba2f6fe571e967c Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 00:02:03 +0200 Subject: [PATCH 01/26] refactor(mention): extract shared escapeHtml helper Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/utils/mention.spec.ts | 26 +++++++++++++++++++++++++- frontend/src/lib/utils/mention.ts | 24 ++++++++++++++---------- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/frontend/src/lib/utils/mention.spec.ts b/frontend/src/lib/utils/mention.spec.ts index 5a7982be..4f15bc10 100644 --- a/frontend/src/lib/utils/mention.spec.ts +++ b/frontend/src/lib/utils/mention.spec.ts @@ -1,7 +1,31 @@ import { describe, it, expect } from 'vitest'; -import { detectMention, extractContent, renderBody } from './mention'; +import { detectMention, escapeHtml, extractContent, renderBody } from './mention'; import type { MentionDTO } from '$lib/types'; +// ─── escapeHtml ─────────────────────────────────────────────────────────────── + +describe('escapeHtml', () => { + it('escapes ampersand', () => { + expect(escapeHtml('AT&T')).toBe('AT&T'); + }); + + it('escapes less-than and greater-than', () => { + expect(escapeHtml(' + +
+ + + {#if popupOpen} +
+ {#if results.length === 0} +

{m.person_mention_popup_empty()}

+ {:else} + {#each results as person, i (person.id)} +
{ + e.preventDefault(); + selectPerson(person); + }} + > + {person.displayName} + {#if formatLifeDateRange(person.birthYear, person.deathYear)} + + {formatLifeDateRange(person.birthYear, person.deathYear)} + + {/if} +
+ {/each} + {/if} +
+ {/if} +
diff --git a/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts new file mode 100644 index 00000000..25362537 --- /dev/null +++ b/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts @@ -0,0 +1,348 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import PersonMentionEditorHost from './PersonMentionEditor.test-host.svelte'; +import type { components } from '$lib/generated/api'; + +type Person = components['schemas']['Person']; +type PersonMention = components['schemas']['PersonMention']; + +const waitForDebounce = () => new Promise((r) => setTimeout(r, 250)); +const tick = () => new Promise((r) => setTimeout(r, 0)); + +const AUGUSTE: Person = { + id: 'p-aug', + firstName: 'Auguste', + lastName: 'Raddatz', + displayName: 'Auguste Raddatz', + birthYear: 1882, + deathYear: 1944 +} as unknown as Person; + +const ANNA: Person = { + id: 'p-anna', + firstName: 'Anna', + lastName: 'Schmidt', + displayName: 'Anna Schmidt', + birthYear: 1860 +} as unknown as Person; + +function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) { + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) }); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +function mockFetchEmpty() { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) }); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +function getTextarea(): HTMLTextAreaElement { + return document.querySelector('textarea')!; +} + +function clickOption(personId: string) { + const opt = document.querySelector( + `[role="option"][data-test-person-id="${personId}"]` + ) as HTMLElement; + opt.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); +} + +type Snapshot = { value: string; mentionedPersons: PersonMention[] }; + +function renderHost(initial: { value?: string; mentionedPersons?: PersonMention[] } = {}) { + let snapshot: Snapshot = { + value: initial.value ?? '', + mentionedPersons: initial.mentionedPersons ?? [] + }; + render(PersonMentionEditorHost, { + initialValue: initial.value ?? '', + initialMentions: initial.mentionedPersons ?? [], + onChange: (snap: Snapshot) => { + snapshot = snap; + } + }); + return { + get snapshot() { + return snapshot; + } + }; +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +// ─── Rendering ──────────────────────────────────────────────────────────────── + +describe('PersonMentionEditor — rendering', () => { + it('renders the textarea with placeholder', async () => { + render(PersonMentionEditorHost, { + initialValue: '', + initialMentions: [], + placeholder: 'Transkription…', + onChange: () => {} + }); + await expect.element(page.getByPlaceholder('Transkription…')).toBeInTheDocument(); + }); + + it('reflects bound initial value', async () => { + render(PersonMentionEditorHost, { + initialValue: 'Hallo Welt', + initialMentions: [], + onChange: () => {} + }); + await expect.element(page.getByRole('textbox')).toHaveValue('Hallo Welt'); + }); +}); + +// ─── Typeahead opens on @ ───────────────────────────────────────────────────── + +describe('PersonMentionEditor — typeahead', () => { + it('opens the popup when typing @ + query and shows results', async () => { + mockFetchWithPersons(); + renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@Aug'; + ta.selectionStart = 4; + ta.selectionEnd = 4; + ta.dispatchEvent(new Event('input', { bubbles: true })); + + await waitForDebounce(); + + await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument(); + }); + + it('hits /api/persons?q= with the typed query', async () => { + const fetchMock = mockFetchWithPersons(); + renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@Aug'; + ta.selectionStart = 4; + ta.selectionEnd = 4; + ta.dispatchEvent(new Event('input', { bubbles: true })); + + await waitForDebounce(); + + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug')); + }); + + it('shows life dates next to the name in the dropdown', async () => { + mockFetchWithPersons(); + renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@Aug'; + ta.selectionStart = 4; + ta.selectionEnd = 4; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await waitForDebounce(); + + await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument(); + }); + + it('shows empty state when no persons match', async () => { + mockFetchEmpty(); + renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@xyz'; + ta.selectionStart = 4; + ta.selectionEnd = 4; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await waitForDebounce(); + + await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument(); + }); + + it('keeps the popup open when the query has a trailing space (multi-word names)', async () => { + mockFetchWithPersons(); + renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@Auguste '; + ta.selectionStart = 9; + ta.selectionEnd = 9; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await waitForDebounce(); + + await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument(); + }); +}); + +// ─── Selection writes text + sidecar ───────────────────────────────────────── + +describe('PersonMentionEditor — selecting a person', () => { + it('inserts @DisplayName followed by a trailing space into the textarea', async () => { + mockFetchWithPersons(); + const host = renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@Aug'; + ta.selectionStart = 4; + ta.selectionEnd = 4; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await waitForDebounce(); + + clickOption('p-aug'); + await tick(); + + expect(host.snapshot.value).toBe('@Auguste Raddatz '); + }); + + it('pushes {personId, displayName} into the bound mentionedPersons array', async () => { + mockFetchWithPersons(); + const host = renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@Aug'; + ta.selectionStart = 4; + ta.selectionEnd = 4; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await waitForDebounce(); + + clickOption('p-aug'); + await tick(); + + expect(host.snapshot.mentionedPersons).toEqual([ + { personId: 'p-aug', displayName: 'Auguste Raddatz' } + ]); + }); + + it('does not duplicate the sidecar entry when the same person is selected twice', async () => { + mockFetchWithPersons(); + const host = renderHost({ + value: '@Auguste Raddatz ', + mentionedPersons: [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }] + }); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@Auguste Raddatz @Aug'; + ta.selectionStart = ta.value.length; + ta.selectionEnd = ta.value.length; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await waitForDebounce(); + + clickOption('p-aug'); + await tick(); + + expect(host.snapshot.mentionedPersons).toHaveLength(1); + }); +}); + +// ─── Keyboard navigation (B11b) ────────────────────────────────────────────── + +describe('PersonMentionEditor — keyboard navigation (B11b)', () => { + it('ArrowDown / ArrowUp cycle the highlighted result', async () => { + mockFetchWithPersons(); + renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@A'; + ta.selectionStart = 2; + ta.selectionEnd = 2; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await waitForDebounce(); + + const optAuguste = document.querySelector( + '[role="option"][data-test-person-id="p-aug"]' + ) as HTMLElement; + const optAnna = document.querySelector( + '[role="option"][data-test-person-id="p-anna"]' + ) as HTMLElement; + + expect(optAuguste.getAttribute('aria-selected')).toBe('true'); + expect(optAnna.getAttribute('aria-selected')).toBe('false'); + + ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + await tick(); + + expect(optAuguste.getAttribute('aria-selected')).toBe('false'); + expect(optAnna.getAttribute('aria-selected')).toBe('true'); + + ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); + await tick(); + + expect(optAuguste.getAttribute('aria-selected')).toBe('true'); + expect(optAnna.getAttribute('aria-selected')).toBe('false'); + }); + + it('Enter selects the currently highlighted result', async () => { + mockFetchWithPersons(); + const host = renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@A'; + ta.selectionStart = 2; + ta.selectionEnd = 2; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await waitForDebounce(); + + ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + await tick(); + ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await tick(); + + expect(host.snapshot.mentionedPersons).toEqual([ + { personId: 'p-anna', displayName: 'Anna Schmidt' } + ]); + }); + + it('Escape closes the popup without inserting anything', async () => { + mockFetchWithPersons(); + const host = renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@Aug'; + ta.selectionStart = 4; + ta.selectionEnd = 4; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await waitForDebounce(); + + ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await tick(); + + await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument(); + expect(host.snapshot.value).toBe('@Aug'); + expect(host.snapshot.mentionedPersons).toEqual([]); + }); +}); + +// ─── Touch target (WCAG 2.2 AA) ────────────────────────────────────────────── + +describe('PersonMentionEditor — touch target', () => { + it('each result row has min-h-[44px] (WCAG 2.2 AA)', async () => { + mockFetchWithPersons(); + renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@Aug'; + ta.selectionStart = 4; + ta.selectionEnd = 4; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await waitForDebounce(); + + const option = document.querySelector('[role="option"]') as HTMLElement; + expect(option).not.toBeNull(); + expect(option.className).toContain('min-h-[44px]'); + }); +}); diff --git a/frontend/src/lib/components/PersonMentionEditor.test-host.svelte b/frontend/src/lib/components/PersonMentionEditor.test-host.svelte new file mode 100644 index 00000000..e608d694 --- /dev/null +++ b/frontend/src/lib/components/PersonMentionEditor.test-host.svelte @@ -0,0 +1,31 @@ + + + -- 2.49.1 From 02d3e2ab618a2187f8379335f90875cb8aacc5af Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 00:32:09 +0200 Subject: [PATCH 05/26] feat(transcription): swap plain textarea for PersonMentionEditor and thread mentionedPersons through autosave - TranscriptionBlockData now carries mentionedPersons (matches backend schema added in PR-A). - useBlockAutoSave.saveFn signature widens to (blockId, text, mentions); pendingMentions is tracked alongside pendingTexts and is preserved on failure so a retry resends the in-flight payload (B12). - TranscriptionBlock.svelte renders , exposing the textarea node back through a captureTextarea callback so the existing quote-selection feature still works. - saveBlock in routes/documents/[id]/+page.svelte forwards mentions on PUT. - flushOnUnload sends mentions in the keepalive payload too. Co-Authored-By: Claude Sonnet 4.6 --- .../lib/components/PersonMentionEditor.svelte | 9 ++- .../lib/components/TranscriptionBlock.svelte | 79 +++++++++++-------- .../TranscriptionBlock.test-host.svelte | 16 +++- .../components/TranscriptionEditView.svelte | 11 ++- .../TranscriptionEditView.svelte.spec.ts | 33 ++++++-- .../TranscriptionReadView.svelte.test.ts | 15 +++- .../__tests__/useBlockAutoSave.svelte.test.ts | 59 +++++++++----- .../__tests__/useBlockDragDrop.svelte.test.ts | 3 +- .../src/lib/hooks/useBlockAutoSave.svelte.ts | 46 +++++++++-- frontend/src/lib/types.ts | 6 ++ .../src/routes/documents/[id]/+page.svelte | 8 +- 11 files changed, 207 insertions(+), 78 deletions(-) diff --git a/frontend/src/lib/components/PersonMentionEditor.svelte b/frontend/src/lib/components/PersonMentionEditor.svelte index 45029bf9..f6c563de 100644 --- a/frontend/src/lib/components/PersonMentionEditor.svelte +++ b/frontend/src/lib/components/PersonMentionEditor.svelte @@ -16,6 +16,10 @@ type Props = { disabled?: boolean; onfocus?: () => void; onblur?: () => void; + // Optional escape hatch: lets the parent observe the underlying textarea node + // (e.g. to read selection bounds for quote-selection features). Returning a + // cleanup function from the parent is not required. + captureTextarea?: (node: HTMLTextAreaElement) => void | (() => void); }; let { @@ -25,7 +29,8 @@ let { rows = 1, disabled = false, onfocus, - onblur + onblur, + captureTextarea }: Props = $props(); let query: string | null = $state(null); @@ -38,7 +43,9 @@ let debounceTimer: ReturnType | undefined; function attachTextarea(node: HTMLTextAreaElement) { textarea = node; + const parentCleanup = captureTextarea?.(node); return () => { + parentCleanup?.(); textarea = null; }; } diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte b/frontend/src/lib/components/TranscriptionBlock.svelte index 74598c7e..97d4fcde 100644 --- a/frontend/src/lib/components/TranscriptionBlock.svelte +++ b/frontend/src/lib/components/TranscriptionBlock.svelte @@ -2,6 +2,8 @@ import { m } from '$lib/paraglide/messages.js'; import { getConfirmService } from '$lib/services/confirm.svelte.js'; import CommentThread from './CommentThread.svelte'; +import PersonMentionEditor from './PersonMentionEditor.svelte'; +import type { PersonMention } from '$lib/types'; const { confirm } = getConfirmService(); @@ -12,13 +14,14 @@ type Props = { documentId: string; blockNumber: number; text: string; + mentionedPersons: PersonMention[]; label: string | null; active: boolean; reviewed: boolean; saveState: SaveState; canComment: boolean; currentUserId: string | null; - onTextChange: (text: string) => void; + onTextChange: (text: string, mentionedPersons: PersonMention[]) => void; onFocus: () => void; onDeleteClick: () => void; onRetry: () => void; @@ -35,6 +38,7 @@ let { documentId, blockNumber, text, + mentionedPersons, label = null, active, reviewed, @@ -54,10 +58,10 @@ let { }: Props = $props(); let localText = $state(text); +let localMentions = $state([...mentionedPersons]); let commentOpen = $state(false); let commentCount = $state(0); let selectedQuote = $state(null); -let textareaEl = $state(null); const hasComments = $derived(commentCount > 0); @@ -66,6 +70,7 @@ let prevBlockId = $state(blockId); $effect(() => { if (blockId !== prevBlockId) { localText = text; + localMentions = [...mentionedPersons]; prevBlockId = blockId; } }); @@ -74,29 +79,32 @@ let leftBorderClass = $derived( saveState === 'error' ? 'border-l-2 border-error' : active ? 'border-l-2 border-turquoise' : '' ); -function autoresize(node: HTMLTextAreaElement) { +// Single source of truth for the editor's textarea — stored on attach so +// we can read selection bounds for quote selection without re-querying the DOM. +let textareaEl: HTMLTextAreaElement | null = null; + +function captureTextarea(node: HTMLTextAreaElement) { textareaEl = node; - function resize() { - node.style.height = 'auto'; - node.style.height = `${node.scrollHeight}px`; - } - - resize(); - - return { - update() { - resize(); - }, - destroy() { - textareaEl = null; - } + resizeTextarea(); + return () => { + textareaEl = null; }; } -function handleInput(event: Event) { - const target = event.target as HTMLTextAreaElement; - localText = target.value; - onTextChange(target.value); +function resizeTextarea() { + if (!textareaEl) return; + textareaEl.style.height = 'auto'; + textareaEl.style.height = `${textareaEl.scrollHeight}px`; +} + +$effect(() => { + // Re-run autoresize whenever the bound text changes. + void localText; + resizeTextarea(); +}); + +function emitChange() { + onTextChange(localText, localMentions); } async function handleDelete() { @@ -181,17 +189,24 @@ function handleTextareaMouseUp() { {/if} - - + +
+ localText, + (v) => { + localText = v; + emitChange(); + }} + bind:mentionedPersons={() => localMentions, + (m) => { + localMentions = m; + emitChange(); + }} + placeholder={m.transcription_block_placeholder()} + onfocus={onFocus} + captureTextarea={captureTextarea} + /> +
{#if selectedQuote}

{m.transcription_block_quote_hint()}

diff --git a/frontend/src/lib/components/TranscriptionBlock.test-host.svelte b/frontend/src/lib/components/TranscriptionBlock.test-host.svelte index 9a03b4bf..d2e2055d 100644 --- a/frontend/src/lib/components/TranscriptionBlock.test-host.svelte +++ b/frontend/src/lib/components/TranscriptionBlock.test-host.svelte @@ -1,21 +1,24 @@ - + diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte b/frontend/src/lib/components/TranscriptionEditView.svelte index eae2c825..64e97fef 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte +++ b/frontend/src/lib/components/TranscriptionEditView.svelte @@ -3,7 +3,7 @@ import { m } from '$lib/paraglide/messages.js'; import TranscriptionBlock from './TranscriptionBlock.svelte'; import OcrTrigger from './OcrTrigger.svelte'; import TranscribeCoachEmptyState from './TranscribeCoachEmptyState.svelte'; -import type { TranscriptionBlockData } from '$lib/types'; +import type { PersonMention, TranscriptionBlockData } from '$lib/types'; import { createBlockAutoSave } from '$lib/hooks/useBlockAutoSave.svelte'; import { createBlockDragDrop } from '$lib/hooks/useBlockDragDrop.svelte'; @@ -16,7 +16,7 @@ type Props = { storedScriptType?: string; canRunOcr?: boolean; onBlockFocus: (blockId: string) => void; - onSaveBlock: (blockId: string, text: string) => Promise; + onSaveBlock: (blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise; onDeleteBlock: (blockId: string) => Promise; onReviewToggle: (blockId: string) => Promise; onMarkAllReviewed?: () => Promise; @@ -245,16 +245,19 @@ async function handleLabelToggle(label: string) { documentId={documentId} blockNumber={i + 1} text={block.text} + mentionedPersons={block.mentionedPersons ?? []} label={block.label} active={activeBlockId === block.id} reviewed={block.reviewed ?? false} saveState={autoSave.getSaveState(block.id)} canComment={canComment} currentUserId={currentUserId} - onTextChange={(text) => autoSave.handleTextChange(block.id, text)} + onTextChange={(text, mentions) => + autoSave.handleTextChange(block.id, text, mentions)} onFocus={() => handleFocus(block.id)} onDeleteClick={() => handleDelete(block.id)} - onRetry={() => autoSave.handleRetry(block.id, block.text)} + onRetry={() => + autoSave.handleRetry(block.id, block.text, block.mentionedPersons ?? [])} onReviewToggle={() => onReviewToggle(block.id)} onMoveUp={() => handleMoveUp(block.id)} onMoveDown={() => handleMoveDown(block.id)} diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts b/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts index beeea901..39bf098c 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts +++ b/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts @@ -15,7 +15,8 @@ const block1 = { sortOrder: 0, version: 0, source: 'MANUAL' as const, - reviewed: false + reviewed: false, + mentionedPersons: [] }; const block2 = { id: 'b2', @@ -26,7 +27,8 @@ const block2 = { sortOrder: 1, version: 0, source: 'OCR' as const, - reviewed: true + reviewed: true, + mentionedPersons: [] }; function renderView(overrides: Record = {}, service = createConfirmService()) { @@ -141,7 +143,28 @@ describe('TranscriptionEditView — auto-save debounce', () => { vi.advanceTimersByTime(1500); await vi.runAllTimersAsync(); - expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Neue Zeile'); + expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Neue Zeile', []); + vi.useRealTimers(); + }); + + it('passes the block mentionedPersons array as the 3rd save argument', async () => { + vi.useFakeTimers(); + const onSaveBlock = vi.fn().mockResolvedValue(undefined); + const blockWithMention = { + ...block1, + mentionedPersons: [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }] + }; + renderView({ blocks: [blockWithMention], onSaveBlock }); + + const textarea = page.getByRole('textbox').first(); + await textarea.fill('Hallo @Auguste Raddatz'); + + vi.advanceTimersByTime(1500); + await vi.runAllTimersAsync(); + + expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Hallo @Auguste Raddatz', [ + { personId: 'p-aug', displayName: 'Auguste Raddatz' } + ]); vi.useRealTimers(); }); @@ -165,7 +188,7 @@ describe('TranscriptionEditView — auto-save debounce', () => { // Only one save with the final value expect(onSaveBlock).toHaveBeenCalledTimes(1); - expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Second'); + expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Second', []); vi.useRealTimers(); }); }); @@ -220,7 +243,7 @@ describe('TranscriptionEditView — flush on blur', () => { el.dispatchEvent(new FocusEvent('blur', { bubbles: true })); await vi.runAllTimersAsync(); - expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Blur text'); + expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Blur text', []); vi.useRealTimers(); }); }); diff --git a/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts b/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts index 70823be2..c62eee55 100644 --- a/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts +++ b/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts @@ -12,7 +12,10 @@ const blocks: TranscriptionBlockData[] = [ text: 'First paragraph text.', label: null, sortOrder: 1, - version: 1 + version: 1, + source: 'MANUAL', + reviewed: false, + mentionedPersons: [] }, { id: 'b2', @@ -49,7 +52,10 @@ describe('TranscriptionReadView', () => { text: 'Text before [unleserlich] text after', label: null, sortOrder: 1, - version: 1 + version: 1, + source: 'MANUAL', + reviewed: false, + mentionedPersons: [] } ], onParagraphClick: () => {} @@ -71,7 +77,10 @@ describe('TranscriptionReadView', () => { text: 'Some [...] text', label: null, sortOrder: 1, - version: 1 + version: 1, + source: 'MANUAL', + reviewed: false, + mentionedPersons: [] } ], onParagraphClick: () => {} diff --git a/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts b/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts index 739fb432..8e2633e0 100644 --- a/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts +++ b/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; +import type { PersonMention } from '$lib/types'; -const mockSaveFn = vi.fn<(blockId: string, text: string) => Promise>(); +const mockSaveFn = + vi.fn<(blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise>(); + +const NO_MENTIONS: PersonMention[] = []; const { createBlockAutoSave } = await import('../useBlockAutoSave.svelte'); @@ -22,25 +26,25 @@ describe('createBlockAutoSave', () => { it('debounce coalesces multiple changes — saves once after 1500ms', async () => { const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'text 1'); - as.handleTextChange('block-1', 'text 2'); - as.handleTextChange('block-1', 'text 3'); + as.handleTextChange('block-1', 'text 1', NO_MENTIONS); + as.handleTextChange('block-1', 'text 2', NO_MENTIONS); + as.handleTextChange('block-1', 'text 3', NO_MENTIONS); await vi.advanceTimersByTimeAsync(1500); expect(mockSaveFn).toHaveBeenCalledTimes(1); - expect(mockSaveFn).toHaveBeenCalledWith('block-1', 'text 3'); + expect(mockSaveFn).toHaveBeenCalledWith('block-1', 'text 3', NO_MENTIONS); }); it('handles concurrent blocks independently', async () => { const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'hello'); - as.handleTextChange('block-2', 'world'); + as.handleTextChange('block-1', 'hello', NO_MENTIONS); + as.handleTextChange('block-2', 'world', NO_MENTIONS); await vi.advanceTimersByTimeAsync(1500); expect(mockSaveFn).toHaveBeenCalledTimes(2); }); it('sets save state to saving then saved on success', async () => { const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'text'); + as.handleTextChange('block-1', 'text', NO_MENTIONS); vi.advanceTimersByTime(1500); expect(as.getSaveState('block-1')).toBe('saving'); await Promise.resolve(); @@ -50,7 +54,7 @@ describe('createBlockAutoSave', () => { it('sets save state to error on save failure', async () => { mockSaveFn.mockRejectedValue(new Error('save failed')); const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'text'); + as.handleTextChange('block-1', 'text', NO_MENTIONS); await vi.advanceTimersByTimeAsync(1500); expect(as.getSaveState('block-1')).toBe('error'); }); @@ -59,24 +63,24 @@ describe('createBlockAutoSave', () => { mockSaveFn.mockRejectedValueOnce(new Error('first fails')); mockSaveFn.mockResolvedValueOnce(undefined); const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'original'); + as.handleTextChange('block-1', 'original', NO_MENTIONS); await vi.advanceTimersByTimeAsync(1500); expect(as.getSaveState('block-1')).toBe('error'); - await as.handleRetry('block-1', 'original'); + await as.handleRetry('block-1', 'original', NO_MENTIONS); expect(mockSaveFn).toHaveBeenCalledTimes(2); expect(as.getSaveState('block-1')).toBe('saved'); }); it('clearBlock removes all state for a block', () => { const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'text'); + as.handleTextChange('block-1', 'text', NO_MENTIONS); as.clearBlock('block-1'); expect(as.getSaveState('block-1')).toBe('idle'); }); it('destroy clears all pending timers so no save occurs', async () => { const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'text'); + as.handleTextChange('block-1', 'text', NO_MENTIONS); as.destroy(); await vi.advanceTimersByTimeAsync(2000); expect(mockSaveFn).not.toHaveBeenCalled(); @@ -101,8 +105,8 @@ describe('flushOnUnload', () => { it('sends a PUT request with keepalive:true for each pending block', () => { const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'hello'); - as.handleTextChange('block-2', 'world'); + as.handleTextChange('block-1', 'hello', NO_MENTIONS); + as.handleTextChange('block-2', 'world', NO_MENTIONS); as.flushOnUnload(); expect(mockFetch).toHaveBeenCalledTimes(2); @@ -111,7 +115,7 @@ describe('flushOnUnload', () => { expect.objectContaining({ method: 'PUT', keepalive: true, - body: JSON.stringify({ text: 'hello' }) + body: JSON.stringify({ text: 'hello', mentionedPersons: [] }) }) ); expect(mockFetch).toHaveBeenCalledWith( @@ -119,7 +123,7 @@ describe('flushOnUnload', () => { expect.objectContaining({ method: 'PUT', keepalive: true, - body: JSON.stringify({ text: 'world' }) + body: JSON.stringify({ text: 'world', mentionedPersons: [] }) }) ); }); @@ -127,7 +131,7 @@ describe('flushOnUnload', () => { it('does not call navigator.sendBeacon', () => { const sendBeaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'text'); + as.handleTextChange('block-1', 'text', NO_MENTIONS); as.flushOnUnload(); expect(sendBeaconSpy).not.toHaveBeenCalled(); @@ -142,7 +146,7 @@ describe('flushOnUnload', () => { it('cancels the debounce timer so saveFn is not also called', async () => { const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'text'); + as.handleTextChange('block-1', 'text', NO_MENTIONS); as.flushOnUnload(); await vi.advanceTimersByTimeAsync(2000); @@ -151,13 +155,26 @@ describe('flushOnUnload', () => { it('does not send fetch if debounce already fired and pendingTexts is empty', async () => { const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'text'); + as.handleTextChange('block-1', 'text', NO_MENTIONS); await vi.advanceTimersByTimeAsync(1500); - // debounce has fired; pendingTexts should be empty now mockFetch.mockClear(); as.flushOnUnload(); expect(mockFetch).not.toHaveBeenCalled(); }); + + it('flushes the pending mentionedPersons sidecar alongside text', () => { + const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); + const mentions: PersonMention[] = [{ personId: 'p-1', displayName: 'Auguste Raddatz' }]; + as.handleTextChange('block-1', '@Auguste Raddatz', mentions); + as.flushOnUnload(); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/documents/doc-1/transcription-blocks/block-1', + expect.objectContaining({ + body: JSON.stringify({ text: '@Auguste Raddatz', mentionedPersons: mentions }) + }) + ); + }); }); diff --git a/frontend/src/lib/hooks/__tests__/useBlockDragDrop.svelte.test.ts b/frontend/src/lib/hooks/__tests__/useBlockDragDrop.svelte.test.ts index a0c541f3..3e4adaa2 100644 --- a/frontend/src/lib/hooks/__tests__/useBlockDragDrop.svelte.test.ts +++ b/frontend/src/lib/hooks/__tests__/useBlockDragDrop.svelte.test.ts @@ -12,7 +12,8 @@ function makeBlock(id: string, sortOrder: number): TranscriptionBlockData { sortOrder, version: 1, source: 'MANUAL', - reviewed: false + reviewed: false, + mentionedPersons: [] }; } diff --git a/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts b/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts index 07dc5692..fd76f3e8 100644 --- a/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts +++ b/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts @@ -1,9 +1,10 @@ import { SvelteMap } from 'svelte/reactivity'; +import type { PersonMention } from '$lib/types'; export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error'; type Options = { - saveFn: (blockId: string, text: string) => Promise; + saveFn: (blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise; documentId: string; }; @@ -11,6 +12,7 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) { const saveStates = new SvelteMap(); const debounceTimers = new SvelteMap>(); const pendingTexts = new SvelteMap(); + const pendingMentions = new SvelteMap(); const fadeTimers: ReturnType[] = []; function getSaveState(blockId: string): SaveState { @@ -21,18 +23,27 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) { saveStates.set(blockId, state); } + function getPendingMentions(blockId: string, fallback: PersonMention[]): PersonMention[] { + return pendingMentions.get(blockId) ?? fallback; + } + async function executeSave(blockId: string): Promise { const text = pendingTexts.get(blockId); if (text === undefined) return; + const mentions = pendingMentions.get(blockId) ?? []; pendingTexts.delete(blockId); + pendingMentions.delete(blockId); setSaveState(blockId, 'saving'); try { - await saveFn(blockId, text); + await saveFn(blockId, text, mentions); setSaveState(blockId, 'saved'); scheduleSavedFade(blockId); } catch { + // Preserve in-flight payload so the user can retry without re-typing. + pendingTexts.set(blockId, text); + pendingMentions.set(blockId, mentions); setSaveState(blockId, 'error'); } } @@ -69,11 +80,22 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) { } } - function handleTextChange(blockId: string, text: string): void { + function handleTextChange( + blockId: string, + text: string, + mentionedPersons: PersonMention[] + ): void { pendingTexts.set(blockId, text); + pendingMentions.set(blockId, mentionedPersons); scheduleDebounce(blockId); } + function handleMentionsChange(blockId: string, mentionedPersons: PersonMention[]): void { + pendingMentions.set(blockId, mentionedPersons); + // Mentions changes always accompany text changes from the editor, so the + // text-debounce timer covers them too. + } + function handleBlur(): void { for (const [blockId] of [...debounceTimers]) { clearDebounce(blockId); @@ -81,29 +103,37 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) { } } - async function handleRetry(blockId: string, currentText: string): Promise { - const pending = pendingTexts.get(blockId); - const text = pending ?? currentText; + async function handleRetry( + blockId: string, + currentText: string, + currentMentions: PersonMention[] + ): Promise { + const text = pendingTexts.get(blockId) ?? currentText; + const mentions = pendingMentions.get(blockId) ?? currentMentions; pendingTexts.set(blockId, text); + pendingMentions.set(blockId, mentions); await executeSave(blockId); } function clearBlock(blockId: string): void { clearDebounce(blockId); pendingTexts.delete(blockId); + pendingMentions.delete(blockId); saveStates.delete(blockId); } function flushOnUnload(): void { for (const [blockId, text] of pendingTexts) { + const mentions = pendingMentions.get(blockId) ?? []; clearDebounce(blockId); void fetch(`/api/documents/${documentId}/transcription-blocks/${blockId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text }), + body: JSON.stringify({ text, mentionedPersons: mentions }), keepalive: true }); pendingTexts.delete(blockId); + pendingMentions.delete(blockId); } } @@ -120,7 +150,9 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) { return { getSaveState, + getPendingMentions, handleTextChange, + handleMentionsChange, handleBlur, handleRetry, clearBlock, diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 2458d35a..f9f810e7 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -37,6 +37,11 @@ export type Comment = { export type DocumentPanelTab = 'metadata' | 'transcription' | 'discussion' | 'history'; +export type PersonMention = { + personId: string; + displayName: string; +}; + export type TranscriptionBlockData = { id: string; annotationId: string; @@ -47,6 +52,7 @@ export type TranscriptionBlockData = { version: number; source: 'MANUAL' | 'OCR'; reviewed: boolean; + mentionedPersons: PersonMention[]; updatedAt?: string | null; }; diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 5c780a54..1ea3e10d 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -88,11 +88,15 @@ async function loadTranscriptionBlocks() { } } -async function saveBlock(blockId: string, text: string) { +async function saveBlock( + blockId: string, + text: string, + mentionedPersons: import('$lib/types').PersonMention[] +) { const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text }) + body: JSON.stringify({ text, mentionedPersons }) }); if (!res.ok) throw new Error('Save failed'); const updated = await res.json(); -- 2.49.1 From e50aab257802f41327d7b9183ec0dfaa46856225 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 00:33:35 +0200 Subject: [PATCH 06/26] test(autosave): preserve text + mentionedPersons across save failure (B12) Locks in the behaviour added with the saveFn signature widening: a rejected save keeps the in-flight payload around so handleRetry resends it without the caller having to re-pass anything. Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/useBlockAutoSave.svelte.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts b/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts index 8e2633e0..93806949 100644 --- a/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts +++ b/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts @@ -71,6 +71,22 @@ describe('createBlockAutoSave', () => { expect(as.getSaveState('block-1')).toBe('saved'); }); + it('preserves the in-flight text + mentionedPersons across a save failure (B12)', async () => { + mockSaveFn.mockRejectedValueOnce(new Error('boom')); + mockSaveFn.mockResolvedValueOnce(undefined); + const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); + + const mentions: PersonMention[] = [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }]; + as.handleTextChange('block-1', '@Auguste Raddatz hi', mentions); + await vi.advanceTimersByTimeAsync(1500); + expect(as.getSaveState('block-1')).toBe('error'); + + // Retry without re-passing the data — the hook resends the preserved payload. + await as.handleRetry('block-1', 'should-not-be-used', []); + expect(mockSaveFn).toHaveBeenLastCalledWith('block-1', '@Auguste Raddatz hi', mentions); + expect(as.getSaveState('block-1')).toBe('saved'); + }); + it('clearBlock removes all state for a block', () => { const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); as.handleTextChange('block-1', 'text', NO_MENTIONS); -- 2.49.1 From 64a61f705c1ec7bcfdffdaf7258e1ef5bfda90c2 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 00:35:27 +0200 Subject: [PATCH 07/26] feat(transcription): handle 409 rename-mid-edit conflict on block save (B12b) When PersonService renames a person while a transcriber is editing a block that mentions them, the block-save endpoint returns 409 (carrying the new ErrorCode.PERSON_RENAME_CONFLICT from PR-A). saveBlock now: 1. Refetches the latest server snapshot of the block. 2. Calls mergeBlockOnConflict to combine: server's mentionedPersons (post-rename displayNames win) + transcriber's unsaved text + any local-only mentions added since the last save. 3. Updates the local block state with the merged result. 4. Re-throws so the autosave indicator surfaces the conflict and the pending payload is preserved for retry (B12). The merge logic is a pure function so it can be unit-tested in isolation and reused for any future conflict-resolution scenarios. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/utils/blockConflictMerge.spec.ts | 103 ++++++++++++++++++ frontend/src/lib/utils/blockConflictMerge.ts | 31 ++++++ .../src/routes/documents/[id]/+page.svelte | 15 +++ 3 files changed, 149 insertions(+) create mode 100644 frontend/src/lib/utils/blockConflictMerge.spec.ts create mode 100644 frontend/src/lib/utils/blockConflictMerge.ts diff --git a/frontend/src/lib/utils/blockConflictMerge.spec.ts b/frontend/src/lib/utils/blockConflictMerge.spec.ts new file mode 100644 index 00000000..e9bd1aad --- /dev/null +++ b/frontend/src/lib/utils/blockConflictMerge.spec.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest'; +import { mergeBlockOnConflict } from './blockConflictMerge'; +import type { PersonMention, TranscriptionBlockData } from '$lib/types'; + +const baseBlock: TranscriptionBlockData = { + id: 'b1', + annotationId: 'a1', + documentId: 'd1', + text: 'old text from server', + label: null, + sortOrder: 0, + version: 7, + source: 'MANUAL', + reviewed: false, + mentionedPersons: [] +}; + +describe('mergeBlockOnConflict', () => { + it('keeps the local unsaved text — never overwritten by server text (B12b)', () => { + const merged = mergeBlockOnConflict({ + serverBlock: { ...baseBlock, text: 'server-side text' }, + localText: 'transcriber unsaved input', + localMentions: [] + }); + expect(merged.text).toBe('transcriber unsaved input'); + }); + + it('takes server-side displayName for personIds present on both sides (rename win)', () => { + const localMentions: PersonMention[] = [ + { personId: 'p-aug', displayName: 'Auguste Raddatz' } // stale: server renamed her + ]; + const serverMentions: PersonMention[] = [ + { personId: 'p-aug', displayName: 'Augusta Raddatz' } // post-rename + ]; + const merged = mergeBlockOnConflict({ + serverBlock: { ...baseBlock, mentionedPersons: serverMentions }, + localText: '@Augusta Raddatz', + localMentions + }); + expect(merged.mentionedPersons).toEqual([ + { personId: 'p-aug', displayName: 'Augusta Raddatz' } + ]); + }); + + it('keeps local-only mentions added since last save', () => { + const localMentions: PersonMention[] = [ + { personId: 'p-anna', displayName: 'Anna Schmidt' } // typed since last save + ]; + const merged = mergeBlockOnConflict({ + serverBlock: { ...baseBlock, mentionedPersons: [] }, + localText: '@Anna Schmidt', + localMentions + }); + expect(merged.mentionedPersons).toContainEqual({ + personId: 'p-anna', + displayName: 'Anna Schmidt' + }); + }); + + it('returns a union of personIds when local and server diverge', () => { + const localMentions: PersonMention[] = [{ personId: 'p-anna', displayName: 'Anna Schmidt' }]; + const serverMentions: PersonMention[] = [{ personId: 'p-aug', displayName: 'Augusta Raddatz' }]; + const merged = mergeBlockOnConflict({ + serverBlock: { ...baseBlock, mentionedPersons: serverMentions }, + localText: '@Augusta Raddatz und @Anna Schmidt', + localMentions + }); + expect(merged.mentionedPersons).toHaveLength(2); + expect(merged.mentionedPersons).toContainEqual({ + personId: 'p-aug', + displayName: 'Augusta Raddatz' + }); + expect(merged.mentionedPersons).toContainEqual({ + personId: 'p-anna', + displayName: 'Anna Schmidt' + }); + }); + + it('carries server version forward so the next save sends the latest revision', () => { + const merged = mergeBlockOnConflict({ + serverBlock: { ...baseBlock, version: 42 }, + localText: 'x', + localMentions: [] + }); + expect(merged.version).toBe(42); + }); + + it('carries other server fields (sortOrder, reviewed, updatedAt) forward', () => { + const merged = mergeBlockOnConflict({ + serverBlock: { + ...baseBlock, + sortOrder: 9, + reviewed: true, + updatedAt: '2026-04-29T10:00:00Z' + }, + localText: 'x', + localMentions: [] + }); + expect(merged.sortOrder).toBe(9); + expect(merged.reviewed).toBe(true); + expect(merged.updatedAt).toBe('2026-04-29T10:00:00Z'); + }); +}); diff --git a/frontend/src/lib/utils/blockConflictMerge.ts b/frontend/src/lib/utils/blockConflictMerge.ts new file mode 100644 index 00000000..af37f9a3 --- /dev/null +++ b/frontend/src/lib/utils/blockConflictMerge.ts @@ -0,0 +1,31 @@ +import type { PersonMention, TranscriptionBlockData } from '$lib/types'; + +type MergeArgs = { + serverBlock: TranscriptionBlockData; + localText: string; + localMentions: PersonMention[]; +}; + +/** + * Resolves a 409-Conflict from the server by combining the latest server + * snapshot with the transcriber's unsaved local edits (B12b). + * + * Rules: + * - The transcriber's typed text always wins — never overwrite their input. + * - Server is the source of truth for the displayName of any person it + * knows about; renames that just landed on the server replace stale local + * names by personId. + * - Local-only mentions added since the last save are preserved. + * - All non-mention fields (version, sortOrder, reviewed, updatedAt, ...) + * come from the server snapshot so the next save sends the current + * revision and matches the latest persisted state. + */ +export function mergeBlockOnConflict(args: MergeArgs): TranscriptionBlockData { + const serverIds = new Set(args.serverBlock.mentionedPersons.map((m) => m.personId)); + const localOnly = args.localMentions.filter((m) => !serverIds.has(m.personId)); + return { + ...args.serverBlock, + text: args.localText, + mentionedPersons: [...args.serverBlock.mentionedPersons, ...localOnly] + }; +} diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 1ea3e10d..b07bad86 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -98,6 +98,21 @@ async function saveBlock( headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, mentionedPersons }) }); + if (res.status === 409) { + // Rename-mid-edit (B12b): refetch latest, merge so transcriber input survives. + const { mergeBlockOnConflict } = await import('$lib/utils/blockConflictMerge'); + const fresh = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}`); + if (fresh.ok) { + const serverBlock = await fresh.json(); + const merged = mergeBlockOnConflict({ + serverBlock, + localText: text, + localMentions: mentionedPersons + }); + transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? merged : b)); + } + throw new Error('Conflict resolved — please save again'); + } if (!res.ok) throw new Error('Save failed'); const updated = await res.json(); transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? updated : b)); -- 2.49.1 From e3175f493ccc87b285394acbb3c2fc3fb37f5dd1 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 00:39:45 +0200 Subject: [PATCH 08/26] test(transcription): backfill mentionedPersons on missed read-view fixture The b2 fixture in the second describe block had been missed when the TranscriptionBlockData type added the mentionedPersons field. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/TranscriptionReadView.svelte.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts b/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts index c62eee55..f49287cc 100644 --- a/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts +++ b/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts @@ -24,7 +24,10 @@ const blocks: TranscriptionBlockData[] = [ text: 'Second paragraph text.', label: null, sortOrder: 2, - version: 1 + version: 1, + source: 'MANUAL', + reviewed: false, + mentionedPersons: [] } ]; -- 2.49.1 From 793496440c57d6f263357c38bf3532abb932e181 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 00:50:35 +0200 Subject: [PATCH 09/26] refactor(person-mention): rename shadowed Paraglide m variable in dedup check Felix #1: inside selectPerson the .some((m) => ...) parameter shadowed the imported Paraglide m helper. Functionally fine, but a footgun. Rename to existing for clarity. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/PersonMentionEditor.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/components/PersonMentionEditor.svelte b/frontend/src/lib/components/PersonMentionEditor.svelte index f6c563de..78094c06 100644 --- a/frontend/src/lib/components/PersonMentionEditor.svelte +++ b/frontend/src/lib/components/PersonMentionEditor.svelte @@ -103,7 +103,7 @@ async function selectPerson(person: Person) { const after = value.slice(cursorPos); value = before + replacement + after; - if (!mentionedPersons.some((m) => m.personId === person.id)) { + if (!mentionedPersons.some((existing) => existing.personId === person.id)) { mentionedPersons = [...mentionedPersons, { personId: person.id!, displayName }]; } -- 2.49.1 From bbde9e8497998b7e08d7f5a050485312665b75d3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 01:02:13 +0200 Subject: [PATCH 10/26] refactor(person-mention): rename shadowed m param in TranscriptionBlock bind setter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same fix as 79349644 — the bind:mentionedPersons setter parameter `m` shadowed the imported Paraglide m helper used two lines later in placeholder={m.transcription_block_placeholder()}. Functionally fine because the inner scope ends before the outer reference, but a clarity trap. Renamed to next. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/TranscriptionBlock.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte b/frontend/src/lib/components/TranscriptionBlock.svelte index 97d4fcde..ff866e08 100644 --- a/frontend/src/lib/components/TranscriptionBlock.svelte +++ b/frontend/src/lib/components/TranscriptionBlock.svelte @@ -198,8 +198,8 @@ function handleTextareaMouseUp() { emitChange(); }} bind:mentionedPersons={() => localMentions, - (m) => { - localMentions = m; + (next) => { + localMentions = next; emitChange(); }} placeholder={m.transcription_block_placeholder()} -- 2.49.1 From cb51e8e432898671275eb4fc86ae81dc31c997e3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 01:04:25 +0200 Subject: [PATCH 11/26] refactor(autosave): drop unused handleMentionsChange + getPendingMentions exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Felix #2: both were exported anticipating a future use that never came — the editor only emits text+mentions through handleTextChange. Dead public surface invites stale code; ship the smaller API. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/hooks/useBlockAutoSave.svelte.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts b/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts index fd76f3e8..21de5442 100644 --- a/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts +++ b/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts @@ -23,10 +23,6 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) { saveStates.set(blockId, state); } - function getPendingMentions(blockId: string, fallback: PersonMention[]): PersonMention[] { - return pendingMentions.get(blockId) ?? fallback; - } - async function executeSave(blockId: string): Promise { const text = pendingTexts.get(blockId); if (text === undefined) return; @@ -90,12 +86,6 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) { scheduleDebounce(blockId); } - function handleMentionsChange(blockId: string, mentionedPersons: PersonMention[]): void { - pendingMentions.set(blockId, mentionedPersons); - // Mentions changes always accompany text changes from the editor, so the - // text-debounce timer covers them too. - } - function handleBlur(): void { for (const [blockId] of [...debounceTimers]) { clearDebounce(blockId); @@ -150,9 +140,7 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) { return { getSaveState, - getPendingMentions, handleTextChange, - handleMentionsChange, handleBlur, handleRetry, clearBlock, -- 2.49.1 From fd3a44d10c0eb515f8d71f427639dbad7ba10f26 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 01:05:47 +0200 Subject: [PATCH 12/26] refactor(transcription): typed BlockConflictResolvedError instead of prose throw Felix #3: the 409 path was throwing a human-prose Error which read like an i18n string that escaped translation. Replace with a named class carrying code='CONFLICT_RESOLVED' so callers can branch on intent and future error reporters can map the structured code instead of grepping strings. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/utils/blockConflictMerge.spec.ts | 27 ++++++++++++++++++- frontend/src/lib/utils/blockConflictMerge.ts | 16 +++++++++++ .../src/routes/documents/[id]/+page.svelte | 5 ++-- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/utils/blockConflictMerge.spec.ts b/frontend/src/lib/utils/blockConflictMerge.spec.ts index e9bd1aad..e10b245d 100644 --- a/frontend/src/lib/utils/blockConflictMerge.spec.ts +++ b/frontend/src/lib/utils/blockConflictMerge.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { mergeBlockOnConflict } from './blockConflictMerge'; +import { BlockConflictResolvedError, mergeBlockOnConflict } from './blockConflictMerge'; import type { PersonMention, TranscriptionBlockData } from '$lib/types'; const baseBlock: TranscriptionBlockData = { @@ -85,6 +85,21 @@ describe('mergeBlockOnConflict', () => { expect(merged.version).toBe(42); }); + it('carries server-only mention array through when local has none', () => { + const merged = mergeBlockOnConflict({ + serverBlock: { + ...baseBlock, + mentionedPersons: [ + { personId: 'p-aug', displayName: 'Augusta Raddatz' }, + { personId: 'p-anna', displayName: 'Anna Schmidt' } + ] + }, + localText: 'x', + localMentions: [] + }); + expect(merged.mentionedPersons).toHaveLength(2); + }); + it('carries other server fields (sortOrder, reviewed, updatedAt) forward', () => { const merged = mergeBlockOnConflict({ serverBlock: { @@ -101,3 +116,13 @@ describe('mergeBlockOnConflict', () => { expect(merged.updatedAt).toBe('2026-04-29T10:00:00Z'); }); }); + +describe('BlockConflictResolvedError', () => { + it('is an Error with code = CONFLICT_RESOLVED', () => { + const err = new BlockConflictResolvedError('block-1'); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('CONFLICT_RESOLVED'); + expect(err.name).toBe('BlockConflictResolvedError'); + expect(err.message).toContain('block-1'); + }); +}); diff --git a/frontend/src/lib/utils/blockConflictMerge.ts b/frontend/src/lib/utils/blockConflictMerge.ts index af37f9a3..c3a00039 100644 --- a/frontend/src/lib/utils/blockConflictMerge.ts +++ b/frontend/src/lib/utils/blockConflictMerge.ts @@ -1,5 +1,21 @@ import type { PersonMention, TranscriptionBlockData } from '$lib/types'; +/** + * Sentinel thrown by saveBlock after a 409 rename-mid-edit has been merged + * into local state. Surfaces to the autosave hook as an error (so the UI + * shows the retry indicator), but distinguishable from a genuine network + * failure via the code. + */ +export class BlockConflictResolvedError extends Error { + readonly code = 'CONFLICT_RESOLVED' as const; + constructor(blockId: string) { + super( + `Block ${blockId} was rebased onto the latest server snapshot — retry to save the merged result` + ); + this.name = 'BlockConflictResolvedError'; + } +} + type MergeArgs = { serverBlock: TranscriptionBlockData; localText: string; diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index b07bad86..88540618 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -100,7 +100,8 @@ async function saveBlock( }); if (res.status === 409) { // Rename-mid-edit (B12b): refetch latest, merge so transcriber input survives. - const { mergeBlockOnConflict } = await import('$lib/utils/blockConflictMerge'); + const { mergeBlockOnConflict, BlockConflictResolvedError } = + await import('$lib/utils/blockConflictMerge'); const fresh = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}`); if (fresh.ok) { const serverBlock = await fresh.json(); @@ -111,7 +112,7 @@ async function saveBlock( }); transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? merged : b)); } - throw new Error('Conflict resolved — please save again'); + throw new BlockConflictResolvedError(blockId); } if (!res.ok) throw new Error('Save failed'); const updated = await res.json(); -- 2.49.1 From 49db82e1bd94b6b2184202c27e45963a0adce75f Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 01:08:12 +0200 Subject: [PATCH 13/26] refactor(person-mention): move autoresize into PersonMentionEditor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Felix #5: TranscriptionBlock had a `\$effect(() => { void localText; ... })` hack to re-trigger autoresize on text change, plus a captureTextarea callback that the parent only used to size a node it didn't own. The editor owns the textarea — it should also size it. Move the autoresize \$effect into PersonMentionEditor so the parent only captures the node when it genuinely needs to read selection bounds (quote selection still works). Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/PersonMentionEditor.svelte | 14 ++++++++++++++ .../src/lib/components/TranscriptionBlock.svelte | 13 ------------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/frontend/src/lib/components/PersonMentionEditor.svelte b/frontend/src/lib/components/PersonMentionEditor.svelte index 78094c06..e194e0e2 100644 --- a/frontend/src/lib/components/PersonMentionEditor.svelte +++ b/frontend/src/lib/components/PersonMentionEditor.svelte @@ -43,6 +43,7 @@ let debounceTimer: ReturnType | undefined; function attachTextarea(node: HTMLTextAreaElement) { textarea = node; + resizeTextarea(); const parentCleanup = captureTextarea?.(node); return () => { parentCleanup?.(); @@ -50,6 +51,19 @@ function attachTextarea(node: HTMLTextAreaElement) { }; } +function resizeTextarea() { + if (!textarea) return; + textarea.style.height = 'auto'; + textarea.style.height = `${textarea.scrollHeight}px`; +} + +// Autoresize on every value change — read `value` so this $effect +// re-runs whenever the bound prop is reassigned. +$effect(() => { + void value; + resizeTextarea(); +}); + function handleInput() { if (!textarea) return; const cursorPos = textarea.selectionStart; diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte b/frontend/src/lib/components/TranscriptionBlock.svelte index ff866e08..982b43ff 100644 --- a/frontend/src/lib/components/TranscriptionBlock.svelte +++ b/frontend/src/lib/components/TranscriptionBlock.svelte @@ -85,24 +85,11 @@ let textareaEl: HTMLTextAreaElement | null = null; function captureTextarea(node: HTMLTextAreaElement) { textareaEl = node; - resizeTextarea(); return () => { textareaEl = null; }; } -function resizeTextarea() { - if (!textareaEl) return; - textareaEl.style.height = 'auto'; - textareaEl.style.height = `${textareaEl.scrollHeight}px`; -} - -$effect(() => { - // Re-run autoresize whenever the bound text changes. - void localText; - resizeTextarea(); -}); - function emitChange() { onTextChange(localText, localMentions); } -- 2.49.1 From 362a84dde94d0829e2b1f8a59a688d37e2c14689 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 01:09:13 +0200 Subject: [PATCH 14/26] fix(escapeHtml): cover apostrophe to harden single-quoted attribute use Sina #5505 action item: escapeHtml escaped the four common entities but not the apostrophe. Today every consumer uses double-quoted attributes, but a future renderer change to single quotes would silently open a stored-XSS hole. Cheaper to fix now, with a regression test. Also pin the idempotence-by-composition property: a second call re-escapes the & introduced by the first. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/utils/mention.spec.ts | 11 +++++++++++ frontend/src/lib/utils/mention.ts | 10 ++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/utils/mention.spec.ts b/frontend/src/lib/utils/mention.spec.ts index 4f15bc10..04b659b1 100644 --- a/frontend/src/lib/utils/mention.spec.ts +++ b/frontend/src/lib/utils/mention.spec.ts @@ -24,6 +24,17 @@ describe('escapeHtml', () => { it('escapes ampersand before other entities to avoid double-encoding', () => { expect(escapeHtml('a& { + expect(escapeHtml("d'Artagnan")).toBe('d'Artagnan'); + }); + + it('does not collapse already-encoded entities (re-escapes the &)', () => { + // escapeHtml is idempotent by composition: the second pass re-escapes + // the & that was added by the first. Pin the property so the helper + // can't be "cleverly" optimised to skip it. + expect(escapeHtml('&')).toBe('&amp;'); + }); }); // ─── detectMention ──────────────────────────────────────────────────────────── diff --git a/frontend/src/lib/utils/mention.ts b/frontend/src/lib/utils/mention.ts index c0e0f11b..8b91bc1c 100644 --- a/frontend/src/lib/utils/mention.ts +++ b/frontend/src/lib/utils/mention.ts @@ -45,15 +45,21 @@ export function extractContent( } /** - * Escapes the four HTML-special characters that can break out of text content + * Escapes the five HTML-special characters that can break out of text content * or attribute values. & must be escaped first to avoid double-encoding. + * + * Includes the apostrophe so the helper is safe in single-quoted attribute + * values too — the renderTranscriptionBody anchor template in PR-B2 uses + * double quotes today, but a future template change shouldn't open a + * stored-XSS hole (Sina #5505 action item). */ export function escapeHtml(str: string): string { return str .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') - .replaceAll('"', '"'); + .replaceAll('"', '"') + .replaceAll("'", '''); } /** -- 2.49.1 From 43aacd9f60d7cdca937426fdbb2ac1914af12b38 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 01:09:52 +0200 Subject: [PATCH 15/26] fix(transcription): UUID-guard saveBlock path interpolation Sina #5505 concern 1: doc.id and blockId are server-trusted today, but the path-interpolation pattern is repeated three times across the route and the autosave hook. Validate both ids against the standard UUID regex before any fetch fires so a future feature taking user-supplied ids cannot silently introduce a path-injection vector. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/documents/[id]/+page.svelte | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 88540618..0baa8985 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -88,11 +88,20 @@ async function loadTranscriptionBlocks() { } } +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + async function saveBlock( blockId: string, text: string, mentionedPersons: import('$lib/types').PersonMention[] ) { + // Path-injection defence in depth (Sina #5505): both ids are server-controlled + // today, but reject anything that isn't a UUID before interpolating it into + // the URL — a future feature accepting user-supplied ids must not silently + // bypass this check. + if (!UUID_RE.test(doc.id) || !UUID_RE.test(blockId)) { + throw new Error(`Invalid id for save: doc=${doc.id} block=${blockId}`); + } const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, -- 2.49.1 From cacbd577523af7159547f47f98815eb4ff2f3d9d Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 01:10:30 +0200 Subject: [PATCH 16/26] docs(person-mention): document implicit auth assumption on typeahead fetch Sina #5505 concern 2: the typeahead silently relies on the Vite-proxy cookie injection + same-origin policy for auth. Spell that out in the fetch site so the next reader doesn't have to derive it from the proxy config. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/PersonMentionEditor.svelte | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/lib/components/PersonMentionEditor.svelte b/frontend/src/lib/components/PersonMentionEditor.svelte index e194e0e2..57c6102b 100644 --- a/frontend/src/lib/components/PersonMentionEditor.svelte +++ b/frontend/src/lib/components/PersonMentionEditor.svelte @@ -94,6 +94,11 @@ function scheduleSearch(q: string) { } debounceTimer = setTimeout(async () => { try { + // SECURITY: relies on the SvelteKit Vite proxy injecting the auth_token + // cookie as the Authorization header (vite.config.ts) and on the + // browser's same-origin policy for the /api/* path. Mounted in + // transcribe mode behind WRITE_ALL — never reachable to unauthenticated + // users. const res = await fetch(`/api/persons?q=${encodeURIComponent(q)}`); if (res.ok) { const data: Person[] = await res.json(); -- 2.49.1 From f0bb1c3163a7ac98fd4e7e0ea4564004911e85b3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 01:11:33 +0200 Subject: [PATCH 17/26] fix(person-mention): close popup on textarea blur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leonie #5507 concern 1: tabbing away from the editor left the popup hanging over the next field. Add a 150ms-deferred close on blur — the delay lets onmousedown on a result fire before the popup unmounts (the race that the existing onmousedown+e.preventDefault() pattern depends on). Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/PersonMentionEditor.svelte | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/PersonMentionEditor.svelte b/frontend/src/lib/components/PersonMentionEditor.svelte index 57c6102b..1f82909d 100644 --- a/frontend/src/lib/components/PersonMentionEditor.svelte +++ b/frontend/src/lib/components/PersonMentionEditor.svelte @@ -143,6 +143,14 @@ function closePopup() { clearTimeout(debounceTimer); } +function handleBlur() { + // Small delay so an option's onmousedown can fire and select before the + // popup unmounts. Without this, clicking a result on the way out would + // race with blur and lose the selection. + setTimeout(() => closePopup(), 150); + onblur?.(); +} + function handleKeydown(e: KeyboardEvent) { if (query === null) return; @@ -191,7 +199,7 @@ const popupOpen = $derived(query !== null); oninput={handleInput} onkeydown={handleKeydown} onfocus={onfocus} - onblur={onblur} + onblur={handleBlur} > {#if popupOpen} -- 2.49.1 From a8a3b7f574394540180a78ba5eee31b825e656cf Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 01:12:37 +0200 Subject: [PATCH 18/26] fix(person-mention): textarea focus ring + 44px tap target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leonie #5507 concerns 4 + 6: - The textarea had outline-none and no focus indicator — broken for keyboard-only navigation now that the typeahead is fully keyboard-driven. - A rows=1 textarea is ~24px tall (Merriweather + 1.625 line-height), below the WCAG 2.2 AA Target Size (44×44) requirement for the focused actionable element. Add focus-visible ring/border in brand-mint and a min-h of 44px with py-2.5 padding so the empty-state textarea hits the target. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/PersonMentionEditor.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/components/PersonMentionEditor.svelte b/frontend/src/lib/components/PersonMentionEditor.svelte index 1f82909d..1f25fc80 100644 --- a/frontend/src/lib/components/PersonMentionEditor.svelte +++ b/frontend/src/lib/components/PersonMentionEditor.svelte @@ -191,7 +191,7 @@ const popupOpen = $derived(query !== null);