From c667a5add840ceea43c909ad17e63fa22bc8216b Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 19 May 2026 20:51:53 +0200 Subject: [PATCH 01/41] feat(i18n): add person_mention_search_prompt message key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/messages/de.json | 1 + frontend/messages/en.json | 1 + frontend/messages/es.json | 1 + 3 files changed, 3 insertions(+) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 37e25574..4b2d3716 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -445,6 +445,7 @@ "person_mention_load_error": "Person konnte nicht geladen werden.", "person_mention_loading": "Lade Person…", "person_mention_popup_empty": "Keine Personen gefunden", + "person_mention_search_prompt": "Namen eingeben…", "person_mention_btn_label": "Person verlinken", "person_mention_create_new": "Neue Person anlegen", "transcription_editor_aria_label": "Transkriptionstext", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index b4b675c5..98fa5a11 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -445,6 +445,7 @@ "person_mention_load_error": "Could not load person.", "person_mention_loading": "Loading person…", "person_mention_popup_empty": "No persons found", + "person_mention_search_prompt": "Enter a name…", "person_mention_btn_label": "Link person", "person_mention_create_new": "Create new person", "transcription_editor_aria_label": "Transcription text", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 898b3e85..29df65e9 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -445,6 +445,7 @@ "person_mention_load_error": "No se pudo cargar la persona.", "person_mention_loading": "Cargando persona…", "person_mention_popup_empty": "No se encontraron personas", + "person_mention_search_prompt": "Escribe un nombre…", "person_mention_btn_label": "Vincular persona", "person_mention_create_new": "Crear nueva persona", "transcription_editor_aria_label": "Texto de transcripción", -- 2.49.1 From fa7fe4236353677c09d11246be6c726df2bbb0b7 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 19 May 2026 20:55:00 +0200 Subject: [PATCH 02/41] feat(transcription): add search input with initialQuery prefill to MentionDropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../shared/discussion/MentionDropdown.svelte | 40 ++++++++++++++++- .../discussion/MentionDropdown.svelte.spec.ts | 44 +++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/shared/discussion/MentionDropdown.svelte.spec.ts diff --git a/frontend/src/lib/shared/discussion/MentionDropdown.svelte b/frontend/src/lib/shared/discussion/MentionDropdown.svelte index 4932eda4..176b144b 100644 --- a/frontend/src/lib/shared/discussion/MentionDropdown.svelte +++ b/frontend/src/lib/shared/discussion/MentionDropdown.svelte @@ -2,6 +2,7 @@ import type { components } from '$lib/generated/api'; // eslint-disable-next-line boundaries/dependencies -- mention dropdown needs person date formatting; extract to shared if it becomes reusable import { formatLifeDateRange } from '$lib/person/personLifeDates'; +import { untrack } from 'svelte'; import { m } from '$lib/paraglide/messages.js'; type Person = components['schemas']['Person']; @@ -17,7 +18,19 @@ type DropdownState = { clientRect: (() => DOMRect | null) | null; }; -let { model }: { model: DropdownState } = $props(); +let { + model, + initialQuery = '', + onSearch = () => {} +}: { + model: DropdownState; + initialQuery?: string; + onSearch?: (query: string) => void; +} = $props(); + +// initialQuery is a one-shot prop — PersonMentionEditor mounts a fresh dropdown +// with the typed text on each Tiptap onStart, so we deliberately snapshot here. +let searchQuery = $state(untrack(() => initialQuery)); // highlightedIndex must be both writable (keyboard handler mutates it) and // reset when `items` changes (so it never points past the end of a new list). @@ -119,6 +132,31 @@ function selectItem(item: Person) { style:bottom={position.bottom} style:left={position.left} > +
+ +
+ + onSearch(e.currentTarget.value)} + onmousedown={(e) => e.stopPropagation()} + /> +
+
{#if model.items.length === 0}

{m.person_mention_popup_empty()} diff --git a/frontend/src/lib/shared/discussion/MentionDropdown.svelte.spec.ts b/frontend/src/lib/shared/discussion/MentionDropdown.svelte.spec.ts new file mode 100644 index 00000000..907741f7 --- /dev/null +++ b/frontend/src/lib/shared/discussion/MentionDropdown.svelte.spec.ts @@ -0,0 +1,44 @@ +/** + * MentionDropdown — direct component tests. + * + * These tests render the dropdown in isolation, passing the `model` proxy + * (matching what PersonMentionEditor would pass). They cover the dropdown's + * own surface: the search input, the empty-query prompt, and the existing + * "no results" / "create new" behaviors. Wiring tests against Tiptap live + * in PersonMentionEditor.svelte.spec.ts. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import MentionDropdown from './MentionDropdown.svelte'; +import type { components } from '$lib/generated/api'; + +type Person = components['schemas']['Person']; + +type DropdownState = { + items: Person[]; + command: (item: Person) => void; + clientRect: (() => DOMRect | null) | null; +}; + +function makeModel(items: Person[] = []): DropdownState { + return { + items, + command: () => {}, + clientRect: () => new DOMRect(0, 0, 0, 0) + }; +} + +afterEach(() => cleanup()); + +describe('MentionDropdown — search input', () => { + it('renders a search input pre-filled with the initialQuery prop', async () => { + render(MentionDropdown, { + model: makeModel(), + initialQuery: 'WdG', + onSearch: () => {} + }); + + await expect.element(page.getByRole('searchbox')).toHaveValue('WdG'); + }); +}); -- 2.49.1 From 38b87f6a9f69ed473cb687816401cc4e19db99b1 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 19 May 2026 20:56:15 +0200 Subject: [PATCH 03/41] feat(transcription): add data-test-search-input hook for E2E selectors 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 --- .../src/lib/shared/discussion/MentionDropdown.svelte | 1 + .../shared/discussion/MentionDropdown.svelte.spec.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/frontend/src/lib/shared/discussion/MentionDropdown.svelte b/frontend/src/lib/shared/discussion/MentionDropdown.svelte index 176b144b..07c0f628 100644 --- a/frontend/src/lib/shared/discussion/MentionDropdown.svelte +++ b/frontend/src/lib/shared/discussion/MentionDropdown.svelte @@ -149,6 +149,7 @@ function selectItem(item: Person) { { await expect.element(page.getByRole('searchbox')).toHaveValue('WdG'); }); + + it('exposes a data-test-search-input attribute for E2E selectors', async () => { + render(MentionDropdown, { + model: makeModel(), + initialQuery: '', + onSearch: () => {} + }); + + const input = document.querySelector('[data-test-search-input]'); + expect(input).not.toBeNull(); + expect((input as HTMLInputElement).type).toBe('search'); + }); }); -- 2.49.1 From ff3e8fb7555e08780dd4d90c574bab35d294352d Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 19 May 2026 20:57:15 +0200 Subject: [PATCH 04/41] test(transcription): cover MentionDropdown onSearch callback wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../discussion/MentionDropdown.svelte.spec.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/shared/discussion/MentionDropdown.svelte.spec.ts b/frontend/src/lib/shared/discussion/MentionDropdown.svelte.spec.ts index d73ff552..3021fbfa 100644 --- a/frontend/src/lib/shared/discussion/MentionDropdown.svelte.spec.ts +++ b/frontend/src/lib/shared/discussion/MentionDropdown.svelte.spec.ts @@ -7,9 +7,9 @@ * "no results" / "create new" behaviors. Wiring tests against Tiptap live * in PersonMentionEditor.svelte.spec.ts. */ -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; +import { page, userEvent } from 'vitest/browser'; import MentionDropdown from './MentionDropdown.svelte'; import type { components } from '$lib/generated/api'; @@ -53,4 +53,20 @@ describe('MentionDropdown — search input', () => { expect(input).not.toBeNull(); expect((input as HTMLInputElement).type).toBe('search'); }); + + it('invokes onSearch with the current value whenever the user types', async () => { + const onSearch = vi.fn(); + render(MentionDropdown, { + model: makeModel(), + initialQuery: '', + onSearch + }); + + await userEvent.type(page.getByRole('searchbox'), 'Walter'); + + await vi.waitFor(() => { + expect(onSearch).toHaveBeenCalled(); + expect(onSearch).toHaveBeenLastCalledWith('Walter'); + }); + }); }); -- 2.49.1 From bdc0b112b6ef0a646eb7ec8f1b7e1b5a7a95d2a9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 19 May 2026 21:03:13 +0200 Subject: [PATCH 05/41] feat(transcription): wire dropdown search input to editor @-text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../shared/discussion/MentionDropdown.svelte | 35 ++++++++++++++----- .../discussion/MentionDropdown.svelte.spec.ts | 31 +++++++++++++--- .../discussion/PersonMentionEditor.svelte | 21 +++++++++-- .../PersonMentionEditor.svelte.spec.ts | 15 ++++++++ 4 files changed, 86 insertions(+), 16 deletions(-) diff --git a/frontend/src/lib/shared/discussion/MentionDropdown.svelte b/frontend/src/lib/shared/discussion/MentionDropdown.svelte index 07c0f628..5e6c2157 100644 --- a/frontend/src/lib/shared/discussion/MentionDropdown.svelte +++ b/frontend/src/lib/shared/discussion/MentionDropdown.svelte @@ -20,17 +20,25 @@ type DropdownState = { let { model, - initialQuery = '', + editorQuery = '', onSearch = () => {} }: { model: DropdownState; - initialQuery?: string; + /** Text typed after `@` in the host editor. Mirrors into the search input + * until the user takes manual ownership by typing into the input itself. */ + editorQuery?: string; onSearch?: (query: string) => void; } = $props(); -// initialQuery is a one-shot prop — PersonMentionEditor mounts a fresh dropdown -// with the typed text on each Tiptap onStart, so we deliberately snapshot here. -let searchQuery = $state(untrack(() => initialQuery)); +let searchQuery = $state(untrack(() => editorQuery)); +let userHasEdited = $state(false); + +// Mirror the editor's typed text until the user takes ownership. +$effect(() => { + if (!userHasEdited) { + searchQuery = editorQuery; + } +}); // highlightedIndex must be both writable (keyboard handler mutates it) and // reset when `items` changes (so it never points past the end of a new list). @@ -153,15 +161,24 @@ function selectItem(item: Person) { class="min-h-[44px] w-full bg-transparent font-sans text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-inset" placeholder={m.person_mention_search_prompt()} bind:value={searchQuery} - oninput={(e) => onSearch(e.currentTarget.value)} + oninput={(e) => { + userHasEdited = true; + onSearch(e.currentTarget.value); + }} onmousedown={(e) => e.stopPropagation()} /> {#if model.items.length === 0} -

- {m.person_mention_popup_empty()} -

+ {#if searchQuery.trim() === ''} +

+ {m.person_mention_search_prompt()} +

+ {:else} +

+ {m.person_mention_popup_empty()} +

+ {/if} +

+ {searchQuery.trim() === '' + ? m.person_mention_search_prompt() + : m.person_mention_popup_empty()} +

{ expect(input.value).toBe('A'.repeat(100)); }); + it('caps the listbox width to the viewport (320 px reflow guard — Leonie FINDING-MENTION-005)', async () => { + render(MentionDropdown, { props: { model: baseModel() } }); + + const listbox = document.querySelector('[role="listbox"]') as HTMLElement; + expect(listbox).not.toBeNull(); + expect(listbox.className).toContain('max-w-[calc(100vw-1rem)]'); + }); + it('invokes onSearch with the current value whenever the user types', async () => { const onSearch = vi.fn(); render(MentionDropdown, { props: { model: baseModel(), onSearch } }); -- 2.49.1 From cb0ce90fd6c8b5af8f98bfdb45ab7e1e3596a26c Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 19 May 2026 23:28:00 +0200 Subject: [PATCH 29/41] a11y(transcription): bump @mention search input to text-base (16 px floor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/lib/shared/discussion/MentionDropdown.svelte | 2 +- .../lib/shared/discussion/MentionDropdown.svelte.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/shared/discussion/MentionDropdown.svelte b/frontend/src/lib/shared/discussion/MentionDropdown.svelte index a8186ffe..9732df50 100644 --- a/frontend/src/lib/shared/discussion/MentionDropdown.svelte +++ b/frontend/src/lib/shared/discussion/MentionDropdown.svelte @@ -182,7 +182,7 @@ function selectItem(item: Person) { type="search" data-test-search-input maxlength={MAX_QUERY_LENGTH} - class="min-h-[44px] w-full bg-transparent font-sans text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-inset" + class="min-h-[44px] w-full bg-transparent font-sans text-base text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-inset" placeholder={m.person_mention_search_prompt()} bind:value={searchQuery} oninput={() => { diff --git a/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts b/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts index adf60496..5ccc85bb 100644 --- a/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts +++ b/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts @@ -204,6 +204,15 @@ describe('MentionDropdown — search input', () => { expect(listbox.className).toContain('max-w-[calc(100vw-1rem)]'); }); + it('renders the @mention search input at text-base (16 px senior-audience floor — Leonie FINDING-MENTION-006)', async () => { + render(MentionDropdown, { props: { model: baseModel() } }); + + const input = document.querySelector('[data-test-search-input]') as HTMLInputElement; + expect(input).not.toBeNull(); + expect(input.className).toContain('text-base'); + expect(input.className).not.toContain('text-sm'); + }); + it('invokes onSearch with the current value whenever the user types', async () => { const onSearch = vi.fn(); render(MentionDropdown, { props: { model: baseModel(), onSearch } }); -- 2.49.1 From 5f6b896bd2eeb7735aafd057a6b6ae4f0d192cbf Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 19 May 2026 23:59:04 +0200 Subject: [PATCH 30/41] refactor(transcription): hoist @mention constants to shared module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single source of truth for MAX_QUERY_LENGTH, SEARCH_DEBOUNCE_MS, and SEARCH_RESULT_LIMIT — MentionDropdown imports MAX_QUERY_LENGTH; PersonMentionEditor imports the debounce + result-limit; the spec's mirror now imports SEARCH_DEBOUNCE_MS so it can never drift. Unblocks the displayName length-cap fix (Felix #3 on PR #629). Co-Authored-By: Claude Opus 4.7 --- .../src/lib/shared/discussion/MentionDropdown.svelte | 11 ++++++----- .../lib/shared/discussion/PersonMentionEditor.svelte | 4 +--- .../discussion/PersonMentionEditor.svelte.spec.ts | 8 ++++---- .../src/lib/shared/discussion/mentionConstants.ts | 6 ++++++ 4 files changed, 17 insertions(+), 12 deletions(-) create mode 100644 frontend/src/lib/shared/discussion/mentionConstants.ts diff --git a/frontend/src/lib/shared/discussion/MentionDropdown.svelte b/frontend/src/lib/shared/discussion/MentionDropdown.svelte index 9732df50..0cc4378c 100644 --- a/frontend/src/lib/shared/discussion/MentionDropdown.svelte +++ b/frontend/src/lib/shared/discussion/MentionDropdown.svelte @@ -4,17 +4,18 @@ import type { components } from '$lib/generated/api'; import { formatLifeDateRange } from '$lib/person/personLifeDates'; import { untrack } from 'svelte'; import { m } from '$lib/paraglide/messages.js'; - -type Person = components['schemas']['Person']; - // Layered defence cap on the @mention search query length (CWE-400 // amplification). The attribute below caps direct // user edits, but the editor-mirror path (Tiptap contenteditable -> mirror // $effect -> searchQuery) is not covered by `maxlength` since the // contenteditable has no such enforcement. Clipping at the mirror keeps // the cap honest from both paths. Tracked server-side separately. -// Nora #1 on PR #629. -const MAX_QUERY_LENGTH = 100; +// Nora #1 on PR #629. Hoisted to mentionConstants.ts so the host editor +// (PersonMentionEditor) can clip the inserted displayName to the same cap +// — see Felix #3 on PR #629. +import { MAX_QUERY_LENGTH } from './mentionConstants'; + +type Person = components['schemas']['Person']; // The dropdown receives a single reactive state object. PersonMentionEditor // mutates fields on this object (model.items = ..., etc.) and Svelte's $state diff --git a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte index 6512f2fd..8714a574 100644 --- a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte +++ b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte @@ -9,9 +9,7 @@ import type { PersonMention } from '$lib/shared/types'; import { deserialize, serialize } from '$lib/shared/discussion/mentionSerializer'; import { debounce } from '$lib/shared/utils/debounce'; import MentionDropdown from './MentionDropdown.svelte'; - -const SEARCH_DEBOUNCE_MS = 150; -const SEARCH_RESULT_LIMIT = 5; +import { SEARCH_DEBOUNCE_MS, SEARCH_RESULT_LIMIT } from './mentionConstants'; type Person = components['schemas']['Person']; diff --git a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts index e21eb412..591a7ef8 100644 --- a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts +++ b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts @@ -11,14 +11,14 @@ import { page, userEvent } from 'vitest/browser'; import PersonMentionEditorHost from './PersonMentionEditor.test-host.svelte'; import type { components } from '$lib/generated/api'; import { m } from '$lib/paraglide/messages.js'; +// Single source of truth for the debounce window — imported from the shared +// module so the test cannot drift from production. Sara on PR #629 round 3. +import { SEARCH_DEBOUNCE_MS } from './mentionConstants'; type Person = components['schemas']['Person']; type PersonMention = components['schemas']['PersonMention']; -// Mirror of the debounce in PersonMentionEditor.svelte. Naming the magic and -// using a generous slack (SEARCH_DEBOUNCE_MS + 350 = 500 ms) kills CI-jitter -// flakiness Sara raised on PR #629. -const SEARCH_DEBOUNCE_MS = 150; +// Slack on top of the debounce to absorb CI jitter (total 500 ms is generous). const POST_DEBOUNCE_SLACK_MS = 350; const AUGUSTE: Person = { diff --git a/frontend/src/lib/shared/discussion/mentionConstants.ts b/frontend/src/lib/shared/discussion/mentionConstants.ts new file mode 100644 index 00000000..3b946345 --- /dev/null +++ b/frontend/src/lib/shared/discussion/mentionConstants.ts @@ -0,0 +1,6 @@ +/** Shared knobs for the @mention typeahead. Single source of truth for + * the dropdown component and the host editor — keeps the layered length + * cap and the debounce window consistent across both files. */ +export const MAX_QUERY_LENGTH = 100; +export const SEARCH_DEBOUNCE_MS = 150; +export const SEARCH_RESULT_LIMIT = 5; -- 2.49.1 From 4b75ca80ccdefd3e4aad017a001ed852848a3f43 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 20 May 2026 00:00:53 +0200 Subject: [PATCH 31/41] fix(transcription): clip @mention displayName to MAX_QUERY_LENGTH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dropdown's editor-mirror clips at 100 chars (CWE-400, Nora #1), but the host editor previously fed renderProps.query directly to displayName on selection — so a 200-char @-suffix would search the first 100 chars but insert 200 chars. Clip once in updateState and use the clipped value for both the inserted displayName and the dropdown's editorQuery mirror, keeping "what I searched" and "what got inserted" in sync. Felix #3 on PR #629. Co-Authored-By: Claude Opus 4.7 --- .../discussion/PersonMentionEditor.svelte | 14 +++++++-- .../PersonMentionEditor.svelte.spec.ts | 30 +++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte index 8714a574..466c00ed 100644 --- a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte +++ b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte @@ -9,7 +9,7 @@ import type { PersonMention } from '$lib/shared/types'; import { deserialize, serialize } from '$lib/shared/discussion/mentionSerializer'; import { debounce } from '$lib/shared/utils/debounce'; import MentionDropdown from './MentionDropdown.svelte'; -import { SEARCH_DEBOUNCE_MS, SEARCH_RESULT_LIMIT } from './mentionConstants'; +import { MAX_QUERY_LENGTH, SEARCH_DEBOUNCE_MS, SEARCH_RESULT_LIMIT } from './mentionConstants'; type Person = components['schemas']['Person']; @@ -225,14 +225,22 @@ onMount(() => { }; const updateState = (renderProps: LooseRenderProps) => { + // Clip once here so both the inserted displayName and the + // dropdown's editor-mirror see the same value. The dropdown + // already clips the mirror (Nora #1 CWE-400), but without + // clipping at the command boundary an unclipped query would + // still flow through as the inserted displayName — visible + // UI divergence between "what I searched" and "what was + // inserted". Felix #3 on PR #629. + const clippedQuery = renderProps.query.slice(0, MAX_QUERY_LENGTH); // AC-1: pass typed query as displayName, not person.displayName dropdownState.command = (item: Person) => renderProps.command({ personId: item.id, - displayName: renderProps.query + displayName: clippedQuery }); dropdownState.clientRect = renderProps.clientRect ?? null; - dropdownState.editorQuery = renderProps.query; + dropdownState.editorQuery = clippedQuery; }; return { diff --git a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts index 591a7ef8..0620ecfb 100644 --- a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts +++ b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts @@ -473,6 +473,36 @@ describe('PersonMentionEditor — AC-1: typed text as displayName', () => { }); }); + it('clips the inserted displayName to MAX_QUERY_LENGTH=100 chars (Felix #3 on PR #629)', async () => { + // CWE-400 amplification: the dropdown clips its search input + mirror at + // 100 chars (Nora #1), but the host editor was passing the unclipped + // renderProps.query straight through to displayName — so a 105-char + // @-suffix in the editor could insert a 105-char displayName into the + // sidecar even though the dropdown only searched the first 100. + mockFetchWithPersons(); + const host = renderHost(); + + // Type @ + 105 'A' chars in the contenteditable. The renderProps.query + // fed into the command callback derives from the editor text after `@`, + // not the dropdown's searchbox — so we must drive the editor. + await userEvent.type(page.getByRole('textbox'), '@' + 'A'.repeat(105)); + + // The mocked /api/persons returns AUGUSTE for any query — wait for it. + await vi.waitFor(async () => { + await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible(); + }); + + const option = (await page + .getByRole('option', { name: /Auguste Raddatz/ }) + .element()) as HTMLElement; + option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); + + await vi.waitFor(() => { + expect(host.snapshot.mentionedPersons).toHaveLength(1); + expect(host.snapshot.mentionedPersons[0].displayName.length).toBeLessThanOrEqual(100); + }); + }); + it('does not duplicate the sidecar entry when the same person is selected twice', async () => { mockFetchWithPersons(); const host = renderHost({ -- 2.49.1 From 5c588caf26c1cd393e2d9d789e63fb8bfa7e24c9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 20 May 2026 00:01:54 +0200 Subject: [PATCH 32/41] test(transcription): restore strong one-fetch regression guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sara on PR #629 round 3: the round-2 fix captured the fetch count AFTER typing '@', so a regression that re-introduced the legacy per-keystroke items() callback would have its '@'-keystroke fetch silently absorbed into the baseline. Drop the baseline subtraction and count every /api/persons fetch since render — typing '@' + fill('Walter') must total exactly one fetch. Co-Authored-By: Claude Opus 4.7 --- .../PersonMentionEditor.svelte.spec.ts | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts index 0620ecfb..6d43d56b 100644 --- a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts +++ b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts @@ -219,30 +219,36 @@ describe('PersonMentionEditor — AC-2/3: search input drives fetch', () => { expect(fetchesAfterSearch).toBe(1); }); - it('fires exactly one /api/persons fetch when the user searches for Walter (debounced)', async () => { + it('fires exactly one /api/persons fetch when the user searches for Walter (regression guard)', async () => { + // Regression guard: a previous version of PersonMentionEditor had a + // duplicated `items()` callback in the Tiptap suggestion config that + // fetched per-keystroke in addition to the debounced search-input fetch + // (Markus & Felix round-1). To catch that regression, we must NOT + // subtract any baseline — every fetch from render onwards counts. + // Sara on PR #629 round 3. const fetchMock = vi .fn() .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) }); vi.stubGlobal('fetch', fetchMock); renderHost(); - // Open the dropdown first so the search input is reachable. `fill` then - // drives the searchbox in one input event — sidesteps per-keystroke - // debounce timing on CI that Sara flagged on PR #629 round 2. + // Open the dropdown, then drive the search input via fill() — sidesteps + // per-keystroke timing of userEvent.type that Sara flagged round 2. await userEvent.type(page.getByRole('textbox'), '@'); await vi.waitFor(async () => { await expect.element(page.getByRole('searchbox')).toBeVisible(); }); - - const fetchesBeforeSearch = fetchMock.mock.calls.length; - 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(([url]) => typeof url === 'string' && url.startsWith('/api/persons')); + // No baseline subtraction — count ALL /api/persons fetches since render. + // If the legacy per-keystroke items() callback returns, typing `@` alone + // would already produce one fetch and `fill('Walter')` another, breaking + // this assertion. + const personsFetches = fetchMock.mock.calls.filter( + ([url]) => typeof url === 'string' && url.startsWith('/api/persons') + ); expect(personsFetches.length).toBe(1); }); -- 2.49.1 From d2f0eef45e63cb96af81bceb7ea96672a05b4423 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 20 May 2026 00:02:42 +0200 Subject: [PATCH 33/41] test(transcription): replace setTimeout(50) with tick() in sticky-takeover Sara on PR #629 round 3: the magic 50 ms in the @mention sticky-takeover test was anchored to nothing and read as a race-fix it wasn't. Replace with await tick() so the intent ("flush pending Svelte reactivity") is explicit. The expect.element polling already covers timing drift. Co-Authored-By: Claude Opus 4.7 --- .../lib/shared/discussion/MentionDropdown.svelte.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts b/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts index 5ccc85bb..95124728 100644 --- a/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts +++ b/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page, userEvent } from 'vitest/browser'; -import { flushSync, mount, unmount } from 'svelte'; +import { flushSync, mount, tick, unmount } from 'svelte'; import MentionDropdown from './MentionDropdown.svelte'; import MentionDropdownFixture from './MentionDropdown.test-fixture.svelte'; import { m } from '$lib/paraglide/messages.js'; @@ -241,7 +241,10 @@ describe('MentionDropdown — search input', () => { await expect.element(page.getByRole('searchbox')).toHaveValue('Walter'); setEditorQuery('WdGruyter'); - await new Promise((r) => setTimeout(r, 50)); + // Flush pending Svelte reactivity so any (non-)update from the mirror + // $effect has landed before we assert. expect.element already polls, so + // no fixed-timeout fallback is needed. Sara on PR #629 round 3. + await tick(); await expect.element(page.getByRole('searchbox')).toHaveValue('Walter'); }); -- 2.49.1 From 8a77e6442114741d6610619c5eba82c126925c9a Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 20 May 2026 00:04:02 +0200 Subject: [PATCH 34/41] refactor(test): complete .test-host -> .test-fixture rename sweep Round 2 renamed only MentionDropdown's fixture; three siblings retained the old suffix. Rename PersonMentionEditor, confirm, and TranscriptionBlock test hosts to the .test-fixture suffix and update the three importers so the boundary is uniform across the repo. Felix #1 / Tobi #1 on PR #629. Co-Authored-By: Claude Opus 4.7 --- .../document/transcription/TranscriptionBlock.svelte.spec.ts | 2 +- ....test-host.svelte => TranscriptionBlock.test-fixture.svelte} | 0 .../lib/shared/discussion/PersonMentionEditor.svelte.spec.ts | 2 +- ...test-host.svelte => PersonMentionEditor.test-fixture.svelte} | 0 frontend/src/lib/shared/services/confirm.svelte.test.ts | 2 +- .../{confirm.test-host.svelte => confirm.test-fixture.svelte} | 0 6 files changed, 3 insertions(+), 3 deletions(-) rename frontend/src/lib/document/transcription/{TranscriptionBlock.test-host.svelte => TranscriptionBlock.test-fixture.svelte} (100%) rename frontend/src/lib/shared/discussion/{PersonMentionEditor.test-host.svelte => PersonMentionEditor.test-fixture.svelte} (100%) rename frontend/src/lib/shared/services/{confirm.test-host.svelte => confirm.test-fixture.svelte} (100%) diff --git a/frontend/src/lib/document/transcription/TranscriptionBlock.svelte.spec.ts b/frontend/src/lib/document/transcription/TranscriptionBlock.svelte.spec.ts index c5562630..a635238d 100644 --- a/frontend/src/lib/document/transcription/TranscriptionBlock.svelte.spec.ts +++ b/frontend/src/lib/document/transcription/TranscriptionBlock.svelte.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; -import TranscriptionBlockHost from './TranscriptionBlock.test-host.svelte'; +import TranscriptionBlockHost from './TranscriptionBlock.test-fixture.svelte'; import type { ConfirmService } from '$lib/shared/services/confirm.svelte.js'; afterEach(cleanup); diff --git a/frontend/src/lib/document/transcription/TranscriptionBlock.test-host.svelte b/frontend/src/lib/document/transcription/TranscriptionBlock.test-fixture.svelte similarity index 100% rename from frontend/src/lib/document/transcription/TranscriptionBlock.test-host.svelte rename to frontend/src/lib/document/transcription/TranscriptionBlock.test-fixture.svelte diff --git a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts index 6d43d56b..d70c0e09 100644 --- a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts +++ b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts @@ -8,7 +8,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page, userEvent } from 'vitest/browser'; -import PersonMentionEditorHost from './PersonMentionEditor.test-host.svelte'; +import PersonMentionEditorHost from './PersonMentionEditor.test-fixture.svelte'; import type { components } from '$lib/generated/api'; import { m } from '$lib/paraglide/messages.js'; // Single source of truth for the debounce window — imported from the shared diff --git a/frontend/src/lib/shared/discussion/PersonMentionEditor.test-host.svelte b/frontend/src/lib/shared/discussion/PersonMentionEditor.test-fixture.svelte similarity index 100% rename from frontend/src/lib/shared/discussion/PersonMentionEditor.test-host.svelte rename to frontend/src/lib/shared/discussion/PersonMentionEditor.test-fixture.svelte diff --git a/frontend/src/lib/shared/services/confirm.svelte.test.ts b/frontend/src/lib/shared/services/confirm.svelte.test.ts index a1449b96..3b9126f7 100644 --- a/frontend/src/lib/shared/services/confirm.svelte.test.ts +++ b/frontend/src/lib/shared/services/confirm.svelte.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page, userEvent } from 'vitest/browser'; -import TestHost from './confirm.test-host.svelte'; +import TestHost from './confirm.test-fixture.svelte'; import type { ConfirmService } from './confirm.svelte.js'; afterEach(cleanup); diff --git a/frontend/src/lib/shared/services/confirm.test-host.svelte b/frontend/src/lib/shared/services/confirm.test-fixture.svelte similarity index 100% rename from frontend/src/lib/shared/services/confirm.test-host.svelte rename to frontend/src/lib/shared/services/confirm.test-fixture.svelte -- 2.49.1 From d4cfd4c8fd36fa761fe264f5636a06dc7683b528 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 20 May 2026 00:05:17 +0200 Subject: [PATCH 35/41] test(transcription): make @mention onKeyDown tests consistent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap all four onKeyDown unit tests (ArrowDown/ArrowUp/Enter/Escape) in flushSync uniformly so the next reader doesn't have to figure out why some are wrapped and others aren't. Felix #1 on PR #629 round 3. Also add a comment above the describe block calling out that these unit tests do NOT exercise the Tiptap forwarding chain — that is covered by the 'ArrowDown moves the highlight' integration test. Sara #3 on PR #629 round 3. Co-Authored-By: Claude Opus 4.7 --- .../discussion/MentionDropdown.svelte.test.ts | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts b/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts index 95124728..0499cf7b 100644 --- a/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts +++ b/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts @@ -258,8 +258,19 @@ describe('MentionDropdown — search input', () => { // same export so a regression in highlightedIndex/selection logic is caught // at the unit level. The full E2E focus-chain test is deferred to a separate // issue (Playwright). +// +// These unit tests directly invoke the exported `onKeyDown` to pin its +// behaviour in isolation. They do NOT exercise the Tiptap forwarding +// chain (PersonMentionEditor.suggestion.render() returning { onKeyDown }) +// — that integration is covered by the 'ArrowDown moves the highlight' +// test in PersonMentionEditor.svelte.spec.ts. Sara on PR #629 round 3. describe('MentionDropdown — onKeyDown forwarding', () => { + // flushSync ensures Svelte reactivity propagation completes before + // asserting (uniform across all four key tests so the next reader + // doesn't have to figure out why some are wrapped and others aren't). + // Felix #1 suggestion on PR #629 round 3. + it('ArrowDown advances aria-selected to the next option in the listbox', async () => { const container = document.createElement('div'); document.body.appendChild(container); @@ -338,7 +349,10 @@ describe('MentionDropdown — onKeyDown forwarding', () => { try { const exports = instance as unknown as { onKeyDown: (e: KeyboardEvent) => boolean }; - const consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'Enter' })); + let consumed = false; + flushSync(() => { + consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'Enter' })); + }); expect(consumed).toBe(true); expect(command).toHaveBeenCalledTimes(1); expect(command.mock.calls[0][0].id).toBe('p1'); @@ -359,7 +373,10 @@ describe('MentionDropdown — onKeyDown forwarding', () => { }); try { const exports = instance as unknown as { onKeyDown: (e: KeyboardEvent) => boolean }; - const consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'Escape' })); + let consumed = true; + flushSync(() => { + consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'Escape' })); + }); expect(consumed).toBe(false); } finally { unmount(instance); -- 2.49.1 From e0b2db061bc9ad1f12f5fe0868583a33915e12c5 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 20 May 2026 00:05:56 +0200 Subject: [PATCH 36/41] i18n(transcription): align @mention search label verb-number across locales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit de + es already use singular ("Person suchen", "Buscar persona"); en was plural ("Search persons"). Switch en to "Search for a person" so all three locales announce a singular search control to screen-reader users — cross-locale parity polish. Leonie #1 on PR #629 round 3. Co-Authored-By: Claude Opus 4.7 --- frontend/messages/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/messages/en.json b/frontend/messages/en.json index bbd5786c..231c9096 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -445,7 +445,7 @@ "person_mention_load_error": "Could not load person.", "person_mention_loading": "Loading person…", "person_mention_popup_empty": "No persons found", - "person_mention_search_label": "Search persons", + "person_mention_search_label": "Search for a person", "person_mention_search_prompt": "Enter a name…", "person_mention_btn_label": "Link person", "person_mention_create_new": "Create new person", -- 2.49.1 From 790f870cac888f00048b8ddf08a2d6fc9d5a4e67 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 20 May 2026 00:13:47 +0200 Subject: [PATCH 37/41] a11y(transcription): persistent aria-live region for @mention dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The aria-live region previously lived inside {#if items.length === 0} so it remounted whenever items transitioned between empty and populated — VoiceOver in particular swallows announcements from freshly-mounted live regions, and the "N persons found" announcement was missing entirely on the populated branch. Move the live region above the conditional so the element persists, and announce a localized "1 person found" / "N persons found" count on the populated branch. The visible empty-state

stays as a visual cue (no aria-live). Leonie #3 on PR #629 round 3. Adds person_mention_results_count_singular / _plural in de/en/es. Co-Authored-By: Claude Opus 4.7 --- frontend/messages/de.json | 2 + frontend/messages/en.json | 2 + frontend/messages/es.json | 2 + .../shared/discussion/MentionDropdown.svelte | 26 +++++++-- .../discussion/MentionDropdown.svelte.test.ts | 54 +++++++++++++++++-- .../PersonMentionEditor.svelte.spec.ts | 35 +++++++++--- 6 files changed, 106 insertions(+), 15 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 12ea5f5e..bdc89a7e 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -449,6 +449,8 @@ "person_mention_search_prompt": "Namen eingeben…", "person_mention_btn_label": "Person verlinken", "person_mention_create_new": "Neue Person anlegen", + "person_mention_results_count_singular": "1 Person gefunden", + "person_mention_results_count_plural": "{count} Personen gefunden", "transcription_editor_aria_label": "Transkriptionstext", "person_born_name_prefix": "geb.", "page_title_home": "Archiv", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 231c9096..0c2ad4f1 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -449,6 +449,8 @@ "person_mention_search_prompt": "Enter a name…", "person_mention_btn_label": "Link person", "person_mention_create_new": "Create new person", + "person_mention_results_count_singular": "1 person found", + "person_mention_results_count_plural": "{count} persons found", "transcription_editor_aria_label": "Transcription text", "person_born_name_prefix": "née", "page_title_home": "Archive", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 376dd39e..7631e348 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -449,6 +449,8 @@ "person_mention_search_prompt": "Escribe un nombre…", "person_mention_btn_label": "Vincular persona", "person_mention_create_new": "Crear nueva persona", + "person_mention_results_count_singular": "1 persona encontrada", + "person_mention_results_count_plural": "{count} personas encontradas", "transcription_editor_aria_label": "Texto de transcripción", "person_born_name_prefix": "n.", "page_title_home": "Archivo", diff --git a/frontend/src/lib/shared/discussion/MentionDropdown.svelte b/frontend/src/lib/shared/discussion/MentionDropdown.svelte index 0cc4378c..b0d1202a 100644 --- a/frontend/src/lib/shared/discussion/MentionDropdown.svelte +++ b/frontend/src/lib/shared/discussion/MentionDropdown.svelte @@ -193,13 +193,31 @@ function selectItem(item: Person) { />

+ +

+ {#if model.items.length === 0} + {searchQuery.trim() === '' + ? m.person_mention_search_prompt() + : m.person_mention_popup_empty()} + {:else if model.items.length === 1} + {m.person_mention_results_count_singular()} + {:else} + {m.person_mention_results_count_plural({ count: model.items.length })} + {/if} +

{#if model.items.length === 0} -

+

{searchQuery.trim() === '' ? m.person_mention_search_prompt() : m.person_mention_popup_empty()} diff --git a/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts b/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts index 0499cf7b..47554642 100644 --- a/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts +++ b/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts @@ -47,15 +47,25 @@ describe('MentionDropdown', () => { it('shows the "enter a name" prompt when the search field is empty', async () => { render(MentionDropdown, { props: { model: baseModel() } }); - await expect.element(page.getByText(m.person_mention_search_prompt())).toBeVisible(); - await expect.element(page.getByText(m.person_mention_popup_empty())).not.toBeInTheDocument(); + // Scope to the visible empty-state

(text-ink-3) — the persistent + // sr-only aria-live region above also contains the same prompt copy. + const visibleEmptyP = document.querySelector( + '[role="listbox"] p.text-ink-3' + ) as HTMLElement | null; + expect(visibleEmptyP).not.toBeNull(); + expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_search_prompt()); + expect(visibleEmptyP!.textContent ?? '').not.toContain(m.person_mention_popup_empty()); }); it('shows "no persons found" when the search has a query but the list is empty', async () => { render(MentionDropdown, { props: { model: baseModel(), editorQuery: 'WdG' } }); - await expect.element(page.getByText(m.person_mention_popup_empty())).toBeVisible(); - await expect.element(page.getByText(m.person_mention_search_prompt())).not.toBeInTheDocument(); + const visibleEmptyP = document.querySelector( + '[role="listbox"] p.text-ink-3' + ) as HTMLElement | null; + expect(visibleEmptyP).not.toBeNull(); + expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_popup_empty()); + expect(visibleEmptyP!.textContent ?? '').not.toContain(m.person_mention_search_prompt()); }); it('shows the create-new escape hatch link in the empty state', async () => { @@ -156,16 +166,50 @@ describe('MentionDropdown — search input', () => { expect(input.className).toContain('min-h-[44px]'); }); - it('announces empty-state copy via aria-live="polite" (Leonie FINDING-MENTION-002 on PR #629)', async () => { + it('renders a persistent aria-live="polite" region (does not remount on items transition; Leonie #3 on PR #629)', async () => { render(MentionDropdown, { props: { model: baseModel() } }); const listbox = document.querySelector('[role="listbox"]'); expect(listbox).not.toBeNull(); const live = listbox!.querySelector('p[aria-live="polite"]'); expect(live).not.toBeNull(); + // Empty + empty-query → "Namen eingeben…" prompt expect(live!.textContent ?? '').toContain(m.person_mention_search_prompt()); }); + it('announces the result count in the persistent live region when items populate (Leonie #3 on PR #629)', async () => { + render(MentionDropdown, { + props: { + model: baseModel({ + items: [ + makePerson('p1', 'Anna Schmidt'), + makePerson('p2', 'Bert Meier'), + makePerson('p3', 'Carl Vogel') + ] + }) + } + }); + + const listbox = document.querySelector('[role="listbox"]'); + expect(listbox).not.toBeNull(); + const live = listbox!.querySelector('p[aria-live="polite"]'); + expect(live).not.toBeNull(); + // Populated → "3 Personen gefunden" (plural) + expect(live!.textContent ?? '').toContain('3'); + }); + + it('keeps the visible empty-state copy without its own aria-live (the persistent live region announces; Leonie #3 on PR #629)', async () => { + render(MentionDropdown, { props: { model: baseModel(), editorQuery: 'WdG' } }); + + // Visible empty-state

exists with the empty-result copy ... + const empty = document.querySelector('p.text-ink-3') as HTMLElement | null; + expect(empty).not.toBeNull(); + expect(empty!.textContent ?? '').toContain(m.person_mention_popup_empty()); + // ... but it must NOT carry its own aria-live (the persistent sr-only + // region above the conditional is the announcer now). + expect(empty!.hasAttribute('aria-live')).toBe(false); + }); + it('renders the magnifier icon at h-5 w-5 with text-ink-2 (Leonie BLOCKER on PR #629)', async () => { render(MentionDropdown, { props: { model: baseModel() } }); diff --git a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts index d70c0e09..bdbb1b74 100644 --- a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts +++ b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts @@ -166,8 +166,15 @@ describe('PersonMentionEditor — typeahead', () => { await userEvent.type(page.getByRole('textbox'), '@xyz'); - await vi.waitFor(async () => { - await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument(); + // The visible empty-state

(text-ink-3) shows the copy. The persistent + // sr-only aria-live region also contains the same copy, so we scope to the + // visible element to avoid a multi-match resolution in expect.element. + await vi.waitFor(() => { + const visibleEmptyP = document.querySelector( + '[role="listbox"] p.text-ink-3' + ) as HTMLElement | null; + expect(visibleEmptyP).not.toBeNull(); + expect(visibleEmptyP!.textContent ?? '').toContain('Keine Personen gefunden'); }); }); @@ -290,7 +297,13 @@ describe('PersonMentionEditor — whitespace-only query', () => { await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); - await expect.element(page.getByText(m.person_mention_search_prompt())).toBeInTheDocument(); + // Scope to the visible empty-state

(text-ink-3) — the persistent + // sr-only aria-live region above contains the same copy. + const visibleEmptyP = document.querySelector( + '[role="listbox"] p.text-ink-3' + ) as HTMLElement | null; + expect(visibleEmptyP).not.toBeNull(); + expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_search_prompt()); expect(fetchMock).not.toHaveBeenCalled(); }); }); @@ -340,8 +353,14 @@ describe('PersonMentionEditor — server failure', () => { // Pins current silent-failure behaviour. The day someone implements a // distinct error UX (toast / "Suche fehlgeschlagen" copy), this test - // goes red and forces them to update the assertion. - await expect.element(page.getByText(m.person_mention_popup_empty())).toBeInTheDocument(); + // goes red and forces them to update the assertion. Scope to the + // visible

(text-ink-3) — the persistent sr-only live region + // above contains the same copy. + const visibleEmptyP = document.querySelector( + '[role="listbox"] p.text-ink-3' + ) as HTMLElement | null; + expect(visibleEmptyP).not.toBeNull(); + expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_popup_empty()); }); it('on a fetch reject (network failure) keeps the dropdown open with the empty-state copy', async () => { @@ -352,7 +371,11 @@ describe('PersonMentionEditor — server failure', () => { await userEvent.type(page.getByRole('textbox'), '@Aug'); await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); - await expect.element(page.getByText(m.person_mention_popup_empty())).toBeInTheDocument(); + const visibleEmptyP = document.querySelector( + '[role="listbox"] p.text-ink-3' + ) as HTMLElement | null; + expect(visibleEmptyP).not.toBeNull(); + expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_popup_empty()); }); }); -- 2.49.1 From 3c5fc6907c3dfd77e7dbde4d605d0f1395207182 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 20 May 2026 00:15:01 +0200 Subject: [PATCH 38/41] chore(lint): forbid *.test-fixture.svelte imports from production code Add ESLint no-restricted-imports rule banning *.test-fixture.svelte from non-test files. Tree-shaking already keeps test fixtures out of the production bundle, but making the boundary lint-enforced catches an accidental autocomplete-driven import in a route or component. Test files and the fixtures themselves are exempt. Nora #2 on PR #629 round 3. Co-Authored-By: Claude Opus 4.7 --- frontend/eslint.config.js | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 5ef17b59..037353b8 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -106,6 +106,31 @@ export default defineConfig( ] } }, + { + // Forbid test fixtures (*.test-fixture.svelte) from being imported by + // production code. Tree-shaking keeps them out of the production bundle + // today (no route reaches them), but a lint rule makes the boundary + // explicit so an accidental autocomplete import in a route or component + // fails fast. Test files (*.spec.ts / *.test.ts) and the fixtures + // themselves are exempt — see the next block. Nora #2 on PR #629 + // round 3. + files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js', '**/*.ts'], + ignores: ['**/*.spec.ts', '**/*.test.ts', '**/*.test-fixture.svelte'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['**/*.test-fixture.svelte'], + message: + 'Test fixtures (*.test-fixture.svelte) are test-only — do not import from production code. Tracked by #637.' + } + ] + } + ] + } + }, { plugins: { boundaries }, settings: { -- 2.49.1 From 51cb8e7e22876d17fb8da4c5f8660bfd1ed76909 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 20 May 2026 07:12:48 +0200 Subject: [PATCH 39/41] test(transcription): polish @mention test docstrings and tighten clip assert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-4 polish from Sara (#11199) and Felix (#11186): - Replace setTimeout(50) in stale-response race with tick() — matches round-3 pattern Sara verified in the sticky-takeover test. - Add intent comment above the "clear input" wait — it is a negative assertion that must not be optimised away. - Tighten displayName-clip assert from <=100 to ===100 so the test discriminates "clip works" from "clip works AND nothing weakened it". - JSDoc POST_DEBOUNCE_SLACK_MS with the calibration rationale. Co-Authored-By: Claude Opus 4.7 --- .../PersonMentionEditor.svelte.spec.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts index bdbb1b74..3b58a62f 100644 --- a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts +++ b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts @@ -8,6 +8,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page, userEvent } from 'vitest/browser'; +import { tick } from 'svelte'; import PersonMentionEditorHost from './PersonMentionEditor.test-fixture.svelte'; import type { components } from '$lib/generated/api'; import { m } from '$lib/paraglide/messages.js'; @@ -18,7 +19,12 @@ import { SEARCH_DEBOUNCE_MS } from './mentionConstants'; type Person = components['schemas']['Person']; type PersonMention = components['schemas']['PersonMention']; -// Slack on top of the debounce to absorb CI jitter (total 500 ms is generous). +/** + * Headroom above SEARCH_DEBOUNCE_MS for the debounce-window wait + * assertions in this file. 350 ms is calibrated against CI-runner jitter + * we observed pre-#629; dropping it below ~200 ms reintroduces flake. + * See PR #629 round-2 review comment #10935 (Sara). + */ const POST_DEBOUNCE_SLACK_MS = 350; const AUGUSTE: Person = { @@ -275,6 +281,9 @@ describe('PersonMentionEditor — AC-2/3: search input drives fetch', () => { await userEvent.clear(page.getByRole('searchbox')); + // Negative assertion: wait past the debounce window to confirm no + // trailing fetch was scheduled. Removing this wait would mask a + // re-introduction of the keystroke-driven items() fetch. await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); expect(fetchMock.mock.calls.length).toBe(fetchesBeforeClear); @@ -332,7 +341,10 @@ describe('PersonMentionEditor — stale-response race', () => { // The stale fetch now resolves with persons. The dropdown must stay empty. resolveFetch({ ok: true, json: () => Promise.resolve([AUGUSTE]) }); - await new Promise((r) => setTimeout(r, 50)); + // Flush pending Svelte reactivity so any (non-)update from the stale + // fetch resolution has landed before we assert. expect.element already + // polls, so no fixed-timeout fallback is needed. Sara on PR #629 round 4. + await tick(); await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument(); }); @@ -528,7 +540,10 @@ describe('PersonMentionEditor — AC-1: typed text as displayName', () => { await vi.waitFor(() => { expect(host.snapshot.mentionedPersons).toHaveLength(1); - expect(host.snapshot.mentionedPersons[0].displayName.length).toBeLessThanOrEqual(100); + // Tight assertion: input is 105 chars, cap is exactly 100. Using + // `toHaveLength(100)` discriminates "clip works" from "clip works + // AND nothing weakened it to e.g. 95". Sara on PR #629 round 4. + expect(host.snapshot.mentionedPersons[0].displayName).toHaveLength(100); }); }); -- 2.49.1 From ec9855f60bb9a53f95686e0c6e4b7c54bfbf195c Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 20 May 2026 07:15:15 +0200 Subject: [PATCH 40/41] a11y(transcription): hide visible @mention empty-state from AT and fold empty-query check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-4 polish from Leonie (S-2), Felix (#3), Sara (#4): - Add aria-hidden="true" to the visible empty-state

so VoiceOver does not double-announce — the persistent sr-only live region is now the sole AT source of truth (NVDA already de-duped, VoiceOver did not). - Extract `searchQuery.trim() === ''` into an `isQueryEmpty` $derived; both the announcer branch and the visible empty-state branch now read from the single intent-named alias. - Cover the singular branch of the persistent live region (1 item -> "1 Person gefunden" / "1 person found" / "1 persona encontrada"). Plural was already covered; this closes the missing-branch gap. - Extend the existing "no aria-live on visible

" test to also assert aria-hidden="true" so a regression on the AT-source-of-truth contract goes red immediately. Co-Authored-By: Claude Opus 4.7 --- .../shared/discussion/MentionDropdown.svelte | 21 +++++++++------- .../discussion/MentionDropdown.svelte.test.ts | 24 ++++++++++++++++++- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/shared/discussion/MentionDropdown.svelte b/frontend/src/lib/shared/discussion/MentionDropdown.svelte index b0d1202a..c647f434 100644 --- a/frontend/src/lib/shared/discussion/MentionDropdown.svelte +++ b/frontend/src/lib/shared/discussion/MentionDropdown.svelte @@ -43,6 +43,11 @@ let { let searchQuery = $state(untrack(() => editorQuery.slice(0, MAX_QUERY_LENGTH))); let userHasEdited = $state(false); +// Intent-revealing alias used by both the persistent aria-live announcer and +// the visible empty-state copy. Folding the duplicated rule into one $derived +// keeps the two branches in lockstep. Felix #3 on PR #629 round 4. +const isQueryEmpty = $derived(searchQuery.trim() === ''); + // Mirror the editor's typed text until the user takes ownership. // // Why `$state + $effect` (not `$derived`): `searchQuery` is also written by @@ -203,9 +208,7 @@ function selectItem(item: Person) { -->

{#if model.items.length === 0} - {searchQuery.trim() === '' - ? m.person_mention_search_prompt() - : m.person_mention_popup_empty()} + {isQueryEmpty ? m.person_mention_search_prompt() : m.person_mention_popup_empty()} {:else if model.items.length === 1} {m.person_mention_results_count_singular()} {:else} @@ -215,12 +218,14 @@ function selectItem(item: Person) { {#if model.items.length === 0} -

- {searchQuery.trim() === '' - ? m.person_mention_search_prompt() - : m.person_mention_popup_empty()} +