feat: decouple person-mention display text from person name (#372) #373

Merged
marcel merged 17 commits from feat/person-mentions-issue-362-frontend-b2 into main 2026-04-29 16:55:53 +02:00

17 Commits

Author SHA1 Message Date
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