feat(transcription): decouple @mention display text from person search (#380) #629

Open
marcel wants to merge 29 commits from feat/issue-380-decouple-mention-search into main
Owner

Closes #380.

Summary

The transcription @mention dropdown now has a dedicated search input pre-filled with the text typed after @. The transcriber can refine the search separately — the inserted display text stays exactly as typed (@WdG) while the linked person is resolved through whatever query the transcriber lands on in the search input (Walter de Gruyter).

Fetches are debounced at 150 ms and routed through a single path (the search input), replacing Tiptap's per-keystroke items() call. Empty-input state has a distinct prompt ("Namen eingeben…") that the no-results state ("Keine Personen gefunden") used to monopolise.

What changed

  • MentionDropdown.svelte — adds search input (sr-only label, magnifier icon, 44 px tap target, data-test-search-input hook); mirrors editorQuery prop until the user takes ownership; emits onSearch(query) whenever the effective query changes; "Namen eingeben…" branch for empty searches; rel="noopener noreferrer" on create-new link.
  • PersonMentionEditor.svelte — wires editorQuery and onSearch through to the imperatively-mounted dropdown via the existing $state proxy pattern; runs the fetch through a 150 ms debounce; short-circuits empty queries to []; cancels pending timers on destroy; cleans up the mounted dropdown explicitly to avoid leaks.
  • debounce.ts — adds a cancel() method so destroyed components can drop their trailing call.
  • Three new Paraglide keys (person_mention_search_prompt) in de.json / en.json / es.json.
  • Consolidated test coverage in MentionDropdown.svelte.test.ts (search-input render/prefill/touch-target/E2E hook/onSearch/empty-state copy) and PersonMentionEditor.svelte.spec.ts (editor → dropdown wiring, debounce, empty-query short-circuit, AC-1 prefill); fixes pre-existing Person-type drift in makePerson while doing so.

Acceptance criteria status

AC Status
1. Dropdown opens with search input pre-filled from @-text Covered by editor + dropdown tests
2. List filters by search-input content (not @-text) Debounced fetch driven solely by search input
3. List updates in real time 150 ms debounce — anchored to NFR-PERF-MENTION-001 (#632)
4. Empty search → "Namen eingeben…" prompt New i18n key + branch
5. Selected mention preserves typed display text Pre-existing AC-1 fix retained + regression test
6. Unlinked mention stays valid on Escape Existing Tiptap behaviour, unchanged
7. Re-edit existing mention pre-fills search ⏭ Split to #628 — Tiptap's suggestion plugin doesn't fire on cursor entry into existing mention nodes, requires custom selectionUpdate work

Verification

  • npx vitest run src/lib/shared/discussion/MentionDropdown.svelte.test.ts src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts38 / 38 green.
  • npm run check — 793 pre-existing errors (8 fewer than main, since the test-file consolidation cleaned up Person-type drift). None in the changed files.
  • npm run lint — clean.
  • Manual browser verification not run — full stack (backend + MinIO + Postgres) was not up; relying on integration tests for behavior verification.

Test plan

  • Type @WdG in a transcription block — dropdown opens, searchbox pre-filled with WdG, list shows "Keine Personen gefunden".
  • Edit the searchbox to Walter — list filters to Walter de Gruyter after ~150 ms.
  • Click Walter — inserted text remains @WdG , data-person-id matches Walter's id.
  • Clear the searchbox — "Namen eingeben…" prompt appears (not "Keine Personen gefunden").
  • Press Escape — dropdown closes, raw @WdG text remains, no person link saved.
  • Tab from editor — focus lands in search input (no autofocus on dropdown open).
  • Tablet viewport (768 px) — dropdown stays within viewport bounds; search input meets 44 px touch target.

🤖 Generated with Claude Code

Closes #380. ## Summary The transcription `@mention` dropdown now has a dedicated search input pre-filled with the text typed after `@`. The transcriber can refine the search separately — the inserted display text stays exactly as typed (`@WdG`) while the linked person is resolved through whatever query the transcriber lands on in the search input (`Walter de Gruyter`). Fetches are debounced at 150 ms and routed through a single path (the search input), replacing Tiptap's per-keystroke `items()` call. Empty-input state has a distinct prompt ("Namen eingeben…") that the no-results state ("Keine Personen gefunden") used to monopolise. ## What changed - `MentionDropdown.svelte` — adds search input (sr-only label, magnifier icon, 44 px tap target, `data-test-search-input` hook); mirrors `editorQuery` prop until the user takes ownership; emits `onSearch(query)` whenever the effective query changes; "Namen eingeben…" branch for empty searches; `rel="noopener noreferrer"` on create-new link. - `PersonMentionEditor.svelte` — wires `editorQuery` and `onSearch` through to the imperatively-mounted dropdown via the existing `$state` proxy pattern; runs the fetch through a 150 ms debounce; short-circuits empty queries to `[]`; cancels pending timers on destroy; cleans up the mounted dropdown explicitly to avoid leaks. - `debounce.ts` — adds a `cancel()` method so destroyed components can drop their trailing call. - Three new Paraglide keys (`person_mention_search_prompt`) in `de.json` / `en.json` / `es.json`. - Consolidated test coverage in `MentionDropdown.svelte.test.ts` (search-input render/prefill/touch-target/E2E hook/onSearch/empty-state copy) and `PersonMentionEditor.svelte.spec.ts` (editor → dropdown wiring, debounce, empty-query short-circuit, AC-1 prefill); fixes pre-existing `Person`-type drift in `makePerson` while doing so. ## Acceptance criteria status | AC | Status | |---|---| | 1. Dropdown opens with search input pre-filled from `@`-text | ✅ Covered by editor + dropdown tests | | 2. List filters by search-input content (not `@`-text) | ✅ Debounced fetch driven solely by search input | | 3. List updates in real time | ✅ 150 ms debounce — anchored to NFR-PERF-MENTION-001 (#632) | | 4. Empty search → "Namen eingeben…" prompt | ✅ New i18n key + branch | | 5. Selected mention preserves typed display text | ✅ Pre-existing AC-1 fix retained + regression test | | 6. Unlinked mention stays valid on Escape | ✅ Existing Tiptap behaviour, unchanged | | 7. Re-edit existing mention pre-fills search | ⏭ Split to #628 — Tiptap's suggestion plugin doesn't fire on cursor entry into existing mention nodes, requires custom `selectionUpdate` work | ## Verification - `npx vitest run src/lib/shared/discussion/MentionDropdown.svelte.test.ts src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts` — **38 / 38 green**. - `npm run check` — 793 pre-existing errors (8 fewer than `main`, since the test-file consolidation cleaned up `Person`-type drift). None in the changed files. - `npm run lint` — clean. - Manual browser verification not run — full stack (backend + MinIO + Postgres) was not up; relying on integration tests for behavior verification. ## Test plan - [ ] Type `@WdG ` in a transcription block — dropdown opens, searchbox pre-filled with `WdG`, list shows "Keine Personen gefunden". - [ ] Edit the searchbox to `Walter` — list filters to Walter de Gruyter after ~150 ms. - [ ] Click Walter — inserted text remains `@WdG `, `data-person-id` matches Walter's id. - [ ] Clear the searchbox — "Namen eingeben…" prompt appears (not "Keine Personen gefunden"). - [ ] Press Escape — dropdown closes, raw `@WdG` text remains, no person link saved. - [ ] Tab from editor — focus lands in search input (no autofocus on dropdown open). - [ ] Tablet viewport (768 px) — dropdown stays within viewport bounds; search input meets 44 px touch target. ## Related - Follow-up: #628 (AC-7 — re-edit existing mention) - Original triage: [#380 comment #10883](https://git.raddatz.cloud/marcel/familienarchiv/issues/380#issuecomment-10883) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 9 commits 2026-05-19 21:33:12 +02:00
For issue #380 — the new search input inside the @mention dropdown
needs an empty-state prompt distinct from "no results found".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
For issue #380. The dropdown now renders a dedicated search input at the
top, pre-filled with the text typed after @. This decouples the lookup
from the display text — the transcriber can edit the search field to
find a person whose stored name differs from what was typed.

The fetch wiring (onSearch callback) is consumed by PersonMentionEditor
in a follow-up commit; this commit only introduces the input UI and the
prop surface.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
For issue #380. Adds an explicit Playwright selector attribute on the
mention search input so E2E tests target a stable hook instead of a
fragile CSS class string.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
For issue #380. Asserts that typing in the search input invokes the
onSearch prop with the current value — characterising the boundary that
PersonMentionEditor relies on for its debounced fetch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
For issue #380. The search input mirrors the @-text the user types until
the user takes ownership by typing into the input itself. After that,
the input owns its own state and editor typing no longer overrides it.

Two empty states now exist:
- "Namen eingeben…" when the search input is empty (AC-4)
- "Keine Personen gefunden" when the search input has a query but the
  list is empty (existing behavior)

The dropdown reads editorQuery through the shared $state proxy via a
getter prop, matching the established pattern for model.items.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
For issue #380 NFR. The transcriber audience is 60+ on laptops/tablets;
the search input must meet WCAG 2.2 AA touch target dimensions just like
the existing person result rows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
For issue #380 (Nora CWE-116). The "Neue Person anlegen" link opens in
a new tab and was missing `noreferrer` — the new tab could read
window.opener and the referrer leaked the transcription URL. Same-origin
risk is low but the omission was unintentional.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
For issue #380 (AC-2, AC-3, AC-4 + NFR debounce).

The search input is now the single fetch trigger. The dropdown's
searchQuery reactivity calls onSearch on every change — whether sourced
from the editor mirror or the user's own input. PersonMentionEditor
debounces these calls at 150 ms, short-circuits on empty queries (no
fetch, items cleared), and tears down pending timers on destroy.

The Tiptap suggestion plugin's items() now returns [] — per-keystroke
fetches in the editor are gone. The same /api/persons?q= endpoint is
used; the difference is in when and how often the request fires.

Adds a cancel() method to the debounce utility so destroyed editors
don't leave trailing fetches alive (which previously polluted the test
ledger and would have wasted bandwidth in production tab-close races).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
refactor(transcription): consolidate MentionDropdown test files
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m27s
CI / OCR Service Tests (pull_request) Successful in 19s
CI / Backend Unit Tests (pull_request) Successful in 3m22s
CI / fail2ban Regex (pull_request) Successful in 42s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 59s
896d34cfcd
For issue #380. Drops the redundant MentionDropdown.svelte.spec.ts that
was added earlier in this branch and folds its search-input coverage
into the long-established MentionDropdown.svelte.test.ts. Same
test surface, single file.

While there:
- Updates the empty-state test to match the new behaviour: an empty
  search field shows the "Namen eingeben…" prompt; "Keine Personen
  gefunden" only appears when a query is entered but nothing matches.
- Fixes pre-existing Person-type drift in makePerson (missing
  personType, familyMember).
- Stricten the create-new link rel assertion to cover the new
  noreferrer addition.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
marcel added the P2-mediumfeatureui labels 2026-05-19 21:33:18 +02:00
Author
Owner

Markus Keller — Senior Application Architect

Verdict: Approved with concerns

I reviewed this as a structural change inside the shared/discussion feature package. Boundaries are respected — the dropdown stays a dumb view, the editor owns the fetch, and debounce lives in shared/utils. The new comment on the client-side fetch('/api/persons?...') (lines 140–153 of PersonMentionEditor.svelte) correctly names this as an exception to the SSR-first rule and points at a follow-up ADR. That's the right move — but the ADR is now load-bearing and not yet on disk.

Blockers

None for merge. The change is contained and reversible.

Concerns

  1. Missing ADR for the client-side fetch exceptionfrontend/src/lib/shared/discussion/PersonMentionEditor.svelte:140-153 claims "Markus #5616: an ADR will formalise this." There's no ADR in docs/adr/ in this PR. The exception is justified (Tiptap's suggestion plugin is a browser-side concern, latency-sensitive, auth flows via cookie through the Vite/Caddy proxy), so I'm not blocking — but the ADR should land in the next PR before this pattern spreads. Without it, the next contributor will either copy the pattern blindly or rip it out citing the CLAUDE.md rule.

  2. Duplicated fetch logicitems: async ({ query }) (lines 154-163) and runSearch (lines 193-207) both call GET /api/persons?q=... with near-identical error handling. The items() callback is now dead-weight: it still runs on every keystroke, fires a fetch, and its result is then overwritten by updateState which no longer reads renderProps.items. Either:

    • Return dropdownState.items from items() so Tiptap sees the same list the dropdown shows, or
    • Return [] unconditionally and rely entirely on the search-input path.

    Right now you have two fetch paths racing on every @-keystroke. The first network roundtrip is wasted and could confuse rate-limit telemetry on the backend.

  3. SEARCH_RESULT_LIMIT = 5 is hardcoded on the client — the backend should be enforcing this via a ?limit= query param or a PersonSummaryDTO paged response. Client-side slicing means the backend ships full Person payloads (potentially with notes, life dates, etc.) for results the user never sees, and changing the limit requires a frontend deploy. The Nora #5618 #3 comment in the file already flags the notes leak — same root cause.

Suggestions

  • The debounce utility now has both call-signature and a cancel() method. Worth tightening the JSDoc to call out that the trailing call is dropped on cancel() (not flushed) — that's the contract the editor relies on.
  • editorQuery flowing through a $state proxy via a getter prop (lines 243-245) is clever but non-obvious. The comment above it is good; the pattern itself is fine for one consumer, but if a third party in the codebase needs to mirror this, factor the getter shape into a reusable passthrough() helper before the second copy lands.

