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: { diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 37e25574..bdc89a7e 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -445,8 +445,12 @@ "person_mention_load_error": "Person konnte nicht geladen werden.", "person_mention_loading": "Lade Person…", "person_mention_popup_empty": "Keine Personen gefunden", + "person_mention_search_label": "Person suchen", + "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 b4b675c5..0c2ad4f1 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -445,8 +445,12 @@ "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 for a person", + "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 898b3e85..7631e348 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -445,8 +445,12 @@ "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_label": "Buscar persona", + "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/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/MentionDropdown.svelte b/frontend/src/lib/shared/discussion/MentionDropdown.svelte index 4932eda4..c647f434 100644 --- a/frontend/src/lib/shared/discussion/MentionDropdown.svelte +++ b/frontend/src/lib/shared/discussion/MentionDropdown.svelte @@ -2,7 +2,18 @@ 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'; +// 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. 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']; @@ -17,7 +28,46 @@ type DropdownState = { clientRect: (() => DOMRect | null) | null; }; -let { model }: { model: DropdownState } = $props(); +let { + model, + editorQuery = '', + onSearch = () => {} +}: { + model: DropdownState; + /** 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(); + +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 +// `bind:value` on the below, so it needs to be a mutable `$state`. +// A `$derived` would be read-only and would clobber direct user edits on +// every editor keystroke. The `userHasEdited` latch pins ownership once the +// user types into the input. Felix #1 on PR #629. +$effect(() => { + if (!userHasEdited) { + searchQuery = editorQuery.slice(0, MAX_QUERY_LENGTH); + } +}); + +// Fire onSearch whenever the effective query changes — covers both the +// editor mirror and direct input edits. This is the only place onSearch +// fires; when the dropdown is unmounted, the effect is disposed and no +// further fetches occur. +$effect(() => { + onSearch(searchQuery); +}); // 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). @@ -112,16 +162,70 @@ function selectItem(item: Person) { unauthenticated users. -->
+ {#if model.items.length === 0} + {isQueryEmpty ? 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} -- {m.person_mention_popup_empty()} + +