Visual specification for the natural-language search mode. The mode toggle lives inside the search input as an inline pill — inspired by Google's AI Mode button — keeping the filter row unchanged. Loading, error, and empty states are full-height panels that fill the result area rather than inline one-liners. Issue #739 — Archive Intelligence milestone.
All colour values for the toggle pill, chips, and status states. No new tokens — everything maps to existing layout.css semantics.
| Toggle pill — keyword (resting) | #f4f2ec bg · #c8c4be border · #4b5563 text — muted, doesn't compete with query text |
| Toggle pill — smart (active) | #012851 bg (bg-primary) · #a1dcd8 text (text-primary-fg)9.2:1 AAA ✓ |
| Chip border / text | #012851 — navy 1.5 px; Tinos for person names14.5:1 AAA ✓ |
| Chip prefix (type label) | #012851 at 65% opacity — "Absender:", "Zeitraum:", "Stichwort:" |
| Chip × divider | rgba(1,40,81,.15) — 1 px left border inside chip |
| Disambiguation chip border | #d97706 — amber; signals disambiguation needed |
| Status area — loading spinner | rgba(1,40,81,.12) track + #012851 active slice; 36 px dia, 3 px stroke |
| Status area — error icon circle | #fef2f2 bg · #f87171 border — red-100/red-400 |
| Status area — warning icon circle | #fffbeb bg · #fbbf24 border — amber-50/amber-400 |
| Focus ring (chips + link) | focus-visible:ring-2 focus-visible:ring-brand-navy on chip wrappers and fallback link |
| Toggle pill — keyword (resting) | #011526 bg · #1e3a55 border · #6b7280 text |
| Toggle pill — smart (active) | #a1dcd8 bg · #012851 text — exact inverse9.2:1 AAA ✓ |
| Chip border | #8b97a5 — blue-grey |
| Chip name text | #f0efe9 — sand-white14.5:1 AAA ✓ |
| Status area — loading spinner | rgba(161,220,216,.12) track + #a1dcd8 mint active slice |
| Status loading title | #a1dcd8 — mint |
| Status error title | #f0efe9 — sand-white |
| Status body text | #8b97a5 — muted blue-grey |
| Error action button | border + text #a1dcd8 mint on transparent bg |
| Empty state fallback link | #a1dcd8 mint, underlined |
The toggle is an absolutely-positioned pill at the right edge of the search input, replacing the magnifier icon. The input gets extra right padding (pr-28) to keep query text from overlapping the pill. The rest of the filter row — Sort, Filter, Reset — is unchanged. On desktop the pill reads "Text" or "KI"; on mobile (below sm) it reads "KI-Suche" / "Textsuche" for clarity.
Keyword mode. The "Text" pill sits at the right edge of the input, muted (#f4f2ec) so it doesn't compete with the query text. Sort, Filter, and Reset remain in the same position in the filter row.
Smart mode active. The "KI" pill becomes navy (#012851) with mint text (#a1dcd8) — bg-primary / text-primary-fg, matching the AND/OR operator active state. Query text is in Tinos italic. The rest of the bar is unchanged.
Dark, keyword mode. Pill uses #1e3a55 border on #011526 bg with #6b7280 text.
Dark, smart mode. Pill inverts to mint bg (#a1dcd8) + navy text (#012851).
The loading state fills the result area instead of showing a tiny inline spinner. A centred, vertically-padded panel with a large spinner ring and two lines of text matches the space that results would normally occupy. role="status" aria-live="polite" announces arrival to screen readers without stealing focus. Spinner uses motion-safe:animate-spin, subtitle uses motion-safe:animate-pulse.
Light. The spinner ring is 36 px with 3 px stroke. Subtitle explains the expected wait — "bis zu 15 Sekunden" manages expectations. No timeout countdown shown during the request.
Dark. Spinner track rgba(161,220,216,.12), active slice mint #a1dcd8. Title in mint. Subtitle in muted #4e6070.
After a successful NL response, InterpretationChipRow renders above the result list. Every chip has a type prefix so the senior audience knows what the filter is — "1914–1918" without "Zeitraum:" is ambiguous. Keyword chips only appear when keywordsApplied === true.
Three chips: Absender (person), Zeitraum (date range), Stichwort (keyword — only because keywordsApplied === true). Removing any chip calls GET /api/documents/search with that param absent. The × button has aria-label="Filter entfernen: Absender Walter Raddatz".
When resolvedPersons has 2 entries, a single directional chip replaces two person chips. Index 0 = sender, index 1 = receiver. Each name is in a separately-truncatable <span>; the → arrow is aria-hidden; the chip wrapper carries a plain-language aria-label. The × button sits outside both spans — never clipped.
Single directional chip for 2-name resolution. The → arrow is aria-hidden. Chip's aria-label reads "Von Walter Raddatz zu Emma Raddatz, Filter entfernen" — plain language for screen readers and the 60+ audience.
At 320 px, each name span caps at max-w-[8rem] (128 px) with truncation ellipsis. The × button is a fixed-width sibling outside the chip-body — never clipped regardless of how long the names are.
When ambiguousPersons is non-empty the chip renders in amber. Clicking it opens an accessible disclosure. Focus moves into the list on open; Escape returns focus to the trigger. Build this after the multi-OR approach is confirmed in #738.
Amber chip signals "action needed". Names listed comma-separated; "(auswählen…)" in italics makes the affordance explicit for the 60+ audience. "▾" alone is not sufficient. Search results are empty while ambiguous.
Picker open. Focus lands on the first item. Abbrechen (or Escape) closes and returns focus to the trigger — keyboard position never lost. Suchen fires GET /api/documents/search immediately with selected person IDs.
All three outcome states fill the result area with a centred panel that uses the available vertical space. They share the same layout: icon → title → body → action. The height matches what a short result list would occupy so the page doesn't collapse to a one-liner with a wall of white below.
Chips remain visible — the user sees what was searched. The fallback link switches to keyword mode in-place (Option A from issue): smartMode = false, keeps q, triggers keyword search. No navigation, no scroll reset. Min-h 44 px touch target on link.
503. Full-area error panel with icon, explanatory body text, and the keyword fallback button. The button calls switchToKeywordMode(). Separate case in getErrorMessage().
429. Warning-style panel with no keyword fallback button — the rate limit is temporary; the user should wait and retry. Do not group this case with UNAVAILABLE even if messages look similar.
The toggle pill stays inside the input on mobile too — the input is full-width anyway so there is room. Below the sm breakpoint (640 px) the pill label expands slightly to "KI-Suche" / "Textsuche" for senior legibility at small sizes. Chips use flex flex-wrap — no horizontal scroll at 320 px.
Keyword mode on mobile. Pill reads "Textsuche" at narrow widths for senior legibility — abbreviated "Text" could be read as a noun. The input is 44 px tall (meets touch target). Sort and Filter buttons remain in the same row below.
Smart mode on mobile. Pill reads "KI-Suche". The input is 44 px tall. Chips wrap at 303 px — "Stichwort: krieg" lands on the second line. No horizontal scroll. × buttons at 22 px wide, outside truncatable spans.
| Element | Tailwind / CSS | Notes |
|---|---|---|
| Input wrapper | relative flex-1 |
Position context for the absolutely-placed toggle pill |
| Search input — smart mode | pr-28 (≥ sm), pr-24 (mobile) — extra right padding for pill |
Also: maxlength="500" when smartMode; oninput={smartMode ? undefined : onSearch} |
| Toggle pill wrapper | absolute right-2 top-1/2 -translate-y-1/2 |
Sits over the input's right padding; pointer-events-auto (parent input is pointer-events-none on the right side for the icon slot) |
| Pill — keyword (resting) | flex items-center gap-1.5 rounded-full border border-line bg-muted text-ink-2 text-[7.5px] font-bold px-2.5 py-1 min-h-[28px] cursor-pointer focus-visible:ring-2 focus-visible:ring-brand-navy outline-none |
aria-pressed="false"; icon + "Text" (desktop) / "Textsuche" (mobile, sm:hidden) |
| Pill — smart (active) | flex items-center gap-1.5 rounded-full border border-primary bg-primary text-primary-fg text-[7.5px] font-bold px-2.5 py-1 min-h-[28px] cursor-pointer focus-visible:ring-2 focus-visible:ring-brand-navy outline-none |
aria-pressed="true"; icon + "KI" (desktop) / "KI-Suche" (mobile). Matches AND/OR button active pattern |
| Chip wrapper | inline-flex items-stretch border-[1.5px] border-primary rounded-full bg-surface overflow-hidden focus-visible:ring-2 focus-visible:ring-brand-navy outline-none |
Entire wrapper focusable — ring on wrapper, not only the × button |
| Chip body | flex items-center gap-1 pl-3 pr-2 py-1 |
Prefix: text-[8px] font-bold opacity-65; name: font-serif text-[9.5px] |
| Chip name span (directional) | sm:max-w-[12rem] max-w-[8rem] truncate |
Two separate spans; <span aria-hidden="true">→</span> between. Chip: aria-label="Von {p0} zu {p1}, Filter entfernen" |
| Chip × button | w-7 shrink-0 flex items-center justify-center border-l border-primary/15 text-[11px] text-ink min-h-[36px] |
aria-label="Filter entfernen: {label}"; min-h-[36px] extends touch target beyond visual size |
| Chip row | flex flex-wrap gap-2 |
Wraps at 320 px without horizontal scroll; no max-width on the row itself |
| SmartSearchStatus — loading | flex flex-col items-center justify-center gap-3 py-16 text-center |
Spinner: w-9 h-9 rounded-full border-[3px] border-primary/12 border-t-primary motion-safe:animate-spin; title: text-sm font-bold; sub: text-[9px] text-ink-3 max-w-[20rem]. Wrapper: role="status" aria-live="polite" |
| SmartSearchStatus — error (503) | flex flex-col items-center gap-3 py-16 text-center |
Icon circle: w-10 h-10 rounded-full bg-red-50 border-2 border-red-400 flex items-center justify-center; action: border border-primary text-primary px-4 py-2 text-[9px] font-bold rounded-sm |
| SmartSearchStatus — warning (429) | Same layout as 503 but amber icon; no action button | Separate case SMART_SEARCH_RATE_LIMITED in getErrorMessage(). Do not group with 503 |
| Empty state fallback link | text-primary font-bold text-[9px] underline underline-offset-2 focus-visible:ring-2 focus-visible:ring-brand-navy outline-none py-3 inline-block |
Option A: smartMode = false, keep q, call onSearch(). No navigation. i18n: search_empty_retry_keyword |
| Disambiguation chip | Same chip structure but border-amber-600 bg-amber-50; append "(auswählen…)" hint span in italics |
Trigger: aria-expanded aria-controls; min-h-[44px]; aria-label={m.search_disambiguation_trigger_label()} |
| Disambiguation picker panel | bg-surface border border-primary rounded-sm p-2 |
Focus moves to first item on open. Escape → return focus to trigger. Suchen → GET /api/documents/search |
| Key | German | Used by |
|---|---|---|
search_toggle_smart_label | "KI" / "KI-Suche" | SmartModeToggle — smart mode |
search_toggle_keyword_label | "Text" / "Textsuche" | 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 |
search_disambiguation_trigger_label | "Mehrere Personen gefunden — zum Auswählen klicken" | Disambiguation chip trigger |
| Step | File | Change |
|---|---|---|
| 1 | ErrorCode.java | Add SMART_SEARCH_UNAVAILABLE, SMART_SEARCH_RATE_LIMITED |
| 2 | frontend/src/lib/shared/errors.ts | Add both to ErrorCode union type |
| 3 | errors.ts → getErrorMessage() | One separate case per code — do not group |
| 4 | messages/{de,en,es}.json | All 12 keys above in all three locale files |