feat(search): NL search frontend — toggle, chips, disambiguation, empty state #739
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?
Part of epic #735. Depends on the backend issue (#738).
Visual spec:
docs/specs/nl-search-spec.html— toggle pill anatomy (both states), all chip types, loading / error / empty full-area panels, light + dark, desktop + mobile 320 px.Goal
Add a smart search mode to the existing document search page. One input, one toggle pill inside the input — keyword mode stays unchanged. The LLM parsing happens on the server; the frontend only sends the query, renders the interpretation chips, and handles the three new states (loading, empty, error).
Pre-condition
Do not start this issue until the backend issue (#738) is merged and
npm run generate:apihas been run. TheNlQueryInterpretationtype does not exist yet. Building against a hand-crafted local type and then regenerating is a recipe for drift.When #738 lands, verify that its PR:
CLAUDE.mdanddocs/architecture/c4/l3-backend-*.pumlfor the newsearch/backend package/api/search/nl— the 2–15s expected latency must not trigger the existing p95 latency alert calibrated for document search (<500ms)POST /api/search/nlmust return 400 for queries longer than 500 chars. The client-sidemaxlength="500"is UX, not a security control.Do not implement
DisambiguationPickeruntil the multi-OR disambiguation approach is confirmed in #738 (Architecture Open Decision). Build toggle → chips → status → empty state first; stub disambiguation.Component Structure
New sub-components go in
src/routes/search/(subdirectory;SearchFilterBar.sveltestays insrc/routes/— do not move it).SearchFilterBar.svelteis currently 302 lines. Adding toggle, loading state, chips, disambiguation, empty state, and error state in-file would push it past 400 lines and give it two interaction modes. Split before coding:SmartModeToggle.svelte— toggle pill witharia-pressed, active style, focus ringInterpretationChipRow.svelte— chip list fromNlQueryInterpretation, handles × removalDisambiguationPicker.svelte— accessible disclosure + person list; useSvelteSet<string>fromsvelte/reactivityfor selected person IDs (plainSetmutations are invisible to Svelte's reactivity system)SmartSearchStatus.svelte— loading (role="status") + error state combined as full-area panelsSearchFilterBar.svelte— orchestrates all of the above alongside existing filter inputssmartModebinding site:smartMode: boolean = $bindable(false)is declared aslet smartMode = $state(false)insrc/routes/documents/+page.svelte(the only page that usesSearchFilterBar) and passed asbind:smartModeto<SearchFilterBar>. The home page (src/routes/+page.svelte) uses a dashboard layout — it does not importSearchFilterBar.frontend/CLAUDE.mdupdate: Thesrc/routes/search/component directory must be added to the Project Structure section infrontend/CLAUDE.md. It is not a new route (no+page.svelte) so the rootCLAUDE.mdroute table does not apply, but the component structure tree must reflect it. This is a merge blocker.Changes to
SearchFilterBar.svelteMode toggle
Add
smartMode: boolean = $bindable(false)prop (lifted to parent, consistent with existing bindable props).Toggle pill — sits inside the
relative flex-1input wrapper, absolutely positioned at the right edge (same slot as the magnifier icon). Inspired by Google's AI Mode button:absolute right-2 top-1/2 -translate-y-1/2 pointer-events-auto— positioned over the input's right paddingpr-28in smart mode so query text never overlaps the pillrounded-full px-2.5 py-1 flex items-center gap-1.5 text-[7.5px] font-bold outline-none focus-visible:ring-2 focus-visible:ring-brand-navy cursor-pointerborder border-line bg-muted text-ink-2— muted, does not compete with query textborder border-primary bg-primary text-primary-fg— matches the existing AND/OR operator button active pattern (SearchFilterBar.svelte line 173). Do not introduce a brand-navy background as a one-off token.search_toggle_smart_label→ "KI" /search_toggle_keyword_label→ "Text". Mobile (belowsm/ 640 px): label expands to "KI-Suche" / "Textsuche" for senior legibility — use a<span class="sm:hidden">suffix span.aria-pressed={smartMode}— communicates toggle state to screen readersIn smart mode, submitting the search calls
POST /api/search/nlinstead ofGET /api/documents/search. Useoninput={smartMode ? undefined : onSearch}on the search input — typing in smart mode does not trigger search; only form submit or explicit button click fires the NL POST. Also addmaxlength="500"to the input whensmartModeis active; remove it in keyword mode. Toggle state is UI-local, not persisted — resets to off on page navigation (accepted limitation, see below).Loading state
Full-area centered panel that fills the result area — not an inline one-liner. Rendered by
SmartSearchStatus.svelte:role="status" aria-live="polite"announces arrival to screen readers without stealing focus. Show for the full duration of the NL request (2–15s on CPU inference — do not show a timeout countdown). No staged phases. Usemotion-safe:animate-spinandmotion-safe:animate-pulse— Tailwind 4 evaluatesprefers-reduced-motion: reduceat the CSS layer automatically; no JavaScriptmatchMediavariable needed.Interpretation chips
Displayed above the result list after a smart search returns. All chip labels include a type prefix for senior legibility (a date like "1914–1918" is meaningless without context).
Chip row container: wrap chips in
<div class="flex flex-wrap gap-2">— chips wrap to the next line at 320px without horizontal scrolling.Single-name query (person + date + keywords when
keywordsApplied == true):2-name query (directional):
When
interpretation.resolvedPersonshas 2 entries, render a single directional chip usingresolvedPersons[0].displayName → resolvedPersons[1].displayName. Index 0 = sender, index 1 = receiver — the directionality is meaningful and must be shown. Do not render two separate person chips.Directional chip markup: use two separately-truncatable
<span>elements for the names, with the arrow between them:Give the chip wrapper an explicit
aria-label="Von {person0.displayName} zu {person1.displayName}, Filter entfernen"so screen readers announce the directional relation in plain language instead of reading the→character literally.keywordsAppliedrule: Only show keyword chips wheninterpretation.keywordsApplied === true. ForpersonRole: "any"single-name queries, keywords are parsed by the LLM but not applied to filter results — omit keyword chips entirely. Do not show greyed-out or disabled keyword chips.Each chip:
GET /api/documents/search(not POST) with remaining resolved paramsInterpretationChipRowemitsonRemoveChip(type: 'sender' | 'directional' | 'date' | 'keyword')callback. ('receiver'is omitted — no spec scenario produces a standalone receiver chip: single-name queries always resolve to sender, 2-name queries use the directional type.)SearchFilterBarimplements it — clearing the relevant$bindableprops (senderId,receiverId,from,to) and then callingonSearch. The chip row never calls the API directly.senderIdandreceiverIdsimultaneously — the chip represents the pairaria-label={Filter entfernen: ${label}}— not icon-onlymin-h-[44px]touch target (extend hit area; visual width stays narrow)focus-visible:ring-2 focus-visible:ring-brand-navy(not only the × button)<span class="sm:max-w-[12rem] max-w-[8rem] truncate">on each name span (narrower at 320px to leave room for the × button); the × button stays at fixed width outside{#each}over chips with a stable identifier — use filter type + entity ID (e.g.,"sender:" + person.id); for keyword chips use"keyword:" + keyword(the string itself is stable since the LLM deduplicates keywords)$derived, not$effect, for computed chip labels and filtered person lists{@html}anywhere in chip label rendering — LLM output must never reachinnerHTMLDisambiguation chip
When
NlQueryInterpretation.ambiguousPersonsis non-empty:Use a text cue "(auswählen...)" rather than ▾ alone — the ▾ character is not a recognizable affordance for the senior audience. The trigger button must have
aria-label="Mehrere Personen gefunden — zum Auswählen klicken"andmin-h-[44px].Clicking opens an accessible disclosure:
aria-expandedon the triggeraria-controlspointing to the picker panelTransparent empty state
When smart search returns zero results:
smartMode = false, keepq(raw query), trigger keyword search immediately. No navigation, no scroll reset. Requires toggle + query state wired together inSearchFilterBaror its parent.focus-visible:ring-2 focus-visible:ring-brand-navyand sufficient vertical padding to meet the 44px touch targetError states
Both error states are full-area centered panels (same
py-16 text-centerlayout as loading) rendered bySmartSearchStatus.svelte:SMART_SEARCH_UNAVAILABLE(503): Red icon circle (bg-red-50 border-2 border-red-400) + "Intelligente Suche nicht verfügbar" (title) + body text (search_error_unavailable_body) + "Zur Volltextsuche wechseln" button that callsswitchToKeywordMode().SMART_SEARCH_RATE_LIMITED(429): Amber icon circle (bg-amber-50 border-2 border-amber-400) + "Zu viele Anfragen" (title) + body text (search_error_rate_limited_body). No action button — the rate limit is temporary; the user should wait and retry in-place.Each error code must have its own
caseingetErrorMessage()— do not group them even if messages look similar.Error code rollout sequence — all four steps must land atomically:
SMART_SEARCH_UNAVAILABLEandSMART_SEARCH_RATE_LIMITEDtoErrorCode.javaErrorCodetype infrontend/src/lib/shared/errors.tscasefor each ingetErrorMessage()messages/{de,en,es}.jsonAPI integration
After the backend issue is merged, run
npm run generate:apito regenerate TypeScript types.Client-side POST pattern —
createApiClientat$lib/shared/api.server.tsimports$env/dynamic/privateand is server-side only. Browser components must use rawfetchwith CSRF token:This is the established pattern for all browser-component POSTs (
OcrTrainingCard.svelte,BulkDocumentEditLayout.svelte,admin/system/+page.svelte). ThewithCsrfwrapper is required — CSRF protection is active viaCookieCsrfTokenRepositoryand omitting it will return 403. UseparseBackendErrorto safely extract the error code (handles non-JSON error bodies gracefully). Note:page.route('/api/search/nl', ...)in Playwright intercepts before the real request is sent — CSRF enforcement is only verified in manual full-stack tests, not CI.Key response fields:
interpretation.resolvedPersons—PersonHint[](id + displayName); index 0 = sender, index 1 = receiver for 2-name queriesinterpretation.ambiguousPersons— non-empty means disambiguation UI; search result is emptyinterpretation.keywordsApplied—falseforpersonRole: "any"single-name queries; omit keyword chips when falseinterpretation.keywords— list of keyword strings; only render as chips whenkeywordsApplied === trueinterpretation.dateFrom/dateTo— ISO-8601 date strings or nullNew i18n message keys
All new UI strings must be added to
messages/{de,en,es}.jsonbefore any component renders them. Missing keys silently render as empty strings in non-German locales.search_toggle_smart_label< sm)search_toggle_keyword_label< sm)search_loading_nlsearch_loading_nl_subsearch_error_unavailablesearch_error_unavailable_bodysearch_switch_to_keywordsearch_error_rate_limitedsearch_error_rate_limited_bodysearch_empty_retry_keywordsearch_filter_remove_labelsearch_disambiguation_trigger_labelTest plan
Vitest (vitest-browser-svelte) — create 4 new spec files in
src/routes/search/, one per extracted component. Do not add smart-mode tests to the existingSearchFilterBar.svelte.spec.ts(already 197 lines, 5 describe blocks). TDD order: toggle first (simplest), disambiguation last (most complex). AddafterEach(() => cleanup())to each new spec file (matches existing pattern inSearchFilterBar.svelte.spec.ts).SmartModeToggle.svelte.spec.ts: toggle rendersaria-pressed="false"by default; clicking setsaria-pressed="true"and backSmartModeToggle.svelte.spec.ts: typing in the search input in smart mode does not call the NL endpoint (nooninputregression)SmartModeToggle.svelte.spec.ts: toggle label showssearch_toggle_smart_labelwhensmartMode === trueandsearch_toggle_keyword_labelwhensmartMode === falseSmartModeToggle.svelte.spec.ts: input hasmaxlength="500"whensmartMode === true; attribute absent whensmartMode === falseInterpretationChipRow.svelte.spec.ts: chips render with correct type-prefixed labels ([Absender: ...],[Zeitraum: ...],[Stichwort: ...])InterpretationChipRow.svelte.spec.ts: clicking × removes chip and callsGET /api/documents/searchwith the specific param absent (assert params, not just thatonSearchwas called)InterpretationChipRow.svelte.spec.ts: after one chip removed, row re-renders with N−1 chips (not 0)InterpretationChipRow.svelte.spec.ts: removing directional chip[Walter → Emma ×]callsGETwith neithersenderIdnorreceiverIdInterpretationChipRow.svelte.spec.ts:keywordsApplied: false→ keyword chips NOT rendered even whenkeywordsis non-emptyInterpretationChipRow.svelte.spec.ts:keywordsApplied: truewith emptykeywordsarray → no keyword chips renderedInterpretationChipRow.svelte.spec.ts:keywordsApplied: truewith 3 keywords → exactly 3 keyword chips renderedInterpretationChipRow.svelte.spec.ts: 2-name resolved → single directional chip containing→character (assertgetByText(/→/), not just chip count)InterpretationChipRow.svelte.spec.ts:×button is visible (in DOM) when chip label display name is 100 characters (long-label truncation does not push button off-screen)DisambiguationPicker.svelte.spec.ts: disambiguation chip shows ambiguous names and opens picker on clickDisambiguationPicker.svelte.spec.ts: focus moves into the picker list on open (getByRole+expect.poll(() => ...).toHaveFocus()— use poll in case disclosure has a CSS transition)DisambiguationPicker.svelte.spec.ts: closing the picker (Escape) returns focus to the trigger buttonDisambiguationPicker.svelte.spec.ts: dismissing the picker without selecting a person does NOT callonSearchSmartSearchStatus.svelte.spec.ts: loading state hasrole="status"and correct textSmartSearchStatus.svelte.spec.ts: loading state with unresolved Promise →getByRole('status')visible → resolve → assert it disappears; addvi.restoreAllMocks()inafterEachto prevent Promise leaksSmartSearchStatus.svelte.spec.ts:SMART_SEARCH_UNAVAILABLErenders full-area panel with icon, title, body, and switch-to-keyword buttonSmartSearchStatus.svelte.spec.ts:SMART_SEARCH_RATE_LIMITEDrenders full-area panel with icon, title, body — without switch-to-keyword buttonSearchFilterBar.svelte.spec.ts(existing): toggling back to keyword mode clears all interpretation chipsSearchFilterBar.svelte.spec.ts(existing): submitting a new query in smart mode clears existing chips before rendering new onesPlaywright E2E (1 test, happy path):
page.route('/api/search/nl', handler)to return a fixedNlQueryInterpretationfixture. Fixture must include:keywordsApplied: truewith at least one keyword and a 2-nameresolvedPersonspair so the directional chip renders. Add a deliberate ~100ms delay in the route handler (handler.fulfill({ delay: 100, body: ... })) so the loading state is assertable before the response arrives.role="status"visible → fixture resolves → chips appear → runAxeBuilderon the search region (both light and dark mode) → remove one chip → keyword search re-runs with remaining paramsAcceptance Criteria
/documentspage, labeled with the active mode label, and switches modes on clicksearch_toggle_smart_labelin smart mode andsearch_toggle_keyword_labelin keyword mode (expanded label on mobile belowsm)bg-primary text-primary-fg(matches existing AND/OR button pattern); resting pill usesbg-muted border-line text-ink-2[Absender: Walter Raddatz ×] [Zeitraum: 1914–1918 ×] [Stichwort: krieg ×]GET /api/documents/searchwith the resolved params minus the removed filter (notPOST /api/search/nl)[Walter → Emma ×]clears bothsenderIdandreceiverIdsimultaneouslySMART_SEARCH_UNAVAILABLEshows a full-area panel with icon, title, body, and keyword fallback buttonSMART_SEARCH_RATE_LIMITED(429) shows a full-area panel with icon, title, body — no keyword fallback buttonrole="status")interpretation.keywordsApplied === false, no keyword chips are shownaria-labelaria-pressedreflecting current modefocus-visible:ring-2(not only the × button)Absender:,Zeitraum:,Stichwort:)motion-safe:Tailwind utilities (both stop whenprefers-reduced-motionis set)maxlength="500"; in keyword mode, nomaxlengthattributefrontend/CLAUDE.mdProject Structure section updated to documentsrc/routes/search/Known Limitations (accepted)
Open Decisions
Architecture — Multi-sender OR query after disambiguation
The existing
GET /api/documents/searchtakes a singlesenderIdparam. When the user selects two persons from the disambiguation picker, the frontend must pass both. Options:senderIds[]array param toGET /api/documents/search— requires a backend change, keeps the chip re-run pattern clean.POST /api/search/nlwith selected person IDs — no change to keyword endpoint, but re-runs LLM parsing.Must be resolved in #738 before
DisambiguationPickeris built.DevOps / PR notes
/api/search/nlmust land in #738 before either issue merges. The 2–15s expected latency will page on every request under the existing p95 alert calibrated for document search. Confirm #738's PR includes this observability work./api/search/nllatency in Grafana does not trigger existing p95 latency alerts.page.route('/api/search/nl', ...)to mock — Ollama is not required in CI. CI needs only: SvelteKit dev server + Playwright browser. For manual testing of the full NL flow, run the stack withdocker-compose up -dand ensure the Ollama container from #738 is running.npm run generate:apirun. Verify #738's PR updatedCLAUDE.md,l3-backend-*.pumlfor the newsearch/package, and includes the Prometheus histogram for/api/search/nl.NlQueryInterpretationtype must exist insrc/lib/generated/before building."Implementation complete ✅ — PR #757
Branch:
feat/issue-739-nl-search-frontendCommits (TDD, atomic)
feat(search): add NL search frontend i18n keys (de/en/es)feat(search): add SmartModeToggle pill component— 8 specsfeat(search): add InterpretationChipRow component— 9 specsfeat(search): add SmartSearchStatus full-area panels— 5 specsfeat(search): add DisambiguationPicker single-select disclosure— 5 specsfeat(search): wire SmartModeToggle into SearchFilterBarfeat(search): orchestrate NL search on the documents pagetest(search): cover smart-mode chip lifecycle hooks(SearchFilterBar → 17 specs)docs(search): document src/routes/search/ component directorytest(search): add NL search happy-path Playwright E2ETests
npm run buildclean;svelte-checkintroduces no new errors.Resolved open decision (disambiguation)
Backend #738 shipped only single-person
searchDocumentsByPersonId(nosenderIds[]array). The picker is therefore single-select (Option C-style): selecting a candidate re-runs viaGET /api/documents/search. One sender + one receiver (directional) is fully supported.Deviations from the issue draft
4c620619) instead of the draft's "Du".documents/+page.svelte), not insideSearchFilterBar— consistent with the lifted-state pattern. Chip-clearing is driven by theonModeToggle/onSmartSearchcallbacks, which the SearchFilterBar specs pin.Next: multi-persona review on PR #757.
marcel referenced this issue2026-06-06 18:27:48 +02:00
marcel referenced this issue2026-06-06 18:28:37 +02:00
marcel referenced this issue2026-06-06 18:28:38 +02:00
marcel referenced this issue2026-06-06 18:28:40 +02:00
marcel referenced this issue2026-06-06 18:28:56 +02:00
marcel referenced this issue2026-06-06 19:15:33 +02:00
marcel referenced this issue2026-06-06 19:15:33 +02:00
marcel referenced this issue2026-06-06 19:15:33 +02:00
marcel referenced this issue2026-06-06 19:15:33 +02:00
marcel referenced this issue2026-06-06 19:15:42 +02:00
marcel referenced this issue2026-06-06 19:15:56 +02:00
marcel referenced this issue2026-06-06 19:16:13 +02:00
marcel referenced this issue2026-06-06 19:16:31 +02:00
marcel referenced this issue2026-06-06 19:27:47 +02:00
marcel referenced this issue2026-06-06 20:36:27 +02:00
marcel referenced this issue2026-06-06 21:05:26 +02:00