diff --git a/docs/specs/nl-search-spec.html b/docs/specs/nl-search-spec.html new file mode 100644 index 00000000..f44b71eb --- /dev/null +++ b/docs/specs/nl-search-spec.html @@ -0,0 +1,1212 @@ + + + + + +NL Search — Toggle, Chips & States · /documents · Familienarchiv + + + + +
+ + + +
+

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

+ +
+ 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 × dividerrgba(1,40,81,.15) — 1 px left border inside chip
Disambiguation chip border#d97706 — amber; signals disambiguation needed
Status area — loading spinnerrgba(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 spinnerrgba(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 buttonborder + 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
+
+
+
+
+ + + + +
M
+
+
+
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
+
+
+
+
+ + + + +
M
+
+
+
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.

+
+ +
+
+
+
+ + + + +
M
+
+
+
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
+
+
+
+
M
+
+
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
+
+
+
+
M
+
+
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
+
+
+
+
M
+
+
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
+
+
+
+
M
+
+
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
+
+
+
+
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
+
+
+
+
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

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ElementTailwind / CSSNotes
Input wrapperrelative flex-1Position context for the absolutely-placed toggle pill
Search input — smart modepr-28 (≥ sm), pr-24 (mobile) — extra right padding for pillAlso: maxlength="500" when smartMode; oninput={smartMode ? undefined : onSearch}
Toggle pill wrapperabsolute right-2 top-1/2 -translate-y-1/2Sits 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-nonearia-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-nonearia-pressed="true"; icon + "KI" (desktop) / "KI-Suche" (mobile). Matches AND/OR button active pattern
Chip wrapperinline-flex items-stretch border-[1.5px] border-primary rounded-full bg-surface overflow-hidden focus-visible:ring-2 focus-visible:ring-brand-navy outline-noneEntire wrapper focusable — ring on wrapper, not only the × button
Chip bodyflex items-center gap-1 pl-3 pr-2 py-1Prefix: text-[8px] font-bold opacity-65; name: font-serif text-[9.5px]
Chip name span (directional)sm:max-w-[12rem] max-w-[8rem] truncateTwo separate spans; <span aria-hidden="true">→</span> between. Chip: aria-label="Von {p0} zu {p1}, Filter entfernen"
Chip × buttonw-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 rowflex flex-wrap gap-2Wraps at 320 px without horizontal scroll; no max-width on the row itself
SmartSearchStatus — loadingflex flex-col items-center justify-center gap-3 py-16 text-centerSpinner: 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-centerIcon 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 buttonSeparate case SMART_SEARCH_RATE_LIMITED in getErrorMessage(). Do not group with 503
Empty state fallback linktext-primary font-bold text-[9px] underline underline-offset-2 focus-visible:ring-2 focus-visible:ring-brand-navy outline-none py-3 inline-blockOption A: smartMode = false, keep q, call onSearch(). No navigation. i18n: search_empty_retry_keyword
Disambiguation chipSame chip structure but border-amber-600 bg-amber-50; append "(auswählen…)" hint span in italicsTrigger: aria-expanded aria-controls; min-h-[44px]; aria-label={m.search_disambiguation_trigger_label()}
Disambiguation picker panelbg-surface border border-primary rounded-sm p-2Focus 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)

+
+
+ + + + + + + + + + + + + + + + +
KeyGermanUsed 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)

+
+
+ + + + + + + + +
StepFileChange
1ErrorCode.javaAdd SMART_SEARCH_UNAVAILABLE, SMART_SEARCH_RATE_LIMITED
2frontend/src/lib/shared/errors.tsAdd both to ErrorCode union type
3errors.ts → getErrorMessage()One separate case per code — do not group
4messages/{de,en,es}.jsonAll 12 keys above in all three locale files
+
+
+ + +
+ +