feat: person @mentions edit-mode infrastructure (PR-B1, #362) #369

Merged
marcel merged 26 commits from feat/person-mentions-issue-362-frontend-b1 into main 2026-04-29 08:05:49 +02:00
Owner

Scope — PR-B1 of issue #362

Frontend edit-mode infrastructure for the person @mention feature. Read-mode rendering + hover card stays for PR-B2 (per the split decided in #5339 §G).

Backend (PR-A) merged in #366 and provides the mentionedPersons JSONB column, the typed REST contract on TranscriptionBlock/UpdateTranscriptionBlockDTO, the propagation listener, and ErrorCode.PERSON_RENAME_CONFLICT (409 on rename-mid-edit).

What this PR adds

Where Purpose
escapeHtml shared helper src/lib/utils/mention.ts Pulled out of renderBody so PR-B2's renderTranscriptionBody reuses it (Felix #5294). Now also escapes ' (Sina #5505 action item).
detectPersonMention src/lib/utils/personMention.ts Multi-word query detector — the existing detectMention stops at the first space, which breaks for any historical name with a space (the common case in this archive). Stops at \n or a second @. 14 unit tests.
6 Paraglide keys (de/en/es) messages/{de,en,es}.json The 3 mandated by the plan + popup_empty/btn_label/create_new for the editor.
PersonMentionEditor.svelte src/lib/components/ Hits /api/persons?q=, renders min-h-[44px] rows (WCAG 2.2 AA touch target), shows life dates via formatLifeDateRange() (no reimplementation, per Leonie #5329), inserts @DisplayName into the textarea, pushes {personId, displayName} into the bound sidecar, deduplicates by personId. Loading indicator during fetch, empty state offers a "Neue Person anlegen" link, popup closes on blur, focus ring on textarea, keyboard-highlighted row visually distinct from hover. Keyboard: ArrowDown/Up cycle, Enter selects, Escape closes — covered by the B11b tests. 15 component tests via vitest-browser-svelte (now driven by fake timers, 13× faster).
Textarea swap TranscriptionBlock.svelte <textarea> replaced by <PersonMentionEditor>. Autoresize lives in the editor; the parent only captures the textarea node when it needs to read selection bounds (quote-selection feature).
Autosave threading useBlockAutoSave.svelte.ts saveFn widens to (blockId, text, mentionedPersons). pendingMentions mirrors pendingTexts; on save failure the in-flight payload is preserved so retry resends it without the caller having to re-pass anything (B12).
409 conflict resolution src/lib/utils/saveBlockWithConflictRetry.ts + blockConflictMerge.ts + routes/documents/[id]/+page.svelte When a rename lands mid-edit, the helper refetches the block and merges via the pure mergeBlockOnConflict() helper: server is source of truth for the post-rename displayName, transcriber's typed text always wins, local-only mentions added since the last save are preserved (B12b). Throws a typed BlockConflictResolvedError carrying the merged snapshot on its merged property. UUID-guards the path interpolation (Sina #5505). 14 unit tests across the two utils.
Type src/lib/types.ts New exported PersonMention and mentionedPersons field added to TranscriptionBlockData.

Tests

Suite Count Notes
mention.spec.ts +7 escapeHtml (5 originals + apostrophe + idempotence)
personMention.spec.ts 14 Multi-word detector, boundary cases (whitespace, newline)
PersonMentionEditor.svelte.spec.ts 15 Rendering, typeahead, selection, keyboard nav, touch target, fetch-rejects
blockConflictMerge.spec.ts 8 Pure merge logic + BlockConflictResolvedError
saveBlockWithConflictRetry.spec.ts 6 Full 409 orchestration with mock fetch
useBlockAutoSave.svelte.test.ts 15 Signature widen + B12 (in-flight preservation, with state transition assertion) + flush-mentions
TranscriptionEditView.svelte.spec.ts +1 Sidecar passthrough on save

All new tests pass. Pre-existing flaky tests are unchanged (verified with git stash against main):

  • TranscriptionBlock.svelte.spec.ts — 3 click-related tests fail both with and without this PR.
  • TranscriptionEditView.svelte.spec.ts — 2 "mark all reviewed" tests fail both with and without this PR.
  • routes/hilfe/transkription/page.svelte.spec.ts — 1 Wikipedia-link test fails both with and without this PR (root-owned .svelte-kit artifact).

What I'm NOT shipping in B1 (B2 commitments)

  • AC-3, AC-4, AC-5 — read-mode hover card + click/tap navigation (PR-B2's main scope).
  • AC-6 happy-path e2e — the propagation listener + 409 handling are both present and individually unit-tested, but the full Playwright round-trip ("type mention → save → backend rename → reload page → assert text contains new name") will land in PR-B2's e2e suite (per ReqEng #5510 §1).
  • AC-7 — person deletion → mention degrades to plain text. The sidecar already tolerates dangling personIds (see mergeBlockOnConflict not filtering); the actual rendering behaviour (404-on-fetch → degrade to plain text) belongs to PR-B2's renderTranscriptionBody + PersonHoverCard (per ReqEng #5510 §2).
  • B2-XSSdisplayName escape on render. escapeHtml is already extracted and unit-tested for B2 to consume.

Open questions

  • OQ-1 (two persons with same displayName) — closed as accepted limitation. The selectPerson dedup by personId is correct; the render-side first-sidecar-wins rule lives in PR-B2. Family archive context makes this edge case low-risk.

409 conflict UX statement

When the backend returns 409 PERSON_RENAME_CONFLICT mid-edit:

  1. The helper refetches the latest server block and merges with the transcriber's unsaved input.
  2. Local state is updated with the merged snapshot (transcriber sees the new sidecar/displayNames immediately, their typed text is preserved).
  3. BlockConflictResolvedError is thrown so the autosave hook flips the block into error state with the existing "Erneut versuchen" indicator.
  4. The transcriber's next keystroke (or click on "Erneut versuchen") triggers a fresh save with the merged state.

No new UI surface for the conflict — the existing autosave error indicator is reused.

Test plan

  • All new tests green; full suite delta = +71 tests.
  • npm run lint — clean.
  • npx svelte-check — no new errors in touched files.
  • Manual smoke: open a document in transcribe mode, type @Aug, pick a result, blur — backend should receive { text, mentionedPersons } (verified via the autosave test asserting flush payload shape).

🤖 Generated with Claude Code

## Scope — PR-B1 of issue #362 Frontend edit-mode infrastructure for the person `@mention` feature. Read-mode rendering + hover card stays for **PR-B2** (per the split decided in [#5339 §G](http://heim-nas:3005/marcel/familienarchiv/issues/362#issuecomment-5339)). Backend (PR-A) merged in #366 and provides the `mentionedPersons` JSONB column, the typed REST contract on `TranscriptionBlock`/`UpdateTranscriptionBlockDTO`, the propagation listener, and `ErrorCode.PERSON_RENAME_CONFLICT` (409 on rename-mid-edit). ## What this PR adds | | Where | Purpose | |---|---|---| | `escapeHtml` shared helper | `src/lib/utils/mention.ts` | Pulled out of `renderBody` so PR-B2's `renderTranscriptionBody` reuses it (Felix #5294). Now also escapes `'` (Sina #5505 action item). | | `detectPersonMention` | `src/lib/utils/personMention.ts` | Multi-word query detector — the existing `detectMention` stops at the first space, which breaks for any historical name with a space (the common case in this archive). Stops at `\n` or a second `@`. 14 unit tests. | | 6 Paraglide keys (de/en/es) | `messages/{de,en,es}.json` | The 3 mandated by the plan + `popup_empty`/`btn_label`/`create_new` for the editor. | | `PersonMentionEditor.svelte` | `src/lib/components/` | Hits `/api/persons?q=`, renders `min-h-[44px]` rows (WCAG 2.2 AA touch target), shows life dates via `formatLifeDateRange()` (no reimplementation, per Leonie #5329), inserts `@DisplayName` into the textarea, pushes `{personId, displayName}` into the bound sidecar, deduplicates by `personId`. Loading indicator during fetch, empty state offers a "Neue Person anlegen" link, popup closes on blur, focus ring on textarea, keyboard-highlighted row visually distinct from hover. Keyboard: ArrowDown/Up cycle, Enter selects, Escape closes — covered by the B11b tests. 15 component tests via vitest-browser-svelte (now driven by fake timers, 13× faster). | | Textarea swap | `TranscriptionBlock.svelte` | `<textarea>` replaced by `<PersonMentionEditor>`. Autoresize lives in the editor; the parent only captures the textarea node when it needs to read selection bounds (quote-selection feature). | | Autosave threading | `useBlockAutoSave.svelte.ts` | `saveFn` widens to `(blockId, text, mentionedPersons)`. `pendingMentions` mirrors `pendingTexts`; on save failure the in-flight payload is preserved so retry resends it without the caller having to re-pass anything (B12). | | 409 conflict resolution | `src/lib/utils/saveBlockWithConflictRetry.ts` + `blockConflictMerge.ts` + `routes/documents/[id]/+page.svelte` | When a rename lands mid-edit, the helper refetches the block and merges via the pure `mergeBlockOnConflict()` helper: server is source of truth for the post-rename `displayName`, transcriber's typed text always wins, local-only mentions added since the last save are preserved (B12b). Throws a typed `BlockConflictResolvedError` carrying the merged snapshot on its `merged` property. UUID-guards the path interpolation (Sina #5505). 14 unit tests across the two utils. | | Type | `src/lib/types.ts` | New exported `PersonMention` and `mentionedPersons` field added to `TranscriptionBlockData`. | ## Tests | Suite | Count | Notes | |---|---|---| | `mention.spec.ts` | +7 | `escapeHtml` (5 originals + apostrophe + idempotence) | | `personMention.spec.ts` | 14 | Multi-word detector, boundary cases (whitespace, newline) | | `PersonMentionEditor.svelte.spec.ts` | 15 | Rendering, typeahead, selection, keyboard nav, touch target, fetch-rejects | | `blockConflictMerge.spec.ts` | 8 | Pure merge logic + `BlockConflictResolvedError` | | `saveBlockWithConflictRetry.spec.ts` | 6 | Full 409 orchestration with mock fetch | | `useBlockAutoSave.svelte.test.ts` | 15 | Signature widen + B12 (in-flight preservation, with state transition assertion) + flush-mentions | | `TranscriptionEditView.svelte.spec.ts` | +1 | Sidecar passthrough on save | All new tests pass. Pre-existing flaky tests are unchanged (verified with `git stash` against `main`): - `TranscriptionBlock.svelte.spec.ts` — 3 click-related tests fail both with and without this PR. - `TranscriptionEditView.svelte.spec.ts` — 2 "mark all reviewed" tests fail both with and without this PR. - `routes/hilfe/transkription/page.svelte.spec.ts` — 1 Wikipedia-link test fails both with and without this PR (root-owned `.svelte-kit` artifact). ## What I'm NOT shipping in B1 (B2 commitments) - **AC-3, AC-4, AC-5** — read-mode hover card + click/tap navigation (PR-B2's main scope). - **AC-6 happy-path e2e** — the propagation listener + 409 handling are both present and individually unit-tested, but the full Playwright round-trip ("type mention → save → backend rename → reload page → assert text contains new name") will land in PR-B2's e2e suite (per ReqEng #5510 §1). - **AC-7** — person deletion → mention degrades to plain text. The sidecar already tolerates dangling `personId`s (see `mergeBlockOnConflict` not filtering); the actual rendering behaviour (404-on-fetch → degrade to plain text) belongs to PR-B2's `renderTranscriptionBody` + `PersonHoverCard` (per ReqEng #5510 §2). - **B2-XSS** — `displayName` escape on render. `escapeHtml` is already extracted and unit-tested for B2 to consume. ## Open questions - **OQ-1** (two persons with same `displayName`) — closed as **accepted limitation**. The `selectPerson` dedup by `personId` is correct; the render-side first-sidecar-wins rule lives in PR-B2. Family archive context makes this edge case low-risk. ## 409 conflict UX statement When the backend returns 409 PERSON_RENAME_CONFLICT mid-edit: 1. The helper refetches the latest server block and merges with the transcriber's unsaved input. 2. Local state is updated with the merged snapshot (transcriber sees the new sidecar/displayNames immediately, their typed text is preserved). 3. `BlockConflictResolvedError` is thrown so the autosave hook flips the block into `error` state with the existing "Erneut versuchen" indicator. 4. The transcriber's next keystroke (or click on "Erneut versuchen") triggers a fresh save with the merged state. No new UI surface for the conflict — the existing autosave error indicator is reused. ## Test plan - [x] All new tests green; full suite delta = +71 tests. - [x] `npm run lint` — clean. - [x] `npx svelte-check` — no new errors in touched files. - [ ] Manual smoke: open a document in transcribe mode, type `@Aug`, pick a result, blur — backend should receive `{ text, mentionedPersons }` (verified via the autosave test asserting flush payload shape). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 8 commits 2026-04-29 00:41:04 +02:00
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment mentions stop at a space; person mentions must accept spaces
because historical display names are commonly multi-word.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds the 3 keys mandated by the plan (open_link, hover_hint, load_error)
plus the editor's popup_empty + btn_label so PersonMentionEditor mirrors
the existing user-mention editor's i18n pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mirrors MentionEditor for users but searches /api/persons?q=, allows
multi-word queries (delegated to detectPersonMention), displays life
dates next to each result, and uses min-h-[44px] rows for WCAG 2.2 AA
touch targets. Selection writes both the @DisplayName text and a
{personId, displayName} sidecar entry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 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 <PersonMentionEditor>, 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
test(transcription): backfill mentionedPersons on missed read-view fixture
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m18s
CI / OCR Service Tests (push) Successful in 34s
CI / Backend Unit Tests (push) Failing after 3m5s
CI / Unit & Component Tests (pull_request) Failing after 3m10s
CI / OCR Service Tests (pull_request) Successful in 31s
CI / Backend Unit Tests (pull_request) Failing after 3m7s
e3175f493c
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 <noreply@anthropic.com>
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

The diff is well-shaped: pure helpers (detectPersonMention, mergeBlockOnConflict, escapeHtml) extracted with focused unit tests, the PersonMentionEditor carries its own host-driven component tests, and the autosave hook signature widening is mirrored across every call-site. Red/green TDD discipline is visible — pure-function tests first, component tests second, integration last. Solid PR.

A few clean-code / TDD nits before merge:

Concerns

  1. PersonMentionEditor.svelte:106 — shadowed m. Inside the dedup .some((m) => m.personId === ...), m shadows the imported Paraglide m from line 5. It works because the shadow is scoped, but it's a footgun the next reader won't enjoy. Rename to existing (and same in TranscriptionBlock.svelte:201 where (m) => { localMentions = m; ... } shadows it again).

  2. useBlockAutoSave.svelte.ts:91-95 — dead public surface. handleMentionsChange is exported but never called anywhere in the diff (the block emits text + mentions through handleTextChange only). The companion comment even admits "Mentions changes always accompany text changes from the editor". Either delete it now or leave a // TODO(B12-PR2) — exporting unused functions invites stale code. Same for getPendingMentions — only the merge path could plausibly want it, and +page.svelte doesn't.

  3. +page.svelte:113throw new Error('Conflict resolved — please save again') is control flow disguised as an exception. The autosave hook catches it and flips state to error, which is the right outcome, but the message is human-prose mid-codebase. Consider a typed sentinel: throw new BlockConflictResolvedError() or just throw Object.assign(new Error('CONFLICT_RESOLVED'), { code: 'CONFLICT_RESOLVED' }) so a future reviewer sees this is structured, not a translation string that escaped i18n.

  4. PersonMentionEditor.svelte:104value = before + replacement + after mutates the bindable but skips oninput. This means handleInput doesn't run on programmatic insertion, so the popup state is closed manually via closePopup() immediately after. Fine today, but if a future feature wants to e.g. detect a chained mention immediately after selection, it'll miss it. Not a blocker — just call out the asymmetry in a comment if you keep it.

  5. TranscriptionBlock.svelte:78-89 — autoresize lost its use: action. The old use:autoresize={localText} was a self-contained Svelte action with attach/update/destroy. The replacement is a captureTextarea callback + a side-effecting $effect(() => { void localText; resizeTextarea(); }). Functionally equivalent, but the void localText reactivity tag is the kind of thing that confuses readers. Consider $effect(() => { resizeTextarea(); }); $effect(() => { localText; }); or — cleaner — keep the action API and pass the textarea up via the action's node parameter.

Nits (skip if rushed)

  • useBlockAutoSave.svelte.ts:153getPendingMentions parameter shadows the getPendingMentions function declared 4 lines above. Cosmetic.
  • PersonMentionEditor.svelte.spec.ts:283waitForDebounce = () => new Promise((r) => setTimeout(r, 250)) — the editor debounce is 200ms; using 250ms is wisely defensive but adds 50ms × 14 tests = 0.7s. Trim to 220ms or use vitest fake timers for consistency with the autosave tests.
  • personMention.spec.ts:14'@Aug@bar' test expects null. The textbook case is @Aug @Bert (whitespace boundary, the second @ opens a new query). Worth one test for that too.

What I checked

  • TDD: every helper has unit tests written first; PersonMentionEditor has component tests covering rendering, typeahead, selection, keyboard, and touch target.
  • Component size: PersonMentionEditor.svelte is 213 lines — at the upper edge of "one nameable region" but justified (popup + textarea is one composable unit).
  • Props discipline: domain-named props throughout (value, mentionedPersons, displayName); no data blobs, no item/obj.
  • {#each} keys: line 187 keys on (person.id)
  • Autosave behaviour preserved: 13 existing tests updated to assert the new third arg; B12 + flush-mentions tests added.
  • The mergeBlockOnConflict rules match the issue's B12b spec: local text wins, server wins on shared personId, local-only mentions preserved.

Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** The diff is well-shaped: pure helpers (`detectPersonMention`, `mergeBlockOnConflict`, `escapeHtml`) extracted with focused unit tests, the `PersonMentionEditor` carries its own host-driven component tests, and the autosave hook signature widening is mirrored across every call-site. Red/green TDD discipline is visible — pure-function tests first, component tests second, integration last. Solid PR. A few clean-code / TDD nits before merge: ### Concerns 1. **`PersonMentionEditor.svelte:106` — shadowed `m`.** Inside the dedup `.some((m) => m.personId === ...)`, `m` shadows the imported Paraglide `m` from line 5. It works because the shadow is scoped, but it's a footgun the next reader won't enjoy. Rename to `existing` (and same in `TranscriptionBlock.svelte:201` where `(m) => { localMentions = m; ... }` shadows it again). 2. **`useBlockAutoSave.svelte.ts:91-95` — dead public surface.** `handleMentionsChange` is exported but never called anywhere in the diff (the block emits text + mentions through `handleTextChange` only). The companion comment even admits "Mentions changes always accompany text changes from the editor". Either delete it now or leave a `// TODO(B12-PR2)` — exporting unused functions invites stale code. Same for `getPendingMentions` — only the merge path could plausibly want it, and `+page.svelte` doesn't. 3. **`+page.svelte:113` — `throw new Error('Conflict resolved — please save again')` is control flow disguised as an exception.** The autosave hook catches it and flips state to `error`, which is the right outcome, but the message is human-prose mid-codebase. Consider a typed sentinel: `throw new BlockConflictResolvedError()` or just `throw Object.assign(new Error('CONFLICT_RESOLVED'), { code: 'CONFLICT_RESOLVED' })` so a future reviewer sees this is structured, not a translation string that escaped i18n. 4. **`PersonMentionEditor.svelte:104` — `value = before + replacement + after` mutates the bindable but skips `oninput`.** This means `handleInput` doesn't run on programmatic insertion, so the popup state is closed manually via `closePopup()` immediately after. Fine today, but if a future feature wants to e.g. detect a chained mention immediately after selection, it'll miss it. Not a blocker — just call out the asymmetry in a comment if you keep it. 5. **`TranscriptionBlock.svelte:78-89` — autoresize lost its `use:` action.** The old `use:autoresize={localText}` was a self-contained Svelte action with attach/update/destroy. The replacement is a `captureTextarea` callback + a side-effecting `$effect(() => { void localText; resizeTextarea(); })`. Functionally equivalent, but the `void localText` reactivity tag is the kind of thing that confuses readers. Consider `$effect(() => { resizeTextarea(); }); $effect(() => { localText; });` or — cleaner — keep the action API and pass the textarea up via the action's `node` parameter. ### Nits (skip if rushed) - `useBlockAutoSave.svelte.ts:153` — `getPendingMentions` parameter shadows the `getPendingMentions` function declared 4 lines above. Cosmetic. - `PersonMentionEditor.svelte.spec.ts:283` — `waitForDebounce = () => new Promise((r) => setTimeout(r, 250))` — the editor debounce is 200ms; using 250ms is wisely defensive but adds 50ms × 14 tests = 0.7s. Trim to 220ms or use vitest fake timers for consistency with the autosave tests. - `personMention.spec.ts:14` — `'@Aug@bar'` test expects null. The textbook case is `@Aug @Bert` (whitespace boundary, the second `@` opens a new query). Worth one test for that too. ### What I checked - TDD: every helper has unit tests written first; `PersonMentionEditor` has component tests covering rendering, typeahead, selection, keyboard, and touch target. - Component size: `PersonMentionEditor.svelte` is 213 lines — at the upper edge of "one nameable region" but justified (popup + textarea is one composable unit). - Props discipline: domain-named props throughout (`value`, `mentionedPersons`, `displayName`); no `data` blobs, no `item`/`obj`. - `{#each}` keys: line 187 keys on `(person.id)` ✅ - Autosave behaviour preserved: 13 existing tests updated to assert the new third arg; B12 + flush-mentions tests added. - The `mergeBlockOnConflict` rules match the issue's B12b spec: local text wins, server wins on shared personId, local-only mentions preserved. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Author
Owner

🏛️ Markus Keller — Application Architect

Verdict: Approved

The split between PR-B1 (edit infra) and PR-B2 (read render + hover card) is the right boundary — not contrived. PR-B1 owns the data flow from textarea → sidecar → autosave → backend; PR-B2 owns the inverse direction. Each PR is independently shippable and independently reversible. That's the test of a good module boundary.

What I checked

  • Layer integrity. PersonMentionEditor consumes /api/persons?q= (existing endpoint). +page.svelte consumes /api/documents/{id}/transcription-blocks/{id} for both PUT and refetch. No cross-domain repository reach-arounds — the frontend continues to talk to one backend route per concern.
  • Module ownership. mention.ts (comment mentions) and personMention.ts (transcription mentions) are deliberately distinct. The shared escapeHtml is the only piece that fans out — extracted exactly once into mention.ts, re-imported. No duplication, no premature abstraction.
  • Conflict resolution as a pure function. mergeBlockOnConflict is a 6-line stateless reducer with 6 tests. That's textbook ��� the merge logic is testable without spinning up a fetch mock or a Svelte component.
  • Type contract. PersonMention is exported from src/lib/types.ts and mirrored from the generated components['schemas']['PersonMention'] (the editor uses the latter). Two sources of truth — see Suggestion below.
  • Public surface of the autosave hook. Three new exports: getPendingMentions, handleMentionsChange. Both unused. The hook is a service module — its API surface should mirror its actual contract.

Suggestions (non-blocking)

  1. src/lib/types.ts:40 vs components['schemas']['PersonMention'] — duplicated type. The hand-rolled PersonMention in types.ts is now redundant with the OpenAPI-generated equivalent (PR-A shipped that schema). Long-term, the hand-rolled one will drift. Suggestion: re-export the generated type from types.ts and delete the hand-rolled one in a follow-up:

    import type { components } from '$lib/generated/api';
    export type PersonMention = components['schemas']['PersonMention'];
    

    Not a blocker for B1 — this is consistent with how other types in types.ts are still hand-rolled — but mention it in the B2 spec so future-you doesn't introduce a third copy.

  2. +page.svelte:101-115 — 409 logic inline in the page component. The page is the right entry point (it owns the doc-id context and the transcriptionBlocks state), but the merge-and-refetch sequence is 14 lines that don't belong in +page.svelte long-term. Once PR-B2 lands, consider extracting saveBlockWithConflictRetry() into src/lib/services/transcriptionBlockSave.ts. ADR-worthy if it grows further.

  3. No Svelte action for the mention popup positioning. The popup is absolute mt-1 w-72 — works for the common case but will clip near the viewport bottom. The issue's hover-card spec mentions getBoundingClientRect() flip rules; the editor popup deserves the same treatment eventually. Out of scope for B1 — flag as B2-adjacent.

What I'm NOT flagging

  • The hand-rolled bind: setter pair bind:value={() => localText, (v) => { localText = v; emitChange(); }} is unusual but legal Svelte 5. It's the only way to fan-out a side-effect on a bind without a parent $effect watcher. Acceptable.
  • The 200ms debounce in scheduleSearch is independent of the 1500ms autosave debounce — different concerns, different lifetimes. No coupling needed.
  • No new endpoints. Per the issue plan.

Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com

## 🏛️ Markus Keller — Application Architect **Verdict: ✅ Approved** The split between PR-B1 (edit infra) and PR-B2 (read render + hover card) is the right boundary — not contrived. PR-B1 owns the data flow from textarea → sidecar → autosave → backend; PR-B2 owns the inverse direction. Each PR is independently shippable and independently reversible. That's the test of a good module boundary. ### What I checked - **Layer integrity.** `PersonMentionEditor` consumes `/api/persons?q=` (existing endpoint). `+page.svelte` consumes `/api/documents/{id}/transcription-blocks/{id}` for both PUT and refetch. No cross-domain repository reach-arounds — the frontend continues to talk to one backend route per concern. - **Module ownership.** `mention.ts` (comment mentions) and `personMention.ts` (transcription mentions) are deliberately distinct. The shared `escapeHtml` is the only piece that fans out — extracted exactly once into `mention.ts`, re-imported. No duplication, no premature abstraction. - **Conflict resolution as a pure function.** `mergeBlockOnConflict` is a 6-line stateless reducer with 6 tests. That's textbook ��� the merge logic is testable without spinning up a fetch mock or a Svelte component. - **Type contract.** `PersonMention` is exported from `src/lib/types.ts` *and* mirrored from the generated `components['schemas']['PersonMention']` (the editor uses the latter). Two sources of truth — see Suggestion below. - **Public surface of the autosave hook.** Three new exports: `getPendingMentions`, `handleMentionsChange`. Both unused. The hook is a service module — its API surface should mirror its actual contract. ### Suggestions (non-blocking) 1. **`src/lib/types.ts:40` vs `components['schemas']['PersonMention']` — duplicated type.** The hand-rolled `PersonMention` in `types.ts` is now redundant with the OpenAPI-generated equivalent (`PR-A` shipped that schema). Long-term, the hand-rolled one will drift. Suggestion: re-export the generated type from `types.ts` and delete the hand-rolled one in a follow-up: ```ts import type { components } from '$lib/generated/api'; export type PersonMention = components['schemas']['PersonMention']; ``` Not a blocker for B1 — this is consistent with how other types in `types.ts` are still hand-rolled — but mention it in the B2 spec so future-you doesn't introduce a third copy. 2. **`+page.svelte:101-115` — 409 logic inline in the page component.** The page is the right entry point (it owns the doc-id context and the `transcriptionBlocks` state), but the merge-and-refetch sequence is 14 lines that don't belong in `+page.svelte` long-term. Once PR-B2 lands, consider extracting `saveBlockWithConflictRetry()` into `src/lib/services/transcriptionBlockSave.ts`. ADR-worthy if it grows further. 3. **No Svelte action for the mention popup positioning.** The popup is `absolute mt-1 w-72` — works for the common case but will clip near the viewport bottom. The issue's hover-card spec mentions `getBoundingClientRect()` flip rules; the editor popup deserves the same treatment eventually. Out of scope for B1 — flag as B2-adjacent. ### What I'm NOT flagging - The hand-rolled `bind:` setter pair `bind:value={() => localText, (v) => { localText = v; emitChange(); }}` is unusual but legal Svelte 5. It's the only way to fan-out a side-effect on a bind without a parent `$effect` watcher. Acceptable. - The 200ms debounce in `scheduleSearch` is independent of the 1500ms autosave debounce — different concerns, different lifetimes. No coupling needed. - No new endpoints. ✅ Per the issue plan. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Author
Owner

🛡️ Sina Tariq — Security Expert

Verdict: ⚠️ Approved with concerns

This PR is mostly edit-mode plumbing — no rendering of untrusted input, no new endpoints, no auth changes. The persisted XSS surface (rendering user-typed text as HTML) is explicitly out of scope and lives in PR-B2. That's the right line to draw, and escapeHtml is already extracted and unit-tested ready for it.

That said, the edit path does touch a few things worth flagging:

Concerns

  1. +page.svelte:104 — refetch URL is interpolated from path params. await fetch(\/api/documents/${doc.id}/transcription-blocks/${blockId}`)— bothdoc.idandblockIdare server-controlled (loaded from the page data), so this is fine *today*. But the same shape is repeated in the PUT call above and inuseBlockAutoSave.svelte.ts:108. If a future feature ever lets the user supply a blockId-shaped string client-side, you've got a path-injection latent vector. Concrete ask: add a UUID guard (/^[0-9a-f-]{36}$/i.test(blockId)) at the top of saveBlock()` before any fetch fires. Defence in depth costs three lines.

  2. PersonMentionEditor.svelte:83fetch('/api/persons?q=' + encodeURIComponent(q)) runs on every keystroke after debounce. No CSRF token, no credentials: 'include'. This works because SvelteKit's Vite proxy injects the cookie auth header, but it means the typeahead is silently authenticated — an unauthenticated user typing in the editor would get a 401 with no clear UX path. Fine for B1 (the editor is only shown to logged-in transcribers behind WRITE_ALL), but worth a // SECURITY: relies on session cookie + same-origin policy comment so the next reader understands the implicit assumption.

  3. PersonMentionEditor.svelte:84-86 — no rate-limiting on typeahead. The 200ms debounce is the only throttle. A power user typing fast will fire ~5 requests/sec; 10 transcribers = 50 req/s burst. Not a security concern per se, but a DoS-adjacent observation: /api/persons?q= should have a server-side rate limit eventually. Out of scope for this PR — flag for the security checklist on the persons-search endpoint, not for this PR.

  4. mergeBlockOnConflict trusts server-returned mentionedPersons. Server snapshot replaces local displayName for matching personIds. If the server is ever compromised, an attacker-controlled displayName containing markup will flow through localText (transcriber's typed text wins, but the sidecar displayName is server-trusted). PR-B2 needs escapeHtml on every displayName before it goes into {@html}. Already extracted — just don't forget it.

What I checked

  • No eval, no new Function, no dangerouslyInnerHTML, no {@html} in this PR.
  • encodeURIComponent on the query param at line 83 — proper.
  • The new escapeHtml helper escapes & first (correctly, to avoid double-encoding) and covers <, >, ". Missing: ' (apostrophe). For attribute values quoted with double-quotes, this is fine, but if PR-B2 ever uses single-quoted attributes it'll bite. Recommend adding .replaceAll("'", '&#39;') defensively. (Cheap to do here, would be one more case in mention.spec.ts.)
  • No SQL — frontend-only PR.
  • No new auth/permission decisions — relies on existing WRITE_ALL gate on transcription routes.
  • The 409 rename-mid-edit handling does not leak server-internal error details to the user (just a generic "save again" prompt).

Action items

  • Suggestion (cheap, do it now): add ' to escapeHtml and one test case. Pre-empts a B2 footgun.
  • Suggestion (B2-adjacent): when PR-B2 introduces renderTranscriptionBody, every displayName from the sidecar must go through escapeHtml before substitution into the <button data-person-id="...">{name}</button> template — both the name and the id should be escaped. Add a stored-XSS test fixture: displayName: '"><script>alert(1)</script>'.

Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com

## 🛡️ Sina Tariq — Security Expert **Verdict: ⚠️ Approved with concerns** This PR is mostly edit-mode plumbing — no rendering of untrusted input, no new endpoints, no auth changes. The persisted XSS surface (rendering user-typed text as HTML) is explicitly out of scope and lives in PR-B2. That's the right line to draw, and `escapeHtml` is already extracted and unit-tested ready for it. That said, the edit path *does* touch a few things worth flagging: ### Concerns 1. **`+page.svelte:104` — refetch URL is interpolated from path params.** `await fetch(\`/api/documents/${doc.id}/transcription-blocks/${blockId}\`)` — both `doc.id` and `blockId` are server-controlled (loaded from the page data), so this is fine *today*. But the same shape is repeated in the PUT call above and in `useBlockAutoSave.svelte.ts:108`. If a future feature ever lets the user supply a `blockId`-shaped string client-side, you've got a path-injection latent vector. Concrete ask: add a UUID guard (`/^[0-9a-f-]{36}$/i.test(blockId)`) at the top of `saveBlock()` before any fetch fires. Defence in depth costs three lines. 2. **`PersonMentionEditor.svelte:83` — `fetch('/api/persons?q=' + encodeURIComponent(q))` runs on every keystroke after debounce.** No CSRF token, no `credentials: 'include'`. This works because SvelteKit's Vite proxy injects the cookie auth header, but it means the typeahead is silently authenticated — an unauthenticated user typing in the editor would get a 401 with no clear UX path. Fine for B1 (the editor is only shown to logged-in transcribers behind `WRITE_ALL`), but worth a `// SECURITY: relies on session cookie + same-origin policy` comment so the next reader understands the implicit assumption. 3. **`PersonMentionEditor.svelte:84-86` — no rate-limiting on typeahead.** The 200ms debounce is the only throttle. A power user typing fast will fire ~5 requests/sec; 10 transcribers = 50 req/s burst. Not a security concern per se, but a DoS-adjacent observation: `/api/persons?q=` should have a server-side rate limit eventually. Out of scope for this PR — flag for the security checklist on the persons-search endpoint, not for this PR. 4. **`mergeBlockOnConflict` trusts server-returned `mentionedPersons`.** Server snapshot replaces local `displayName` for matching `personId`s. If the server is ever compromised, an attacker-controlled `displayName` containing markup will flow through `localText` (transcriber's typed text wins, but the sidecar `displayName` is server-trusted). PR-B2 needs `escapeHtml` on every `displayName` *before* it goes into `{@html}`. Already extracted — just don't forget it. ### What I checked - ✅ No `eval`, no `new Function`, no `dangerouslyInnerHTML`, no `{@html}` in this PR. - ✅ `encodeURIComponent` on the query param at line 83 — proper. - ✅ The new `escapeHtml` helper escapes `&` first (correctly, to avoid double-encoding) and covers `<`, `>`, `"`. **Missing**: `'` (apostrophe). For attribute values quoted with double-quotes, this is fine, but if PR-B2 ever uses single-quoted attributes it'll bite. Recommend adding `.replaceAll("'", '&#39;')` defensively. (Cheap to do here, would be one more case in `mention.spec.ts`.) - ✅ No SQL — frontend-only PR. - ✅ No new auth/permission decisions — relies on existing `WRITE_ALL` gate on transcription routes. - ✅ The `409` rename-mid-edit handling does not leak server-internal error details to the user (just a generic "save again" prompt). ### Action items - **Suggestion (cheap, do it now):** add `'` to `escapeHtml` and one test case. Pre-empts a B2 footgun. - **Suggestion (B2-adjacent):** when PR-B2 introduces `renderTranscriptionBody`, every `displayName` from the sidecar must go through `escapeHtml` before substitution into the `<button data-person-id="...">{name}</button>` template — both the name and the id should be escaped. Add a stored-XSS test fixture: `displayName: '"><script>alert(1)</script>'`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Author
Owner

🧪 Tester Persona

Verdict: ⚠️ Approved with concerns

Test coverage on this PR is genuinely strong — 40+ new assertions across 6 test files, the new pure functions are 100%-covered, and every existing test was updated to match the widened signature instead of being silenced. That's a good faith effort, not check-box TDD.

But there are a few testing gaps and stability risks worth raising before merge.

Concerns

  1. PersonMentionEditor.svelte.spec.ts — real timers + 250ms sleeps everywhere. Fourteen tests × await waitForDebounce() of setTimeout(r, 250) = 3.5 seconds of real wall-clock waiting per test run. The autosave hook tests use vi.useFakeTimers() and run in milliseconds. Apply the same pattern here:

    vi.useFakeTimers();
    // dispatch input
    await vi.advanceTimersByTimeAsync(200);
    

    Browser-mode tests support fake timers. This will both speed up the suite and make it deterministic — the current 250ms wait is racing against the 200ms debounce inside the editor with only 50ms of slack, which on a busy CI runner is a flake waiting to happen.

  2. No test for +page.svelte:101 409 conflict path. The merge function has 6 unit tests and the autosave hook has B12 (preserve in-flight on failure), but the actual orchestration in saveBlock — "PUT returns 409 → refetch → merge → throw Conflict resolved" — is untested. Suggested test: stub fetch to return 409 then 200-with-block-data, call saveBlock, assert the merged block landed in transcriptionBlocks and an error was thrown. Without it, the integration is purely visual.

  3. PersonMentionEditor.svelte.spec.ts:495 — relies on test ordering for "does not duplicate". The test passes a mentionedPersons initial value but then types @Auguste Raddatz @Aug on top. The textarea state is set imperatively (ta.value = ...) but the value prop binding may not have observed it (no input event between the host re-mount and the new value). Confirm by adding an explicit ta.dispatchEvent(new Event('input')) before the second @Aug is checked, or assert the initial state explicitly.

  4. Missing edge case: typeahead with fetch rejecting (network error). Line 90: } catch { results = []; }. There's a test for mockFetchEmpty() returning ok: true, json: [], but no test for a thrown rejection (no network). Add one — it's two lines and prevents a regression where someone replaces the broad catch with a .then(...) chain that bubbles.

  5. useBlockAutoSave.svelte.test.ts:79-92 (B12) tests handleRetry('block-1', 'should-not-be-used', []). Good test, but the retry path also runs through executeSave, which in turn calls setSaveState('saving') then 'saved'. Worth asserting the state transition explicitly: expect(states).toEqual(['saving', 'saved']). Right now the test only asserts the final 'saved' state, which would also pass if the hook skipped the saving state entirely.

Nits (cheap)

  • mention.spec.ts — the new escapeHtml block has 5 cases but no test for already-encoded input (escapeHtml('&amp;')'&amp;amp;'). The doc comment promises this property; pin it with a test.
  • personMention.spec.ts:13'@Aug\nfoo', 8 — cursor 8 is after the newline. Add a complement: '@Aug\nfoo', 4 — cursor immediately at the newline boundary, expecting 'Aug'. The boundary case is where regressions hide.
  • blockConflictMerge.spec.ts — no test for an empty local mention array against a non-empty server array. Probably implied by another test, but explicitly: "server has 2, local has 0 → merged has 2". One-line addition.

What I checked

  • Test pyramid: 26 unit tests (pure helpers) + 14 component tests + 0 e2e. Pyramid shape is correct for B1 (no integration to mock).
  • Determinism: most tests are deterministic; the setTimeout(250) waits in the editor spec are the one wobbly bit.
  • Test naming: descriptive ("ArrowDown / ArrowUp cycle the highlighted result", "preserves the in-flight text + mentionedPersons across a save failure (B12)") — these read like specs, which is what tests should do.
  • Pre-existing flaky tests (3 TranscriptionBlock clicks, 2 TranscriptionEditView mark-all-reviewed, 1 hilfe page) are correctly excluded from the test plan with a verified-against-main note. Acceptable.

Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com

## 🧪 Tester Persona **Verdict: ⚠️ Approved with concerns** Test coverage on this PR is genuinely strong — 40+ new assertions across 6 test files, the new pure functions are 100%-covered, and every existing test was updated to match the widened signature instead of being silenced. That's a good faith effort, not check-box TDD. But there are a few testing gaps and stability risks worth raising before merge. ### Concerns 1. **`PersonMentionEditor.svelte.spec.ts` — real timers + 250ms sleeps everywhere.** Fourteen tests × `await waitForDebounce()` of `setTimeout(r, 250)` = 3.5 seconds of real wall-clock waiting per test run. The autosave hook tests use `vi.useFakeTimers()` and run in milliseconds. Apply the same pattern here: ```ts vi.useFakeTimers(); // dispatch input await vi.advanceTimersByTimeAsync(200); ``` Browser-mode tests support fake timers. This will both speed up the suite and make it deterministic — the current 250ms wait is racing against the 200ms debounce inside the editor with only 50ms of slack, which on a busy CI runner is a flake waiting to happen. 2. **No test for `+page.svelte:101` 409 conflict path.** The merge function has 6 unit tests and the autosave hook has B12 (preserve in-flight on failure), but the actual orchestration in `saveBlock` — "PUT returns 409 → refetch → merge → throw `Conflict resolved`" — is untested. Suggested test: stub `fetch` to return 409 then 200-with-block-data, call `saveBlock`, assert the merged block landed in `transcriptionBlocks` and an error was thrown. Without it, the integration is purely visual. 3. **`PersonMentionEditor.svelte.spec.ts:495` — relies on test ordering for "does not duplicate".** The test passes a `mentionedPersons` initial value but then types `@Auguste Raddatz @Aug` on top. The textarea state is set imperatively (`ta.value = ...`) but the `value` prop binding may not have observed it (no input event between the host re-mount and the new value). Confirm by adding an explicit `ta.dispatchEvent(new Event('input'))` *before* the second `@Aug` is checked, or assert the initial state explicitly. 4. **Missing edge case: typeahead with `fetch` rejecting (network error).** Line 90: `} catch { results = []; }`. There's a test for `mockFetchEmpty()` returning `ok: true, json: []`, but no test for a thrown rejection (no network). Add one — it's two lines and prevents a regression where someone replaces the broad catch with a `.then(...)` chain that bubbles. 5. **`useBlockAutoSave.svelte.test.ts:79-92` (B12) tests `handleRetry('block-1', 'should-not-be-used', [])`.** Good test, but the retry path also runs through `executeSave`, which in turn calls `setSaveState('saving')` then `'saved'`. Worth asserting the state transition explicitly: `expect(states).toEqual(['saving', 'saved'])`. Right now the test only asserts the final `'saved'` state, which would also pass if the hook skipped the saving state entirely. ### Nits (cheap) - `mention.spec.ts` — the new `escapeHtml` block has 5 cases but no test for already-encoded input (`escapeHtml('&amp;')` → `'&amp;amp;'`). The doc comment promises this property; pin it with a test. - `personMention.spec.ts:13` — `'@Aug\nfoo', 8` — cursor 8 is *after* the newline. Add a complement: `'@Aug\nfoo', 4` — cursor immediately at the newline boundary, expecting `'Aug'`. The boundary case is where regressions hide. - `blockConflictMerge.spec.ts` — no test for an empty local mention array against a non-empty server array. Probably implied by another test, but explicitly: "server has 2, local has 0 → merged has 2". One-line addition. ### What I checked - Test pyramid: 26 unit tests (pure helpers) + 14 component tests + 0 e2e. Pyramid shape is correct for B1 (no integration to mock). - Determinism: most tests are deterministic; the `setTimeout(250)` waits in the editor spec are the one wobbly bit. - Test naming: descriptive ("ArrowDown / ArrowUp cycle the highlighted result", "preserves the in-flight text + mentionedPersons across a save failure (B12)") — these read like specs, which is what tests should do. - Pre-existing flaky tests (3 `TranscriptionBlock` clicks, 2 `TranscriptionEditView` mark-all-reviewed, 1 hilfe page) are correctly excluded from the test plan with a verified-against-main note. Acceptable. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Author
Owner

🎨 Leonie Werner — UI/UX Expert

Verdict: ⚠️ Approved with concerns

The 44px touch targets, the life-dates row in the dropdown, and the German-first translations in the popup empty state — all signs you respected the dual-audience rule (60+ transcribers on tablets, 25-42 readers on phones). Reuse of formatLifeDateRange is exactly the kind of shared-vocabulary discipline I asked for in #5329. Thanks.

But there are a handful of UX issues that will be felt by transcribers within minutes of using this:

Concerns

  1. The popup never closes on outside click or blur. Tested mentally: open the editor, type @Aug, popup opens, tab away to comment box, popup is still hanging there over the next textarea. The current close paths are: Escape, selecting a result, or detected === null from typing past a newline. Add onblur={closePopup} on the textarea (with a small setTimeout to let onmousedown fire first on the option) — this is a basic-hygiene typeahead behaviour. Ten transcribers will find this within the first hour.

  2. No visual indication of the "@" trigger / no help text. The textarea looks identical to before. A first-time transcriber won't discover the @-mention feature without being told. At minimum: when the textarea is focused and empty, show a subtle hint near the bottom: "Tipp: @ tippen um eine Person zu verlinken". The existing transcription_block_quote_hint pattern is a good model. Bonus: a small "@-Person verlinken" button next to the existing label affordances.

  3. Highlighted-row contrast. bg-canvas (the same grey as :hover) for the keyboard-highlighted row means no visual difference between hover and ArrowDown selection. A 65-year-old with a trackpad sweeping over rows can't tell where the keyboard cursor is. Use bg-canvas for hover and a stronger token (e.g. bg-brand-mint/25 or ring-2 ring-brand-mint) for aria-selected="true". Two-state selection is a textbook a11y issue (WCAG 1.4.11 Non-Text Contrast).

  4. No focus ring on the textarea. The original textarea had outline-none and the new one inherits that. With the typeahead now being keyboard-driven, the textarea is the keyboard nexus — losing its focus indicator entirely is a problem for keyboard-only users. Add focus-visible:ring-2 focus-visible:ring-brand-mint focus-visible:ring-inset (or move to outline rather than ring; either is fine — just something).

  5. Empty state UX is mildly hostile. "Keine Personen gefunden" — fine, but no escape hatch. After typing @xyz, the user is told their search failed and… that's it. Consider adding "Person anlegen?" with a link to /persons/new?name={query} (the existing PersonTypeahead does this). Or at minimum: when results are empty after a search has fired, show a subtle "Vielleicht ist die Person noch nicht im Archiv? [Neue Person anlegen]" hint.

  6. min-h-[44px] is on the row — but the textarea itself has rows={1}. A single-row textarea is ~24px tall (Merriweather 16px + line-height 1.625). The textarea's touch target is below 44px when collapsed. WCAG 2.2 AA Target Size requires the actionable element. The popup options pass; the textarea itself fails. Easy fix: min-h-[44px] on the textarea container (the parent div.relative), or py-2.5 on the textarea so it expands.

  7. No loading indicator during the 200ms debounce + fetch. When the network is slow, the user types @Aug and stares at an empty popup for up to 1-2s before results appear. A skeleton row or a "Suche…" message would close the perceptual gap. The hover-card spec in #362 already mandates a skeleton shimmer for the same reason — apply the principle here.

Nits

  • text-base on the option name + text-xs on the date row — a 4-step type ramp inside a 44px row. Consider text-sm for the name, the text-xs for the date — keeps the row vertically compact for tablets.
  • The w-72 (288px) popup is fixed-width. On a 320px-wide phone (the lowest reader target), this would clip. PR-B2 covers read mode, but since the editor is device-responsive, consider max-w-[min(18rem,calc(100vw-2rem))].
  • placeholder:text-ink-3 on the textarea — ink-3 is your "tertiary" colour, which is borderline grey-on-grey. Run a contrast check; if WCAG AA fails, escalate to ink-2.

What I checked

  • Brand palette adherence — brand-navy/brand-mint/brand-sand not directly referenced (uses semantic tokens text-ink, bg-surface, border-line — correct, that's how design tokens should work).
  • Font discipline — font-serif for names, font-sans for metadata. Consistent.
  • German-first translations checked: "Keine Personen gefunden" (good — present tense, matches existing copy register), "Person verlinken" (good).
  • ⚠️ No screenshots or proofshot artifacts in the PR. For a UI feature, that's a yellow flag — the spec changed (textarea swap, new popup), and the proofshot tooling is configured for exactly this. Run a quick proofshot session before merge.
  • Reuses formatLifeDateRange instead of reimplementing the * / ASCII art. Good.

Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com

## 🎨 Leonie Werner — UI/UX Expert **Verdict: ⚠️ Approved with concerns** The 44px touch targets, the life-dates row in the dropdown, and the German-first translations in the popup empty state — all signs you respected the dual-audience rule (60+ transcribers on tablets, 25-42 readers on phones). Reuse of `formatLifeDateRange` is exactly the kind of shared-vocabulary discipline I asked for in #5329. Thanks. But there are a handful of UX issues that *will* be felt by transcribers within minutes of using this: ### Concerns 1. **The popup never closes on outside click or blur.** Tested mentally: open the editor, type `@Aug`, popup opens, tab away to comment box, popup is still hanging there over the next textarea. The current close paths are: Escape, selecting a result, or `detected === null` from typing past a newline. Add `onblur={closePopup}` on the textarea (with a small `setTimeout` to let `onmousedown` fire first on the option) — this is a basic-hygiene typeahead behaviour. Ten transcribers will find this within the first hour. 2. **No visual indication of the "@" trigger / no help text.** The textarea looks identical to before. A first-time transcriber won't discover the `@`-mention feature without being told. At minimum: when the textarea is focused and empty, show a subtle hint near the bottom: *"Tipp: @ tippen um eine Person zu verlinken"*. The existing `transcription_block_quote_hint` pattern is a good model. Bonus: a small "@-Person verlinken" button next to the existing label affordances. 3. **Highlighted-row contrast.** `bg-canvas` (the same grey as `:hover`) for the keyboard-highlighted row means *no visual difference between hover and ArrowDown selection*. A 65-year-old with a trackpad sweeping over rows can't tell where the keyboard cursor is. Use `bg-canvas` for hover and a stronger token (e.g. `bg-brand-mint/25` or `ring-2 ring-brand-mint`) for `aria-selected="true"`. Two-state selection is a textbook a11y issue (WCAG 1.4.11 Non-Text Contrast). 4. **No focus ring on the textarea.** The original textarea had `outline-none` and the new one inherits that. With the typeahead now being keyboard-driven, the textarea is the keyboard nexus — losing its focus indicator entirely is a problem for keyboard-only users. Add `focus-visible:ring-2 focus-visible:ring-brand-mint focus-visible:ring-inset` (or move to `outline` rather than ring; either is fine — just *something*). 5. **Empty state UX is mildly hostile.** "Keine Personen gefunden" — fine, but no escape hatch. After typing `@xyz`, the user is told their search failed and… that's it. Consider adding "Person anlegen?" with a link to `/persons/new?name={query}` (the existing PersonTypeahead does this). Or at minimum: when results are empty after a search has fired, show a subtle "Vielleicht ist die Person noch nicht im Archiv? [Neue Person anlegen]" hint. 6. **`min-h-[44px]` is on the row — but the textarea itself has `rows={1}`.** A single-row textarea is ~24px tall (Merriweather 16px + line-height 1.625). The textarea's *touch target* is below 44px when collapsed. WCAG 2.2 AA Target Size requires the actionable element. The popup options pass; the textarea itself fails. Easy fix: `min-h-[44px]` on the textarea container (the parent `div.relative`), or `py-2.5` on the textarea so it expands. 7. **No loading indicator during the 200ms debounce + fetch.** When the network is slow, the user types `@Aug` and stares at an empty popup for up to 1-2s before results appear. A skeleton row or a "Suche…" message would close the perceptual gap. The hover-card spec in #362 already mandates a skeleton shimmer for the same reason — apply the principle here. ### Nits - `text-base` on the option name + `text-xs` on the date row — a 4-step type ramp inside a 44px row. Consider `text-sm` for the name, the `text-xs` for the date — keeps the row vertically compact for tablets. - The `w-72` (288px) popup is fixed-width. On a 320px-wide phone (the lowest reader target), this would clip. PR-B2 covers read mode, but since the editor *is* device-responsive, consider `max-w-[min(18rem,calc(100vw-2rem))]`. - `placeholder:text-ink-3` on the textarea — `ink-3` is your "tertiary" colour, which is borderline grey-on-grey. Run a contrast check; if WCAG AA fails, escalate to `ink-2`. ### What I checked - ✅ Brand palette adherence — `brand-navy`/`brand-mint`/`brand-sand` not directly referenced (uses semantic tokens `text-ink`, `bg-surface`, `border-line` — correct, that's how design tokens should work). - ✅ Font discipline — `font-serif` for names, `font-sans` for metadata. Consistent. - ✅ German-first translations checked: "Keine Personen gefunden" (good — present tense, matches existing copy register), "Person verlinken" (good). - ⚠️ No screenshots or proofshot artifacts in the PR. For a UI feature, that's a yellow flag — the spec changed (textarea swap, new popup), and the proofshot tooling is configured for exactly this. Run a quick proofshot session before merge. - ✅ Reuses `formatLifeDateRange` instead of reimplementing the `*` / `†` ASCII art. Good. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Author
Owner

⚙️ DevOps Persona

Verdict: LGTM (frontend-only)

This PR is purely frontend code: 3 i18n JSON files, 2 new Svelte components, 1 new utility, 1 new test-host, 1 service hook update, plus tests. No Dockerfile changes, no docker-compose changes, no CI workflow changes, no migration files, no infra config, no MinIO bucket changes.

What I checked

  • No Dockerfile / docker-compose.yml touched.
  • No .github/workflows/ or .gitea/workflows/ touched.
  • No db/migration/ files (backend V{n} migrations are PR-A).
  • No new external dependencies in package.json (the diff doesn't touch it).
  • No build script or env var changes.
  • The new Paraglide message keys will be picked up by the existing Vite plugin at dev/build time — no compilation step required.

Observations (not blockers)

  • The new typeahead fires /api/persons?q= on every typed character (after a 200ms debounce). The persons-search endpoint is in PR-A and presumably unbounded; if the production deploy gets even a handful of concurrent transcribers, expect a measurable bump in DB load on persons searches. Worth asking whether a server-side rate-limit or response cache (60s TTL by query) belongs in the persons controller. Filing as devops-adjacent observation, not as a blocker for this PR.
  • No frontend telemetry / observability around the 409 conflict path. If the rename-mid-edit conflict is rarer than expected (or never fires), you won't know without instrumentation. Future: a posthog.capture('block_conflict_resolved') or equivalent. Not for this PR.

Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com

## ⚙️ DevOps Persona **Verdict: ✅ LGTM (frontend-only)** This PR is purely frontend code: 3 i18n JSON files, 2 new Svelte components, 1 new utility, 1 new test-host, 1 service hook update, plus tests. No Dockerfile changes, no docker-compose changes, no CI workflow changes, no migration files, no infra config, no MinIO bucket changes. ### What I checked - ✅ No `Dockerfile` / `docker-compose.yml` touched. - ✅ No `.github/workflows/` or `.gitea/workflows/` touched. - ✅ No `db/migration/` files (backend `V{n}` migrations are PR-A). - ✅ No new external dependencies in `package.json` (the diff doesn't touch it). - ✅ No build script or env var changes. - ✅ The new Paraglide message keys will be picked up by the existing Vite plugin at dev/build time — no compilation step required. ### Observations (not blockers) - The new typeahead fires `/api/persons?q=` on every typed character (after a 200ms debounce). The persons-search endpoint is in PR-A and presumably unbounded; if the production deploy gets even a handful of concurrent transcribers, expect a measurable bump in DB load on `persons` searches. Worth asking whether a server-side rate-limit or response cache (60s TTL by query) belongs in the persons controller. Filing as devops-adjacent observation, not as a blocker for this PR. - No frontend telemetry / observability around the 409 conflict path. If the rename-mid-edit conflict is rarer than expected (or never fires), you won't know without instrumentation. Future: a `posthog.capture('block_conflict_resolved')` or equivalent. Not for this PR. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Author
Owner

📋 Requirements Engineer

Verdict: ⚠️ Approved with concerns

The PR description is unusually disciplined — every change is mapped to a spec section (B11b, B12, B12b) and OOS items are explicitly split to PR-B2 with a link back to the issue thread. That is the level of traceability I usually have to extract by interview. Thank you.

That said, when I cross-walk the PR against issue #362's acceptance criteria, three of them are partially satisfied by this PR but cannot be claimed as done until B2 lands. I want to make sure that's deliberate and visible.

Acceptance-criteria walk-through

Pulling each AC from the issue body:

# AC summary Status in PR-B1 Notes
AC-1 Type @Aug → results show name + life dates Implemented Tests cover this.
AC-2 Select a person → text gets @DisplayName, sidecar gets {personId, displayName}, no UUID in text Implemented Tests cover this.
AC-3 Reader hovers → hover card within 200ms OOS — PR-B2 Correctly excluded.
AC-4 Click mention → navigate to /persons/{id} OOS — PR-B2 Correctly excluded.
AC-5 Touch tap → navigate directly OOS — PR-B2 Correctly excluded.
AC-6 Display name update → text + sidecar updated atomically ⚠️ Partially Backend (PR-A) does the propagation. Frontend handles 409 mid-edit. But there is no e2e test confirming: rename a person → reload doc → block.text shows new name. The merge-on-409 unit tests are isolated from the rename-propagation flow.
AC-7 Person deleted → mention degrades to plain text Not addressed in B1 or B2 explicitly See concern 3 below.

Concerns (requirements-side)

  1. AC-6: end-to-end rename-propagation isn't verified anywhere. PR-A has the listener; PR-B1 has the 409-merge for the mid-edit case. But the happy path — "transcriber edits block, rename happens, transcriber re-opens doc, sees new name in text and sidecar" — relies on PR-A propagation working AND the frontend re-fetching the block on doc load. There's no test that asserts this round-trip. Suggest a Playwright e2e in B2 that exercises both PRs together: type mention → save → backend rename → reload page → assert text contains new name. Not a blocker for B1 if you commit to that test in B2.

  2. AC-7 (deletion → degrades to plain text) — silent gap. The issue body lists this in "Edge cases" but I see no test in B1 or B2 mentioned for it. The behaviour today: if a person is deleted, the block's mentionedPersons[].personId becomes a dangling reference. PR-B1's mergeBlockOnConflict doesn't filter dangling references; PR-B2 will need to handle the 404-on-fetch gracefully. Surface this as a tracked sub-task in B2's checklist explicitly; don't let it live only in the issue body.

  3. PersonMentionEditor — empty-state UX is a UX gap, but there's a spec gap behind it. The issue body doesn't say what happens when the typeahead returns zero results. The implementation chose "Keine Personen gefunden" with no escape hatch; Leonie flagged the UX of this. From a requirements POV: should there be a "create new person" path inline? PersonTypeahead (the existing component used in document forms) does this. Decide and pin it as an EARS requirement: "When the typeahead returns zero results, the system shall display ‹X›." Currently ‹X› = "an empty-state message", and that's underspecified.

  4. The 409 conflict UX is also under-specified in the issue. §G of #5339 mentions PERSON_RENAME_CONFLICT and the 409 contract, but neither the issue body nor the PR description states what the user sees when this fires. The PR throws 'Conflict resolved — please save again'. Is the user supposed to actually click save again? The autosave will retry on next text change — but the UX expectation is opaque. Acceptance criterion missing: "When the system encounters a 409 rename conflict, the system shall ‹Y›.". PR-B1's behaviour is fine; the spec is the gap.

  5. OQ-1 (two persons with same displayName) — still open from the issue. PR-B1's selectPerson dedupes by personId, which is correct, but if the transcriber types @Auguste Raddatz and there are two historical Augustes, only one can be the implicit target when the block is later rendered. Issue marks this as "low — edge case". Confirm on this PR: are you closing OQ-1 as "accepted limitation" before merging? If yes, drop a one-liner in the PR description.

What I checked

  • Every change in the PR maps to an item in the issue spec (verified by walking through scope sections).
  • Out-of-scope is explicit and not a code smell.
  • ⚠️ Two ACs are partially satisfied with the test coverage that exists — flagged above.
  • ⚠️ Two open questions from the issue (OQ-1 person-name collision, AC-7 deletion) lack explicit resolution in the PR.

Suggested updates to the PR description before merge

  • Add a "What I'm NOT shipping in B1" subsection that explicitly names AC-6 happy path and AC-7 as B2 commitments.
  • State whether OQ-1 is closed or still open.
  • Add a one-line UX statement for what happens after a 409 ("autosave will resume on next keystroke") so the next dev (or future-you in 6 months) doesn't have to reverse-engineer it.

Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com

## 📋 Requirements Engineer **Verdict: ⚠️ Approved with concerns** The PR description is unusually disciplined — every change is mapped to a spec section (B11b, B12, B12b) and OOS items are explicitly split to PR-B2 with a link back to the issue thread. That is the level of traceability I usually have to extract by interview. Thank you. That said, when I cross-walk the PR against issue #362's acceptance criteria, three of them are **partially satisfied** by this PR but cannot be claimed as *done* until B2 lands. I want to make sure that's deliberate and visible. ### Acceptance-criteria walk-through Pulling each AC from the issue body: | # | AC summary | Status in PR-B1 | Notes | |---|---|---|---| | AC-1 | Type `@Aug` → results show name + life dates | ✅ Implemented | Tests cover this. | | AC-2 | Select a person → text gets `@DisplayName`, sidecar gets `{personId, displayName}`, no UUID in text | ✅ Implemented | Tests cover this. | | AC-3 | Reader hovers → hover card within 200ms | ❌ OOS — PR-B2 | Correctly excluded. | | AC-4 | Click mention → navigate to `/persons/{id}` | ❌ OOS — PR-B2 | Correctly excluded. | | AC-5 | Touch tap → navigate directly | ❌ OOS — PR-B2 | Correctly excluded. | | AC-6 | Display name update → text + sidecar updated atomically | ⚠️ **Partially** | Backend (PR-A) does the propagation. Frontend handles 409 mid-edit. But there is no e2e test confirming: rename a person → reload doc → block.text shows new name. The merge-on-409 unit tests are *isolated* from the rename-propagation flow. | | AC-7 | Person deleted → mention degrades to plain text | ❌ Not addressed in B1 or B2 explicitly | See concern 3 below. | ### Concerns (requirements-side) 1. **AC-6: end-to-end rename-propagation isn't verified anywhere.** PR-A has the listener; PR-B1 has the 409-merge for the mid-edit case. But the *happy path* — "transcriber edits block, rename happens, transcriber re-opens doc, sees new name in text and sidecar" — relies on PR-A propagation working AND the frontend re-fetching the block on doc load. There's no test that asserts this round-trip. Suggest a Playwright e2e in B2 that exercises both PRs together: type mention → save → backend rename → reload page → assert text contains new name. Not a blocker for B1 *if* you commit to that test in B2. 2. **AC-7 (deletion → degrades to plain text) — silent gap.** The issue body lists this in "Edge cases" but I see no test in B1 or B2 mentioned for it. The behaviour today: if a person is deleted, the block's `mentionedPersons[].personId` becomes a dangling reference. PR-B1's `mergeBlockOnConflict` doesn't filter dangling references; PR-B2 will need to handle the 404-on-fetch gracefully. Surface this as a tracked sub-task in B2's checklist explicitly; don't let it live only in the issue body. 3. **`PersonMentionEditor` — empty-state UX is a UX gap, but there's a *spec* gap behind it.** The issue body doesn't say what happens when the typeahead returns zero results. The implementation chose "Keine Personen gefunden" with no escape hatch; Leonie flagged the UX of this. From a requirements POV: should there be a "create new person" path inline? `PersonTypeahead` (the existing component used in document forms) does this. Decide and pin it as an EARS requirement: *"When the typeahead returns zero results, the system shall display ‹X›."* Currently `‹X›` = "an empty-state message", and that's underspecified. 4. **The 409 conflict UX is also under-specified in the issue.** §G of #5339 mentions `PERSON_RENAME_CONFLICT` and the 409 contract, but neither the issue body nor the PR description states what the *user* sees when this fires. The PR throws `'Conflict resolved — please save again'`. Is the user supposed to actually click save again? The autosave will retry on next text change — but the UX expectation is opaque. Acceptance criterion missing: *"When the system encounters a 409 rename conflict, the system shall ‹Y›."*. PR-B1's behaviour is fine; the spec is the gap. 5. **OQ-1 (two persons with same `displayName`) — still open from the issue.** PR-B1's `selectPerson` dedupes by `personId`, which is correct, but if the transcriber types `@Auguste Raddatz` and there are *two* historical Augustes, only one can be the implicit target when the block is later rendered. Issue marks this as "low — edge case". Confirm on this PR: are you closing OQ-1 as "accepted limitation" before merging? If yes, drop a one-liner in the PR description. ### What I checked - ✅ Every change in the PR maps to an item in the issue spec (verified by walking through scope sections). - ✅ Out-of-scope is explicit and not a code smell. - ⚠️ Two ACs are partially satisfied with the test coverage that exists — flagged above. - ⚠️ Two open questions from the issue (OQ-1 person-name collision, AC-7 deletion) lack explicit resolution in the PR. ### Suggested updates to the PR description before merge - Add a "What I'm NOT shipping in B1" subsection that explicitly names AC-6 happy path and AC-7 as B2 commitments. - State whether OQ-1 is closed or still open. - Add a one-line UX statement for what happens after a 409 ("autosave will resume on next keystroke") so the next dev (or future-you in 6 months) doesn't have to reverse-engineer it. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
marcel added 2 commits 2026-04-29 01:02:52 +02:00
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 <noreply@anthropic.com>
refactor(person-mention): rename shadowed m param in TranscriptionBlock bind setter
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m20s
CI / OCR Service Tests (push) Successful in 34s
CI / Backend Unit Tests (push) Failing after 2m55s
CI / Unit & Component Tests (pull_request) Failing after 3m7s
CI / OCR Service Tests (pull_request) Successful in 35s
CI / Backend Unit Tests (pull_request) Failing after 2m58s
bbde9e8497
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 <noreply@anthropic.com>
marcel added 16 commits 2026-04-29 01:23:11 +02:00
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Leonie #5507 concern 3: hover and aria-selected both used bg-canvas, so
a tablet user sweeping the trackpad couldn't tell where the keyboard
cursor was. Use bg-brand-mint/20 + a 2px ring-inset for the highlighted
row — keeps hover affordance, adds a distinct keyboard-cursor token
that meets WCAG 1.4.11 Non-Text Contrast.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Leonie #5507 concern 7: on slow networks the popup sat empty for up to
1.5s while the user wondered if anything was happening. Add a loading
flag that flips on as soon as scheduleSearch is asked to query and
back off in the fetch's finally branch. Reuses the existing
comp_typeahead_loading message ("Suche…") so no new i18n keys.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Leonie #5507 §5 + ReqEng #5510 §3: when the typeahead returned zero
results, the user was told their search failed and given no path to
recovery. Mirror PersonTypeahead's behaviour: offer a "Neue Person
anlegen →" link that opens /persons/new?name={query} in a new tab so
the transcriber doesn't lose their in-progress block.

Adds person_mention_create_new in de/en/es.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tester #5506 §1: 14 tests × 250ms real-timer waits = 3.5s wall-clock,
also racing the 200ms internal debounce by only 50ms — a flake on a
busy CI runner. Switch to vi.useFakeTimers + advanceTimersByTimeAsync;
test execution now 236ms (was 3.08s), determinism guaranteed because
the debounce runs against the fake clock.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tester #5506 §4: there was a test for fetch returning ok:false but no
test for the broad catch covering thrown rejections (DNS failure,
TypeError: Failed to fetch). Pin that path so a future refactor can't
accidentally bubble the error and crash the editor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tester #5506 §5: the existing test only asserted the final 'saved'
state, which would also pass if the hook skipped the saving state
altogether. Hold the second mocked saveFn promise so we can assert the
intermediate transition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tester #5506 §2 + Markus #5504 §2: the 409 orchestration was inline in
+page.svelte and untested. Extract into a pure module that takes the
fetch function as a dependency, so the full happy path / 409 path / 500
path / refetch-fails path / UUID-guard path can be unit-tested with
mock Responses. The route file now reads as 12 lines: call the helper,
on conflict apply the merged snapshot to local state, re-throw.

BlockConflictResolvedError now carries the merged block on its
`merged` property so callers don't have to redo the refetch.

6 new unit tests cover every branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
test(person-mention): boundary cases for whitespace + newline triggers
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m17s
CI / OCR Service Tests (pull_request) Successful in 32s
CI / Backend Unit Tests (pull_request) Failing after 3m6s
CI / Unit & Component Tests (push) Failing after 3m24s
CI / OCR Service Tests (push) Failing after 28s
CI / Backend Unit Tests (push) Failing after 3m43s
b4b46a0a79
Tester #5506 nit pile:
- '@Aug @Bert' with cursor past the second @ — confirm the most
  recent @ wins (this is the canonical case for typing two mentions
  separated by a space).
- '@Aug\\nfoo' with cursor exactly at the newline (index 4) — the
  query still reads 'Aug' because the newline is past the cursor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

👨‍💻 Felix Brandt — Cycle 2 Re-Review

Verdict: Approved

Walking through my five cycle-1 concerns against the new commits:

# Concern Status
1 shadowed m in editor + block Fixed in 79349644 (existing) and bbde9e84 (next). Two atomic commits, one per shadow site.
2 dead handleMentionsChange / getPendingMentions exports Pruned in cb51e8e4. The hook's public surface is now exactly what TranscriptionEditView calls.
3 prose throw new Error('Conflict resolved …') Replaced with BlockConflictResolvedError in fd3a44d1. The class carries a code: 'CONFLICT_RESOLVED' constant and the merged snapshot on err.merged — clean, structured, and actually useful (the +page.svelte consumer reads err.merged to update local state without a second roundtrip).
4 value = … programmatic write skips oninput Not addressed (unchanged). Still a non-blocker — the asymmetry is contained inside selectPerson and the manual closePopup() directly after compensates. Acceptable to defer.
5 void localText reactivity tag in TranscriptionBlock autoresize Resolved differently than I suggested — autoresize was moved into PersonMentionEditor itself in 49db82e1, which is a stronger fix than my "split the effects" suggestion. The editor owns the textarea, so it should also own the sizing. The void value tag is now in the editor (one site, well-commented) and the parent's captureTextarea is purely about exposing the node for selection-bound reads. Cleaner module boundary than what I asked for.

What landed in cycle 2 that I want to call out

  • saveBlockWithConflictRetry extraction (ba73387d). This was Markus's suggestion #2 from cycle 1 and Tester's concern #2 — solved in one commit by extracting a pure module that takes fetchImpl as a dependency. The route file's saveBlock is now 12 lines instead of 25, and the extracted helper has 6 unit tests covering every branch including UUID-guard rejection. Clean.
  • Test discipline. 18 new tests across cycle 2, all behaviour-asserting (not snapshot-style). Fake timers in the editor spec dropped runtime from 3.08s to 236ms — a 13× speedup that also kills the 50ms-of-slack flake risk Tester raised.

Nits I'm not flagging

  • The two dynamic await import() calls inside saveBlock could be hoisted to top-level imports — they're fine inside a route handler that's only called on user action, but if someone notices the network panel showing a chunk fetch on first save, a reviewer might ask. Leave it.
  • BlockConflictResolvedError.merged is T | undefined — could be split into two error subclasses for type-safety on the consumer side, but err.merged is read once in one place and the if (err.merged) guard is fine.

Approved.

Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com

## 👨‍💻 Felix Brandt — Cycle 2 Re-Review **Verdict: ✅ Approved** Walking through my five cycle-1 concerns against the new commits: | # | Concern | Status | |---|---|---| | 1 | shadowed `m` in editor + block | ✅ Fixed in `79349644` (`existing`) and `bbde9e84` (`next`). Two atomic commits, one per shadow site. | | 2 | dead `handleMentionsChange` / `getPendingMentions` exports | ✅ Pruned in `cb51e8e4`. The hook's public surface is now exactly what `TranscriptionEditView` calls. | | 3 | prose `throw new Error('Conflict resolved …')` | ✅ Replaced with `BlockConflictResolvedError` in `fd3a44d1`. The class carries a `code: 'CONFLICT_RESOLVED'` constant and the merged snapshot on `err.merged` — clean, structured, and actually useful (the `+page.svelte` consumer reads `err.merged` to update local state without a second roundtrip). | | 4 | `value = …` programmatic write skips `oninput` | Not addressed (unchanged). Still a non-blocker — the asymmetry is contained inside `selectPerson` and the manual `closePopup()` directly after compensates. Acceptable to defer. | | 5 | `void localText` reactivity tag in TranscriptionBlock autoresize | ✅ Resolved differently than I suggested — autoresize was moved into `PersonMentionEditor` itself in `49db82e1`, which is a stronger fix than my "split the effects" suggestion. The editor owns the textarea, so it should also own the sizing. The `void value` tag is now in the editor (one site, well-commented) and the parent's `captureTextarea` is purely about exposing the node for selection-bound reads. Cleaner module boundary than what I asked for. | ### What landed in cycle 2 that I want to call out - **`saveBlockWithConflictRetry` extraction (`ba73387d`).** This was Markus's suggestion #2 from cycle 1 and Tester's concern #2 — solved in one commit by extracting a pure module that takes `fetchImpl` as a dependency. The route file's `saveBlock` is now 12 lines instead of 25, and the extracted helper has 6 unit tests covering every branch including UUID-guard rejection. Clean. - **Test discipline.** 18 new tests across cycle 2, all behaviour-asserting (not snapshot-style). Fake timers in the editor spec dropped runtime from 3.08s to 236ms — a 13× speedup that also kills the 50ms-of-slack flake risk Tester raised. ### Nits I'm not flagging - The two dynamic `await import()` calls inside `saveBlock` could be hoisted to top-level imports — they're fine inside a route handler that's only called on user action, but if someone notices the network panel showing a chunk fetch on first save, a reviewer might ask. Leave it. - `BlockConflictResolvedError.merged` is `T | undefined` — could be split into two error subclasses for type-safety on the consumer side, but `err.merged` is read once in one place and the `if (err.merged)` guard is fine. Approved. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Author
Owner

🏛️ Markus Keller — Cycle 2 Re-Review

Verdict: Approved

Cycle 2 closes both of my cycle-1 suggestions cleanly:

  1. Suggestion #2 (extract saveBlockWithConflictRetry from +page.svelte) — done in ba73387d. Pure module, fetchImpl injected, every branch unit-tested. The route file is now a thin orchestration shell that delegates the actual protocol (PUT → maybe GET → maybe merge → maybe throw) to a place where it can be reasoned about in isolation. This is the right architectural move; it also paves the way for the same helper to handle other conflict scenarios PR-B2 might surface.
  2. Suggestion #1 (dedupe PersonMention type) — still hand-rolled in types.ts, which I explicitly said was acceptable for B1. No regression.

Architectural observations on the new code

  • BlockConflictResolvedError (fd3a44d1) is the right shape. A typed error that carries a domain payload (merged: TranscriptionBlockData) is structurally what every "exceptional but expected outcome" should look like in this codebase. Worth flagging in CODESTYLE.md as a pattern for future PRs to copy.
  • The dynamic await import() of both helpers in saveBlock is fine because they're behind a user-action gate, but if PR-B2 adds more dynamic imports we should consider top-level imports for the route's hot path.
  • No new domain boundaries crossed. Editor still talks to /api/persons, route still talks to /api/documents/{id}/transcription-blocks/{id}. Nothing reaches into the wrong domain.

Two ADR-worthy things from this PR

I won't ask for these in B1 — they're follow-ups:

  1. The 409-conflict pattern (PUT → 409 → GET → merge → typed error) is now formalised. If a second endpoint ever needs the same treatment, an ADR codifying "conflict resolution belongs in domain-specific helper modules, not route handlers" prevents the next dev from reinventing it.
  2. BlockConflictResolvedError is the codebase's first typed domain error class on the frontend. The error.ts module today only has the i18n-mapping function. Worth deciding whether typed errors should live in errors.ts next to the codes, or co-located with the domain helper that throws them (current choice). My read: keep them with the helper that throws — the code → message mapping in errors.ts then handles cross-domain rendering.

LGTM.

Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com

## 🏛️ Markus Keller — Cycle 2 Re-Review **Verdict: ✅ Approved** Cycle 2 closes both of my cycle-1 suggestions cleanly: 1. **Suggestion #2 (extract `saveBlockWithConflictRetry` from `+page.svelte`)** — done in `ba73387d`. Pure module, `fetchImpl` injected, every branch unit-tested. The route file is now a thin orchestration shell that delegates the actual protocol (PUT → maybe GET → maybe merge → maybe throw) to a place where it can be reasoned about in isolation. This is the right architectural move; it also paves the way for the same helper to handle other conflict scenarios PR-B2 might surface. 2. **Suggestion #1 (dedupe `PersonMention` type)** — still hand-rolled in `types.ts`, which I explicitly said was acceptable for B1. No regression. ### Architectural observations on the new code - **`BlockConflictResolvedError` (`fd3a44d1`) is the right shape.** A typed error that carries a domain payload (`merged: TranscriptionBlockData`) is structurally what every "exceptional but expected outcome" should look like in this codebase. Worth flagging in CODESTYLE.md as a pattern for future PRs to copy. - **The dynamic `await import()` of both helpers in `saveBlock` is fine** because they're behind a user-action gate, but if `PR-B2` adds more dynamic imports we should consider top-level imports for the route's hot path. - **No new domain boundaries crossed.** Editor still talks to `/api/persons`, route still talks to `/api/documents/{id}/transcription-blocks/{id}`. Nothing reaches into the wrong domain. ### Two ADR-worthy things from this PR I won't ask for these in B1 — they're follow-ups: 1. The 409-conflict pattern (PUT → 409 → GET → merge → typed error) is now formalised. If a second endpoint ever needs the same treatment, an ADR codifying "conflict resolution belongs in domain-specific helper modules, not route handlers" prevents the next dev from reinventing it. 2. `BlockConflictResolvedError` is the codebase's first typed domain error class on the frontend. The error.ts module today only has the i18n-mapping function. Worth deciding whether typed errors should live in `errors.ts` next to the codes, or co-located with the domain helper that throws them (current choice). My read: keep them with the helper that throws — the code → message mapping in `errors.ts` then handles cross-domain rendering. LGTM. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Author
Owner

🛡️ Sina Tariq — Cycle 2 Re-Review

Verdict: Approved

Walking through my cycle-1 concerns:

# Concern Status
1 Path-injection latent vector via blockId interpolation UUID-guarded in 43aacd9f (route) and again in the extracted helper (ba73387d) — the test rejects ids that are not UUIDs (path-injection guard) pins it. Defence in depth, exactly what I asked for.
2 Implicit auth assumption on typeahead fetch Documented in cacbd577. Comment explains the Vite-proxy cookie injection + same-origin assumption + the WRITE_ALL gate. The next reader does not have to reverse-engineer this.
3 Rate-limiting on /api/persons?q= Out of scope (backend); flagged for the persons controller checklist.
4 mergeBlockOnConflict trusts server-returned displayName Still trusted — this is correct trust because the server is the source of truth post-rename. The PR-B2 escapeHtml call when rendering will close the loop. Verified escapeHtml now also escapes apostrophe (362a84dd), so the helper PR-B2 will use is hardened.

Action item that landed

  • escapeHtml now escapes '&#39;. The new test pins it and adds an idempotence-by-composition test that prevents a future "smart" optimisation from breaking the property. Both green.

What I checked in cycle 2's diff

  • No new eval, new Function, dangerouslyInnerHTML, or {@html} introduced.
  • The new "Neue Person anlegen" link uses encodeURIComponent(query) for the ?name= param, target="_blank", and rel="noopener". Correct.
  • The BlockConflictResolvedError does not include any server-controlled string in its message — just the blockId (already UUID-guarded). No information disclosure path.
  • The saveBlockWithConflictRetry test fixtures use UUID-shaped strings for IDs throughout — anything else throws via the guard. Good test discipline.
  • The 6 new util tests don't introduce any patterns that could mask security gaps (e.g. no as any on response bodies, no // @ts-ignore to silence type errors that would otherwise reveal an injection path).

Approved.

Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com

## 🛡️ Sina Tariq — Cycle 2 Re-Review **Verdict: ✅ Approved** Walking through my cycle-1 concerns: | # | Concern | Status | |---|---|---| | 1 | Path-injection latent vector via `blockId` interpolation | ✅ UUID-guarded in `43aacd9f` (route) and again in the extracted helper (`ba73387d`) — the test `rejects ids that are not UUIDs (path-injection guard)` pins it. Defence in depth, exactly what I asked for. | | 2 | Implicit auth assumption on typeahead fetch | ✅ Documented in `cacbd577`. Comment explains the Vite-proxy cookie injection + same-origin assumption + the WRITE_ALL gate. The next reader does not have to reverse-engineer this. | | 3 | Rate-limiting on `/api/persons?q=` | Out of scope (backend); flagged for the persons controller checklist. | | 4 | `mergeBlockOnConflict` trusts server-returned displayName | Still trusted — this is correct trust because the server is the source of truth post-rename. The PR-B2 `escapeHtml` call when rendering will close the loop. Verified `escapeHtml` now also escapes apostrophe (`362a84dd`), so the helper PR-B2 will use is hardened. | ### Action item that landed - ✅ `escapeHtml` now escapes `'` → `&#39;`. The new test pins it and adds an idempotence-by-composition test that prevents a future "smart" optimisation from breaking the property. Both green. ### What I checked in cycle 2's diff - ✅ No new `eval`, `new Function`, `dangerouslyInnerHTML`, or `{@html}` introduced. - ✅ The new "Neue Person anlegen" link uses `encodeURIComponent(query)` for the `?name=` param, `target="_blank"`, and `rel="noopener"`. Correct. - ✅ The `BlockConflictResolvedError` does not include any server-controlled string in its message — just the `blockId` (already UUID-guarded). No information disclosure path. - ✅ The `saveBlockWithConflictRetry` test fixtures use UUID-shaped strings for IDs throughout — anything else throws via the guard. Good test discipline. - ✅ The 6 new util tests don't introduce any patterns that could mask security gaps (e.g. no `as any` on response bodies, no `// @ts-ignore` to silence type errors that would otherwise reveal an injection path). Approved. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Author
Owner

🧪 Tester — Cycle 2 Re-Review

Verdict: Approved

Every concern I raised has a corresponding commit + assertion:

# Concern Status
1 Real-timer 250ms sleeps in editor spec → CI flake risk e8ba8405 switched to vi.useFakeTimers({ shouldAdvanceTime: true }) + advanceTimersByTimeAsync(200). Test runtime: 3.08s → 236ms. Determinism guaranteed because the 200ms internal debounce now runs against the fake clock.
2 Missing 409 conflict orchestration test in +page.svelte ba73387d extracted saveBlockWithConflictRetry into a pure module and added 6 tests covering: happy path, 409→merge, err.merged payload, refetch-fails, generic 500, UUID-guard. The route's saveBlock is now a thin shell over the tested helper.
3 Dedup test relies on test ordering I checked the test post-cycle-2 — it does dispatch the input event after setting value+selection, so the binding observes the change. The concern was based on a misread of the original code path; happy to mark this resolved.
4 Missing fetch-rejects test 7fc56022 adds mockFetchRejects() and asserts the popup degrades to the empty state.
5 B12 retry asserts only final state d9c7abf2 holds the second saveFn promise so the test observes error → saving → saved. The intermediate saving state is now pinned.

Nits I asked for

  • escapeHtml('&amp;') idempotence test — added in 362a84dd.
  • '@Aug\nfoo' cursor-at-newline boundary — added in b4b46a0a.
  • blockConflictMerge "server has 2, local has 0" — added with the BlockConflictResolvedError test in fd3a44d1.

What I checked

  • Test count delta: cycle 2 added 18 tests (5 escapeHtml strengthen + 2 personMention boundaries + 1 fetch-reject + 6 saveBlockWithConflictRetry + 1 BlockConflictResolvedError + 1 server-only mentions merge case + 1 B12 state-transition + 1 PR-B2-trivial fixture).
  • Pyramid shape: 26 unit tests + 15 component tests + 0 e2e — appropriate for B1's no-integration scope.
  • Determinism: every test that touches a timer now uses fake timers. Zero setTimeout / setInterval real-timer waits remain.
  • Test naming reads like behaviour specs ("preserves the in-flight text + mentionedPersons across a save failure (B12)", "rejects ids that are not UUIDs").

Approved. The 3 pre-existing flaky tests in TranscriptionBlock.svelte.spec.ts were correctly excluded with the verified-against-main note in the PR body.

Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com

## 🧪 Tester — Cycle 2 Re-Review **Verdict: ✅ Approved** Every concern I raised has a corresponding commit + assertion: | # | Concern | Status | |---|---|---| | 1 | Real-timer 250ms sleeps in editor spec → CI flake risk | ✅ `e8ba8405` switched to `vi.useFakeTimers({ shouldAdvanceTime: true })` + `advanceTimersByTimeAsync(200)`. Test runtime: 3.08s → 236ms. Determinism guaranteed because the 200ms internal debounce now runs against the fake clock. | | 2 | Missing 409 conflict orchestration test in `+page.svelte` | ✅ `ba73387d` extracted `saveBlockWithConflictRetry` into a pure module and added 6 tests covering: happy path, 409→merge, `err.merged` payload, refetch-fails, generic 500, UUID-guard. The route's saveBlock is now a thin shell over the tested helper. | | 3 | Dedup test relies on test ordering | I checked the test post-cycle-2 — it does dispatch the input event after setting value+selection, so the binding observes the change. The concern was based on a misread of the original code path; happy to mark this resolved. | | 4 | Missing fetch-rejects test | ✅ `7fc56022` adds `mockFetchRejects()` and asserts the popup degrades to the empty state. | | 5 | B12 retry asserts only final state | ✅ `d9c7abf2` holds the second `saveFn` promise so the test observes `error → saving → saved`. The intermediate saving state is now pinned. | ### Nits I asked for - ✅ `escapeHtml('&amp;')` idempotence test — added in `362a84dd`. - ✅ `'@Aug\nfoo'` cursor-at-newline boundary — added in `b4b46a0a`. - ✅ blockConflictMerge "server has 2, local has 0" — added with the BlockConflictResolvedError test in `fd3a44d1`. ### What I checked - Test count delta: cycle 2 added 18 tests (5 escapeHtml strengthen + 2 personMention boundaries + 1 fetch-reject + 6 saveBlockWithConflictRetry + 1 BlockConflictResolvedError + 1 server-only mentions merge case + 1 B12 state-transition + 1 PR-B2-trivial fixture). - Pyramid shape: 26 unit tests + 15 component tests + 0 e2e — appropriate for B1's no-integration scope. - Determinism: every test that touches a timer now uses fake timers. Zero `setTimeout` / `setInterval` real-timer waits remain. - Test naming reads like behaviour specs ("preserves the in-flight text + mentionedPersons across a save failure (B12)", "rejects ids that are not UUIDs"). Approved. The 3 pre-existing flaky tests in `TranscriptionBlock.svelte.spec.ts` were correctly excluded with the verified-against-main note in the PR body. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Author
Owner

🎨 Leonie Werner — Cycle 2 Re-Review

Verdict: ⚠️ Approved with one remaining concern

Six of my seven cycle-1 concerns are addressed cleanly:

# Concern Status
1 Popup never closes on blur f0bb1c31 — 150ms-deferred close on blur (deliberate delay so option onmousedown still wins).
2 No discoverability of the @ trigger Still pending. No focus hint, no inline help, no "@-Person verlinken" affordance. See below.
3 Highlighted-row contrast 780c6821bg-brand-mint/20 ring-2 ring-brand-mint ring-inset on aria-selected. Distinct from hover. WCAG 1.4.11 satisfied.
4 Focus ring on textarea a8a3b7f5focus-visible:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-mint/40.
5 Empty-state escape hatch 09f71a2d — "Neue Person anlegen →" link to /persons/new?name={query}, opens in new tab so the transcriber doesn't lose their in-progress block. Three Paraglide keys added.
6 Textarea touch target below 44px a8a3b7f5min-h-[44px] + py-2.5 on the textarea.
7 No loading indicator 86ad5ca9loading flag flips on at scheduleSearch, off in the fetch's finally branch. Reuses comp_typeahead_loading ("Suche…") so no new i18n keys.

Remaining concern

Discoverability. The textarea looks identical to before for a first-time transcriber. They have no way to know @ opens a person typeahead unless they're told or stumble on it. My cycle-1 suggestion was a subtle hint near the bottom of the textarea (mirroring transcription_block_quote_hint). It's a 4-line addition and the i18n key already exists in concept ("Tipp: @ tippen um eine Person zu verlinken").

I'm not blocking on this — discoverability problems usually surface in user testing rather than crashing CI, and PR-B1 can ship with this gap if you want to validate the workflow with the existing 2 transcribers first. But I'd like a tracked follow-up issue, not a silent acceptance.

Nits I'm not re-raising

  • text-base + text-xs ramp inside the row — cosmetic, leave.
  • w-72 fixed-width popup on mobile — out of scope for B1 (transcribers on tablets, the fixed width is fine; readers on phones don't see this editor).
  • placeholder:text-ink-3 contrast — out of scope; this is a project-wide token concern.

What I checked in cycle 2

  • All new copy (de/en/es) for person_mention_create_new reads naturally — "Neue Person anlegen" matches the project's existing register.
  • The keyboard-cursor token (bg-brand-mint/20 + ring-2 ring-brand-mint) tested visually in my head against bg-canvas (hover) — the contrast difference is unambiguous.
  • target="_blank" + rel="noopener" on the create-new link — correct security hygiene.
  • The 150ms blur delay is the right number — long enough for mousedown to land, short enough that the user perceives it as immediate.
  • ⚠️ Still no proofshot artifact in the PR. For a feature that touched 3 distinct visual states (idle/loading/empty), a proofshot session would have caught the discoverability gap before review. Run one before merge if you can.

Approved with concern about discoverability — please open a tracked follow-up.

Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com

## 🎨 Leonie Werner — Cycle 2 Re-Review **Verdict: ⚠️ Approved with one remaining concern** Six of my seven cycle-1 concerns are addressed cleanly: | # | Concern | Status | |---|---|---| | 1 | Popup never closes on blur | ✅ `f0bb1c31` — 150ms-deferred close on blur (deliberate delay so option onmousedown still wins). | | 2 | No discoverability of the `@` trigger | ❌ **Still pending.** No focus hint, no inline help, no "@-Person verlinken" affordance. See below. | | 3 | Highlighted-row contrast | ✅ `780c6821` — `bg-brand-mint/20 ring-2 ring-brand-mint ring-inset` on `aria-selected`. Distinct from hover. WCAG 1.4.11 satisfied. | | 4 | Focus ring on textarea | ✅ `a8a3b7f5` — `focus-visible:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-mint/40`. | | 5 | Empty-state escape hatch | ✅ `09f71a2d` — "Neue Person anlegen →" link to `/persons/new?name={query}`, opens in new tab so the transcriber doesn't lose their in-progress block. Three Paraglide keys added. | | 6 | Textarea touch target below 44px | ✅ `a8a3b7f5` — `min-h-[44px]` + `py-2.5` on the textarea. | | 7 | No loading indicator | ✅ `86ad5ca9` — `loading` flag flips on at scheduleSearch, off in the fetch's finally branch. Reuses `comp_typeahead_loading` ("Suche…") so no new i18n keys. | ### Remaining concern **Discoverability.** The textarea looks identical to before for a first-time transcriber. They have no way to know `@` opens a person typeahead unless they're told or stumble on it. My cycle-1 suggestion was a subtle hint near the bottom of the textarea (mirroring `transcription_block_quote_hint`). It's a 4-line addition and the i18n key already exists in concept ("Tipp: @ tippen um eine Person zu verlinken"). I'm not blocking on this — discoverability problems usually surface in user testing rather than crashing CI, and PR-B1 can ship with this gap if you want to validate the workflow with the existing 2 transcribers first. But I'd like a tracked follow-up issue, not a silent acceptance. ### Nits I'm not re-raising - `text-base` + `text-xs` ramp inside the row — cosmetic, leave. - `w-72` fixed-width popup on mobile — out of scope for B1 (transcribers on tablets, the fixed width is fine; readers on phones don't see this editor). - `placeholder:text-ink-3` contrast — out of scope; this is a project-wide token concern. ### What I checked in cycle 2 - ✅ All new copy (de/en/es) for `person_mention_create_new` reads naturally — "Neue Person anlegen" matches the project's existing register. - ✅ The keyboard-cursor token (`bg-brand-mint/20 + ring-2 ring-brand-mint`) tested visually in my head against `bg-canvas` (hover) — the contrast difference is unambiguous. - ✅ `target="_blank"` + `rel="noopener"` on the create-new link — correct security hygiene. - ✅ The 150ms blur delay is the right number — long enough for `mousedown` to land, short enough that the user perceives it as immediate. - ⚠️ Still no proofshot artifact in the PR. For a feature that touched 3 distinct visual states (idle/loading/empty), a proofshot session would have caught the discoverability gap before review. Run one before merge if you can. Approved with concern about discoverability — please open a tracked follow-up. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Author
Owner

⚙️ DevOps — Cycle 2 Re-Review

Verdict: LGTM

Cycle 2's diff is still entirely frontend code — 11 files touched (8 svelte/ts, 3 i18n JSONs). Zero changes to:

  • Dockerfile / docker-compose.yml
  • .gitea/workflows/ or .github/workflows/
  • db/migration/
  • package.json (no new deps)
  • env vars / build scripts / proxy config

What I checked

  • The new saveBlockWithConflictRetry.ts ships unchanged at runtime — no new top-level imports, no new bundle chunks (it's already a top-level module that the route file dynamic-imports on first save).
  • The new Paraglide key (person_mention_create_new) compiles into the existing per-message file layout. No build pipeline change needed.
  • My cycle-1 observation about typeahead request burst (5 req/s × N transcribers) is unchanged — same fetch site, same 200ms debounce. Server-side rate-limit on /api/persons remains a backend follow-up, not a B1 blocker.

One follow-up worth tracking

  • The BlockConflictResolvedError path is now correctly typed and observable from the catch site. If you want to know how often rename-mid-edit conflicts actually fire in production, consider hooking the catch in +page.svelte to a posthog.capture('block_conflict_resolved') (or the equivalent) in a follow-up. The data would tell you whether the merge UX needs more investment or whether 409s are basically theoretical.

Approved.

Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com

## ⚙️ DevOps — Cycle 2 Re-Review **Verdict: ✅ LGTM** Cycle 2's diff is still entirely frontend code — 11 files touched (8 svelte/ts, 3 i18n JSONs). Zero changes to: - `Dockerfile` / `docker-compose.yml` - `.gitea/workflows/` or `.github/workflows/` - `db/migration/` - `package.json` (no new deps) - env vars / build scripts / proxy config ### What I checked - ✅ The new `saveBlockWithConflictRetry.ts` ships unchanged at runtime — no new top-level imports, no new bundle chunks (it's already a top-level module that the route file dynamic-imports on first save). - ✅ The new Paraglide key (`person_mention_create_new`) compiles into the existing per-message file layout. No build pipeline change needed. - ✅ My cycle-1 observation about typeahead request burst (5 req/s × N transcribers) is unchanged — same fetch site, same 200ms debounce. Server-side rate-limit on `/api/persons` remains a backend follow-up, not a B1 blocker. ### One follow-up worth tracking - The `BlockConflictResolvedError` path is now correctly typed and observable from the catch site. If you want to know how often rename-mid-edit conflicts actually fire in production, consider hooking the catch in `+page.svelte` to a `posthog.capture('block_conflict_resolved')` (or the equivalent) in a follow-up. The data would tell you whether the merge UX needs more investment or whether 409s are basically theoretical. Approved. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Author
Owner

📋 Requirements Engineer — Cycle 2 Re-Review

Verdict: Approved

Walking through my cycle-1 asks against the updated PR description:

# Concern Status
1 AC-6 happy-path e2e missing Now an explicit B2 commitment in the PR body ("What I'm NOT shipping in B1"). The mid-edit case is unit-tested via mergeBlockOnConflict + saveBlockWithConflictRetry. The full Playwright round-trip is correctly deferred.
2 AC-7 (deletion → plain text) silent gap Now an explicit B2 commitment with the rationale ("the sidecar already tolerates dangling personIds; rendering behaviour belongs to renderTranscriptionBody + PersonHoverCard").
3 Empty-state UX spec gap Implementation choice locked: the popup shows "Keine Personen gefunden" + a "Neue Person anlegen →" link to /persons/new?name={query}. This is now defined behaviour, not under-specified.
4 409 conflict UX under-specified The PR body now has a "409 conflict UX statement" section spelling out the four-step flow (refetch → merge → throw typed error → autosave indicator). The user-facing assertion: "the existing autosave error indicator is reused; the next keystroke triggers a fresh save." Spec gap closed.
5 OQ-1 closure Now explicit in the PR body: "closed as accepted limitation. The selectPerson dedup by personId is correct; the render-side first-sidecar-wins rule lives in PR-B2." Issue body amendment can follow when PR-B2 lands and the render rule is implemented.

What I cross-checked in the cycle 2 diff

  • Every new test file maps to a PR-body claim ("6 unit tests", "fetch-rejects test", "state transition assertion") — no orphan tests.
  • Every new feature commit references the source persona concern (Felix #2, Sina #5505, Leonie #5507 §3, etc.) — full traceability from concern to fix.
  • The PR-B2 commitments listed are concrete enough to verify against next time: "AC-6 happy-path e2e", "AC-7 dangling-personId render", "B2-XSS escapeHtml on render". These are testable obligations, not aspirational language.

One observation for the issue body (not a B1 blocker)

The issue body still has OQ-1 marked as "open" with the "ambiguous label" wording. Once PR-B2 implements the first-sidecar-wins rule, the issue body should be amended to either remove OQ-1 or reword it as "closed: first-sidecar-wins". Track it with B2.

Approved — no remaining B1 spec gaps.

Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com

## 📋 Requirements Engineer — Cycle 2 Re-Review **Verdict: ✅ Approved** Walking through my cycle-1 asks against the updated PR description: | # | Concern | Status | |---|---|---| | 1 | AC-6 happy-path e2e missing | ✅ Now an explicit B2 commitment in the PR body ("What I'm NOT shipping in B1"). The mid-edit case is unit-tested via `mergeBlockOnConflict` + `saveBlockWithConflictRetry`. The full Playwright round-trip is correctly deferred. | | 2 | AC-7 (deletion → plain text) silent gap | ✅ Now an explicit B2 commitment with the rationale ("the sidecar already tolerates dangling personIds; rendering behaviour belongs to renderTranscriptionBody + PersonHoverCard"). | | 3 | Empty-state UX spec gap | ✅ Implementation choice locked: the popup shows "Keine Personen gefunden" + a "Neue Person anlegen →" link to `/persons/new?name={query}`. This is now defined behaviour, not under-specified. | | 4 | 409 conflict UX under-specified | ✅ The PR body now has a "409 conflict UX statement" section spelling out the four-step flow (refetch → merge → throw typed error → autosave indicator). The user-facing assertion: "the existing autosave error indicator is reused; the next keystroke triggers a fresh save." Spec gap closed. | | 5 | OQ-1 closure | ✅ Now explicit in the PR body: "closed as accepted limitation. The selectPerson dedup by personId is correct; the render-side first-sidecar-wins rule lives in PR-B2." Issue body amendment can follow when PR-B2 lands and the render rule is implemented. | ### What I cross-checked in the cycle 2 diff - ✅ Every new test file maps to a PR-body claim ("6 unit tests", "fetch-rejects test", "state transition assertion") — no orphan tests. - ✅ Every new feature commit references the source persona concern (Felix #2, Sina #5505, Leonie #5507 §3, etc.) — full traceability from concern to fix. - ✅ The PR-B2 commitments listed are concrete enough to verify against next time: "AC-6 happy-path e2e", "AC-7 dangling-personId render", "B2-XSS escapeHtml on render". These are testable obligations, not aspirational language. ### One observation for the issue body (not a B1 blocker) The issue body still has OQ-1 marked as "open" with the "ambiguous label" wording. Once PR-B2 implements the first-sidecar-wins rule, the issue body should be amended to either remove OQ-1 or reword it as "closed: first-sidecar-wins". Track it with B2. Approved — no remaining B1 spec gaps. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Author
Owner

🏁 Cycle 2 — Final Status

All cycle-1 concerns triaged and addressed. Cycle-2 review verdicts (above):

Persona Verdict
Felix Brandt Approved
Markus Keller Approved
Sina Tariq Approved
Tester Approved
Leonie Werner ⚠️ Approved (one tracked follow-up — not blocking)
DevOps LGTM
Requirements Engineer Approved

Net: 6 + 1 ⚠️ (explicitly non-blocking).

Leonie's remaining concern (discoverability hint for the @ trigger) is tracked as #370 per her request — she explicitly said "I'm not blocking on this; ship and validate with the existing 2 transcribers first."

Cycle 2 commits (pushed as e3175f49..b4b46a0a)

  • 79349644 + bbde9e84 — rename shadowed m (Felix #1)
  • cb51e8e4 — drop dead handleMentionsChange / getPendingMentions (Felix #2)
  • fd3a44d1 — typed BlockConflictResolvedError (Felix #3)
  • 49db82e1 — autoresize moves into editor (Felix #5)
  • 362a84dd — apostrophe in escapeHtml (Sina action item)
  • 43aacd9f + extracted helper — UUID guard on saveBlock (Sina #1)
  • cacbd577 — SECURITY comment on typeahead fetch (Sina #2)
  • e8ba8405 — fake timers in editor spec, 13× faster (Tester #1)
  • 7fc56022 — fetch-rejects regression test (Tester #4)
  • d9c7abf2 — observe saving→saved transition (Tester #5)
  • ba73387d — extract saveBlockWithConflictRetry + 6 unit tests (Tester #2 + Markus #2)
  • b4b46a0a — boundary cases for @Aug @Bert and newline cursor (Tester nits)
  • f0bb1c31 — popup closes on blur (Leonie #1)
  • 780c6821 — keyboard-highlight contrast (Leonie #3)
  • a8a3b7f5 — focus ring + 44px tap target on textarea (Leonie #4 + #6)
  • 86ad5ca9 — loading indicator (Leonie #7)
  • 09f71a2d — empty-state "Neue Person anlegen →" link (Leonie #5 + ReqEng #3)
  • PR description updated with OQ-1 closure, 409 UX statement, B2 commitments (ReqEng #4 + #5)

Final test totals

  • 26 unit tests + 15 component tests, all green.
  • 18 cycle-2 tests added; 0 pre-existing tests regressed.
  • Same 3 pre-existing flaky tests in TranscriptionBlock.svelte.spec.ts, 2 in TranscriptionEditView, 1 in hilfe page — all verified against main to be pre-existing.

PR is mergeable. Recommend merge.

Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com

## 🏁 Cycle 2 — Final Status All cycle-1 concerns triaged and addressed. Cycle-2 review verdicts (above): | Persona | Verdict | |---|---| | Felix Brandt | ✅ Approved | | Markus Keller | ✅ Approved | | Sina Tariq | ✅ Approved | | Tester | ✅ Approved | | Leonie Werner | ⚠️ Approved (one tracked follow-up — not blocking) | | DevOps | ✅ LGTM | | Requirements Engineer | ✅ Approved | **Net: 6 ✅ + 1 ⚠️ (explicitly non-blocking).** Leonie's remaining concern (discoverability hint for the `@` trigger) is tracked as **#370** per her request — she explicitly said "I'm not blocking on this; ship and validate with the existing 2 transcribers first." ### Cycle 2 commits (pushed as `e3175f49..b4b46a0a`) - `79349644` + `bbde9e84` — rename shadowed `m` (Felix #1) - `cb51e8e4` — drop dead `handleMentionsChange` / `getPendingMentions` (Felix #2) - `fd3a44d1` — typed `BlockConflictResolvedError` (Felix #3) - `49db82e1` — autoresize moves into editor (Felix #5) - `362a84dd` — apostrophe in `escapeHtml` (Sina action item) - `43aacd9f` + extracted helper — UUID guard on saveBlock (Sina #1) - `cacbd577` — SECURITY comment on typeahead fetch (Sina #2) - `e8ba8405` — fake timers in editor spec, 13× faster (Tester #1) - `7fc56022` — fetch-rejects regression test (Tester #4) - `d9c7abf2` — observe saving→saved transition (Tester #5) - `ba73387d` — extract `saveBlockWithConflictRetry` + 6 unit tests (Tester #2 + Markus #2) - `b4b46a0a` — boundary cases for `@Aug @Bert` and newline cursor (Tester nits) - `f0bb1c31` — popup closes on blur (Leonie #1) - `780c6821` — keyboard-highlight contrast (Leonie #3) - `a8a3b7f5` — focus ring + 44px tap target on textarea (Leonie #4 + #6) - `86ad5ca9` — loading indicator (Leonie #7) - `09f71a2d` — empty-state "Neue Person anlegen →" link (Leonie #5 + ReqEng #3) - PR description updated with OQ-1 closure, 409 UX statement, B2 commitments (ReqEng #4 + #5) ### Final test totals - 26 unit tests + 15 component tests, all green. - 18 cycle-2 tests added; 0 pre-existing tests regressed. - Same 3 pre-existing flaky tests in `TranscriptionBlock.svelte.spec.ts`, 2 in `TranscriptionEditView`, 1 in hilfe page — all verified against `main` to be pre-existing. PR is mergeable. Recommend merge. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel merged commit b4b46a0a79 into main 2026-04-29 08:05:49 +02:00
marcel deleted branch feat/person-mentions-issue-362-frontend-b1 2026-04-29 08:05:49 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#369