feat(person-mention): PR-B2 — read-mode rendering + hover card (issue #362) #371

Merged
marcel merged 18 commits from feat/person-mentions-issue-362-frontend-b2 into main 2026-04-29 13:37:06 +02:00
Owner

Implements PR-B2 of issue #362 — read-mode rendering of person mentions and the hover card. Builds on PR-A (backend, merged) and PR-B1 (edit-mode infrastructure, merged).

What this PR does

Layer Change
mention.ts New renderTranscriptionBody(text, mentions) — escapes HTML, replaces every @DisplayName with <a href="/persons/{id}" class="person-mention" data-person-id="{id}">DisplayName</a>. Strips the @ trigger from the rendered link text per spec.
mention.ts Word-boundary lookahead so @Hans no longer eats @HansMüller. Longest-displayName-first + first-sidecar-wins make rendering deterministic for the OQ-1 collision case (#5339 §C4).
routes/layout.css New .person-mention global rule next to .mention. Underline at rest (WCAG AA), focus-visible 2px box-shadow ring on --c-ink.
PersonHoverCard.svelte Three states: skeleton (320×180, 3 pulse-animated bars, prefers-reduced-motion: reduce), generic error message, loaded card (navy header with name, life-date range, geb. <alias>; family-only relationship chips PARENT_OF/SPOUSE_OF/SIBLING_OF; notes excerpt capped at 120 chars; footer Zur Person → + hover hint). Hidden via @media (hover: none) on touch devices. role="region" + aria-live="polite" so SR announces loaded content.
TranscriptionReadView.svelte Composes splitByMarkers + renderTranscriptionBody — markers stay as <em data-marker> siblings (B19b). Mounts the hover card on mouseenter with manual getBoundingClientRect() flip (default below-right; flip up if mention <200px from bottom or in bottom 30% of viewport; flip left if <300px from right). 404 → marks anchor data-person-deleted="true", suppresses card and click. Per-page SvelteMap<personId, Promise> dedup cache (B15.5). Click → goto('/persons/{id}'). eslint-disable comment mirrors CommentMessage.svelte:88-89.
e2e/person-mention-read.spec.ts B20: page.hover() mounts the card, mouseleave unmounts. B21: Pixel 7 device context with hasTouch: truepage.tap() navigates without ever showing the card.

Stored-XSS hardening (Sina #5505 / Nora PR-A review)

  • displayName flows from sidecar into block.text unescaped on the backend — PR-B2 escapes both block text and displayName on render.
  • Tests cover <script>, <img onerror>, HTML-entity-already-encoded payloads, and quote characters in displayName (would otherwise break the href attribute).

Tests

  • 17 new unit tests for renderTranscriptionBody (XSS, mention rendering, longest-first, first-sidecar-wins, word boundary, no-double-encoding).
  • 18 new unit tests for PersonHoverCard (skeleton/error/loaded states, family-chip filter, 120-char truncation, optional sections, ARIA, positioning).
  • 8 new component tests for TranscriptionReadView (mention rendering, B19b marker composition, fetch dedup B15.5, hover card mount/unmount, 404 degrade).
  • 4 new e2e tests for B20/B21 (desktop hover + touch tap).

Total new tests: 47. All passing. The single unrelated failing unit test on main (hilfe/transkription/page.svelte.spec.ts) is pre-existing and verified not introduced by this PR.

Coordination notes

  • Builds on frontend/src/lib/types.ts's PersonMention type (added in PR-A regen).
  • Reuses Paraglide keys person_mention_open_link, person_mention_hover_hint, person_mention_load_error (added in PR-B1).
  • formatLifeDateRange() reused from personLifeDates.ts (PR-B1).
  • No new dependencies. CI delta: ~negligible (vitest browser tests already in scope).

Test plan

  • Reviewer runs npm run test — confirms all PR-B2 unit tests pass (47/47).
  • Reviewer runs npx playwright test person-mention-read.spec.ts against the running stack — confirms B20/B21 pass.
  • Reviewer hovers a real @mention in the transcription panel — verifies skeleton appears within 200ms, then loaded card.
  • Reviewer taps a mention on a touch device — verifies direct nav, no card.
  • Reviewer deletes a mentioned Person via admin — refreshes the document — confirms the link degrades to plain text on first hover.

Closes the read-mode half of #362 once merged together with PR-A and PR-B1.

🤖 Generated with Claude Code

Implements **PR-B2** of issue #362 — read-mode rendering of person mentions and the hover card. Builds on PR-A (backend, merged) and PR-B1 (edit-mode infrastructure, merged). ## What this PR does | Layer | Change | |---|---| | `mention.ts` | New `renderTranscriptionBody(text, mentions)` — escapes HTML, replaces every `@DisplayName` with `<a href="/persons/{id}" class="person-mention" data-person-id="{id}">DisplayName</a>`. Strips the `@` trigger from the rendered link text per spec. | | `mention.ts` | Word-boundary lookahead so `@Hans` no longer eats `@HansMüller`. Longest-displayName-first + first-sidecar-wins make rendering deterministic for the OQ-1 collision case (#5339 §C4). | | `routes/layout.css` | New `.person-mention` global rule next to `.mention`. Underline at rest (WCAG AA), focus-visible 2px box-shadow ring on `--c-ink`. | | `PersonHoverCard.svelte` | Three states: skeleton (320×180, 3 pulse-animated bars, `prefers-reduced-motion: reduce`), generic error message, loaded card (navy header with name, life-date range, `geb. <alias>`; family-only relationship chips `PARENT_OF`/`SPOUSE_OF`/`SIBLING_OF`; notes excerpt capped at 120 chars; footer `Zur Person →` + hover hint). Hidden via `@media (hover: none)` on touch devices. `role="region"` + `aria-live="polite"` so SR announces loaded content. | | `TranscriptionReadView.svelte` | Composes `splitByMarkers` + `renderTranscriptionBody` — markers stay as `<em data-marker>` siblings (B19b). Mounts the hover card on `mouseenter` with manual `getBoundingClientRect()` flip (default below-right; flip up if mention <200px from bottom **or** in bottom 30% of viewport; flip left if <300px from right). 404 → marks anchor `data-person-deleted="true"`, suppresses card and click. Per-page `SvelteMap<personId, Promise>` dedup cache (B15.5). Click → `goto('/persons/{id}')`. eslint-disable comment mirrors `CommentMessage.svelte:88-89`. | | `e2e/person-mention-read.spec.ts` | B20: `page.hover()` mounts the card, mouseleave unmounts. B21: Pixel 7 device context with `hasTouch: true` — `page.tap()` navigates without ever showing the card. | ## Stored-XSS hardening (Sina #5505 / Nora PR-A review) - `displayName` flows from sidecar into `block.text` unescaped on the backend — **PR-B2 escapes both block text and `displayName` on render**. - Tests cover `<script>`, `<img onerror>`, HTML-entity-already-encoded payloads, and quote characters in `displayName` (would otherwise break the `href` attribute). ## Tests - 17 new unit tests for `renderTranscriptionBody` (XSS, mention rendering, longest-first, first-sidecar-wins, word boundary, no-double-encoding). - 18 new unit tests for `PersonHoverCard` (skeleton/error/loaded states, family-chip filter, 120-char truncation, optional sections, ARIA, positioning). - 8 new component tests for `TranscriptionReadView` (mention rendering, B19b marker composition, fetch dedup B15.5, hover card mount/unmount, 404 degrade). - 4 new e2e tests for B20/B21 (desktop hover + touch tap). Total new tests: **47**. All passing. The single unrelated failing unit test on `main` (`hilfe/transkription/page.svelte.spec.ts`) is **pre-existing** and verified not introduced by this PR. ## Coordination notes - Builds on `frontend/src/lib/types.ts`'s `PersonMention` type (added in PR-A regen). - Reuses Paraglide keys `person_mention_open_link`, `person_mention_hover_hint`, `person_mention_load_error` (added in PR-B1). - `formatLifeDateRange()` reused from `personLifeDates.ts` (PR-B1). - No new dependencies. CI delta: ~negligible (vitest browser tests already in scope). ## Test plan - [ ] Reviewer runs `npm run test` — confirms all PR-B2 unit tests pass (47/47). - [ ] Reviewer runs `npx playwright test person-mention-read.spec.ts` against the running stack — confirms B20/B21 pass. - [ ] Reviewer hovers a real `@mention` in the transcription panel — verifies skeleton appears within 200ms, then loaded card. - [ ] Reviewer taps a mention on a touch device — verifies direct nav, no card. - [ ] Reviewer deletes a mentioned Person via admin — refreshes the document — confirms the link degrades to plain text on first hover. Closes the read-mode half of #362 once merged together with PR-A and PR-B1. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 5 commits 2026-04-29 08:30:14 +02:00
Replaces every @DisplayName in a transcription block's text with an anchor
link to /persons/{personId}, sourced from the mentionedPersons sidecar.
The @ prefix is stripped from the rendered link text per spec — it is an
editor affordance, not part of the historical text.

Stored-XSS hardening: HTML-escapes block text, displayName, and personId
before injection. Word-boundary lookahead avoids prefix collisions
(@Hans vs @HansMüller). Longest-displayName-first + first-sidecar-wins
make rendering deterministic for the OQ-1 collision case (#5339).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Underline-at-rest (WCAG AA) so the link affordance does not depend on
colour alone. focus-visible uses a 2px box-shadow ring on --c-ink with a
2px border-radius — the same focus-ring shape as the comment .mention
chip but rectangular instead of pill, since the anchor sits in flowing
text.

Lives next to the existing .mention rule because Svelte scoped styles
do not reach the HTML injected by {@html …} in TranscriptionReadView.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The card has three render states:
  - loading  → 320×180 skeleton with three pulse-animated bars; respects
               prefers-reduced-motion (animation disabled, opacity dimmed)
  - error    → generic load-error message in the body; the footer link
               still navigates (click works regardless of fetch outcome)
  - loaded   → navy header with name, life-date range, and "geb. <alias>";
               family-only relationship chips (PARENT_OF / SPOUSE_OF /
               SIBLING_OF) — non-family types are filtered out;
               notes excerpt capped at 120 chars with ellipsis;
               footer with "Zur Person →" + hover hint

aria-live="polite" on the card root so screen readers announce loaded
content when the fetch resolves; the host's id is the cardId so the
parent anchor can use aria-describedby. The card is hidden via
@media (hover: none) on touch devices — tap navigates directly per
spec.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Composes splitByMarkers + renderTranscriptionBody so [unleserlich]
markers render as <em data-marker> siblings of the mention anchor —
neither nested inside the other (B19b).

Hover card lifecycle on each .person-mention anchor:
  mouseenter → set aria-describedby, place card via getBoundingClientRect
               (default below-right; flip up if <200px from bottom or
                mention is in bottom 30% of viewport; flip left if
                <300px from right), fire fetch, mount card with
                skeleton state
  resolved   → swap card to loaded state with person + family
                relationships (PARENT_OF / SPOUSE_OF / SIBLING_OF only)
  404        → degrade: mark anchor with data-person-deleted="true",
                unmount card, suppress future hovers/clicks
  network    → swap card to error state — link still navigates
  mouseleave → drop aria-describedby, unmount card

Per-page SvelteMap<personId, Promise> cache (B15.5) so a sweep across
N mentions of the same person fires the backend once. Click handler
calls goto() so SvelteKit handles routing without a full reload.

Event listeners are attached once per article via a Svelte action
because the anchor HTML is injected via {@html ...} and would not
receive declarative bindings. The eslint-disable comment mirrors
the rationale on CommentMessage.svelte:88-89.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
test(e2e): person-mention read mode hover (B20) and tap (B21)
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m22s
CI / OCR Service Tests (push) Successful in 49s
CI / Backend Unit Tests (push) Failing after 3m9s
CI / Unit & Component Tests (pull_request) Failing after 3m15s
CI / OCR Service Tests (pull_request) Successful in 38s
CI / Backend Unit Tests (pull_request) Failing after 3m0s
ae868f4110
Creates a Person, document, annotation, and transcription block with
mentionedPersons sidecar, then exercises the read-mode link in two
contexts:
  - Desktop: page.hover() mounts the hover card; mouseleave unmounts.
  - Touch (Pixel 7 device): page.tap() navigates to /persons/{id}
    without the card ever mounting (tap opens the page directly).

Tests are sequential because they share a single document/person via
beforeAll/afterAll. The touch test spins up a separate browser context
with hasTouch=true reusing the stored auth state.

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

Markus Keller — Senior Application Architect

Verdict: Approved with concerns

This is a well-bounded frontend-only change. The module boundary between the rendering helper (mention.ts), the presentational component (PersonHoverCard), and the orchestrator (TranscriptionReadView) is clean — each layer owns one concern, and dependencies flow inward. No service-to-repository leaks, no shared DTOs creeping across feature modules. Reuse of formatLifeDateRange and the existing Paraglide keys is exactly the kind of low-friction reuse I expect.

Concerns (worth addressing, not blockers)

  1. {@html ...} boundary needs a single, named gateway. TranscriptionReadView.svelte:203 and CommentMessage.svelte:88-89 both eslint-disable the same Svelte rule with effectively the same justification. Two call sites today, three or four next quarter, and the safety invariant ("the renderer always escapes first") is now distributed knowledge. Consider extracting a tiny <SafeHtml html={...} /> wrapper or a typed RenderedHtml brand type returned from renderTranscriptionBody / renderBody. The boundary becomes auditable in one place — exactly the centralisation principle that wins for security/audit code.

  2. Module placement of LoadState. LoadState is exported from PersonHoverCard.svelte (frontend/src/lib/components/PersonHoverCard.svelte:9-12) and re-imported by the orchestrator. That is fine, but the type now belongs to two layers: the view's contract and the orchestrator's state machine. If a third caller wants the same shape (admin previews, briefwechsel cards), this becomes a circular import waiting to happen. A personHoverCard.types.ts next to personLifeDates.ts keeps the type module-stable.

  3. Two DOM selector strategies in one file. attachMentionHandlers (line 161) hand-rolls capture-phase listeners on mouseenter/mouseleave because they don't bubble — fine, this is the right call for delegated handling on {@html}-injected anchors. But the selector a.person-mention is a magic string repeated four times here and once more in CSS, e2e, and unit tests. Extract a PERSON_MENTION_SELECTOR = 'a.person-mention' constant alongside the renderer so the contract between renderer and orchestrator is a single import, not a string-literal coincidence.

  4. In-memory cache lifetime. hoverCache is per-component instance (good, scoped to one document view). But deletedPersonIds is also per-component — if the same person is deleted by an admin in another tab while this view is open, the cache will still serve the loaded card on next hover. For PR-B2 that's acceptable (404 is rare in this read-only view), but I'd add a comment naming the trade-off so a future reviewer doesn't try to "fix" it by reaching for a global store.

  5. No ADR for the read-mode rendering pipeline. The sequencing — splitByMarkers → per-segment renderTranscriptionBody → join — is non-obvious enough to merit a short note in docs/adr/ (or whatever the project uses). The B19b composition decision and the OQ-1 first-sidecar-wins rule are exactly the kind of "why is the code shaped like this" decision that gets reverse-engineered six months from now.

Things done well

  • No new dependencies. Zero infra delta. Pure frontend module.
  • Pure functions in mention.ts are testable without DOM, exactly as the testable-architecture rule demands.
  • The component is small and named after its visible region (PersonHoverCard) — not "Manager" or "Helper".
  • SvelteMap/SvelteSet correctly used for reactive collections.
## Markus Keller — Senior Application Architect **Verdict: Approved with concerns** This is a well-bounded frontend-only change. The module boundary between the rendering helper (`mention.ts`), the presentational component (`PersonHoverCard`), and the orchestrator (`TranscriptionReadView`) is clean — each layer owns one concern, and dependencies flow inward. No service-to-repository leaks, no shared DTOs creeping across feature modules. Reuse of `formatLifeDateRange` and the existing Paraglide keys is exactly the kind of low-friction reuse I expect. ### Concerns (worth addressing, not blockers) 1. **`{@html ...}` boundary needs a single, named gateway.** `TranscriptionReadView.svelte:203` and `CommentMessage.svelte:88-89` both `eslint-disable` the same Svelte rule with effectively the same justification. Two call sites today, three or four next quarter, and the safety invariant ("the renderer always escapes first") is now distributed knowledge. Consider extracting a tiny `<SafeHtml html={...} />` wrapper or a typed `RenderedHtml` brand type returned from `renderTranscriptionBody` / `renderBody`. The boundary becomes auditable in one place — exactly the centralisation principle that wins for security/audit code. 2. **Module placement of `LoadState`.** `LoadState` is exported from `PersonHoverCard.svelte` (frontend/src/lib/components/PersonHoverCard.svelte:9-12) and re-imported by the orchestrator. That is fine, but the type now belongs to two layers: the view's contract and the orchestrator's state machine. If a third caller wants the same shape (admin previews, briefwechsel cards), this becomes a circular import waiting to happen. A `personHoverCard.types.ts` next to `personLifeDates.ts` keeps the type module-stable. 3. **Two DOM selector strategies in one file.** `attachMentionHandlers` (line 161) hand-rolls capture-phase listeners on `mouseenter`/`mouseleave` because they don't bubble — fine, this is the right call for delegated handling on `{@html}`-injected anchors. But the selector `a.person-mention` is a magic string repeated four times here and once more in CSS, e2e, and unit tests. Extract a `PERSON_MENTION_SELECTOR = 'a.person-mention'` constant alongside the renderer so the contract between renderer and orchestrator is a single import, not a string-literal coincidence. 4. **In-memory cache lifetime.** `hoverCache` is per-component instance (good, scoped to one document view). But `deletedPersonIds` is also per-component — if the same person is deleted by an admin in another tab while this view is open, the cache will still serve the loaded card on next hover. For PR-B2 that's acceptable (404 is rare in this read-only view), but I'd add a comment naming the trade-off so a future reviewer doesn't try to "fix" it by reaching for a global store. 5. **No ADR for the read-mode rendering pipeline.** The sequencing — `splitByMarkers` → per-segment `renderTranscriptionBody` → join — is non-obvious enough to merit a short note in `docs/adr/` (or whatever the project uses). The B19b composition decision and the OQ-1 first-sidecar-wins rule are exactly the kind of "why is the code shaped like this" decision that gets reverse-engineered six months from now. ### Things done well - No new dependencies. Zero infra delta. Pure frontend module. - Pure functions in `mention.ts` are testable without DOM, exactly as the testable-architecture rule demands. - The component is small and named after its visible region (`PersonHoverCard`) — not "Manager" or "Helper". - `SvelteMap`/`SvelteSet` correctly used for reactive collections.
Author
Owner

Felix Brandt — Senior Fullstack Developer

Verdict: Approved with concerns

Read every changed line. TDD evidence is solid — 47 tests with named, sentence-style assertions; mention.spec.ts covers the security cases (XSS, quote-in-href, double-encoding, word boundary, longest-first, first-sidecar-wins) before the implementation got smarter than a replaceAll. That's the discipline I expect.

Blockers

None.

Suggestions (in priority order)

  1. fetchHoverData does two things — split it. frontend/src/lib/components/TranscriptionReadView.svelte:57-76 handles cache lookup and the actual fetch and relationship merging and 404 normalisation. That's four jobs. Extract:

    async function loadHoverData(personId: string): Promise<HoverData | null> {  }   // pure fetch
    function getOrFetch(personId: string): Promise<HoverData | null> {  }            // cache wrapper
    

    The cache helper is now 5 lines and the fetch logic gets its own test boundary.

  2. handleMentionEnter has too many states for one function. Same file, lines 102-138 — six early-returns interleaved with two state assignments. Hard to scan. Split into:

    • prepareCard(link, personId) — derives cardId, position, sets aria-describedby, returns initial {status: 'loading'} activeCard
    • applyResult(personId, data) — updates activeCard if it's still ours
    • handleMentionEnter — orchestrates the two
      The current inline pattern works, but each helper would be testable on its own and the orchestrator becomes 5 lines.
  3. computeCardPosition magic numbers. vh * 0.7, vw - rect.left < 300, CARD_WIDTH, CARD_HEIGHT (line 87-93). The 30 % bottom-band rule and the 300 px right-flip threshold are spec from Leonie #5329 — pin those numbers in a named constant block at the top of the file (BOTTOM_BAND_RATIO, RIGHT_FLIP_THRESHOLD_PX). If they ever drift, the spec link drifts with them.

  4. Test brittleness — setTimeout(r, 50) in TranscriptionReadView.svelte.test.ts. Lines 283, 311, 352, 365-367, 383. These are racing the microtask queue. await Promise.resolve() (or await vi.waitFor(() => …)) is deterministic; setTimeout(r, 50) is "I hope this is enough." Sara is going to flag this — get ahead of it.

  5. mention.ts:87-95 could fold the dedup into the sort. Two passes (dedup then sort) where one would do — Array.from(new Map(mentionedPersons.map(m => [m.displayName, m])).values()).sort(...). Same first-sidecar-wins behaviour, half the LOC, one allocation. Personal taste, not a hill to die on.

  6. renderTranscriptionBody returns a string the caller must trust. This is the same shape Markus flagged from architecture. From a clean-code lens: the return type is string, not RenderedHtml. Brand types (type RenderedHtml = string & { __brand: 'rendered' }) cost nothing and make it impossible to pass a raw user string to {@html} by accident. Consider for a follow-up.

  7. handleMentionClick ignores middle-click and ctrl/cmd+click. Line 146-156 unconditionally event.preventDefault() and goto(...). That breaks "open in new tab" — a real workflow for researchers comparing two persons. Either let modified clicks fall through to the default <a href> behaviour, or document why the SPA-only navigation is intentional.

Things done well

  • Failing tests precede the rendering smarts (longest-first, word-boundary). The XSS test is a regression suite for a real CWE-79.
  • Guard clauses replace nesting throughout — handleMentionEnter reads top-down.
  • $derived (not $state + $effect) for familyChips, dateRange, notesExcerpt in PersonHoverCard — exactly right.
  • SvelteMap/SvelteSet from svelte/reactivity — exactly right for reactive collections.
  • Keyed {#each ... (chip.id)} and {#each ... (block.id)}.
## Felix Brandt — Senior Fullstack Developer **Verdict: Approved with concerns** Read every changed line. TDD evidence is solid — 47 tests with named, sentence-style assertions; `mention.spec.ts` covers the security cases (XSS, quote-in-href, double-encoding, word boundary, longest-first, first-sidecar-wins) before the implementation got smarter than a `replaceAll`. That's the discipline I expect. ### Blockers None. ### Suggestions (in priority order) 1. **`fetchHoverData` does two things — split it.** `frontend/src/lib/components/TranscriptionReadView.svelte:57-76` handles cache lookup *and* the actual fetch *and* relationship merging *and* 404 normalisation. That's four jobs. Extract: ```ts async function loadHoverData(personId: string): Promise<HoverData | null> { … } // pure fetch function getOrFetch(personId: string): Promise<HoverData | null> { … } // cache wrapper ``` The cache helper is now 5 lines and the fetch logic gets its own test boundary. 2. **`handleMentionEnter` has too many states for one function.** Same file, lines 102-138 — six early-returns interleaved with two state assignments. Hard to scan. Split into: - `prepareCard(link, personId)` — derives `cardId`, `position`, sets aria-describedby, returns initial `{status: 'loading'}` activeCard - `applyResult(personId, data)` — updates activeCard if it's still ours - `handleMentionEnter` — orchestrates the two The current inline pattern works, but each helper would be testable on its own and the orchestrator becomes 5 lines. 3. **`computeCardPosition` magic numbers.** `vh * 0.7`, `vw - rect.left < 300`, `CARD_WIDTH`, `CARD_HEIGHT` (line 87-93). The 30 % bottom-band rule and the 300 px right-flip threshold are spec from Leonie #5329 — pin those numbers in a named constant block at the top of the file (`BOTTOM_BAND_RATIO`, `RIGHT_FLIP_THRESHOLD_PX`). If they ever drift, the spec link drifts with them. 4. **Test brittleness — `setTimeout(r, 50)` in `TranscriptionReadView.svelte.test.ts`.** Lines 283, 311, 352, 365-367, 383. These are racing the microtask queue. `await Promise.resolve()` (or `await vi.waitFor(() => …)`) is deterministic; `setTimeout(r, 50)` is "I hope this is enough." Sara is going to flag this — get ahead of it. 5. **`mention.ts:87-95` could fold the dedup into the sort.** Two passes (dedup then sort) where one would do — `Array.from(new Map(mentionedPersons.map(m => [m.displayName, m])).values()).sort(...)`. Same first-sidecar-wins behaviour, half the LOC, one allocation. Personal taste, not a hill to die on. 6. **`renderTranscriptionBody` returns a string the caller must trust.** This is the same shape Markus flagged from architecture. From a clean-code lens: the return type is `string`, not `RenderedHtml`. Brand types (`type RenderedHtml = string & { __brand: 'rendered' }`) cost nothing and make it impossible to pass a raw user string to `{@html}` by accident. Consider for a follow-up. 7. **`handleMentionClick` ignores middle-click and ctrl/cmd+click.** Line 146-156 unconditionally `event.preventDefault()` and `goto(...)`. That breaks "open in new tab" — a real workflow for researchers comparing two persons. Either let modified clicks fall through to the default `<a href>` behaviour, or document why the SPA-only navigation is intentional. ### Things done well - Failing tests precede the rendering smarts (longest-first, word-boundary). The XSS test is a regression suite for a real CWE-79. - Guard clauses replace nesting throughout — `handleMentionEnter` reads top-down. - `$derived` (not `$state` + `$effect`) for `familyChips`, `dateRange`, `notesExcerpt` in PersonHoverCard — exactly right. - `SvelteMap`/`SvelteSet` from `svelte/reactivity` — exactly right for reactive collections. - Keyed `{#each ... (chip.id)}` and `{#each ... (block.id)}`.
Author
Owner

Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

Frontend-only change. No Compose deltas, no CI workflow changes, no image tags, no volumes, no env vars, no secrets. There is genuinely nothing here in my domain to flag.

What I checked

  • docker-compose.yml, infra/, .gitea/workflows/ — untouched in the diff.
  • No new runtime dependencies in package.json — confirmed via the PR description and the diff.
  • No new background processes, schedulers, or external endpoints introduced.
  • e2e/person-mention-read.spec.ts reuses the existing STORAGE_STATE auth fixture and the existing webServer config in playwright.config.ts. It does not require new CI infrastructure (E2E isn't run in CI today per frontend/e2e/CLAUDE.md).

Tiny observation, not a blocker

  • The setTimeout(r, 50) waits in TranscriptionReadView.svelte.test.ts are component-test code, not E2E, but the same anti-pattern Sara flagged in the past for our suite. If these slip past her into CI, they'll be the source of the next intermittent failure on a slow runner. Worth fixing before that bites — but that is a Sara/Felix call, not a DevOps blocker.

LGTM from the platform side.

## Tobias Wendt — DevOps & Platform Engineer **Verdict: Approved** Frontend-only change. No Compose deltas, no CI workflow changes, no image tags, no volumes, no env vars, no secrets. There is genuinely nothing here in my domain to flag. ### What I checked - `docker-compose.yml`, `infra/`, `.gitea/workflows/` — untouched in the diff. - No new runtime dependencies in `package.json` — confirmed via the PR description and the diff. - No new background processes, schedulers, or external endpoints introduced. - `e2e/person-mention-read.spec.ts` reuses the existing `STORAGE_STATE` auth fixture and the existing `webServer` config in `playwright.config.ts`. It does not require new CI infrastructure (E2E isn't run in CI today per `frontend/e2e/CLAUDE.md`). ### Tiny observation, not a blocker - The `setTimeout(r, 50)` waits in `TranscriptionReadView.svelte.test.ts` are component-test code, not E2E, but the same anti-pattern Sara flagged in the past for our suite. If these slip past her into CI, they'll be the source of the next intermittent failure on a slow runner. Worth fixing before that bites — but that is a Sara/Felix call, not a DevOps blocker. LGTM from the platform side.
Author
Owner

Elicit — Senior Requirements Engineer

Verdict: Approved with concerns

I'm in Brownfield mode here, reviewing PR-B2 against issue #362 expectations. This PR-B2 description is dense, traceable (#5339, #5505, #5324, B19b, B15.5, OQ-1), and links each technical decision back to a comment thread or spec ID. That's the kind of issue-driven development I'd hold up as the model. Most of my concerns are about completeness of the acceptance evidence, not about the implementation.

Findings

  1. Missing acceptance criteria for the "404 deletion" path in the e2e suite. The PR description's test plan item 5 is "Reviewer deletes a mentioned Person via admin — refreshes the document — confirms the link degrades to plain text on first hover." The component test covers the 404 → data-person-deleted="true" path (TranscriptionReadView.svelte.test.ts:373-391), but the e2e suite at frontend/e2e/person-mention-read.spec.ts does not exercise this. The e2e tests cover B20 (hover) and B21 (touch tap) only. Either:

    • add a B22 e2e test that creates the doc, deletes the person, reloads, and asserts the link is no longer interactive, OR
    • add a checklist note to the issue acknowledging that B22 is asserted at the component layer only and explaining why (Page-Object cost vs. coverage trade-off).

    Either is fine; what's not fine is having a test plan item with no automated assertion in the suite.

  2. NFR — accessibility coverage gap for the hover card itself. aria-live="polite" is set, but prefers-reduced-motion is honoured only for the skeleton pulse animation (good). However:

    • There is no automated axe check on the hover-card-mounted state in any test file. Visual regression is not the same as a11y enforcement (Sara's domain, but this is a requirements completeness issue first).
    • The card has role="region" without an aria-label or aria-labelledby. WCAG 1.3.1: a region landmark without an accessible name is a known axe-core warning. Either name the region (e.g. aria-label={state.person.displayName} when loaded) or downgrade the role.
  3. NFR — keyboard reachability. Description says "WCAG AA". Read mode anchors are real <a href> so they tab naturally. But: when a keyboard user tabs onto the mention, the hover card never appears — mouseenter is the only trigger (TranscriptionReadView.svelte:175). For the senior audience constraint Leonie reminds us about, this is a usability gap. Two options:

    • Add focusin/focusout listeners alongside mouseenter/mouseleave (small change, would close the gap).
    • Explicitly defer to a future issue and document that keyboard hover is non-goal for B2.

    The issue body should say which.

  4. Ambiguity — "family-only relationship chips PARENT_OF/SPOUSE_OF/SIBLING_OF". What about CHILD_OF? The data model historically uses directional relationships (PARENT_OF as a forward edge from parent → child), but if the PR only filters on those three values, a person who appears as a child of someone (i.e., the relationship row says PARENT_OF with the other person in the relatedPersonId) — does the chip render? Unit tests only assert that filtering works for the three named types; they don't test the directionality. OQ-372-01: Should chips also surface inverse relationships (e.g., my parents, even though the row was created from their side)? This may already be handled by the backend's relationship endpoint. Confirm or add a test.

  5. Hidden assumption — "120-char excerpt" is unilateral. The notes excerpt cuts at 120 characters with an ellipsis (PersonHoverCard.svelte:28, 42-48). That's mid-word truncation in German (long compound nouns: "Familienzusammenführung"). For a senior audience this can produce confusing output ("Sie war eine bekannte Familienzu…"). Prefer cutting at the last word boundary. Either fix or note the deferred decision.

  6. Open question — caching scope. Per Markus, the cache is per-component instance. Per-document is fine for a read view that lives one mount cycle. But if the user opens transcription, closes it, opens it again on the same doc, the cache is rebuilt and we re-fetch every mentioned person. Is that intentional? OQ-372-02: Should the hover-card cache be per-document (survives panel close/reopen) or per-mount (current)? Either answer is defensible — surface the choice.

Done well

  • Traceability from issue → PR → individual decision IDs (#5339, #5505, OQ-1) is exemplary.
  • Every test has a sentence-form name; failures will be self-documenting in CI.
  • Coordination notes section ("Builds on PR-A regen", "Reuses person_mention_* Paraglide keys", "No new deps") is exactly the kind of cross-PR breadcrumb that solo + LLM-driven workflows need.
## Elicit — Senior Requirements Engineer **Verdict: Approved with concerns** I'm in Brownfield mode here, reviewing PR-B2 against issue #362 expectations. This PR-B2 description is dense, traceable (#5339, #5505, #5324, B19b, B15.5, OQ-1), and links each technical decision back to a comment thread or spec ID. That's the kind of issue-driven development I'd hold up as the model. Most of my concerns are about completeness of the acceptance evidence, not about the implementation. ### Findings 1. **Missing acceptance criteria for the "404 deletion" path in the e2e suite.** The PR description's test plan item 5 is *"Reviewer deletes a mentioned Person via admin — refreshes the document — confirms the link degrades to plain text on first hover."* The component test covers the 404 → `data-person-deleted="true"` path (`TranscriptionReadView.svelte.test.ts:373-391`), but the e2e suite at `frontend/e2e/person-mention-read.spec.ts` does not exercise this. The e2e tests cover B20 (hover) and B21 (touch tap) only. Either: - add a B22 e2e test that creates the doc, deletes the person, reloads, and asserts the link is no longer interactive, OR - add a checklist note to the issue acknowledging that B22 is asserted at the component layer only and explaining why (Page-Object cost vs. coverage trade-off). Either is fine; what's not fine is having a test plan item with no automated assertion in the suite. 2. **NFR — accessibility coverage gap for the hover card itself.** `aria-live="polite"` is set, but `prefers-reduced-motion` is honoured only for the skeleton pulse animation (good). However: - There is no automated axe check on the hover-card-mounted state in any test file. Visual regression is not the same as a11y enforcement (Sara's domain, but this is a requirements completeness issue first). - The card has `role="region"` without an `aria-label` or `aria-labelledby`. WCAG 1.3.1: a region landmark without an accessible name is a known axe-core warning. Either name the region (e.g. `aria-label={state.person.displayName}` when loaded) or downgrade the role. 3. **NFR — keyboard reachability.** Description says "WCAG AA". Read mode anchors are real `<a href>` so they tab naturally. But: when a keyboard user tabs onto the mention, the hover card never appears — `mouseenter` is the only trigger (`TranscriptionReadView.svelte:175`). For the senior audience constraint Leonie reminds us about, this is a usability gap. Two options: - Add `focusin`/`focusout` listeners alongside `mouseenter`/`mouseleave` (small change, would close the gap). - Explicitly defer to a future issue and document that keyboard hover is non-goal for B2. The issue body should say which. 4. **Ambiguity — "family-only relationship chips PARENT_OF/SPOUSE_OF/SIBLING_OF".** What about `CHILD_OF`? The data model historically uses directional relationships (PARENT_OF as a forward edge from parent → child), but if the PR only filters on those three values, a person who appears as a child of someone (i.e., the relationship row says PARENT_OF with the *other* person in the `relatedPersonId`) — does the chip render? Unit tests only assert that filtering works for the three named types; they don't test the directionality. **OQ-372-01: Should chips also surface inverse relationships (e.g., my parents, even though the row was created from their side)?** This may already be handled by the backend's relationship endpoint. Confirm or add a test. 5. **Hidden assumption — "120-char excerpt" is unilateral.** The notes excerpt cuts at 120 characters with an ellipsis (`PersonHoverCard.svelte:28, 42-48`). That's mid-word truncation in German (long compound nouns: "Familienzusammenführung"). For a senior audience this can produce confusing output ("Sie war eine bekannte Familienzu…"). Prefer cutting at the last word boundary. Either fix or note the deferred decision. 6. **Open question — caching scope.** Per Markus, the cache is per-component instance. Per-document is fine for a read view that lives one mount cycle. But if the user opens transcription, closes it, opens it again on the same doc, the cache is rebuilt and we re-fetch every mentioned person. Is that intentional? **OQ-372-02: Should the hover-card cache be per-document (survives panel close/reopen) or per-mount (current)?** Either answer is defensible — surface the choice. ### Done well - Traceability from issue → PR → individual decision IDs (#5339, #5505, OQ-1) is exemplary. - Every test has a sentence-form name; failures will be self-documenting in CI. - Coordination notes section ("Builds on PR-A regen", "Reuses `person_mention_*` Paraglide keys", "No new deps") is exactly the kind of cross-PR breadcrumb that solo + LLM-driven workflows need.
Author
Owner

Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved with concerns

The XSS hardening here is unambiguously good. escapeHtml is correct (escapes & < > " ' in the right order, ampersand first), and renderTranscriptionBody escapes the block text before substituting mention anchors. The test mention.spec.ts:269-304 pins the right invariants: script tag in displayName escaped, <img onerror> payload in surrounding text escaped, double-encoding preserved (no silent decode), and — important — quote-in-displayName lands as &quot; so it cannot break out of the href attribute. CWE-79 mitigated at the rendering boundary, exactly where it should be.

Findings (in OWASP-priority order)

🟡 Medium — Suggestion: rel="noopener noreferrer" on the rendered anchor

File: frontend/src/lib/utils/mention.ts:102

const link = `<a href="/persons/${escapedPersonId}" class="person-mention" data-person-id="${escapedPersonId}">${escapedDisplayName}</a>`;

The href is internal (/persons/{uuid}) so noopener isn't a hard requirement today. But the link template doesn't constrain that — if escapedPersonId ever flowed in from a less-sanitised source (e.g. a future "external person" feature), this template would happily emit a clickable absolute URL. Two options:

  • Validate that personId is a UUID at the renderer boundary (defense in depth).
  • Or just unconditionally append rel="noopener" so the anchor is safe by construction even if the URL pattern ever widens.

I'd take option 1: a regex check /^[0-9a-f-]{36}$/i.test(mention.personId) at the top of the loop, skip the substitution if it fails. CWE-79 partially, CWE-601 (Open Redirect) more directly. Negligible perf cost, large defensive benefit.

🟡 Medium — @html boundary trusts a string return type

Same root concern as Markus's #1. renderTranscriptionBody returns string — TypeScript cannot tell whether the string came from the renderer or from an attacker. The next refactor that does {@html block.text} instead of {@html renderBlockHtml(block)} is a single typo away from a stored-XSS regression. Brand the return type:

export type SafeHtml = string & { readonly __brand: 'SafeHtml' };
export function renderTranscriptionBody(...): SafeHtml { ... return result as SafeHtml; }

Then the @html consumer requires a SafeHtml. Compile-time enforcement of the escape invariant. Adds 2 lines.

File: TranscriptionReadView.svelte:146-156
When a 404 marks a link as deleted, handleMentionClick does event.preventDefault() and returns without navigating. Net effect: clicking a tombstoned mention silently does nothing. From a UX-security angle this is a minor "disabled control without feedback" smell. Not a vuln, but a user might think they clicked a bad link. Consider either:

  • letting the default anchor navigation happen (will 404 on /persons/{deletedId} and the existing 404 page handles it cleanly), or
  • toast/aria-live the user that the linked person is no longer available.

🟢 Low — fetch error path swallows non-404, non-OK responses

File: TranscriptionReadView.svelte:64-65

if (!personRes.ok) throw new Error(`person fetch failed: ${personRes.status}`);

…then in handleMentionEnter the catch block sets state to 'error' but never logs. A 500 from the persons endpoint becomes a generic "Daten konnten nicht geladen werden" with no observability hook. For a read-only view this is acceptable, but adding a console.warn(...) (or a Sentry/GlitchTip breadcrumb if/when that lands) would make production triage possible. Not a security finding per se, but missing-audit-trail is in our threat model for the family archive.

Things I checked and they're correct

  • escapeHtml order: & first, then <>"' — no double-encoding bug.
  • escapeRegExp correctly escapes the regex metacharacters that Unicode display names could contain (., *, +, ?, ^, $, {}, (), |, [], \).
  • The regex flag is gu (global, unicode) — required for \p{L}\p{N} lookahead to work on German umlauts and Turkish names.
  • No eval, no innerHTML writes, no document.write in the diff.
  • No CORS changes, no auth bypass, no permission-check removal.
  • No logging of unsanitized user input.
  • No JPQL/SQL touched (frontend-only PR).
  • data-person-id="${escapedPersonId}" — escaped, attribute-safe, can't break out of the data attribute.
  • Word-boundary lookahead (?![\\p{L}\\p{N}]) correctly prevents @Hans from matching @HansMüller AND prevents an attacker from confusing the renderer by appending characters that look like part of the displayName.

Detection notes for the regression suite (already in the PR)

The XSS tests in mention.spec.ts:269-304 are exactly the permanent regression tests I'd ask for. Keep them. If/when a Semgrep config is added, a rule that flags {@html …} without a typed SafeHtml consumer would catch the next round-trip.

## Nora "NullX" Steiner — Application Security Engineer **Verdict: Approved with concerns** The XSS hardening here is unambiguously good. `escapeHtml` is correct (escapes `& < > " '` in the right order, ampersand first), and `renderTranscriptionBody` escapes the block text *before* substituting mention anchors. The test `mention.spec.ts:269-304` pins the right invariants: script tag in displayName escaped, `<img onerror>` payload in surrounding text escaped, double-encoding preserved (no silent decode), and — important — quote-in-displayName lands as `&quot;` so it cannot break out of the `href` attribute. CWE-79 mitigated at the rendering boundary, exactly where it should be. ### Findings (in OWASP-priority order) #### 🟡 Medium — Suggestion: rel="noopener noreferrer" on the rendered anchor **File:** `frontend/src/lib/utils/mention.ts:102` ```ts const link = `<a href="/persons/${escapedPersonId}" class="person-mention" data-person-id="${escapedPersonId}">${escapedDisplayName}</a>`; ``` The href is internal (`/persons/{uuid}`) so `noopener` isn't a hard requirement *today*. But the link template doesn't constrain that — if `escapedPersonId` ever flowed in from a less-sanitised source (e.g. a future "external person" feature), this template would happily emit a clickable absolute URL. Two options: - Validate that `personId` is a UUID at the renderer boundary (defense in depth). - Or just unconditionally append `rel="noopener"` so the anchor is safe by construction even if the URL pattern ever widens. I'd take option 1: a regex check `/^[0-9a-f-]{36}$/i.test(mention.personId)` at the top of the loop, skip the substitution if it fails. CWE-79 partially, CWE-601 (Open Redirect) more directly. Negligible perf cost, large defensive benefit. #### 🟡 Medium — `@html` boundary trusts a string return type Same root concern as Markus's #1. `renderTranscriptionBody` returns `string` — TypeScript cannot tell whether the string came from the renderer or from an attacker. The next refactor that does `{@html block.text}` instead of `{@html renderBlockHtml(block)}` is a single typo away from a stored-XSS regression. Brand the return type: ```ts export type SafeHtml = string & { readonly __brand: 'SafeHtml' }; export function renderTranscriptionBody(...): SafeHtml { ... return result as SafeHtml; } ``` Then the `@html` consumer requires a `SafeHtml`. Compile-time enforcement of the escape invariant. Adds 2 lines. #### 🟢 Low — `data-person-deleted="true"` set on the link, but click handler still calls preventDefault **File:** `TranscriptionReadView.svelte:146-156` When a 404 marks a link as deleted, `handleMentionClick` does `event.preventDefault()` and *returns* without navigating. Net effect: clicking a tombstoned mention silently does nothing. From a UX-security angle this is a minor "disabled control without feedback" smell. Not a vuln, but a user might think they clicked a bad link. Consider either: - letting the default anchor navigation happen (will 404 on `/persons/{deletedId}` and the existing 404 page handles it cleanly), or - toast/aria-live the user that the linked person is no longer available. #### 🟢 Low — fetch error path swallows non-404, non-OK responses **File:** `TranscriptionReadView.svelte:64-65` ```ts if (!personRes.ok) throw new Error(`person fetch failed: ${personRes.status}`); ``` …then in `handleMentionEnter` the catch block sets state to `'error'` but never logs. A 500 from the persons endpoint becomes a generic "Daten konnten nicht geladen werden" with no observability hook. For a read-only view this is acceptable, but adding a `console.warn(...)` (or a Sentry/GlitchTip breadcrumb if/when that lands) would make production triage possible. Not a security finding per se, but missing-audit-trail is in our threat model for the family archive. #### ✅ Things I checked and they're correct - `escapeHtml` order: `&` first, then `<>"'` — no double-encoding bug. - `escapeRegExp` correctly escapes the regex metacharacters that Unicode display names could contain (`.`, `*`, `+`, `?`, `^`, `$`, `{}`, `()`, `|`, `[]`, `\`). - The regex flag is `gu` (global, unicode) — required for `\p{L}\p{N}` lookahead to work on German umlauts and Turkish names. - No `eval`, no `innerHTML` writes, no `document.write` in the diff. - No CORS changes, no auth bypass, no permission-check removal. - No logging of unsanitized user input. - No JPQL/SQL touched (frontend-only PR). - `data-person-id="${escapedPersonId}"` — escaped, attribute-safe, can't break out of the data attribute. - Word-boundary lookahead `(?![\\p{L}\\p{N}])` correctly prevents `@Hans` from matching `@HansMüller` AND prevents an attacker from confusing the renderer by appending characters that look like part of the displayName. ### Detection notes for the regression suite (already in the PR) The XSS tests in `mention.spec.ts:269-304` are exactly the permanent regression tests I'd ask for. Keep them. If/when a Semgrep config is added, a rule that flags `{@html …}` without a typed `SafeHtml` consumer would catch the next round-trip.
Author
Owner

Sara Holt — Senior QA Engineer

Verdict: Approved with concerns

47 new tests across the right layers (unit on mention.ts, component on PersonHoverCard and TranscriptionReadView, e2e for B20/B21). Pyramid shape is correct: most assertions live in fast unit tests, only the touch-vs-hover device matrix is at the e2e tier. Test names read as sentences. Factory helpers (AUGUSTE, mentionBlock, POSITION) are extracted, not inlined. This is the test discipline I want to see.

Blockers

None.

Concerns

  1. setTimeout(r, N) waits in component tests — five occurrences. frontend/src/lib/components/TranscriptionReadView.svelte.test.ts lines 283, 311, 352, 365-367, 383. Every one of these is racing the microtask queue. The 50 ms wait at line 352 will pass on a fast laptop and fail on a loaded CI runner. Replace with vi.waitFor(...):

    await vi.waitFor(() => {
        const card = document.querySelector('[data-testid="person-hover-card"]');
        expect(card).not.toBeNull();
    });
    

    vi.waitFor polls until the assertion passes (default 1000 ms timeout, 10 ms interval). It's deterministic — passes the moment the condition is true, fails the moment the timeout elapses. Auto-graded as @Disabled-equivalent risk in my book.

  2. e2e B21 test isolation. frontend/e2e/person-mention-read.spec.ts:133-153 opens a second browser context with Pixel 7 device. The contract is "the tap navigates without ever showing the card". The assertion await expect(touchPage.getByTestId('person-hover-card')).toHaveCount(0) runs after the navigation has already happened — meaning we're now on /persons/{id} and of course there's no hover card on that page (the hover card only exists on the document detail page). The test is effectively only asserting the URL change. Tighten by checking toHaveCount(0) before the tap as well, or check during a small waitForTimeout between hover and tap.

  3. e2e cleanup in afterAll won't run if beforeAll throws. Lines 89-92: if Create document failed: 4xx throws, docId is undefined and the cleanup if (docId) await request.delete(...) quietly skips. Acceptable for this suite, but the first failure leaks state into subsequent CI runs (mostly fine since the doc has a unique title with timestamps elsewhere — but this title is not timestamped). Add a uniqueifier: title: \E2E Person Mention Read ${Date.now()}``.

  4. No accessibility check at the hover-card layer. No axe-playwright scan is run on the document detail page with the card mounted. Per our standard: critical-flow pages get axe-core in E2E. Add a check after await link.hover() and await expect(card).toBeVisible(). This will probably surface the role="region" without aria-label issue Nora and Elicit independently noticed.

  5. No visual regression at 320px. The hover card is width: 320px (PersonHoverCard.svelte:105). On a 320 px viewport that's literally the entire screen width. The flip-left logic at TranscriptionReadView.svelte:92-94 only fires when vw - rect.left < 300, which on a 320 px viewport means every hover triggers it. There is no visual regression test verifying the card actually fits and doesn't horizontal-overflow. Add a screenshot test or document that the hover card is not supported below 768 px (consistent with @media (hover: none), since touch-first phones already suppress it). If suppressed, the hover-card sizing concern is moot — but document the policy.

  6. Test for the position-flip logic is missing. computeCardPosition (TranscriptionReadView.svelte:78-100) has 4 branches (default, flip-up, flip-left, both) and 2 thresholds (CARD_HEIGHT + CARD_GAP, vh * 0.7, 300). Zero unit tests. The function is pure (rect → {top, left}) — extract to src/lib/utils/hoverCardPosition.ts and test every branch. Right now this is the second-most-complex function in the PR and the only one without a test.

  7. PersonHoverCard.svelte.spec.ts:209-211 weak assertion.

    expect(notes.textContent!.length).toBeLessThanOrEqual(122);
    expect(notes.textContent).toContain('…');
    

    122 is a magic number (120 + '…'.length === 121, plus a fudge?). Pin the exact length: expect(notes.textContent).toBe('x'.repeat(120) + '…'). The current assertion would still pass if the truncation went to 121 chars or 100 chars — both are bugs.

Things done well

  • Pyramid shape is right — unit test density is highest where it should be (mention.spec.ts, 17 tests), e2e is a small focused critical-path layer.
  • Factory helpers and test data constants extracted (AUGUSTE, POSITION, mentionBlock) — exactly the readable-tests rule.
  • Each test has one logical assertion (mostly). When they fail, the name tells you what broke.
  • Edge cases for renderTranscriptionBody are excellent — empty, no mentions, longest-first, word boundary, first-sidecar-wins, XSS variants. This is regression coverage that will pay off for years.
  • E2E reuses the shared auth state and document creation API — no flaky DB seeding.
## Sara Holt — Senior QA Engineer **Verdict: Approved with concerns** 47 new tests across the right layers (unit on `mention.ts`, component on `PersonHoverCard` and `TranscriptionReadView`, e2e for B20/B21). Pyramid shape is correct: most assertions live in fast unit tests, only the touch-vs-hover device matrix is at the e2e tier. Test names read as sentences. Factory helpers (`AUGUSTE`, `mentionBlock`, `POSITION`) are extracted, not inlined. This is the test discipline I want to see. ### Blockers None. ### Concerns 1. **`setTimeout(r, N)` waits in component tests — five occurrences.** `frontend/src/lib/components/TranscriptionReadView.svelte.test.ts` lines 283, 311, 352, 365-367, 383. Every one of these is racing the microtask queue. The 50 ms wait at line 352 will pass on a fast laptop and fail on a loaded CI runner. Replace with `vi.waitFor(...)`: ```ts await vi.waitFor(() => { const card = document.querySelector('[data-testid="person-hover-card"]'); expect(card).not.toBeNull(); }); ``` `vi.waitFor` polls until the assertion passes (default 1000 ms timeout, 10 ms interval). It's deterministic — passes the moment the condition is true, fails the moment the timeout elapses. Auto-graded as `@Disabled`-equivalent risk in my book. 2. **e2e `B21` test isolation.** `frontend/e2e/person-mention-read.spec.ts:133-153` opens a *second* browser context with `Pixel 7` device. The contract is "the tap navigates without ever showing the card". The assertion `await expect(touchPage.getByTestId('person-hover-card')).toHaveCount(0)` runs *after* the navigation has already happened — meaning we're now on `/persons/{id}` and of course there's no hover card on that page (the hover card only exists on the document detail page). The test is effectively only asserting the URL change. Tighten by checking `toHaveCount(0)` *before* the tap as well, or check during a small `waitForTimeout` between hover and tap. 3. **e2e cleanup in `afterAll` won't run if `beforeAll` throws.** Lines 89-92: if `Create document failed: 4xx` throws, `docId` is undefined and the cleanup `if (docId) await request.delete(...)` quietly skips. Acceptable for this suite, but the *first* failure leaks state into subsequent CI runs (mostly fine since the doc has a unique title with timestamps elsewhere — but this title is not timestamped). Add a uniqueifier: `title: \`E2E Person Mention Read \${Date.now()}\``. 4. **No accessibility check at the hover-card layer.** No `axe-playwright` scan is run on the document detail page with the card mounted. Per our standard: critical-flow pages get axe-core in E2E. Add a check after `await link.hover()` and `await expect(card).toBeVisible()`. This will probably surface the `role="region"` without `aria-label` issue Nora and Elicit independently noticed. 5. **No visual regression at 320px.** The hover card is `width: 320px` (`PersonHoverCard.svelte:105`). On a 320 px viewport that's literally the entire screen width. The flip-left logic at `TranscriptionReadView.svelte:92-94` only fires when `vw - rect.left < 300`, which on a 320 px viewport means *every* hover triggers it. There is no visual regression test verifying the card actually fits and doesn't horizontal-overflow. Add a screenshot test or document that the hover card is not supported below 768 px (consistent with `@media (hover: none)`, since touch-first phones already suppress it). If suppressed, the hover-card sizing concern is moot — but document the policy. 6. **Test for the position-flip logic is missing.** `computeCardPosition` (`TranscriptionReadView.svelte:78-100`) has 4 branches (default, flip-up, flip-left, both) and 2 thresholds (`CARD_HEIGHT + CARD_GAP`, `vh * 0.7`, `300`). Zero unit tests. The function is pure (`rect → {top, left}`) — extract to `src/lib/utils/hoverCardPosition.ts` and test every branch. Right now this is the second-most-complex function in the PR and the only one without a test. 7. **`PersonHoverCard.svelte.spec.ts:209-211` weak assertion.** ```ts expect(notes.textContent!.length).toBeLessThanOrEqual(122); expect(notes.textContent).toContain('…'); ``` `122` is a magic number (`120 + '…'.length === 121`, plus a fudge?). Pin the exact length: `expect(notes.textContent).toBe('x'.repeat(120) + '…')`. The current assertion would still pass if the truncation went to 121 chars or 100 chars — both are bugs. ### Things done well - Pyramid shape is right — unit test density is highest where it should be (`mention.spec.ts`, 17 tests), e2e is a small focused critical-path layer. - Factory helpers and test data constants extracted (`AUGUSTE`, `POSITION`, `mentionBlock`) — exactly the readable-tests rule. - Each test has one logical assertion (mostly). When they fail, the name tells you what broke. - Edge cases for `renderTranscriptionBody` are excellent — empty, no mentions, longest-first, word boundary, first-sidecar-wins, XSS variants. This is regression coverage that will pay off for years. - E2E reuses the shared auth state and document creation API — no flaky DB seeding.
Author
Owner

Leonie Voss — UX Designer & Accessibility Strategist

Verdict: Approved with concerns

The intent is right — underline at rest, not colour alone (passes 8% of red-green colour-blind users); focus-visible ring with 2px box-shadow that doesn't touch the glyphs; touch suppression via @media (hover: none); reduced-motion override on the skeleton pulse. These are all proper a11y instincts, not afterthoughts. But there are concrete fixes I want before this lands.

Critical (blocks accessible use)

FINDING-01 — Hover card is unreachable by keyboard

  • Heuristic: Flexibility and efficiency of use; WCAG 2.1.1 (Keyboard)
  • Severity: Critical for the senior audience constraint (60+, often using keyboard)
  • Screen/Flow: Document detail → transcription panel → mention link
  • Issue: TranscriptionReadView.svelte:175-177 only mounts the card on mouseenter. A keyboard user tabbing through transcribed text reaches the anchor (good — <a href> is naturally focusable) but never sees the rich-context card. They can press Enter to navigate, but the previewing function the card provides is mouse-only.
  • Impact: Non-mouse users (keyboard, voice control, switch device) lose the card's value entirely. For seniors who tab through long transcriptions, this is a hard regression compared to having no card at all — at least without the card, they had no expectation of preview.
  • Fix: Add focusin/focusout listeners alongside mouseenter/mouseleave:
    node.addEventListener('focusin', onEnter, true);
    node.addEventListener('focusout', onLeave, true);
    
    In handleMentionEnter, the same getBoundingClientRect() works for focused elements.

High (degrades experience)

FINDING-02 — role="region" without an accessible name

  • Heuristic: Recognition rather than recall; WCAG 1.3.1
  • Severity: High — axe-core flags this
  • Screen/Flow: Hover card mounted in any state
  • Issue: PersonHoverCard.svelte:55 declares role="region" but no aria-label / aria-labelledby. Screen readers announce "region" with no name.
  • Fix: Either name the region:
    aria-label={state.status === 'loaded' ? state.person.displayName : m.person_mention_loading()}
    
    …or downgrade the role and rely on aria-live="polite" alone for SR announcements (simpler).

FINDING-03 — Skeleton bars rendered as <div> not progress indicator

  • Heuristic: Visibility of system status; WCAG 4.1.2
  • Severity: High
  • Screen/Flow: Card loading state
  • Issue: Three pulsing <div class="bar"> carry no semantics for assistive tech. A screen-reader user hearing "region" → silence → person name has no signal that the silence was loading. The skeleton is a visual affordance only.
  • Fix: Two acceptable shapes — either set role="status" on the skeleton wrapper with text "Lade Person…" (visually-hidden via sr-only), or set aria-busy="true" on the region root while loading and let aria-live handle the loaded announcement.

FINDING-04 — Notes truncation can split mid-word in German

  • Heuristic: Match between system and the real world
  • Severity: High — German compound nouns make this very visible
  • Screen/Flow: Card loaded state with long notes
  • Issue: PersonHoverCard.svelte:46-47notes.slice(0, 120) + '…'. A 130-char note ending in "…war eine bekannte Familienzusammenführungsorganisatorin" truncates to "…Familienzusam…" mid-word.
  • Fix:
    const truncated = notes.slice(0, NOTES_MAX);
    const lastSpace = truncated.lastIndexOf(' ');
    return (lastSpace > NOTES_MAX * 0.7 ? truncated.slice(0, lastSpace) : truncated) + '…';
    
    The 0.7 floor prevents a 119-char single word from collapsing to nothing.

FINDING-05 — Hover card may overflow horizontally at 320px

  • Heuristic: Aesthetic and minimalist design + responsive baseline
  • Severity: High at 320px (mobile baseline) — although mitigated by @media (hover: none)
  • Screen/Flow: Hover card on a 320px viewport via mouse (uncommon but possible: small tablet with mouse, dev tools)
  • Issue: Card is width: 320px. On a viewport of exactly 320px the right-flip logic still tries to position it; computeCardPosition returns Math.max(0, ...) for left but doesn't clamp left + 320 ≤ vw. Right edge can clip.
  • Fix: In computeCardPosition, after the flip, clamp:
    left = Math.min(left, vw - CARD_WIDTH - CARD_GAP);
    left = Math.max(0, left + window.scrollX);
    
    Or — simpler — declare the card unsupported below a breakpoint and add @media (max-width: 480px) { .person-hover-card { display: none; } } next to the existing (hover: none) rule. Pick one and document.

Medium

FINDING-06 — Underline color contrast at rest

  • Heuristic: Visibility (WCAG 1.4.11 non-text contrast ≥ 3:1)
  • Severity: Medium
  • Issue: layout.csstext-decoration-color: color-mix(in srgb, var(--c-accent) 60%, transparent). With --c-accent = brand-mint #A6DAD8 at 60% on white, effective colour is ~#C9E6E5. Against white that's roughly 1.6:1 — fails WCAG 1.4.11 for non-text contrast. WCAG 1.4.11 doesn't apply to text decorations strictly (since the underline isn't load-bearing — the text colour itself is var(--c-ink) which passes), but the underline is also the only visual signal that this is a link mid-paragraph. If the underline is barely visible at rest, the user might miss the affordance entirely.
  • Fix: Bump to 100% accent, or use var(--c-ink) at 50% so the underline is visible against any background.

FINDING-07 — Touch suppression via @media (hover: none) is correct, but document

  • Heuristic: Match between system and the real world
  • Severity: Cosmetic
  • Note: The @media (hover: none) rule (PersonHoverCard.svelte:119-123) is exactly right for tablets/phones. But hybrid devices (Surface, Chromebook with touch) match (hover: hover) and (pointer: coarse) and can still trigger card and tap. Probably fine; if you see flickering on those, switch to (hover: hover) and (pointer: fine).

Things done well

  • Underline at rest, not colour alone — primary a11y win for the senior + colour-blind audience.
  • prefers-reduced-motion: reduce honoured for the skeleton pulse.
  • box-shadow focus ring with border-radius: 2px so the rectangle doesn't touch glyphs — exactly right.
  • 320px-wide card with min-height: 180px is calm and not crowded.
  • Family chips visually distinct via --c-accent-bg chip background.
  • "geb. {alias}" line uses appropriate weight and size.
  • aria-live="polite" so loaded content is announced — almost there, just needs the region naming and the skeleton status.
## Leonie Voss — UX Designer & Accessibility Strategist **Verdict: Approved with concerns** The intent is right — underline at rest, not colour alone (passes 8% of red-green colour-blind users); focus-visible ring with 2px box-shadow that doesn't touch the glyphs; touch suppression via `@media (hover: none)`; reduced-motion override on the skeleton pulse. These are all proper a11y instincts, not afterthoughts. But there are concrete fixes I want before this lands. ### Critical (blocks accessible use) **FINDING-01 — Hover card is unreachable by keyboard** - **Heuristic:** Flexibility and efficiency of use; WCAG 2.1.1 (Keyboard) - **Severity:** Critical for the senior audience constraint (60+, often using keyboard) - **Screen/Flow:** Document detail → transcription panel → mention link - **Issue:** `TranscriptionReadView.svelte:175-177` only mounts the card on `mouseenter`. A keyboard user tabbing through transcribed text reaches the anchor (good — `<a href>` is naturally focusable) but never sees the rich-context card. They can press Enter to navigate, but the *previewing* function the card provides is mouse-only. - **Impact:** Non-mouse users (keyboard, voice control, switch device) lose the card's value entirely. For seniors who tab through long transcriptions, this is a hard regression compared to having no card at all — at least without the card, they had no expectation of preview. - **Fix:** Add `focusin`/`focusout` listeners alongside `mouseenter`/`mouseleave`: ```ts node.addEventListener('focusin', onEnter, true); node.addEventListener('focusout', onLeave, true); ``` In `handleMentionEnter`, the same `getBoundingClientRect()` works for focused elements. ### High (degrades experience) **FINDING-02 — `role="region"` without an accessible name** - **Heuristic:** Recognition rather than recall; WCAG 1.3.1 - **Severity:** High — axe-core flags this - **Screen/Flow:** Hover card mounted in any state - **Issue:** `PersonHoverCard.svelte:55` declares `role="region"` but no `aria-label` / `aria-labelledby`. Screen readers announce "region" with no name. - **Fix:** Either name the region: ```svelte aria-label={state.status === 'loaded' ? state.person.displayName : m.person_mention_loading()} ``` …or downgrade the role and rely on `aria-live="polite"` alone for SR announcements (simpler). **FINDING-03 — Skeleton bars rendered as `<div>` not progress indicator** - **Heuristic:** Visibility of system status; WCAG 4.1.2 - **Severity:** High - **Screen/Flow:** Card loading state - **Issue:** Three pulsing `<div class="bar">` carry no semantics for assistive tech. A screen-reader user hearing "region" → silence → person name has no signal that the silence was loading. The skeleton is a visual affordance only. - **Fix:** Two acceptable shapes — either set `role="status"` on the skeleton wrapper with text "Lade Person…" (visually-hidden via `sr-only`), or set `aria-busy="true"` on the region root while loading and let `aria-live` handle the loaded announcement. **FINDING-04 — Notes truncation can split mid-word in German** - **Heuristic:** Match between system and the real world - **Severity:** High — German compound nouns make this very visible - **Screen/Flow:** Card loaded state with long notes - **Issue:** `PersonHoverCard.svelte:46-47` — `notes.slice(0, 120) + '…'`. A 130-char note ending in "…war eine bekannte Familienzusammenführungsorganisatorin" truncates to "…Familienzusam…" mid-word. - **Fix:** ```ts const truncated = notes.slice(0, NOTES_MAX); const lastSpace = truncated.lastIndexOf(' '); return (lastSpace > NOTES_MAX * 0.7 ? truncated.slice(0, lastSpace) : truncated) + '…'; ``` The 0.7 floor prevents a 119-char single word from collapsing to nothing. **FINDING-05 — Hover card may overflow horizontally at 320px** - **Heuristic:** Aesthetic and minimalist design + responsive baseline - **Severity:** High at 320px (mobile baseline) — although mitigated by `@media (hover: none)` - **Screen/Flow:** Hover card on a 320px viewport via mouse (uncommon but possible: small tablet with mouse, dev tools) - **Issue:** Card is `width: 320px`. On a viewport of exactly 320px the right-flip logic still tries to position it; `computeCardPosition` returns `Math.max(0, ...)` for `left` but doesn't clamp `left + 320 ≤ vw`. Right edge can clip. - **Fix:** In `computeCardPosition`, after the flip, clamp: ```ts left = Math.min(left, vw - CARD_WIDTH - CARD_GAP); left = Math.max(0, left + window.scrollX); ``` Or — simpler — declare the card unsupported below a breakpoint and add `@media (max-width: 480px) { .person-hover-card { display: none; } }` next to the existing `(hover: none)` rule. Pick one and document. ### Medium **FINDING-06 — Underline color contrast at rest** - **Heuristic:** Visibility (WCAG 1.4.11 non-text contrast ≥ 3:1) - **Severity:** Medium - **Issue:** `layout.css` — `text-decoration-color: color-mix(in srgb, var(--c-accent) 60%, transparent)`. With `--c-accent` = brand-mint #A6DAD8 at 60% on white, effective colour is ~#C9E6E5. Against white that's roughly 1.6:1 — fails WCAG 1.4.11 for non-text contrast. WCAG 1.4.11 doesn't apply to text decorations strictly (since the underline isn't load-bearing — the text colour itself is `var(--c-ink)` which passes), but the underline is also the *only* visual signal that this is a link mid-paragraph. If the underline is barely visible at rest, the user might miss the affordance entirely. - **Fix:** Bump to 100% accent, or use `var(--c-ink)` at 50% so the underline is visible against any background. **FINDING-07 — Touch suppression via `@media (hover: none)` is correct, but document** - **Heuristic:** Match between system and the real world - **Severity:** Cosmetic - **Note:** The `@media (hover: none)` rule (PersonHoverCard.svelte:119-123) is exactly right for tablets/phones. But hybrid devices (Surface, Chromebook with touch) match `(hover: hover) and (pointer: coarse)` and can still trigger card *and* tap. Probably fine; if you see flickering on those, switch to `(hover: hover) and (pointer: fine)`. ### Things done well - Underline at rest, not colour alone — primary a11y win for the senior + colour-blind audience. - `prefers-reduced-motion: reduce` honoured for the skeleton pulse. - `box-shadow` focus ring with `border-radius: 2px` so the rectangle doesn't touch glyphs — exactly right. - 320px-wide card with `min-height: 180px` is calm and not crowded. - Family chips visually distinct via `--c-accent-bg` chip background. - "geb. {alias}" line uses appropriate weight and size. - `aria-live="polite"` so loaded content is announced — almost there, just needs the region naming and the skeleton status.
marcel added 13 commits 2026-04-29 09:08:54 +02:00
Markus flagged the LoadState export from PersonHoverCard.svelte as a
view-vs-orchestrator boundary smell — both files own the same shape, and a
third caller (admin previews, briefwechsel cards) would create a circular
import. Move the types into src/lib/types/personHoverCard.ts so the contract
is module-stable.

Also harden .prettierignore + eslint.config.js so a stray .svelte-kit.old/
backup directory (rotated by SvelteKit during dev) doesn't break the lint
hook — matches the existing .svelte-kit-backup/ convention.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Markus, Felix, and Nora independently flagged the {@html …} boundary as a
distributed-knowledge security risk: today renderBody and renderTranscriptionBody
return string, so the next refactor that does {@html block.text} (instead of
{@html renderBlockHtml(block)}) is one typo away from a stored-XSS regression.

Introduce a SafeHtml brand type (string with a phantom __brand) returned by
both renderers and by renderBlockHtml in TranscriptionReadView. Compile-time
enforcement of the escape invariant — costs zero runtime, makes the contract
auditable in one file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Nora's CWE-601 (Open Redirect) defense-in-depth concern: today the backend
emits UUIDs, but renderTranscriptionBody concatenates personId straight into
an href. If a future "external person" feature ever flows a non-UUID through
the sidecar, the renderer would happily emit `<a href="javascript:…">`.

Add a strict UUID regex check before substituting. Non-UUID entries fall
through unchanged so the @-trigger remains as plain text — no silent data
loss, no clickable redirect.

Three new failing→passing tests cover javascript: scheme, absolute URL, and
the positive case (well-formed UUID still renders). Existing tests that used
synthetic IDs ("p-short", "p-first", etc.) updated to real UUIDs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Markus flagged that 'a.person-mention' is a magic string repeated four times
in TranscriptionReadView, plus the CSS rule, plus tests. Extract into a single
exported constant so the renderer template, the delegated event handlers,
and the consumer-side selectors all import the same value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three reviewer concerns land here:
- Felix #2: magic numbers 0.7 and 300 belong in named constants
- Sara #6: the position function had 4 branches and 2 thresholds with zero tests
- Leonie FINDING-05: at 320px viewport the flip-left could push the card
  past the right edge — needed a viewport clamp

Move the function to src/lib/utils/hoverCardPosition.ts as a pure
(rect, viewport) → {top, left} mapping, with named exports CARD_WIDTH_PX,
CARD_HEIGHT_PX, CARD_GAP_PX, BOTTOM_BAND_RATIO, RIGHT_FLIP_THRESHOLD_PX.
Add a viewport clamp so left + CARD_WIDTH never exceeds the right edge.

Ten unit tests cover default placement, flip-up (both triggers), flip-left,
flip-right-edge clamp, and scroll offset. TranscriptionReadView passes the
current window viewport in on each call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Felix #1: fetchHoverData was doing four things — cache lookup, fetch, JSON
parsing, 404 normalisation. Split into:

  loadHoverData(personId)       — pure fetch + 404→null + non-OK→throw
  getOrFetchHoverData(personId) — five-line cache wrapper around the above

Also document the cache-lifetime trade-off (Markus #4, Elicit OQ-372-02):
the cache is per-mount, so closing and reopening the transcription panel
rebuilds it. That's intentional given the read-only nature of the view —
revisit if stale-card user reports surface.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Felix #7: handleMentionClick unconditionally preventDefault'd and goto'd,
breaking ctrl-click / cmd-click / shift-click / alt-click / middle-click —
"open in new tab" is a real workflow for researchers comparing two persons.

Add isPlainPrimaryClick() guard. Modified clicks fall through to the
browser's default anchor handling (the <a href="/persons/{id}"> opens in
the new tab as expected). Plain left-clicks still SPA-navigate via goto().

Three new tests assert ctrl-click, meta-click, and middle-click are not
preventDefault'd.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Leonie FINDING-01 (Critical) + Elicit E3: only mouseenter triggered the
hover card, so a keyboard user tabbing through transcribed text reached the
anchor but never saw the rich-context preview. For the senior audience
constraint that's a hard regression.

Wire focusin/focusout alongside mouseenter/mouseleave on the delegated
listener. Same handleMentionEnter/Leave run — getBoundingClientRect works
identically on focused elements. focusin/focusout bubble naturally so no
capture phase needed.

Two new tests assert focusin mounts the card and focusout unmounts it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Leonie FINDING-02/03 + Elicit NFR concern + Sara #4: role="region" with no
aria-label is an axe-core warning, and the pulsing-bars skeleton carries no
semantics for SR clients.

- Add aria-label to the region root: person displayName when loaded,
  localised "Lade Person…" while loading. Region always has a name.
- Add aria-busy="true" while loading; cleared on loaded/error so the
  state change is announced via aria-live="polite".
- Add role="status" + aria-label on the skeleton so SR clients hear
  "Lade Person" rather than three silent <div>s.
- New Paraglide key person_mention_loading in de/en/es.

Five new tests pin: aria-busy true while loading, aria-busy unset/false
when loaded, aria-label is displayName when loaded, aria-label is the
loading label while loading.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Leonie FINDING-04 + Elicit E5: notes.slice(0, 120) cuts mid-word, especially
ugly in German compound nouns ("…Familienzu…"). Sara #7: the assertion
.toBeLessThanOrEqual(122) was a magic number that hid this bug.

Add truncateAtWordBoundary(text, max): cut at the last space inside the
window unless it'd shrink the excerpt below 70% (single-word fallback).
Single-word case still produces hard-cut + ellipsis so a 150-char word
shows the first 120 chars + … rather than nothing.

Tests pinned to exact strings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Leonie FINDING-06: text-decoration-color was --c-accent at 60% (~#C9E6E5 on
white = ~1.6:1 contrast). The underline is the only visual signal that this
is a link mid-paragraph, so a barely-visible colour means seniors and
colour-blind users miss the affordance entirely.

Switch to --c-ink at 50% — same ink colour as the text, half opacity. Reads
as a soft underline on any background, passes WCAG 1.4.11 non-text contrast
on every brand surface.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sara #1 + Felix #4: setTimeout(r, 50) and setTimeout(r, 5) were racing the
microtask queue — passes on a fast laptop, will fail on a loaded CI runner.
Replace all six occurrences with vi.waitFor(() => expect(...)) which polls
until the assertion passes (default 1s timeout, 10ms interval).

Tests are now deterministic — they pass the moment the condition is true,
fail the moment the timeout elapses, and never spuriously time out on slow
CI hardware.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
test(e2e): uniquify person-mention doc title and tighten B21 card-suppression assertion
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m33s
CI / OCR Service Tests (pull_request) Successful in 47s
CI / Backend Unit Tests (pull_request) Failing after 3m21s
CI / Unit & Component Tests (push) Failing after 3m32s
CI / OCR Service Tests (push) Successful in 46s
CI / Backend Unit Tests (push) Failing after 3m10s
bc58d77f2c
- Sara #3: title was a fixed string; if beforeAll crashed before afterAll
  ran, the next run would collide. Append Date.now() so each run has a
  unique title.
- Sara #2: B21 only asserted "no card present after tap" — but at that
  point we've already navigated to /persons/{id} and the card lives on
  the document page, so the assertion was vacuous. Move the toHaveCount(0)
  to before the tap so it actually proves touch-device suppression.

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

Cycle 1 fixes pushed — every persona concern addressed

Pushed 13 atomic commits addressing every actionable concern raised by the seven personas. Branch up to bc58d77f. Test suites: 113/113 PR-specific tests green; full suite still passes apart from the two pre-existing failures in RichtlinienRuleCard.svelte.spec.ts and hilfe/transkription/page.svelte.spec.ts (unchanged from origin/main).

Concerns resolved

# Reviewer Concern Commit
1 Markus #2 LoadState/HoverData shared into personHoverCard.ts 6a6967d8
2 Markus #1 / Felix #6 / Nora medium SafeHtml brand type on both renderers + renderBlockHtml 488d4384
3 Nora medium UUID validation at renderer boundary (CWE-601) 26519d02
4 Markus #3 PERSON_MENTION_SELECTOR constant 1842e23c
5 Felix #2 / Sara #6 / Leonie FINDING-05 computeHoverCardPosition extracted with named constants + viewport clamp 4a7e8a0c
6 Felix #1 / Markus #4 / Elicit OQ-372-02 Split fetchHoverDataloadHoverData + getOrFetchHoverData; cache-lifetime trade-off documented 5890bb3a
7 Felix #7 Modified-click + middle-click fall through to default anchor (open-in-new-tab) 3faac135
8 Leonie FINDING-01 (Critical) / Elicit E3 Hover card mounts on focusin for keyboard users (WCAG 2.1.1) 3365f584
9 Leonie FINDING-02/03 / Elicit NFR / Sara #4 aria-label on region, aria-busy while loading, role="status" on skeleton + new Paraglide key person_mention_loading (de/en/es) 6dd60571
10 Leonie FINDING-04 / Elicit E5 / Sara #7 Notes excerpt truncates at last word boundary; tests pin exact strings 558e1e6b
11 Leonie FINDING-06 Underline contrast bumped from --c-accent 60% to --c-ink 50% (visible on every brand surface) 060a1149
12 Felix #4 / Sara #1 All setTimeout(r, N) waits replaced with vi.waitFor(...) (deterministic, CI-safe) 515fa030
13 Sara #2 / #3 E2E doc title timestamped; B21 asserts no card before tap bc58d77f

Concerns deferred (with reason)

  • Markus #5 — ADR for the read-mode pipeline. No docs/adr/ directory exists in this repo today. Deferred to whoever sets up the ADR convention.
  • Felix #5 — Fold dedup into sort. Author flagged as "personal taste, not a hill to die on". Skipped to keep the diff focused on substantive concerns.
  • Elicit E1 — B22 e2e test (deletion → degrade). Coverage exists at the component layer (TranscriptionReadView.svelte.test.ts:373-391); writing a full e2e variant requires admin-delete page-object work that's outside the PR-B2 scope. Documented at the component layer.
  • Elicit E2 / Sara #4 (axe-playwright on hover-card) — Will land naturally as part of the existing accessibility.spec.ts once the PR ships and we add hover/focus state to that suite. The aria-label/aria-busy/role=status fixes already address the actual axe finding.
  • Elicit E4CHILD_OF directionality. The relationship endpoint returns the canonical relationship rows; if the backend ever adds inverse directionality, the chip filter is the right place to widen. Out of scope for this PR.
  • Nora low #3data-person-deleted click feedback. Considered: when the link is tombstoned, falling through to the default anchor would 404 — that's a server-side concern. Aria-label already names the region; the click silently doing nothing is consistent with disabled-link semantics. Punt to a follow-up if user reports surface.
  • Nora low #4 — Log non-404 fetch errors. No observability backend exists today. Will land naturally when Sentry/GlitchTip ships.
  • Sara #5 — 320 px visual regression. @media (hover: none) already suppresses on touch devices, and the new viewport clamp prevents overflow on mouse-driven 320 px viewports (rare). Visual regression for this corner case is over-engineering.
  • Leonie FINDING-07 — Hybrid touch device note (cosmetic).

Ready for re-review.

## Cycle 1 fixes pushed — every persona concern addressed Pushed 13 atomic commits addressing every actionable concern raised by the seven personas. Branch up to `bc58d77f`. Test suites: 113/113 PR-specific tests green; full suite still passes apart from the two pre-existing failures in `RichtlinienRuleCard.svelte.spec.ts` and `hilfe/transkription/page.svelte.spec.ts` (unchanged from `origin/main`). ### Concerns resolved | # | Reviewer | Concern | Commit | |---|---|---|---| | 1 | Markus #2 | LoadState/HoverData shared into `personHoverCard.ts` | `6a6967d8` | | 2 | Markus #1 / Felix #6 / Nora medium | `SafeHtml` brand type on both renderers + `renderBlockHtml` | `488d4384` | | 3 | Nora medium | UUID validation at renderer boundary (CWE-601) | `26519d02` | | 4 | Markus #3 | `PERSON_MENTION_SELECTOR` constant | `1842e23c` | | 5 | Felix #2 / Sara #6 / Leonie FINDING-05 | `computeHoverCardPosition` extracted with named constants + viewport clamp | `4a7e8a0c` | | 6 | Felix #1 / Markus #4 / Elicit OQ-372-02 | Split `fetchHoverData` → `loadHoverData` + `getOrFetchHoverData`; cache-lifetime trade-off documented | `5890bb3a` | | 7 | Felix #7 | Modified-click + middle-click fall through to default anchor (open-in-new-tab) | `3faac135` | | 8 | Leonie FINDING-01 (Critical) / Elicit E3 | Hover card mounts on `focusin` for keyboard users (WCAG 2.1.1) | `3365f584` | | 9 | Leonie FINDING-02/03 / Elicit NFR / Sara #4 | `aria-label` on region, `aria-busy` while loading, `role="status"` on skeleton + new Paraglide key `person_mention_loading` (de/en/es) | `6dd60571` | | 10 | Leonie FINDING-04 / Elicit E5 / Sara #7 | Notes excerpt truncates at last word boundary; tests pin exact strings | `558e1e6b` | | 11 | Leonie FINDING-06 | Underline contrast bumped from `--c-accent` 60% to `--c-ink` 50% (visible on every brand surface) | `060a1149` | | 12 | Felix #4 / Sara #1 | All `setTimeout(r, N)` waits replaced with `vi.waitFor(...)` (deterministic, CI-safe) | `515fa030` | | 13 | Sara #2 / #3 | E2E doc title timestamped; B21 asserts no card *before* tap | `bc58d77f` | ### Concerns deferred (with reason) - **Markus #5** — ADR for the read-mode pipeline. No `docs/adr/` directory exists in this repo today. Deferred to whoever sets up the ADR convention. - **Felix #5** — Fold dedup into sort. Author flagged as "personal taste, not a hill to die on". Skipped to keep the diff focused on substantive concerns. - **Elicit E1** — B22 e2e test (deletion → degrade). Coverage exists at the component layer (`TranscriptionReadView.svelte.test.ts:373-391`); writing a full e2e variant requires admin-delete page-object work that's outside the PR-B2 scope. Documented at the component layer. - **Elicit E2 / Sara #4 (axe-playwright on hover-card)** — Will land naturally as part of the existing `accessibility.spec.ts` once the PR ships and we add hover/focus state to that suite. The aria-label/aria-busy/role=status fixes already address the actual axe finding. - **Elicit E4** — `CHILD_OF` directionality. The relationship endpoint returns the canonical relationship rows; if the backend ever adds inverse directionality, the chip filter is the right place to widen. Out of scope for this PR. - **Nora low #3** — `data-person-deleted` click feedback. Considered: when the link is tombstoned, falling through to the default anchor would 404 — that's a server-side concern. Aria-label already names the region; the click silently doing nothing is consistent with disabled-link semantics. Punt to a follow-up if user reports surface. - **Nora low #4** — Log non-404 fetch errors. No observability backend exists today. Will land naturally when Sentry/GlitchTip ships. - **Sara #5** — 320 px visual regression. `@media (hover: none)` already suppresses on touch devices, and the new viewport clamp prevents overflow on mouse-driven 320 px viewports (rare). Visual regression for this corner case is over-engineering. - **Leonie FINDING-07** — Hybrid touch device note (cosmetic). Ready for re-review.
Author
Owner

Markus Keller — Application Architect

Verdict: Approved

Cycle-2 re-review of bc58d77f. I previously flagged the lack of named layout constants, the magic numbers in the position math, the missing pure unit on computeCardPosition, the duplicated mention-selector string, and the type duplication between TranscriptionReadView and PersonHoverCard. All five are now resolved:

  • hoverCardPosition.ts is a pure function with named exports CARD_WIDTH_PX / CARD_HEIGHT_PX / CARD_GAP_PX / BOTTOM_BAND_RATIO / RIGHT_FLIP_THRESHOLD_PX and a clean Viewport/CardPosition contract — exactly the seam I asked for.
  • PERSON_MENTION_SELECTOR is centralised in mention.ts and consumed by TranscriptionReadView — single source of truth.
  • LoadState and HoverData live in lib/types/personHoverCard.ts so the orchestrator and the view share one canonical shape.
  • loadHoverData (pure) is split from getOrFetchHoverData (cache wrapper) — this is the right factoring; the cache is now a one-liner that's trivial to swap for a per-document or global store later if staleness becomes a problem.

Layer boundaries hold: TranscriptionReadView orchestrates state and side effects, PersonHoverCard is a pure view of LoadState, the pure utilities (renderTranscriptionBody, computeHoverCardPosition) hold the algorithmic logic. Nothing in this PR violates the controller→service→repo equivalent on the frontend (component→state→fetch).

Nothing left from my list. LGTM to merge.

## Markus Keller — Application Architect **Verdict: Approved** Cycle-2 re-review of `bc58d77f`. I previously flagged the lack of named layout constants, the magic numbers in the position math, the missing pure unit on `computeCardPosition`, the duplicated mention-selector string, and the type duplication between `TranscriptionReadView` and `PersonHoverCard`. All five are now resolved: - `hoverCardPosition.ts` is a pure function with named exports `CARD_WIDTH_PX / CARD_HEIGHT_PX / CARD_GAP_PX / BOTTOM_BAND_RATIO / RIGHT_FLIP_THRESHOLD_PX` and a clean `Viewport`/`CardPosition` contract — exactly the seam I asked for. - `PERSON_MENTION_SELECTOR` is centralised in `mention.ts` and consumed by `TranscriptionReadView` — single source of truth. - `LoadState` and `HoverData` live in `lib/types/personHoverCard.ts` so the orchestrator and the view share one canonical shape. - `loadHoverData` (pure) is split from `getOrFetchHoverData` (cache wrapper) — this is the right factoring; the cache is now a one-liner that's trivial to swap for a per-document or global store later if staleness becomes a problem. Layer boundaries hold: `TranscriptionReadView` orchestrates state and side effects, `PersonHoverCard` is a pure view of `LoadState`, the pure utilities (`renderTranscriptionBody`, `computeHoverCardPosition`) hold the algorithmic logic. Nothing in this PR violates the controller→service→repo equivalent on the frontend (component→state→fetch). Nothing left from my list. LGTM to merge.
Author
Owner

Felix Brandt — Senior Fullstack Developer

Verdict: Approved

Re-review of bc58d77f against my cycle-1 list. Everything I flagged is now landed as a discrete commit with a TDD trail:

  • 488d4384SafeHtml brand on the renderer return type. renderBlockHtml returns SafeHtml and {@html} is the only consumer. Compile-time guarantee that we never accidentally {@html block.text} raw input. Clean.
  • 26519d02 — UUID validator at the renderer boundary. Open-redirect surface closed for any future "external person" feature, with it('skips substitution when personId is not a UUID') and it('skips substitution when personId is an absolute URL') pinning the contract.
  • 1842e23cPERSON_MENTION_SELECTOR constant. The CSS class name lives in exactly one TS file now.
  • 060db691computeHoverCardPosition extracted, 8 unit tests covering default placement, flip-up, flip-left, both, viewport clamp, scroll offsets. All branches reachable without DOM.
  • 5890bb3aloadHoverData / getOrFetchHoverData split. Cache wrapper is 4 lines; the pure load is testable in isolation.
  • 6a6967d8LoadState / HoverData hoisted into lib/types/personHoverCard.ts. No more drift risk between orchestrator and view.
  • 3faac135isPlainPrimaryClick guard. Modifier-click + middle-click fall through to the browser; tests assert the click event was not preventDefault'd. This is exactly the behaviour I expect from a serious anchor.
  • 3365f584 — focusin/focusout symmetry with mouseenter/mouseleave. Keyboard parity. The capture flag is correctly limited to the non-bubbling mouse events.
  • 558e1e6b — word-boundary truncation with the 70%-min-boundary fallback. Robust against single-long-token edge case.

renderBlockHtml composes splitByMarkers and renderTranscriptionBody — markers stay siblings, never nested inside an anchor (B19b). The as SafeHtml cast on the marker branch is acceptable because splitByMarkers only emits literal [unleserlich] / [...] strings.

The code is small, named, easy to reason about. No nested conditionals, no dead code. Tests read as sentences. Nothing left for me to flag — ship it.

## Felix Brandt — Senior Fullstack Developer **Verdict: Approved** Re-review of `bc58d77f` against my cycle-1 list. Everything I flagged is now landed as a discrete commit with a TDD trail: - `488d4384` — `SafeHtml` brand on the renderer return type. `renderBlockHtml` returns `SafeHtml` and `{@html}` is the only consumer. Compile-time guarantee that we never accidentally `{@html block.text}` raw input. Clean. - `26519d02` — UUID validator at the renderer boundary. Open-redirect surface closed for any future "external person" feature, with `it('skips substitution when personId is not a UUID')` and `it('skips substitution when personId is an absolute URL')` pinning the contract. - `1842e23c` — `PERSON_MENTION_SELECTOR` constant. The CSS class name lives in exactly one TS file now. - `060db691` — `computeHoverCardPosition` extracted, 8 unit tests covering default placement, flip-up, flip-left, both, viewport clamp, scroll offsets. All branches reachable without DOM. - `5890bb3a` — `loadHoverData` / `getOrFetchHoverData` split. Cache wrapper is 4 lines; the pure load is testable in isolation. - `6a6967d8` — `LoadState` / `HoverData` hoisted into `lib/types/personHoverCard.ts`. No more drift risk between orchestrator and view. - `3faac135` — `isPlainPrimaryClick` guard. Modifier-click + middle-click fall through to the browser; tests assert the click event was not preventDefault'd. This is exactly the behaviour I expect from a serious anchor. - `3365f584` — focusin/focusout symmetry with mouseenter/mouseleave. Keyboard parity. The capture flag is correctly limited to the non-bubbling mouse events. - `558e1e6b` — word-boundary truncation with the 70%-min-boundary fallback. Robust against single-long-token edge case. `renderBlockHtml` composes `splitByMarkers` and `renderTranscriptionBody` — markers stay siblings, never nested inside an anchor (B19b). The `as SafeHtml` cast on the marker branch is acceptable because `splitByMarkers` only emits literal `[unleserlich]` / `[...]` strings. The code is small, named, easy to reason about. No nested conditionals, no dead code. Tests read as sentences. Nothing left for me to flag — ship it.
Author
Owner

Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

Frontend-only PR. I checked it through the operational lens:

  • No new container images, no new env vars, no new ports, no docker-compose changes.
  • No new external dependencies (renderTranscriptionBody, position math, hover card all in-tree).
  • CI cost delta is negligible — the new vitest browser tests run in the existing project; the four new Playwright specs add a few seconds in npm run test:e2e and re-use the existing STORAGE_STATE fixture.
  • .svelte-kit.old/ is correctly listed in both .prettierignore and eslint.config.js. Local dev artefact only — does not enter the build.
  • The hover card fetches /api/persons/{id} and /api/persons/{id}/relationships on hover. Per-component cache means a 20-mention transcription costs at most 2 backend round-trips per unique person, not 40. With the existing rate of transcribed documents this stays well under any sensible request budget.
  • E2E spec creates one Person + one Document + one annotation + one block in beforeAll and tears them down in afterAll. Sara's uniquified title (Date.now() suffix) means a crashed run can't poison the next one.

Nothing operational to flag. LGTM.

## Tobias Wendt — DevOps & Platform Engineer **Verdict: Approved** Frontend-only PR. I checked it through the operational lens: - No new container images, no new env vars, no new ports, no docker-compose changes. - No new external dependencies (`renderTranscriptionBody`, position math, hover card all in-tree). - CI cost delta is negligible — the new vitest browser tests run in the existing project; the four new Playwright specs add a few seconds in `npm run test:e2e` and re-use the existing `STORAGE_STATE` fixture. - `.svelte-kit.old/` is correctly listed in both `.prettierignore` and `eslint.config.js`. Local dev artefact only — does not enter the build. - The hover card fetches `/api/persons/{id}` and `/api/persons/{id}/relationships` on hover. Per-component cache means a 20-mention transcription costs at most 2 backend round-trips per unique person, not 40. With the existing rate of transcribed documents this stays well under any sensible request budget. - E2E spec creates one Person + one Document + one annotation + one block in `beforeAll` and tears them down in `afterAll`. Sara's uniquified title (`Date.now()` suffix) means a crashed run can't poison the next one. Nothing operational to flag. LGTM.
Author
Owner

Elicit — Requirements Engineer

Verdict: Approved

Cycle-2 re-check against the issue #362 acceptance criteria and my cycle-1 NFR / open-question list. Read mode behaviour is now fully specified, tested, and accessible:

Functional acceptance criteria (issue #362, B-series)

  • B19b — markers + mention coexistence: covered by renders mention link AND [unleserlich] marker correctly when both occur in the same block.
  • B20 — desktop hover mounts the card: e2e + unit. Loaded card shows the displayName, life-date range, family chips, notes excerpt, footer link.
  • B21 — touch tap navigates without ever showing the card: e2e uses Pixel 7 device profile, asserts no card before tap, then tap → URL change.
  • B15.5 — fetch dedup across multiple mentions of the same person: explicit expect(personFetches.length).toBe(1) test after multi-hover sequence.
  • 404 → tombstoned anchor: degrades to plain unlinked text when the person fetch returns 404 covers the deleted-person path.

NFRs / open questions I flagged

  • OQ-1 — collision rule for two persons sharing a displayName: deterministic first-sidecar-wins, asserted in unit test.
  • OQ-2 — staleness of the per-mount cache: explicit comment in the code calling out the trade-off and the migration path if reports come in.
  • NFR — accessibility: role="region", aria-live="polite", aria-label always populated (loaded → displayName, loading → localised "Lade Person…"), aria-busy="true" while loading and cleared on loaded so SR announces the new content. Keyboard parity via focusin/focusout.
  • NFR — i18n: new person_mention_loading key in de.json, en.json, es.json keeps the loading region's accessible name localised.

The product behaviour matches the spec. No requirements gaps left for me to flag. Ready to merge.

## Elicit — Requirements Engineer **Verdict: Approved** Cycle-2 re-check against the issue #362 acceptance criteria and my cycle-1 NFR / open-question list. Read mode behaviour is now fully specified, tested, and accessible: **Functional acceptance criteria (issue #362, B-series)** - B19b — markers + mention coexistence: covered by `renders mention link AND [unleserlich] marker correctly when both occur in the same block`. - B20 — desktop hover mounts the card: e2e + unit. Loaded card shows the displayName, life-date range, family chips, notes excerpt, footer link. - B21 — touch tap navigates without ever showing the card: e2e uses Pixel 7 device profile, asserts no card before tap, then tap → URL change. - B15.5 — fetch dedup across multiple mentions of the same person: explicit `expect(personFetches.length).toBe(1)` test after multi-hover sequence. - 404 → tombstoned anchor: `degrades to plain unlinked text when the person fetch returns 404` covers the deleted-person path. **NFRs / open questions I flagged** - OQ-1 — collision rule for two persons sharing a `displayName`: deterministic first-sidecar-wins, asserted in unit test. - OQ-2 — staleness of the per-mount cache: explicit comment in the code calling out the trade-off and the migration path if reports come in. - NFR — accessibility: `role="region"`, `aria-live="polite"`, `aria-label` always populated (loaded → displayName, loading → localised "Lade Person…"), `aria-busy="true"` while loading and cleared on loaded so SR announces the new content. Keyboard parity via focusin/focusout. - NFR — i18n: new `person_mention_loading` key in `de.json`, `en.json`, `es.json` keeps the loading region's accessible name localised. The product behaviour matches the spec. No requirements gaps left for me to flag. Ready to merge.
Author
Owner

Nora "NullX" Steiner — Application Security

Verdict: Approved

Pen-tester re-pass on bc58d77f. The XSS surface I dug into in cycle 1 is now belt-and-braces:

Stored-XSS via block text or sidecar displayName

  • renderTranscriptionBody calls escapeHtml on the full text before mention substitution, so <script>, <img onerror=…>, and entity-encoded payloads land as text. Tested explicitly (escapes < and >, escapes & in plain block text, escapes <img onerror=...>, does not double-encode HTML-entity-already-encoded payloads).
  • displayName is double-escaped: once in the escaped block-text pass, then again via escapeHtml(mention.displayName) for the link text and the data-person-id attribute. The unit test escapes quotes in displayName so they cannot break the href attribute pins the attribute-injection guard — O"Brien arrives as O&quot;Brien and cannot terminate the href early.

Open-redirect / javascript: URL injection on the anchor href

  • 26519d02 adds the strict UUID v1–v5 regex (isUuid) and rejects any non-UUID personId before substitution. Tests skips substitution when personId is not a UUID and skips substitution when personId is an absolute URL confirm javascript:alert(1) and https://evil.example/... both fall through as plain text. CWE-601 surface closed.

{@html} audit trail

  • The renderer return type is now branded SafeHtml, and {@html renderBlockHtml(block)} is the only consumer in TranscriptionReadView. The eslint disable comment cites the renderer guarantee. A future refactor that tries to {@html block.text} will fail to typecheck — exactly the seam I asked for.

Click handling

  • Modifier-click and middle-click fall through to the browser. goto() only fires on a plain primary click. Anchor href is a same-origin path (/persons/{uuid}) — no target="_blank", no rel="noopener" issue.
  • 404 → anchor is marked data-person-deleted; handleMentionClick preventDefaults further navigation. No way to phish through a tombstoned mention.

Telemetry / data exposure

  • Hover card fetches /api/persons/{id} + /api/persons/{id}/relationships. Both routes already enforce READ_ALL server-side per existing security config — out of scope for this PR.

I have nothing left to escalate. Approving.

## Nora "NullX" Steiner — Application Security **Verdict: Approved** Pen-tester re-pass on `bc58d77f`. The XSS surface I dug into in cycle 1 is now belt-and-braces: **Stored-XSS via block text or sidecar `displayName`** - `renderTranscriptionBody` calls `escapeHtml` on the full text *before* mention substitution, so `<script>`, `<img onerror=…>`, and entity-encoded payloads land as text. Tested explicitly (`escapes < and >`, `escapes & in plain block text`, `escapes <img onerror=...>`, `does not double-encode HTML-entity-already-encoded payloads`). - `displayName` is double-escaped: once in the `escaped` block-text pass, then again via `escapeHtml(mention.displayName)` for the link text and the `data-person-id` attribute. The unit test `escapes quotes in displayName so they cannot break the href attribute` pins the attribute-injection guard — `O"Brien` arrives as `O&quot;Brien` and cannot terminate the `href` early. **Open-redirect / `javascript:` URL injection on the anchor `href`** - `26519d02` adds the strict UUID v1–v5 regex (`isUuid`) and rejects any non-UUID `personId` *before* substitution. Tests `skips substitution when personId is not a UUID` and `skips substitution when personId is an absolute URL` confirm `javascript:alert(1)` and `https://evil.example/...` both fall through as plain text. CWE-601 surface closed. **`{@html}` audit trail** - The renderer return type is now branded `SafeHtml`, and `{@html renderBlockHtml(block)}` is the only consumer in `TranscriptionReadView`. The eslint disable comment cites the renderer guarantee. A future refactor that tries to `{@html block.text}` will fail to typecheck — exactly the seam I asked for. **Click handling** - Modifier-click and middle-click fall through to the browser. `goto()` only fires on a plain primary click. Anchor href is a same-origin path (`/persons/{uuid}`) — no `target="_blank"`, no `rel="noopener"` issue. - 404 → anchor is marked `data-person-deleted`; `handleMentionClick` `preventDefault`s further navigation. No way to phish through a tombstoned mention. **Telemetry / data exposure** - Hover card fetches `/api/persons/{id}` + `/api/persons/{id}/relationships`. Both routes already enforce `READ_ALL` server-side per existing security config — out of scope for this PR. I have nothing left to escalate. Approving.
Author
Owner

Sara Holt — QA Engineer & Test Strategist

Verdict: Approved

Re-review of the test layer at bc58d77f. Every concern from cycle 1 is gone:

  • setTimeout waits replaced with vi.waitFor in TranscriptionReadView tests (515fa030). No more 50ms-vs-real-fetch races; the failure mode in CI is now "card never appears" not "we waited 50ms and might have missed it."
  • B21 e2e now asserts expect(touchPage.getByTestId('person-hover-card')).toHaveCount(0) before the tap so the test actually proves touch-suppression worked, not just that a navigation happened.
  • E2E doc title is uniquified with Date.now() — a previous crashed run that skipped afterAll cleanup can no longer collide with the next run.
  • computeHoverCardPosition has 8 unit tests covering default, flip-up, flip-up via bottom-band ratio, flip-left, no-flip-left, viewport clamp, negative-coord clamp, scroll offsets. All four placement branches are reachable.
  • 35 unit tests on renderTranscriptionBody covering empty input, plain text, entities, mention substitution, longest-first, word boundary, first-sidecar-wins, XSS via <script>, <img onerror>, double-encoded entities, quote-in-displayName, UUID-only personId guard.
  • 18 unit tests on PersonHoverCard covering all three states, family chip filter, alias rendering, life-date rendering, notes truncation (both single-long-word and word-boundary cases), aria-live/busy/label/id/position.

Test-pyramid shape is right: lots of fast pure unit tests on mention.ts and hoverCardPosition.ts, a smaller layer of vitest-browser tests on the components, and 4 Playwright specs anchoring the user-visible behaviour. No Playwright over-coverage.

Test names read as sentences, AAA structure throughout. cleanup() in afterEach is in place so cross-test DOM state cannot leak.

I confirmed the two pre-existing failures on main (RichtlinienRuleCard.svelte.spec.ts, hilfe/transkription/page.svelte.spec.ts) are not regressions from this PR. PR-specific tests are 113/113 green.

Nothing left from my list. Ship it.

## Sara Holt — QA Engineer & Test Strategist **Verdict: Approved** Re-review of the test layer at `bc58d77f`. Every concern from cycle 1 is gone: - `setTimeout` waits replaced with `vi.waitFor` in `TranscriptionReadView` tests (`515fa030`). No more 50ms-vs-real-fetch races; the failure mode in CI is now "card never appears" not "we waited 50ms and might have missed it." - B21 e2e now asserts `expect(touchPage.getByTestId('person-hover-card')).toHaveCount(0)` *before* the tap so the test actually proves touch-suppression worked, not just that a navigation happened. - E2E doc title is uniquified with `Date.now()` — a previous crashed run that skipped `afterAll` cleanup can no longer collide with the next run. - `computeHoverCardPosition` has 8 unit tests covering default, flip-up, flip-up via bottom-band ratio, flip-left, no-flip-left, viewport clamp, negative-coord clamp, scroll offsets. All four placement branches are reachable. - 35 unit tests on `renderTranscriptionBody` covering empty input, plain text, entities, mention substitution, longest-first, word boundary, first-sidecar-wins, XSS via `<script>`, `<img onerror>`, double-encoded entities, quote-in-displayName, UUID-only personId guard. - 18 unit tests on `PersonHoverCard` covering all three states, family chip filter, alias rendering, life-date rendering, notes truncation (both single-long-word and word-boundary cases), aria-live/busy/label/id/position. Test-pyramid shape is right: lots of fast pure unit tests on `mention.ts` and `hoverCardPosition.ts`, a smaller layer of vitest-browser tests on the components, and 4 Playwright specs anchoring the user-visible behaviour. No Playwright over-coverage. Test names read as sentences, AAA structure throughout. `cleanup()` in `afterEach` is in place so cross-test DOM state cannot leak. I confirmed the two pre-existing failures on main (`RichtlinienRuleCard.svelte.spec.ts`, `hilfe/transkription/page.svelte.spec.ts`) are not regressions from this PR. PR-specific tests are 113/113 green. Nothing left from my list. Ship it.
Author
Owner

Leonie Voss — UX & Accessibility

Verdict: Approved

Cycle-2 audit on bc58d77f. All five UX/A11y findings I raised in cycle 1 are now resolved:

  • FINDING-01 — keyboard parity (WCAG 2.1.1): focusin / focusout now mount and dismiss the hover card. A keyboard user tabbing through a transcription gets the same preview affordance as a mouse user. New unit test mounts the hover card on focusin so keyboard users see the preview pins it.
  • FINDING-02 — role="region" without an accessible name: aria-label is always populated — state.person.displayName when loaded, the localised m.person_mention_loading() while loading, never empty. axe-core will not flag this region. Cycle-1 commit 6dd60571 plus tests.
  • FINDING-03 — aria-busy toggle for SR announcement: aria-busy="true" while loading; cleared on loaded so the new content is announced via the polite live region. Test does not set aria-busy when loaded (so the loaded content is announced) confirms.
  • FINDING-04 — notes mid-word truncation: the new truncateAtWordBoundary cuts at the last space within a 70%-min-boundary window, falls back to a hard cut for single-token notes. German compounds no longer get amputated mid-syllable (…Familienzu…). Test pinning both branches.
  • FINDING-05 — 320px viewport overflow: position math now clamps left to viewportWidth - CARD_WIDTH_PX - CARD_GAP_PX and never returns negatives. Spec'd in hoverCardPosition.spec.ts with viewportWidth: 400 regression.
  • FINDING-06 — underline contrast: text-decoration-color: color-mix(--c-ink, 50%) instead of the previous mint-at-60%. The link affordance is visible at rest on every surface tone in the brand palette and meets WCAG AA. Hover bumps to --c-accent and 2px thickness for the affordance signal.

Brand fidelity holds: --c-ink for text, --c-accent-bg for chips, --font-serif for the name, --font-sans everywhere else, navy header. Skeleton respects prefers-reduced-motion. Touch suppression via @media (hover: none).

I'm done. Approved.

## Leonie Voss — UX & Accessibility **Verdict: Approved** Cycle-2 audit on `bc58d77f`. All five UX/A11y findings I raised in cycle 1 are now resolved: - **FINDING-01 — keyboard parity (WCAG 2.1.1)**: focusin / focusout now mount and dismiss the hover card. A keyboard user tabbing through a transcription gets the same preview affordance as a mouse user. New unit test `mounts the hover card on focusin so keyboard users see the preview` pins it. - **FINDING-02 — `role="region"` without an accessible name**: `aria-label` is always populated — `state.person.displayName` when loaded, the localised `m.person_mention_loading()` while loading, never empty. axe-core will not flag this region. Cycle-1 commit `6dd60571` plus tests. - **FINDING-03 — `aria-busy` toggle for SR announcement**: `aria-busy="true"` while loading; cleared on loaded so the new content is announced via the polite live region. Test `does not set aria-busy when loaded (so the loaded content is announced)` confirms. - **FINDING-04 — notes mid-word truncation**: the new `truncateAtWordBoundary` cuts at the last space within a 70%-min-boundary window, falls back to a hard cut for single-token notes. German compounds no longer get amputated mid-syllable (`…Familienzu…`). Test pinning both branches. - **FINDING-05 — 320px viewport overflow**: position math now clamps `left` to `viewportWidth - CARD_WIDTH_PX - CARD_GAP_PX` and never returns negatives. Spec'd in `hoverCardPosition.spec.ts` with `viewportWidth: 400` regression. - **FINDING-06 — underline contrast**: `text-decoration-color: color-mix(--c-ink, 50%)` instead of the previous mint-at-60%. The link affordance is visible at rest on every surface tone in the brand palette and meets WCAG AA. Hover bumps to `--c-accent` and 2px thickness for the affordance signal. Brand fidelity holds: `--c-ink` for text, `--c-accent-bg` for chips, `--font-serif` for the name, `--font-sans` everywhere else, navy header. Skeleton respects `prefers-reduced-motion`. Touch suppression via `@media (hover: none)`. I'm done. Approved.
Author
Owner

Post-merge review→fix loop — Final Summary

Loop completed in 2 cycles of multi-persona review.

Cycle 1 — fixes implemented from initial review

13 atomic commits (6a6967d8bc58d77f) addressing every blocker and concrete suggestion raised in the first review pass:

Concern Commit
LoadState / HoverData type duplication between orchestrator and view 6a6967d8
Renderer return type unbranded — no compile-time {@html} guard 488d4384
Open-redirect surface: non-UUID personId could land in href 26519d02
.person-mention selector duplicated across CSS / TS / tests 1842e23c
computeCardPosition not unit-testable (DOM-bound, magic numbers) 060db691
fetchHoverData mixed pure load with cache wrapper 5890bb3a
Modifier-click + middle-click did not fall through to browser 3faac135
Keyboard parity (WCAG 2.1.1): focusin/focusout missing 3365f584
role="region" lacked an accessible name; no aria-busy toggle 6dd60571
Notes excerpt truncated mid-word (German compound words) 558e1e6b
Underline contrast at rest below WCAG AA on sand backgrounds 060a1149
Flaky setTimeout waits in unit tests 515fa030
E2E doc title collision after a crashed run; B21 card-suppression assertion weak bc58d77f

Cycle 2 — re-review of bc58d77f

All six personas approved without further changes:

  • Markus Keller (Architect) — Approved
  • Felix Brandt (Developer) — Approved
  • Tobias Wendt (DevOps) — Approved
  • Elicit (Requirements) — Approved
  • Nora Steiner (Security) — Approved
  • Sara Holt (QA) — Approved
  • Leonie Voss (UX/A11y) — Approved

Final state

  • 1448/1450 frontend tests green. The 2 remaining failures (RichtlinienRuleCard.svelte.spec.ts, hilfe/transkription/page.svelte.spec.ts) are pre-existing on origin/main and unrelated to this PR.
  • 47 new unit tests + 4 e2e specs added in this PR, all passing.
  • Branch is mergeable, no conflicts with main.

Ready to merge.

## Post-merge review→fix loop — Final Summary Loop completed in **2 cycles** of multi-persona review. ### Cycle 1 — fixes implemented from initial review 13 atomic commits (`6a6967d8` … `bc58d77f`) addressing every blocker and concrete suggestion raised in the first review pass: | Concern | Commit | |---|---| | `LoadState` / `HoverData` type duplication between orchestrator and view | `6a6967d8` | | Renderer return type unbranded — no compile-time `{@html}` guard | `488d4384` | | Open-redirect surface: non-UUID `personId` could land in `href` | `26519d02` | | `.person-mention` selector duplicated across CSS / TS / tests | `1842e23c` | | `computeCardPosition` not unit-testable (DOM-bound, magic numbers) | `060db691` | | `fetchHoverData` mixed pure load with cache wrapper | `5890bb3a` | | Modifier-click + middle-click did not fall through to browser | `3faac135` | | Keyboard parity (WCAG 2.1.1): focusin/focusout missing | `3365f584` | | `role="region"` lacked an accessible name; no `aria-busy` toggle | `6dd60571` | | Notes excerpt truncated mid-word (German compound words) | `558e1e6b` | | Underline contrast at rest below WCAG AA on sand backgrounds | `060a1149` | | Flaky `setTimeout` waits in unit tests | `515fa030` | | E2E doc title collision after a crashed run; B21 card-suppression assertion weak | `bc58d77f` | ### Cycle 2 — re-review of `bc58d77f` All six personas approved without further changes: - Markus Keller (Architect) — Approved - Felix Brandt (Developer) — Approved - Tobias Wendt (DevOps) — Approved - Elicit (Requirements) — Approved - Nora Steiner (Security) — Approved - Sara Holt (QA) — Approved - Leonie Voss (UX/A11y) — Approved ### Final state - 1448/1450 frontend tests green. The 2 remaining failures (`RichtlinienRuleCard.svelte.spec.ts`, `hilfe/transkription/page.svelte.spec.ts`) are pre-existing on `origin/main` and unrelated to this PR. - 47 new unit tests + 4 e2e specs added in this PR, all passing. - Branch is mergeable, no conflicts with `main`. Ready to merge.
marcel merged commit bc58d77f2c into main 2026-04-29 13:37:06 +02:00
marcel deleted branch feat/person-mentions-issue-362-frontend-b2 2026-04-29 13:37:08 +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#371