feat(transcription): re-edit existing @mention by pre-filling the search input (#628) #717

Merged
marcel merged 9 commits from feat/issue-628-mention-reedit into main 2026-06-03 07:55:30 +02:00
Owner

Closes #628.

Adds an explicit re-edit affordance to saved @mentions: each rendered mention carries a pencil that opens the search dropdown pre-filled with the stored displayName, letting a transcriber re-link the person without retyping.

What changed

  • Single dropdown controller (createMentionController in PersonMentionEditor.svelte): the dropdown lifecycle (mountedDropdown / requestId / debouncedSearch / dropdownState) is lifted out of Tiptap's suggestion.render() closure into one owner with open/update/close/onKeyDown. render() is now a thin adapter. This is the single owner the AC-6 invariant needs; requestId increment-on-open is preserved (+ bumped on open so a superseded open's in-flight fetch is discarded).
  • Mention NodeView (mentionNodeView.ts): hosts each mention as a Tiptap NodeView rendering the @displayName token text as textContent (never innerHTML) plus a contenteditable=false pencil in a fixed-width slot — instant opacity reveal on whole-token hover + keyboard focus, no reflow. renderHTML/renderText stay for serialization/clipboard.
  • commitRelink: swaps only personId in place via setNodeMarkup, so the stored displayName is preserved by construction (never round-tripped through insertContentAt). personId is sourced solely from the selected Person — never the reflected data-person-id or the clipped search text.
  • Dismiss + keyboard (MentionDropdown.svelte): a visible × control (shared with the fresh-@ path) + Escape; the re-edit path (no suggestion plugin to forward keys) focuses the search input on open and handles Arrow/Enter/Escape itself.
  • Editing announce: re-edit open announces context through the existing persistent aria-live region (editingDisplayName) — no second live region.
  • i18n: person_mention_edit_label, person_mention_editing_announce, person_mention_dismiss_label in messages/{de,en,es}.json.

Acceptance criteria

AC-1…AC-9 + NFRs (no-reflow, a11y announce, MAX_QUERY_LENGTH clip, anti-spoof provenance) covered by browser tests in PersonMentionEditor.svelte.spec.ts. The serializer round-trip (text byte-identical, only personId swapped) is pinned.

Deviation — flagging for review

The justCommitted latch from the Implementation Notes is not added: it guards the selectionUpdate-driven auto-open that Resolved Decision 1 removed, so there's no vector to suppress in the explicit-gesture design, and an unexercised latch risked blocking a legitimate immediate re-edit. Trivial to add back if preferred.

Testing

  • Browser-only (vitest-browser-svelte) — validated by the --project=client CI lane.
  • npm run check + eslint clean for all changed files; prettier enforced by the pre-commit hook.

Docs