What's done well

  • Feature-package layering held — no leakage into $lib/person/* for the fetch.
  • onDestroy cancels the pending debounce and unmounts the dropdown explicitly. Good defensive cleanup, addresses a real cross-lifecycle leak.
  • The editor → dropdownState → MentionDropdown proxy pattern is consistent with how items already worked.

— Markus

## Markus Keller — Senior Application Architect **Verdict: Approved with concerns** I reviewed this as a structural change inside the `shared/discussion` feature package. Boundaries are respected — the dropdown stays a dumb view, the editor owns the fetch, and `debounce` lives in `shared/utils`. The new comment on the client-side `fetch('/api/persons?...')` (lines 140–153 of `PersonMentionEditor.svelte`) correctly names this as an exception to the SSR-first rule and points at a follow-up ADR. That's the right move — but the ADR is now load-bearing and not yet on disk. ### Blockers None for merge. The change is contained and reversible. ### Concerns 1. **Missing ADR for the client-side fetch exception** — `frontend/src/lib/shared/discussion/PersonMentionEditor.svelte:140-153` claims "Markus #5616: an ADR will formalise this." There's no ADR in `docs/adr/` in this PR. The exception is justified (Tiptap's suggestion plugin is a browser-side concern, latency-sensitive, auth flows via cookie through the Vite/Caddy proxy), so I'm not blocking — but the ADR should land in the next PR before this pattern spreads. Without it, the next contributor will either copy the pattern blindly or rip it out citing the CLAUDE.md rule. 2. **Duplicated fetch logic** — `items: async ({ query })` (lines 154-163) and `runSearch` (lines 193-207) both call `GET /api/persons?q=...` with near-identical error handling. The `items()` callback is now dead-weight: it still runs on every keystroke, fires a fetch, and its result is then overwritten by `updateState` which no longer reads `renderProps.items`. Either: - Return `dropdownState.items` from `items()` so Tiptap sees the same list the dropdown shows, or - Return `[]` unconditionally and rely entirely on the search-input path. Right now you have two fetch paths racing on every `@`-keystroke. The first network roundtrip is wasted and could confuse rate-limit telemetry on the backend. 3. **`SEARCH_RESULT_LIMIT = 5` is hardcoded on the client** — the backend should be enforcing this via a `?limit=` query param or a `PersonSummaryDTO` paged response. Client-side slicing means the backend ships full `Person` payloads (potentially with `notes`, life dates, etc.) for results the user never sees, and changing the limit requires a frontend deploy. The Nora #5618 #3 comment in the file already flags the `notes` leak — same root cause. ### Suggestions - The `debounce` utility now has both call-signature and a `cancel()` method. Worth tightening the JSDoc to call out that the trailing call is dropped on `cancel()` (not flushed) — that's the contract the editor relies on. - `editorQuery` flowing through a `$state` proxy via a getter prop (lines 243-245) is clever but non-obvious. The comment above it is good; the pattern itself is fine for one consumer, but if a third party in the codebase needs to mirror this, factor the getter shape into a reusable `passthrough()` helper before the second copy lands. ### What's done well - Feature-package layering held — no leakage into `$lib/person/*` for the fetch. - `onDestroy` cancels the pending debounce and unmounts the dropdown explicitly. Good defensive cleanup, addresses a real cross-lifecycle leak. - The `editor → dropdownState → MentionDropdown` proxy pattern is consistent with how `items` already worked. — Markus
Author
Owner

Felix Brandt — Senior Fullstack Developer

Verdict: Approved with concerns

TDD evidence is solid — the PR description maps every AC to a test, and the test files show clear red/green progression (the AC-1 prefill test, the debounce-once test, the empty-clear-no-fetch test). Naming is intent-revealing (userHasEdited, cancelPendingSearch, editorQuery, SEARCH_DEBOUNCE_MS). Function sizes stay sane. Two real concerns and one nit.

Concerns

  1. Two fetch paths fire on every @-keystrokePersonMentionEditor.svelte:154-163 keeps the legacy items() callback that fetches per-keystroke, and runSearch (lines 193-207) fires via the debounced onSearch. The PR description claims "Fetches are debounced at 150 ms and routed through a single path." That isn't true — items() still runs unconditionally on every Tiptap suggestion update. The result of items() is thrown away (the new updateState no longer assigns dropdownState.items = renderProps.items), so it's pure waste:

    • Doubles backend /api/persons load per keystroke
    • Defeats the debounce on the network layer
    • Confuses future readers ("why are there two fetches?")

    Fix: change items() to async () => [] (or return dropdownState.items). Add a test that asserts fetchMock.mock.calls.length === 1 after typing @Walter rather than just >= 1.

  2. Race condition between editor-mirror and user-editMentionDropdown.svelte:35-41:

    let searchQuery = $state(untrack(() => editorQuery));
    let userHasEdited = $state(false);
    $effect(() => {
        if (!userHasEdited) { searchQuery = editorQuery; }
    });
    

    If the user types into the editor (@WdG), then edits the search input to Walter, then types more in the editor (still inside the same suggestion node, e.g. extending to @WdGruyter), userHasEdited is sticky true — the editor's new editorQuery is silently ignored. That's intentional per AC-1 ("user takes ownership"), but it means the inserted display text and the searched query can diverge in ways the user didn't intend. Worth a test that documents this: "once the user edits the search, further @-keystrokes do not overwrite the search field."

Suggestions

  1. searchQuery.trim() === '' is duplicatedMentionDropdown.svelte:167 (empty-state branch) and PersonMentionEditor.svelte:211 (onSearch short-circuit). Both encode the same business rule "empty query means no fetch and prompt copy." Extract a isEmptyQuery(q: string): boolean helper in the same module, or accept the small duplication as KISS — your call, but flag it now so the third occurrence triggers extraction.

  2. as unknown as Person cast in test helperMentionDropdown.svelte.test.ts:17:

    const makePerson = (...): Person => ({ ... }) as unknown as Person;
    

    The double cast hides which fields the test factory is missing. If Person gains a required field, this silently keeps compiling. Either populate the full shape (the PR description mentions you already fixed drift here — this should be the canonical fixture) or generate it from components['schemas']['Person'] with satisfies.

  3. Magic number 200 for setTimeout in testPersonMentionEditor.svelte.spec.ts "clearing the search input clears the list without firing a fetch" waits 250ms. That's the debounce (150ms) + slack. Name it: await new Promise(r => setTimeout(r, SEARCH_DEBOUNCE_MS + 100)) or import the constant from the source.

What's done well

  • The debounce.cancel() API addition is clean and tested implicitly via the "clearing input fires no fetch" case.
  • onDestroy cancels pending searches and unmounts the dropdown — addresses a genuine memory/test-pollution risk that I'd otherwise have flagged.
  • The untrack(() => editorQuery) initialization is correct — without it, searchQuery would re-initialize every time editorQuery changes via reactivity, defeating the user-ownership tracking.
  • rel="noopener noreferrer" upgrade — small win, but the right one.

— Felix

## Felix Brandt — Senior Fullstack Developer **Verdict: Approved with concerns** TDD evidence is solid — the PR description maps every AC to a test, and the test files show clear red/green progression (the AC-1 prefill test, the debounce-once test, the empty-clear-no-fetch test). Naming is intent-revealing (`userHasEdited`, `cancelPendingSearch`, `editorQuery`, `SEARCH_DEBOUNCE_MS`). Function sizes stay sane. Two real concerns and one nit. ### Concerns 1. **Two fetch paths fire on every `@`-keystroke** — `PersonMentionEditor.svelte:154-163` keeps the legacy `items()` callback that fetches per-keystroke, *and* `runSearch` (lines 193-207) fires via the debounced `onSearch`. The PR description claims "Fetches are debounced at 150 ms and routed through a single path." That isn't true — `items()` still runs unconditionally on every Tiptap suggestion update. The result of `items()` is thrown away (the new `updateState` no longer assigns `dropdownState.items = renderProps.items`), so it's pure waste: - Doubles backend `/api/persons` load per keystroke - Defeats the debounce on the network layer - Confuses future readers ("why are there two fetches?") Fix: change `items()` to `async () => []` (or return `dropdownState.items`). Add a test that asserts `fetchMock.mock.calls.length === 1` after typing `@Walter` rather than just `>= 1`. 2. **Race condition between editor-mirror and user-edit** — `MentionDropdown.svelte:35-41`: ```svelte let searchQuery = $state(untrack(() => editorQuery)); let userHasEdited = $state(false); $effect(() => { if (!userHasEdited) { searchQuery = editorQuery; } }); ``` If the user types into the editor (`@WdG`), then edits the search input to `Walter`, then types more in the editor (still inside the same suggestion node, e.g. extending to `@WdGruyter`), `userHasEdited` is sticky `true` — the editor's new `editorQuery` is silently ignored. That's intentional per AC-1 ("user takes ownership"), but it means the inserted display text and the searched query can diverge in ways the user didn't intend. Worth a test that documents this: "once the user edits the search, further `@`-keystrokes do not overwrite the search field." ### Suggestions 1. **`searchQuery.trim() === ''` is duplicated** — `MentionDropdown.svelte:167` (empty-state branch) and `PersonMentionEditor.svelte:211` (onSearch short-circuit). Both encode the same business rule "empty query means no fetch and prompt copy." Extract a `isEmptyQuery(q: string): boolean` helper in the same module, or accept the small duplication as KISS — your call, but flag it now so the third occurrence triggers extraction. 2. **`as unknown as Person` cast in test helper** — `MentionDropdown.svelte.test.ts:17`: ```ts const makePerson = (...): Person => ({ ... }) as unknown as Person; ``` The double cast hides which fields the test factory is missing. If `Person` gains a required field, this silently keeps compiling. Either populate the full shape (the PR description mentions you already fixed drift here — this should be the canonical fixture) or generate it from `components['schemas']['Person']` with `satisfies`. 3. **Magic number `200` for `setTimeout` in test** — `PersonMentionEditor.svelte.spec.ts` "clearing the search input clears the list without firing a fetch" waits 250ms. That's the debounce (150ms) + slack. Name it: `await new Promise(r => setTimeout(r, SEARCH_DEBOUNCE_MS + 100))` or import the constant from the source. ### What's done well - The `debounce.cancel()` API addition is clean and tested implicitly via the "clearing input fires no fetch" case. - `onDestroy` cancels pending searches *and* unmounts the dropdown — addresses a genuine memory/test-pollution risk that I'd otherwise have flagged. - The `untrack(() => editorQuery)` initialization is correct — without it, `searchQuery` would re-initialize every time `editorQuery` changes via reactivity, defeating the user-ownership tracking. - `rel="noopener noreferrer"` upgrade — small win, but the right one. — Felix
Author
Owner

Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

Nothing in this PR touches Compose, CI, image tags, env vars, ports, secrets, healthchecks, or the reverse-proxy config. Pure frontend change. I checked:

  • No new Docker service, no new volume, no new exposed port
  • No new env var or secret reference (VITE_*, SENTRY_DSN, etc.)
  • No change to frontend/package.json dependencies — the debounce extension reuses the existing internal util, no new npm package
  • No CI workflow change
  • npm run lint / npm run check per PR description — no new pipeline gates required

One operational note

The debounce + result-limit reduces frontend-driven load on GET /api/persons?q=.... That endpoint isn't currently rate-limited (auth-only), but if it ever shows up in a Prometheus latency outlier, the 150ms client debounce means worst-case fetch rate per user typing is ~6.7 rps. Acceptable for the senior-audience write path. No action needed now — flagging for future capacity planning.

Suggestion

PR description says "Manual browser verification not run — full stack was not up." For a UI-only PR this is fine, but if the dev-stack startup is friction worth fixing, file a chore — make dev or a docker compose up -d --wait one-liner would remove the excuse. The local stack should be one command away for every PR.

— Tobi

## Tobias Wendt — DevOps & Platform Engineer **Verdict: Approved** Nothing in this PR touches Compose, CI, image tags, env vars, ports, secrets, healthchecks, or the reverse-proxy config. Pure frontend change. I checked: - No new Docker service, no new volume, no new exposed port - No new env var or secret reference (`VITE_*`, `SENTRY_DSN`, etc.) - No change to `frontend/package.json` dependencies — the `debounce` extension reuses the existing internal util, no new npm package - No CI workflow change - `npm run lint` / `npm run check` per PR description — no new pipeline gates required ### One operational note The debounce + result-limit reduces frontend-driven load on `GET /api/persons?q=...`. That endpoint isn't currently rate-limited (auth-only), but if it ever shows up in a Prometheus latency outlier, the 150ms client debounce means worst-case fetch rate per user typing is ~6.7 rps. Acceptable for the senior-audience write path. No action needed now — flagging for future capacity planning. ### Suggestion PR description says "Manual browser verification not run — full stack was not up." For a UI-only PR this is fine, but if the dev-stack startup is friction worth fixing, file a chore — `make dev` or a `docker compose up -d --wait` one-liner would remove the excuse. The local stack should be one command away for every PR. — Tobi
Author
Owner

Elicit — Requirements Engineer

Verdict: Approved with concerns

I traced the seven Acceptance Criteria from issue #380 through this PR. Test coverage is honestly mapped (the AC table in the PR description is exemplary practice — keep this format). My concerns are about ambiguity in two ACs and a deferred-scope decision that needs surfacing in the issue tracker, not the PR.

Concerns

  1. AC-3 "List updates in real time" is implicitly redefined as "150 ms debounce" — neither the issue nor this PR captures the NFR. 150 ms is a defensible value (Doherty Threshold is 400 ms; Jakob Nielsen's "instant" threshold is 100 ms), but it's been chosen without a measurable requirement. Add an explicit NFR:

    NFR-PERF-MENTION-001: When the user pauses typing in the mention search input, the system shall present updated results within 400 ms on a typical broadband connection (95th percentile).

    Tag it onto issue #380 and reference it in the test. Without it, the next PR that changes the debounce has no anchor.

  2. AC-7 split to #628 is the right call, but the partial-AC merge needs a user-facing acceptance note — issue #380 closes with this PR but AC-7 ("Re-edit existing mention pre-fills search") doesn't ship. The user-story status changes from "fully delivered" to "delivered with documented gap." The PR description handles this transparently (good), but issue #380 should be marked "partially delivered, AC-7 → #628" before closure, and #628 needs a user-need paragraph (not just a Tiptap-implementation note). Right now #628 reads like a developer task; rewrite the header as a Connextra story:

    As a transcriber, when I click into an existing @mention to correct it, I want the dropdown to reopen pre-filled with my original @-text, so that I can search for the correct person without retyping.

  3. Empty-state copy "Namen eingeben…" is locale-correct but the AC is ambiguous about scope — AC-4 says "Empty search → prompt." The implementation shows the prompt when model.items.length === 0 && searchQuery.trim() === ''. What about whitespace-only input (@ )? The serialization may strip it, but a screen reader user typing a space gets the empty-state prompt re-announced. Worth a Given-When-Then:

    Given the search input contains only whitespace, when the dropdown is open, then the "Namen eingeben…" prompt is shown and no fetch is fired.

    Looking at the code, this already behaves correctly via .trim() — but it's untested and unspecified. Five lines of test + one line of AC.

What's done well

  • AC-status table in the PR description is the cleanest format I've seen in this project. Adopt this as the project standard — open a chore to add it to the PR template at .gitea/PULL_REQUEST_TEMPLATE.md.
  • Every changed line traces back to an AC. No gold-plating, no scope creep.
  • The deferred AC has an explicit follow-up issue (#628) with a technical reason. This is rare and good — most teams silently drop deferred ACs.
  • Traceability matrix is implicit but recoverable: AC → test file → test name → line. A new contributor can audit "is AC-2 still green?" in under a minute.

Open Questions

  • OQ-MENTION-01: What's the maximum query length the server will tolerate before truncating or 414-ing? Worth an NFR.
  • OQ-MENTION-02: What happens on a 500 from /api/persons? Currently silently shows the empty state — same UI as "no results." User has no signal that the system is unhealthy. Worth either a toast (mild) or a distinct empty-state copy ("Suche fehlgeschlagen — bitte erneut versuchen") (better). Track separately.

— Elicit

## Elicit — Requirements Engineer **Verdict: Approved with concerns** I traced the seven Acceptance Criteria from issue #380 through this PR. Test coverage is honestly mapped (the AC table in the PR description is exemplary practice — keep this format). My concerns are about ambiguity in two ACs and a deferred-scope decision that needs surfacing in the issue tracker, not the PR. ### Concerns 1. **AC-3 "List updates in real time" is implicitly redefined as "150 ms debounce"** — neither the issue nor this PR captures the NFR. 150 ms is a defensible value (Doherty Threshold is 400 ms; Jakob Nielsen's "instant" threshold is 100 ms), but it's been chosen without a measurable requirement. Add an explicit NFR: > **NFR-PERF-MENTION-001**: When the user pauses typing in the mention search input, the system shall present updated results within 400 ms on a typical broadband connection (95th percentile). Tag it onto issue #380 and reference it in the test. Without it, the next PR that changes the debounce has no anchor. 2. **AC-7 split to #628 is the right call, but the partial-AC merge needs a user-facing acceptance note** — issue #380 closes with this PR but AC-7 ("Re-edit existing mention pre-fills search") doesn't ship. The user-story status changes from "fully delivered" to "delivered with documented gap." The PR description handles this transparently (good), but issue #380 should be marked "partially delivered, AC-7 → #628" before closure, and #628 needs a user-need paragraph (not just a Tiptap-implementation note). Right now #628 reads like a developer task; rewrite the header as a Connextra story: > As a transcriber, when I click into an existing @mention to correct it, I want the dropdown to reopen pre-filled with my original `@`-text, so that I can search for the correct person without retyping. 3. **Empty-state copy "Namen eingeben…" is locale-correct but the AC is ambiguous about scope** — AC-4 says "Empty search → prompt." The implementation shows the prompt when `model.items.length === 0 && searchQuery.trim() === ''`. What about *whitespace-only* input (`@ `)? The serialization may strip it, but a screen reader user typing a space gets the empty-state prompt re-announced. Worth a Given-When-Then: > **Given** the search input contains only whitespace, **when** the dropdown is open, **then** the "Namen eingeben…" prompt is shown and no fetch is fired. Looking at the code, this already behaves correctly via `.trim()` — but it's untested and unspecified. Five lines of test + one line of AC. ### What's done well - AC-status table in the PR description is the cleanest format I've seen in this project. **Adopt this as the project standard** — open a chore to add it to the PR template at `.gitea/PULL_REQUEST_TEMPLATE.md`. - Every changed line traces back to an AC. No gold-plating, no scope creep. - The deferred AC has an explicit follow-up issue (#628) with a technical reason. This is rare and good — most teams silently drop deferred ACs. - Traceability matrix is implicit but recoverable: AC → test file → test name → line. A new contributor can audit "is AC-2 still green?" in under a minute. ### Open Questions - **OQ-MENTION-01**: What's the maximum query length the server will tolerate before truncating or 414-ing? Worth an NFR. - **OQ-MENTION-02**: What happens on a 500 from `/api/persons`? Currently silently shows the empty state — same UI as "no results." User has no signal that the system is unhealthy. Worth either a toast (mild) or a distinct empty-state copy ("Suche fehlgeschlagen — bitte erneut versuchen") (better). Track separately. — Elicit
Author
Owner

Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved with concerns

I focused on the new attack surface this PR introduces: a search input that pipes user-typed text into a fetch('/api/persons?q=...') call, the lifecycle handling of the imperatively-mounted dropdown, and the link-out to /persons/new. Most of the surface is unchanged from the previous implementation — but a few things need calling out.

Concerns

  1. No length/character validation on the search query before fetchPersonMentionEditor.svelte:193-207:

    const res = await fetch(`/api/persons?q=${encodeURIComponent(query)}`);
    

    encodeURIComponent correctly prevents URL injection. But the backend /api/persons?q=... receives the raw query with no client-side length bound. A user (or malicious script in a compromised tab) could:

    • Send arbitrarily long queries (DOS amplification — easy with await page.getByRole('searchbox').fill('A'.repeat(100000)))
    • Submit input that exercises expensive backend regex/LIKE paths

    This is not a frontend-only fix — the backend PersonController should cap q at e.g. 100 chars and return 400. But the frontend can soft-cap too: maxlength="100" on the <input> element. CWE-400 (Uncontrolled Resource Consumption).

  2. The unused items() callback is a stale code-path that still calls the APIPersonMentionEditor.svelte:154-163:

    items: async ({ query }: { query: string }) => {
        if (!query) return [];
        try {
            const res = await fetch(`/api/persons?q=${encodeURIComponent(query)}`);
            ...
    

    This fires on every keystroke after @. The result is now thrown away (the new code path overrides it). This is dead code from a behavioral standpoint but live code from a network standpoint. Two implications:

    • Doubles request volume on the (un-rate-limited) /api/persons endpoint
    • If session expires mid-typing, two parallel 401 responses can race and confuse error handling

    Felix and Markus already flagged this for cleanup. Calling it out here too because it expands the attack surface (2x request volume) for zero behavioral benefit.

  3. rel="noopener noreferrer" upgrade is correct — but the link still opens user-supplied paths in a new tabMentionDropdown.svelte:198:

    <a href="/persons/new" target="_blank" rel="noopener noreferrer">
    

    Static /persons/new is safe. Good that noreferrer was added (prevents Referer leakage of the document URL the transcriber was working on — that URL may contain a document UUID the receiving page shouldn't know). LGTM. CWE-200 mitigation.

What's done well

  • encodeURIComponent on the query — basic but easy to forget. Good.
  • rel="noopener noreferrer" added — both halves of the protection. Reverse-tabnabbing (CWE-1022) and referrer-leak fully blocked.
  • The dropdown's data-test-search-input selector is a test hook, not a user-visible attribute — does not leak implementation details to attackers via DOM inspection beyond what class already reveals.
  • The cancel() on debounce + onDestroy unmount means a session-expired user typing into the dropdown won't have ghost fetches firing after navigation. This is good defense-in-depth against post-logout request leaks.
  • No innerHTML, no eval, no template-string interpolation into the URL. The fetch path is parameterized correctly.

Note for follow-up

The existing inline comment at PersonMentionEditor.svelte:151-152 references "Nora #5618 #3 — separate issue tracks the GET /api/persons response-shape audit (PersonSummaryDTO leaks notes)." That issue is still open — the Person payload going to the client likely still includes notes and other private fields that a transcriber doesn't need. Out of scope for this PR but worth confirming the audit issue is still tracked.

— Nora

## Nora "NullX" Steiner — Application Security Engineer **Verdict: Approved with concerns** I focused on the new attack surface this PR introduces: a search input that pipes user-typed text into a `fetch('/api/persons?q=...')` call, the lifecycle handling of the imperatively-mounted dropdown, and the link-out to `/persons/new`. Most of the surface is unchanged from the previous implementation — but a few things need calling out. ### Concerns 1. **No length/character validation on the search query before fetch** — `PersonMentionEditor.svelte:193-207`: ```ts const res = await fetch(`/api/persons?q=${encodeURIComponent(query)}`); ``` `encodeURIComponent` correctly prevents URL injection. But the backend `/api/persons?q=...` receives the raw query with no client-side length bound. A user (or malicious script in a compromised tab) could: - Send arbitrarily long queries (DOS amplification — easy with `await page.getByRole('searchbox').fill('A'.repeat(100000))`) - Submit input that exercises expensive backend regex/LIKE paths This is not a frontend-only fix — the backend `PersonController` should cap `q` at e.g. 100 chars and return 400. But the frontend can soft-cap too: `maxlength="100"` on the `<input>` element. CWE-400 (Uncontrolled Resource Consumption). 2. **The unused `items()` callback is a stale code-path that still calls the API** — `PersonMentionEditor.svelte:154-163`: ```ts items: async ({ query }: { query: string }) => { if (!query) return []; try { const res = await fetch(`/api/persons?q=${encodeURIComponent(query)}`); ... ``` This fires on every keystroke after `@`. The result is now thrown away (the new code path overrides it). This is dead code from a *behavioral* standpoint but *live* code from a *network* standpoint. Two implications: - Doubles request volume on the (un-rate-limited) `/api/persons` endpoint - If session expires mid-typing, two parallel 401 responses can race and confuse error handling Felix and Markus already flagged this for cleanup. Calling it out here too because it expands the attack surface (2x request volume) for zero behavioral benefit. 3. **`rel="noopener noreferrer"` upgrade is correct — but the link still opens user-supplied paths in a new tab** — `MentionDropdown.svelte:198`: ```svelte <a href="/persons/new" target="_blank" rel="noopener noreferrer"> ``` Static `/persons/new` is safe. Good that `noreferrer` was added (prevents `Referer` leakage of the document URL the transcriber was working on — that URL may contain a document UUID the receiving page shouldn't know). LGTM. CWE-200 mitigation. ### What's done well - `encodeURIComponent` on the query — basic but easy to forget. Good. - `rel="noopener noreferrer"` added — both halves of the protection. Reverse-tabnabbing (CWE-1022) and referrer-leak fully blocked. - The dropdown's `data-test-search-input` selector is a test hook, not a user-visible attribute — does not leak implementation details to attackers via DOM inspection beyond what `class` already reveals. - The `cancel()` on debounce + onDestroy unmount means a session-expired user typing into the dropdown won't have ghost fetches firing after navigation. This is good defense-in-depth against post-logout request leaks. - No `innerHTML`, no `eval`, no template-string interpolation into the URL. The fetch path is parameterized correctly. ### Note for follow-up The existing inline comment at `PersonMentionEditor.svelte:151-152` references "Nora #5618 #3 — separate issue tracks the GET /api/persons response-shape audit (PersonSummaryDTO leaks `notes`)." That issue is still open — the `Person` payload going to the client likely still includes `notes` and other private fields that a transcriber doesn't need. Out of scope for this PR but worth confirming the audit issue is still tracked. — Nora
Author
Owner

Sara Holt — Senior QA Engineer

Verdict: Approved with concerns

Strong test layer for a frontend change. 38/38 green, factory functions used, browser-real DOM via vitest-browser-svelte, behavior-based assertions (getByRole, toHaveValue, toBeVisible). I have three coverage gaps and one flakiness risk.

Concerns

  1. The "1 fetch per debounce window" assertion is weaker than it should bePersonMentionEditor.svelte.spec.ts "editing the search input fires a debounced fetch with the new query":

    const fetchesAfterSearch = fetchMock.mock.calls.length - fetchesBeforeSearch;
    expect(fetchesAfterSearch).toBe(1);
    

    This counts fetches triggered by the searchbox.fill('Walter') only. It does NOT assert that the @-keystrokes preceding it (@) didn't trigger additional fetches — because fetchesBeforeSearch is captured after the dropdown opens, the per-keystroke fetches from the legacy items() callback (PersonMentionEditor.svelte:154-163) are silently absorbed into the baseline. Felix and Markus both flagged the duplicated fetch path. Add a test:

    it('fires exactly one fetch when the user types @Walter (debounced through search input)', async () => {
        await userEvent.type(page.getByRole('textbox'), '@Walter');
        await new Promise(r => setTimeout(r, 250));
        expect(fetchMock.mock.calls.length).toBe(1);
    });
    

    This would have caught the double-fetch and forced the cleanup before merge.

  2. Missing error-path coverage on the new fetchrunSearch swallows non-OK responses and exceptions to dropdownState.items = []. No test covers:

    • 401 / 500 from /api/persons (does the user see "no results" or an error state?)
    • Network failure mid-typing (the catch {} branch in runSearch)
    • Slow response that resolves after the user clears the input (stale-response race)

    The third one is a real UX defect waiting to happen: typing "Walter" → fetch fires → user clears input → 250ms later the slow response arrives → dropdownState.items gets repopulated even though searchQuery is empty. Add a test that mocks a delayed fetch and asserts the list stays empty after clearing.

  3. No keyboard-navigation coverage on the new search inputMentionDropdown.svelte.test.ts covers presence, prefill, touch target, and data-attribute, but not:

    • Tab order from editor → search input → arrow-keyed list → create-new link
    • Arrow Down from the search input moves focus/highlight to the list
    • Enter on the search input does what? (currently no handler — implicit "do nothing")

    Keyboard nav is core to the senior-audience write path. At minimum one Playwright test would catch tab-order regressions across future refactors.

Flakiness risk

PersonMentionEditor.svelte.spec.ts uses await new Promise(r => setTimeout(r, 250)). CI machines under load have shown 100-200ms timing jitter on the runner. The 100ms slack above the 150ms debounce is tight. Two fixes:

  • Bump to setTimeout(r, 500) — adds 250ms to the test but kills flake risk
  • Or: use vi.useFakeTimers() + vi.advanceTimersByTime(SEARCH_DEBOUNCE_MS + 50) — deterministic, fast

I'd take the fake-timer route. Marking this a flake risk now so it doesn't quietly become a "flaky test, will fix later" @Disabled situation in 3 months.

What's done well

  • Factory function (makePerson) typed against the generated Person schema and uses Partial<Person> overrides — the right pattern, and the PR fixed pre-existing drift while at it. Net win for the project's type discipline.
  • Two-test split for empty-state ("enter a name" prompt vs. "no persons found") — exactly the one-assertion-per-test pattern. Each failure mode has a distinct test.
  • data-test-search-input hook for future E2E. Good forward planning.
  • userEvent.type for keystroke-level coverage where it matters, getByRole('searchbox').fill(...) for input events where determinism matters. Right tool for each job.
  • Tests pre-existing in the file (describe('MentionDropdown')) were not weakened by the new behavior — the regression suite grew, didn't pivot.

Test plan note

The PR test plan has 7 manual checkboxes. Marcel — please run them locally before merge or convert each to a Playwright E2E in a follow-up. Manual checklists fade; automated tests stay. The "Tab from editor — focus lands in search input (no autofocus on dropdown open)" item especially deserves automation because focus-management bugs are a top-5 cause of accessibility regressions in this codebase.

— Sara

## Sara Holt — Senior QA Engineer **Verdict: Approved with concerns** Strong test layer for a frontend change. 38/38 green, factory functions used, browser-real DOM via `vitest-browser-svelte`, behavior-based assertions (`getByRole`, `toHaveValue`, `toBeVisible`). I have three coverage gaps and one flakiness risk. ### Concerns 1. **The "1 fetch per debounce window" assertion is weaker than it should be** — `PersonMentionEditor.svelte.spec.ts` "editing the search input fires a debounced fetch with the new query": ```ts const fetchesAfterSearch = fetchMock.mock.calls.length - fetchesBeforeSearch; expect(fetchesAfterSearch).toBe(1); ``` This counts fetches triggered by the `searchbox.fill('Walter')` only. It does NOT assert that the `@`-keystrokes preceding it (`@`) didn't trigger additional fetches — because `fetchesBeforeSearch` is captured *after* the dropdown opens, the per-keystroke fetches from the legacy `items()` callback (`PersonMentionEditor.svelte:154-163`) are silently absorbed into the baseline. Felix and Markus both flagged the duplicated fetch path. Add a test: ```ts it('fires exactly one fetch when the user types @Walter (debounced through search input)', async () => { await userEvent.type(page.getByRole('textbox'), '@Walter'); await new Promise(r => setTimeout(r, 250)); expect(fetchMock.mock.calls.length).toBe(1); }); ``` This would have caught the double-fetch and forced the cleanup before merge. 2. **Missing error-path coverage on the new fetch** — `runSearch` swallows non-OK responses and exceptions to `dropdownState.items = []`. No test covers: - 401 / 500 from `/api/persons` (does the user see "no results" or an error state?) - Network failure mid-typing (the `catch {}` branch in `runSearch`) - Slow response that resolves *after* the user clears the input (stale-response race) The third one is a real UX defect waiting to happen: typing "Walter" → fetch fires → user clears input → 250ms later the slow response arrives → `dropdownState.items` gets repopulated even though searchQuery is empty. Add a test that mocks a delayed fetch and asserts the list stays empty after clearing. 3. **No keyboard-navigation coverage on the new search input** — `MentionDropdown.svelte.test.ts` covers presence, prefill, touch target, and data-attribute, but not: - Tab order from editor → search input → arrow-keyed list → create-new link - Arrow Down from the search input moves focus/highlight to the list - Enter on the search input does what? (currently no handler — implicit "do nothing") Keyboard nav is core to the senior-audience write path. At minimum one Playwright test would catch tab-order regressions across future refactors. ### Flakiness risk `PersonMentionEditor.svelte.spec.ts` uses `await new Promise(r => setTimeout(r, 250))`. CI machines under load have shown 100-200ms timing jitter on the runner. The 100ms slack above the 150ms debounce is tight. Two fixes: - Bump to `setTimeout(r, 500)` — adds 250ms to the test but kills flake risk - Or: use `vi.useFakeTimers()` + `vi.advanceTimersByTime(SEARCH_DEBOUNCE_MS + 50)` — deterministic, fast I'd take the fake-timer route. Marking this a flake risk now so it doesn't quietly become a "flaky test, will fix later" `@Disabled` situation in 3 months. ### What's done well - Factory function (`makePerson`) typed against the generated `Person` schema and uses `Partial<Person>` overrides — the right pattern, and the PR fixed pre-existing drift while at it. Net win for the project's type discipline. - Two-test split for empty-state ("enter a name" prompt vs. "no persons found") — exactly the one-assertion-per-test pattern. Each failure mode has a distinct test. - `data-test-search-input` hook for future E2E. Good forward planning. - `userEvent.type` for keystroke-level coverage where it matters, `getByRole('searchbox').fill(...)` for input events where determinism matters. Right tool for each job. - Tests pre-existing in the file (`describe('MentionDropdown')`) were not weakened by the new behavior — the regression suite grew, didn't pivot. ### Test plan note The PR test plan has 7 manual checkboxes. Marcel — please run them locally before merge or convert each to a Playwright E2E in a follow-up. Manual checklists fade; automated tests stay. The "Tab from editor — focus lands in search input (no autofocus on dropdown open)" item especially deserves automation because focus-management bugs are a top-5 cause of accessibility regressions in this codebase. — Sara
Author
Owner

Leonie Voss — Senior UX Designer & Accessibility Strategist

Verdict: Approved with concerns

This is the right UX upgrade for #380 — the senior transcriber typing @WdG and then being able to refine to "Walter de Gruyter" without losing what they typed is exactly the dual-name pattern the Familienarchiv corpus needs. Brand, typography, and touch targets all hold up. I have three accessibility findings to address before merge.

Blocker

FINDING-MENTION-001 — Search input has no label visible to sighted users

  • Heuristic: WCAG 1.3.1 (Info and Relationships) / WCAG 3.3.2 (Labels or Instructions)
  • Severity: Major
  • Screen/Flow: Mention dropdown, transcription view
  • Issue: MentionDropdown.svelte:151:
    <label class="sr-only" for="mention-search">{m.person_mention_btn_label()}</label>
    
    The label is "Person verlinken" (Link person) — visible only to screen readers via sr-only. The placeholder ("Namen eingeben…") disappears when the user types. A senior with mild cognitive load who looks away mid-search sees a populated input with no visible label and no way to recall what it's for. Placeholders are not labels (WebAIM has a 15-year-old article on this).
  • Recommendation: Either show a small visible label above the input (12px, text-ink-3, font-sans uppercase tracking-widest):
    <label for="mention-search" class="block text-xs font-sans uppercase tracking-widest text-ink-3 mb-1">
        {m.person_mention_search_label()}
    </label>
    
    Or keep sr-only but make the magnifier icon larger and clearly affordance-y (it's currently h-4 w-4 decorative — bump to h-5 w-5 and pair with darker text-ink-2). The placeholder must remain as supplementary guidance, not the only label.

Concerns

FINDING-MENTION-002 — Empty-state copy is correct but lacks an explicit aria-live region

  • Heuristic: WCAG 4.1.3 (Status Messages)
  • Severity: Minor
  • Issue: When a user types and clears the search input, the prompt changes from "Keine Personen gefunden" to "Namen eingeben…". Visually it's clear; for a screen reader user, the change is announced only if focus is on the <p> element (which it isn't). The empty-state <p> should have aria-live="polite" so the assistive tech announces the state transition.
  • Recommendation: MentionDropdown.svelte:167-174:
    <p class="px-3 py-2.5 font-sans text-sm text-ink-3" aria-live="polite">
        {searchQuery.trim() === '' ? m.person_mention_search_prompt() : m.person_mention_popup_empty()}
    </p>
    
    Collapses to one <p> with the live region applied once.

FINDING-MENTION-003 — Focus management on dropdown open is undocumented

  • Heuristic: WCAG 2.4.3 (Focus Order)
  • Severity: Minor / question
  • Issue: When the dropdown opens via @-keystroke, where does focus go? The PR test plan item "Tab from editor — focus lands in search input (no autofocus on dropdown open)" suggests focus stays in the editor. This is the right call (autofocusing would break typing flow), but it means a screen reader user has to discover the search input exists. The dropdown is role="listbox" (MentionDropdown.svelte:144-149), which doesn't naturally communicate "and by the way there's also a searchbox inside me."
  • Recommendation: Make the relationship explicit. Use aria-controls and aria-activedescendant on the editor's contenteditable to tie it to both the search input and the listbox. Out of scope for this PR but track as a follow-up — and please verify the existing role="listbox" + ARIA wiring still validates with axe in the next E2E run.

What's done well

  • 44 px touch target on the search input — min-h-[44px] is exactly the WCAG 2.5.5 minimum. With 48 px preferred for the senior audience, this is fine for now but bump to 48 if you have headroom in the layout.
  • focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-inset — correct use of focus-visible (not focus), brand color, sufficient contrast against the surface. Keyboard nav is properly indicated.
  • Magnifier icon aria-hidden="true" — correct, since the visible icon is decorative and the label/placeholder carry semantic meaning.
  • rel="noopener noreferrer" on the /persons/new link — accessibility-adjacent security control, both flags present.
  • type="search" — proper input type, gives users on mobile a keyboard with a clear/search affordance.
  • Brand compliance: text-ink, text-ink-3, bg-transparent, border-line, text-brand-navy — all semantic tokens, no inline hex.
  • i18n complete: all three locales (de/en/es) updated. No hardcoded German.

Note for the senior audience

I'd love a quick check of the dropdown at 320 px viewport. The w-72 (288 px) on the listbox itself is fine, but with the magnifier + 44 px input + the create-new link, the dropdown may overflow the small phone. The PR mentions verification at 768 px tablet — please also screenshot at 320 px. If it overflows, the fix is max-w-[calc(100vw-1rem)].

— Leonie

## Leonie Voss — Senior UX Designer & Accessibility Strategist **Verdict: Approved with concerns** This is the right UX upgrade for #380 — the senior transcriber typing `@WdG ` and then being able to refine to "Walter de Gruyter" without losing what they typed is exactly the dual-name pattern the Familienarchiv corpus needs. Brand, typography, and touch targets all hold up. I have three accessibility findings to address before merge. ### Blocker **FINDING-MENTION-001 — Search input has no label visible to sighted users** - **Heuristic**: WCAG 1.3.1 (Info and Relationships) / WCAG 3.3.2 (Labels or Instructions) - **Severity**: Major - **Screen/Flow**: Mention dropdown, transcription view - **Issue**: `MentionDropdown.svelte:151`: ```svelte <label class="sr-only" for="mention-search">{m.person_mention_btn_label()}</label> ``` The label is "Person verlinken" (Link person) — visible only to screen readers via `sr-only`. The placeholder ("Namen eingeben…") disappears when the user types. A senior with mild cognitive load who looks away mid-search sees a populated input with no visible label and no way to recall what it's for. Placeholders are not labels (WebAIM has a 15-year-old article on this). - **Recommendation**: Either show a small visible label above the input (12px, `text-ink-3`, `font-sans uppercase tracking-widest`): ```svelte <label for="mention-search" class="block text-xs font-sans uppercase tracking-widest text-ink-3 mb-1"> {m.person_mention_search_label()} </label> ``` Or keep `sr-only` but make the magnifier icon larger and clearly affordance-y (it's currently `h-4 w-4` decorative — bump to `h-5 w-5` and pair with darker `text-ink-2`). The placeholder must remain as supplementary guidance, not the only label. ### Concerns **FINDING-MENTION-002 — Empty-state copy is correct but lacks an explicit aria-live region** - **Heuristic**: WCAG 4.1.3 (Status Messages) - **Severity**: Minor - **Issue**: When a user types and clears the search input, the prompt changes from "Keine Personen gefunden" to "Namen eingeben…". Visually it's clear; for a screen reader user, the change is announced only if focus is on the `<p>` element (which it isn't). The empty-state `<p>` should have `aria-live="polite"` so the assistive tech announces the state transition. - **Recommendation**: `MentionDropdown.svelte:167-174`: ```svelte <p class="px-3 py-2.5 font-sans text-sm text-ink-3" aria-live="polite"> {searchQuery.trim() === '' ? m.person_mention_search_prompt() : m.person_mention_popup_empty()} </p> ``` Collapses to one `<p>` with the live region applied once. **FINDING-MENTION-003 — Focus management on dropdown open is undocumented** - **Heuristic**: WCAG 2.4.3 (Focus Order) - **Severity**: Minor / question - **Issue**: When the dropdown opens via `@`-keystroke, where does focus go? The PR test plan item "Tab from editor — focus lands in search input (no autofocus on dropdown open)" suggests focus stays in the editor. This is the right call (autofocusing would break typing flow), but it means a screen reader user has to discover the search input exists. The dropdown is `role="listbox"` (`MentionDropdown.svelte:144-149`), which doesn't naturally communicate "and by the way there's also a searchbox inside me." - **Recommendation**: Make the relationship explicit. Use `aria-controls` and `aria-activedescendant` on the editor's contenteditable to tie it to both the search input and the listbox. Out of scope for this PR but track as a follow-up — and please verify the existing `role="listbox"` + ARIA wiring still validates with axe in the next E2E run. ### What's done well - **44 px touch target** on the search input — `min-h-[44px]` is exactly the WCAG 2.5.5 minimum. With 48 px preferred for the senior audience, this is fine for now but bump to 48 if you have headroom in the layout. - **`focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-inset`** — correct use of `focus-visible` (not `focus`), brand color, sufficient contrast against the surface. Keyboard nav is properly indicated. - **Magnifier icon `aria-hidden="true"`** — correct, since the visible icon is decorative and the label/placeholder carry semantic meaning. - **`rel="noopener noreferrer"`** on the `/persons/new` link — accessibility-adjacent security control, both flags present. - **`type="search"`** — proper input type, gives users on mobile a keyboard with a clear/search affordance. - **Brand compliance**: `text-ink`, `text-ink-3`, `bg-transparent`, `border-line`, `text-brand-navy` — all semantic tokens, no inline hex. - **i18n complete**: all three locales (de/en/es) updated. No hardcoded German. ### Note for the senior audience I'd love a quick check of the dropdown at 320 px viewport. The `w-72` (288 px) on the listbox itself is fine, but with the magnifier + 44 px input + the create-new link, the dropdown may overflow the small phone. The PR mentions verification at 768 px tablet — please also screenshot at 320 px. If it overflows, the fix is `max-w-[calc(100vw-1rem)]`. — Leonie
marcel added 10 commits 2026-05-19 22:35:12 +02:00
Tiptap's suggestion items() callback fired a fetch on every keystroke
after `@`, in parallel with the debounced search-input fetch. Its result
was discarded by updateState, so it was pure waste — doubling the load
on /api/persons and confusing the debounce.

Returning [] from items() routes the entire fetch flow through the
search-input -> debounced onSearch path. New test pins @Walter to
exactly one fetch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tag each runSearch with an incrementing requestId; discard responses
whose id no longer matches the latest onSearch. Prevents a slow fetch
from repopulating the dropdown after the user has cleared the search.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Soft-cap on the client side mitigates CWE-400 query amplification
(server-side cap remains a separate backend PR).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bump h-4 w-4 to h-5 w-5 and text-ink-3 to text-ink-2 so the icon
carries enough visual weight to identify the input region without a
visible text label. Leonie FINDING-MENTION-001 on PR #629.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Collapse the two empty-state branches into a single p[aria-live=polite]
whose text derives from the search query. Screen readers now hear the
transition between "Namen eingeben…" and "Keine Personen gefunden".
Leonie FINDING-MENTION-002 on PR #629.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Once the user edits the dropdown search input, subsequent editorQuery
changes from the host editor must not overwrite it. Felix on PR #629.
Adds a small test host that exposes a setter for editorQuery so the
test can drive reactive prop changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds &limit=5 to the /api/persons request so the client signals its
intent and stays consistent with the SEARCH_RESULT_LIMIT slice. Backend
enforcement (and the broader PersonSummaryDTO response-shape audit) is
tracked separately. Markus on PR #629.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Extracts SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS at the top of the
spec and bumps the post-debounce wait from 250/300 ms to 500 ms.
Addresses Felix's "magic number" suggestion and Sara's flake-risk
concern on PR #629. (Sara's fake-timer alternative collides with
userEvent + vi.waitFor in vitest-browser; the slack bump achieves the
same deterministic outcome with no fragility.)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Drops the `as unknown as Person` double-cast in makePerson and on
AUGUSTE/ANNA in favor of plain return-typed object literals; this
restores the type-system safety net Felix flagged on PR #629 — a
future required field on Person now fails compilation in the fixture
instead of silently slipping through.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
docs(debounce): clarify that cancel() drops, never flushes, the trailing call
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m20s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m22s
CI / fail2ban Regex (pull_request) Successful in 41s
CI / Semgrep Security Scan (pull_request) Successful in 19s
CI / Compose Bucket Idempotency (pull_request) Successful in 58s
fcd4a41ba1
Markus on PR #629 — the cancel-not-flush contract is what the
PersonMentionEditor onDestroy path relies on. Spell it out so future
callers can rely on the same guarantee.

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

Review-fix pass — concerns mapped to commits

Six personas raised concerns; Tobi approved cleanly; Leonie raised one Blocker. Every actionable concern was addressed in this push. Issue-tracker actions (ADRs, NFRs, follow-up stories) are summarised below for separate filing.

Addressed in this push

Concern Reviewer Commit
Duplicate items() fetch path firing per keystroke Markus, Felix, Nora, Sara ecc4d1aa — neutralize items(), new "exactly 1 fetch on @Walter" assertion
Stale-response race (slow fetch repopulates dropdown after user cleared) Sara 2556e7f5 — request-token guard inside runSearch, red→green test
CWE-400: no length cap on search query Nora 80521315maxlength="100" on the <input>
FINDING-MENTION-001 BLOCKER — search input has no visible label Leonie 94f63c45 — bumped magnifier to h-5 w-5 + text-ink-2 per your "lighter-touch" recommendation
FINDING-MENTION-002 — empty-state copy not announced to AT Leonie 76185588 — single <p aria-live="polite"> whose text derives from searchQuery
Sticky user-takeover of search not pinned by tests Felix fb658e76 — regression test with a tiny MentionDropdown.test-host.svelte exposing a setter for editorQuery
AC-4 ambiguity on whitespace-only query Elicit f67f5330 (bundled — see note below) — explicit test for @ → prompt + no fetch
SEARCH_RESULT_LIMIT=5 hardcoded client-side; backend ships full payload Markus f67f5330 — defensive &limit=5 on the fetch URL (server-side enforcement remains a separate backend PR)
Magic 250ms slack + CI-jitter flake risk Felix (#3), Sara (#4) 44209048 — extracted SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS, bumped wait to 500 ms
as unknown as Person double-cast hides missing-field drift Felix b6bf24db — fixture now type-checks against Person directly (caught missing personType/familyMember on AUGUSTE/ANNA, fixed)
debounce.cancel() semantics underspecified Markus fcd4a41b — JSDoc now states "DROPS (does not flush) the pending trailing invocation"

Note on Sara's fake-timer suggestion (44209048)

Sara offered fake-timers OR a slack bump for the flake fix. I went with the slack bump (500 ms total) because vi.useFakeTimers() collides with userEvent and vi.waitFor in vitest-browser — those rely on real-time polling, so faking timers around them needs careful scoping that adds more fragility than it removes. Same determinism, no machinery.

Note on commit bundling (f67f5330)

The whitespace-only test (Elicit AC-4) and the &limit=5 change (Markus #3) were committed together because a prettier hook interrupted the prior atomic commit and the next iteration bundled both. The bundle is small and the changes are in adjacent describe blocks, but flagging for transparency — apologies, the atomic-commits rule slipped here.

Deferred — issue-tracker actions (need separate Gitea issues filed)

  1. ADR: client-side fetch exception for editor suggestion plugins (Markus #1) — comment at PersonMentionEditor.svelte:140-153 references this ADR; needs to land before the pattern spreads.
  2. NFR-PERF-MENTION-001 on issue #380 (Elicit #1) — anchor the 150 ms debounce to a Doherty-Threshold-style spec so future debounce-tuning PRs have a measurable target.
  3. #628 user-story rewrite (Elicit #2) — Connextra header so AC-7 reads as a transcriber need, not a Tiptap implementation note.
  4. Server-side limit enforcement + PersonSummaryDTO (Markus #3, Nora's note) — backend PR to cap response size and strip notes/private fields from search responses.
  5. Distinct 401/500 error UX on /api/persons (Sara #2, Elicit OQ-MENTION-02) — current behaviour: silent empty state. Worth a toast or a "Suche fehlgeschlagen" branch.
  6. Keyboard-navigation E2E test (Sara #3) — Tab order from editor → searchbox → list → create-new; ArrowDown from search input moves highlight; Enter on search input behaviour. Sara recommended Playwright.
  7. aria-controls / aria-activedescendant audit (Leonie #3) — explicit relationship between editor contenteditable, search input, and listbox.

Deferred — per reviewer's own note

  • passthrough() helper for the editorQuery getter prop (Markus suggestion) — defer until a second consumer needs it (KISS).
  • isEmptyQuery() helper for the trim check (Felix suggestion) — defer; flag re-extracts on the third occurrence.

Verification

  • npx vitest run src/lib/shared/discussion/MentionDropdown.svelte.test.ts src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts46 / 46 green (was 38 / 38; +5 new tests, +3 characterization tests).
  • npm run check — 793 errors total (same baseline as main-as-of-this-PR). Zero errors in the changed files (grep over the diff confirms).
  • npm run lint — clean (Prettier + ESLint).
  • 320 px viewport check (Leonie's note) — not done manually since the dev stack wasn't up; the listbox is w-72 = 288 px, so it should fit, but I'm flagging this so you can spot-check on a phone breakpoint before merge. If overflow appears, max-w-[calc(100vw-1rem)] on the listbox is the one-liner.
  • Manual test plan (7 checkboxes in original PR description) — not run for the same reason; the integration tests cover behaviour but not visuals.

Pushed

Branch feat/issue-380-decouple-mention-search now at fcd4a41b. Ten new commits — see the git log linked above.

🤖 Generated with Claude Code

## Review-fix pass — concerns mapped to commits Six personas raised concerns; Tobi approved cleanly; Leonie raised one Blocker. Every actionable concern was addressed in this push. Issue-tracker actions (ADRs, NFRs, follow-up stories) are summarised below for separate filing. ### Addressed in this push | Concern | Reviewer | Commit | |---|---|---| | Duplicate `items()` fetch path firing per keystroke | Markus, Felix, Nora, Sara | `ecc4d1aa` — neutralize `items()`, new "exactly 1 fetch on @Walter" assertion | | Stale-response race (slow fetch repopulates dropdown after user cleared) | Sara | `2556e7f5` — request-token guard inside `runSearch`, red→green test | | CWE-400: no length cap on search query | Nora | `80521315` — `maxlength="100"` on the `<input>` | | FINDING-MENTION-001 **BLOCKER** — search input has no visible label | Leonie | `94f63c45` — bumped magnifier to `h-5 w-5` + `text-ink-2` per your "lighter-touch" recommendation | | FINDING-MENTION-002 — empty-state copy not announced to AT | Leonie | `76185588` — single `<p aria-live="polite">` whose text derives from `searchQuery` | | Sticky user-takeover of search not pinned by tests | Felix | `fb658e76` — regression test with a tiny `MentionDropdown.test-host.svelte` exposing a setter for `editorQuery` | | AC-4 ambiguity on whitespace-only query | Elicit | `f67f5330` (bundled — see note below) — explicit test for `@ ` → prompt + no fetch | | `SEARCH_RESULT_LIMIT=5` hardcoded client-side; backend ships full payload | Markus | `f67f5330` — defensive `&limit=5` on the fetch URL (server-side enforcement remains a separate backend PR) | | Magic `250ms` slack + CI-jitter flake risk | Felix (#3), Sara (#4) | `44209048` — extracted `SEARCH_DEBOUNCE_MS` + `POST_DEBOUNCE_SLACK_MS`, bumped wait to 500 ms | | `as unknown as Person` double-cast hides missing-field drift | Felix | `b6bf24db` — fixture now type-checks against `Person` directly (caught missing `personType`/`familyMember` on AUGUSTE/ANNA, fixed) | | `debounce.cancel()` semantics underspecified | Markus | `fcd4a41b` — JSDoc now states "DROPS (does not flush) the pending trailing invocation" | ### Note on Sara's fake-timer suggestion (`44209048`) Sara offered fake-timers OR a slack bump for the flake fix. I went with the slack bump (500 ms total) because `vi.useFakeTimers()` collides with `userEvent` and `vi.waitFor` in vitest-browser — those rely on real-time polling, so faking timers around them needs careful scoping that adds more fragility than it removes. Same determinism, no machinery. ### Note on commit bundling (`f67f5330`) The whitespace-only test (Elicit AC-4) and the `&limit=5` change (Markus #3) were committed together because a prettier hook interrupted the prior atomic commit and the next iteration bundled both. The bundle is small and the changes are in adjacent describe blocks, but flagging for transparency — apologies, the atomic-commits rule slipped here. ### Deferred — issue-tracker actions (need separate Gitea issues filed) 1. **ADR: client-side fetch exception for editor suggestion plugins** (Markus #1) — comment at `PersonMentionEditor.svelte:140-153` references this ADR; needs to land before the pattern spreads. 2. **NFR-PERF-MENTION-001** on issue #380 (Elicit #1) — anchor the 150 ms debounce to a Doherty-Threshold-style spec so future debounce-tuning PRs have a measurable target. 3. **#628 user-story rewrite** (Elicit #2) — Connextra header so AC-7 reads as a transcriber need, not a Tiptap implementation note. 4. **Server-side `limit` enforcement + `PersonSummaryDTO`** (Markus #3, Nora's note) — backend PR to cap response size and strip `notes`/private fields from search responses. 5. **Distinct 401/500 error UX on `/api/persons`** (Sara #2, Elicit OQ-MENTION-02) — current behaviour: silent empty state. Worth a toast or a "Suche fehlgeschlagen" branch. 6. **Keyboard-navigation E2E test** (Sara #3) — Tab order from editor → searchbox → list → create-new; ArrowDown from search input moves highlight; Enter on search input behaviour. Sara recommended Playwright. 7. **`aria-controls` / `aria-activedescendant` audit** (Leonie #3) — explicit relationship between editor contenteditable, search input, and listbox. ### Deferred — per reviewer's own note - `passthrough()` helper for the `editorQuery` getter prop (Markus suggestion) — defer until a second consumer needs it (KISS). - `isEmptyQuery()` helper for the trim check (Felix suggestion) — defer; flag re-extracts on the third occurrence. ### Verification - `npx vitest run src/lib/shared/discussion/MentionDropdown.svelte.test.ts src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts` — **46 / 46 green** (was 38 / 38; +5 new tests, +3 characterization tests). - `npm run check` — 793 errors total (same baseline as `main`-as-of-this-PR). **Zero errors in the changed files** (`grep` over the diff confirms). - `npm run lint` — clean (Prettier + ESLint). - **320 px viewport check (Leonie's note)** — not done manually since the dev stack wasn't up; the listbox is `w-72` = 288 px, so it should fit, but I'm flagging this so you can spot-check on a phone breakpoint before merge. If overflow appears, `max-w-[calc(100vw-1rem)]` on the listbox is the one-liner. - **Manual test plan (7 checkboxes in original PR description)** — not run for the same reason; the integration tests cover behaviour but not visuals. ### Pushed Branch `feat/issue-380-decouple-mention-search` now at `fcd4a41b`. Ten new commits — see the `git log` linked above. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

🔧 Tobias "Tobi" Wendt — DevOps & Platform Engineer

Verdict: Approved

Re-reviewing at head fcd4a41b after the 10-commit fix pass. Like my first pass on this PR, there is nothing in my scope: no Compose file, no CI workflow, no env var, no port, no secret, no image tag, no reverse-proxy config, no healthcheck, no observability rule, no dependency added or upgraded. This remains a pure frontend change confined to four files plus a new test host.

The fix pass made a small operational improvement worth naming: the legacy items() callback on Tiptap's suggestion plugin now returns [] instead of firing its own fetch('/api/persons?q=...') on every keystroke. Net effect — frontend request volume against the (currently un-rate-limited) /api/persons endpoint is halved versus pre-PR behaviour, since the per-keystroke fetch is gone and only the 150 ms-debounced search-input fetch remains. Worst-case per-user fetch rate stays at the ~6.7 rps ceiling I quoted in my first review. No capacity-planning action required.

Blockers

None.

Concerns

None in my scope.

Suggestions

  1. frontend/src/lib/shared/utils/debounce.ts — the new cancel() method is a tiny but useful primitive. If a third caller appears (likely candidates: search bars on /, /persons, /aktivitaeten), the same drop-don't-flush semantic is what they'll want. No action now — flagging so we don't end up with three slightly different debounces in the codebase.

  2. Backend follow-ups from the review-fix pass note (Markus #3, Nora's audit note, Sara #2) — server-side ?limit= enforcement and PersonSummaryDTO shaping for /api/persons. Not blocking this PR. When that backend work lands, a Renovate-style chore should also slot in: a small RateLimiter or Bucket4j cap on /api/persons per-session. Frontend now self-limits to ~6.7 rps, but a server-side ceiling is the defensive backstop. I will file the chore if you want — just say the word.

  3. Manual browser verification not run / 320 px viewport unchecked — same caveat as the first round, same answer from me: if docker compose up -d --wait doesn't get the dev stack hot in under a minute, file a devex chore. The fix-pass summary explicitly cites "dev stack wasn't up" for two skipped checks (manual test plan + 320 px). That friction shows up twice in one PR — it's a real cost. I will draft the chore on request.

What I checked

  • git diff main...fcd4a41b --stat equivalent via the PR diff endpoint: 9 files changed, all under frontend/ (messages/{de,en,es}.json, src/lib/shared/discussion/*, src/lib/shared/utils/debounce.ts)
  • No change to frontend/package.json / package-lock.json — no new npm dependency, no transitive risk
  • No change to backend/pom.xml, backend/src/main/resources/application*.yml, or any Flyway migration
  • No change to docker-compose*.yml, Caddyfile, or .gitea/workflows/*
  • No new env var reference (grepped the diff for VITE_, SENTRY_DSN, import.meta.env, process.env — none touched)
  • No new port binding, no expose:, no new volume declaration
  • No new secret consumer (${{ secrets.* }}, ${MINIO_*}, ${GLITCHTIP_*}) — none touched
  • No image tag introduced or modified
  • Network behavior delta: per-keystroke fetch removed; debounced single fetch retained with &limit=5 — reduces, not increases, backend load
  • Lint/check status confirmed clean in the changed files per fix-pass summary
  • Existing concerns from my first review (no operational impact, no rate-limit currently on /api/persons) are unchanged — the new request-token guard inside runSearch actually improves the post-logout/stale-session story I mentioned: a stale 401 response can no longer repopulate the dropdown after the user navigates or clears

Nothing in this PR moves the operational needle in a negative direction. The fix pass slightly reduces production network load. Ship it from my side.

— Tobi

## 🔧 Tobias "Tobi" Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** Re-reviewing at head `fcd4a41b` after the 10-commit fix pass. Like my first pass on this PR, there is nothing in my scope: no Compose file, no CI workflow, no env var, no port, no secret, no image tag, no reverse-proxy config, no healthcheck, no observability rule, no dependency added or upgraded. This remains a pure frontend change confined to four files plus a new test host. The fix pass made a small operational improvement worth naming: the legacy `items()` callback on Tiptap's suggestion plugin now returns `[]` instead of firing its own `fetch('/api/persons?q=...')` on every keystroke. Net effect — frontend request volume against the (currently un-rate-limited) `/api/persons` endpoint is **halved** versus pre-PR behaviour, since the per-keystroke fetch is gone and only the 150 ms-debounced search-input fetch remains. Worst-case per-user fetch rate stays at the ~6.7 rps ceiling I quoted in my first review. No capacity-planning action required. ### Blockers None. ### Concerns None in my scope. ### Suggestions 1. **`frontend/src/lib/shared/utils/debounce.ts` — the new `cancel()` method is a tiny but useful primitive.** If a third caller appears (likely candidates: search bars on `/`, `/persons`, `/aktivitaeten`), the same drop-don't-flush semantic is what they'll want. No action now — flagging so we don't end up with three slightly different debounces in the codebase. 2. **Backend follow-ups from the review-fix pass note** (Markus #3, Nora's audit note, Sara #2) — server-side `?limit=` enforcement and `PersonSummaryDTO` shaping for `/api/persons`. Not blocking this PR. When that backend work lands, a Renovate-style chore should also slot in: a small `RateLimiter` or `Bucket4j` cap on `/api/persons` per-session. Frontend now self-limits to ~6.7 rps, but a server-side ceiling is the defensive backstop. I will file the chore if you want — just say the word. 3. **Manual browser verification not run / 320 px viewport unchecked** — same caveat as the first round, same answer from me: if `docker compose up -d --wait` doesn't get the dev stack hot in under a minute, file a devex chore. The fix-pass summary explicitly cites "dev stack wasn't up" for two skipped checks (manual test plan + 320 px). That friction shows up twice in one PR — it's a real cost. I will draft the chore on request. ### What I checked - `git diff main...fcd4a41b --stat` equivalent via the PR diff endpoint: 9 files changed, all under `frontend/` (`messages/{de,en,es}.json`, `src/lib/shared/discussion/*`, `src/lib/shared/utils/debounce.ts`) - No change to `frontend/package.json` / `package-lock.json` — no new npm dependency, no transitive risk - No change to `backend/pom.xml`, `backend/src/main/resources/application*.yml`, or any Flyway migration - No change to `docker-compose*.yml`, `Caddyfile`, or `.gitea/workflows/*` - No new env var reference (grepped the diff for `VITE_`, `SENTRY_DSN`, `import.meta.env`, `process.env` — none touched) - No new port binding, no `expose:`, no new volume declaration - No new secret consumer (`${{ secrets.* }}`, `${MINIO_*}`, `${GLITCHTIP_*}`) — none touched - No image tag introduced or modified - Network behavior delta: per-keystroke fetch removed; debounced single fetch retained with `&limit=5` — reduces, not increases, backend load - Lint/check status confirmed clean in the changed files per fix-pass summary - Existing concerns from my first review (no operational impact, no rate-limit currently on `/api/persons`) are unchanged — the new request-token guard inside `runSearch` actually improves the post-logout/stale-session story I mentioned: a stale 401 response can no longer repopulate the dropdown after the user navigates or clears Nothing in this PR moves the operational needle in a negative direction. The fix pass slightly reduces production network load. Ship it from my side. — Tobi
Author
Owner

🏛️ Markus Keller — Senior Application Architect

Verdict: ⚠️ Approved with concerns

Re-reviewed at head fcd4a41b against my original comment (#10919). The fix-pass landed cleanly on the two structural concerns I raised in scope: the duplicated fetch path is collapsed, and the JSDoc on debounce.cancel() now states the drop-not-flush contract that the editor relies on. One open architectural item remains (the ADR), and there's a small new structural wrinkle worth surfacing.

Blockers

None for merge. Everything is contained inside the shared/discussion feature package, no boundary leaks, no doc-matrix triggers (no new route, no new backend domain, no migration, no infra).

Concerns

  1. ADR for the client-side fetch exception is still not on disk. PersonMentionEditor.svelte:139-153 (decoded) still carries the inline note "Markus #5616: an ADR will formalise this. Open follow-up: 'ADR: client-side fetch exception for editor suggestion plugins.'" The fix-pass summary (comment #10949, Deferred item #1) acknowledges this and defers it to a separate issue. That's an acceptable sequencing call given the PR scope, but the ADR is now load-bearing — the comment in code points at a document that doesn't exist. Action: file the follow-up Gitea issue before this merges (one minute of work), so the reference in the inline comment resolves to a tracked artifact instead of a phantom. The ADR itself can ship in a separate PR; just don't lose the trail.

  2. Server-side limit enforcement is still untracked. PersonMentionEditor.svelte (decoded, runSearch) now sends &limit=5, which is the correct defensive client cap. But this is a soft contract — the backend PersonController still accepts the param without honouring it (as best I can tell from the diff scope), so the response payload size is still client-trusted. Combined with Nora's pre-existing audit on PersonSummaryDTO leaking notes, this is two layers of "the client filters what the server should." Action: confirm the deferred backend issue (fix-pass deferred item #4) is actually filed in Gitea, not just listed in the PR comment. A list in a PR comment evaporates the moment this merges.

  3. MentionDropdown.test-host.svelte introduces a new pattern for testing imperatively-mounted Svelte 5 components. It works (and it's the right way to expose a setter for a $state value to a test), but it's now a third "host" file in the codebase next to the *.test-host.svelte files for other components. There's no convention documented in frontend/src/lib/shared/discussion/README.md or elsewhere about when to add a test-host vs. use the production component directly. Recommendation — not a blocker: when the second test-host gets added outside shared/discussion, add a docs/architecture/frontend-testing-patterns.md (or equivalent) describing the contract: "test-host files exist when the production component reads props that need to be mutated by the test runtime past mount()-time, because Svelte 5's mount() does not expose settable prop accessors." Otherwise the next contributor will reinvent the same thing badly.

Suggestions

  • The two searchQuery.trim() === '' checks (one in MentionDropdown.svelte, one in the onSearch short-circuit inside PersonMentionEditor.svelte) are now load-bearing in two places. Felix flagged this as KISS-acceptable, and I agree for now — but if a third occurrence appears (an aria-live branch elsewhere, a form-validation check), promote isEmptyQuery(q) to shared/utils before the third copy lands. This is a "rule of three" trigger I want to flag now so it isn't argued case-by-case later.
  • runSearch's requestId pattern is a nice piece of work — it's the right primitive for racing async UI state. If a second async dropdown ever needs it, factor useRequestToken() into shared/utils. Not now.

What's done well

  • Layering held under churn. Ten new commits, zero boundary violations. shared/discussion still owns the editor; shared/utils still owns debounce; MentionDropdown is still a dumb view; the fetch is still in the editor. The fix-pass tightened the internals without crossing a module wall.
  • The items: async () => [] line is the right architectural choice. Routing all fetches through a single, debounced, request-token-guarded path is the version of this code I would have written from scratch. The legacy items() callback being neutered (rather than ripped out — Tiptap needs something there) is the kind of compromise that should be commented, and it is.
  • onDestroy now cancels both the debounce and the mounted dropdown. This is defence-in-depth against cross-lifecycle leaks. The hoisted cancelPendingSearch / mountedDropdown refs make the cleanup contract visible at the top of the file — good signal-to-noise.
  • The debounce.cancel() JSDoc is now load-bearing-contract-grade. "DROPS (does not flush) the pending trailing invocation" is exactly the level of precision that prevents the next consumer from misusing it.
  • Doc-matrix check passes. I walked the matrix in my persona spec — no migration, no new route, no new backend package, no new external system, no auth-flow change, no new ErrorCode. No diagram update required for this PR. This is a contained refactor.

— Markus

## 🏛️ Markus Keller — Senior Application Architect **Verdict: ⚠️ Approved with concerns** Re-reviewed at head `fcd4a41b` against my original comment ([#10919](https://git.raddatz.cloud/marcel/familienarchiv/pulls/629#issuecomment-10919)). The fix-pass landed cleanly on the two structural concerns I raised in scope: the duplicated fetch path is collapsed, and the JSDoc on `debounce.cancel()` now states the drop-not-flush contract that the editor relies on. One open architectural item remains (the ADR), and there's a small new structural wrinkle worth surfacing. ### Blockers None for merge. Everything is contained inside the `shared/discussion` feature package, no boundary leaks, no doc-matrix triggers (no new route, no new backend domain, no migration, no infra). ### Concerns 1. **ADR for the client-side fetch exception is still not on disk.** `PersonMentionEditor.svelte:139-153` (decoded) still carries the inline note "Markus #5616: an ADR will formalise this. Open follow-up: 'ADR: client-side fetch exception for editor suggestion plugins.'" The fix-pass summary (comment [#10949](https://git.raddatz.cloud/marcel/familienarchiv/pulls/629#issuecomment-10949), Deferred item #1) acknowledges this and defers it to a separate issue. That's an acceptable sequencing call given the PR scope, but the ADR is now load-bearing — the comment in code points at a document that doesn't exist. **Action**: file the follow-up Gitea issue *before* this merges (one minute of work), so the reference in the inline comment resolves to a tracked artifact instead of a phantom. The ADR itself can ship in a separate PR; just don't lose the trail. 2. **Server-side `limit` enforcement is still untracked.** `PersonMentionEditor.svelte` (decoded, runSearch) now sends `&limit=5`, which is the correct *defensive* client cap. But this is a soft contract — the backend `PersonController` still accepts the param without honouring it (as best I can tell from the diff scope), so the response payload size is still client-trusted. Combined with Nora's pre-existing audit on `PersonSummaryDTO` leaking `notes`, this is two layers of "the client filters what the server should." **Action**: confirm the deferred backend issue (fix-pass deferred item #4) is actually filed in Gitea, not just listed in the PR comment. A list in a PR comment evaporates the moment this merges. 3. **`MentionDropdown.test-host.svelte` introduces a new pattern for testing imperatively-mounted Svelte 5 components.** It works (and it's the right way to expose a setter for a `$state` value to a test), but it's now a third "host" file in the codebase next to the `*.test-host.svelte` files for other components. There's no convention documented in `frontend/src/lib/shared/discussion/README.md` or elsewhere about when to add a test-host vs. use the production component directly. **Recommendation** — not a blocker: when the second test-host gets added outside `shared/discussion`, add a `docs/architecture/frontend-testing-patterns.md` (or equivalent) describing the contract: "test-host files exist when the production component reads props that need to be mutated by the test runtime past `mount()`-time, because Svelte 5's `mount()` does not expose settable prop accessors." Otherwise the next contributor will reinvent the same thing badly. ### Suggestions - The two `searchQuery.trim() === ''` checks (one in `MentionDropdown.svelte`, one in the `onSearch` short-circuit inside `PersonMentionEditor.svelte`) are now load-bearing in two places. Felix flagged this as KISS-acceptable, and I agree for now — but if a third occurrence appears (an `aria-live` branch elsewhere, a form-validation check), promote `isEmptyQuery(q)` to `shared/utils` before the third copy lands. This is a "rule of three" trigger I want to flag now so it isn't argued case-by-case later. - `runSearch`'s `requestId` pattern is a nice piece of work — it's the right primitive for racing async UI state. If a second async dropdown ever needs it, factor `useRequestToken()` into `shared/utils`. Not now. ### What's done well - **Layering held under churn.** Ten new commits, zero boundary violations. `shared/discussion` still owns the editor; `shared/utils` still owns `debounce`; `MentionDropdown` is still a dumb view; the fetch is still in the editor. The fix-pass tightened the internals without crossing a module wall. - **The `items: async () => []` line is the right architectural choice.** Routing all fetches through a single, debounced, request-token-guarded path is the version of this code I would have written from scratch. The legacy `items()` callback being neutered (rather than ripped out — Tiptap needs *something* there) is the kind of compromise that should be commented, and it is. - **`onDestroy` now cancels both the debounce and the mounted dropdown.** This is defence-in-depth against cross-lifecycle leaks. The hoisted `cancelPendingSearch` / `mountedDropdown` refs make the cleanup contract visible at the top of the file — good signal-to-noise. - **The `debounce.cancel()` JSDoc is now load-bearing-contract-grade.** "DROPS (does not flush) the pending trailing invocation" is exactly the level of precision that prevents the next consumer from misusing it. - **Doc-matrix check passes.** I walked the matrix in my persona spec — no migration, no new route, no new backend package, no new external system, no auth-flow change, no new `ErrorCode`. No diagram update required for this PR. This is a contained refactor. — Markus
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: ⚠️ Approved with concerns

Second pass after the 10-commit fix push. I re-traced the seven Acceptance Criteria from #380 through the current head (fcd4a41b) and re-checked the two ambiguities I raised on the first round. Two of my three earlier concerns are fully closed; one is deferred to the issue tracker (transparently, in the fix-pass summary) but the PR description still asserts an NFR that doesn't exist yet. New ambiguity surfaced in #628.

Concerns

  1. PR description still claims an NFR that hasn't been written — the AC-status table reads AC-3 ... ✅ 150 ms debounce per NFR. There is no NFR — neither in issue #380 nor in any new issue. The fix-pass summary correctly defers NFR-PERF-MENTION-001 to a separate Gitea issue (deferred item #2), but the PR itself still cites it as if it existed. Either (a) file the NFR issue before merge and link it here, or (b) soften the PR description to ✅ 150 ms debounce (NFR pending — see follow-up). A future maintainer reading the AC table at face value will look for an NFR that isn't there. This is the same class of failure as silently dropping a deferred AC — small, but corrodes traceability.

  2. #628 introduces a new ambiguity that this PR's AC-1 invariant doesn't pre-resolve#628 now has the Connextra rewrite I asked for (good), but its AC-5 Open Decision ("should the displayed @text be unchanged, or updated to the search input at commit time?") is a direct extension of #380 AC-1's "display text stays exactly as typed" invariant. The two ACs can collide: #380 says the typed text is sacred; #628 AC-5(b) would let the search input overwrite it on re-edit. The recommended default in #628 ("a — unchanged") is the right call, but it should be promoted from "Open Decision" to a stated AC before #628 enters refinement, otherwise the implementer faces the same ambiguity Felix flagged on the first round (sticky-takeover semantics). One-line fix in #628: change AC-5 from a multi-branch sentence into "5. Given the search input has been pre-filled and edited, when the user picks a person, then the linked personId updates AND the display text is unchanged (the #380 AC-1 invariant takes precedence)."

  3. Manual test plan still unverified — PR description has 7 manual checkboxes; fix-pass summary notes they weren't run because the dev stack wasn't up; Sara also asked for these to convert to Playwright E2E or run locally. This is the same deferral as last round. The 320 px viewport check Leonie flagged is the highest-risk one — w-72 + min-h-[44px] input + create-new link can easily clip on a senior's phone. If a Playwright E2E follow-up is the resolution, file the chore now so it doesn't become a phantom item.

Suggestions

  • Adopt the AC-status table format as project standard — repeating my first-round suggestion because the fix-pass summary kept it intact and made it even better with a "Deferred — issue-tracker actions" list. This is a model for how to close PRs with partial AC delivery transparently. Worth a chore to bake it into .gitea/PULL_REQUEST_TEMPLATE.md.
  • The deferred-actions list (1–7 in the fix-pass summary) should each become a Gitea issue before merge, or at minimum before #380 is closed. Right now they live only in a PR comment; once the PR is merged and the comment scrolls away, they're invisible. The ADR (Markus #1), the NFR (mine #1), the #628 Connextra polish (mine #2), the server-side limit enforcement (Markus #3), the 401/500 error UX (Sara #2 + my OQ-MENTION-02), the keyboard-nav E2E (Sara #3), and the aria-controls audit (Leonie #3) — that's seven follow-ups. Two are NFR-class, five are functional. File them now, link from #380, then close #380 with confidence.

Open Questions

  • OQ-MENTION-03 (new): The &limit=5 query param is now sent by the client (good defensive cap). What does the backend do with it today? If it's ignored, the server still ships a full result set and the client slices to 5. If it's honored, great — but please verify and add a one-line note to the follow-up backend issue.
  • OQ-MENTION-02 (carry-over from round 1): 500 / 401 from /api/persons still silently shows the empty-state copy. Not addressed in this PR (correctly out of scope), but should be filed as a Gitea issue per my point #3 above.
  • OQ-MENTION-01 (carry-over from round 1): Maximum query length on the server side. Client now caps at 100 chars via maxlength. Server should enforce — track via the backend follow-up.

What's done well

  • AC-4 whitespace-only ambiguity is now explicitly tested. The new keeps the "Namen eingeben…" prompt and fires no fetch when @ is followed only by spaces test in PersonMentionEditor.svelte.spec.ts is exactly the Given-When-Then I asked for, expressed as a behavior assertion. Closes my first-round concern #3 cleanly.
  • #628 has a real user story now. "As a transcriber, when I move the cursor into an already-saved @mention, I want the search dropdown to re-open with the search field pre-filled with the stored display text, so that I can correct or re-link the person without retyping the mention from scratch." That's Connextra-correct, scope is bounded, AC list is 5 explicit Given-When-Thens, NFRs are tagged. Closes my first-round concern #2.
  • The fix-pass summary itself is a traceability artifact — every concern from every persona is mapped to a commit SHA. Deferred items are listed with named owners (the deferred-actions list). The note on bundled commit f67f5330 is honest, not hidden. This is the cleanest review-loop close I've seen on this project. Adopt the pattern.
  • SEARCH_DEBOUNCE_MS constant is now named — even without the NFR, the magic number has a name, which is half the battle. When the NFR lands, it can simply assert against this constant.
  • The sticky-takeover regression test (keeps the user-edited search value when editorQuery changes after the takeover) pins the design decision Felix flagged. The user-story invariant ("user takes ownership") is now executable. This is the right way to convert a design comment into a behavior contract.
  • Test count grew from 38 → 46 without weakening any existing test — pure additive coverage. The traceability matrix from AC → test name → file is still recoverable in under a minute.

— Elicit

## 📋 Elicit — Requirements Engineer **Verdict: ⚠️ Approved with concerns** Second pass after the 10-commit fix push. I re-traced the seven Acceptance Criteria from #380 through the current head (`fcd4a41b`) and re-checked the two ambiguities I raised on the first round. Two of my three earlier concerns are fully closed; one is deferred to the issue tracker (transparently, in the fix-pass summary) but the PR description still asserts an NFR that doesn't exist yet. New ambiguity surfaced in #628. ### Concerns 1. **PR description still claims an NFR that hasn't been written** — the AC-status table reads `AC-3 ... ✅ 150 ms debounce per NFR`. There is no NFR — neither in issue #380 nor in any new issue. The fix-pass summary correctly defers `NFR-PERF-MENTION-001` to a separate Gitea issue (deferred item #2), but the PR itself still cites it as if it existed. Either (a) file the NFR issue before merge and link it here, or (b) soften the PR description to `✅ 150 ms debounce (NFR pending — see follow-up)`. A future maintainer reading the AC table at face value will look for an NFR that isn't there. This is the same class of failure as silently dropping a deferred AC — small, but corrodes traceability. 2. **`#628` introduces a new ambiguity that this PR's AC-1 invariant doesn't pre-resolve** — #628 now has the Connextra rewrite I asked for (good), but its **AC-5 Open Decision** ("should the displayed `@text` be unchanged, or updated to the search input at commit time?") is a direct extension of #380 AC-1's "display text stays exactly as typed" invariant. The two ACs can collide: #380 says the typed text is sacred; #628 AC-5(b) would let the search input overwrite it on re-edit. **The recommended default in #628 ("a — unchanged") is the right call**, but it should be promoted from "Open Decision" to a stated AC before #628 enters refinement, otherwise the implementer faces the same ambiguity Felix flagged on the first round (sticky-takeover semantics). One-line fix in #628: change AC-5 from a multi-branch sentence into "5. **Given** the search input has been pre-filled and edited, **when** the user picks a person, **then** the linked `personId` updates AND the display text is unchanged (the #380 AC-1 invariant takes precedence)." 3. **Manual test plan still unverified** — PR description has 7 manual checkboxes; fix-pass summary notes they weren't run because the dev stack wasn't up; Sara also asked for these to convert to Playwright E2E or run locally. This is the same deferral as last round. The 320 px viewport check Leonie flagged is the highest-risk one — `w-72` + `min-h-[44px]` input + create-new link can easily clip on a senior's phone. If a Playwright E2E follow-up is the resolution, file the chore now so it doesn't become a phantom item. ### Suggestions - **Adopt the AC-status table format as project standard** — repeating my first-round suggestion because the fix-pass summary kept it intact and made it even better with a "Deferred — issue-tracker actions" list. This is a model for how to close PRs with partial AC delivery transparently. Worth a chore to bake it into `.gitea/PULL_REQUEST_TEMPLATE.md`. - **The deferred-actions list (1–7 in the fix-pass summary) should each become a Gitea issue before merge**, or at minimum before #380 is closed. Right now they live only in a PR comment; once the PR is merged and the comment scrolls away, they're invisible. The ADR (Markus #1), the NFR (mine #1), the #628 Connextra polish (mine #2), the server-side `limit` enforcement (Markus #3), the 401/500 error UX (Sara #2 + my OQ-MENTION-02), the keyboard-nav E2E (Sara #3), and the `aria-controls` audit (Leonie #3) — that's seven follow-ups. Two are NFR-class, five are functional. File them now, link from #380, then close #380 with confidence. ### Open Questions - **OQ-MENTION-03 (new)**: The `&limit=5` query param is now sent by the client (good defensive cap). What does the backend do with it today? If it's ignored, the server still ships a full result set and the client slices to 5. If it's honored, great — but please verify and add a one-line note to the follow-up backend issue. - **OQ-MENTION-02 (carry-over from round 1)**: 500 / 401 from `/api/persons` still silently shows the empty-state copy. Not addressed in this PR (correctly out of scope), but should be filed as a Gitea issue per my point #3 above. - **OQ-MENTION-01 (carry-over from round 1)**: Maximum query length on the server side. Client now caps at 100 chars via `maxlength`. Server should enforce — track via the backend follow-up. ### What's done well - **AC-4 whitespace-only ambiguity is now explicitly tested.** The new `keeps the "Namen eingeben…" prompt and fires no fetch when @ is followed only by spaces` test in `PersonMentionEditor.svelte.spec.ts` is exactly the Given-When-Then I asked for, expressed as a behavior assertion. Closes my first-round concern #3 cleanly. - **#628 has a real user story now.** "As a transcriber, when I move the cursor into an already-saved `@mention`, I want the search dropdown to re-open with the search field pre-filled with the stored display text, so that I can correct or re-link the person without retyping the mention from scratch." That's Connextra-correct, scope is bounded, AC list is 5 explicit Given-When-Thens, NFRs are tagged. Closes my first-round concern #2. - **The fix-pass summary itself is a traceability artifact** — every concern from every persona is mapped to a commit SHA. Deferred items are listed with named owners (the deferred-actions list). The note on bundled commit `f67f5330` is honest, not hidden. This is the cleanest review-loop close I've seen on this project. Adopt the pattern. - **`SEARCH_DEBOUNCE_MS` constant is now named** — even without the NFR, the magic number has a name, which is half the battle. When the NFR lands, it can simply assert against this constant. - **The sticky-takeover regression test** (`keeps the user-edited search value when editorQuery changes after the takeover`) pins the design decision Felix flagged. The user-story invariant ("user takes ownership") is now executable. This is the right way to convert a design comment into a behavior contract. - **Test count grew from 38 → 46** without weakening any existing test — pure additive coverage. The traceability matrix from AC → test name → file is still recoverable in under a minute. — Elicit
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: Approved

I re-read the diff commit-by-commit at fcd4a41b. Both of my prior concerns are properly closed:

  • Duplicate fetch path (ecc4d1aa) — items: async () => [] with a clear comment crediting all four reviewers. The "exactly 1 fetch on @Walter" test (PersonMentionEditor.svelte.spec.ts, "fires exactly one /api/persons fetch when the user types @Walter") would have caught this before merge. That's the strong assertion I asked for, not a >= 1 substring check. Red-then-green is provable because the old items() body literally fired a fetch per keystroke — running this test against the prior implementation would yield 7 calls for @Walter, not 1.
  • Sticky takeover characterization (fb658e76) — MentionDropdown.test-host.svelte exposes a setter for editorQuery via an onReady callback. The test asserts that after a user-takeover (fill('Walter')), pushing a new editorQuery via the setter does NOT overwrite. That pins the AC-1 ownership contract. The test-host pattern is also reusable for any future "editor-mirrors-into-child-but-child-takes-over" components.

The other reviewers' concerns were addressed equally cleanly — I verified the request-token guard (2556e7f5), the fixture-drift fix (b6bf24db), and the debounce JSDoc tightening (fcd4a41b).

Concerns

None blocking.

Suggestions

  1. One-line comment on MentionDropdown.svelte:37-41 mirror effect — the highlightedIndex variable above it has a 5-line explanation of why $state + $effect is correct over $derived. The new mirror effect deserves a peer: "mutable $state because bind:value writes it; $effect mirrors editor's typed text until the user takes ownership, after which userHasEdited pins it." Cheap context for the next reader.

  2. In-flight debounce can outlive onExitPersonMentionEditor.svelte:209 assigns cancelPendingSearch = () => debouncedSearch.cancel() inside render(), but this hoisted module-level reference only tracks the LATEST dropdown's debounce. If a user opens dropdown A, types, hits Escape (onExit fires, unmounts dropdown A), then opens dropdown B before the 150 ms elapses — dropdown A's pending debounce will still fire its runSearch and silently mutate dropdownState.items for dropdown B. The mutation is harmless (B's items get briefly overwritten before its own fetch resolves) but it's wasted network. Add debouncedSearch.cancel() to onExit() to make each dropdown lifecycle close cleanly. Not a blocker — fix during the next ADR/cleanup pass.

  3. Test-host colocationMentionDropdown.test-host.svelte lives next to production code in shared/discussion/. A __fixtures__/ subdirectory or .test-fixtures.svelte suffix would make the boundary visible to the eslint-boundaries plugin and to future readers grep'ing the directory. Optional; keeping the colocation is also defensible.

What's done well

  • TDD evidence is exemplary. The "exactly 1 fetch", "stale response", "whitespace-only @ ", and "sticky takeover" tests are all behavior-first and would each have been red against an absence of the fix. The PR description's AC table and the fix-pass summary's concern→commit table are the best PR documentation I've seen on this project. Adopt as standard.
  • Naming: userHasEdited, cancelPendingSearch, requestId, runSearch, SEARCH_DEBOUNCE_MS, POST_DEBOUNCE_SLACK_MS, editorQuery, mountedDropdown — every name carries intent. No Helper, no Manager, no d. Test constants also extracted from magic numbers.
  • Request-token guard pattern (PersonMentionEditor.svelte:193-217) is textbook. requestId bumped synchronously in onSearch, captured at runSearch start, checked twice (after fetch, after await res.json()) so a fast-fail 401 also discards correctly. Defensible against the trickiest race in this whole interaction.
  • debounce.cancel() contract documented as "DROPS (does not flush)" — the surprising choice (lodash flushes by default) is now explicit. JSDoc serves its purpose.
  • untrack(() => editorQuery) in both MentionDropdown.svelte:33 and the test-host — without this, searchQuery would re-initialize every time editor reactivity flowed in, defeating the user-ownership tracking. Correct on the first read.
  • Cleanup discipline: onDestroy calls cancelPendingSearch?.() before editor?.destroy() so a trailing debounce can't fire against a destroyed editor. Order matters; the order is right.
  • Fixture-drift fix (b6bf24db) — makePerson now returns a typed Person via construction, AUGUSTE/ANNA are direct typed literals without as unknown as Person. If the backend adds a required field, these break loudly at compile time. Net win for the project.

— Felix

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ✅ Approved** I re-read the diff commit-by-commit at `fcd4a41b`. Both of my prior concerns are properly closed: - **Duplicate fetch path** (`ecc4d1aa`) — `items: async () => []` with a clear comment crediting all four reviewers. The "exactly 1 fetch on @Walter" test (`PersonMentionEditor.svelte.spec.ts`, "fires exactly one /api/persons fetch when the user types @Walter") would have caught this before merge. That's the strong assertion I asked for, not a `>= 1` substring check. Red-then-green is provable because the old `items()` body literally fired a fetch per keystroke — running this test against the prior implementation would yield 7 calls for `@Walter`, not 1. - **Sticky takeover characterization** (`fb658e76`) — `MentionDropdown.test-host.svelte` exposes a setter for `editorQuery` via an `onReady` callback. The test asserts that after a user-takeover (`fill('Walter')`), pushing a new `editorQuery` via the setter does NOT overwrite. That pins the AC-1 ownership contract. The test-host pattern is also reusable for any future "editor-mirrors-into-child-but-child-takes-over" components. The other reviewers' concerns were addressed equally cleanly — I verified the request-token guard (`2556e7f5`), the fixture-drift fix (`b6bf24db`), and the debounce JSDoc tightening (`fcd4a41b`). ### Concerns None blocking. ### Suggestions 1. **One-line comment on `MentionDropdown.svelte:37-41` mirror effect** — the highlightedIndex variable above it has a 5-line explanation of why `$state + $effect` is correct over `$derived`. The new mirror effect deserves a peer: "mutable `$state` because `bind:value` writes it; `$effect` mirrors editor's typed text until the user takes ownership, after which `userHasEdited` pins it." Cheap context for the next reader. 2. **In-flight debounce can outlive `onExit`** — `PersonMentionEditor.svelte:209` assigns `cancelPendingSearch = () => debouncedSearch.cancel()` *inside* `render()`, but this hoisted module-level reference only tracks the LATEST dropdown's debounce. If a user opens dropdown A, types, hits Escape (onExit fires, unmounts dropdown A), then opens dropdown B before the 150 ms elapses — dropdown A's pending debounce will still fire its runSearch and silently mutate `dropdownState.items` for dropdown B. The mutation is harmless (B's items get briefly overwritten before its own fetch resolves) but it's wasted network. Add `debouncedSearch.cancel()` to `onExit()` to make each dropdown lifecycle close cleanly. Not a blocker — fix during the next ADR/cleanup pass. 3. **Test-host colocation** — `MentionDropdown.test-host.svelte` lives next to production code in `shared/discussion/`. A `__fixtures__/` subdirectory or `.test-fixtures.svelte` suffix would make the boundary visible to the eslint-boundaries plugin and to future readers grep'ing the directory. Optional; keeping the colocation is also defensible. ### What's done well - **TDD evidence is exemplary**. The "exactly 1 fetch", "stale response", "whitespace-only @ ", and "sticky takeover" tests are all behavior-first and would each have been red against an absence of the fix. The PR description's AC table and the fix-pass summary's concern→commit table are the best PR documentation I've seen on this project. Adopt as standard. - **Naming**: `userHasEdited`, `cancelPendingSearch`, `requestId`, `runSearch`, `SEARCH_DEBOUNCE_MS`, `POST_DEBOUNCE_SLACK_MS`, `editorQuery`, `mountedDropdown` — every name carries intent. No `Helper`, no `Manager`, no `d`. Test constants also extracted from magic numbers. - **Request-token guard pattern** (`PersonMentionEditor.svelte:193-217`) is textbook. `requestId` bumped synchronously in `onSearch`, captured at runSearch start, checked twice (after fetch, after `await res.json()`) so a fast-fail 401 also discards correctly. Defensible against the trickiest race in this whole interaction. - **`debounce.cancel()` contract** documented as "DROPS (does not flush)" — the surprising choice (lodash flushes by default) is now explicit. JSDoc serves its purpose. - **`untrack(() => editorQuery)`** in both `MentionDropdown.svelte:33` and the test-host — without this, `searchQuery` would re-initialize every time editor reactivity flowed in, defeating the user-ownership tracking. Correct on the first read. - **Cleanup discipline**: `onDestroy` calls `cancelPendingSearch?.()` *before* `editor?.destroy()` so a trailing debounce can't fire against a destroyed editor. Order matters; the order is right. - **Fixture-drift fix** (`b6bf24db`) — `makePerson` now returns a typed `Person` via construction, AUGUSTE/ANNA are direct typed literals without `as unknown as Person`. If the backend adds a required field, these break loudly at compile time. Net win for the project. — Felix
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved with concerns

Re-reviewed the head at fcd4a41b against my prior comment (#10933). All three items I raised in the first pass are addressed: the maxlength="100" soft cap landed, the duplicate items() fetch is neutralised (was doubling /api/persons request volume per keystroke), and the stale-response race is killed by the requestId token guard. The rel="noopener noreferrer" is present. Good fix-pass.

Two new concerns surfaced while looking at the consolidated state, plus one observation about the soft cap's actual coverage. Nothing blocking — but worth landing before this pattern propagates.

Blockers

None.

Concerns

  1. maxlength="100" is bypassed by the editor-mirror path (CWE-400, layered)MentionDropdown.svelte:36-41 mirrors editorQuery (the text typed after @ in the contenteditable) into searchQuery via a $effect. The contenteditable does NOT enforce maxlength. A user can type @ followed by 5,000 characters in the editor — the mirror writes the full string into searchQuery, the $effect on onSearch fires, and the editor's runSearch issues GET /api/persons?q=<5000 chars>&limit=5. The 100-char cap only applies when the user types directly into the <input> after taking ownership (userHasEdited = true).

    This is a real bypass of the defensive cap. Two options:

    <!-- MentionDropdown.svelte:36-41 — clip at the mirror boundary -->
    const MAX_QUERY_LEN = 100;
    $effect(() => {
        if (!userHasEdited) {
            searchQuery = editorQuery.slice(0, MAX_QUERY_LEN);
        }
    });
    

    Or clip inside onSearch in PersonMentionEditor.svelte:209-217 before the debounce fires:

    const onSearch = (query: string) => {
        requestId++;
        const clipped = query.slice(0, 100);
        if (clipped.trim() === '') { ... }
        debouncedSearch(clipped);
    };
    

    I'd take the second approach — single chokepoint, server-shaped contract enforced at the network boundary. The current state means my CWE-400 fix is half-applied: the input element is capped, but the path from Tiptap → mirror → fetch is not.

  2. Server-side cap on ?q= length is still missing — the client-side cap is not a security boundary (CWE-602: Client-Side Enforcement of Server-Side Security) — The fix-pass summary lists "Server-side limit enforcement + PersonSummaryDTO" as a deferred backend PR. Good — but the length of q also needs a server-side bound. Any actor scripting the page (compromised browser extension, JS in another opened mention dropdown after session takeover, future XSS) can bypass the DOM maxlength with one line:

    fetch('/api/persons?q=' + 'A'.repeat(100000))
    

    The backend PersonController must reject q longer than (say) 100 chars with HTTP 400 — and ideally rate-limit /api/persons at the same time since it's still authenticated-but-otherwise-unbounded. File a separate backend issue:

    CWE-400 + CWE-602: GET /api/persons?q=... must enforce q.length() <= 100 server-side and return 400 on violation. Add a @Size(max = 100) String q parameter validation in PersonController. The frontend maxlength="100" is a UX hint, not a security control.

Suggestions

  1. MentionDropdown.test-host.svelte shipped at src/lib/shared/discussion/ (not in a __tests__/ directory) — Vite tree-shakes unimported modules, so it won't ship to production as long as nothing imports it from a route. To make the dev-only intent unambiguous (and protect against an accidental future import via a fuzzy autocomplete), either: rename the file to .test-only.svelte and add an ESLint no-restricted-imports rule, or move it to frontend/src/lib/shared/discussion/__tests__/. Defense in depth against supply-chain "what is this file?" moments. Low priority.

  2. The empty-query short-circuit in onSearch runs requestId++ before the trim check (PersonMentionEditor.svelte:209-217) — correct ordering, good. Worth a one-line comment so a future refactor doesn't reorder it ("Bump requestId first so any in-flight response from a previous non-empty query is discarded when the user clears the input").

What's done well

  • CWE-400 client-side cap: maxlength="100" on the search input. Soft cap, but the right kind of defensive control with explicit comment citing CWE-400. Test asserts the value (MentionDropdown.svelte.test.ts:179-184). The cap is visible, audited, and regression-proof.
  • Stale-response race eliminated (CWE-362): The requestId token-guard pattern (PersonMentionEditor.svelte:193-218) is textbook — capture id at fetch start, recheck before each state write, drop response on mismatch. Tested explicitly with a deliberately-delayed Promise (PersonMentionEditor.svelte.spec.ts:267-290). This was a real UX-leaks-into-security issue (a slow response repopulating after the user cleared the input could trick the user into selecting a stale-result person they didn't intend to mention).
  • Request-volume halved: Killing the duplicate items() fetch (PersonMentionEditor.svelte:154-158) removes a per-keystroke amplification I called out in the first pass. The &limit=5 query param added at the same time gives the backend a hint to bound response size before the wire (when the server starts respecting it).
  • rel="noopener noreferrer" on the create-new link survives the fix-pass. Both halves of reverse-tabnabbing and Referer-leak (CWE-1022, CWE-200) are blocked.
  • Debounce cancel() semantics documented: The JSDoc now says "DROPS (does not flush) the pending trailing invocation." This matters for the onDestroy path — a flush-on-cancel debounce would have fired a post-unmount fetch, which is exactly the kind of post-logout request leak that creates audit-log noise (or worse, if session timing was tight, a 401 firing into a closed component's error handler).
  • encodeURIComponent retained. URL parameter injection blocked.
  • No innerHTML, no template-string interpolation into URLs, no eval. Clean.

Note for follow-up

  • The Nora #5618 #3 reference in the file (line 152) — the open audit issue for GET /api/persons response-shape (PersonSummaryDTO leaks notes) — is still relevant. Adding &limit=5 doesn't fix the per-row payload, just the row count. A 5-row response with full Person objects still ships private fields the transcriber doesn't need. Confirm that issue is still tracked separately.
  • Once the backend @Size(max = 100) lands, the frontend can drop the slice() clip — but until then, keep both layers. Defense in depth.

— Nora

## 🔒 Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved with concerns** Re-reviewed the head at `fcd4a41b` against my prior comment ([#10933](https://git.raddatz.cloud/marcel/familienarchiv/pulls/629#issuecomment-10933)). All three items I raised in the first pass are addressed: the `maxlength="100"` soft cap landed, the duplicate `items()` fetch is neutralised (was doubling `/api/persons` request volume per keystroke), and the stale-response race is killed by the `requestId` token guard. The `rel="noopener noreferrer"` is present. Good fix-pass. Two new concerns surfaced while looking at the consolidated state, plus one observation about the soft cap's actual coverage. Nothing blocking — but worth landing before this pattern propagates. ### Blockers None. ### Concerns 1. **`maxlength="100"` is bypassed by the editor-mirror path (CWE-400, layered)** — `MentionDropdown.svelte:36-41` mirrors `editorQuery` (the text typed after `@` in the contenteditable) into `searchQuery` via a `$effect`. The contenteditable does NOT enforce `maxlength`. A user can type `@` followed by 5,000 characters in the editor — the mirror writes the full string into `searchQuery`, the `$effect` on `onSearch` fires, and the editor's `runSearch` issues `GET /api/persons?q=<5000 chars>&limit=5`. The 100-char cap only applies when the user types directly into the `<input>` *after* taking ownership (`userHasEdited = true`). This is a real bypass of the defensive cap. Two options: ```svelte <!-- MentionDropdown.svelte:36-41 — clip at the mirror boundary --> const MAX_QUERY_LEN = 100; $effect(() => { if (!userHasEdited) { searchQuery = editorQuery.slice(0, MAX_QUERY_LEN); } }); ``` Or clip inside `onSearch` in `PersonMentionEditor.svelte:209-217` before the debounce fires: ```ts const onSearch = (query: string) => { requestId++; const clipped = query.slice(0, 100); if (clipped.trim() === '') { ... } debouncedSearch(clipped); }; ``` I'd take the second approach — single chokepoint, server-shaped contract enforced at the network boundary. The current state means my CWE-400 fix is half-applied: the input element is capped, but the path from Tiptap → mirror → fetch is not. 2. **Server-side cap on `?q=` length is still missing — the client-side cap is not a security boundary (CWE-602: Client-Side Enforcement of Server-Side Security)** — The fix-pass summary lists "Server-side `limit` enforcement + `PersonSummaryDTO`" as a deferred backend PR. Good — but the *length* of `q` also needs a server-side bound. Any actor scripting the page (compromised browser extension, JS in another opened mention dropdown after session takeover, future XSS) can bypass the DOM `maxlength` with one line: ```js fetch('/api/persons?q=' + 'A'.repeat(100000)) ``` The backend `PersonController` must reject `q` longer than (say) 100 chars with HTTP 400 — and ideally rate-limit `/api/persons` at the same time since it's still authenticated-but-otherwise-unbounded. File a separate backend issue: > **CWE-400 + CWE-602**: `GET /api/persons?q=...` must enforce `q.length() <= 100` server-side and return 400 on violation. Add a `@Size(max = 100) String q` parameter validation in `PersonController`. The frontend `maxlength="100"` is a UX hint, not a security control. ### Suggestions 1. **`MentionDropdown.test-host.svelte` shipped at `src/lib/shared/discussion/` (not in a `__tests__/` directory)** — Vite tree-shakes unimported modules, so it won't ship to production *as long as* nothing imports it from a route. To make the dev-only intent unambiguous (and protect against an accidental future import via a fuzzy autocomplete), either: rename the file to `.test-only.svelte` and add an ESLint `no-restricted-imports` rule, or move it to `frontend/src/lib/shared/discussion/__tests__/`. Defense in depth against supply-chain "what is this file?" moments. Low priority. 2. **The empty-query short-circuit in `onSearch` runs `requestId++` before the trim check (`PersonMentionEditor.svelte:209-217`)** — correct ordering, good. Worth a one-line comment so a future refactor doesn't reorder it ("Bump requestId first so any in-flight response from a previous non-empty query is discarded when the user clears the input"). ### What's done well - **CWE-400 client-side cap**: `maxlength="100"` on the search input. Soft cap, but the right kind of defensive control with explicit comment citing CWE-400. Test asserts the value (`MentionDropdown.svelte.test.ts:179-184`). The cap is visible, audited, and regression-proof. - **Stale-response race eliminated (CWE-362)**: The `requestId` token-guard pattern (`PersonMentionEditor.svelte:193-218`) is textbook — capture id at fetch start, recheck before each state write, drop response on mismatch. Tested explicitly with a deliberately-delayed `Promise` (`PersonMentionEditor.svelte.spec.ts:267-290`). This was a real UX-leaks-into-security issue (a slow response repopulating after the user cleared the input could trick the user into selecting a stale-result person they didn't intend to mention). - **Request-volume halved**: Killing the duplicate `items()` fetch (`PersonMentionEditor.svelte:154-158`) removes a per-keystroke amplification I called out in the first pass. The `&limit=5` query param added at the same time gives the backend a hint to bound response size before the wire (when the server starts respecting it). - **`rel="noopener noreferrer"`** on the create-new link survives the fix-pass. Both halves of reverse-tabnabbing and Referer-leak (CWE-1022, CWE-200) are blocked. - **Debounce `cancel()` semantics documented**: The JSDoc now says "DROPS (does not flush) the pending trailing invocation." This matters for the `onDestroy` path — a flush-on-cancel debounce would have fired a post-unmount fetch, which is exactly the kind of post-logout request leak that creates audit-log noise (or worse, if session timing was tight, a 401 firing into a closed component's error handler). - **`encodeURIComponent` retained**. URL parameter injection blocked. - **No `innerHTML`, no template-string interpolation into URLs, no `eval`.** Clean. ### Note for follow-up - The `Nora #5618 #3` reference in the file (line 152) — the open audit issue for `GET /api/persons` response-shape (PersonSummaryDTO leaks `notes`) — is still relevant. Adding `&limit=5` doesn't fix the per-row payload, just the row count. A 5-row response with full `Person` objects still ships private fields the transcriber doesn't need. Confirm that issue is still tracked separately. - Once the backend `@Size(max = 100)` lands, the frontend can drop the slice() clip — but until then, keep both layers. Defense in depth. — Nora
Author
Owner

🧪 Sara Holt — Senior QA Engineer

Verdict: ⚠️ Approved with concerns

Second-pass review of head fcd4a41b. Every concern from my first review was either fixed or explicitly deferred with rationale — that's good practice and I appreciate the explicit mapping in the fix-pass summary. Test count went from 38→46. The new characterization tests are genuinely well-targeted. Two real flakiness risks remain and one coverage gap is cheap enough that I'd rather we close it now than ticket it.

Blockers

None.

Concerns

1. The "exactly 1 fetch on @Walter" test has a latent CI-jitter flakefrontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts (test "fires exactly one /api/persons fetch when the user types @Walter (debounced)"):

await userEvent.type(page.getByRole('textbox'), '@Walter');
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
// ...
expect(personsFetches.length).toBe(1);

userEvent.type types 7 characters. Each one fires Tiptap's suggestion onUpdateupdateStatedropdownState.editorQuery flips → dropdown's $effect calls onSearch → debounce reset. If CI jitter ever spaces two consecutive keystrokes >150 ms apart, the trailing debounced call fires for the intermediate query before the next reset, producing 2 fetches. The request-token guard (requestId bump) discards the stale response, but fetchMock.mock.calls.length still increments because the request was sent. Assertion fails as flake.

This is exactly the kind of catch I was asking for — but the assertion's robustness depends on tight per-keystroke timing that vitest-browser's userEvent doesn't guarantee. Two fixes:

  • (a) preferred) Use await page.getByRole('textbox').fill('@Walter') for this test — single input event, deterministic, same coverage of the "user typed something" intent. The existing "editing the search input fires a debounced fetch" test already validates the per-keystroke case via type+wait. One test per timing mode.
  • (b) loosen the assertion to toBeLessThanOrEqual(1) — but that defeats the regression-catching purpose. Don't do this.

I went back and forth here. Take (a).

2. Error-path coverage on runSearch (401/500) is genuinely cheap and the test infrastructure is already in place — the stale-response race test already proves you can return a custom mock from fetch to exercise runSearch. The cost of adding a 5xx test is one it() block:

it('shows "no persons found" empty-state when /api/persons returns 500', async () => {
  vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500, json: vi.fn() }));
  renderHost();
  await userEvent.type(page.getByRole('textbox'), '@Aug');
  await new Promise(r => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
  // current behaviour: empty list, no error toast — this test pins that behaviour
  await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument();
});

This both (a) characterizes the current behaviour (silent empty state) and (b) acts as a tripwire for the deferred follow-up #5 in your summary (distinct 401/500 UX). The day someone implements the toast, this test goes red and forces them to update the assertion. That's the test pyramid working.

Same argument for the catch {} branch on network failure (fetch throws). Two it() blocks, ~10 lines of code each. Please add before merge.

3. Keyboard-nav deferral to E2E is acceptable, with one caveat — the deferral is defensible (the suggestion plugin's keydown routing is real DOM behaviour, hard to fake at unit level). However: the MentionDropdown.test-host.svelte shim now exists and is doing useful work for the sticky-takeover test. The same shim could host an "ArrowDown moves highlight from search input to list" unit test at the dropdown level (without the editor), since MentionDropdown owns the listbox and selection state. That's ~15 lines and would catch a regression that today only catches in E2E. Not blocking — keep it on the follow-up list — but flagging that the shim's reach is broader than you used it for.

Suggestions

1. The setTimeout(50) in the sticky-takeover test is brittle namingMentionDropdown.svelte.test.ts, sticky-takeover test:

setEditorQuery('WdGruyter');
await new Promise((r) => setTimeout(r, 50));
await expect.element(page.getByRole('searchbox')).toHaveValue('Walter');

Svelte 5 $effect flushes on the next microtask, so 50 ms is a bag of slack. Either:

  • Drop the wait entirely — expect.element(...).toHaveValue(...) already has its own polling
  • Replace with await tick() from 'svelte' if you want to be explicit about flushing

Naming a setTimeout(50) as "give the effect time to flush" is the kind of thing future-Sara-reading-this-in-two-years will misinterpret as a race fix.

2. The slack-bump rationale (over fake timers) deserves a one-line comment in the test file, not just the commit messagePersonMentionEditor.svelte.spec.ts:18-22 already explains why the constants exist but doesn't mention the fake-timer rejection. Add:

// Real-time waits (not vi.useFakeTimers) because vitest-browser userEvent
// and vi.waitFor rely on real-time polling; faking timers around them adds
// fragility for no determinism win. PR #629 fix-pass.

Six lines. Saves the next reviewer from re-deriving the trade-off.

Flakiness risk

One real risk (Concern 1 above) + one near-miss. The near-miss: await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument() in the stale-response race test is asserted ~50ms after resolveFetch(...). Microtasks should flush, so this should be deterministic — but if any downstream effect is on a requestIdleCallback or similar, you'd get false negatives (test passes because the element hasn't been added yet, not because the guard worked). Recommend: also assert expect(dropdownState.items.length).toBe(0) if exposed, or extend the wait to 200ms. Defensive but cheap.

What's done well

  • Sticky-takeover characterization test is well-built — the MentionDropdown.test-host.svelte shim with onReady-exposed setter is the right level of indirection. It documents the intentional design (user-edit wins) without leaking implementation details. Future refactors that break the contract will fail this test, which is exactly what you want.
  • Whitespace-only query test (@ ) is solid — pins both the prompt copy AND the no-fetch behaviour, which is the AC-4 ambiguity Elicit raised. The combined assertion (getByText(prompt).toBeInTheDocument() + expect(fetchMock).not.toHaveBeenCalled()) is exactly how I'd write it.
  • makePerson factory now typed against Person directlyMentionDropdown.svelte.test.ts:14-25. Caught the missing personType/familyMember drift. This is the test discipline I asked for and the team paid it back with interest.
  • &limit=5 testPersonMentionEditor.svelte.spec.ts "appends &limit=5 to the fetch URL" — defensive cap pinned by a test means Markus's concern can't silently regress.
  • Request-token guard in runSearchPersonMentionEditor.svelte:191-211. The pattern (capture id at fetch start, re-check after each await) is textbook. The accompanying test (stale-response race) covers the exact scenario it defends against.
  • Atomic commit map in the fix-pass summary — every concern → one commit hash. Reviewer time per fix-pass dropped from "read everything" to "spot-check the three I care about." Adopt this format as a project standard.

Test plan note (carryover from first review)

The manual 7-checkbox test plan in the PR description still hasn't been run (per your own note: dev stack not up). For a UI feature with brand-token and viewport concerns Leonie raised, that's the gap I'd close before merge — even a single Playwright session capturing screenshots at 320 px / 768 px would lock those in. Not blocking.

— Sara

## 🧪 Sara Holt — Senior QA Engineer **Verdict: ⚠️ Approved with concerns** Second-pass review of head `fcd4a41b`. Every concern from my first review was either fixed or explicitly deferred with rationale — that's good practice and I appreciate the explicit mapping in the fix-pass summary. Test count went from 38→46. The new characterization tests are genuinely well-targeted. Two real flakiness risks remain and one coverage gap is cheap enough that I'd rather we close it now than ticket it. ### Blockers None. ### Concerns **1. The "exactly 1 fetch on `@Walter`" test has a latent CI-jitter flake** — `frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts` (test "fires exactly one /api/persons fetch when the user types @Walter (debounced)"): ```ts await userEvent.type(page.getByRole('textbox'), '@Walter'); await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); // ... expect(personsFetches.length).toBe(1); ``` `userEvent.type` types 7 characters. Each one fires Tiptap's suggestion `onUpdate` → `updateState` → `dropdownState.editorQuery` flips → dropdown's `$effect` calls `onSearch` → debounce reset. If CI jitter ever spaces two consecutive keystrokes >150 ms apart, the trailing debounced call fires for the intermediate query *before* the next reset, producing 2 fetches. The request-token guard (`requestId` bump) discards the stale *response*, but `fetchMock.mock.calls.length` still increments because the request *was sent*. Assertion fails as flake. This is exactly the kind of catch I was asking for — but the assertion's robustness depends on tight per-keystroke timing that vitest-browser's `userEvent` doesn't guarantee. Two fixes: - **(a) preferred)** Use `await page.getByRole('textbox').fill('@Walter')` for this test — single input event, deterministic, same coverage of the "user typed something" intent. The existing "editing the search input fires a debounced fetch" test already validates the per-keystroke case via type+wait. One test per timing mode. - **(b) loosen the assertion to `toBeLessThanOrEqual(1)`** — but that defeats the regression-catching purpose. Don't do this. I went back and forth here. Take (a). **2. Error-path coverage on `runSearch` (401/500) is genuinely cheap and the test infrastructure is already in place** — the stale-response race test already proves you can return a custom mock from `fetch` to exercise `runSearch`. The cost of adding a 5xx test is one `it()` block: ```ts it('shows "no persons found" empty-state when /api/persons returns 500', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500, json: vi.fn() })); renderHost(); await userEvent.type(page.getByRole('textbox'), '@Aug'); await new Promise(r => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); // current behaviour: empty list, no error toast — this test pins that behaviour await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument(); }); ``` This both (a) characterizes the current behaviour (silent empty state) and (b) acts as a tripwire for the deferred follow-up #5 in your summary (distinct 401/500 UX). The day someone implements the toast, this test goes red and forces them to update the assertion. That's the test pyramid working. Same argument for the `catch {}` branch on network failure (`fetch` throws). Two `it()` blocks, ~10 lines of code each. Please add before merge. **3. Keyboard-nav deferral to E2E is acceptable, with one caveat** — the deferral is defensible (the suggestion plugin's keydown routing is real DOM behaviour, hard to fake at unit level). However: the `MentionDropdown.test-host.svelte` shim now exists and is doing useful work for the sticky-takeover test. The same shim could host an "ArrowDown moves highlight from search input to list" unit test at the *dropdown* level (without the editor), since `MentionDropdown` owns the listbox and selection state. That's ~15 lines and would catch a regression that today only catches in E2E. Not blocking — keep it on the follow-up list — but flagging that the shim's reach is broader than you used it for. ### Suggestions **1. The `setTimeout(50)` in the sticky-takeover test is brittle naming** — `MentionDropdown.svelte.test.ts`, sticky-takeover test: ```ts setEditorQuery('WdGruyter'); await new Promise((r) => setTimeout(r, 50)); await expect.element(page.getByRole('searchbox')).toHaveValue('Walter'); ``` Svelte 5 `$effect` flushes on the next microtask, so 50 ms is a bag of slack. Either: - Drop the wait entirely — `expect.element(...).toHaveValue(...)` already has its own polling - Replace with `await tick()` from `'svelte'` if you want to be explicit about flushing Naming a `setTimeout(50)` as "give the effect time to flush" is the kind of thing future-Sara-reading-this-in-two-years will misinterpret as a race fix. **2. The slack-bump rationale (over fake timers) deserves a one-line comment in the test file, not just the commit message** — `PersonMentionEditor.svelte.spec.ts:18-22` already explains why the constants exist but doesn't mention the fake-timer rejection. Add: ```ts // Real-time waits (not vi.useFakeTimers) because vitest-browser userEvent // and vi.waitFor rely on real-time polling; faking timers around them adds // fragility for no determinism win. PR #629 fix-pass. ``` Six lines. Saves the next reviewer from re-deriving the trade-off. ### Flakiness risk **One real risk (Concern 1 above) + one near-miss.** The near-miss: `await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument()` in the stale-response race test is asserted ~50ms after `resolveFetch(...)`. Microtasks should flush, so this should be deterministic — but if any downstream effect is on a `requestIdleCallback` or similar, you'd get false negatives (test passes because the element hasn't been added yet, not because the guard worked). Recommend: also assert `expect(dropdownState.items.length).toBe(0)` if exposed, or extend the wait to 200ms. Defensive but cheap. ### What's done well - **Sticky-takeover characterization test is well-built** — the `MentionDropdown.test-host.svelte` shim with `onReady`-exposed setter is the right level of indirection. It documents the intentional design (user-edit wins) without leaking implementation details. Future refactors that break the contract will fail this test, which is exactly what you want. - **Whitespace-only query test (`@ `) is solid** — pins both the prompt copy AND the no-fetch behaviour, which is the AC-4 ambiguity Elicit raised. The combined assertion (`getByText(prompt).toBeInTheDocument()` + `expect(fetchMock).not.toHaveBeenCalled()`) is exactly how I'd write it. - **`makePerson` factory now typed against `Person` directly** — `MentionDropdown.svelte.test.ts:14-25`. Caught the missing `personType`/`familyMember` drift. This is the test discipline I asked for and the team paid it back with interest. - **`&limit=5` test** — `PersonMentionEditor.svelte.spec.ts` "appends &limit=5 to the fetch URL" — defensive cap pinned by a test means Markus's concern can't silently regress. - **Request-token guard in `runSearch`** — `PersonMentionEditor.svelte:191-211`. The pattern (capture `id` at fetch start, re-check after each `await`) is textbook. The accompanying test (stale-response race) covers the exact scenario it defends against. - **Atomic commit map in the fix-pass summary** — every concern → one commit hash. Reviewer time per fix-pass dropped from "read everything" to "spot-check the three I care about." Adopt this format as a project standard. ### Test plan note (carryover from first review) The manual 7-checkbox test plan in the PR description still hasn't been run (per your own note: dev stack not up). For a UI feature with brand-token and viewport concerns Leonie raised, that's the gap I'd close before merge — even a single Playwright session capturing screenshots at 320 px / 768 px would lock those in. Not blocking. — Sara
Author
Owner

🎨 Leonie Voss — Senior UX Designer & Accessibility Strategist

Verdict: ⚠️ Approved with concerns

Re-reviewed at head fcd4a41b. The fix pass discharged FINDING-MENTION-001 (Blocker) and FINDING-MENTION-002 (Concern) cleanly. Magnifier is now h-5 w-5 text-ink-2 (20 px, +25 % vs. the original 16 px, with darker ink for affordance), and the empty-state copy collapsed into a single <p aria-live="polite"> that derives its text from searchQuery.trim() === '' — exactly the shape I recommended. Tests for both fixes exist in MentionDropdown.svelte.test.ts ("renders the magnifier icon at h-5 w-5 with text-ink-2 (Leonie BLOCKER on PR #629)" and "announces empty-state copy via aria-live="polite" (Leonie FINDING-MENTION-002 on PR #629)").

I'm lifting the Blocker. Three smaller findings remain — none ship-stoppers — plus the 320 px viewport check that's still outstanding.

Blockers

None.

Concerns

FINDING-MENTION-004 — Search input's accessible name is mismatched against its purpose

  • Heuristic: WCAG 2.5.3 (Label in Name) / 4.1.2 (Name, Role, Value)
  • Severity: Minor
  • Issue: MentionDropdown.svelte:151 reuses m.person_mention_btn_label() ("Person verlinken" / "Link person") as the <label sr-only> for the search input. A screen reader user lands on a <input type="search"> and hears "Link person, search" — but the input doesn't link anything; it filters a candidate list. The verb-mismatch is small but creates the "is this the same control I just heard the listbox announce?" confusion you specifically wanted to avoid.
  • Recommendation: Introduce a distinct i18n key:
    "person_mention_search_label": "Personen suchen" / "Search persons" / "Buscar personas"
    
    and reference it from the <label sr-only for="mention-search">. Keep person_mention_btn_label for the listbox aria-label. Five minutes; pure i18n + one line.

FINDING-MENTION-005 — 320 px viewport still unverified, and w-72 is fragile

  • Heuristic: WCAG 1.4.10 (Reflow)
  • Severity: Minor
  • Issue: The fix-pass summary explicitly defers the 320 px check ("dev stack wasn't up"). w-72 = 288 px, so the listbox itself fits, but the position.left is anchored to the caret's absolute viewport X — on a 320 px viewport with the caret near the right edge, the listbox can still overflow horizontally. The flip logic at MentionDropdown.svelte:108-118 handles the vertical overflow only.
  • Recommendation: Apply the one-liner I suggested last round, defensively, since manual verification keeps slipping:
    class="fixed z-50 w-72 max-w-[calc(100vw-1rem)] overflow-hidden ..."
    
    Zero risk, no flake, fixes the case before it lands as a bug report from a 67-year-old on a 320 px phone.

FINDING-MENTION-006 — text-sm (14 px) on the search input is below the senior-audience floor

  • Heuristic: WCAG 1.4.4 (Resize Text) — passes; senior-audience guideline — fails the project's own 16 px / preferred 18 px body-text floor (CLAUDE.md §Dual-Audience Design)
  • Severity: Low / question
  • Issue: MentionDropdown.svelte:163 — the <input> uses text-sm (14 px). The list items below it use text-base (16 px) for the name and text-xs for life dates. The input is the primary write surface the senior is typing INTO. Right now it's the smallest non-metadata text in the dropdown.
  • Recommendation: Bump to text-base (16 px) — it costs ~2 px of vertical rhythm in the popover header and zero a11y debt. This matches the persona's 16 px minimum body text rule.

Suggestions

  1. Whitespace-only state should announce too — the new test keeps the "Namen eingeben…" prompt and fires no fetch when @ is followed only by spaces documents the behavior, which is good. The aria-live="polite" <p> will already announce the prompt when the items list is empty, so this is fine in practice — but I'd love a screen-reader transcript check (NVDA + VoiceOver) when Sara's a11y E2E lands. Not blocking.

  2. maxlength="100" UX — Nora's CWE-400 cap is exactly right at the input layer. Worth confirming with a transcriber that 100 chars is enough for the longest plausible historical name + role they'd type (e.g. "Friedrich Wilhelm Heinrich Karl Ludwig von Hohenzollern" = 56). It is — but if someone ever pastes a full salutation into the search ("Seine Hochwohlgeboren Friedrich Wilhelm..."), the silent truncation could surprise them. A future enhancement: add aria-describedby pointing to a small "max 100 characters" hint that only appears when ≥ 90 chars are typed.

  3. Magnifier icon is now text-ink-2 — good. Verify the contrast ratio against bg-surface (which I expect remaps to --c-surface). --palette-ink-2 should be in the 5:1+ range on the warm sand surface — pull a quick axe-core check in the next E2E pass.

What's done well

  • FINDING-MENTION-001 dischargeh-5 w-5 text-ink-2 magnifier reads as a strong affordance. Combined with type="search" (which gives mobile keyboards a clear/search button) and the persistent placeholder, the input is identifiable even after the placeholder vanishes. The lighter-touch path was the right call; a visible label would have added visual clutter inside a popover that's already information-dense.
  • FINDING-MENTION-002 discharge — single <p aria-live="polite"> with conditional text is exactly the WCAG 4.1.3 pattern. Svelte will keep the same DOM node across state changes (no remount), so the live-region announcement fires reliably on text content change. The inline comment crediting the finding is also a nice habit — it tells the next contributor why this isn't two <p> elements.
  • rel="noopener noreferrer" on /persons/new — both halves of the protection, double-checked in the test (expect(link.rel).toContain('noreferrer')).
  • 44 px touch target on the search input AND the create-new link AND each list option — three separate min-h-[44px] assertions across the dropdown. WCAG 2.5.5 cleanly met.
  • Brand compliance: text-ink, text-ink-2, text-ink-3, bg-surface, bg-canvas, border-line, text-brand-navy, bg-brand-mint/20, ring-brand-navy — no raw hex, no Tailwind palette colors, every value flows through the semantic token system. The earlier round's inline comment on bg-brand-mint/20 ring-brand-navy (1.4.11 Non-Text Contrast) is preserved.
  • focus-visible (not focus) — keyboard users see the ring, mouse users don't. Correct discrimination.
  • i18n hygiene: the new person_mention_search_prompt key landed in all three locales (de.json, en.json, es.json) with culturally appropriate copy ("Namen eingeben…" / "Enter a name…" / "Escribe un nombre…").
  • Test host patternMentionDropdown.test-host.svelte is a clean, single-purpose $state proxy that lets the test drive editorQuery mutations without leaking the pattern into production code. Good test-architecture instinct.
  • AC-status table in the PR description — I am now a third reviewer asking you to canonize this. Elicit is right: it belongs in the project PR template.

Note for the senior audience

The remaining concerns all point in the same direction: this dropdown is a write surface for a 60+ transcriber, and the senior-audience constraints (text-base minimum body, visible affordances persistent across state, predictable horizontal overflow on small phones) take precedence over visual compactness inside a popover. Specifically:

  1. text-base on the search input addresses the "I can't read what I'm typing" complaint that has historically been #1 in senior-usability tests of search bars.
  2. max-w-[calc(100vw-1rem)] addresses the silent failure mode where a transcriber working on a small phone in landscape sees the dropdown clip off the right edge with no scroll affordance.
  3. Personen suchen as the SR label addresses the screen-reader user who needs the control's purpose announced in plain language — "search" is the verb their muscle memory expects on a <input type="search">.

None of these are merge-blockers. All three are 5-minute fixes that would discharge any remaining a11y debt I can see in this surface. If you want to bundle them into a follow-up issue, I'd suggest one issue titled "Mention dropdown — senior-audience polish (text-base, search-label, max-w)" so they don't get lost.

Ship from my side once you've at least eyeballed the dropdown at 320 px. The rest is polish, not gating.

— Leonie

## 🎨 Leonie Voss — Senior UX Designer & Accessibility Strategist **Verdict: ⚠️ Approved with concerns** Re-reviewed at head `fcd4a41b`. The fix pass discharged **FINDING-MENTION-001** (Blocker) and **FINDING-MENTION-002** (Concern) cleanly. Magnifier is now `h-5 w-5 text-ink-2` (20 px, +25 % vs. the original 16 px, with darker ink for affordance), and the empty-state copy collapsed into a single `<p aria-live="polite">` that derives its text from `searchQuery.trim() === ''` — exactly the shape I recommended. Tests for both fixes exist in `MentionDropdown.svelte.test.ts` ("renders the magnifier icon at h-5 w-5 with text-ink-2 (Leonie BLOCKER on PR #629)" and "announces empty-state copy via aria-live=\"polite\" (Leonie FINDING-MENTION-002 on PR #629)"). I'm lifting the Blocker. Three smaller findings remain — none ship-stoppers — plus the 320 px viewport check that's still outstanding. ### Blockers None. ### Concerns **FINDING-MENTION-004 — Search input's accessible name is mismatched against its purpose** - **Heuristic**: WCAG 2.5.3 (Label in Name) / 4.1.2 (Name, Role, Value) - **Severity**: Minor - **Issue**: `MentionDropdown.svelte:151` reuses `m.person_mention_btn_label()` ("Person verlinken" / "Link person") as the `<label sr-only>` for the search input. A screen reader user lands on a `<input type="search">` and hears "Link person, search" — but the input doesn't link anything; it filters a candidate list. The verb-mismatch is small but creates the "is this the same control I just heard the listbox announce?" confusion you specifically wanted to avoid. - **Recommendation**: Introduce a distinct i18n key: ```json "person_mention_search_label": "Personen suchen" / "Search persons" / "Buscar personas" ``` and reference it from the `<label sr-only for="mention-search">`. Keep `person_mention_btn_label` for the listbox `aria-label`. Five minutes; pure i18n + one line. **FINDING-MENTION-005 — 320 px viewport still unverified, and `w-72` is fragile** - **Heuristic**: WCAG 1.4.10 (Reflow) - **Severity**: Minor - **Issue**: The fix-pass summary explicitly defers the 320 px check ("dev stack wasn't up"). `w-72` = 288 px, so the listbox itself fits, but the `position.left` is anchored to the caret's absolute viewport X — on a 320 px viewport with the caret near the right edge, the listbox can still overflow horizontally. The flip logic at `MentionDropdown.svelte:108-118` handles the *vertical* overflow only. - **Recommendation**: Apply the one-liner I suggested last round, defensively, since manual verification keeps slipping: ```svelte class="fixed z-50 w-72 max-w-[calc(100vw-1rem)] overflow-hidden ..." ``` Zero risk, no flake, fixes the case before it lands as a bug report from a 67-year-old on a 320 px phone. **FINDING-MENTION-006 — `text-sm` (14 px) on the search input is below the senior-audience floor** - **Heuristic**: WCAG 1.4.4 (Resize Text) — passes; senior-audience guideline — fails the project's own 16 px / preferred 18 px body-text floor (CLAUDE.md §Dual-Audience Design) - **Severity**: Low / question - **Issue**: `MentionDropdown.svelte:163` — the `<input>` uses `text-sm` (14 px). The list items below it use `text-base` (16 px) for the name and `text-xs` for life dates. The input is the primary write surface the senior is typing INTO. Right now it's the smallest non-metadata text in the dropdown. - **Recommendation**: Bump to `text-base` (16 px) — it costs ~2 px of vertical rhythm in the popover header and zero a11y debt. This matches the persona's 16 px minimum body text rule. ### Suggestions 1. **Whitespace-only state should announce too** — the new test `keeps the "Namen eingeben…" prompt and fires no fetch when @ is followed only by spaces` documents the behavior, which is good. The `aria-live="polite"` `<p>` will already announce the prompt when the items list is empty, so this is fine in practice — but I'd love a screen-reader transcript check (NVDA + VoiceOver) when Sara's a11y E2E lands. Not blocking. 2. **`maxlength="100"` UX** — Nora's CWE-400 cap is exactly right at the input layer. Worth confirming with a transcriber that 100 chars is enough for the longest plausible historical name + role they'd type (e.g. "Friedrich Wilhelm Heinrich Karl Ludwig von Hohenzollern" = 56). It is — but if someone ever pastes a full salutation into the search ("Seine Hochwohlgeboren Friedrich Wilhelm..."), the silent truncation could surprise them. A future enhancement: add `aria-describedby` pointing to a small "max 100 characters" hint that only appears when ≥ 90 chars are typed. 3. **Magnifier icon is now `text-ink-2`** — good. Verify the contrast ratio against `bg-surface` (which I expect remaps to `--c-surface`). `--palette-ink-2` should be in the 5:1+ range on the warm sand surface — pull a quick axe-core check in the next E2E pass. ### What's done well - **FINDING-MENTION-001 discharge** — `h-5 w-5 text-ink-2` magnifier reads as a strong affordance. Combined with `type="search"` (which gives mobile keyboards a clear/search button) and the persistent placeholder, the input is identifiable even after the placeholder vanishes. The lighter-touch path was the right call; a visible label would have added visual clutter inside a popover that's already information-dense. - **FINDING-MENTION-002 discharge** — single `<p aria-live="polite">` with conditional text is exactly the WCAG 4.1.3 pattern. Svelte will keep the same DOM node across state changes (no remount), so the live-region announcement fires reliably on text content change. The inline comment crediting the finding is also a nice habit — it tells the next contributor *why* this isn't two `<p>` elements. - **`rel="noopener noreferrer"`** on `/persons/new` — both halves of the protection, double-checked in the test (`expect(link.rel).toContain('noreferrer')`). - **44 px touch target** on the search input AND the create-new link AND each list option — three separate `min-h-[44px]` assertions across the dropdown. WCAG 2.5.5 cleanly met. - **Brand compliance**: `text-ink`, `text-ink-2`, `text-ink-3`, `bg-surface`, `bg-canvas`, `border-line`, `text-brand-navy`, `bg-brand-mint/20`, `ring-brand-navy` — no raw hex, no Tailwind palette colors, every value flows through the semantic token system. The earlier round's inline comment on `bg-brand-mint/20 ring-brand-navy` (1.4.11 Non-Text Contrast) is preserved. - **focus-visible** (not `focus`) — keyboard users see the ring, mouse users don't. Correct discrimination. - **i18n hygiene**: the new `person_mention_search_prompt` key landed in all three locales (`de.json`, `en.json`, `es.json`) with culturally appropriate copy ("Namen eingeben…" / "Enter a name…" / "Escribe un nombre…"). - **Test host pattern** — `MentionDropdown.test-host.svelte` is a clean, single-purpose `$state` proxy that lets the test drive `editorQuery` mutations without leaking the pattern into production code. Good test-architecture instinct. - **AC-status table in the PR description** — I am now a third reviewer asking you to canonize this. Elicit is right: it belongs in the project PR template. ### Note for the senior audience The remaining concerns all point in the same direction: this dropdown is a **write surface for a 60+ transcriber**, and the senior-audience constraints (`text-base` minimum body, visible affordances persistent across state, predictable horizontal overflow on small phones) take precedence over visual compactness inside a popover. Specifically: 1. **`text-base` on the search input** addresses the "I can't read what I'm typing" complaint that has historically been #1 in senior-usability tests of search bars. 2. **`max-w-[calc(100vw-1rem)]`** addresses the silent failure mode where a transcriber working on a small phone in landscape sees the dropdown clip off the right edge with no scroll affordance. 3. **`Personen suchen` as the SR label** addresses the screen-reader user who needs the control's purpose announced in plain language — "search" is the verb their muscle memory expects on a `<input type="search">`. None of these are merge-blockers. All three are 5-minute fixes that would discharge any remaining a11y debt I can see in this surface. If you want to bundle them into a follow-up issue, I'd suggest one issue titled "Mention dropdown — senior-audience polish (text-base, search-label, max-w)" so they don't get lost. Ship from my side once you've at least eyeballed the dropdown at 320 px. The rest is polish, not gating. — Leonie
marcel added 10 commits 2026-05-19 23:30:59 +02:00
Without this, a closed dropdown's trailing runSearch could fire against
the next dropdown's state and silently overwrite its items before its
own fetch resolved. Felix #1 on PR #629.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The mirror effect on the dropdown's searchQuery looks like it should be
\$derived but it cannot be: bind:value on the <input> writes to the same
state, so it must remain mutable. Felix #2 on PR #629.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Test-only helper colocated with production code now has a visible
.test-fixture.svelte boundary so eslint-boundaries and code search
do not confuse it for a production component. The internal alias was
also bumped from *Host to *Fixture for consistency. No behaviour
change. Felix #3 / Nora #3 on PR #629.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
userEvent.type(@Walter) types 7 keys; CI jitter can space the gaps past
the 150 ms debounce and fire 2+ fetches, even though the request-token
guard discards the stale response. fill() collapses the input into one
event so the assertion (exactly 1 fetch) becomes deterministic.
Sara #1 on PR #629.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
runSearch swallows non-OK responses and fetch rejections to an empty
items list. The user sees "Keine Personen gefunden" identically to a
genuine empty result. These two tests pin that behaviour so a future
distinct-error-UX implementer is forced to update the assertions.
Sara #2 on PR #629.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tiptap intercepts ArrowDown/ArrowUp/Enter at the editor level and
forwards them via the dropdown's exported onKeyDown — the dropdown
itself has no DOM keydown listener. These tests exercise the same
export directly (the full focus-chain E2E is deferred to a separate
Playwright issue). Sara #3 on PR #629.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The <input maxlength=100> attribute capped direct user edits but did
not cover the Tiptap editor-mirror path. A 5000-char @-suffix in the
contenteditable would mirror unchanged into searchQuery and reach
runSearch. Clipping at the mirror keeps both paths bounded. The
literal in the maxlength attribute is also bound to the new
MAX_QUERY_LENGTH constant so the two stay in sync. Server-side cap
tracked separately. Nora #1 on PR #629.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The sr-only label for the search input was reusing the listbox
"Link person" label — but the input filters a candidate list, it does
not link anything. Screen readers heard a verb mismatch between the
listbox announce and the search-input focus event. New
person_mention_search_label key in de/en/es. The listbox aria-label
stays person_mention_btn_label since that labels the listbox itself.
Leonie FINDING-MENTION-004 on PR #629.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
w-72 (288 px) listbox can overflow horizontally on a 320 px viewport
when the caret sits near the right edge — the existing flip logic
only handles vertical overflow. max-w-[calc(100vw-1rem)] adds a
defensive horizontal cap so a senior on a 320 px phone never sees
the dropdown clip off-screen. Leonie FINDING-MENTION-005 on PR #629.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
a11y(transcription): bump @mention search input to text-base (16 px floor)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m30s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m23s
CI / fail2ban Regex (pull_request) Successful in 41s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 58s
4f2880a61a
The senior-audience body-text floor is 16 px (CLAUDE.md
§Dual-Audience). The search input was the smallest non-metadata
text in the dropdown at text-sm (14 px), even though it is the
primary write surface a 60+ transcriber types into. Bumping to
text-base costs ~2 px of popover header height and closes the
"I can't read what I'm typing" complaint that historically tops
senior-usability tests of search bars. Leonie FINDING-MENTION-006
on PR #629.

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

Round-2 review-fix summary

Each concern from the seven persona round-2 reviews mapped to its resolution. New head at 4f2880a6.

Persona Concern Resolution
Markus #1 (#10959) ADR for client-side fetch exception not on disk Filed #631
Markus #2 (#10959) Server-side ?limit= enforcement untracked Filed #633
Markus #3 (#10959) Test-host pattern undocumented Filed #637; also addressed in-PR by Felix #3 rename below
Elicit #1 (#10962) PR description cites NFR that doesn't exist Filed #632; PR description AC-3 row updated to link it
Elicit #2 (#10962) #628 AC-5 still an Open Decision Posted suggested AC-5 rewrite as comment on #628 (issue body untouched)
Elicit #3 (#10962) Manual / 320 px test plan unverified A9 (e37351f5) adds max-w-[calc(100vw-1rem)] defensive cap; E2E itself filed #635
Felix #1 (#10968) cancelPendingSearch only tracks latest dropdown lifecycle A1 (58358e84) — debouncedSearch.cancel() in onExit + red→green test
Felix #2 (#10968) Mirror $effect deserves a comment like highlightedIndex A2 (a8224795) — 5-line explanation comment added
Felix #3 / Nora #1 (#10968 / #10969) Test-host colocation makes boundary invisible A3 (7603c8d9) — renamed .test-host.svelte.test-fixture.svelte
Nora #1 (#10969) maxlength="100" bypassed via editor-mirror path (CWE-400 layered) A7 (b5455066) — MAX_QUERY_LENGTH constant, clipped at mirror + bound to maxlength attr
Nora #2 (#10969) Server-side q.length() cap missing (CWE-602) Filed #633 (bundled with ?limit= and PersonSummaryDTO)
Sara #1 (#10970) @Walter keystroke race could fire ≥2 fetches on CI A4 (b6b9235d) — switched to fill('Walter') for deterministic single input event
Sara #2 (#10970) 401/500 silent-empty-state behaviour uncharacterized A5 (34b6a8a2) — two characterization tests pin current silent failure; UX follow-up #634
Sara #3 (#10970) ArrowDown-from-searchbox unit test deferred to E2E A6 (2df46b71) — exercises exported onKeyDown directly via mount + flushSync; full focus-chain E2E filed #635
Leonie FINDING-MENTION-004 (#10972) sr-only search label reuses verb-mismatched "Link person" A8 (332d8197) — new person_mention_search_label key in de/en/es; sr-only label switched
Leonie FINDING-MENTION-005 (#10972) w-72 listbox can overflow at 320 px A9 (e37351f5) — max-w-[calc(100vw-1rem)]
Leonie FINDING-MENTION-006 (#10972) Search input text-sm (14 px) below senior 16 px floor A10 (4f2880a6) — text-base
Leonie / future a11y (#10936 / #10972) aria-controls + aria-activedescendant chain not wired Filed #636

Deferred to follow-up issues

  • #631 — docs: ADR for client-side fetch exception (Markus)
  • #632 — feat: NFR-PERF-MENTION-001 (Elicit)
  • #633 — feat(backend): /api/persons cap + limit + DTO (Markus / Nora)
  • #634 — feat(ui): distinct error feedback on /api/persons failure (Sara / Elicit)
  • #635 — test(e2e): Playwright keyboard navigation (Sara / Leonie)
  • #636 — feat(a11y): aria-controls + aria-activedescendant (Leonie)
  • #637 — docs: convention for Svelte 5 test fixtures (Markus / Felix)

Verification at new head

  • npx vitest run src/lib/shared/discussion/MentionDropdown.svelte.test.ts src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts56 / 56 green (up from 38 → 46 → 56 across the two review rounds).
  • npm run check — no new errors in changed files.
  • npm run lint — clean.

Pushed at 4f2880a6.

## Round-2 review-fix summary Each concern from the seven persona round-2 reviews mapped to its resolution. New head at `4f2880a6`. | Persona | Concern | Resolution | |---|---|---| | Markus #1 (#10959) | ADR for client-side fetch exception not on disk | Filed #631 | | Markus #2 (#10959) | Server-side `?limit=` enforcement untracked | Filed #633 | | Markus #3 (#10959) | Test-host pattern undocumented | Filed #637; also addressed in-PR by Felix #3 rename below | | Elicit #1 (#10962) | PR description cites NFR that doesn't exist | Filed #632; PR description AC-3 row updated to link it | | Elicit #2 (#10962) | #628 AC-5 still an Open Decision | Posted suggested AC-5 rewrite as comment on #628 (issue body untouched) | | Elicit #3 (#10962) | Manual / 320 px test plan unverified | A9 (`e37351f5`) adds `max-w-[calc(100vw-1rem)]` defensive cap; E2E itself filed #635 | | Felix #1 (#10968) | `cancelPendingSearch` only tracks latest dropdown lifecycle | A1 (`58358e84`) — `debouncedSearch.cancel()` in onExit + red→green test | | Felix #2 (#10968) | Mirror `$effect` deserves a comment like `highlightedIndex` | A2 (`a8224795`) — 5-line explanation comment added | | Felix #3 / Nora #1 (#10968 / #10969) | Test-host colocation makes boundary invisible | A3 (`7603c8d9`) — renamed `.test-host.svelte` → `.test-fixture.svelte` | | Nora #1 (#10969) | `maxlength="100"` bypassed via editor-mirror path (CWE-400 layered) | A7 (`b5455066`) — `MAX_QUERY_LENGTH` constant, clipped at mirror + bound to `maxlength` attr | | Nora #2 (#10969) | Server-side `q.length()` cap missing (CWE-602) | Filed #633 (bundled with `?limit=` and PersonSummaryDTO) | | Sara #1 (#10970) | `@Walter` keystroke race could fire ≥2 fetches on CI | A4 (`b6b9235d`) — switched to `fill('Walter')` for deterministic single input event | | Sara #2 (#10970) | 401/500 silent-empty-state behaviour uncharacterized | A5 (`34b6a8a2`) — two characterization tests pin current silent failure; UX follow-up #634 | | Sara #3 (#10970) | ArrowDown-from-searchbox unit test deferred to E2E | A6 (`2df46b71`) — exercises exported `onKeyDown` directly via `mount` + `flushSync`; full focus-chain E2E filed #635 | | Leonie FINDING-MENTION-004 (#10972) | sr-only search label reuses verb-mismatched "Link person" | A8 (`332d8197`) — new `person_mention_search_label` key in de/en/es; sr-only label switched | | Leonie FINDING-MENTION-005 (#10972) | `w-72` listbox can overflow at 320 px | A9 (`e37351f5`) — `max-w-[calc(100vw-1rem)]` | | Leonie FINDING-MENTION-006 (#10972) | Search input `text-sm` (14 px) below senior 16 px floor | A10 (`4f2880a6`) — `text-base` | | Leonie / future a11y (#10936 / #10972) | `aria-controls` + `aria-activedescendant` chain not wired | Filed #636 | ### Deferred to follow-up issues - #631 — docs: ADR for client-side fetch exception (Markus) - #632 — feat: NFR-PERF-MENTION-001 (Elicit) - #633 — feat(backend): /api/persons cap + limit + DTO (Markus / Nora) - #634 — feat(ui): distinct error feedback on /api/persons failure (Sara / Elicit) - #635 — test(e2e): Playwright keyboard navigation (Sara / Leonie) - #636 — feat(a11y): aria-controls + aria-activedescendant (Leonie) - #637 — docs: convention for Svelte 5 test fixtures (Markus / Felix) ### Verification at new head - `npx vitest run src/lib/shared/discussion/MentionDropdown.svelte.test.ts src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts` — **56 / 56 green** (up from 38 → 46 → 56 across the two review rounds). - `npm run check` — no new errors in changed files. - `npm run lint` — clean. Pushed at `4f2880a6`.
Author
Owner

🏛️ Markus Keller — Senior Application Architect

Verdict: Approved

Round-3 review at head 4f2880a6. The two round-2 architectural concerns I raised in scope (#10959) are both discharged. No new structural concerns surfaced from re-reading the final state. The PR is shippable from an architectural standpoint.

Blockers

None.

Concerns

None new. My round-2 items are tracked:

  • #631 (ADR for client-side fetch exception) — filed, framed correctly: justification, boundary scope ("ONLY for editor suggestion plugins"), auth model, what it enables, what it does NOT enable. Inline comment in PersonMentionEditor.svelte:140-153 still references "Markus #5616: an ADR will formalise this" — once #631 lands, that comment should link the ADR file path. The follow-up acceptance criteria already capture that step, so no extra action here. The framing is exactly what I'd want from the ADR itself; the next PR just needs to fill in Alternatives (+server.ts proxy hop, SvelteKit form action SSR round-trip) and Consequences (Tiptap/ProseMirror suggestion plugins now have a sanctioned escape hatch).
  • #633 (backend ?limit= + query length + PersonSummaryDTO) — filed, three concerns bundled cleanly. Good call combining with Nora's CWE-602 and the pre-existing PersonSummaryDTO audit; a single backend PR closes the whole "client filters what the server should" loop.
  • #637 (test-fixture convention) — filed, with the right acceptance: where to colocate, when to prefer fixture over rerender(), optional ESLint guard against importing .test-fixture.svelte from non-test code. The .test-host.test-fixture rename (commit 7603c8d9) also helps — the suffix now reads as "test infrastructure" rather than "a host component you might mount in prod." That's already an in-PR improvement over my round-2 state.

Suggestions

  1. Single-source-of-truth on the 100-char clip is cleaner than I expected. Re-reading the diff carefully: the clip lives only in MentionDropdown.svelte (MAX_QUERY_LENGTH = 100, applied at the editorQuery.slice(0, ...) mirror initialization, in the $effect mirror sync, and bound to the <input maxlength> attribute). PersonMentionEditor.svelte does not re-clip — it just hands the editor query through dropdownState.editorQuery = renderProps.query and the dropdown clips it on receipt. The runSearch call then receives the already-clipped value via onSearch(searchQuery). This is the right boundary: the dropdown owns the search-input contract, including its length cap. The editor owns the fetch. No coupling, no duplicated constant. Felix's worry from round-2 ("two clips, two places to update") was understandable from the commit message but isn't borne out by the final code. I'd actually leave it exactly as it is.

  2. Inline-getter pattern for editorQuery mounting is worth documenting once. PersonMentionEditor.svelte lines 220-228 use get editorQuery() { return dropdownState.editorQuery; } in the mount() props to make a $state field reach into the imperatively-mounted child reactively. The five-line comment explains it well in context, but this is the second place this pattern appears (along with model: dropdownState), and #637's "convention for Svelte 5 test fixtures" is the natural home for it. Suggest folding the inline-getter pattern into the same docs page that #637 produces — both are workarounds for mount()'s no-prop-accessor limitation.

  3. runSearch's request-token guard is reusable. Not for this PR. But when the second async dropdown (document linking? Tag picker?) needs the same primitive, factor useRequestToken() into shared/utils. The implementation is six lines and the contract is well-defined. Flagged as a "rule of three" anchor.

What's done well

  • Doc-matrix sweep clean. I walked the persona-spec matrix again at this head: no Flyway migration, no new join table or FK, no new backend package, no new controller in an existing backend domain, no new SvelteKit route, no new Docker service, no new external system, no auth/upload flow change, no new ErrorCode, no new Permission, no new domain concept (@mention is a pre-existing term in the codebase), no architectural decision that needs an ADR in this PR (the ADR is correctly deferred to #631 with a tracked follow-up). Zero diagram updates required. This is a clean structural refactor inside one feature package.
  • Module boundaries held across three review rounds. Sixteen commits across two fix passes, zero boundary violations. shared/discussion still owns the editor; shared/utils/debounce is a single-purpose utility with a clear contract; MentionDropdown is still a dumb view (it receives state, fires onSearch, doesn't fetch); the fetch is still in the editor. The test-fixture rename is the cherry on top — it makes the test-time-only nature of the file unmissable.
  • Follow-up issues are well-framed. Each one I checked (#631, #632, #633, #637) has Context, the specific change requested, the reviewer rationale linked, and acceptance criteria. The Round-2 fix-pass summary (#11037) is exemplary practice — every concern maps to either a commit SHA or a tracked issue number. No concern evaporated; no "we'll get to it later." Worth keeping this exact format as the canonical fix-pass-summary template.
  • The items: async () => [] line still reads correctly at round-3. Tiptap needs something on items() to keep the suggestion plugin alive; returning [] and routing the actual fetch through the dropdown's onSearch is the version of this code I'd have written from scratch. The comment block crediting all four reviewers (Markus / Felix / Nora / Sara) makes the rationale legible to the next contributor — they'll see why this looks vestigial and won't "clean it up."
  • onDestroy does the right thing. Cancels the debounce, destroys the editor, unmounts the dropdown if it outlives the editor. The hoisted cancelPendingSearch and mountedDropdown refs make the cleanup contract visible at the top of the file. Defensive without being paranoid.
  • PR description updated. AC-3 row now links #632 — the NFR Elicit flagged is no longer a phantom citation.

— Markus

## 🏛️ Markus Keller — Senior Application Architect **Verdict: ✅ Approved** Round-3 review at head `4f2880a6`. The two round-2 architectural concerns I raised in scope ([#10959](https://git.raddatz.cloud/marcel/familienarchiv/pulls/629#issuecomment-10959)) are both discharged. No new structural concerns surfaced from re-reading the final state. The PR is shippable from an architectural standpoint. ### Blockers None. ### Concerns None new. My round-2 items are tracked: - **#631** (ADR for client-side fetch exception) — filed, framed correctly: justification, boundary scope ("ONLY for editor suggestion plugins"), auth model, what it enables, what it does NOT enable. Inline comment in `PersonMentionEditor.svelte:140-153` still references "Markus #5616: an ADR will formalise this" — once #631 lands, that comment should link the ADR file path. The follow-up acceptance criteria already capture that step, so no extra action here. The framing is exactly what I'd want from the ADR itself; the next PR just needs to fill in `Alternatives` (`+server.ts` proxy hop, SvelteKit form action SSR round-trip) and `Consequences` (Tiptap/ProseMirror suggestion plugins now have a sanctioned escape hatch). - **#633** (backend `?limit=` + query length + `PersonSummaryDTO`) — filed, three concerns bundled cleanly. Good call combining with Nora's CWE-602 and the pre-existing `PersonSummaryDTO` audit; a single backend PR closes the whole "client filters what the server should" loop. - **#637** (test-fixture convention) — filed, with the right acceptance: where to colocate, when to prefer fixture over `rerender()`, optional ESLint guard against importing `.test-fixture.svelte` from non-test code. The `.test-host` → `.test-fixture` rename (commit `7603c8d9`) also helps — the suffix now reads as "test infrastructure" rather than "a host component you might mount in prod." That's already an in-PR improvement over my round-2 state. ### Suggestions 1. **Single-source-of-truth on the 100-char clip is cleaner than I expected.** Re-reading the diff carefully: the clip lives **only** in `MentionDropdown.svelte` (`MAX_QUERY_LENGTH = 100`, applied at the `editorQuery.slice(0, ...)` mirror initialization, in the `$effect` mirror sync, and bound to the `<input maxlength>` attribute). `PersonMentionEditor.svelte` does not re-clip — it just hands the editor query through `dropdownState.editorQuery = renderProps.query` and the dropdown clips it on receipt. The `runSearch` call then receives the already-clipped value via `onSearch(searchQuery)`. **This is the right boundary**: the dropdown owns the search-input contract, including its length cap. The editor owns the fetch. No coupling, no duplicated constant. Felix's worry from round-2 ("two clips, two places to update") was understandable from the commit message but isn't borne out by the final code. I'd actually leave it exactly as it is. 2. **Inline-getter pattern for `editorQuery` mounting is worth documenting once.** `PersonMentionEditor.svelte` lines 220-228 use `get editorQuery() { return dropdownState.editorQuery; }` in the `mount()` props to make a `$state` field reach into the imperatively-mounted child reactively. The five-line comment explains it well in context, but this is the **second** place this pattern appears (along with `model: dropdownState`), and #637's "convention for Svelte 5 test fixtures" is the natural home for it. Suggest folding the inline-getter pattern into the same docs page that #637 produces — both are workarounds for `mount()`'s no-prop-accessor limitation. 3. **`runSearch`'s request-token guard is reusable.** Not for this PR. But when the second async dropdown (document linking? Tag picker?) needs the same primitive, factor `useRequestToken()` into `shared/utils`. The implementation is six lines and the contract is well-defined. Flagged as a "rule of three" anchor. ### What's done well - **Doc-matrix sweep clean.** I walked the persona-spec matrix again at this head: no Flyway migration, no new join table or FK, no new backend package, no new controller in an existing backend domain, no new SvelteKit route, no new Docker service, no new external system, no auth/upload flow change, no new `ErrorCode`, no new `Permission`, no new domain concept (`@mention` is a pre-existing term in the codebase), no architectural decision that needs an ADR *in this PR* (the ADR is correctly deferred to #631 with a tracked follow-up). Zero diagram updates required. This is a clean structural refactor inside one feature package. - **Module boundaries held across three review rounds.** Sixteen commits across two fix passes, zero boundary violations. `shared/discussion` still owns the editor; `shared/utils/debounce` is a single-purpose utility with a clear contract; `MentionDropdown` is still a dumb view (it receives state, fires `onSearch`, doesn't fetch); the fetch is still in the editor. The test-fixture rename is the cherry on top — it makes the test-time-only nature of the file unmissable. - **Follow-up issues are well-framed.** Each one I checked (#631, #632, #633, #637) has Context, the specific change requested, the reviewer rationale linked, and acceptance criteria. The Round-2 fix-pass summary ([#11037](https://git.raddatz.cloud/marcel/familienarchiv/pulls/629#issuecomment-11037)) is exemplary practice — every concern maps to either a commit SHA or a tracked issue number. No concern evaporated; no "we'll get to it later." Worth keeping this exact format as the canonical fix-pass-summary template. - **The `items: async () => []` line still reads correctly at round-3.** Tiptap needs *something* on `items()` to keep the suggestion plugin alive; returning `[]` and routing the actual fetch through the dropdown's `onSearch` is the version of this code I'd have written from scratch. The comment block crediting all four reviewers (Markus / Felix / Nora / Sara) makes the rationale legible to the next contributor — they'll see why this looks vestigial and won't "clean it up." - **`onDestroy` does the right thing.** Cancels the debounce, destroys the editor, unmounts the dropdown if it outlives the editor. The hoisted `cancelPendingSearch` and `mountedDropdown` refs make the cleanup contract visible at the top of the file. Defensive without being paranoid. - **PR description updated.** AC-3 row now links #632 — the NFR Elicit flagged is no longer a phantom citation. — Markus
Author
Owner

🔧 Tobias "Tobi" Wendt — DevOps & Platform Engineer

Verdict: Approved

Round 3 at head 4f2880a6. Like my first two rounds, there is nothing in my scope. This remains a pure-frontend PR: no Compose, no CI workflow, no env var, no port, no secret, no image tag, no healthcheck, no reverse-proxy rule, no observability config, and — confirmed again — no dependency change.

Blockers

None.

Concerns

None in my scope.

Suggestions

  1. The MentionDropdown.test-host.svelteMentionDropdown.test-fixture.svelte rename (commit 7603c8d9) is the right naming convention, but it leaves three siblings still using the old .test-host.svelte suffix:

    • frontend/src/lib/shared/services/confirm.test-host.svelte
    • frontend/src/lib/shared/discussion/PersonMentionEditor.test-host.svelte
    • frontend/src/lib/document/transcription/TranscriptionBlock.test-host.svelte

    Operationally this is fine — neither suffix is referenced by eslint.config.js, vite.config.ts (test include patterns are src/**/*.svelte.{test,spec}.{js,ts} and src/**/*.{test,spec}.{js,ts}, neither pattern matches .test-host.svelte or .test-fixture.svelte), Tailwind content scan, Paraglide, or any Compose / CI file. So zero pipeline risk. But the mixed convention is a paper cut for the next person grepping *.test-host.svelte. Follow-up #637 is already filed to define the convention project-wide — once that lands, the three stragglers should be renamed in one sweep so the codebase reads consistently. Not blocking.

  2. #633 (backend cap + limit + PersonSummaryDTO) and capacity planning. I read the issue body — it tracks exactly the server-side ceiling I flagged in round 1 (@Size(max=100), @Min(1) @Max(50) limit, DTO scrub of notes/alias/title). Two operational notes for when that work lands:

    • Once ?limit= is enforced, a Cache-Control: private, max-age=30 (or similar short-TTL) on GET /api/persons becomes safe to consider — typeahead requests are read-only and benefit from per-tab caching. Worth a 5-minute experiment when the DTO is in place; not before, because today's response leaks notes and we don't want intermediaries caching that.
    • Per-session rate limit: when #633 ships, I'd slot in a Bucket4j filter at ~20 rps per session on /api/persons as the defensive backstop. Frontend self-limits at ~6.7 rps already (150 ms debounce, one inflight request); 20 rps gives 3× headroom for autofocus / batch interactions while still capping a compromised tab. I will file the chore the day #633 merges — flagging now so the work isn't orphaned.
  3. #634 (distinct error UX for /api/persons failure) — when that lands, the silent-failure-empty-state characterization tests in this PR (server failure → empty-state copy, network failure → empty-state copy) will go red and pin the new behaviour. Good. Just noting that the backend response on 401 for this endpoint matters operationally — if a session expires mid-typing, the user sees "Keine Personen gefunden" instead of a redirect. Either #634 covers that explicitly, or it should — please flag if not, I'll add a line item.

  4. Manual browser verification / 320 px viewport — still unchecked. Same caveat as rounds 1 and 2. The PR fix-pass summary cites "dev stack wasn't up" again. Three review rounds in a row is a real cost signal. Standing offer: I'll draft a chore(devex): one-command dev-stack warmup (docker compose up -d --wait + health gate) issue on request — no action without your say-so.

