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

Merged
marcel merged 41 commits from feat/issue-380-decouple-mention-search into main 2026-05-20 20:36:39 +02:00
2 changed files with 22 additions and 3 deletions
Showing only changes of commit 5580fe9545 - Show all commits

View File

@@ -7,6 +7,15 @@ 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 <input maxlength> 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;
// The dropdown receives a single reactive state object. PersonMentionEditor
// mutates fields on this object (model.items = ..., etc.) and Svelte's $state
// proxy reactivity propagates the change here. This is the supported way to
@@ -30,7 +39,7 @@ let {
onSearch?: (query: string) => void;
} = $props();
let searchQuery = $state(untrack(() => editorQuery));
let searchQuery = $state(untrack(() => editorQuery.slice(0, MAX_QUERY_LENGTH)));
let userHasEdited = $state(false);
// Mirror the editor's typed text until the user takes ownership.
@@ -42,7 +51,7 @@ let userHasEdited = $state(false);
// user types into the input. Felix #1 on PR #629.
$effect(() => {
if (!userHasEdited) {
searchQuery = editorQuery;
searchQuery = editorQuery.slice(0, MAX_QUERY_LENGTH);
}
});
@@ -172,7 +181,7 @@ function selectItem(item: Person) {
id="mention-search"
type="search"
data-test-search-input
maxlength="100"
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"
placeholder={m.person_mention_search_prompt()}
bind:value={searchQuery}

View File

@@ -186,6 +186,16 @@ describe('MentionDropdown — search input', () => {
expect(input.maxLength).toBe(100);
});
it('clips a long editorQuery mirror to 100 chars (CWE-400 layered — Nora #1 on PR #629)', async () => {
const longQuery = 'A'.repeat(200);
render(MentionDropdown, { props: { model: baseModel(), editorQuery: longQuery } });
const input = document.querySelector('[data-test-search-input]') as HTMLInputElement;
expect(input).not.toBeNull();
expect(input.value.length).toBe(100);
expect(input.value).toBe('A'.repeat(100));
});
it('invokes onSearch with the current value whenever the user types', async () => {
const onSearch = vi.fn();
render(MentionDropdown, { props: { model: baseModel(), onSearch } });