No migration / route / permission / ErrorCode / domain term → no doc-table updates (per the issue's Docs section).

🤖 Generated with Claude Code

Closes #628. Adds an explicit re-edit affordance to saved `@mention`s: each rendered mention carries a pencil that opens the search dropdown pre-filled with the stored `displayName`, letting a transcriber re-link the person without retyping. ## What changed - **Single dropdown controller** (`createMentionController` in `PersonMentionEditor.svelte`): the dropdown lifecycle (`mountedDropdown` / `requestId` / `debouncedSearch` / `dropdownState`) is lifted out of Tiptap's `suggestion.render()` closure into one owner with `open`/`update`/`close`/`onKeyDown`. `render()` is now a thin adapter. This is the single owner the AC-6 invariant needs; `requestId` increment-on-open is preserved (+ bumped on open so a superseded open's in-flight fetch is discarded). - **Mention NodeView** (`mentionNodeView.ts`): hosts each mention as a Tiptap NodeView rendering the `@displayName` token text as `textContent` (never `innerHTML`) plus a `contenteditable=false` pencil in a fixed-width slot — instant opacity reveal on whole-token hover + keyboard focus, no reflow. `renderHTML`/`renderText` stay for serialization/clipboard. - **`commitRelink`**: swaps **only** `personId` in place via `setNodeMarkup`, so the stored `displayName` is preserved by construction (never round-tripped through `insertContentAt`). `personId` is sourced solely from the selected `Person` — never the reflected `data-person-id` or the clipped search text. - **Dismiss + keyboard** (`MentionDropdown.svelte`): a visible × control (shared with the fresh-@ path) + Escape; the re-edit path (no suggestion plugin to forward keys) focuses the search input on open and handles Arrow/Enter/Escape itself. - **Editing announce**: re-edit open announces context through the *existing* persistent `aria-live` region (`editingDisplayName`) — no second live region. - **i18n**: `person_mention_edit_label`, `person_mention_editing_announce`, `person_mention_dismiss_label` in `messages/{de,en,es}.json`. ## Acceptance criteria AC-1…AC-9 + NFRs (no-reflow, a11y announce, `MAX_QUERY_LENGTH` clip, anti-spoof provenance) covered by browser tests in `PersonMentionEditor.svelte.spec.ts`. The serializer round-trip (`text` byte-identical, only `personId` swapped) is pinned. ## Deviation — flagging for review The `justCommitted` latch from the Implementation Notes is **not** added: it guards the `selectionUpdate`-driven auto-open that Resolved Decision 1 removed, so there's no vector to suppress in the explicit-gesture design, and an unexercised latch risked blocking a legitimate immediate re-edit. Trivial to add back if preferred. ## Testing - Browser-only (`vitest-browser-svelte`) — validated by the `--project=client` CI lane. - `npm run check` + `eslint` clean for all changed files; `prettier` enforced by the pre-commit hook. ## Docs No migration / route / permission / `ErrorCode` / domain term → no doc-table updates (per the issue's Docs section). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 7 commits 2026-06-02 19:59:28 +02:00
Keys for the re-edit affordance landing in #628:
- person_mention_edit_label   — pencil button aria-label
- person_mention_editing_announce — aria-live editing context
- person_mention_dismiss_label — dropdown close button aria-label

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pulls mountedDropdown / requestId / debouncedSearch / dropdownState ownership
out of Tiptap's suggestion.render() closure into one createMentionController().
render() becomes a thin adapter: onStart→open, onUpdate→update, onExit→close.

This is the single-owner structure #628 needs for the AC-6 single-dropdown
invariant — the upcoming pencil re-edit affordance opens via the same
controller.open() instead of racing the suggestion plugin over module state.
open() now also bumps the request token so an open-A→open-B sequence discards
A's in-flight fetch (preserved increment-on-open semantics). No behaviour
change for the fresh-@ path — existing browser suite is the regression guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Hosts each mention as a Tiptap NodeView (mentionNodeView.ts) that renders the
@displayName token (textContent — never innerHTML) plus a contenteditable=false
pencil button in a fixed-width slot, revealed on whole-token hover and keyboard
focus (instant opacity swap, no reflow). Activating the pencil (click or Enter/
Space) opens the single mention dropdown via the controller, anchored at the
token and pre-filled with the stored displayName.

commitRelink swaps ONLY personId in place via setNodeMarkup, sourcing the id
solely from the selected Person — the stored displayName is preserved by
construction (AC-3), even after the search input is edited (AC-5, the #380 AC-1
invariant). renderHTML/renderText stay for serialization + clipboard.

AC-1/AC-2/AC-3/AC-5 + serializer round-trip covered by browser tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a visible × dismiss control to MentionDropdown (shared by the fresh-@ and
re-edit paths) and, for the re-edit path which has no Tiptap suggestion plugin
to forward keys, focuses the search input on open and handles its own keyboard:
Escape dismisses (AC-4), Arrow/Enter reuse the exported selection logic so the
dropdown is navigable on its own (AC-9 parity with the fresh-@ dropdown).

Both close paths (Escape + ×) leave the mention node attrs + text byte-identical
(AC-4) — close() never touches the document. Controller wires ondismiss=close
(+refocus editor) and focusOnMount only for the re-edit open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Locks in the single-owner controller guarantees: pencil→pencil, fresh-@→pencil
and pencil→fresh-@ all leave exactly one dropdown open; the request-token bump
on open discards a superseded open's in-flight fetch (open A → open B → A
resolves, deterministic, no sleeps). Plus a #380 AC-1 regression guard that the
fresh-@ path still inserts the typed text as displayName after the controller
refactor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- AC-7: disabled editor → pencil is disabled + aria-disabled + tabindex -1, and
  neither keyboard nor pointer activation mounts a dropdown (WCAG 2.1.1, not just
  pointer-events-none).
- AC-8: plain text shows no pencil/dropdown; two adjacent mentions each keep one
  pencil with no spurious gap pencil and no auto-open; a doc-start mention still
  renders its pencil.
