# NL Search — Toggle, Interpretation Chips & States · /documents

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.

Familienarchiv · 2026-06-06 · Leonie Voss, UX Lead
NL Search · Issue #739 Desktop 1280 px / Mobile 320 px Light + Dark Archive Intelligence Milestone

## 1 · Design tokens

All colour values for the toggle pill, chips, and status states. No new tokens — everything maps to existing `layout.css` semantics.

Light theme

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

Dark theme

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

## 2 · Toggle pill inside the search input — keyword vs smart mode

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.

Toggle pill — anatomy (both states at 2× zoom)
Keyword mode (resting)
Text
bg: #f4f2ec (bg-muted)
border: #c8c4be (border-line)
text: #4b5563 (text-ink-2)
aria-pressed="false"
Smart mode (active)
KI
bg: #012851 (bg-primary)
text: #a1dcd8 (text-primary-fg)
Matches AND/OR operator active
aria-pressed="true"
Smart mode — dark
KI
bg: #a1dcd8 (mint) — inverted
text: #012851 (navy)
Same pattern as selected node dark
Full filter bar — keyword mode (light)
Suche nach Absender, Empfänger, Inhalt…
Text
Sortierung ▾
Filter
✕

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.

Full filter bar — smart mode active (light)
Was hat walter im krieg geschrieben?
KI
Sortierung ▾
Filter
✕

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.

Keyword mode — dark
Suche nach Absender, Empfänger, Inhalt…
Text
Sortierung ▾
Filter
✕

Dark, keyword mode. Pill uses #1e3a55 border on #011526 bg with #6b7280 text.

Smart mode — dark
Was hat walter im krieg geschrieben?
KI
Sortierung ▾
Filter
✕

Dark, smart mode. Pill inverts to mint bg (#a1dcd8) + navy text (#012851).

## 3 · Loading state — full-area panel

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 — loading
Dokumente
Intelligente Suche aktiv
Was hat walter im krieg geschrieben?
KI
Sortierung ▾
Filter
✕
Archiv wird befragt…
Die KI analysiert Ihre Anfrage.
Das kann bis zu 15 Sekunden dauern.
role="status" aria-live="polite" motion-safe:animate-spin (ring) motion-safe:animate-pulse (subtitle)

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 — loading
Dokumente
Intelligente Suche aktiv
Was hat walter im krieg geschrieben?
KI
Sortierung ▾
Filter
✕
Archiv wird befragt…
Die KI analysiert Ihre Anfrage.
Das kann bis zu 15 Sekunden dauern.

Dark. Spinner track rgba(161,220,216,.12), active slice mint #a1dcd8. Title in mint. Subtitle in muted #4e6070.

## 4 · Interpretation chips — single-name query (light)

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`.

Dokumente
12 Ergebnisse
Was hat walter im krieg geschrieben?
KI
Sortierung ▾
Filter
✕
Absender:Walter Raddatz
×
Zeitraum:1914–1918
×
Stichwort:krieg
×
▤
Brief an Emma Raddatz, 14. August 1916
Von Walter Raddatz · 1916 · 3 Seiten
▤
Feldpostkarte aus Verdun, Juni 1917
Von Walter Raddatz · 1917 · 1 Seite
▤
Brief an die Familie, Weihnachten 1914
Von Walter Raddatz · 1914 · 2 Seiten
▤
Postkarte an Frieda, März 1918
Von Walter Raddatz · 1918 · 1 Seite

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"`.

## 5 · Interpretation chips — 2-name directional + long-name truncation

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.

Directional chip — desktop
Dokumente
4 Ergebnisse
Briefe von walter an emma um 1916
KI
Sortierung ▾
Filter
✕
Walter Raddatz → Emma Raddatz
×
Zeitraum:1914–1918
×
aria-label="Von Walter Raddatz zu Emma Raddatz, Filter entfernen"
Removing clears BOTH senderId AND receiverId
▤
Brief an Emma Raddatz, 14. August 1916
Von Walter Raddatz · 1916
▤
Brief an Emma, Ostern 1915
Von Walter Raddatz · 1915

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.

Long-name truncation at 320 px
Wilhelmine-Frieder… → Emma-Karl…
×
max-w-[8rem] per name span; × outside both

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.

## 6 · Disambiguation chip & picker

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.

Disambiguation chip — collapsed
Absender:Walter Raddatz, Walter Müller
(auswählen…)
×
aria-expanded="false" · aria-label="Mehrere Personen gefunden — zum Auswählen klicken"

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 — first item focused
Absender:Walter Raddatz, Walter Müller
(auswählen…)
×
aria-expanded="true" · Focus moves to first item on open
Welcher Walter ist gemeint?
✓
Walter Raddatz1888–1952 · Sohn von Karl Raddatz
Walter Müller1882–1941 · Ehemann von Frieda Raddatz
Escape → focus returns to trigger · "Suchen" → GET /api/documents/search

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.

## 7 · Empty state & error states — full result-area panels

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.

Empty — zero results
Dokumente
Keine Ergebnisse
Briefe über gärten im frühling
KI
Sortierung ▾
Filter
✕
Absender:Walter Raddatz
×
Stichwort:gärten
×
Keine Ergebnisse
Für „Briefe über gärten" wurden keine Dokumente gefunden. Versuchen Sie es mit einer einfachen Stichwortsuche.
Als Volltextsuche wiederholen

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 — SMART_SEARCH_UNAVAILABLE
Dokumente
Intelligente Suche nicht verfügbar
Was hat walter geschrieben?
KI
Sortierung ▾
Filter
✕
Intelligente Suche nicht verfügbar
Die KI-Suche ist momentan nicht erreichbar. Sie können Ihre Anfrage als einfache Volltextsuche wiederholen.
ErrorCode: SMART_SEARCH_UNAVAILABLEi18n: search_error_unavailable + search_switch_to_keyword

503. Full-area error panel with icon, explanatory body text, and the keyword fallback button. The button calls `switchToKeywordMode()`. Separate `case` in `getErrorMessage()`.

429 — SMART_SEARCH_RATE_LIMITED
Dokumente
Zu viele Anfragen
Was hat walter geschrieben?
KI
Sortierung ▾
Filter
✕
Zu viele Anfragen
Du hast die intelligente Suche zu häufig genutzt.
Bitte warte eine Minute und versuche es erneut.
ErrorCode: SMART_SEARCH_RATE_LIMITEDNo fallback button — limit is temporarySeparate 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.

## 8 · Mobile (320 px) — toggle in input, chips wrapping

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.

Mobile 320 px — keyword mode
Familienarchiv
M
Dokumente
Suche in Briefen und Urkunden
Suche…
Textsuche
Sortierung ▾
Filter
✕

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.

Mobile 320 px — smart mode + chips wrapping
Familienarchiv
M
Dokumente
12 Ergebnisse
Was hat walter im krieg…
KI-Suche
Sortierung ▾
Filter
✕
Absender: Walter Raddatz
×
Zeitraum:1914–1918
×
Stichwort:krieg
×
▤
Brief an Emma, August 1916
Walter Raddatz · 1916
▤
Feldpostkarte, Juni 1917
Walter Raddatz · 1917

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.

## 9 · Implementation reference

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`

## 9.1 · i18n keys (messages/{de,en,es}.json)

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

## 9.2 · Atomic ErrorCode rollout (one commit)

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