feat(search): NL search frontend — toggle, chips, disambiguation, empty state #739

Closed
opened 2026-06-06 12:16:07 +02:00 by marcel · 1 comment
Owner

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:api has been run. The NlQueryInterpretation type 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:

  • Updated CLAUDE.md and docs/architecture/c4/l3-backend-*.puml for the new search/ backend package
  • Added a separate Prometheus histogram bucket (or label exclusion) for /api/search/nl — the 2–15s expected latency must not trigger the existing p95 latency alert calibrated for document search (<500ms)
  • Enforces server-side query length validation: POST /api/search/nl must return 400 for queries longer than 500 chars. The client-side maxlength="500" is UX, not a security control.

Do not implement DisambiguationPicker until 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.svelte stays in src/routes/ — do not move it).

SearchFilterBar.svelte is 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 with aria-pressed, active style, focus ring
  • InterpretationChipRow.svelte — chip list from NlQueryInterpretation, handles × removal
  • DisambiguationPicker.svelte — accessible disclosure + person list; use SvelteSet<string> from svelte/reactivity for selected person IDs (plain Set mutations are invisible to Svelte's reactivity system)
  • SmartSearchStatus.svelte — loading (role="status") + error state combined as full-area panels
  • SearchFilterBar.svelte — orchestrates all of the above alongside existing filter inputs

smartMode binding site: smartMode: boolean = $bindable(false) is declared as let smartMode = $state(false) in src/routes/documents/+page.svelte (the only page that uses SearchFilterBar) and passed as bind:smartMode to <SearchFilterBar>. The home page (src/routes/+page.svelte) uses a dashboard layout — it does not import SearchFilterBar.

frontend/CLAUDE.md update: The src/routes/search/ component directory must be added to the Project Structure section in frontend/CLAUDE.md. It is not a new route (no +page.svelte) so the root CLAUDE.md route table does not apply, but the component structure tree must reflect it. This is a merge blocker.

Changes to SearchFilterBar.svelte

Mode toggle

Add smartMode: boolean = $bindable(false) prop (lifted to parent, consistent with existing bindable props).

Toggle pill — sits inside the relative flex-1 input 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 padding
  • Input right padding expands to pr-28 in smart mode so query text never overlaps the pill
  • Style: rounded-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-pointer
  • Resting (keyword mode): border border-line bg-muted text-ink-2 — muted, does not compete with query text
  • Active (smart mode): border 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.
  • Icon + short text label. Desktop: search_toggle_smart_label → "KI" / search_toggle_keyword_label → "Text". Mobile (below sm / 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 readers
  • Mobile layout: pill stays inside the input at all breakpoints. The Sort + Filter + Reset row beneath is unchanged. No stacked layout.

In smart mode, submitting the search calls POST /api/search/nl instead of GET /api/documents/search. Use oninput={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 add maxlength="500" to the input when smartMode is 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:

<div role="status" aria-live="polite" class="flex flex-col items-center justify-center gap-3 py-16 text-center">
  <!-- 36 px / 3 px-stroke spinner ring: rounded-full border-[3px] border-primary/12 border-t-primary motion-safe:animate-spin -->
  <!-- title: text-sm font-bold — "Archiv wird befragt…" (search_loading_nl) -->
  <!-- subtitle: text-[9px] text-ink-3 max-w-xs motion-safe:animate-pulse — (search_loading_nl_sub) -->
</div>

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. Use motion-safe:animate-spin and motion-safe:animate-pulse — Tailwind 4 evaluates prefers-reduced-motion: reduce at the CSS layer automatically; no JavaScript matchMedia variable 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):

<!-- Example render: -->
[Absender: Walter Raddatz ×]  [Zeitraum: 1914–1918 ×]  [Stichwort: krieg ×]

2-name query (directional):

<!-- resolvedPersons[0] → resolvedPersons[1] -->
[Walter Raddatz → Emma Raddatz ×]  [Zeitraum: 1914–1918 ×]

When interpretation.resolvedPersons has 2 entries, render a single directional chip using resolvedPersons[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:

<span class="sm:max-w-[12rem] max-w-[8rem] truncate">{person0.displayName}</span>
<span aria-hidden="true"></span>
<span class="sm:max-w-[12rem] max-w-[8rem] truncate">{person1.displayName}</span>

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.

keywordsApplied rule: Only show keyword chips when interpretation.keywordsApplied === true. For personRole: "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:

  • Removable: clicking × drops that filter and re-runs via GET /api/documents/search (not POST) with remaining resolved params
  • Chip removal callback: InterpretationChipRow emits onRemoveChip(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.) SearchFilterBar implements it — clearing the relevant $bindable props (senderId, receiverId, from, to) and then calling onSearch. The chip row never calls the API directly.
  • Directional chip removal clears both senderId and receiverId simultaneously — the chip represents the pair
  • × button needs aria-label={Filter entfernen: ${label}} — not icon-only
  • × button needs min-h-[44px] touch target (extend hit area; visual width stays narrow)
  • Chip wrapper needs focus-visible:ring-2 focus-visible:ring-brand-navy (not only the × button)
  • Long chip labels: use <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
  • Key {#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)
  • Use $derived, not $effect, for computed chip labels and filtered person lists
  • No {@html} anywhere in chip label rendering — LLM output must never reach innerHTML

Disambiguation chip

When NlQueryInterpretation.ambiguousPersons is non-empty:

[Absender: Walter Raddatz, Walter Müller (auswählen...)]

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" and min-h-[44px].

Clicking opens an accessible disclosure:

  • aria-expanded on the trigger
  • aria-controls pointing to the picker panel
  • Focus moves into the picker list on open
  • Closing the picker (Escape or click outside) returns focus to the trigger button — do not let keyboard position get lost
  • User can select one or multiple persons (OR); selecting re-runs the search immediately
  • Dismissing without selecting leaves the chip in its ambiguous state (search results remain empty — accepted behaviour, see Known Limitations)

Open decision (Architecture): Multi-sender OR after disambiguation — see Open Decisions section below. Do not build DisambiguationPicker until resolved in #738.

Transparent empty state

When smart search returns zero results:

  • Show interpretation chips (what was searched)
  • Show: "Keine Ergebnisse — Als Volltextsuche wiederholen" link that switches to keyword mode with the raw query pre-filled
  • Implementation: Option A — in-page state change: set smartMode = false, keep q (raw query), trigger keyword search immediately. No navigation, no scroll reset. Requires toggle + query state wired together in SearchFilterBar or its parent.
  • The link must be keyboard-reachable with focus-visible:ring-2 focus-visible:ring-brand-navy and sufficient vertical padding to meet the 44px touch target

Error states

Both error states are full-area centered panels (same py-16 text-center layout as loading) rendered by SmartSearchStatus.svelte:

icon circle (40 px) → title (bold) → body text → optional action button

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 calls switchToKeywordMode().

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 case in getErrorMessage() — do not group them even if messages look similar.

Error code rollout sequence — all four steps must land atomically:

  1. Add SMART_SEARCH_UNAVAILABLE and SMART_SEARCH_RATE_LIMITED to ErrorCode.java
  2. Add to ErrorCode type in frontend/src/lib/shared/errors.ts
  3. Add a separate case for each in getErrorMessage()
  4. Add i18n keys in messages/{de,en,es}.json

API integration

After the backend issue is merged, run npm run generate:api to regenerate TypeScript types.

Client-side POST patterncreateApiClient at $lib/shared/api.server.ts imports $env/dynamic/private and is server-side only. Browser components must use raw fetch with CSRF token:

import { withCsrf } from '$lib/shared/cookies';
import { parseBackendError } from '$lib/shared/errors';

const res = await fetch('/api/search/nl', withCsrf({
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ query })
}));
if (!res.ok) {
  const err = await parseBackendError(res);
  // switch on err?.code to distinguish SMART_SEARCH_UNAVAILABLE vs SMART_SEARCH_RATE_LIMITED
  return;
}
const data = await res.json();

This is the established pattern for all browser-component POSTs (OcrTrainingCard.svelte, BulkDocumentEditLayout.svelte, admin/system/+page.svelte). The withCsrf wrapper is required — CSRF protection is active via CookieCsrfTokenRepository and omitting it will return 403. Use parseBackendError to 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.resolvedPersonsPersonHint[] (id + displayName); index 0 = sender, index 1 = receiver for 2-name queries
  • interpretation.ambiguousPersons — non-empty means disambiguation UI; search result is empty
  • interpretation.keywordsAppliedfalse for personRole: "any" single-name queries; omit keyword chips when false
  • interpretation.keywords — list of keyword strings; only render as chips when keywordsApplied === true
  • interpretation.dateFrom / dateTo — ISO-8601 date strings or null

New i18n message keys

All new UI strings must be added to messages/{de,en,es}.json before any component renders them. Missing keys silently render as empty strings in non-German locales.

Key German Usage
search_toggle_smart_label "KI" (desktop) / "KI-Suche" (mobile < sm) SmartModeToggle — smart mode
search_toggle_keyword_label "Text" (desktop) / "Textsuche" (mobile < sm) SmartModeToggle — keyword mode
search_loading_nl "Archiv wird befragt…" SmartSearchStatus loading title
search_loading_nl_sub "Die KI analysiert Ihre Anfrage. Das kann bis zu 15 Sekunden dauern." SmartSearchStatus loading subtitle
search_error_unavailable "Intelligente Suche nicht verfügbar" SmartSearchStatus 503 title
search_error_unavailable_body "Die KI-Suche ist momentan nicht erreichbar. Sie können Ihre Anfrage als einfache Volltextsuche wiederholen." SmartSearchStatus 503 body
search_switch_to_keyword "Zur Volltextsuche wechseln" SmartSearchStatus 503 button
search_error_rate_limited "Zu viele Anfragen" SmartSearchStatus 429 title
search_error_rate_limited_body "Du hast die intelligente Suche zu häufig genutzt. Bitte warte eine Minute und versuche es erneut." SmartSearchStatus 429 body
search_empty_retry_keyword "Als Volltextsuche wiederholen" Empty state link
search_filter_remove_label "Filter entfernen: {label}" Chip × button aria-label template
search_disambiguation_trigger_label "Mehrere Personen gefunden — zum Auswählen klicken" Disambiguation chip trigger

Test 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 existing SearchFilterBar.svelte.spec.ts (already 197 lines, 5 describe blocks). TDD order: toggle first (simplest), disambiguation last (most complex). Add afterEach(() => cleanup()) to each new spec file (matches existing pattern in SearchFilterBar.svelte.spec.ts).

  • SmartModeToggle.svelte.spec.ts: toggle renders aria-pressed="false" by default; clicking sets aria-pressed="true" and back
  • SmartModeToggle.svelte.spec.ts: typing in the search input in smart mode does not call the NL endpoint (no oninput regression)
  • SmartModeToggle.svelte.spec.ts: toggle label shows search_toggle_smart_label when smartMode === true and search_toggle_keyword_label when smartMode === false
  • SmartModeToggle.svelte.spec.ts: input has maxlength="500" when smartMode === true; attribute absent when smartMode === false
  • InterpretationChipRow.svelte.spec.ts: chips render with correct type-prefixed labels ([Absender: ...], [Zeitraum: ...], [Stichwort: ...])
  • InterpretationChipRow.svelte.spec.ts: clicking × removes chip and calls GET /api/documents/search with the specific param absent (assert params, not just that onSearch was 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 ×] calls GET with neither senderId nor receiverId
  • InterpretationChipRow.svelte.spec.ts: keywordsApplied: false → keyword chips NOT rendered even when keywords is non-empty
  • InterpretationChipRow.svelte.spec.ts: keywordsApplied: true with empty keywords array → no keyword chips rendered
  • InterpretationChipRow.svelte.spec.ts: keywordsApplied: true with 3 keywords → exactly 3 keyword chips rendered
  • InterpretationChipRow.svelte.spec.ts: 2-name resolved → single directional chip containing character (assert getByText(/→/), 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 click
  • DisambiguationPicker.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 button
  • DisambiguationPicker.svelte.spec.ts: dismissing the picker without selecting a person does NOT call onSearch
  • SmartSearchStatus.svelte.spec.ts: loading state has role="status" and correct text
  • SmartSearchStatus.svelte.spec.ts: loading state with unresolved Promise → getByRole('status') visible → resolve → assert it disappears; add vi.restoreAllMocks() in afterEach to prevent Promise leaks
  • SmartSearchStatus.svelte.spec.ts: SMART_SEARCH_UNAVAILABLE renders full-area panel with icon, title, body, and switch-to-keyword button
  • SmartSearchStatus.svelte.spec.ts: SMART_SEARCH_RATE_LIMITED renders full-area panel with icon, title, body — without switch-to-keyword button
  • SearchFilterBar.svelte.spec.ts (existing): toggling back to keyword mode clears all interpretation chips
  • SearchFilterBar.svelte.spec.ts (existing): submitting a new query in smart mode clears existing chips before rendering new ones

Playwright E2E (1 test, happy path):

  • Use page.route('/api/search/nl', handler) to return a fixed NlQueryInterpretation fixture. Fixture must include: keywordsApplied: true with at least one keyword and a 2-name resolvedPersons pair 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.
  • Type NL query → submit → assert role="status" visible → fixture resolves → chips appear → run AxeBuilder on the search region (both light and dark mode) → remove one chip → keyword search re-runs with remaining params

Acceptance Criteria

  • Toggle pill is visible inside the search input on the /documents page, labeled with the active mode label, and switches modes on click
  • Toggle label reads search_toggle_smart_label in smart mode and search_toggle_keyword_label in keyword mode (expanded label on mobile below sm)
  • Active pill uses bg-primary text-primary-fg (matches existing AND/OR button pattern); resting pill uses bg-muted border-line text-ink-2
  • In smart mode, submitting "Was hat walter im krieg geschrieben?" shows interpretation chips: [Absender: Walter Raddatz ×] [Zeitraum: 1914–1918 ×] [Stichwort: krieg ×]
  • Removing a chip calls GET /api/documents/search with the resolved params minus the removed filter (not POST /api/search/nl)
  • Removing the directional chip [Walter → Emma ×] clears both senderId and receiverId simultaneously
  • Ambiguous name chip expands to a picker; selecting a person re-runs immediately
  • Closing the disambiguation picker (Escape or click outside) returns focus to the trigger button
  • Empty smart search shows chips + "Als Volltextsuche wiederholen" link (Option A: in-page mode switch, no navigation)
  • SMART_SEARCH_UNAVAILABLE shows a full-area panel with icon, title, body, and keyword fallback button
  • SMART_SEARCH_RATE_LIMITED (429) shows a full-area panel with icon, title, body — no keyword fallback button
  • Loading state is a full-area centered panel announced by screen readers (role="status")
  • When interpretation.keywordsApplied === false, no keyword chips are shown
  • 2-name query with both resolved shows a single directional chip "Walter → Emma" (not two separate person chips)
  • All interactive elements have ≥44px touch targets
  • All × buttons have descriptive aria-label
  • Toggle pill has aria-pressed reflecting current mode
  • Submitting a new query in smart mode clears existing chips before rendering new ones
  • Toggling from smart mode to keyword mode removes all interpretation chips from the DOM
  • Long chip labels are truncated with × button outside the truncated span; directional chip uses two separately-truncatable name spans
  • Chip wrappers have focus-visible:ring-2 (not only the × button)
  • All chip labels include a type prefix (Absender:, Zeitraum:, Stichwort:)
  • Spinner and pulsing subtitle in loading state use motion-safe: Tailwind utilities (both stop when prefers-reduced-motion is set)
  • Chip row wraps to a new line at 320px viewport width without horizontal scrolling
  • In smart mode, the search input has maxlength="500"; in keyword mode, no maxlength attribute
  • frontend/CLAUDE.md Project Structure section updated to document src/routes/search/

Known Limitations (accepted)

  • Smart mode resets on page navigation. Toggle state is UI-local. A user who navigates away (e.g., opens a document) and returns via Back will see keyword mode with no NL chips. They must re-enter and re-submit their NL query.
  • Dismissing the disambiguation picker without selecting leaves the chip in its ambiguous state and the search results empty. This is by design — no automatic fallback is triggered.

Open Decisions

Architecture — Multi-sender OR query after disambiguation

The existing GET /api/documents/search takes a single senderId param. When the user selects two persons from the disambiguation picker, the frontend must pass both. Options:

  • (A) Backend adds a senderIds[] array param to GET /api/documents/search — requires a backend change, keeps the chip re-run pattern clean.
  • (B) Disambiguation picker triggers a new POST /api/search/nl with selected person IDs — no change to keyword endpoint, but re-runs LLM parsing.
  • (C) Picker forces exactly one person (OR dropped) — simplest, deviates from spec.

Must be resolved in #738 before DisambiguationPicker is built.

DevOps / PR notes

  • No infrastructure changes required for this frontend issue.
  • Prometheus histogram for /api/search/nl must 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.
  • Post-merge: verify /api/search/nl latency in Grafana does not trigger existing p95 latency alerts.
  • Playwright E2E uses 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 with docker-compose up -d and ensure the Ollama container from #738 is running.
  • PR description must include: "Depends on #738 merged + npm run generate:api run. Verify #738's PR updated CLAUDE.md, l3-backend-*.puml for the new search/ package, and includes the Prometheus histogram for /api/search/nl. NlQueryInterpretation type must exist in src/lib/generated/ before building."
Part of epic #735. Depends on the backend issue (#738). **Visual spec:** [`docs/specs/nl-search-spec.html`](https://git.raddatz.cloud/marcel/familienarchiv/src/branch/main/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:api` has been run.** The `NlQueryInterpretation` type 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: - Updated `CLAUDE.md` and `docs/architecture/c4/l3-backend-*.puml` for the new `search/` backend package - Added a separate Prometheus histogram bucket (or label exclusion) for `/api/search/nl` — the 2–15s expected latency must not trigger the existing p95 latency alert calibrated for document search (<500ms) - Enforces server-side query length validation: `POST /api/search/nl` must return 400 for queries longer than 500 chars. The client-side `maxlength="500"` is UX, not a security control. **Do not implement `DisambiguationPicker` until 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.svelte` stays in `src/routes/` — do not move it). `SearchFilterBar.svelte` is 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 with `aria-pressed`, active style, focus ring - `InterpretationChipRow.svelte` — chip list from `NlQueryInterpretation`, handles × removal - `DisambiguationPicker.svelte` — accessible disclosure + person list; use `SvelteSet<string>` from `svelte/reactivity` for selected person IDs (plain `Set` mutations are invisible to Svelte's reactivity system) - `SmartSearchStatus.svelte` — loading (`role="status"`) + error state combined as full-area panels - `SearchFilterBar.svelte` — orchestrates all of the above alongside existing filter inputs **`smartMode` binding site:** `smartMode: boolean = $bindable(false)` is declared as `let smartMode = $state(false)` in `src/routes/documents/+page.svelte` (the only page that uses `SearchFilterBar`) and passed as `bind:smartMode` to `<SearchFilterBar>`. The home page (`src/routes/+page.svelte`) uses a dashboard layout — it does not import `SearchFilterBar`. **`frontend/CLAUDE.md` update:** The `src/routes/search/` component directory must be added to the Project Structure section in `frontend/CLAUDE.md`. It is not a new route (no `+page.svelte`) so the root `CLAUDE.md` route table does not apply, but the component structure tree must reflect it. This is a merge blocker. ## Changes to `SearchFilterBar.svelte` ### Mode toggle Add `smartMode: boolean = $bindable(false)` prop (lifted to parent, consistent with existing bindable props). **Toggle pill** — sits inside the `relative flex-1` input 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 padding - Input right padding expands to `pr-28` in smart mode so query text never overlaps the pill - **Style**: `rounded-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-pointer` - **Resting (keyword mode)**: `border border-line bg-muted text-ink-2` — muted, does not compete with query text - **Active (smart mode)**: `border 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. - Icon + short text label. Desktop: `search_toggle_smart_label` → "KI" / `search_toggle_keyword_label` → "Text". Mobile (below `sm` / 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 readers - **Mobile layout**: pill stays inside the input at all breakpoints. The Sort + Filter + Reset row beneath is unchanged. No stacked layout. In smart mode, submitting the search calls `POST /api/search/nl` instead of `GET /api/documents/search`. Use `oninput={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 add `maxlength="500"` to the input when `smartMode` is 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`: ```svelte <div role="status" aria-live="polite" class="flex flex-col items-center justify-center gap-3 py-16 text-center"> <!-- 36 px / 3 px-stroke spinner ring: rounded-full border-[3px] border-primary/12 border-t-primary motion-safe:animate-spin --> <!-- title: text-sm font-bold — "Archiv wird befragt…" (search_loading_nl) --> <!-- subtitle: text-[9px] text-ink-3 max-w-xs motion-safe:animate-pulse — (search_loading_nl_sub) --> </div> ``` `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. Use `motion-safe:animate-spin` and `motion-safe:animate-pulse` — Tailwind 4 evaluates `prefers-reduced-motion: reduce` at the CSS layer automatically; no JavaScript `matchMedia` variable 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`):** ```svelte <!-- Example render: --> [Absender: Walter Raddatz ×] [Zeitraum: 1914–1918 ×] [Stichwort: krieg ×] ``` **2-name query (directional):** ```svelte <!-- resolvedPersons[0] → resolvedPersons[1] --> [Walter Raddatz → Emma Raddatz ×] [Zeitraum: 1914–1918 ×] ``` When `interpretation.resolvedPersons` has 2 entries, render a **single directional chip** using `resolvedPersons[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: ```svelte <span class="sm:max-w-[12rem] max-w-[8rem] truncate">{person0.displayName}</span> <span aria-hidden="true">→</span> <span class="sm:max-w-[12rem] max-w-[8rem] truncate">{person1.displayName}</span> ``` 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. **`keywordsApplied` rule:** Only show keyword chips when `interpretation.keywordsApplied === true`. For `personRole: "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: - Removable: clicking × drops that filter and re-runs via **`GET /api/documents/search`** (not POST) with remaining resolved params - **Chip removal callback**: `InterpretationChipRow` emits `onRemoveChip(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.) `SearchFilterBar` implements it — clearing the relevant `$bindable` props (`senderId`, `receiverId`, `from`, `to`) and then calling `onSearch`. The chip row never calls the API directly. - **Directional chip removal** clears **both** `senderId` and `receiverId` simultaneously — the chip represents the pair - × button needs `aria-label={`Filter entfernen: ${label}`}` — not icon-only - × button needs `min-h-[44px]` touch target (extend hit area; visual width stays narrow) - Chip wrapper needs `focus-visible:ring-2 focus-visible:ring-brand-navy` (not only the × button) - Long chip labels: use `<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 - Key `{#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) - Use `$derived`, not `$effect`, for computed chip labels and filtered person lists - No `{@html}` anywhere in chip label rendering — LLM output must never reach `innerHTML` ### Disambiguation chip When `NlQueryInterpretation.ambiguousPersons` is non-empty: ```svelte [Absender: Walter Raddatz, Walter Müller (auswählen...)] ``` 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"` and `min-h-[44px]`. Clicking opens an accessible disclosure: - `aria-expanded` on the trigger - `aria-controls` pointing to the picker panel - Focus moves into the picker list on open - **Closing the picker (Escape or click outside) returns focus to the trigger button** — do not let keyboard position get lost - User can select one or multiple persons (OR); selecting re-runs the search immediately - Dismissing without selecting leaves the chip in its ambiguous state (search results remain empty — accepted behaviour, see Known Limitations) > **Open decision (Architecture):** Multi-sender OR after disambiguation — see Open Decisions section below. **Do not build `DisambiguationPicker` until resolved in #738.** ### Transparent empty state When smart search returns zero results: - Show interpretation chips (what was searched) - Show: **"Keine Ergebnisse — Als Volltextsuche wiederholen"** link that switches to keyword mode with the raw query pre-filled - **Implementation: Option A** — in-page state change: set `smartMode = false`, keep `q` (raw query), trigger keyword search immediately. No navigation, no scroll reset. Requires toggle + query state wired together in `SearchFilterBar` or its parent. - The link must be keyboard-reachable with `focus-visible:ring-2 focus-visible:ring-brand-navy` and sufficient vertical padding to meet the 44px touch target ### Error states Both error states are **full-area centered panels** (same `py-16 text-center` layout as loading) rendered by `SmartSearchStatus.svelte`: ``` icon circle (40 px) → title (bold) → body text → optional action button ``` **`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 calls `switchToKeywordMode()`. **`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 `case` in `getErrorMessage()` — do not group them even if messages look similar. **Error code rollout sequence** — all four steps must land atomically: 1. Add `SMART_SEARCH_UNAVAILABLE` and `SMART_SEARCH_RATE_LIMITED` to `ErrorCode.java` 2. Add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts` 3. Add a separate `case` for each in `getErrorMessage()` 4. Add i18n keys in `messages/{de,en,es}.json` ## API integration After the backend issue is merged, run `npm run generate:api` to regenerate TypeScript types. **Client-side POST pattern** — `createApiClient` at `$lib/shared/api.server.ts` imports `$env/dynamic/private` and is **server-side only**. Browser components must use raw `fetch` with CSRF token: ```svelte import { withCsrf } from '$lib/shared/cookies'; import { parseBackendError } from '$lib/shared/errors'; const res = await fetch('/api/search/nl', withCsrf({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query }) })); if (!res.ok) { const err = await parseBackendError(res); // switch on err?.code to distinguish SMART_SEARCH_UNAVAILABLE vs SMART_SEARCH_RATE_LIMITED return; } const data = await res.json(); ``` This is the established pattern for all browser-component POSTs (`OcrTrainingCard.svelte`, `BulkDocumentEditLayout.svelte`, `admin/system/+page.svelte`). The `withCsrf` wrapper is required — CSRF protection is active via `CookieCsrfTokenRepository` and omitting it will return 403. Use `parseBackendError` to 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 queries - `interpretation.ambiguousPersons` — non-empty means disambiguation UI; search result is empty - `interpretation.keywordsApplied` — `false` for `personRole: "any"` single-name queries; omit keyword chips when false - `interpretation.keywords` — list of keyword strings; only render as chips when `keywordsApplied === true` - `interpretation.dateFrom` / `dateTo` — ISO-8601 date strings or null ## New i18n message keys All new UI strings must be added to `messages/{de,en,es}.json` before any component renders them. Missing keys silently render as empty strings in non-German locales. | Key | German | Usage | |-----|--------|-------| | `search_toggle_smart_label` | "KI" (desktop) / "KI-Suche" (mobile `< sm`) | SmartModeToggle — smart mode | | `search_toggle_keyword_label` | "Text" (desktop) / "Textsuche" (mobile `< sm`) | SmartModeToggle — keyword mode | | `search_loading_nl` | "Archiv wird befragt…" | SmartSearchStatus loading title | | `search_loading_nl_sub` | "Die KI analysiert Ihre Anfrage. Das kann bis zu 15 Sekunden dauern." | SmartSearchStatus loading subtitle | | `search_error_unavailable` | "Intelligente Suche nicht verfügbar" | SmartSearchStatus 503 title | | `search_error_unavailable_body` | "Die KI-Suche ist momentan nicht erreichbar. Sie können Ihre Anfrage als einfache Volltextsuche wiederholen." | SmartSearchStatus 503 body | | `search_switch_to_keyword` | "Zur Volltextsuche wechseln" | SmartSearchStatus 503 button | | `search_error_rate_limited` | "Zu viele Anfragen" | SmartSearchStatus 429 title | | `search_error_rate_limited_body` | "Du hast die intelligente Suche zu häufig genutzt. Bitte warte eine Minute und versuche es erneut." | SmartSearchStatus 429 body | | `search_empty_retry_keyword` | "Als Volltextsuche wiederholen" | Empty state link | | `search_filter_remove_label` | "Filter entfernen: {label}" | Chip × button aria-label template | | `search_disambiguation_trigger_label` | "Mehrere Personen gefunden — zum Auswählen klicken" | Disambiguation chip trigger | ## Test 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 existing `SearchFilterBar.svelte.spec.ts` (already 197 lines, 5 describe blocks). TDD order: toggle first (simplest), disambiguation last (most complex). Add `afterEach(() => cleanup())` to each new spec file (matches existing pattern in `SearchFilterBar.svelte.spec.ts`). - `SmartModeToggle.svelte.spec.ts`: toggle renders `aria-pressed="false"` by default; clicking sets `aria-pressed="true"` and back - `SmartModeToggle.svelte.spec.ts`: typing in the search input in smart mode does **not** call the NL endpoint (no `oninput` regression) - `SmartModeToggle.svelte.spec.ts`: toggle label shows `search_toggle_smart_label` when `smartMode === true` and `search_toggle_keyword_label` when `smartMode === false` - `SmartModeToggle.svelte.spec.ts`: input has `maxlength="500"` when `smartMode === true`; attribute absent when `smartMode === false` - `InterpretationChipRow.svelte.spec.ts`: chips render with correct type-prefixed labels (`[Absender: ...]`, `[Zeitraum: ...]`, `[Stichwort: ...]`) - `InterpretationChipRow.svelte.spec.ts`: clicking × removes chip and calls `GET /api/documents/search` with the specific param absent (assert params, not just that `onSearch` was 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 ×]` calls `GET` with neither `senderId` nor `receiverId` - `InterpretationChipRow.svelte.spec.ts`: `keywordsApplied: false` → keyword chips NOT rendered even when `keywords` is non-empty - `InterpretationChipRow.svelte.spec.ts`: `keywordsApplied: true` with empty `keywords` array → no keyword chips rendered - `InterpretationChipRow.svelte.spec.ts`: `keywordsApplied: true` with 3 keywords → exactly 3 keyword chips rendered - `InterpretationChipRow.svelte.spec.ts`: 2-name resolved → single directional chip containing `→` character (assert `getByText(/→/)`, 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 click - `DisambiguationPicker.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 button - `DisambiguationPicker.svelte.spec.ts`: dismissing the picker without selecting a person does NOT call `onSearch` - `SmartSearchStatus.svelte.spec.ts`: loading state has `role="status"` and correct text - `SmartSearchStatus.svelte.spec.ts`: loading state with unresolved Promise → `getByRole('status')` visible → resolve → assert it disappears; add `vi.restoreAllMocks()` in `afterEach` to prevent Promise leaks - `SmartSearchStatus.svelte.spec.ts`: `SMART_SEARCH_UNAVAILABLE` renders full-area panel with icon, title, body, and switch-to-keyword button - `SmartSearchStatus.svelte.spec.ts`: `SMART_SEARCH_RATE_LIMITED` renders full-area panel with icon, title, body — **without** switch-to-keyword button - `SearchFilterBar.svelte.spec.ts` (existing): toggling back to keyword mode clears all interpretation chips - `SearchFilterBar.svelte.spec.ts` (existing): submitting a new query in smart mode clears existing chips before rendering new ones **Playwright E2E** (1 test, happy path): - Use `page.route('/api/search/nl', handler)` to return a fixed `NlQueryInterpretation` fixture. Fixture must include: `keywordsApplied: true` with at least one keyword **and** a 2-name `resolvedPersons` pair 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. - Type NL query → submit → assert `role="status"` visible → fixture resolves → chips appear → run `AxeBuilder` on the search region (both light and dark mode) → remove one chip → keyword search re-runs with remaining params ## Acceptance Criteria - **Toggle pill is visible inside the search input on the `/documents` page**, labeled with the active mode label, and switches modes on click - **Toggle label reads `search_toggle_smart_label` in smart mode and `search_toggle_keyword_label` in keyword mode** (expanded label on mobile below `sm`) - **Active pill uses `bg-primary text-primary-fg`** (matches existing AND/OR button pattern); resting pill uses `bg-muted border-line text-ink-2` - In smart mode, submitting "Was hat walter im krieg geschrieben?" shows interpretation chips: `[Absender: Walter Raddatz ×] [Zeitraum: 1914–1918 ×] [Stichwort: krieg ×]` - **Removing a chip calls `GET /api/documents/search` with the resolved params minus the removed filter** (not `POST /api/search/nl`) - **Removing the directional chip `[Walter → Emma ×]` clears both `senderId` and `receiverId` simultaneously** - Ambiguous name chip expands to a picker; selecting a person re-runs immediately - **Closing the disambiguation picker (Escape or click outside) returns focus to the trigger button** - Empty smart search shows chips + "Als Volltextsuche wiederholen" link (Option A: in-page mode switch, no navigation) - `SMART_SEARCH_UNAVAILABLE` shows a full-area panel with icon, title, body, and keyword fallback button - `SMART_SEARCH_RATE_LIMITED` (429) shows a full-area panel with icon, title, body — no keyword fallback button - Loading state is a full-area centered panel announced by screen readers (`role="status"`) - When `interpretation.keywordsApplied === false`, no keyword chips are shown - 2-name query with both resolved shows a single directional chip "Walter → Emma" (not two separate person chips) - All interactive elements have ≥44px touch targets - All × buttons have descriptive `aria-label` - Toggle pill has `aria-pressed` reflecting current mode - **Submitting a new query in smart mode clears existing chips before rendering new ones** - **Toggling from smart mode to keyword mode removes all interpretation chips from the DOM** - **Long chip labels are truncated** with × button outside the truncated span; directional chip uses two separately-truncatable name spans - **Chip wrappers have `focus-visible:ring-2`** (not only the × button) - **All chip labels include a type prefix** (`Absender:`, `Zeitraum:`, `Stichwort:`) - **Spinner and pulsing subtitle in loading state use `motion-safe:` Tailwind utilities** (both stop when `prefers-reduced-motion` is set) - **Chip row wraps to a new line at 320px viewport width without horizontal scrolling** - **In smart mode, the search input has `maxlength="500"`; in keyword mode, no `maxlength` attribute** - `frontend/CLAUDE.md` Project Structure section updated to document `src/routes/search/` ## Known Limitations (accepted) - **Smart mode resets on page navigation.** Toggle state is UI-local. A user who navigates away (e.g., opens a document) and returns via Back will see keyword mode with no NL chips. They must re-enter and re-submit their NL query. - **Dismissing the disambiguation picker without selecting** leaves the chip in its ambiguous state and the search results empty. This is by design — no automatic fallback is triggered. ## Open Decisions ### Architecture — Multi-sender OR query after disambiguation The existing `GET /api/documents/search` takes a single `senderId` param. When the user selects two persons from the disambiguation picker, the frontend must pass both. Options: - **(A)** Backend adds a `senderIds[]` array param to `GET /api/documents/search` — requires a backend change, keeps the chip re-run pattern clean. - **(B)** Disambiguation picker triggers a new `POST /api/search/nl` with selected person IDs — no change to keyword endpoint, but re-runs LLM parsing. - **(C)** Picker forces exactly one person (OR dropped) — simplest, deviates from spec. **Must be resolved in #738 before `DisambiguationPicker` is built.** ## DevOps / PR notes - No infrastructure changes required for this frontend issue. - **Prometheus histogram for `/api/search/nl` must 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. - Post-merge: verify `/api/search/nl` latency in Grafana does not trigger existing p95 latency alerts. - **Playwright E2E uses `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 with `docker-compose up -d` and ensure the Ollama container from #738 is running. - PR description must include: "Depends on #738 merged + `npm run generate:api` run. Verify #738's PR updated `CLAUDE.md`, `l3-backend-*.puml` for the new `search/` package, and includes the Prometheus histogram for `/api/search/nl`. `NlQueryInterpretation` type must exist in `src/lib/generated/` before building."
marcel added this to the Archive Intelligence — NL Search milestone 2026-06-06 12:16:07 +02:00
marcel added the P2-mediumfeatureui labels 2026-06-06 12:16:38 +02:00
Author
Owner

Implementation complete — PR #757

Branch: feat/issue-739-nl-search-frontend

Commits (TDD, atomic)

  1. feat(search): add NL search frontend i18n keys (de/en/es)
  2. feat(search): add SmartModeToggle pill component — 8 specs
  3. feat(search): add InterpretationChipRow component — 9 specs
  4. feat(search): add SmartSearchStatus full-area panels — 5 specs
  5. feat(search): add DisambiguationPicker single-select disclosure — 5 specs
  6. feat(search): wire SmartModeToggle into SearchFilterBar
  7. feat(search): orchestrate NL search on the documents page
  8. test(search): cover smart-mode chip lifecycle hooks (SearchFilterBar → 17 specs)
  9. docs(search): document src/routes/search/ component directory
  10. test(search): add NL search happy-path Playwright E2E

Tests

  • 44 vitest-browser-svelte specs (all green), 1 Playwright happy-path E2E.
  • npm run build clean; svelte-check introduces no new errors.

Resolved open decision (disambiguation)

Backend #738 shipped only single-person searchDocumentsByPersonId (no senderIds[] array). The picker is therefore single-select (Option C-style): selecting a candidate re-runs via GET /api/documents/search. One sender + one receiver (directional) is fully supported.

Deviations from the issue draft

  • Formal Sie used in all German strings (project standard, commit 4c620619) instead of the draft's "Du".
  • The decorative magnifier icon moved to the input's left slot so the always-visible toggle pill owns the right slot without overlap.
  • Interpretation chips render in the result area (documents/+page.svelte), not inside SearchFilterBar — consistent with the lifted-state pattern. Chip-clearing is driven by the onModeToggle/onSmartSearch callbacks, which the SearchFilterBar specs pin.

Next: multi-persona review on PR #757.

## Implementation complete ✅ — PR #757 Branch: `feat/issue-739-nl-search-frontend` ### Commits (TDD, atomic) 1. `feat(search): add NL search frontend i18n keys (de/en/es)` 2. `feat(search): add SmartModeToggle pill component` — 8 specs 3. `feat(search): add InterpretationChipRow component` — 9 specs 4. `feat(search): add SmartSearchStatus full-area panels` — 5 specs 5. `feat(search): add DisambiguationPicker single-select disclosure` — 5 specs 6. `feat(search): wire SmartModeToggle into SearchFilterBar` 7. `feat(search): orchestrate NL search on the documents page` 8. `test(search): cover smart-mode chip lifecycle hooks` (SearchFilterBar → 17 specs) 9. `docs(search): document src/routes/search/ component directory` 10. `test(search): add NL search happy-path Playwright E2E` ### Tests - 44 vitest-browser-svelte specs (all green), 1 Playwright happy-path E2E. - `npm run build` clean; `svelte-check` introduces no new errors. ### Resolved open decision (disambiguation) Backend #738 shipped only single-person `searchDocumentsByPersonId` (no `senderIds[]` array). The picker is therefore **single-select** (Option C-style): selecting a candidate re-runs via `GET /api/documents/search`. One sender + one receiver (directional) is fully supported. ### Deviations from the issue draft - **Formal Sie** used in all German strings (project standard, commit 4c620619) instead of the draft's "Du". - The decorative magnifier icon moved to the input's **left** slot so the always-visible toggle pill owns the right slot without overlap. - Interpretation chips render in the **result area** (`documents/+page.svelte`), not inside `SearchFilterBar` — consistent with the lifted-state pattern. Chip-clearing is driven by the `onModeToggle`/`onSmartSearch` callbacks, which the SearchFilterBar specs pin. Next: multi-persona review on PR #757.
Sign in to join this conversation.
No Label P2-medium feature ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#739