feat(transcription): decouple @mention display text from person search #380
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
When a transcriber types
@WdGin a transcription block, the dropdown filters persons using "WdG" as the search string — and finds nothing, because the stored person is "Walter de Gruyter". The same problem occurs with relative names like@Vater von Hildeor any nickname that doesn't match the stored display name.The display text typed in the document is historically correct and must be preserved verbatim. What needs to improve is the lookup — decoupling it from the typed text.
User Story
As a transcriber, I want to type a short form or nickname after
@and still find and link the correct person via a separate search field, so that the transcription preserves the original text while the system resolves the correct identity.Acceptance Criteria
Given the @mention dropdown is open, when it appears, then a search input field is visible and pre-filled with the text the user typed after
@.Given the search field contains text, when the user views the person list, then the list is filtered by the search field content (not the @-text).
Given the user edits the search field, when the content changes, then the person list updates in real time to match the new search string.
Given the user clears the search field completely, when the field is empty, then the person list shows no results and a prompt such as "Namen eingeben…" is displayed.
Given the user selects a person from the list, when the mention is saved, then the display text in the transcription remains exactly as the user typed it after
@(e.g. "WdG"), and the selected person is stored as the linked entity.Given the user closes the dropdown without selecting a person, when the mention is saved, then the display text is preserved and no person link is stored (unlinked mention remains valid).
Given an existing @mention is re-opened for editing, when the dropdown appears, then the search field is pre-filled with the saved display text.
NFR Notes
aria-label; keyboard navigation through the list must remain intact.🏛️ Markus Keller — Application Architect
Observations
PersonMentionEditoralready lives in$lib/shared/discussion/— correctly scoped as a shared component since it is used both in the transcription workflow and (viaMentionEditor) in comment threads. The new feature extends the dropdown portion only.MentionDropdown.svelte. This is a pure UI change; the backendGET /api/persons?q=endpoint already exists, is already called fromPersonMentionEditor, and already searches acrossdisplayName,alias, andperson_name_aliases. No new backend API is needed.searchWithDocumentCountquery inPersonRepositoryalready searches theperson_name_aliasestable and thep.aliasfield — so "WdG" would match if it is stored there. The real gap is in the UX flow, not the search query itself. It is worth deciding whether the alias data for historical nicknames is already populated and covering this use-case, or whether alias population is a prerequisite.PersonSummaryDTOleaksnotesto the frontend (visible in the generatedapi.ts). This was already flagged asNora #5618 #3in thePersonMentionEditorsource comment. Issue #380 does not make it worse, but thenotesleak remains open.<input>inside the imperatively-mountedMentionDropdownintroduces a focus complexity: the transcription editor must not blur when the user clicks into the search field. The existingonmousedown+e.preventDefault()pattern on list items handles this for selection — the same pattern is needed on the search input itself.queryviarenderProps. The new feature decouples the search string fromquery— the typed@textstays in the editor while the search input holds a separate, editable string. This meansrenderProps.queryis only used as the initial value of the search input; subsequent searches must be driven by the input's own state, not by Tiptap'sonUpdate.Recommendations
MentionDropdown.svelte— no new component needed. The dropdown is already fully isolated; adding a<input>at the top is proportionate.initialQueryas a prop toMentionDropdown(set ononStartfromrenderProps.query). The search input is$state-local inside the dropdown. The dropdown triggers a callback prop (onSearch: (q: string) => void) when the input changes;PersonMentionEditorhandles the debounced fetch and updatesdropdownState.items. This keeps fetch logic centralized inPersonMentionEditor, not duplicated inside the dropdown.onmousedown={(e) => e.preventDefault()}on the search<input>element to prevent the editor from blurring when the user clicks into the field. Without this, clicking the search input closes the dropdown.PersonMentionEditoralready notes thatMarkus #5616flagged this for an ADR. This feature adds another consumer of the pattern. Createdocs/adr/ADR-0XX-client-side-fetch-editor-suggestions.mdbefore or alongside this PR — that note has been open long enough.GET /api/persons?q=endpoint is already correct and searches aliases. If WdG still does not match, the fix is a data issue (alias not populated), not a code issue.Open Decisions
persons.aliasorperson_name_aliasestable already populated with short-forms like "WdG"? If not, this feature improves the UX but does not solve the root motivating example from the issue body. Worth clarifying scope: is populating alias data in scope for this issue, or a follow-up?👨💻 Felix Brandt — Fullstack Developer
Observations
MentionDropdown.svelteis currently 172 lines and has one visual concern (the list). Adding a search input keeps it under 220 lines — no split needed.modelshared state object pattern (a$stateproxy object passed by reference fromPersonMentionEditor). The new search input will need its own local statelet searchQuery = $state(initialQuery), initialized from a newinitialQueryprop. AddinginitialQueryas a prop alongsidemodelis clean.onKeyDownexported function currently handles Arrow/Enter/Escape. With a search input present,ArrowDownandEntermust only fire on the list when focus is on the list or the editor — if focus is inside the search input,Entershould not select the currently highlighted item (that would be surprising). The input should instead trigger search oninputevent, not on Enter.itemsasync fetch inPersonMentionEditoris currently wired to Tiptap'sonUpdatecallback (which fires on every@+ keystroke). After decoupling, a second fetch path is needed: the search input'soninputevent. Both paths land in the samedropdownState.items— the callback prop approach (Markus's rec) keeps this in one place.debounceis already used inMentionEditor.svelte(the comment-thread variant) with 200 ms. The NFR says ≤ 150 ms debounce. Use 150 ms consistently across both editors.displayNameon a stored mention node is the typed short-form. When Tiptap opens an existing mention for editing (via the suggestion plugin),renderProps.querywill be the display text. Passing it asinitialQuerysatisfies AC-7 automatically.{#each model.items as person, i (person.id)}already has a key expression — good. The new search input does not change this.Recommendations
initialQuery: stringprop toMentionDropdownand initializelet searchQuery = $state(initialQuery). Wireoninput={(e) => onSearch(e.currentTarget.value)}on the input. ExportonSearchas a prop.onmousedown={(e) => e.preventDefault()}to prevent editor blur when clicking into it.highlightedIndexto 0 whensearchQuerychanges — the existing$effectthat watchesmodel.itemsalready does this for item list changes, so it is covered as long as the new search → fetch → items update path flows throughdropdownState.items.PersonMentionEditorfor the search input callback — replace the existing Tiptap-driven immediate fetch.searchQueryis empty, do not fire a fetch — callonSearch('')and inPersonMentionEditorsetdropdownState.items = []immediately. Show the "Namen eingeben…" prompt whensearchQuery === ''. The existing{#if model.items.length === 0}block is where this condition branches — add aemptyBecauseNoQueryflag or check the query length in the dropdown.PersonMentionEditor.svelte.spec.ts. New tests needed: AC-3 (live filter update), AC-4 (empty prompt), AC-7 (pre-fill on re-open).🛠️ Tobias Wendt — DevOps & Platform Engineer
Observations
/api/persons?q=endpoint is already being called on every@-keystroke; the new search input will trigger additional calls. The backend does not currently apply query rate limiting. At family-archive scale this is a non-issue, but worth noting.PersonSummaryDTOreturningnotesover the wire (already flagged in the source) is a mild data-transfer overhead, not an infrastructure concern.Recommendations
/api/persons/searchendpoint with a different response shape just for this dropdown. The existing endpoint is correct and already proxied through Vite/Caddy.onUpdate. The current Tiptap suggestion plugin firesitems()on every character — replacing that with a 150 ms debounce on the decoupled search input is a minor performance improvement for the backend.data-test-search-inputattribute (or equivalentaria-label) on the new search field so Playwright E2E selectors do not rely on fragile CSS class selectors.📋 Elicit — Requirements Engineer
Observations
PersonMentionEditorstoresrenderProps.queryasdisplayName, notperson.displayName. This was implemented as "AC-1 fix" in the current code. AC-5 should be verified rather than implemented from scratch. The teststores the typed query as displayName, not the person DB nameinPersonMentionEditor.svelte.spec.tsalready covers this.onExitpreserve the raw text. No new code needed.onStartfor an existing mention,renderProps.queryis the current display text. The new search input would receive this asinitialQuery. This is the only AC where an implementation question remains open: does Tiptap actually fireonStartagain when an existing mention is clicked/focused for editing? This should be verified against Tiptap's suggestion extension behavior before claiming AC-7 is delivered.de.jsonfor this (the existingperson_mention_popup_empty= "Keine Personen gefunden" is for the no-results state, not the empty-query state). A new key such asperson_mention_search_prompt(de: "Namen eingeben…", en: "Enter a name…", es: "Escribe un nombre…") is required.Recommendations
person_mention_search_prompttode.json,en.json, andes.jsonbefore or alongside the search input markup.onStartfire for existing mentions when re-edited?) and document the finding. If it does not, AC-7 requires additional implementation.persons.aliasorperson_name_aliases. The issue should explicitly state whether alias population is in scope or a prerequisite handled elsewhere.Open Decisions
onStartwhen the cursor enters an existing mention node, allowing the search field to pre-fill? This is an empirical question that determines whether AC-7 is free or requires custom Tiptap extension work.🔐 Nora "NullX" Steiner — Security Engineer
Observations
/api/persons?q=endpoint is read-only, already protected by@RequirePermission(Permission.READ_ALL), and already parameterized (searchWithDocumentCountuses named:queryparameter — not string concatenation).PersonSummaryDTOleaksnotes— this was already flagged in thePersonMentionEditorsource comment as "Nora #5618 #3". The new search input will trigger more calls to this endpoint (one per search keystroke after debounce), but does not worsen the data exposure. The open issue onPersonSummaryDTOresponse shape remains the fix target; it is not this issue's job.<input>accepts free-text from the user. This text goes intofetch(\/api/persons?q=${encodeURIComponent(query)}`)—encodeURIComponentis correct. The text is also shown as thevalueof the input element, which is text content, not innerHTML — no XSS risk there. The existing XSS test (renders a malicious displayName as text, not as HTML elements`) covers the dropdown rendering path; the search input path is safe by construction.rel="noopener"on the "Neue Person anlegen" link is already present inMentionDropdown.svelte(rel="noopener") — note it is missingnoreferrer. The referrer will leak the transcription page URL to/persons/new. This is low risk (same origin), but for completeness should berel="noopener noreferrer".Recommendations
rel="noopener"torel="noopener noreferrer"on the "Neue Person anlegen"<a>inMentionDropdown.svelte. (CWE-116 — minor, same-origin, but correct practice.)encodeURIComponentis used on the search input's query string before the fetch — do not allow raw interpolation.🧪 Sara Holt — QA Engineer
Observations
PersonMentionEditor.svelte.spec.tsis already well-structured: it usesvitest-browser-svelte, factory functions (mockFetchWithPersons,renderHost), and descriptive test names. The existing 20+ tests cover: typeahead open, fetch call, empty state, keyboard navigation, disabled state, XSS resistance, placeholder, touch target, and i18n content. This is a strong base.@-text) updates the person list. This is the core new behavior.PersonMentionEditortest host (PersonMentionEditor.test-host.svelte) would need to expose the search input touserEvent. Since the dropdown is mounted imperatively todocument.body, the search input would be reachable viapage.getByRole('searchbox')orpage.getByLabel(...)— verify this during implementation.each result row has min-h-[44px]) should be extended to cover the new search input: it must also meet 44px minimum height (WCAG 2.2 AA) since it is a touch target for tablet transcribers.vi.useFakeTimers()to fast-forward time and assert that fetch is not called before 150 ms elapses, then called after. The existing spec already usesvi.waitFor— fake timers complement this for timing-sensitive assertions.oninput→ debounce → fetch mock → items update → list render. Thevi.waitForpattern already used in the file handles async DOM updates correctly. NoThread.sleepequivalents, no risk of flakiness if fake timers are used correctly for the debounce.Recommendations
userEvent.typeinto the search input after dropdown opens; assertfetchis called with the new query and the person list updates.renderHost; open the dropdown on that mention position; assert the search input value equals the saveddisplayName.expect(searchInput.className).toContain('min-h-[44px]')or equivalent.vi.useFakeTimers()for the debounce test to avoid 150 ms real-time waits slowing the suite. One test at the boundary is sufficient — no need for an exhaustive timing matrix.🎨 Leonie Voss — UI/UX Design Lead
Observations
MentionDropdownisw-72(288px fixed width) — borderline tight for a search input plus life-date metadata on tablet. At 768px viewport, 288px is workable but leaves no room for an input label. Usemin-w-[288px]rather than a fixedw-72so the dropdown can flex wider if the caret position allows.min-h-[44px]on the input wrapper andpy-2.5for vertical padding (matching the existing list rows).aria-label: The NFR explicitly requires "a visible label oraria-label" on the search field. A visible label is strongly preferable for the senior audience — even a small inline<label>or a labelled icon (magnifying glass + "Suchen") communicates function. An invisiblearia-labelalone is not enough here. Recommend: a magnifying glass icon at the left of the input (decorative,aria-hidden="true") +placeholder={m.person_mention_search_prompt()}+aria-label={m.person_mention_btn_label()}.focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-insetconsistent with the rest of the UI. The input should useoutline-noneto suppress the browser default and replace with the ring.font-sans text-sm text-ink-3— that is fine.brand-mintfor the focus border on the overallPersonMentionEditorwrapper is already present. The internal search input should usebrand-navyfor its focus ring to differentiate it from the outer editor focus (which usesbrand-mint). This also avoids the WCAG 1.4.11 contrast issue already documented in the dropdown source.Recommendations
Search input structure:
The
sr-onlylabel + visible placeholder covers both screen readers and sighted users.Empty-query state: display "Namen eingeben…" as a
<p class="px-3 py-2.5 font-sans text-sm text-ink-3">— same class as the no-results message. The copy distinction carries the meaning without needing different styling.Do not autofocus the search input on dropdown open. The Tiptap editor must keep focus so typing continues to update the
@textcursor position in the document. The user can Tab into the search input when they want to refine.Tablet layout check: at 768px, test that the dropdown does not overflow the viewport horizontally when opened near the right edge. The existing flip strategy (open upward when near viewport bottom) should be extended to check horizontal bounds if needed.
Open Decisions
🗂️ Decision Queue — Open Items Needing Human Judgment
Three genuine tradeoffs were raised across the persona reviews. These are not implementation tasks — they are decisions that will shape what gets built.
1. Alias data completeness (Markus + Elicit)
Question: Is "WdG" (or similar short-forms) already stored in
persons.aliasorperson_name_aliases? The backend search query already includes those fields. If the alias data is not populated, this feature improves the search UX but does not fix the motivating example from the issue description.Why it matters: Determines whether alias population is in scope for this issue or a separate prerequisite. If it is a separate task, a follow-up issue should be created before this PR merges so the motivating use case does not fall through the cracks.
Options: (a) In scope — add alias population tooling/docs alongside this issue. (b) Out of scope — create a follow-up issue and note the dependency.
2. AC-7: Does Tiptap fire
onStartfor existing mentions? (Elicit + Felix)Question: When a transcriber positions their cursor inside an already-saved
@mentionnode, does Tiptap's suggestion extension fireonStartagain (allowing the search field to pre-fill with the saveddisplayName)? Or does it fireonUpdatewith the existing mention text? Or neither?Why it matters: Determines whether AC-7 ("re-opened mention pre-fills search field") is free (works automatically via
renderProps.query) or requires custom Tiptap extension code to detect cursor entry into an existing mention node. This is an empirical question — needs a quick prototype test before estimation is possible.Options: (a) Verify during implementation spike; if free, include in this issue. (b) If custom extension work is needed, split AC-7 into a follow-up issue.
3. Autofocus vs. Tab-to-search (Leonie)
Question: When the
@mentiondropdown opens, should the search input receive focus automatically, or should the user Tab into it from the editor?Why it matters: Affects all transcribers. Autofocus is faster for refinement but interrupts mid-sentence typing flow. Tab-to-search is safer for keyboard users but adds one extra action. The decision shapes the keyboard interaction model for every @mention operation.
Options: (a) No autofocus — editor retains focus, Tab reaches the input (Leonie's recommendation, safer for keyboard-only users). (b) Autofocus the search input on open — faster refinement, but requires the transcriber to re-focus the editor to resume typing.
This is a product decision about the primary transcription workflow and should be confirmed against real transcriber behavior before implementation.