- Security: an oversized stored displayName clips the search query to 100 chars
  while the preserved node text stays full-length; re-link sources personId
  solely from the picked Person (p-anna), never the reflected/clipped text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(transcription): announce re-edit context via the existing live region (#628)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 1m16s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m38s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 24s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
4abda48965
Passes editingDisplayName into MentionDropdown; the persistent aria-live region
announces person_mention_editing_announce({displayName}) on re-edit open and
falls back to the prompt/empty/count copy once the user edits or results arrive.
Routed through the SAME sr-only region as the result count — no second live
region (avoids the double-announce bug Leonie S-2 fixed). Fresh-@ passes an
empty editingDisplayName, so its announcements are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
marcel added 1 commit 2026-06-02 20:09:23 +02:00
fix(transcription): harden re-edit pencil hit-testing + disable sync (#628 review)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 1m17s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m31s
CI / fail2ban Regex (pull_request) Successful in 50s
CI / Semgrep Security Scan (pull_request) Successful in 29s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
a62a333d09
Addresses the clean-agent review of PR #717:

- C1: the hidden pencil was opacity-0 only, which still hit-tests; its 44px box
  overhangs adjacent text, so a click in the gap between two mentions could land
  on the invisible button and spuriously open the dropdown (AC-8 hole). Add
  pointer-events-none while hidden, re-enabled with the opacity reveal on
  hover/focus.
- C2/N1: editor.setEditable() emits "update", not a ProseMirror transaction, so
  the NodeView's 'transaction' listener missed a mid-session disable flip (stale
  aria-disabled/tabindex; the comment was wrong). Listen on 'update' instead —
  which also skips selection-only changes, so it fires far less often.
- N2: track the node across update() so the pencil opens with the live
  displayName (hardening; relink only swaps personId today).

Tests: structural guard that the hidden pencil is pointer-events-none + reveals,
and a mid-session disable-flip test (fixture gains an onReady setDisabled hook).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author
Owner

Clean-agent review → fixes applied (commit a62a333d)

A fresh-eyes reviewer (no prior context) checked the diff against #628's ACs. Verdict: approve-with-nits, no blockers. Two real correctness issues were confirmed and fixed:

  • C1 — hidden pencil still hit-tested. opacity-0 alone doesn't remove an element from hit-testing, and the 44px button overhangs adjacent text — so a click in the gap between two mentions could land on the invisible pencil and open the dropdown (an AC-8 hole, not caught by the render-time-only AC-8 test). Fix: pointer-events-none while hidden, re-enabled with the opacity reveal on hover/focus.
  • C2 — mid-session disable flip didn't re-sync the pencil. Tiptap's setEditable() emits "update", not a transaction, so the NodeView's 'transaction' listener missed a runtime disabled flip (stale aria-disabled/tabindex; the runtime open-guard still held, so no security impact). The comment was also wrong. Fix: listen on 'update' (also skips selection-only changes → fires far less often, resolving nit N1).
  • N2 (hardening): track the node across update() so the pencil opens with the live displayName.

New tests: structural guard that the hidden pencil is pointer-events-none + reveals, and a mid-session disable-flip test (fixture gained an onReady/setDisabled hook).

Confirmed sound by the reviewer (no change needed): the single-controller refactor preserves the fresh-@ path, the requestId stale-fetch guard, and the omission of the justCommitted latch (the auto-open vector it guarded was removed by Resolved Decision 1).

Re-checked: npm run check + eslint clean for all changed files. Browser tests run on the --project=client CI lane.

## Clean-agent review → fixes applied (commit `a62a333d`) A fresh-eyes reviewer (no prior context) checked the diff against #628's ACs. Verdict: **approve-with-nits**, no blockers. Two real correctness issues were confirmed and fixed: - **C1 — hidden pencil still hit-tested.** `opacity-0` alone doesn't remove an element from hit-testing, and the 44px button overhangs adjacent text — so a click in the gap between two mentions could land on the invisible pencil and open the dropdown (an **AC-8** hole, not caught by the render-time-only AC-8 test). **Fix:** `pointer-events-none` while hidden, re-enabled with the opacity reveal on hover/focus. - **C2 — mid-session disable flip didn't re-sync the pencil.** Tiptap's `setEditable()` emits `"update"`, **not** a transaction, so the NodeView's `'transaction'` listener missed a runtime `disabled` flip (stale `aria-disabled`/`tabindex`; the runtime open-guard still held, so no security impact). The comment was also wrong. **Fix:** listen on `'update'` (also skips selection-only changes → fires far less often, resolving nit N1). - **N2** (hardening): track the node across `update()` so the pencil opens with the live `displayName`. New tests: structural guard that the hidden pencil is `pointer-events-none` + reveals, and a **mid-session disable-flip** test (fixture gained an `onReady`/`setDisabled` hook). Confirmed sound by the reviewer (no change needed): the single-controller refactor preserves the fresh-@ path, the `requestId` stale-fetch guard, and the **omission of the `justCommitted` latch** (the auto-open vector it guarded was removed by Resolved Decision 1). Re-checked: `npm run check` + `eslint` clean for all changed files. Browser tests run on the `--project=client` CI lane.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: Approved

This is the structure I'd want. The single-controller refactor (createMentionController) is the right move — it makes AC-6 fall out of one owner instead of two code paths racing over module state, and render() reads as a thin adapter (onStart→open, onUpdate→update, onExit→close). Two named commit functions (commitFreshInsert / commitRelink) instead of a boolean branch — exactly right, and commitRelink preserving displayName by construction via setNodeMarkup is the clean way to honour the #380 invariant.

What I checked

  • Svelte 5: runes used correctly; no $effect-computes-state anti-patterns introduced; mount()/unmount() lifecycle owned in one place with onDestroy cleanup intact.
  • NodeView: pure-DOM, textContent for the user-influenced name, createElementNS for the static glyph, destroy() removes the listener. currentNode tracking added in the review pass is the correct hardening.
  • Naming / guard clauses: requestRelink, dismiss, writeState, syncEditable all reveal intent. Guard-first in openRelink/requestRelink.

Suggestions (non-blocking)

  • PersonMentionEditor.svelte is now ~370 lines. Still cohesive (one editor, one controller), and you correctly deferred extracting a mentionController.svelte.ts until a second call site appears — agreed. Just flagging the line count for the next person.
  • The as object / as unknown as DropdownExports casts around mount() are unavoidable given Svelte's mount typing; fine.

TDD note: most new behaviour has a test, and the red/green discipline is visible. The single soft spot is the small set of behaviours that were locked in after the controller refactor rather than test-first — but they're genuinely covered now.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ✅ Approved** This is the structure I'd want. The single-controller refactor (`createMentionController`) is the right move — it makes AC-6 fall out of one owner instead of two code paths racing over module state, and `render()` reads as a thin adapter (`onStart→open`, `onUpdate→update`, `onExit→close`). Two named commit functions (`commitFreshInsert` / `commitRelink`) instead of a boolean branch — exactly right, and `commitRelink` preserving `displayName` *by construction* via `setNodeMarkup` is the clean way to honour the #380 invariant. ### What I checked - **Svelte 5**: runes used correctly; no `$effect`-computes-state anti-patterns introduced; `mount()`/`unmount()` lifecycle owned in one place with `onDestroy` cleanup intact. - **NodeView**: pure-DOM, `textContent` for the user-influenced name, `createElementNS` for the static glyph, `destroy()` removes the listener. `currentNode` tracking added in the review pass is the correct hardening. - **Naming / guard clauses**: `requestRelink`, `dismiss`, `writeState`, `syncEditable` all reveal intent. Guard-first in `openRelink`/`requestRelink`. ### Suggestions (non-blocking) - `PersonMentionEditor.svelte` is now ~370 lines. Still cohesive (one editor, one controller), and you correctly deferred extracting a `mentionController.svelte.ts` until a second call site appears — agreed. Just flagging the line count for the next person. - The `as object` / `as unknown as DropdownExports` casts around `mount()` are unavoidable given Svelte's mount typing; fine. TDD note: most new behaviour has a test, and the red/green discipline is visible. The single soft spot is the small set of behaviours that were locked in *after* the controller refactor rather than test-first — but they're genuinely covered now.
Author
Owner

🏛️ Markus Keller — Application Architect

Verdict: Approved

Frontend-only, no module-boundary or transport implications. The judgment calls land where I'd put them.

What I checked

  • No premature abstraction. The controller stays inside PersonMentionEditor.svelte rather than being lifted into a .svelte.ts module — correct for one editor / one consumer. Extract when a second call site appears, not before.
  • mentionNodeView.ts as a pure module (DOM-building, no runes) is a clean seam: testable, framework-light, and it leaves renderHTML/renderText in place for serialization/clipboard. Good separation of "live editing DOM" vs "serialized form".
  • Boundary leak check: the // eslint-disable boundaries/dependencies for personLifeDates in MentionDropdown is pre-existing and unchanged — not this PR's concern.

Documentation gate

Ran the doc-table: no Flyway migration, no new route/controller/service, no ErrorCode/Permission, no new domain term. New i18n keys are not a doc-table trigger. No doc updates required — matches the PR's own Docs section. Nothing to block on.

Note (not a blocker)

The client-side-fetch ADR follow-up referenced in PersonMentionEditor ("ADR: client-side fetch exception for editor suggestion plugins") is still open — but that's inherited from #629, not introduced here. This PR reuses the same justified exception; fine to leave the ADR to its own ticket.

## 🏛️ Markus Keller — Application Architect **Verdict: ✅ Approved** Frontend-only, no module-boundary or transport implications. The judgment calls land where I'd put them. ### What I checked - **No premature abstraction.** The controller stays inside `PersonMentionEditor.svelte` rather than being lifted into a `.svelte.ts` module — correct for one editor / one consumer. Extract when a second call site appears, not before. - **`mentionNodeView.ts` as a pure module** (DOM-building, no runes) is a clean seam: testable, framework-light, and it leaves `renderHTML`/`renderText` in place for serialization/clipboard. Good separation of "live editing DOM" vs "serialized form". - **Boundary leak check**: the `// eslint-disable boundaries/dependencies` for `personLifeDates` in `MentionDropdown` is pre-existing and unchanged — not this PR's concern. ### Documentation gate Ran the doc-table: no Flyway migration, no new route/controller/service, no `ErrorCode`/`Permission`, no new domain term. New i18n keys are **not** a doc-table trigger. **No doc updates required** — matches the PR's own Docs section. Nothing to block on. ### Note (not a blocker) The client-side-fetch ADR follow-up referenced in `PersonMentionEditor` ("ADR: client-side fetch exception for editor suggestion plugins") is still open — but that's inherited from #629, not introduced here. This PR reuses the same justified exception; fine to leave the ADR to its own ticket.
Author
Owner

🛡️ Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

The user-influenced data paths are handled correctly, and there's a regression test for each. No new attack surface.

What I checked

  • CWE-79 (XSS / DOM injection)mentionNodeView.ts builds the token as textContent and the pencil glyph via createElementNS element-by-element. No innerHTML, no document.write, no string-interpolated markup anywhere a displayName flows. data-person-id / data-display-name go through setAttribute, which escapes. The XSS test (<img src=x onerror=...> payload) still asserts no real element materialises.
  • CWE-400 (input amplification) — the search-query clip to MAX_QUERY_LENGTH is preserved in writeState, and there's an explicit test that a 150-char stored displayName clips the search input to 100 while leaving the node text full-length. The clip applies to the query only — correct, the stored text must not be truncated.
  • Authorization / provenance (mass-assignment / spoofing)commitRelink sources personId solely from the selected Person object (item.id), never from the reflected data-person-id or the search text. The security test picks a person whose id differs from the one in the DOM to prove it.
  • Outbound requestencodeURIComponent(query) on the fetch URL; same /api/persons?review=true endpoint and cookie-via-proxy auth as before. No change to the network surface.

Nothing to fix. The "every fix gets a test" discipline is visible — the clip and the provenance both have permanent regression tests.

## 🛡️ Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** The user-influenced data paths are handled correctly, and there's a regression test for each. No new attack surface. ### What I checked - **CWE-79 (XSS / DOM injection)** — `mentionNodeView.ts` builds the token as `textContent` and the pencil glyph via `createElementNS` element-by-element. No `innerHTML`, no `document.write`, no string-interpolated markup anywhere a `displayName` flows. `data-person-id` / `data-display-name` go through `setAttribute`, which escapes. The XSS test (`<img src=x onerror=...>` payload) still asserts no real element materialises. ✅ - **CWE-400 (input amplification)** — the search-query clip to `MAX_QUERY_LENGTH` is preserved in `writeState`, and there's an explicit test that a 150-char stored `displayName` clips the *search input* to 100 while leaving the *node text* full-length. The clip applies to the query only — correct, the stored text must not be truncated. ✅ - **Authorization / provenance (mass-assignment / spoofing)** — `commitRelink` sources `personId` *solely* from the selected `Person` object (`item.id`), never from the reflected `data-person-id` or the search text. The security test picks a person whose id differs from the one in the DOM to prove it. ✅ - **Outbound request** — `encodeURIComponent(query)` on the fetch URL; same `/api/persons?review=true` endpoint and cookie-via-proxy auth as before. No change to the network surface. Nothing to fix. The "every fix gets a test" discipline is visible — the clip and the provenance both have permanent regression tests.
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

Verdict: ⚠️ Approved with concerns

The suite is genuinely well-built — descriptive names, factory helpers, AAA, and the stale-fetch test is deterministic (promise-controlled requestId ordering, no sleep). That's the hard one done right. My concerns are about the gap between what's asserted and what runs locally, not about correctness.

Concerns (should address)

  1. C1's fix is asserted structurally, not behaviourally. The hidden-pencil hit-testing fix is verified by className.toContain('pointer-events-none'). That pins the mechanism, but the actual AC-8 risk — a real pointer click landing in the gap between two adjacent mentions — has no behavioural test. A synthetic dispatchEvent('click') won't catch it (it ignores pointer-events). The honest behavioural test needs a real userEvent.click at a gap coordinate, which is fiddly to target. Acceptable to ship the structural guard, but please log a follow-up for a coordinate-based gap-click test (ideally Playwright E2E where pointer-events is enforced).
  2. One fixed setTimeout sleep in the AC-7 "no dropdown mounts" test (SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS). I normally reject sleeps — but this is a negative assertion (proving an event never happens), where there's nothing to await. It reuses the calibrated slack constant, so I'll allow it. Don't let this pattern spread to positive assertions.

What I verified will hold in CI (reasoning, since the --project=client lane can't run locally)

  • Native dispatchEvent('mousedown') for option selection matches the dropdown's actual handler — consistent with the pre-existing #629 tests, sound.
  • fill() over per-keystroke type() to dodge debounce jitter — correct.
  • focusOnMount and mid-session-disable assertions are wrapped in vi.waitFor, so async focus/setEditable→update propagation won't flake.
  • The new fixture onReady/setDisabled hook is the right way to exercise the reactive disabled path — and it's the test that would have caught the original 'transaction'-listener bug. Good.

Note

Per project convention these run only in CI. Watch the client lane on this PR before merge — that's the real gate, and I can't sign off on green I haven't seen. Everything I can reason about looks deterministic.

## 🧪 Sara Holt — QA Engineer & Test Strategist **Verdict: ⚠️ Approved with concerns** The suite is genuinely well-built — descriptive names, factory helpers, AAA, and the stale-fetch test is *deterministic* (promise-controlled `requestId` ordering, no `sleep`). That's the hard one done right. My concerns are about the gap between what's asserted and what runs locally, not about correctness. ### Concerns (should address) 1. **C1's fix is asserted structurally, not behaviourally.** The hidden-pencil hit-testing fix is verified by `className.toContain('pointer-events-none')`. That pins the *mechanism*, but the actual AC-8 risk — a real pointer click landing in the gap between two adjacent mentions — has no behavioural test. A synthetic `dispatchEvent('click')` won't catch it (it ignores `pointer-events`). The honest behavioural test needs a real `userEvent.click` at a gap coordinate, which is fiddly to target. Acceptable to ship the structural guard, but please log a follow-up for a coordinate-based gap-click test (ideally Playwright E2E where `pointer-events` is enforced). 2. **One fixed `setTimeout` sleep** in the AC-7 "no dropdown mounts" test (`SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS`). I normally reject sleeps — but this is a *negative* assertion (proving an event never happens), where there's nothing to `await`. It reuses the calibrated slack constant, so I'll allow it. Don't let this pattern spread to positive assertions. ### What I verified will hold in CI (reasoning, since the `--project=client` lane can't run locally) - Native `dispatchEvent('mousedown')` for option selection matches the dropdown's actual handler — consistent with the pre-existing #629 tests, sound. - `fill()` over per-keystroke `type()` to dodge debounce jitter — correct. - `focusOnMount` and mid-session-disable assertions are wrapped in `vi.waitFor`, so async focus/`setEditable→update` propagation won't flake. - The new fixture `onReady`/`setDisabled` hook is the right way to exercise the reactive `disabled` path — and it's the test that would have caught the original `'transaction'`-listener bug. Good. ### Note Per project convention these run only in CI. **Watch the `client` lane on this PR before merge** — that's the real gate, and I can't sign off on green I haven't seen. Everything I can reason about looks deterministic.
Author
Owner

🎨 Leonie Voss — UX & Accessibility Lead

Verdict: ⚠️ Approved with concerns

The affordance respects the senior-first constraints I care about, and the a11y wiring is mostly textbook. Two things to verify/track before I'm fully happy.

What's right

  • 44px tap target on both the pencil and the × control (h-11 w-11), glyph kept small (~16px) — meets WCAG 2.2 AA target size while keeping the inline visual light.
  • Keyboard reachability + visible focus: pencil is tabindex 0, focus-visible:ring-2 ring-brand-navy; the × button has the same ring. Keyboard focus reveals the pencil (group-focus-within), so it's not pointer-only.
  • Redundant cue: the glyph itself is the non-colour cue. aria-label via the new i18n key on the icon-only button.
  • Reduced motion: reveal is transition-none — an instant opacity swap, no slide/fade. Exactly right, costs nothing.
  • No reflow: fixed-width slot reserves space; only opacity changes. The pointer-events-none-while-hidden fix also means the invisible target can't hijack a caret click.
  • Live region: editing context announced through the single existing aria-live region — no double-announce.

Concerns

  1. Verify the pencil glyph contrast (WCAG 1.4.11 Non-Text Contrast, 3:1). The glyph is text-ink-2 on bg-surface. A meaningful icon needs ≥3:1. ink-2 is mid-tone — please confirm the exact ratio against surface; if it's under 3:1, bump to text-ink (the token text already uses the darker ink). This is the same class of finding I raised on the dropdown rows in #629, so worth a quick check rather than an assumption.
  2. Touch reveal (Minor, accept-per-issue). With no :hover on touch, the pencil only appears on focus. Resolved Decision 2 scoped this to laptop-primary transcribing and accepts native caret precision, so I'm not blocking — but for the record, a touch transcriber won't discover the pencil by hovering. If touch ever becomes a real path for the author flow, revisit with a tap-to-reveal affordance.

Both are quick. Fix #1 if the ratio misses; #2 is documented-and-accepted.

## 🎨 Leonie Voss — UX & Accessibility Lead **Verdict: ⚠️ Approved with concerns** The affordance respects the senior-first constraints I care about, and the a11y wiring is mostly textbook. Two things to verify/track before I'm fully happy. ### What's right - **44px tap target** on both the pencil and the × control (`h-11 w-11`), glyph kept small (~16px) — meets WCAG 2.2 AA target size while keeping the inline visual light. ✅ - **Keyboard reachability + visible focus**: pencil is `tabindex 0`, `focus-visible:ring-2 ring-brand-navy`; the × button has the same ring. Keyboard focus reveals the pencil (`group-focus-within`), so it's not pointer-only. ✅ - **Redundant cue**: the glyph itself is the non-colour cue. **`aria-label`** via the new i18n key on the icon-only button. ✅ - **Reduced motion**: reveal is `transition-none` — an instant opacity swap, no slide/fade. Exactly right, costs nothing. ✅ - **No reflow**: fixed-width slot reserves space; only opacity changes. The `pointer-events-none`-while-hidden fix also means the invisible target can't hijack a caret click. ✅ - **Live region**: editing context announced through the *single existing* `aria-live` region — no double-announce. ✅ ### Concerns 1. **Verify the pencil glyph contrast (WCAG 1.4.11 Non-Text Contrast, 3:1).** The glyph is `text-ink-2` on `bg-surface`. A meaningful icon needs ≥3:1. `ink-2` is mid-tone — please confirm the exact ratio against `surface`; if it's under 3:1, bump to `text-ink` (the token text already uses the darker ink). This is the same class of finding I raised on the dropdown rows in #629, so worth a quick check rather than an assumption. 2. **Touch reveal (Minor, accept-per-issue).** With no `:hover` on touch, the pencil only appears on focus. Resolved Decision 2 scoped this to laptop-primary transcribing and accepts native caret precision, so I'm not blocking — but for the record, a touch transcriber won't discover the pencil by hovering. If touch ever becomes a real path for the author flow, revisit with a tap-to-reveal affordance. Both are quick. Fix #1 if the ratio misses; #2 is documented-and-accepted.
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: Approved

Traceability is clean — every acceptance criterion maps to at least one named test, and the one deviation is surfaced, not buried.

AC → test traceability

AC Covered Note
AC-1 reveal (kbd + pointer) focusable + reveal-class tests
AC-2 open pre-filled at anchor pointer + keyboard
AC-3 relink in place, text invariant data-person-id swap + text unchanged
AC-4 dismiss (Esc + control), byte-identical both paths two tests
AC-5 edited-then-picked two single-assertion tests; matches the promotion in #628 comment-11033
AC-6 single dropdown, both orderings pencil→pencil, fresh-@↔pencil + #380 regression
AC-7 disabled inert static + mid-session-flip
AC-8 no pencil/dropdown without mention ⚠️ render-time covered; gap-click is structural-only (see below)
AC-9 reuses dropdown + kbd parity shared component + keyboard nav
NFRs (no-reflow / a11y announce / clip / provenance) each has a test

Open-decision register

  • OQ — justCommitted latch deliberately omitted. Correctly logged in the PR body with a sound rationale (the auto-open vector was removed by Resolved Decision 1). This is the right way to handle a spec instruction you're consciously not following — surfaced for the PO, reversible in one commit. No action needed unless the PO wants the belt-and-suspenders guard.

One requirements-completeness note (not a blocker)

AC-8's most important behaviour — "no dropdown when the caret lands between two adjacent mentions" — is currently proven only structurally (the pointer-events-none class), not by a real gap-click. The requirement is satisfied by the implementation, but the evidence is one level removed. Suggest a logged follow-up for a behavioural gap-click test (Sara flagged the same). Scope is otherwise tight — no creep, AC-9's keyboard parity is justified by AC-9 itself.

## 📋 Elicit — Requirements Engineer **Verdict: ✅ Approved** Traceability is clean — every acceptance criterion maps to at least one named test, and the one deviation is *surfaced*, not buried. ### AC → test traceability | AC | Covered | Note | |---|---|---| | AC-1 reveal (kbd + pointer) | ✅ | focusable + reveal-class tests | | AC-2 open pre-filled at anchor | ✅ | pointer + keyboard | | AC-3 relink in place, text invariant | ✅ | `data-person-id` swap + text unchanged | | AC-4 dismiss (Esc + control), byte-identical both paths | ✅ | two tests | | AC-5 edited-then-picked | ✅ | two single-assertion tests; matches the promotion in [#628 comment-11033](https://git.raddatz.cloud/marcel/familienarchiv/issues/628#issuecomment-11033) | | AC-6 single dropdown, both orderings | ✅ | pencil→pencil, fresh-@↔pencil + #380 regression | | AC-7 disabled inert | ✅ | static + mid-session-flip | | AC-8 no pencil/dropdown without mention | ⚠️ | render-time covered; **gap-click is structural-only** (see below) | | AC-9 reuses dropdown + kbd parity | ✅ | shared component + keyboard nav | | NFRs (no-reflow / a11y announce / clip / provenance) | ✅ | each has a test | ### Open-decision register - **OQ — `justCommitted` latch deliberately omitted.** Correctly logged in the PR body with a sound rationale (the auto-open vector was removed by Resolved Decision 1). This is the right way to handle a spec instruction you're consciously not following — surfaced for the PO, reversible in one commit. No action needed unless the PO wants the belt-and-suspenders guard. ### One requirements-completeness note (not a blocker) AC-8's most important behaviour — *"no dropdown when the caret lands between two adjacent mentions"* — is currently proven only structurally (the `pointer-events-none` class), not by a real gap-click. The requirement is satisfied by the implementation, but the *evidence* is one level removed. Suggest a logged follow-up for a behavioural gap-click test (Sara flagged the same). Scope is otherwise tight — no creep, AC-9's keyboard parity is justified by AC-9 itself.
Author
Owner

⚙️ Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved (nothing in my domain)

Checked for anything operational and found none — as expected for a frontend feature PR.

  • No docker-compose*.yml, Dockerfile, or service-topology changes.
  • No CI workflow changes; no action-version bumps; no new caches.
  • No new env vars or secrets; nothing hardcoded.
  • No image tags, ports, or volumes touched.
  • New i18n keys ride the existing Vite/Paraglide compile step — no pipeline change. The browser tests run on the existing --project=client lane.

LGTM from an infra standpoint. The only operational reminder is the one everyone's already noted: this lands on the client test lane in CI, so let that go green before merge.

## ⚙️ Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved (nothing in my domain)** Checked for anything operational and found none — as expected for a frontend feature PR. - No `docker-compose*.yml`, Dockerfile, or service-topology changes. - No CI workflow changes; no action-version bumps; no new caches. - No new env vars or secrets; nothing hardcoded. - No image tags, ports, or volumes touched. - New i18n keys ride the existing Vite/Paraglide compile step — no pipeline change. The browser tests run on the existing `--project=client` lane. LGTM from an infra standpoint. The only operational reminder is the one everyone's already noted: this lands on the **client test lane in CI**, so let that go green before merge.
marcel added 1 commit 2026-06-02 20:22:44 +02:00
test(frontend): exclude mentionNodeView from server coverage (#628)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m21s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m42s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
e6a0fbc915
CI's node coverage run (vite.config.ts, 'measure utility + server-side logic
only') counts every .ts under the include globs via all-files, but the Tiptap
NodeView builds live ProseMirror DOM and only runs in the browser editor — it is
exercised by the client project's browser tests, not the node run. Left in, it
showed 0% and dragged global functions (78.68%) and branches (78.48%) below the
80% gate.

Exclude it alongside the .svelte / browser-only UI files this config already
measures around. Restores the gate: statements 88.82%, branches 82.3%,
functions 87.27%, lines 89.77% (server project, verified locally).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author
Owner

CI: server coverage gate fixed (commit e6a0fbc9)

The "unit and component tests with coverage" step failed on the coverage threshold (functions 78.68%, branches 78.48% < 80%) — not on any test (785/785 passed).

Cause: mentionNodeView.ts is a Tiptap NodeView that builds live ProseMirror DOM — it only runs in the browser editor, so it's exercised by the client lane, not the node coverage run. But it's a plain .ts under the coverage include globs, so all-files counted it at 0% and dragged the global below 80%.

Fix: exclude it from the node coverage config, alongside the .svelte / browser-only UI files that config already measures around (it states it measures "utility and server-side logic only"). Verified locally on the server project:

Statements 88.82% · Branches 82.3% · Functions 87.27% · Lines 89.77%   (all ≥ 80%) — EXIT 0

Note on the RollupError: PARSE_ERROR pos 26 log: it's non-fatal (the build exits 0 with it present) and pre-existing — it comes from the v8 coverage provider raw-parsing an unrelated uncovered file (testHelpers.ts / mentionConstants.ts), not from this PR, and it was already in the original run. Left as-is (out of scope).

Still to watch: the client browser-test lane (the ~37 new PersonMentionEditor.svelte.spec.ts cases) runs in a separate step — that's the behavioural gate for this feature.

## CI: server coverage gate fixed (commit `e6a0fbc9`) The "unit and component tests with coverage" step failed on the **coverage threshold** (functions 78.68%, branches 78.48% < 80%) — **not** on any test (785/785 passed). **Cause:** `mentionNodeView.ts` is a Tiptap NodeView that builds live ProseMirror DOM — it only runs in the browser editor, so it's exercised by the **client** lane, not the node coverage run. But it's a plain `.ts` under the coverage `include` globs, so `all`-files counted it at 0% and dragged the global below 80%. **Fix:** exclude it from the node coverage config, alongside the `.svelte` / browser-only UI files that config already measures around (it states it measures "utility and server-side logic only"). Verified locally on the `server` project: ``` Statements 88.82% · Branches 82.3% · Functions 87.27% · Lines 89.77% (all ≥ 80%) — EXIT 0 ``` **Note on the `RollupError: PARSE_ERROR pos 26` log:** it's **non-fatal** (the build exits 0 with it present) and **pre-existing** — it comes from the v8 coverage provider raw-parsing an unrelated uncovered file (`testHelpers.ts` / `mentionConstants.ts`), not from this PR, and it was already in the original run. Left as-is (out of scope). Still to watch: the **client browser-test lane** (the ~37 new `PersonMentionEditor.svelte.spec.ts` cases) runs in a separate step — that's the behavioural gate for this feature.
marcel merged commit 7d37e610da into main 2026-06-03 07:55:30 +02:00
marcel deleted branch feat/issue-628-mention-reedit 2026-06-03 07:55:30 +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#717