Commit Graph

1762 Commits

Author SHA1 Message Date
Marcel
fb6bffd7ee test(TranscriptionService): verify clear() removes prior mentions before applying DTO
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 20:27:26 +02:00
Marcel
b087de84c4 test(PersonMentionEditor): add placeholder show/hide behavior coverage
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m15s
CI / OCR Service Tests (push) Successful in 28s
CI / Backend Unit Tests (push) Failing after 2m59s
CI / Unit & Component Tests (pull_request) Failing after 3m16s
CI / OCR Service Tests (pull_request) Successful in 31s
CI / Backend Unit Tests (pull_request) Failing after 3m3s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 19:56:12 +02:00
Marcel
3e07f6798c refactor(PersonHoverCard): extract showMaidenName derived, verify chip-type contrast, fix stale position test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 19:55:45 +02:00
Marcel
bc0824b934 refactor(TranscriptionBlock): document EAGER fetch rationale
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 19:55:16 +02:00
Marcel
7ccd541d40 fix(hover-card): use orientation-aware relationship labels; allow spaces in mention
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m35s
CI / OCR Service Tests (push) Successful in 39s
CI / Backend Unit Tests (push) Failing after 3m6s
CI / Unit & Component Tests (pull_request) Failing after 4m38s
CI / OCR Service Tests (pull_request) Successful in 42s
CI / Backend Unit Tests (pull_request) Failing after 3m5s
PersonHoverCard was showing the hovered person as their own parent when stored
as the object side of a PARENT_OF row — now uses chipLabel/otherName from
relationshipLabels (same helpers the person detail page uses) to resolve the
correct name and label from the caller's perspective.

PersonMentionEditor: add allowSpaces:true so typing a last name after a space
no longer exits mention mode mid-query.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 19:22:37 +02:00
Marcel
835dc77382 fix(transcription): persist mentionedPersons on block update; eager-load collection
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m22s
CI / OCR Service Tests (push) Successful in 38s
CI / Backend Unit Tests (push) Failing after 3m3s
CI / Unit & Component Tests (pull_request) Failing after 3m21s
CI / OCR Service Tests (pull_request) Successful in 37s
CI / Backend Unit Tests (pull_request) Failing after 3m0s
TranscriptionService.updateBlock was not writing mentionedPersons from the DTO
back to the entity, so @mentions were lost on every save. Clear-then-addAll
pattern avoids Hibernate orphan issues with @ElementCollection.

Switch @ElementCollection fetch to EAGER so callers can read mentionedPersons
outside an active transaction without a LazyInitializationException.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 18:27:18 +02:00
Marcel
37edac4da6 fix(hover-card): maiden name false positive, placeholder on non-empty editor, card persistence
- PersonHoverCard: alias is compared against both `lastName` and `displayName`
  before showing as maiden name — prevents false positive when alias is stored
  as the full current name (e.g. "Maria Schmidt" ≠ "Schmidt" but name unchanged)
- PersonMentionEditor: data-placeholder was set statically so the CSS ::before
  rule showed the placeholder on any blur even with content; now a $effect
  toggles the attribute based on editor.isEmpty
- TranscriptionReadView: hovering onto the card itself cancels the 150ms close
  timer so the card stays open while reading it; leaving the card closes it
  immediately — onmouseenter/onmouseleave wired through PersonHoverCard props
- hoverCardPosition: removed scrollX/scrollY offset since the card is now
  position:fixed (scroll is already baked into getBoundingClientRect coords)