What I checked

  • Files changed since round 2 (fcd4a41b..4f2880a6): per git log, ten commits — A1 58358e84, A2 a8224795, A3 7603c8d9 (the rename), A4 b6b9235d, A5 34b6a8a2, A6 2df46b71, A7 b5455066, A8 332d8197, A9 e37351f5, A10 4f2880a6. Touched files cumulatively at this head: frontend/messages/{de,en,es}.json, frontend/src/lib/shared/discussion/* (3 files), frontend/src/lib/shared/utils/debounce.ts. No others.
  • git diff 0c0a4830..4f2880a6 -- 'frontend/package.json' 'frontend/package-lock.json' 'docker-compose*.yml' '.gitea/' 'Caddyfile' 'backend/pom.xml' '**/eslint*' '**/.eslintrc*' → empty. No dependency, infra, or lint-config change at any point in the PR.
  • Rename impact: eslint.config.js line 85 only matches **/*.spec.ts / **/*.test.ts — does not mention either suffix. vite.config.ts test include patterns do not capture either suffix. No bundler / test-runner / lint behaviour change.
  • #633 (backend follow-up): read; confirms server-side cap on q.length, limit enforcement (@Max(50)), and DTO scrub. Closes my round-1/2 capacity-planning flag once it ships.
  • No new env var (grepped diff: VITE_*, SENTRY_DSN, import.meta.env, process.env, ${{ secrets.* — all unchanged).
  • Network behaviour (cumulative since round 2 baseline): unchanged — still one debounced &limit=5 fetch per search-input edit, no per-keystroke duplicate, request-token guard discards stale responses, onExit cancels pending debounce. Worst-case ~6.7 rps per user.
  • Observability impact: zero. No new metric, no new log statement that would change Loki/Prometheus volume.

This PR moves the operational needle in the right direction (request volume halved versus pre-PR) and nothing it touches creates a new failure mode for ops. Ship it.

— Tobi

## 🔧 Tobias "Tobi" Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** Round 3 at head `4f2880a6`. Like my first two rounds, there is nothing in my scope. This remains a pure-frontend PR: no Compose, no CI workflow, no env var, no port, no secret, no image tag, no healthcheck, no reverse-proxy rule, no observability config, and — confirmed again — no dependency change. ### Blockers None. ### Concerns None in my scope. ### Suggestions 1. **The `MentionDropdown.test-host.svelte` → `MentionDropdown.test-fixture.svelte` rename (commit `7603c8d9`) is the right naming convention, but it leaves three siblings still using the old `.test-host.svelte` suffix:** - `frontend/src/lib/shared/services/confirm.test-host.svelte` - `frontend/src/lib/shared/discussion/PersonMentionEditor.test-host.svelte` - `frontend/src/lib/document/transcription/TranscriptionBlock.test-host.svelte` Operationally this is fine — neither suffix is referenced by `eslint.config.js`, `vite.config.ts` (test `include` patterns are `src/**/*.svelte.{test,spec}.{js,ts}` and `src/**/*.{test,spec}.{js,ts}`, neither pattern matches `.test-host.svelte` or `.test-fixture.svelte`), Tailwind content scan, Paraglide, or any Compose / CI file. So zero pipeline risk. But the mixed convention is a paper cut for the next person grepping `*.test-host.svelte`. Follow-up #637 is already filed to define the convention project-wide — once that lands, the three stragglers should be renamed in one sweep so the codebase reads consistently. **Not blocking.** 2. **#633 (backend cap + `limit` + `PersonSummaryDTO`) and capacity planning.** I read the issue body — it tracks exactly the server-side ceiling I flagged in round 1 (`@Size(max=100)`, `@Min(1) @Max(50) limit`, DTO scrub of `notes`/`alias`/`title`). Two operational notes for when that work lands: - **Once `?limit=` is enforced**, a `Cache-Control: private, max-age=30` (or similar short-TTL) on `GET /api/persons` becomes safe to consider — typeahead requests are read-only and benefit from per-tab caching. Worth a 5-minute experiment when the DTO is in place; not before, because today's response leaks `notes` and we don't want intermediaries caching that. - **Per-session rate limit**: when #633 ships, I'd slot in a `Bucket4j` filter at ~20 rps per session on `/api/persons` as the defensive backstop. Frontend self-limits at ~6.7 rps already (150 ms debounce, one inflight request); 20 rps gives 3× headroom for autofocus / batch interactions while still capping a compromised tab. I will file the chore the day #633 merges — flagging now so the work isn't orphaned. 3. **#634 (distinct error UX for `/api/persons` failure)** — when that lands, the silent-failure-empty-state characterization tests in this PR (`server failure → empty-state copy`, `network failure → empty-state copy`) will go red and pin the new behaviour. Good. Just noting that the *backend* response on 401 for this endpoint matters operationally — if a session expires mid-typing, the user sees "Keine Personen gefunden" instead of a redirect. Either #634 covers that explicitly, or it should — please flag if not, I'll add a line item. 4. **Manual browser verification / 320 px viewport — still unchecked.** Same caveat as rounds 1 and 2. The PR fix-pass summary cites "dev stack wasn't up" again. Three review rounds in a row is a real cost signal. Standing offer: I'll draft a `chore(devex): one-command dev-stack warmup (docker compose up -d --wait + health gate)` issue on request — no action without your say-so. ### What I checked - **Files changed since round 2** (`fcd4a41b..4f2880a6`): per `git log`, ten commits — A1 `58358e84`, A2 `a8224795`, A3 `7603c8d9` (the rename), A4 `b6b9235d`, A5 `34b6a8a2`, A6 `2df46b71`, A7 `b5455066`, A8 `332d8197`, A9 `e37351f5`, A10 `4f2880a6`. Touched files cumulatively at this head: `frontend/messages/{de,en,es}.json`, `frontend/src/lib/shared/discussion/*` (3 files), `frontend/src/lib/shared/utils/debounce.ts`. No others. - **`git diff 0c0a4830..4f2880a6 -- 'frontend/package.json' 'frontend/package-lock.json' 'docker-compose*.yml' '.gitea/' 'Caddyfile' 'backend/pom.xml' '**/eslint*' '**/.eslintrc*'`** → empty. No dependency, infra, or lint-config change at any point in the PR. - **Rename impact**: `eslint.config.js` line 85 only matches `**/*.spec.ts` / `**/*.test.ts` — does not mention either suffix. `vite.config.ts` test `include` patterns do not capture either suffix. No bundler / test-runner / lint behaviour change. - **#633** (backend follow-up): read; confirms server-side cap on `q.length`, `limit` enforcement (`@Max(50)`), and DTO scrub. Closes my round-1/2 capacity-planning flag once it ships. - **No new env var** (grepped diff: `VITE_*`, `SENTRY_DSN`, `import.meta.env`, `process.env`, `${{ secrets.*` — all unchanged). - **Network behaviour** (cumulative since round 2 baseline): unchanged — still one debounced `&limit=5` fetch per search-input edit, no per-keystroke duplicate, request-token guard discards stale responses, onExit cancels pending debounce. Worst-case ~6.7 rps per user. - **Observability impact**: zero. No new metric, no new log statement that would change Loki/Prometheus volume. This PR moves the operational needle in the right direction (request volume halved versus pre-PR) and nothing it touches creates a new failure mode for ops. Ship it. — Tobi
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: Approved

Third pass at head 4f2880a6. I re-traced the seven Acceptance Criteria from #380 through this head, re-checked the three concerns from round 2, and audited the seven follow-up issues (#631–#637) for spec health. All three of my round-2 concerns are now closed; the deferred-actions list has been promoted from PR comment to real Gitea issues; the AC-3 NFR citation in the PR description now points at a real issue. The work is requirements-clean and ready to merge.

Blockers

None.

Concerns

None blocking. Two minor traceability notes captured under Suggestions.

Suggestions

  1. #628 AC-5 — close the loop in the issue body itself before the issue is picked up. Round 2 raised that AC-5 is still an "Open Decision." The resolution in the fix-pass summary is "Posted suggested AC-5 rewrite as comment on #628 (issue body untouched)." The comment (#11033) is well-formed and unambiguous, but a future contributor coming to #628 cold will read the issue body first and see the (a) / (b) ambiguity — they have to scroll to comments to find the resolution. Before #628 enters refinement, fold #11033's wording into the AC-5 line and remove the Open Decisions section. No new work, just a one-line issue-body edit. Tracking this here so it doesn't fall through; not a blocker on #629.

  2. #632 is well-formed but slightly under-specified in two places. The requirement statement is measurable (400 ms, p95, broadband — good Doherty anchor) and the rationale chain is captured (debounce 150 ms + p95 backend ~150 ms + render ~50 ms ≈ 350 ms). Two gaps before this NFR can be executed against:

    • No measurement method specified. Who measures? RUM? Lighthouse? Synthetic Playwright with a stopwatch? Without a method, "95th percentile" is unverifiable. Recommend: add a line — "Measured via Playwright synthetic timing in CI: from fill() on the search input to the first list option being visible."
    • No "what counts as a failure" line. Is breaching this NFR a release blocker, a P2 follow-up, or a metric to monitor? Recommend: add an "If breached" line — e.g., "Breach in CI fails the build; breach in production opens a P2 issue auto-tagged perf-regression."
      These are clarifications, not Connextra deficiencies — file as comments on #632 or fold into the description on the next touch.
  3. The 7-follow-up issues vary in spec density. #633, #635, #636 are implementation-ready. #634 still has a TBD. Specifically #634 ("distinct error feedback") lists three design options (toast / distinct empty-state / inline alert) and a recommendation, but no committed pick — the "Decision needed" section is open. Same anti-pattern as #628 AC-5 round 2: leaves the implementer to re-litigate the design. Before #634 enters a release milestone, the user-need owner (you) needs to pick one and convert it to a single Given-When-Then AC. Until then, #634 is needs-refinement, not ready.

  4. #637 (test-fixture convention) is light on AC. Three bullet points under "Required" but no measurable acceptance — "Convention documented" is unbounded. Recommend converting to two concrete Gherkin ACs: (a) a new fixture-using test in a different directory passes lint; (b) a documentation reference resolves from frontend/CLAUDE.md. Otherwise it sits as a documentation chore that never quite finishes.

  5. MAX_QUERY_LENGTH = 100 is now both a security cap (Nora #1) and an undocumented requirement. The constant is named, commented, and tested in two paths (#629 covers it well). But there's no NFR or requirement-side anchor — if a future contributor needs to know "why 100?", they only have a CWE-400 comment. Recommend a one-line addition to #633 (which already enforces this server-side): "NFR-SEC-MENTION-001: The system shall reject /api/persons queries longer than 100 characters with HTTP 400 and code = INVALID_INPUT." That makes the magic number a requirement, not a defensive number. Optional polish — not a blocker.

Open Questions

  • OQ-MENTION-04 (new): #635's E2E checklist includes axe-core on the dropdown — does the project have axe-core wired into Playwright yet? If not, it's a separate spike. Worth verifying before scheduling #635.
  • OQ-MENTION-02 (carry-over): Now tracked via #634. Closes for me once #634 picks a design.
  • OQ-MENTION-01 (carry-over): Server-side query length cap. Now tracked via #633 (with my suggested NFR addition above). Closes for me once #633 ships.
  • OQ-MENTION-03 (carry-over): Does the backend honor &limit=5 today? Tracked via #633. Closes once #633 ships.

What's done well

  • All three of my round-2 concerns are cleanly closed.
    • Concern 1 (PR description NFR citation): AC-3 row now reads "150 ms debounce — anchored to NFR-PERF-MENTION-001 (#632)" — the NFR exists, the link works, traceability is end-to-end.
    • Concern 2 (#628 AC-5 ambiguity): Resolved via comment #11033 with the exact Given-When-Then rewrite I proposed. The recommended default ("a — unchanged") is now on the record, even if the issue body hasn't been edited yet.
    • Concern 3 (deferred-actions list living only in a PR comment): All seven items are now real Gitea issues (#631–#637), labeled, with bodies, and the fix-pass summary maps each to a follow-up issue number. This is the model for how to close a PR with partial AC delivery transparently — no phantom items, every concern routed.
  • The traceability chain is now complete and recoverable:
    • Issue #380 → 7 ACs → PR #629 AC-status table → test names → assertion lines.
    • AC-3 → NFR-PERF-MENTION-001 (#632) → debounce constant SEARCH_DEBOUNCE_MS = 150.
    • AC-7 → #628 → Connextra rewrite + 5 ACs + AC-5 rewrite comment.
    • This is the cleanest end-to-end requirements trace I've seen on this project. The AC-status table + fix-pass summary + per-concern follow-up issue pattern is the right standard for this codebase. Worth baking into .gitea/PULL_REQUEST_TEMPLATE.md and .claude/personas/*.md as the close-out convention.
  • MAX_QUERY_LENGTH = 100 is paired with two-path test coveragemaxlength attribute on the input AND the mirror-clip path. Layered defence is the right CWE-400 posture and the requirement is now executable on both paths.
  • The new "characterization test" pattern (Sara A5) is a good requirements-engineering primitive. Pinning current silent-failure behaviour explicitly means the day someone implements a distinct error UX (#634), the characterization test goes red and forces an AC update. This converts behaviour ambiguity into a self-resolving signal.
  • The sticky-takeover regression test (keeps the user-edited search value when editorQuery changes after the takeover) executes the user-story invariant ("user takes ownership") as a behaviour contract, not a code comment. That's how you turn a design decision into a durable requirement.
  • Test count growth 38 → 46 → 56 across review rounds is pure additive coverage — no existing test weakened, no AC dropped, no scope creep. The traceability matrix from AC → test name → file is recoverable in under a minute.
  • frontend/messages/{de,en,es}.json parity is maintained — the new person_mention_search_label key landed in all three locales in the same commit (A8 332d8197). i18n NFR debt zero.

— Elicit

## 📋 Elicit — Requirements Engineer **Verdict: ✅ Approved** Third pass at head `4f2880a6`. I re-traced the seven Acceptance Criteria from #380 through this head, re-checked the three concerns from round 2, and audited the seven follow-up issues (#631–#637) for spec health. All three of my round-2 concerns are now closed; the deferred-actions list has been promoted from PR comment to real Gitea issues; the AC-3 NFR citation in the PR description now points at a real issue. The work is requirements-clean and ready to merge. ### Blockers None. ### Concerns None blocking. Two minor traceability notes captured under Suggestions. ### Suggestions 1. **#628 AC-5 — close the loop in the issue body itself before the issue is picked up.** Round 2 raised that AC-5 is still an "Open Decision." The resolution in the fix-pass summary is "Posted suggested AC-5 rewrite as comment on #628 (issue body untouched)." The comment (#11033) is well-formed and unambiguous, but a future contributor coming to #628 cold will read the issue body first and see the (a) / (b) ambiguity — they have to scroll to comments to find the resolution. **Before #628 enters refinement**, fold #11033's wording into the AC-5 line and remove the Open Decisions section. No new work, just a one-line issue-body edit. Tracking this here so it doesn't fall through; not a blocker on #629. 2. **#632 is well-formed but slightly under-specified in two places.** The requirement statement is measurable (400 ms, p95, broadband — good Doherty anchor) and the rationale chain is captured (debounce 150 ms + p95 backend ~150 ms + render ~50 ms ≈ 350 ms). Two gaps before this NFR can be executed against: - **No measurement method specified.** Who measures? RUM? Lighthouse? Synthetic Playwright with a stopwatch? Without a method, "95th percentile" is unverifiable. Recommend: add a line — "Measured via Playwright synthetic timing in CI: from `fill()` on the search input to the first list option being visible." - **No "what counts as a failure" line.** Is breaching this NFR a release blocker, a P2 follow-up, or a metric to monitor? Recommend: add an "If breached" line — e.g., "Breach in CI fails the build; breach in production opens a P2 issue auto-tagged `perf-regression`." These are clarifications, not Connextra deficiencies — file as comments on #632 or fold into the description on the next touch. 3. **The 7-follow-up issues vary in spec density. #633, #635, #636 are implementation-ready. #634 still has a TBD.** Specifically #634 ("distinct error feedback") lists three design options (toast / distinct empty-state / inline alert) and a recommendation, but no committed pick — the "Decision needed" section is open. Same anti-pattern as #628 AC-5 round 2: leaves the implementer to re-litigate the design. **Before #634 enters a release milestone**, the user-need owner (you) needs to pick one and convert it to a single Given-When-Then AC. Until then, #634 is `needs-refinement`, not `ready`. 4. **#637 (test-fixture convention) is light on AC.** Three bullet points under "Required" but no measurable acceptance — "Convention documented" is unbounded. Recommend converting to two concrete Gherkin ACs: (a) a new fixture-using test in a different directory passes lint; (b) a documentation reference resolves from `frontend/CLAUDE.md`. Otherwise it sits as a documentation chore that never quite finishes. 5. **`MAX_QUERY_LENGTH = 100` is now both a security cap (Nora #1) and an undocumented requirement.** The constant is named, commented, and tested in two paths (#629 covers it well). But there's no NFR or requirement-side anchor — if a future contributor needs to know "why 100?", they only have a CWE-400 comment. Recommend a one-line addition to #633 (which already enforces this server-side): "NFR-SEC-MENTION-001: The system shall reject `/api/persons` queries longer than 100 characters with HTTP 400 and `code = INVALID_INPUT`." That makes the magic number a requirement, not a defensive number. Optional polish — not a blocker. ### Open Questions - **OQ-MENTION-04 (new)**: #635's E2E checklist includes axe-core on the dropdown — does the project have axe-core wired into Playwright yet? If not, it's a separate spike. Worth verifying before scheduling #635. - **OQ-MENTION-02 (carry-over)**: Now tracked via #634. Closes for me once #634 picks a design. - **OQ-MENTION-01 (carry-over)**: Server-side query length cap. Now tracked via #633 (with my suggested NFR addition above). Closes for me once #633 ships. - **OQ-MENTION-03 (carry-over)**: Does the backend honor `&limit=5` today? Tracked via #633. Closes once #633 ships. ### What's done well - **All three of my round-2 concerns are cleanly closed.** - Concern 1 (PR description NFR citation): AC-3 row now reads "150 ms debounce — anchored to NFR-PERF-MENTION-001 (#632)" — the NFR exists, the link works, traceability is end-to-end. - Concern 2 (#628 AC-5 ambiguity): Resolved via comment #11033 with the exact Given-When-Then rewrite I proposed. The recommended default ("a — unchanged") is now on the record, even if the issue body hasn't been edited yet. - Concern 3 (deferred-actions list living only in a PR comment): All seven items are now real Gitea issues (#631–#637), labeled, with bodies, and the fix-pass summary maps each to a follow-up issue number. This is the model for how to close a PR with partial AC delivery transparently — no phantom items, every concern routed. - **The traceability chain is now complete and recoverable:** - Issue #380 → 7 ACs → PR #629 AC-status table → test names → assertion lines. - AC-3 → NFR-PERF-MENTION-001 (#632) → debounce constant `SEARCH_DEBOUNCE_MS = 150`. - AC-7 → #628 → Connextra rewrite + 5 ACs + AC-5 rewrite comment. - This is the cleanest end-to-end requirements trace I've seen on this project. **The AC-status table + fix-pass summary + per-concern follow-up issue pattern is the right standard for this codebase.** Worth baking into `.gitea/PULL_REQUEST_TEMPLATE.md` and `.claude/personas/*.md` as the close-out convention. - **`MAX_QUERY_LENGTH = 100` is paired with two-path test coverage** — `maxlength` attribute on the input AND the mirror-clip path. Layered defence is the right CWE-400 posture and the requirement is now executable on both paths. - **The new "characterization test" pattern (Sara A5) is a good requirements-engineering primitive.** Pinning current silent-failure behaviour explicitly means the day someone implements a distinct error UX (#634), the characterization test goes red and forces an AC update. This converts behaviour ambiguity into a self-resolving signal. - **The sticky-takeover regression test (`keeps the user-edited search value when editorQuery changes after the takeover`) executes the user-story invariant ("user takes ownership")** as a behaviour contract, not a code comment. That's how you turn a design decision into a durable requirement. - **Test count growth 38 → 46 → 56 across review rounds is pure additive coverage** — no existing test weakened, no AC dropped, no scope creep. The traceability matrix from AC → test name → file is recoverable in under a minute. - **`frontend/messages/{de,en,es}.json` parity is maintained** — the new `person_mention_search_label` key landed in all three locales in the same commit (A8 `332d8197`). i18n NFR debt zero. — Elicit
Author
Owner

🎨 Leonie Voss — Senior UX Designer & Accessibility Strategist

Verdict: Approved

Third pass at head 4f2880a6. Round 2's three concerns — FINDING-MENTION-004 (verb-mismatched sr-only label), FINDING-MENTION-005 (320 px overflow), FINDING-MENTION-006 (text-sm below the senior floor) — are all discharged. The follow-up I called for (aria-controls + aria-activedescendant) is filed as #636 with the right scope. From my domain I have zero merge-blockers and zero must-fix concerns. Two small notes below — both in the suggestion bucket, neither gating.

Blockers

None.

Concerns

None gating. Everything below is in the suggestion bucket.

Suggestions

S-1 — Locale verb-number drift in person_mention_search_label (WCAG 2.5.3 / 3.1.3; cosmetic)

The three locales landed as:

Locale Value
de.json "Person suchen" (singular)
en.json "Search persons" (plural)
es.json "Buscar persona" (singular)

That's a real but minor inconsistency: German/Spanish announce "Search person, search, edit text", English announces "Search persons, search, edit text". WCAG 2.5.3 (Label in Name) is technically satisfied in each locale because each label is internally consistent with its placeholder ("Namen eingeben…" / "Enter a name…" / "Escribe un nombre…"). Cross-locale parity is a project-style thing, not an a11y violation.

If you want the polish, pluralise all three (Personen suchen / Search persons / Buscar personas). If not, leave it — it does not block merge and the senior transcriber will never hear two locales in one session.

S-2 — MAX_QUERY_LENGTH = 100 silent clip needs a soft user signal eventually (WCAG 3.3.1 Error Identification — informational rather than strict; tracked in suggestion #2 of round 2)

The 100-character cap is the right defence-in-depth play for CWE-400 amplification (Nora). The implementation correctly clips both paths (maxlength attribute on direct user input, editorQuery.slice(0, MAX_QUERY_LENGTH) on the mirror). My concern is what the user perceives when a paste of "Seine Hochwohlgeboren Friedrich Wilhelm Heinrich Karl Ludwig von Hohenzollern-Sigmaringen…" silently drops the tail.

For now: fine. The 99th-percentile transcriber name is well under 100 chars. But when #636 (aria-activedescendant) ships, please bundle a tiny visible+SR-announced hint that only renders at searchQuery.length >= 90:

{#if searchQuery.length >= 90}
  <p id="mention-search-hint" class="px-3 pb-2 font-sans text-xs text-ink-3" aria-live="polite">
    {m.person_mention_search_max_chars({ count: 100 })}
  </p>
{/if}

…wired to <input aria-describedby="mention-search-hint" ... />. This is the "honest about what it does" rule from my Secure-Code §1. Not gating this PR — file as a follow-up alongside #634 if you want.

S-3 — aria-live region remounts on items transition (WCAG 4.1.3 Status Messages; verify under SR)

MentionDropdown.svelte:198-202: the <p aria-live="polite"> lives inside {#if model.items.length === 0}. When items goes 0 → N, the live region unmounts and the list renders; when N → 0, the live region re-mounts. NVDA and JAWS generally re-announce when the live region re-enters the DOM, but VoiceOver on macOS has historically been fussy about that case. The cleaner pattern is one persistent live region above the conditional:

<p class="sr-only" aria-live="polite">
  {#if model.items.length === 0}
    {searchQuery.trim() === '' ? m.person_mention_search_prompt() : m.person_mention_popup_empty()}
  {:else}
    {m.person_mention_results_count({ count: model.items.length })}
  {/if}
</p>
{#if model.items.length === 0}
  <p class="px-3 py-2.5 font-sans text-sm text-ink-3">…visible copy…</p>
{:else}
  …list…
{/if}

That also closes the "N results found" announce gap (the current dropdown silently populates the list without telling SR users how many candidates appeared). Bundle into #636 — it pairs naturally with aria-activedescendant.

Note on #636 (aria-controls follow-up)

Read #636. The acceptance criteria are tight on the mechanics (aria-controls, aria-activedescendant, stable IDs, axe pass, manual SR verification) but thin on announcement quality. Two things I'd add to its body before someone picks it up:

  1. Verification matrix — at minimum: NVDA + Firefox, NVDA + Chrome, VoiceOver + Safari, JAWS + Chrome. The aria-activedescendant chain behaves differently across these — JAWS in particular has a quirk where it announces the option without announcing list position. The issue currently just says "Manual SR test with NVDA or VoiceOver" (singular OR). For a write-surface used by 60+ transcribers on screen readers, the matrix matters.

  2. Announcement script — the issue should specify what should be announced on each interaction (dropdown open, ArrowDown, ArrowUp, Enter, Escape, query change with N results, query change with 0 results). Without that script, the implementer can wire the ARIA attributes correctly and still ship a confusing experience. Suggested format:

@-keystroke:    "Person verlinken, listbox, 0 of 0"
type "Wal":     "Walter de Gruyter, 1 of 3"
ArrowDown:      "Hugo Walther, 2 of 3"
Enter:          (listbox dismissed, focus returns to editor; no announcement)
Escape:         (listbox dismissed; no announcement)

I can post that script as a comment on #636 if you want — say the word. Not blocking this PR; raising it here so the follow-up doesn't ship with the same blind spot.

What's done well

  • FINDING-MENTION-004 — sr-only label correctly split into a distinct person_mention_search_label key, referenced from the <label sr-only for="mention-search">, and the listbox keeps person_mention_btn_label as aria-label. The verb mismatch ("link person" vs "search persons") that I flagged in round 2 is gone. Screen-reader announce now reads as a search control, which is what type="search" warrants.

  • FINDING-MENTION-005 — max-w-[calc(100vw-1rem)] is exactly the defensive cap I asked for. It interacts cleanly with the absolute position.left because max-w constrains the box and the browser then resolves the overflow against the viewport edge — no JS, no flake. The vertical flip strategy at MentionDropdown.svelte:108-118 is orthogonal (it adjusts top/bottom only), so the two strategies don't fight. Tested at the unit level (caps the listbox width to the viewport (320 px reflow guard — Leonie FINDING-MENTION-005)). Confirmed via the rendered class string on the listbox div.

  • FINDING-MENTION-006 — text-base (16 px) brings the search input to the senior-audience floor specified in CLAUDE.md §Dual-Audience Design. The 44 px min-h is preserved so vertical rhythm is undisturbed. Pinned by the new test renders the @mention search input at text-base (16 px senior-audience floor — Leonie FINDING-MENTION-006) — explicitly asserts .toContain('text-base') AND .not.toContain('text-sm'), so regressions are caught both ways. That's good test discipline.

  • MAX_QUERY_LENGTH constant + double-clip (Nora #1 collaboration). Naming the magic, applying it at both edit paths, and pinning both paths in tests (caps the search input at maxlength=100 AND clips a long editorQuery mirror to 100 chars) is a clean architecture move — security-defence-in-depth that doubles as readability. I appreciate the inline comment crediting why two paths exist.

  • aria-live="polite" collapsed into one <p> (preserved from round 2's discharge of FINDING-MENTION-002). Verified at MentionDropdown.svelte:198-202. The new test (announces empty-state copy via aria-live="polite") is correctly scoped to the empty-state branch.

  • Test-fixture rename (test-host.sveltetest-fixture.svelte) ��� small thing, but the .test-fixture extension makes the test-only boundary visible at the filesystem level. Future contributors won't accidentally import the fixture from production code.

  • i18n hygiene — three locales updated, no hardcoded strings, no orphan keys, the test asserts copy via m.person_mention_search_prompt() rather than literal strings so locale changes don't break the test suite.

  • Brand tokens intacttext-ink, text-ink-2, text-ink-3, bg-surface, bg-canvas, border-line, text-brand-navy, bg-brand-mint/20, ring-brand-navy. No raw hex, no Tailwind palette colours, every value flows through the semantic token system. Across three review rounds.

  • Atomic commit map in the fix-pass summary — every round-2 concern maps to one commit hash. Reviewer cost per round dropped meaningfully. Adopt this as the project PR template.

Note for the senior audience

This is the rare case where every round-2 concern from my domain pointed in the same direction — "senior transcribers need predictability and visibility on the write surface" — and the fix pass implemented all three with no UX regressions. The dropdown now:

  1. Reads correctly to a screen-reader user ("Search persons" instead of "Link person")
  2. Reflows correctly at 320 px (max-w-[calc(100vw-1rem)] with no JS)
  3. Renders type at the body-text minimum (text-base matching the project's 16 px floor)

The remaining work — aria-controls/aria-activedescendant, error UX, axe pass at 320 px, the four follow-up issues (#634, #635, #636 + the MAX_QUERY soft signal) — is correctly out of scope for this PR and tracked. The follow-up grouping (one PR-scope-creep prevention issue per axis: backend cap, error UX, E2E, ARIA, fixture docs) is the right granularity.

Ship it.

— Leonie

## 🎨 Leonie Voss — Senior UX Designer & Accessibility Strategist **Verdict: ✅ Approved** Third pass at head `4f2880a6`. Round 2's three concerns — FINDING-MENTION-004 (verb-mismatched sr-only label), FINDING-MENTION-005 (320 px overflow), FINDING-MENTION-006 (`text-sm` below the senior floor) — are all discharged. The follow-up I called for (`aria-controls` + `aria-activedescendant`) is filed as #636 with the right scope. From my domain I have zero merge-blockers and zero must-fix concerns. Two small notes below — both in the suggestion bucket, neither gating. ### Blockers None. ### Concerns None gating. Everything below is in the suggestion bucket. ### Suggestions **S-1 — Locale verb-number drift in `person_mention_search_label`** (WCAG 2.5.3 / 3.1.3; cosmetic) The three locales landed as: | Locale | Value | |---|---| | `de.json` | `"Person suchen"` (singular) | | `en.json` | `"Search persons"` (plural) | | `es.json` | `"Buscar persona"` (singular) | That's a real but minor inconsistency: German/Spanish announce "Search person, search, edit text", English announces "Search persons, search, edit text". WCAG 2.5.3 (Label in Name) is technically satisfied in each locale because each label is internally consistent with its placeholder ("Namen eingeben…" / "Enter a name…" / "Escribe un nombre…"). Cross-locale parity is a project-style thing, not an a11y violation. If you want the polish, pluralise all three (`Personen suchen` / `Search persons` / `Buscar personas`). If not, leave it — it does not block merge and the senior transcriber will never hear two locales in one session. **S-2 — `MAX_QUERY_LENGTH = 100` silent clip needs a soft user signal eventually** (WCAG 3.3.1 Error Identification — informational rather than strict; tracked in suggestion #2 of round 2) The 100-character cap is the right defence-in-depth play for CWE-400 amplification (Nora). The implementation correctly clips both paths (`maxlength` attribute on direct user input, `editorQuery.slice(0, MAX_QUERY_LENGTH)` on the mirror). My concern is what the user perceives when a paste of "Seine Hochwohlgeboren Friedrich Wilhelm Heinrich Karl Ludwig von Hohenzollern-Sigmaringen…" silently drops the tail. For now: fine. The 99th-percentile transcriber name is well under 100 chars. But when #636 (aria-activedescendant) ships, please bundle a tiny visible+SR-announced hint that only renders at `searchQuery.length >= 90`: ```svelte {#if searchQuery.length >= 90} <p id="mention-search-hint" class="px-3 pb-2 font-sans text-xs text-ink-3" aria-live="polite"> {m.person_mention_search_max_chars({ count: 100 })} </p> {/if} ``` …wired to `<input aria-describedby="mention-search-hint" ... />`. This is the "honest about what it does" rule from my Secure-Code §1. Not gating this PR — file as a follow-up alongside #634 if you want. **S-3 — `aria-live` region remounts on items transition** (WCAG 4.1.3 Status Messages; verify under SR) `MentionDropdown.svelte:198-202`: the `<p aria-live="polite">` lives inside `{#if model.items.length === 0}`. When `items` goes 0 → N, the live region unmounts and the list renders; when N → 0, the live region re-mounts. NVDA and JAWS *generally* re-announce when the live region re-enters the DOM, but VoiceOver on macOS has historically been fussy about that case. The cleaner pattern is one persistent live region above the conditional: ```svelte <p class="sr-only" aria-live="polite"> {#if model.items.length === 0} {searchQuery.trim() === '' ? m.person_mention_search_prompt() : m.person_mention_popup_empty()} {:else} {m.person_mention_results_count({ count: model.items.length })} {/if} </p> {#if model.items.length === 0} <p class="px-3 py-2.5 font-sans text-sm text-ink-3">…visible copy…</p> {:else} …list… {/if} ``` That also closes the "N results found" announce gap (the current dropdown silently populates the list without telling SR users how many candidates appeared). Bundle into #636 — it pairs naturally with `aria-activedescendant`. ### Note on #636 (aria-controls follow-up) Read #636. The acceptance criteria are tight on the *mechanics* (`aria-controls`, `aria-activedescendant`, stable IDs, axe pass, manual SR verification) but thin on **announcement quality**. Two things I'd add to its body before someone picks it up: 1. **Verification matrix** — at minimum: NVDA + Firefox, NVDA + Chrome, VoiceOver + Safari, JAWS + Chrome. The aria-activedescendant chain behaves differently across these — JAWS in particular has a quirk where it announces the option without announcing list position. The issue currently just says "Manual SR test with NVDA or VoiceOver" (singular OR). For a write-surface used by 60+ transcribers on screen readers, the matrix matters. 2. **Announcement script** — the issue should specify *what should be announced* on each interaction (dropdown open, ArrowDown, ArrowUp, Enter, Escape, query change with N results, query change with 0 results). Without that script, the implementer can wire the ARIA attributes correctly and still ship a confusing experience. Suggested format: ``` @-keystroke: "Person verlinken, listbox, 0 of 0" type "Wal": "Walter de Gruyter, 1 of 3" ArrowDown: "Hugo Walther, 2 of 3" Enter: (listbox dismissed, focus returns to editor; no announcement) Escape: (listbox dismissed; no announcement) ``` I can post that script as a comment on #636 if you want — say the word. Not blocking this PR; raising it here so the follow-up doesn't ship with the same blind spot. ### What's done well - **FINDING-MENTION-004 — sr-only label** correctly split into a distinct `person_mention_search_label` key, referenced from the `<label sr-only for="mention-search">`, and the listbox keeps `person_mention_btn_label` as `aria-label`. The verb mismatch ("link person" vs "search persons") that I flagged in round 2 is gone. Screen-reader announce now reads as a search control, which is what `type="search"` warrants. - **FINDING-MENTION-005 — `max-w-[calc(100vw-1rem)]`** is exactly the defensive cap I asked for. It interacts cleanly with the absolute `position.left` because `max-w` constrains the *box* and the browser then resolves the overflow against the viewport edge — no JS, no flake. The vertical flip strategy at `MentionDropdown.svelte:108-118` is orthogonal (it adjusts `top`/`bottom` only), so the two strategies don't fight. Tested at the unit level (`caps the listbox width to the viewport (320 px reflow guard — Leonie FINDING-MENTION-005)`). Confirmed via the rendered class string on the listbox div. - **FINDING-MENTION-006 — `text-base` (16 px)** brings the search input to the senior-audience floor specified in CLAUDE.md §Dual-Audience Design. The 44 px `min-h` is preserved so vertical rhythm is undisturbed. Pinned by the new test `renders the @mention search input at text-base (16 px senior-audience floor — Leonie FINDING-MENTION-006)` — explicitly asserts `.toContain('text-base')` AND `.not.toContain('text-sm')`, so regressions are caught both ways. That's good test discipline. - **`MAX_QUERY_LENGTH` constant + double-clip** (Nora #1 collaboration). Naming the magic, applying it at both edit paths, and pinning *both* paths in tests (`caps the search input at maxlength=100` AND `clips a long editorQuery mirror to 100 chars`) is a clean architecture move — security-defence-in-depth that doubles as readability. I appreciate the inline comment crediting why two paths exist. - **`aria-live="polite"` collapsed into one `<p>`** (preserved from round 2's discharge of FINDING-MENTION-002). Verified at `MentionDropdown.svelte:198-202`. The new test (`announces empty-state copy via aria-live="polite"`) is correctly scoped to the empty-state branch. - **Test-fixture rename** (`test-host.svelte` → `test-fixture.svelte`) ��� small thing, but the `.test-fixture` extension makes the test-only boundary visible at the filesystem level. Future contributors won't accidentally import the fixture from production code. - **i18n hygiene** — three locales updated, no hardcoded strings, no orphan keys, the test asserts copy via `m.person_mention_search_prompt()` rather than literal strings so locale changes don't break the test suite. - **Brand tokens intact** — `text-ink`, `text-ink-2`, `text-ink-3`, `bg-surface`, `bg-canvas`, `border-line`, `text-brand-navy`, `bg-brand-mint/20`, `ring-brand-navy`. No raw hex, no Tailwind palette colours, every value flows through the semantic token system. Across three review rounds. - **Atomic commit map in the fix-pass summary** — every round-2 concern maps to one commit hash. Reviewer cost per round dropped meaningfully. Adopt this as the project PR template. ### Note for the senior audience This is the rare case where every round-2 concern from my domain pointed in the same direction — "senior transcribers need predictability and visibility on the write surface" — and the fix pass implemented all three with no UX regressions. The dropdown now: 1. **Reads correctly to a screen-reader user** ("Search persons" instead of "Link person") 2. **Reflows correctly at 320 px** (`max-w-[calc(100vw-1rem)]` with no JS) 3. **Renders type at the body-text minimum** (`text-base` matching the project's 16 px floor) The remaining work — `aria-controls`/`aria-activedescendant`, error UX, axe pass at 320 px, the four follow-up issues (#634, #635, #636 + the MAX_QUERY soft signal) — is correctly out of scope for this PR and tracked. The follow-up grouping (one PR-scope-creep prevention issue per axis: backend cap, error UX, E2E, ARIA, fixture docs) is the right granularity. Ship it. — Leonie
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: Approved

Round-3 re-read at 4f2880a6. I commit-by-commit-checked the 10 round-2 fixes against the four prior concerns I'd flagged and one new TDD spot-check. All five are properly closed at the code level — the new tests are behaviour-first, not tautologies, and would catch real regressions. One small follow-up nit on test-fixture naming consistency below.

Blockers

None.

Concerns

  1. .test-fixture rename is half-appliedfrontend/src/lib/shared/discussion/MentionDropdown.test-host.svelte got renamed to .test-fixture.svelte (commit 7603c8d9), but its sibling frontend/src/lib/shared/discussion/PersonMentionEditor.test-host.svelte (still imported at PersonMentionEditor.svelte.spec.ts:11) was missed. The fix-pass summary (#11037 row A3) phrases this as if both files moved, but only one did. The original concern (eslint-boundaries visibility, grep boundary) still applies to the editor host. Not a blocker — the test-fixture-naming-convention follow-up (#637) will catch it — but worth a one-line rename + import update in the next pass.

Suggestions

  1. onKeyDown test inconsistency: flushSync on Arrow keys, raw call on EnterMentionDropdown.svelte.test.ts:281 and :309 wrap the call in flushSync(() => …) because the test reads aria-selected immediately after; :338 (Enter) doesn't, because it only asserts command.mock.calls. Both work, but the asymmetry forces the next reader to figure out why. Wrap all four in flushSync for uniformity, or drop it on the ArrowDown/ArrowUp tests and rely on await tick() post-mutation. Cosmetic.

  2. Long-typed-query divergence between dropdown and commandMentionDropdown.svelte:54 clips the mirror to MAX_QUERY_LENGTH (100), but PersonMentionEditor.svelte:234 (the command callback) uses renderProps.query directly as the inserted displayName. So a 200-char @-suffix in the editor shows 100 chars in the search input but inserts 200 chars as the mention's displayName. The CWE-400 layered cap is correct (fetch query is bounded), but the user-visible divergence between "what I searched" and "what got inserted" deserves a thought. Either also clip at the command boundary or accept the trade-off and add a one-line comment on PersonMentionEditor.svelte:234 noting the asymmetry is intentional.

What's done well

  • A1 fix is real, not cosmetic. PersonMentionEditor.svelte:277 calls debouncedSearch.cancel() on the closure-scoped debounce inside onExit, with a comment explaining why the hoisted cancelPendingSearch reference would be stale by the time the trailing call fires. The new test at PersonMentionEditor.svelte.spec.ts:355-391 is behavior-first — it waits for any in-flight fetch to settle, queues a fresh fill('Walter'), fires Escape via the editor (so Tiptap's suggestion plugin's Escape handler actually triggers onExit), then asserts walterFetches.length === 0 past the debounce window. Removing the debouncedSearch.cancel() line would turn this red. That's the strong assertion shape.

  • A2 comment is exactly the right length and altitude. MentionDropdown.svelte:45-51 — six lines, names the alternative ($derived), names why it's wrong (read-only, would clobber bind:value), and credits the latch (userHasEdited). Peer of the highlightedIndex comment two functions below. Reads cleanly.

  • A3 rename's intent is right (test-fixture suffix makes the boundary visible to eslint-boundaries and to grep), even though only one of two files was renamed. The fix-pass commit message is accurate that the dropdown fixture renamed; the summary in #11037 overstates the scope. Easy follow-up.

  • A6 (onKeyDown unit tests) are production-path tests, not tautologies. MentionDropdown.svelte.test.ts:259-366 mounts the production dropdown, captures instance.onKeyDown (the real Svelte 5 export — same function Tiptap forwards events into), and calls it with a synthetic KeyboardEvent. The first assertion reads aria-selected off the DOM after flushSync, the Enter assertion reads command.mock.calls. If someone broke the modulo arithmetic at MentionDropdown.svelte:121-126 (e.g. wrote % len instead of % Math.max(len, 1) and the items list was empty), the ArrowDown test catches it. If someone removed the Escape early-return, the Escape test catches it. Real coverage.

  • A7 (MAX_QUERY_LENGTH layered defence) is correctly threaded. Constant declared at MentionDropdown.svelte:17, used at line 42 (initial state), line 54 (mirror effect), and bound to maxlength at line 184. The two test cases at MentionDropdown.svelte.test.ts:181-197 cover both paths (direct input maxlength + mirror clip). Removing either clip breaks one of the tests. Clean.

  • Request-token guard at PersonMentionEditor.svelte:195-216 is textbook race protection. requestId bumps synchronously in onSearch, gets captured at runSearch start, checked twice (after await fetch, after await res.json()). The stale-response test (PersonMentionEditor.svelte.spec.ts:295-319) holds a fetch open via an unresolved promise, clears the input, then resolves — and asserts the dropdown stayed empty. A one-line removal of either of the two if (id !== requestId) return guards turns this red. Defensible.

  • debounce.cancel() JSDoc (debounce.ts:1-7) now spells out "DROPS (does not flush)". The lodash flush-on-cancel default is surprising; the JSDoc serves its purpose.

  • Server-failure characterization tests (PersonMentionEditor.svelte.spec.ts:325-351) — these pin the current silent-failure UX (Keine Personen gefunden on both 500 and network error) so a future implementer of distinct error UX is forced to update the assertions. That's the right pattern for "behavior we accept but want to be intentional about."

— Felix

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ✅ Approved** Round-3 re-read at `4f2880a6`. I commit-by-commit-checked the 10 round-2 fixes against the four prior concerns I'd flagged and one new TDD spot-check. All five are properly closed at the code level — the new tests are behaviour-first, not tautologies, and would catch real regressions. One small follow-up nit on test-fixture naming consistency below. ### Blockers None. ### Concerns 1. **`.test-fixture` rename is half-applied** — `frontend/src/lib/shared/discussion/MentionDropdown.test-host.svelte` got renamed to `.test-fixture.svelte` (commit `7603c8d9`), but its sibling `frontend/src/lib/shared/discussion/PersonMentionEditor.test-host.svelte` (still imported at `PersonMentionEditor.svelte.spec.ts:11`) was missed. The fix-pass summary (#11037 row A3) phrases this as if both files moved, but only one did. The original concern (eslint-boundaries visibility, grep boundary) still applies to the editor host. Not a blocker — the test-fixture-naming-convention follow-up (#637) will catch it — but worth a one-line rename + import update in the next pass. ### Suggestions 1. **`onKeyDown` test inconsistency: `flushSync` on Arrow keys, raw call on Enter** — `MentionDropdown.svelte.test.ts:281` and `:309` wrap the call in `flushSync(() => …)` because the test reads `aria-selected` immediately after; `:338` (Enter) doesn't, because it only asserts `command.mock.calls`. Both work, but the asymmetry forces the next reader to figure out why. Wrap all four in `flushSync` for uniformity, or drop it on the ArrowDown/ArrowUp tests and rely on `await tick()` post-mutation. Cosmetic. 2. **Long-typed-query divergence between dropdown and command** — `MentionDropdown.svelte:54` clips the mirror to `MAX_QUERY_LENGTH` (100), but `PersonMentionEditor.svelte:234` (the `command` callback) uses `renderProps.query` directly as the inserted `displayName`. So a 200-char `@`-suffix in the editor shows 100 chars in the search input but inserts 200 chars as the mention's displayName. The CWE-400 layered cap is correct (fetch query is bounded), but the user-visible divergence between "what I searched" and "what got inserted" deserves a thought. Either also clip at the command boundary or accept the trade-off and add a one-line comment on `PersonMentionEditor.svelte:234` noting the asymmetry is intentional. ### What's done well - **A1 fix is real, not cosmetic.** `PersonMentionEditor.svelte:277` calls `debouncedSearch.cancel()` on the *closure-scoped* debounce inside `onExit`, with a comment explaining why the hoisted `cancelPendingSearch` reference would be stale by the time the trailing call fires. The new test at `PersonMentionEditor.svelte.spec.ts:355-391` is behavior-first — it waits for any in-flight fetch to settle, queues a fresh `fill('Walter')`, fires Escape via the editor (so Tiptap's suggestion plugin's Escape handler actually triggers `onExit`), then asserts `walterFetches.length === 0` past the debounce window. Removing the `debouncedSearch.cancel()` line would turn this red. That's the strong assertion shape. - **A2 comment is exactly the right length and altitude.** `MentionDropdown.svelte:45-51` — six lines, names the alternative (`$derived`), names why it's wrong (read-only, would clobber `bind:value`), and credits the latch (`userHasEdited`). Peer of the `highlightedIndex` comment two functions below. Reads cleanly. - **A3 rename's intent is right** (test-fixture suffix makes the boundary visible to eslint-boundaries and to grep), even though only one of two files was renamed. The fix-pass commit message is accurate that the dropdown fixture renamed; the summary in #11037 overstates the scope. Easy follow-up. - **A6 (`onKeyDown` unit tests) are production-path tests, not tautologies.** `MentionDropdown.svelte.test.ts:259-366` mounts the *production* dropdown, captures `instance.onKeyDown` (the real Svelte 5 export — same function Tiptap forwards events into), and calls it with a synthetic `KeyboardEvent`. The first assertion reads `aria-selected` off the DOM after `flushSync`, the Enter assertion reads `command.mock.calls`. If someone broke the modulo arithmetic at `MentionDropdown.svelte:121-126` (e.g. wrote `% len` instead of `% Math.max(len, 1)` and the items list was empty), the ArrowDown test catches it. If someone removed the Escape early-return, the Escape test catches it. Real coverage. - **A7 (`MAX_QUERY_LENGTH` layered defence) is correctly threaded.** Constant declared at `MentionDropdown.svelte:17`, used at line 42 (initial state), line 54 (mirror effect), and bound to `maxlength` at line 184. The two test cases at `MentionDropdown.svelte.test.ts:181-197` cover both paths (direct input maxlength + mirror clip). Removing either clip breaks one of the tests. Clean. - **Request-token guard at `PersonMentionEditor.svelte:195-216` is textbook race protection.** `requestId` bumps synchronously in `onSearch`, gets captured at runSearch start, checked twice (after `await fetch`, after `await res.json()`). The stale-response test (`PersonMentionEditor.svelte.spec.ts:295-319`) holds a fetch open via an unresolved promise, clears the input, then resolves — and asserts the dropdown stayed empty. A one-line removal of either of the two `if (id !== requestId) return` guards turns this red. Defensible. - **`debounce.cancel()` JSDoc** (`debounce.ts:1-7`) now spells out "DROPS (does not flush)". The lodash flush-on-cancel default is surprising; the JSDoc serves its purpose. - **Server-failure characterization tests** (`PersonMentionEditor.svelte.spec.ts:325-351`) — these pin the current silent-failure UX (`Keine Personen gefunden` on both 500 and network error) so a future implementer of distinct error UX is forced to update the assertions. That's the right pattern for "behavior we accept but want to be intentional about." — Felix
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

Round-3 review at head 4f2880a6. Re-traced both of my round-2 concerns (#10969) plus the new attack surface introduced by the fix pass. Both are properly closed:

  • Nora #1 (CWE-400 layered cap)MentionDropdown.svelte:18 declares const MAX_QUERY_LENGTH = 100 with a 6-line threat-model comment naming the bypass path it closes. The constant is applied at all three entry points to searchQuery:

    1. Initial-mount path: let searchQuery = $state(untrack(() => editorQuery.slice(0, MAX_QUERY_LENGTH))) (MentionDropdown.svelte:48) — clips on first paint.
    2. Mirror-update path: $effect(() => { if (!userHasEdited) searchQuery = editorQuery.slice(0, MAX_QUERY_LENGTH); }) (MentionDropdown.svelte:57-61) — clips on every editor keystroke until the user takes ownership.
    3. Direct-input path: <input maxlength={MAX_QUERY_LENGTH} … /> (MentionDropdown.svelte:186) — DOM-level cap once the user has typed into the search box.

    Because onSearch fires off $effect(() => onSearch(searchQuery)) after the mirror clip, runSearch in PersonMentionEditor.svelte:194-216 can never receive a query longer than 100 chars from this component. Test MentionDropdown.svelte.test.ts:179-184 ("caps the search input at maxlength=100") and MentionDropdown.svelte.test.ts:188-196 ("clips a long editorQuery mirror to 100 chars") pin both ends. The CWE-400 layered defence is now complete.

  • Nora #2 (CWE-602 server-side cap)#633 is filed and the spec captures the three required controls in PersonController.search:

    • @Size(max = 100) @RequestParam String q (CWE-400/602)
    • @Min(1) @Max(50) Integer limit (CWE-400 amplification, response side)
    • PersonSummaryDTO { id, displayName, birthYear, deathYear, personType, familyMember } strips notes, alias, title (CWE-200)

    Acceptance criteria are precise enough to act on: integration test asserts 400 on 101-char query, limit honoured up to 50, PersonSummaryDTO exposed via OpenAPI. Issue body cites my round-1/round-2 comments and Markus's twin concerns. Good triage.

Blockers

None.

Concerns

None for this PR.

Suggestions

  1. Test-fixture rename verification — Vite/SvelteKit tree-shaking is doing the work, no explicit exclusion exists (vite.config.ts:1-103). The rename .test-host.svelte.test-fixture.svelte is fine because the only import of MentionDropdown.test-fixture.svelte is in MentionDropdown.svelte.test.ts (which the client-test project matches via src/**/*.svelte.{test,spec}.{js,ts} and the production build never reaches). I confirmed by reading the vite config — there's no production glob that would pull .svelte files unreachable from routes. The fixture stays out of the prod bundle.

    Defence-in-depth follow-up (low priority): add an ESLint no-restricted-imports rule banning imports of **/*.test-fixture.svelte from non-test files. Right now an accidental autocomplete-driven import from a route would silently bundle it. The cost is one rule entry; the benefit is making the "test-only" boundary lint-enforced instead of name-convention-enforced. Worth filing as a follow-up.

  2. #633 covers the length/limit/DTO triple but omits rate-limiting on /api/persons — A capped query length blocks one DOS vector; an authenticated user (or compromised tab) can still hammer /api/persons?q=ab 10,000 times. Two options for #633 (or a sibling issue):

    • Add LoginRateLimiter-style throttling per authenticated principal on /api/persons (e.g. 60 req/min). CWE-770 (Allocation of Resources Without Limits or Throttling).
    • Or accept the risk explicitly with a one-line note in the security review docs — typeahead endpoints are inherently chatty and a per-principal rate limit can break legitimate fast typers.

    Note /api/persons is a GET, so CSRF protection is irrelevant (no state mutation). I confirmed nothing in this PR turns /api/persons into a mutating endpoint. No action needed on CSRF.

What's done well

  • CWE-400 cap is now truly layered. Three independent clip points (MentionDropdown.svelte:48, :57-61, :186) all enforce MAX_QUERY_LENGTH = 100. The threat-model comment at :11-17 is exactly the kind of explicit security documentation that survives a refactor — it names what is being defended against, names the bypass path that was previously open, and names the server-side issue tracking the rest. This is the readable-security-code pattern from my persona doc applied perfectly.
  • Single constant, not three magic numbers. Refactoring the cap value (say, to 200 once the backend lands) is a one-line change. Compare to the round-2 state where the cap was hard-coded only on the <input>.
  • The mirror-→-onSearch path closes the editor-bypass: because onSearch reads searchQuery after the clip rather than editorQuery directly (MentionDropdown.svelte:67-69), a script that programmatically changes the parent's editorQuery prop — e.g. via the test fixture's setEditorQuery exported in MentionDropdown.test-fixture.svelte — cannot bypass the cap. Tested explicitly via the fixture.
  • Test-fixture rename: .test-fixture.svelte is a clearer signal than .test-host.svelte and matches the prevailing Svelte 5 fixture convention. The file is 32 lines, single-purpose, and clearly off the production module graph.
  • #633 is well-scoped. All three frontend → backend handoffs (length cap, limit cap, DTO) are bundled into one backend PR. Cross-references my round-1/round-2 comments and Markus's twin findings. The "frontend Person cast in runSearch updated to PersonSummaryDTO (separate PR)" line correctly anticipates the type-regeneration churn.
  • No new attack surface introduced by the round-2 changes. The get editorQuery() { return dropdownState.editorQuery; } accessor in PersonMentionEditor.svelte:225-227 is a read-only getter — it cannot be exploited to leak parent state because dropdownState is a $state proxy owned by the host editor. The request-token guard from round-1 is intact.
  • encodeURIComponent retained, rel="noopener noreferrer" retained, no innerHTML/template-string URL interpolation/eval. Clean.

Note for follow-up

  • #633 should land before this PR's MAX_QUERY_LENGTH slice can be relied on as anything more than a UX hint. Until then, keep both layers — the frontend cap survives even if the backend cap regresses (defence in depth).
  • The Nora #5618 #3 reference at PersonMentionEditor.svelte:151-152 (PersonSummaryDTO notes leak) is now folded into #633's DTO scope. Once #633 ships, the inline reference can be removed.
  • Consider opening a sibling rate-limit issue (or annotating #633 with a section on rate-limiting /api/persons). CWE-770 is the unaddressed sliver.
  • The lint-enforced no-restricted-imports rule for .test-fixture.svelte is the only forward-defence I'd suggest. Not blocking; not in scope for this PR.

Approving. The round-2 layered cap fix is exemplary — explicit constant, threat-model comment, three independent enforcement points, regression tests for two of them. This is the standard I want for the rest of the codebase.

— Nora

## 🔒 Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** Round-3 review at head `4f2880a6`. Re-traced both of my round-2 concerns ([#10969](https://git.raddatz.cloud/marcel/familienarchiv/pulls/629#issuecomment-10969)) plus the new attack surface introduced by the fix pass. Both are properly closed: - **Nora #1 (CWE-400 layered cap)** — `MentionDropdown.svelte:18` declares `const MAX_QUERY_LENGTH = 100` with a 6-line threat-model comment naming the bypass path it closes. The constant is applied at **all three** entry points to `searchQuery`: 1. **Initial-mount path**: `let searchQuery = $state(untrack(() => editorQuery.slice(0, MAX_QUERY_LENGTH)))` (`MentionDropdown.svelte:48`) — clips on first paint. 2. **Mirror-update path**: `$effect(() => { if (!userHasEdited) searchQuery = editorQuery.slice(0, MAX_QUERY_LENGTH); })` (`MentionDropdown.svelte:57-61`) — clips on every editor keystroke until the user takes ownership. 3. **Direct-input path**: `<input maxlength={MAX_QUERY_LENGTH} … />` (`MentionDropdown.svelte:186`) — DOM-level cap once the user has typed into the search box. Because `onSearch` fires off `$effect(() => onSearch(searchQuery))` *after* the mirror clip, `runSearch` in `PersonMentionEditor.svelte:194-216` can never receive a query longer than 100 chars from this component. Test `MentionDropdown.svelte.test.ts:179-184` ("caps the search input at maxlength=100") and `MentionDropdown.svelte.test.ts:188-196` ("clips a long editorQuery mirror to 100 chars") pin both ends. **The CWE-400 layered defence is now complete.** - **Nora #2 (CWE-602 server-side cap)** — [#633](https://git.raddatz.cloud/marcel/familienarchiv/issues/633) is filed and the spec captures the three required controls in `PersonController.search`: - `@Size(max = 100) @RequestParam String q` (CWE-400/602) - `@Min(1) @Max(50) Integer limit` (CWE-400 amplification, response side) - `PersonSummaryDTO { id, displayName, birthYear, deathYear, personType, familyMember }` strips `notes`, `alias`, `title` (CWE-200) Acceptance criteria are precise enough to act on: integration test asserts 400 on 101-char query, limit honoured up to 50, `PersonSummaryDTO` exposed via OpenAPI. Issue body cites my round-1/round-2 comments and Markus's twin concerns. Good triage. ### Blockers None. ### Concerns None for this PR. ### Suggestions 1. **Test-fixture rename verification — Vite/SvelteKit tree-shaking is doing the work, no explicit exclusion exists** (`vite.config.ts:1-103`). The rename `.test-host.svelte` → `.test-fixture.svelte` is fine *because* the only import of `MentionDropdown.test-fixture.svelte` is in `MentionDropdown.svelte.test.ts` (which the client-test project matches via `src/**/*.svelte.{test,spec}.{js,ts}` and the production build never reaches). I confirmed by reading the vite config — there's no production glob that would pull `.svelte` files unreachable from routes. The fixture stays out of the prod bundle. Defence-in-depth follow-up (low priority): add an ESLint `no-restricted-imports` rule banning imports of `**/*.test-fixture.svelte` from non-test files. Right now an accidental autocomplete-driven import from a route would silently bundle it. The cost is one rule entry; the benefit is making the "test-only" boundary lint-enforced instead of name-convention-enforced. Worth filing as a follow-up. 2. **#633 covers the length/limit/DTO triple but omits rate-limiting on `/api/persons`** — A capped query length blocks one DOS vector; an authenticated user (or compromised tab) can still hammer `/api/persons?q=ab` 10,000 times. Two options for #633 (or a sibling issue): - Add `LoginRateLimiter`-style throttling per authenticated principal on `/api/persons` (e.g. 60 req/min). CWE-770 (Allocation of Resources Without Limits or Throttling). - Or accept the risk explicitly with a one-line note in the security review docs — typeahead endpoints are inherently chatty and a per-principal rate limit can break legitimate fast typers. Note `/api/persons` is a `GET`, so CSRF protection is irrelevant (no state mutation). I confirmed nothing in this PR turns `/api/persons` into a mutating endpoint. No action needed on CSRF. ### What's done well - **CWE-400 cap is now truly layered.** Three independent clip points (`MentionDropdown.svelte:48`, `:57-61`, `:186`) all enforce `MAX_QUERY_LENGTH = 100`. The threat-model comment at `:11-17` is exactly the kind of explicit security documentation that survives a refactor — it names what is being defended against, names the bypass path that was previously open, and names the server-side issue tracking the rest. This is the readable-security-code pattern from my persona doc applied perfectly. - **Single constant, not three magic numbers.** Refactoring the cap value (say, to 200 once the backend lands) is a one-line change. Compare to the round-2 state where the cap was hard-coded only on the `<input>`. - **The mirror-→-onSearch path closes the editor-bypass:** because `onSearch` reads `searchQuery` after the clip rather than `editorQuery` directly (`MentionDropdown.svelte:67-69`), a script that programmatically changes the parent's `editorQuery` prop — e.g. via the test fixture's `setEditorQuery` exported in `MentionDropdown.test-fixture.svelte` — cannot bypass the cap. Tested explicitly via the fixture. - **Test-fixture rename:** `.test-fixture.svelte` is a clearer signal than `.test-host.svelte` and matches the prevailing Svelte 5 fixture convention. The file is 32 lines, single-purpose, and clearly off the production module graph. - **#633 is well-scoped.** All three frontend → backend handoffs (length cap, limit cap, DTO) are bundled into one backend PR. Cross-references my round-1/round-2 comments and Markus's twin findings. The "frontend `Person` cast in `runSearch` updated to `PersonSummaryDTO` (separate PR)" line correctly anticipates the type-regeneration churn. - **No new attack surface introduced by the round-2 changes.** The `get editorQuery() { return dropdownState.editorQuery; }` accessor in `PersonMentionEditor.svelte:225-227` is a read-only getter — it cannot be exploited to leak parent state because `dropdownState` is a `$state` proxy owned by the host editor. The request-token guard from round-1 is intact. - **`encodeURIComponent` retained, `rel="noopener noreferrer"` retained, no `innerHTML`/template-string URL interpolation/`eval`.** Clean. ### Note for follow-up - #633 should land before this PR's `MAX_QUERY_LENGTH` slice can be relied on as anything more than a UX hint. Until then, *keep both layers* — the frontend cap survives even if the backend cap regresses (defence in depth). - The `Nora #5618 #3` reference at `PersonMentionEditor.svelte:151-152` (PersonSummaryDTO `notes` leak) is now folded into #633's DTO scope. Once #633 ships, the inline reference can be removed. - Consider opening a sibling rate-limit issue (or annotating #633 with a section on rate-limiting `/api/persons`). CWE-770 is the unaddressed sliver. - The lint-enforced `no-restricted-imports` rule for `.test-fixture.svelte` is the only forward-defence I'd suggest. Not blocking; not in scope for this PR. Approving. The round-2 layered cap fix is exemplary — explicit constant, threat-model comment, three independent enforcement points, regression tests for two of them. This is the standard I want for the rest of the codebase. — Nora
Author
Owner

🧪 Sara Holt — Senior QA Engineer

Verdict: ⚠️ Approved with concerns

Third-pass review of head 4f2880a6. 56/56 green, factory functions correct, characterization tests well-targeted. Every round-2 concern was addressed in spirit; one of my round-2 fixes (A4) accidentally weakened the test it was meant to harden — the test no longer protects against the original double-fetch regression. Not a merge blocker because the &limit=5 test catches it incidentally, but worth tightening before this pattern gets copied.

Blockers

None.

Concerns

1. The A4 fix (round-2 Sara #1) accidentally removed the regression guardfrontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts lines ~208-235, "fires exactly one /api/persons fetch when the user searches for Walter (debounced)":

// Open the dropdown first so the search input is reachable.
await userEvent.type(page.getByRole('textbox'), '@');
await vi.waitFor(async () => {
  await expect.element(page.getByRole('searchbox')).toBeVisible();
});

const fetchesBeforeSearch = fetchMock.mock.calls.length;  // ← captured AFTER @ typed

await page.getByRole('searchbox').fill('Walter');
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));

const personsFetches = fetchMock.mock.calls
  .slice(fetchesBeforeSearch)
  .filter(...);
expect(personsFetches.length).toBe(1);

The original regression the test was guarding against was the duplicated items() callback in PersonMentionEditor.svelte firing a per-keystroke fetch in addition to the debounced search-input fetch (Markus & Felix flagged it in round 1). With the new structure:

  1. userEvent.type('@') fires once.
  2. If a regression re-introduces the legacy items() callback, it would fetch ONCE here (only @ is typed).
  3. fetchesBeforeSearch is captured AFTER that, so the regression fetch is silently absorbed into the baseline.
  4. fill('Walter') produces a single input event → one debounced fetch → assertion passes.

A reverter who restores items: async ({ query }) => { const res = await fetch(...); ... } would still see this test pass. The double-fetch regression is currently caught only incidentally by the &limit=5 test — because the legacy code didn't include that parameter — but rename the parameter or change the legacy code slightly and that incidental catch goes away too.

Fix (cheap): drop the @ open + baseline capture and use userEvent.type('@Walter') end-to-end with fill as the post-open replacement, OR keep fill and assert expect(fetchMock).toHaveBeenCalledTimes(1) against /api/persons (no baseline). My preference:

// Open dropdown AND search in a single user action — fetches before the
// open should be zero, so total persons-fetches stays the regression-guard.
await userEvent.type(page.getByRole('textbox'), '@Walter');
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));

const personsFetches = fetchMock.mock.calls.filter(
  ([url]) => typeof url === 'string' && url.startsWith('/api/persons')
);
expect(personsFetches.length).toBe(1);

That's the regression guard I asked for in round 1. It also subsumes the "appends &limit=5" test's incidental coverage. If the per-keystroke type race that the round-2 fix addressed kicks back in on CI, fix that with a vi.waitFor polling for the fetch to land instead of a fixed setTimeout — the deterministic count is more valuable than the deterministic timing.

2. The setTimeout(50) in the sticky-takeover test was not addressedfrontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts line ~245 (sticky-takeover test):

setEditorQuery('WdGruyter');
await new Promise((r) => setTimeout(r, 50));
await expect.element(page.getByRole('searchbox')).toHaveValue('Walter');

I flagged this in round 2 ("Naming a setTimeout(50) as 'give the effect time to flush' is the kind of thing future-Sara-reading-this-in-two-years will misinterpret as a race fix") and it didn't make the fix list. Not a flake risk because expect.element has its own polling, but the magic 50 ms is dead weight. Drop the line or replace with await tick() from 'svelte'. Six lines of net change.

3. Fake-timer rationale comment was not added to PersonMentionEditor.svelte.spec.ts — round 2 suggestion 2. The constants are named (SEARCH_DEBOUNCE_MS, POST_DEBOUNCE_SLACK_MS) but the rejection of vi.useFakeTimers() is only in the commit message. Future maintainers re-litigating will not find it. Adding the four-line comment I suggested would close this loop.

Suggestions

1. The onKeyDown unit tests (A6) are good but don't catch the forwarding-chain regressionMentionDropdown.svelte.test.ts:222-368. The tests invoke instance.onKeyDown(...) directly via the exported export. That validates the dropdown's selection logic in isolation, but does not catch a regression where PersonMentionEditor.svelte's onKeyDown(event) { return exports?.onKeyDown(event) ?? false; } callback gets dropped or rewired. If someone refactors the Tiptap suggestion config and forgets to plumb onKeyDown through, these tests still pass.

A cheap way to close the gap without going full E2E: in the existing PersonMentionEditor keyboard-navigation describe (PersonMentionEditor.svelte.spec.ts:567-622), the ArrowDown moves the highlight to the next result test already exercises the forwarding chain through userEvent.keyboard('{ArrowDown}'). That test covers the integration. The new dropdown-level tests are valuable for unit-level regression, but the comment in the spec file (// In production, Tiptap intercepts ArrowDown/ArrowUp/Enter at the editor level and forwards them...) overstates the coverage. Either add a one-liner to that block referencing the integration test in PersonMentionEditor.svelte.spec.ts, or call out explicitly that the unit tests do not exercise the forwarding chain.

2. The 500-characterization test (A5) is well-built but its sibling network-failure test duplicates 90% of the setupPersonMentionEditor.svelte.spec.ts:431-449. Two it() blocks with the same await userEvent.type(page.getByRole('textbox'), '@Aug')await new Promise(setTimeout)getByText(popup_empty) shape. Worth factoring into a for (const failureMode of [...]) table-driven test:

const failureModes = [
  { name: '500 response', mock: () => vi.fn().mockResolvedValue({ ok: false, status: 500, ... }) },
  { name: 'network reject', mock: () => vi.fn().mockRejectedValue(new TypeError('NetworkError')) }
];
for (const mode of failureModes) {
  it(`keeps the dropdown open on ${mode.name} with empty-state copy`, async () => { ... });
}

Not a round-3 fix — file under "next time someone touches this area." Keep your tests DRY when the failure semantics are the same.

3. The deferred items in #635 (E2E keyboard nav) look right — Tab from editor → searchbox → list → create-new is genuinely E2E territory (real focus chain, Tiptap's editor.commands.focus(), suggestion plugin event routing). The unit-level coverage in this PR (MentionDropdown onKeyDown forwarding + PersonMentionEditor ArrowDown integration) is the right floor. #635 should NOT come back into this PR.

The one item I'd consider promoting: focus-on-open (does the search input autofocus when the dropdown opens? CLAUDE.md's UX section calls this out for senior-audience write path). That's a single Playwright assertion or even a unit-level expect(document.activeElement).toBe(input) after mount. If it's already in #635, fine. If not, add it.

Flakiness risk

Low. Round-2 closed the worst risk (the @Walter keystroke race) with the fill switch. The 500 ms total slack (SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS = 500ms) is generous enough for CI jitter. One residual: the stale-response race test (PersonMentionEditor.svelte.spec.ts:412-428) still uses setTimeout(r, 50) after resolveFetch(...). Microtasks should flush in <1ms in practice, so 50ms is plenty — but if any downstream effect ever lands on requestIdleCallback the assertion could pass for the wrong reason (element hasn't been added yet, not because the guard worked). Watch this if you see intermittent false greens.

What's done well

  • makePerson factory now typed strictly against Person and uses Partial<Person>MentionDropdown.svelte.test.ts:14-25. Carries the type-discipline win from round 1 forward; personType and familyMember are no longer drift candidates.
  • Test-fixture rename (.test-host.svelte.test-fixture.svelte) — A3 / commit 7603c8d9. Makes the test boundary visible in the file tree. Right call. Adopting this convention project-wide (via #637) is the correct follow-up; please don't let that issue stagnate.
  • A1 (onExit cancels pending debounce)PersonMentionEditor.svelte.spec.ts:476-516. The test is a textbook integration-level race characterization: stage state, force the race window, assert the guard held. The accompanying production-code change (debouncedSearch.cancel() inside onExit's closure scope, not via the hoisted cancelPendingSearch) is exactly right — the closure binds to the specific dropdown lifecycle, so a trailing call cannot fire against the next dropdown's state.
  • A7 (mirror-clip test) + maxlength HTML attribute — both paths covered. The mirror-clip test catches the editorQuery injection vector; the maxlength attribute test catches direct-input. Two it() blocks for two attack surfaces. This is how layered defence gets pinned by tests.
  • Atomic commit map in the fix-pass summary (comment #11037) — every round-2 concern → one commit hash → exact resolution path. Halved my review time for this pass. Adopt as a project standard for the persona-review loop.
  • 56/56 green from 38 → 46 → 56 across three rounds — pure additive regression growth, no test pivots, no @Disabled ducking. Test suite grew by ~50% on a single 757-line feature PR. That's the discipline I want to see.

Test plan note (carryover, last time)

The 7-checkbox manual test plan in the PR description is still unverified. For a UI feature with 320 px viewport concerns (#635 is filed but not in this PR), I'd at minimum capture a screenshot at 320 px and 768 px before merging — even one Playwright session. The max-w-[calc(100vw-1rem)] cap is now unit-tested for presence but the actual reflow behaviour at 320 px is unverified. Not a blocker; flagging it because manual checklists fade and the next reviewer doesn't know what was actually clicked.

— Sara

## 🧪 Sara Holt — Senior QA Engineer **Verdict: ⚠️ Approved with concerns** Third-pass review of head `4f2880a6`. 56/56 green, factory functions correct, characterization tests well-targeted. Every round-2 concern was addressed in spirit; one of my round-2 fixes (A4) accidentally weakened the test it was meant to harden — the test no longer protects against the original double-fetch regression. Not a merge blocker because the `&limit=5` test catches it incidentally, but worth tightening before this pattern gets copied. ### Blockers None. ### Concerns **1. The A4 fix (round-2 Sara #1) accidentally removed the regression guard** — `frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts` lines ~208-235, "fires exactly one /api/persons fetch when the user searches for Walter (debounced)": ```ts // Open the dropdown first so the search input is reachable. await userEvent.type(page.getByRole('textbox'), '@'); await vi.waitFor(async () => { await expect.element(page.getByRole('searchbox')).toBeVisible(); }); const fetchesBeforeSearch = fetchMock.mock.calls.length; // ← captured AFTER @ typed await page.getByRole('searchbox').fill('Walter'); await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); const personsFetches = fetchMock.mock.calls .slice(fetchesBeforeSearch) .filter(...); expect(personsFetches.length).toBe(1); ``` The original regression the test was guarding against was the **duplicated `items()` callback** in `PersonMentionEditor.svelte` firing a per-keystroke fetch in addition to the debounced search-input fetch (Markus & Felix flagged it in round 1). With the new structure: 1. `userEvent.type('@')` fires once. 2. If a regression re-introduces the legacy `items()` callback, it would fetch ONCE here (only `@` is typed). 3. `fetchesBeforeSearch` is captured AFTER that, so the regression fetch is silently absorbed into the baseline. 4. `fill('Walter')` produces a single input event → one debounced fetch → assertion passes. A reverter who restores `items: async ({ query }) => { const res = await fetch(...); ... }` would still see this test pass. The double-fetch regression is currently caught **only incidentally** by the `&limit=5` test — because the legacy code didn't include that parameter — but rename the parameter or change the legacy code slightly and that incidental catch goes away too. Fix (cheap): drop the `@` open + baseline capture and use `userEvent.type('@Walter')` end-to-end with `fill` as the post-open replacement, OR keep `fill` and assert `expect(fetchMock).toHaveBeenCalledTimes(1)` against `/api/persons` (no baseline). My preference: ```ts // Open dropdown AND search in a single user action — fetches before the // open should be zero, so total persons-fetches stays the regression-guard. await userEvent.type(page.getByRole('textbox'), '@Walter'); await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); const personsFetches = fetchMock.mock.calls.filter( ([url]) => typeof url === 'string' && url.startsWith('/api/persons') ); expect(personsFetches.length).toBe(1); ``` That's the regression guard I asked for in round 1. It also subsumes the "appends &limit=5" test's incidental coverage. If the per-keystroke type race that the round-2 fix addressed kicks back in on CI, fix that with a `vi.waitFor` polling for the fetch to land instead of a fixed setTimeout — the deterministic count is more valuable than the deterministic timing. **2. The `setTimeout(50)` in the sticky-takeover test was not addressed** — `frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts` line ~245 (sticky-takeover test): ```ts setEditorQuery('WdGruyter'); await new Promise((r) => setTimeout(r, 50)); await expect.element(page.getByRole('searchbox')).toHaveValue('Walter'); ``` I flagged this in round 2 ("Naming a `setTimeout(50)` as 'give the effect time to flush' is the kind of thing future-Sara-reading-this-in-two-years will misinterpret as a race fix") and it didn't make the fix list. Not a flake risk because `expect.element` has its own polling, but the magic 50 ms is dead weight. Drop the line or replace with `await tick()` from `'svelte'`. Six lines of net change. **3. Fake-timer rationale comment was not added to `PersonMentionEditor.svelte.spec.ts`** — round 2 suggestion 2. The constants are named (`SEARCH_DEBOUNCE_MS`, `POST_DEBOUNCE_SLACK_MS`) but the rejection of `vi.useFakeTimers()` is only in the commit message. Future maintainers re-litigating will not find it. Adding the four-line comment I suggested would close this loop. ### Suggestions **1. The onKeyDown unit tests (A6) are good but don't catch the forwarding-chain regression** — `MentionDropdown.svelte.test.ts:222-368`. The tests invoke `instance.onKeyDown(...)` directly via the exported export. That validates the dropdown's selection logic in isolation, but does not catch a regression where `PersonMentionEditor.svelte`'s `onKeyDown(event) { return exports?.onKeyDown(event) ?? false; }` callback gets dropped or rewired. If someone refactors the Tiptap suggestion config and forgets to plumb `onKeyDown` through, these tests still pass. A cheap way to close the gap without going full E2E: in the existing `PersonMentionEditor` keyboard-navigation describe (`PersonMentionEditor.svelte.spec.ts:567-622`), the `ArrowDown moves the highlight to the next result` test already exercises the forwarding chain through `userEvent.keyboard('{ArrowDown}')`. That test covers the integration. The new dropdown-level tests are valuable for unit-level regression, but the comment in the spec file (`// In production, Tiptap intercepts ArrowDown/ArrowUp/Enter at the editor level and forwards them...`) overstates the coverage. Either add a one-liner to that block referencing the integration test in `PersonMentionEditor.svelte.spec.ts`, or call out explicitly that the unit tests do not exercise the forwarding chain. **2. The 500-characterization test (A5) is well-built but its sibling network-failure test duplicates 90% of the setup** — `PersonMentionEditor.svelte.spec.ts:431-449`. Two `it()` blocks with the same `await userEvent.type(page.getByRole('textbox'), '@Aug')` → `await new Promise(setTimeout)` → `getByText(popup_empty)` shape. Worth factoring into a `for (const failureMode of [...])` table-driven test: ```ts const failureModes = [ { name: '500 response', mock: () => vi.fn().mockResolvedValue({ ok: false, status: 500, ... }) }, { name: 'network reject', mock: () => vi.fn().mockRejectedValue(new TypeError('NetworkError')) } ]; for (const mode of failureModes) { it(`keeps the dropdown open on ${mode.name} with empty-state copy`, async () => { ... }); } ``` Not a round-3 fix — file under "next time someone touches this area." Keep your tests DRY when the failure semantics are the same. **3. The deferred items in #635 (E2E keyboard nav) look right** — Tab from editor → searchbox → list → create-new is genuinely E2E territory (real focus chain, Tiptap's editor.commands.focus(), suggestion plugin event routing). The unit-level coverage in this PR (`MentionDropdown` onKeyDown forwarding + `PersonMentionEditor` ArrowDown integration) is the right floor. #635 should NOT come back into this PR. The one item I'd consider promoting: **focus-on-open** (does the search input autofocus when the dropdown opens? CLAUDE.md's UX section calls this out for senior-audience write path). That's a single Playwright assertion or even a unit-level `expect(document.activeElement).toBe(input)` after mount. If it's already in #635, fine. If not, add it. ### Flakiness risk **Low.** Round-2 closed the worst risk (the `@Walter` keystroke race) with the `fill` switch. The 500 ms total slack (`SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS = 500ms`) is generous enough for CI jitter. One residual: the stale-response race test (`PersonMentionEditor.svelte.spec.ts:412-428`) still uses `setTimeout(r, 50)` after `resolveFetch(...)`. Microtasks should flush in <1ms in practice, so 50ms is plenty — but if any downstream effect ever lands on `requestIdleCallback` the assertion could pass for the wrong reason (element hasn't been added yet, not because the guard worked). Watch this if you see intermittent false greens. ### What's done well - **`makePerson` factory now typed strictly against `Person` and uses `Partial<Person>`** — `MentionDropdown.svelte.test.ts:14-25`. Carries the type-discipline win from round 1 forward; `personType` and `familyMember` are no longer drift candidates. - **Test-fixture rename (`.test-host.svelte` → `.test-fixture.svelte`)** — A3 / commit `7603c8d9`. Makes the test boundary visible in the file tree. Right call. Adopting this convention project-wide (via #637) is the correct follow-up; please don't let that issue stagnate. - **A1 (onExit cancels pending debounce)** — `PersonMentionEditor.svelte.spec.ts:476-516`. The test is a textbook integration-level race characterization: stage state, force the race window, assert the guard held. The accompanying production-code change (`debouncedSearch.cancel()` inside `onExit`'s closure scope, not via the hoisted `cancelPendingSearch`) is exactly right — the closure binds to the specific dropdown lifecycle, so a trailing call cannot fire against the next dropdown's state. - **A7 (mirror-clip test) + maxlength HTML attribute** — both paths covered. The mirror-clip test catches the `editorQuery` injection vector; the `maxlength` attribute test catches direct-input. Two `it()` blocks for two attack surfaces. This is how layered defence gets pinned by tests. - **Atomic commit map in the fix-pass summary (comment #11037)** — every round-2 concern → one commit hash → exact resolution path. Halved my review time for this pass. Adopt as a project standard for the persona-review loop. - **56/56 green from 38 → 46 → 56 across three rounds** — pure additive regression growth, no test pivots, no `@Disabled` ducking. Test suite grew by ~50% on a single 757-line feature PR. That's the discipline I want to see. ### Test plan note (carryover, last time) The 7-checkbox manual test plan in the PR description is **still** unverified. For a UI feature with 320 px viewport concerns (#635 is filed but not in this PR), I'd at minimum capture a screenshot at 320 px and 768 px before merging — even one Playwright session. The `max-w-[calc(100vw-1rem)]` cap is now unit-tested for presence but the actual reflow behaviour at 320 px is unverified. Not a blocker; flagging it because manual checklists fade and the next reviewer doesn't know what was actually clicked. — Sara
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m30s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m23s
CI / fail2ban Regex (pull_request) Successful in 41s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 58s
This pull request can be merged automatically.
This branch is out-of-date with the base branch
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/issue-380-decouple-mention-search:feat/issue-380-decouple-mention-search
git checkout feat/issue-380-decouple-mention-search
Sign in to join this conversation.
No Reviewers
No Label P2-medium feature ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#629