- MentionDropdown: raised z-index from z-20 to z-50 to render above the hover card
- vite.config.ts: pre-bundle Tiptap packages to avoid HMR waterfall on first load

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 18:26:44 +02:00
Marcel
49443ad16a docs(PersonMentionEditor): document client-side fetch exception inline
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m20s
CI / OCR Service Tests (pull_request) Successful in 35s
CI / Backend Unit Tests (pull_request) Failing after 3m4s
CI / Unit & Component Tests (push) Failing after 3m44s
CI / OCR Service Tests (push) Successful in 40s
CI / Backend Unit Tests (push) Failing after 3m14s
Per Markus #5616, the leaf-component fetch in the Tiptap suggestion plugin
violates the project-wide rule from frontend/CLAUDE.md ("Data flows from
+page.server.ts via props — never client-side API fetch"). Add an inline
block-comment explaining why this exception is justified (suggestion runs
client-side per keystroke; same auth surface; no server-side reshape
benefit) and points future readers at the open ADR follow-up plus Nora's
PersonSummaryDTO response-shape audit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 16:21:33 +02:00
Marcel
e6844c403c feat(MentionDropdown): restore "Neue Person anlegen" empty-state link
The Tiptap rewrite dropped the inline "create new person" affordance the
textarea-era component used to render. Without it the workflow regresses:
transcriber must close the dropdown, navigate to /persons/new, come back,
re-type the query. The m.person_mention_create_new() key is still in all
three locale files — add the link back as a 44px-tall row with a top
border separating it from the empty-state message.

target=_blank keeps document/editor state intact; rel=noopener prevents
reverse-tabnabbing. mousedown preventDefault keeps the editor focused
(the dropdown row pattern used for option rows).

Test: empty-state renders a link to /persons/new with the localised label.

Leonie #5621 (Major) + Elicit OQ-373-04.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 16:20:42 +02:00
Marcel
f1932fd5f6 fix(person-mention): WCAG 1.4.11 contrast for mention pill and dropdown ring
Two non-text-contrast failures, both flagged by Leonie #5621:

1. PersonMentionEditor mention pill: decoration-brand-mint (#A6DAD8) on
   white is ≈1.7:1 — fails the 3:1 minimum for meaningful UI indicators.
   Switch to decoration-ink/50, which matches the read-mode .person-mention
   rule (≈6.4:1) and keeps a unified underline language across modes.

2. MentionDropdown highlighted-row ring: ring-brand-mint on bg-brand-mint/20
   is ≈2.5:1 — same failure class. Switch to ring-brand-navy (≈14.5:1
   against the highlight background) so keyboard-driven selection has a
   clearly visible indicator.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 16:19:34 +02:00
Marcel
ba88febc77 fix(PersonMentionEditor): guard setEditable effect against re-entry loop
The disabled-state effect calls editor.setEditable, which triggers a
ProseMirror transaction → onUpdate → bind:value/mentionedPersons writes →
host re-render → child prop pass-through → effect re-fires. Without an
idempotence check, this exceeds Svelte's effect_update_depth and crashes
every consuming spec (TranscriptionBlock 22/22). Compare editor.isEditable
against the desired value first; only call setEditable when it actually
needs to change.

Follow-up to 6ef888a1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 16:18:40 +02:00
Marcel
fa7b97acdc test(PersonMentionEditor): assert no HTML injection via mention displayName
Adds a CWE-79 regression test: a sidecar entry whose displayName contains
an <img onerror=alert(1)> payload must round-trip through deserialize and
the Tiptap renderHTML without producing a real <img> element in the editor
DOM. Locks down the "renderHTML's third tuple entry is a text node, never
parsed as HTML" invariant so a future "use innerHTML for performance"
refactor cannot silently regress.

Nora #5618 detection-gap concern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 16:14:19 +02:00
Marcel
6ef888a128 fix(PersonMentionEditor): enforce disabled state on the contenteditable
Wrapping the editor with pointer-events-none was visual-only — keyboard users
could still tab into the contenteditable and type. Wire `editable: !disabled`
on the Tiptap Editor and a reactive `$effect` that calls setEditable when the
prop flips after mount; expose `aria-disabled="true"` on the wrapper so
screen readers announce the deactivated state.

Tests assert contenteditable=false and aria-disabled=true when disabled;
contenteditable=true otherwise.

Closes WCAG 2.1.1 / 4.1.2 — Felix #5615 + Leonie #5621 + Nora #5618 BLOCKER.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 16:13:32 +02:00
Marcel
94d0733412 chore(i18n): remove orphaned error_person_rename_conflict translation key
errors.ts no longer references this code (the rename-propagation listener
was deleted) and the matching ErrorCode value is gone from the backend.
The Paraglide-compiled message helpers should not include strings nothing
calls — drop the entries from de/en/es to keep the i18n surface honest.

Felix #5615 + Elicit #5624 blocker.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 16:10:36 +02:00
Marcel
4ac94b2feb refactor(frontend): delete orphaned personMention.ts after Tiptap migration
The textarea-era detectPersonMention helper has no production callers since
the suggestion plugin's char: '@' mechanism replaced it. Per "Dead code is
deleted, not commented out", remove the source file and its spec — the spec
was running but tested a function nobody calls.

Felix #5615 blocker.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 16:09:53 +02:00
Marcel
392af640c4 chore(frontend): add Tiptap placeholder CSS and lock Tiptap deps
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m30s
CI / OCR Service Tests (push) Successful in 41s
CI / Backend Unit Tests (push) Failing after 3m10s
CI / Unit & Component Tests (pull_request) Failing after 3m11s
CI / OCR Service Tests (pull_request) Successful in 38s
CI / Backend Unit Tests (pull_request) Failing after 3m4s
Placeholder uses ::before pseudo-element on the contenteditable's
data-placeholder attribute, only visible when the editor is unfocused
and empty. Removes the default ProseMirror focus ring since the outer
wrapper provides its own.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 15:54:26 +02:00
Marcel
7a25feb04e refactor(TranscriptionBlock): migrate quote selection to Tiptap selectionUpdate (AC-7)
Replaces captureTextarea + handleTextareaMouseUp (which read selection
bounds off a real <textarea>) with an onSelectionChange callback prop
on PersonMentionEditor, wired to Tiptap's selectionUpdate event. The
editor emits the selected text directly so the parent no longer needs
DOM access.

Tests are updated to drive the contenteditable via the Selection API
instead of the now-deleted textarea.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 15:53:54 +02:00
Marcel
d87ad36278 feat(PersonMentionEditor): rewrite as Tiptap editor with AC-1 typed-text displayName
Replaces the textarea-based editor with a Tiptap v3 contenteditable.
The custom Mention node uses personId/displayName attrs (instead of
Tiptap's default id/label) so mentionSerializer round-trips cleanly.

AC-1 fix (issue #372): when the user types '@Aug' and selects
'Auguste Raddatz', the mention node stores displayName: 'Aug' (the
typed query) — not the person's DB display name. This preserves
archival fidelity of the original transcription.

The MentionDropdown is mounted imperatively on document.body via
Svelte 5's mount(). Its three pieces of dynamic state (items,
command, clientRect) are passed as a single $state proxy (model)
because Svelte 5's mount() does not return prop accessors.

Spec is fully rewritten — all old tests used document.querySelector
('textarea') which is dead after the migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 15:53:21 +02:00
Marcel
39ddf90725 refactor(MentionDropdown): receive reactive state via single 'model' prop
Svelte 5's mount() does not return prop accessors — setting
'instance.items = newValue' is a no-op. Switching to a single $state
proxy passed as 'model' lets the parent mutate fields and have the
dropdown react. The prop is named 'model' (not 'state') because the
$state rune name shadows a 'state' identifier in Svelte 5 templates.

Position class also switches from absolute to fixed so viewport-
relative DOMRect coordinates from clientRect() work when the dropdown
is mounted on document.body.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 15:52:45 +02:00
Marcel
e5634c301e feat(frontend): add MentionDropdown — Tiptap suggestion-compatible person dropdown
Replaces PersonMentionEditor's inline popup for the Tiptap migration.
Mounted imperatively to document.body by the suggestion plugin's render()
lifecycle. Supports flip-upward strategy when viewport space is tight
(Leonie #5602 mobile keyboard concern). 44px touch targets, WCAG accessible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 15:08:44 +02:00
Marcel
68cb6e9b76 feat(frontend): add mentionSerializer — pure serialize/deserialize for Tiptap ↔ block storage
Converts between the stored format (text + PersonMention sidecar) and Tiptap
ProseMirror JSONContent. Round-trip invariant: serialize(deserialize(t,s)).text === t.
Handles multi-paragraph text (split/join on \n), sidecar deduplication, and
backward compat with old-format full-name sidecar entries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 15:03:58 +02:00
Marcel
5591f95871 chore(deps): install Tiptap 3.22.5 (core, starter-kit, extension-mention)
Exact version pins — all three packages share ProseMirror peer deps and must
stay in sync. Renovate grouping in renovate.json ensures they bump together.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 15:01:19 +02:00
Marcel
41a57c0dc8 feat(frontend): add Tiptap renovate group, i18n keys, fix geb. literal, remove rename-conflict
- renovate.json: group all @tiptap/* packages so version bumps stay in sync
- de/en/es.json: add transcription_editor_aria_label and person_born_name_prefix keys
- PersonHoverCard: replace hardcoded "geb." with m.person_born_name_prefix() (Leonie #5602)
- errors.ts: remove PERSON_RENAME_CONFLICT (backend enum value deleted)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 15:00:13 +02:00
Marcel
2d19ca7244 refactor(backend): delete rename-propagation listener and its infrastructure
PersonMentionPropagationListener rewrites @DisplayName tokens on person rename.
Under the new design, displayName is archival (what the transcriber typed), so
the listener would corrupt transcriptions rather than correct them.

Deletes PersonMentionPropagationListener, PersonDisplayNameChangedEvent, and the
optimistic-lock catch path in PersonService.updatePerson. Removes PERSON_RENAME_CONFLICT
from ErrorCode and all tests that exercised the now-deleted code path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 14:58:18 +02:00
Marcel
bc58d77f2c 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
- 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>
2026-04-29 09:04:59 +02:00
Marcel
515fa03088 test(person-mention): replace setTimeout waits with vi.waitFor
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>
2026-04-29 09:04:02 +02:00
Marcel
060a1149e0 fix(person-mention): bump underline contrast so the link is visible at rest
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>
2026-04-29 09:02:38 +02:00
Marcel
558e1e6b22 fix(person-mention): truncate notes excerpt at last word boundary
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>
2026-04-29 09:01:39 +02:00
Marcel
6dd60571e3 fix(person-mention): name the hover-card region and announce its busy state
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>
2026-04-29 09:00:15 +02:00
Marcel
3365f5845e fix(person-mention): hover card mounts on focusin for keyboard users (WCAG 2.1.1)
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>
2026-04-29 08:57:48 +02:00
Marcel
3faac13533 fix(person-mention): respect modified-click and middle-click for new-tab nav
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>
2026-04-29 08:56:23 +02:00
Marcel
5890bb3abd refactor(person-mention): split fetchHoverData into pure load + cache wrapper
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>
2026-04-29 08:54:35 +02:00
Marcel
060db69108 refactor(person-mention): extract computeHoverCardPosition into testable util
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>
2026-04-29 08:53:29 +02:00
Marcel
1842e23c81 refactor(person-mention): centralise PERSON_MENTION_SELECTOR constant
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>
2026-04-29 08:51:25 +02:00
Marcel
26519d029a feat(person-mention): reject non-UUID personIds at the renderer boundary
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>
2026-04-29 08:50:28 +02:00
Marcel
488d4384a1 refactor(person-mention): brand renderer return types as SafeHtml
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>
2026-04-29 08:48:26 +02:00
Marcel
6a6967d841 refactor(person-mention): hoist LoadState + HoverData into shared types module
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>
2026-04-29 08:46:42 +02:00
Marcel
ae868f4110 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
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>
2026-04-29 08:23:47 +02:00
Marcel
1fd38830fe feat(person-mention): TranscriptionReadView wires hover card and click nav
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>
2026-04-29 08:21:35 +02:00
Marcel
c9c395eb59 feat(person-mention): PersonHoverCard with skeleton/error/loaded states
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>
2026-04-29 08:16:51 +02:00
Marcel
c247e1e971 feat(person-mention): .person-mention global CSS for read-mode anchors
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>
2026-04-29 08:13:32 +02:00
Marcel
eb6e21f032 feat(person-mention): renderTranscriptionBody for safe read-mode HTML
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>
2026-04-29 08:12:52 +02:00
Marcel
b4b46a0a79 test(person-mention): boundary cases for whitespace + newline triggers
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m17s
CI / OCR Service Tests (pull_request) Successful in 32s
CI / Backend Unit Tests (pull_request) Failing after 3m6s
CI / Unit & Component Tests (push) Failing after 3m24s
CI / OCR Service Tests (push) Failing after 28s
CI / Backend Unit Tests (push) Failing after 3m43s
Tester #5506 nit pile:
- '@Aug @Bert' with cursor past the second @ — confirm the most
  recent @ wins (this is the canonical case for typing two mentions
  separated by a space).
- '@Aug\\nfoo' with cursor exactly at the newline (index 4) — the
  query still reads 'Aug' because the newline is past the cursor.

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

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

6 new unit tests cover every branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:20:49 +02:00
Marcel
d9c7abf2ab test(autosave): observe saving→saved transition in B12 retry path
Tester #5506 §5: the existing test only asserted the final 'saved'
state, which would also pass if the hook skipped the saving state
altogether. Hold the second mocked saveFn promise so we can assert the
intermediate transition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:18:43 +02:00
Marcel
7fc56022ae test(person-mention): assert popup degrades to empty state on fetch reject
Tester #5506 §4: there was a test for fetch returning ok:false but no
test for the broad catch covering thrown rejections (DNS failure,
TypeError: Failed to fetch). Pin that path so a future refactor can't
accidentally bubble the error and crash the editor.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:16:55 +02:00
Marcel
09f71a2dce feat(person-mention): empty-state link to create the missing person
Leonie #5507 §5 + ReqEng #5510 §3: when the typeahead returned zero
results, the user was told their search failed and given no path to
recovery. Mirror PersonTypeahead's behaviour: offer a "Neue Person
anlegen →" link that opens /persons/new?name={query} in a new tab so
the transcriber doesn't lose their in-progress block.

Adds person_mention_create_new in de/en/es.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:15:41 +02:00
Marcel
86ad5ca9b3 fix(person-mention): show loading state during debounce + fetch
Leonie #5507 concern 7: on slow networks the popup sat empty for up to
1.5s while the user wondered if anything was happening. Add a loading
flag that flips on as soon as scheduleSearch is asked to query and
back off in the fetch's finally branch. Reuses the existing
comp_typeahead_loading message ("Suche…") so no new i18n keys.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:14:37 +02:00
Marcel
780c682136 fix(person-mention): distinguish keyboard-highlighted row from hover
Leonie #5507 concern 3: hover and aria-selected both used bg-canvas, so
a tablet user sweeping the trackpad couldn't tell where the keyboard
cursor was. Use bg-brand-mint/20 + a 2px ring-inset for the highlighted
row — keeps hover affordance, adds a distinct keyboard-cursor token
that meets WCAG 1.4.11 Non-Text Contrast.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:13:27 +02:00