From ddce268113fcb24ba75ef9b59a287d70101896ae Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 17:32:50 +0200 Subject: [PATCH 01/12] feat(search): add NL search frontend i18n keys (de/en/es) Toggle labels, loading panel, error panels (503/429), empty-state retry, chip type-prefixes + remove label, and disambiguation strings for the smart search UI (#739). Formal Sie form per project standard. Co-Authored-By: Claude Opus 4.8 --- frontend/messages/de.json | 22 ++++++++++++++++++++++ frontend/messages/en.json | 22 ++++++++++++++++++++++ frontend/messages/es.json | 22 ++++++++++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index e4d29d12..1315a8c7 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -25,6 +25,28 @@ "error_smart_search_unavailable": "Die intelligente Suche ist momentan nicht verfügbar. Bitte nutzen Sie die normale Suche.", "error_smart_search_rate_limited": "Sie haben die Suchfunktion zu häufig genutzt. Bitte warten Sie eine Minute.", "smart_search_keywords_not_applied": "Schlüsselwörter konnten bei dieser Suche nicht berücksichtigt werden.", + "search_toggle_smart_label": "KI", + "search_toggle_smart_label_suffix": "-Suche", + "search_toggle_keyword_label": "Text", + "search_toggle_keyword_label_suffix": "suche", + "search_loading_nl": "Archiv wird befragt…", + "search_loading_nl_sub": "Die KI analysiert Ihre Anfrage. Das kann bis zu 15 Sekunden dauern.", + "search_error_unavailable": "Intelligente Suche nicht verfügbar", + "search_error_unavailable_body": "Die KI-Suche ist momentan nicht erreichbar. Sie können Ihre Anfrage als einfache Volltextsuche wiederholen.", + "search_switch_to_keyword": "Zur Volltextsuche wechseln", + "search_error_rate_limited": "Zu viele Anfragen", + "search_error_rate_limited_body": "Sie haben die intelligente Suche zu häufig genutzt. Bitte warten Sie eine Minute und versuchen Sie es erneut.", + "search_empty_nl": "Keine Ergebnisse", + "search_empty_retry_keyword": "Als Volltextsuche wiederholen", + "search_filter_remove_label": "Filter entfernen: {label}", + "search_chip_sender": "Absender", + "search_chip_date": "Zeitraum", + "search_chip_keyword": "Stichwort", + "search_chip_directional_label": "Von {from} zu {to}, Filter entfernen", + "search_disambiguation_trigger_label": "Mehrere Personen gefunden — zum Auswählen klicken", + "search_disambiguation_cue": "(auswählen…)", + "search_disambiguation_heading": "Person auswählen", + "search_disambiguation_select_label": "{name} auswählen", "error_validation_error": "Die Eingabe ist ungültig.", "error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.", "nav_documents": "Dokumente", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 6eb58fca..a1315bdc 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -25,6 +25,28 @@ "error_smart_search_unavailable": "The smart search is currently unavailable. Please use the regular search.", "error_smart_search_rate_limited": "You have used the search function too frequently. Please wait a minute.", "smart_search_keywords_not_applied": "Keywords could not be applied to this search.", + "search_toggle_smart_label": "AI", + "search_toggle_smart_label_suffix": " search", + "search_toggle_keyword_label": "Text", + "search_toggle_keyword_label_suffix": " search", + "search_loading_nl": "Querying the archive…", + "search_loading_nl_sub": "The AI is analysing your request. This can take up to 15 seconds.", + "search_error_unavailable": "Smart search unavailable", + "search_error_unavailable_body": "The AI search is currently unreachable. You can repeat your request as a plain full-text search.", + "search_switch_to_keyword": "Switch to full-text search", + "search_error_rate_limited": "Too many requests", + "search_error_rate_limited_body": "You have used the smart search too frequently. Please wait a minute and try again.", + "search_empty_nl": "No results", + "search_empty_retry_keyword": "Repeat as full-text search", + "search_filter_remove_label": "Remove filter: {label}", + "search_chip_sender": "Sender", + "search_chip_date": "Period", + "search_chip_keyword": "Keyword", + "search_chip_directional_label": "From {from} to {to}, remove filter", + "search_disambiguation_trigger_label": "Several people found — click to choose", + "search_disambiguation_cue": "(choose…)", + "search_disambiguation_heading": "Choose a person", + "search_disambiguation_select_label": "Select {name}", "error_validation_error": "The input is invalid.", "error_internal_error": "An unexpected error occurred.", "nav_documents": "Documents", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 92fb90af..86f2c52e 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -25,6 +25,28 @@ "error_smart_search_unavailable": "La búsqueda inteligente no está disponible en este momento. Por favor, usa la búsqueda normal.", "error_smart_search_rate_limited": "Has utilizado la función de búsqueda demasiadas veces. Por favor, espera un minuto.", "smart_search_keywords_not_applied": "Las palabras clave no pudieron aplicarse a esta búsqueda.", + "search_toggle_smart_label": "IA", + "search_toggle_smart_label_suffix": " búsqueda", + "search_toggle_keyword_label": "Texto", + "search_toggle_keyword_label_suffix": " búsqueda", + "search_loading_nl": "Consultando el archivo…", + "search_loading_nl_sub": "La IA está analizando su solicitud. Esto puede tardar hasta 15 segundos.", + "search_error_unavailable": "Búsqueda inteligente no disponible", + "search_error_unavailable_body": "La búsqueda con IA no está disponible en este momento. Puede repetir su solicitud como una búsqueda de texto completo.", + "search_switch_to_keyword": "Cambiar a búsqueda de texto completo", + "search_error_rate_limited": "Demasiadas solicitudes", + "search_error_rate_limited_body": "Ha utilizado la búsqueda inteligente con demasiada frecuencia. Espere un minuto e inténtelo de nuevo.", + "search_empty_nl": "Sin resultados", + "search_empty_retry_keyword": "Repetir como búsqueda de texto completo", + "search_filter_remove_label": "Eliminar filtro: {label}", + "search_chip_sender": "Remitente", + "search_chip_date": "Período", + "search_chip_keyword": "Palabra clave", + "search_chip_directional_label": "De {from} a {to}, eliminar filtro", + "search_disambiguation_trigger_label": "Se encontraron varias personas — haga clic para elegir", + "search_disambiguation_cue": "(elegir…)", + "search_disambiguation_heading": "Elegir una persona", + "search_disambiguation_select_label": "Seleccionar {name}", "error_validation_error": "La entrada no es válida.", "error_internal_error": "Se ha producido un error inesperado.", "nav_documents": "Documentos", -- 2.49.1 From 9e425c98a144a26518b1c54cf1d51aef37da580e Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 17:35:05 +0200 Subject: [PATCH 02/12] feat(search): add SmartModeToggle pill component (#739) Toggle pill with aria-pressed, active/resting styles matching the AND/OR operator button pattern, and mobile-expanded KI/Text labels. 4 vitest-browser-svelte specs (red/green). Co-Authored-By: Claude Opus 4.8 --- .../src/routes/search/SmartModeToggle.svelte | 38 +++++++++++++++++++ .../search/SmartModeToggle.svelte.spec.ts | 36 ++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 frontend/src/routes/search/SmartModeToggle.svelte create mode 100644 frontend/src/routes/search/SmartModeToggle.svelte.spec.ts diff --git a/frontend/src/routes/search/SmartModeToggle.svelte b/frontend/src/routes/search/SmartModeToggle.svelte new file mode 100644 index 00000000..bb9f7c35 --- /dev/null +++ b/frontend/src/routes/search/SmartModeToggle.svelte @@ -0,0 +1,38 @@ + + + diff --git a/frontend/src/routes/search/SmartModeToggle.svelte.spec.ts b/frontend/src/routes/search/SmartModeToggle.svelte.spec.ts new file mode 100644 index 00000000..042a7008 --- /dev/null +++ b/frontend/src/routes/search/SmartModeToggle.svelte.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import SmartModeToggle from './SmartModeToggle.svelte'; + +afterEach(() => cleanup()); + +describe('SmartModeToggle', () => { + it('renders aria-pressed="false" by default and toggles on click', async () => { + render(SmartModeToggle, { smartMode: false }); + const btn = page.getByRole('button'); + await expect.element(btn).toHaveAttribute('aria-pressed', 'false'); + await btn.click(); + await expect.element(btn).toHaveAttribute('aria-pressed', 'true'); + await btn.click(); + await expect.element(btn).toHaveAttribute('aria-pressed', 'false'); + }); + + it('shows the smart label when smartMode is true', async () => { + render(SmartModeToggle, { smartMode: true }); + const btn = page.getByRole('button'); + await expect.element(btn).toHaveTextContent('KI'); + }); + + it('shows the keyword label when smartMode is false', async () => { + render(SmartModeToggle, { smartMode: false }); + const btn = page.getByRole('button'); + await expect.element(btn).toHaveTextContent('Text'); + }); + + it('applies the active pill style only in smart mode', async () => { + render(SmartModeToggle, { smartMode: true }); + const btn = page.getByRole('button'); + await expect.element(btn).toHaveClass(/bg-primary/); + }); +}); -- 2.49.1 From 8ed65f860221523df1850eb2671b51914bf18632 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 17:38:51 +0200 Subject: [PATCH 03/12] feat(search): add InterpretationChipRow component (#739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders type-prefixed chips (Absender/Zeitraum/Stichwort), a single directional chip for 2-name queries, gates keyword chips on keywordsApplied, and emits onRemoveChip(type, value?). Truncating name spans keep the 44px × button visible; chip wrappers show a focus ring. 9 vitest-browser-svelte specs (red/green). Co-Authored-By: Claude Opus 4.8 --- .../search/InterpretationChipRow.svelte | 133 ++++++++++++++++++ .../InterpretationChipRow.svelte.spec.ts | 133 ++++++++++++++++++ 2 files changed, 266 insertions(+) create mode 100644 frontend/src/routes/search/InterpretationChipRow.svelte create mode 100644 frontend/src/routes/search/InterpretationChipRow.svelte.spec.ts diff --git a/frontend/src/routes/search/InterpretationChipRow.svelte b/frontend/src/routes/search/InterpretationChipRow.svelte new file mode 100644 index 00000000..8e9cdbc0 --- /dev/null +++ b/frontend/src/routes/search/InterpretationChipRow.svelte @@ -0,0 +1,133 @@ + + +
+ {#each chips as chip (chip.key)} + {#if chip.type === 'directional'} + + {chip.from} + + {chip.to} + + + {:else} + + {chip.label} + + + {/if} + {/each} +
+ +{#if showKeywordsNotApplied} +

{m.smart_search_keywords_not_applied()}

+{/if} diff --git a/frontend/src/routes/search/InterpretationChipRow.svelte.spec.ts b/frontend/src/routes/search/InterpretationChipRow.svelte.spec.ts new file mode 100644 index 00000000..f3164389 --- /dev/null +++ b/frontend/src/routes/search/InterpretationChipRow.svelte.spec.ts @@ -0,0 +1,133 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import InterpretationChipRow from './InterpretationChipRow.svelte'; +import type { components } from '$lib/generated/api'; + +type NlQueryInterpretation = components['schemas']['NlQueryInterpretation']; +type PersonHint = components['schemas']['PersonHint']; + +afterEach(() => cleanup()); + +const makePerson = (id: string, displayName: string): PersonHint => ({ id, displayName }); + +const makeInterpretation = ( + overrides: Partial = {} +): NlQueryInterpretation => ({ + resolvedPersons: [], + ambiguousPersons: [], + keywords: [], + rawQuery: 'test', + keywordsApplied: true, + ...overrides +}); + +describe('InterpretationChipRow', () => { + it('renders type-prefixed labels for sender, date and keyword chips', async () => { + render(InterpretationChipRow, { + interpretation: makeInterpretation({ + resolvedPersons: [makePerson('p1', 'Walter Raddatz')], + dateFrom: '1914-01-01', + dateTo: '1918-12-31', + keywords: ['krieg'] + }), + onRemoveChip: vi.fn() + }); + await expect.element(page.getByText('Absender: Walter Raddatz')).toBeInTheDocument(); + await expect.element(page.getByText('Zeitraum: 1914–1918')).toBeInTheDocument(); + await expect.element(page.getByText('Stichwort: krieg')).toBeInTheDocument(); + }); + + it('calls onRemoveChip with "sender" when the sender chip × is clicked', async () => { + const onRemoveChip = vi.fn(); + render(InterpretationChipRow, { + interpretation: makeInterpretation({ + resolvedPersons: [makePerson('p1', 'Walter Raddatz')] + }), + onRemoveChip + }); + await page.getByRole('button', { name: /Absender: Walter Raddatz/ }).click(); + expect(onRemoveChip).toHaveBeenCalledWith('sender', undefined); + }); + + it('removes a chip from the DOM but keeps the rest when one × is clicked', async () => { + const { container } = render(InterpretationChipRow, { + interpretation: makeInterpretation({ + resolvedPersons: [makePerson('p1', 'Walter Raddatz')], + dateFrom: '1914-01-01', + dateTo: '1918-12-31', + keywords: ['krieg'] + }), + onRemoveChip: vi.fn() + }); + expect(container.querySelectorAll('[data-chip-type]')).toHaveLength(3); + await page.getByRole('button', { name: /Absender/ }).click(); + await vi.waitFor(() => expect(container.querySelectorAll('[data-chip-type]')).toHaveLength(2)); + }); + + it('renders a single directional chip with an arrow for a 2-name query', async () => { + const { container } = render(InterpretationChipRow, { + interpretation: makeInterpretation({ + resolvedPersons: [makePerson('p1', 'Walter Raddatz'), makePerson('p2', 'Emma Raddatz')] + }), + onRemoveChip: vi.fn() + }); + expect(container.querySelectorAll('[data-chip-type="directional"]')).toHaveLength(1); + await expect.element(page.getByText(/→/)).toBeInTheDocument(); + }); + + it('calls onRemoveChip with "directional" when the directional chip × is clicked', async () => { + const onRemoveChip = vi.fn(); + render(InterpretationChipRow, { + interpretation: makeInterpretation({ + resolvedPersons: [makePerson('p1', 'Walter Raddatz'), makePerson('p2', 'Emma Raddatz')] + }), + onRemoveChip + }); + await page.getByRole('button', { name: /Walter Raddatz/ }).click(); + expect(onRemoveChip).toHaveBeenCalledWith('directional', undefined); + }); + + it('does not render keyword chips when keywordsApplied is false', async () => { + const { container } = render(InterpretationChipRow, { + interpretation: makeInterpretation({ + keywordsApplied: false, + keywords: ['krieg', 'brief'] + }), + onRemoveChip: vi.fn() + }); + expect(container.querySelectorAll('[data-chip-type="keyword"]')).toHaveLength(0); + }); + + it('renders no keyword chips when keywords is empty', async () => { + const { container } = render(InterpretationChipRow, { + interpretation: makeInterpretation({ keywordsApplied: true, keywords: [] }), + onRemoveChip: vi.fn() + }); + expect(container.querySelectorAll('[data-chip-type="keyword"]')).toHaveLength(0); + }); + + it('renders exactly one keyword chip per keyword', async () => { + const { container } = render(InterpretationChipRow, { + interpretation: makeInterpretation({ + keywordsApplied: true, + keywords: ['krieg', 'brief', 'front'] + }), + onRemoveChip: vi.fn() + }); + expect(container.querySelectorAll('[data-chip-type="keyword"]')).toHaveLength(3); + }); + + it('keeps the × button in the DOM when a display name is 100 characters', async () => { + const longName = 'W'.repeat(100); + render(InterpretationChipRow, { + interpretation: makeInterpretation({ + resolvedPersons: [makePerson('p1', longName)] + }), + onRemoveChip: vi.fn() + }); + await expect + .element(page.getByRole('button', { name: new RegExp('Absender') })) + .toBeInTheDocument(); + }); +}); -- 2.49.1 From fb00c7818e125b0fb33355e1ed6b1da946e5090b Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 17:40:28 +0200 Subject: [PATCH 04/12] feat(search): add SmartSearchStatus full-area panels (#739) Loading panel (role=status, motion-safe spinner + pulsing subtitle) and combined error panels: 503 (red icon + switch-to-keyword button) and 429 (amber clock icon, no action button). 5 vitest-browser-svelte specs. Co-Authored-By: Claude Opus 4.8 --- .../routes/search/SmartSearchStatus.svelte | 69 +++++++++++++++++++ .../search/SmartSearchStatus.svelte.spec.ts | 60 ++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 frontend/src/routes/search/SmartSearchStatus.svelte create mode 100644 frontend/src/routes/search/SmartSearchStatus.svelte.spec.ts diff --git a/frontend/src/routes/search/SmartSearchStatus.svelte b/frontend/src/routes/search/SmartSearchStatus.svelte new file mode 100644 index 00000000..80a0a1d5 --- /dev/null +++ b/frontend/src/routes/search/SmartSearchStatus.svelte @@ -0,0 +1,69 @@ + + +{#if status === 'loading'} +
+ +

{m.search_loading_nl()}

+

+ {m.search_loading_nl_sub()} +

+
+{:else if status === 'error'} + +{/if} diff --git a/frontend/src/routes/search/SmartSearchStatus.svelte.spec.ts b/frontend/src/routes/search/SmartSearchStatus.svelte.spec.ts new file mode 100644 index 00000000..605576d0 --- /dev/null +++ b/frontend/src/routes/search/SmartSearchStatus.svelte.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import SmartSearchStatus from './SmartSearchStatus.svelte'; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('SmartSearchStatus', () => { + it('renders a role="status" loading panel with the loading title', async () => { + render(SmartSearchStatus, { status: 'loading' }); + const status = page.getByRole('status'); + await expect.element(status).toBeInTheDocument(); + await expect.element(status).toHaveTextContent('Archiv wird befragt'); + }); + + it('hides the loading panel once the status changes away from loading', async () => { + const { rerender } = render(SmartSearchStatus, { status: 'loading' }); + await expect.element(page.getByRole('status')).toBeInTheDocument(); + await rerender({ status: 'error', errorCode: 'SMART_SEARCH_UNAVAILABLE' }); + await expect.element(page.getByRole('status')).not.toBeInTheDocument(); + }); + + it('renders the 503 panel with title, body and a switch-to-keyword button', async () => { + render(SmartSearchStatus, { + status: 'error', + errorCode: 'SMART_SEARCH_UNAVAILABLE', + onSwitchToKeyword: vi.fn() + }); + await expect.element(page.getByText('Intelligente Suche nicht verfügbar')).toBeInTheDocument(); + await expect + .element(page.getByRole('button', { name: /Volltextsuche wechseln/ })) + .toBeInTheDocument(); + }); + + it('invokes onSwitchToKeyword when the 503 fallback button is clicked', async () => { + const onSwitchToKeyword = vi.fn(); + render(SmartSearchStatus, { + status: 'error', + errorCode: 'SMART_SEARCH_UNAVAILABLE', + onSwitchToKeyword + }); + await page.getByRole('button', { name: /Volltextsuche wechseln/ }).click(); + expect(onSwitchToKeyword).toHaveBeenCalledOnce(); + }); + + it('renders the 429 panel with title and body but no switch-to-keyword button', async () => { + render(SmartSearchStatus, { + status: 'error', + errorCode: 'SMART_SEARCH_RATE_LIMITED', + onSwitchToKeyword: vi.fn() + }); + await expect.element(page.getByText('Zu viele Anfragen')).toBeInTheDocument(); + await expect + .element(page.getByRole('button', { name: /Volltextsuche wechseln/ })) + .not.toBeInTheDocument(); + }); +}); -- 2.49.1 From fa41394e66c5e7fe1e6b5cef4420294c65766a96 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 17:43:27 +0200 Subject: [PATCH 05/12] feat(search): add DisambiguationPicker single-select disclosure (#739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accessible disclosure: aria-expanded/aria-controls trigger, focus moves into the option list on open, Escape and click-outside close and return focus to the trigger, selecting a candidate emits onSelect. Single-select (GET re-run) per the resolved #738 open decision — backend has no multi-sender OR param. 5 vitest-browser-svelte specs. Co-Authored-By: Claude Opus 4.8 --- .../routes/search/DisambiguationPicker.svelte | 86 +++++++++++++++++++ .../DisambiguationPicker.svelte.spec.ts | 71 +++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 frontend/src/routes/search/DisambiguationPicker.svelte create mode 100644 frontend/src/routes/search/DisambiguationPicker.svelte.spec.ts diff --git a/frontend/src/routes/search/DisambiguationPicker.svelte b/frontend/src/routes/search/DisambiguationPicker.svelte new file mode 100644 index 00000000..b99fea10 --- /dev/null +++ b/frontend/src/routes/search/DisambiguationPicker.svelte @@ -0,0 +1,86 @@ + + + + +
open && closePicker()}> + + + {#if open} +
    + {#each persons as person (person.id)} +
  • + +
  • + {/each} +
+ {/if} +
diff --git a/frontend/src/routes/search/DisambiguationPicker.svelte.spec.ts b/frontend/src/routes/search/DisambiguationPicker.svelte.spec.ts new file mode 100644 index 00000000..5b87a996 --- /dev/null +++ b/frontend/src/routes/search/DisambiguationPicker.svelte.spec.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import DisambiguationPicker from './DisambiguationPicker.svelte'; +import type { components } from '$lib/generated/api'; + +type PersonHint = components['schemas']['PersonHint']; + +afterEach(() => cleanup()); + +const persons: PersonHint[] = [ + { id: 'w1', displayName: 'Walter Raddatz' }, + { id: 'w2', displayName: 'Walter Müller' } +]; + +function pressEscape() { + (document.activeElement as HTMLElement).dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }) + ); +} + +describe('DisambiguationPicker', () => { + it('opens the picker and shows a select option per ambiguous person', async () => { + render(DisambiguationPicker, { persons, onSelect: vi.fn() }); + await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click(); + await expect + .element(page.getByRole('button', { name: 'Walter Raddatz auswählen' })) + .toBeInTheDocument(); + await expect + .element(page.getByRole('button', { name: 'Walter Müller auswählen' })) + .toBeInTheDocument(); + }); + + it('moves focus into the picker list on open', async () => { + render(DisambiguationPicker, { persons, onSelect: vi.fn() }); + await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click(); + await expect + .element(page.getByRole('button', { name: 'Walter Raddatz auswählen' })) + .toHaveFocus(); + }); + + it('returns focus to the trigger when closed with Escape', async () => { + render(DisambiguationPicker, { persons, onSelect: vi.fn() }); + const trigger = page.getByRole('button', { name: /Mehrere Personen gefunden/ }); + await trigger.click(); + await expect + .element(page.getByRole('button', { name: 'Walter Raddatz auswählen' })) + .toHaveFocus(); + pressEscape(); + await expect.element(trigger).toHaveFocus(); + }); + + it('does not call onSelect when dismissed without choosing', async () => { + const onSelect = vi.fn(); + render(DisambiguationPicker, { persons, onSelect }); + await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click(); + await expect + .element(page.getByRole('button', { name: 'Walter Raddatz auswählen' })) + .toHaveFocus(); + pressEscape(); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('calls onSelect with the chosen person', async () => { + const onSelect = vi.fn(); + render(DisambiguationPicker, { persons, onSelect }); + await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click(); + await page.getByRole('button', { name: 'Walter Müller auswählen' }).click(); + expect(onSelect).toHaveBeenCalledWith(persons[1]); + }); +}); -- 2.49.1 From 5945824b54262624789507f8b6c375483a2adc0a Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 17:47:05 +0200 Subject: [PATCH 06/12] feat(search): wire SmartModeToggle into SearchFilterBar (#739) Add smartMode $bindable plus onSmartSearch/onModeToggle callbacks. The toggle pill sits in the input's right slot (decorative icon moved to the left); smart mode disables the live oninput keyword search, adds maxlength=500, and submits the NL query on Enter. 4 integration specs. Co-Authored-By: Claude Opus 4.8 --- frontend/src/routes/SearchFilterBar.svelte | 29 ++++++++++-- .../search/SmartModeToggle.svelte.spec.ts | 47 ++++++++++++++++++- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/frontend/src/routes/SearchFilterBar.svelte b/frontend/src/routes/SearchFilterBar.svelte index 2b980974..779ab8f3 100644 --- a/frontend/src/routes/SearchFilterBar.svelte +++ b/frontend/src/routes/SearchFilterBar.svelte @@ -3,6 +3,7 @@ import PersonTypeahead from '$lib/person/PersonTypeahead.svelte'; import TagInput from '$lib/tag/TagInput.svelte'; import DateInput from '$lib/shared/primitives/DateInput.svelte'; import SortDropdown from '$lib/shared/primitives/SortDropdown.svelte'; +import SmartModeToggle from './search/SmartModeToggle.svelte'; import { slide } from 'svelte/transition'; import { m } from '$lib/paraglide/messages.js'; @@ -20,12 +21,15 @@ let { sort = $bindable('DATE'), dir = $bindable('desc'), showAdvanced = $bindable(false), + smartMode = $bindable(false), initialSenderName = '', initialReceiverName = '', navKey = 0, isLoading = false, onSearch, onSearchImmediate, + onSmartSearch, + onModeToggle, onfocus, onblur }: { @@ -42,16 +46,28 @@ let { sort?: string; dir?: string; showAdvanced?: boolean; + smartMode?: boolean; initialSenderName?: string; initialReceiverName?: string; navKey?: number; isLoading?: boolean; onSearch: () => void; onSearchImmediate?: () => void; + onSmartSearch?: () => void; + onModeToggle?: () => void; onfocus?: () => void; onblur?: () => void; } = $props(); +// In smart mode the keyword search must not fire on every keystroke — the NL +// query is submitted only on Enter (or an explicit button click). +function onSearchKeydown(event: KeyboardEvent) { + if (smartMode && event.key === 'Enter') { + event.preventDefault(); + onSmartSearch?.(); + } +} + // Plain (non-reactive) flag — not $state, so no reactive assignment inside $effect let sortDirMounted = false; @@ -76,14 +92,20 @@ $effect(() => { -
+ +
{#if isLoading} { /> {/if}
+
diff --git a/frontend/src/routes/search/SmartModeToggle.svelte.spec.ts b/frontend/src/routes/search/SmartModeToggle.svelte.spec.ts index 042a7008..01347271 100644 --- a/frontend/src/routes/search/SmartModeToggle.svelte.spec.ts +++ b/frontend/src/routes/search/SmartModeToggle.svelte.spec.ts @@ -1,10 +1,13 @@ -import { describe, expect, it, afterEach } from 'vitest'; +import { describe, expect, it, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import SmartModeToggle from './SmartModeToggle.svelte'; +import SearchFilterBar from '../SearchFilterBar.svelte'; afterEach(() => cleanup()); +const SEARCH_PLACEHOLDER = 'Titel, Personen, Tags durchsuchen…'; + describe('SmartModeToggle', () => { it('renders aria-pressed="false" by default and toggles on click', async () => { render(SmartModeToggle, { smartMode: false }); @@ -34,3 +37,45 @@ describe('SmartModeToggle', () => { await expect.element(btn).toHaveClass(/bg-primary/); }); }); + +describe('SmartModeToggle inside SearchFilterBar', () => { + it('adds maxlength="500" to the search input only in smart mode', async () => { + render(SearchFilterBar, { onSearch: vi.fn(), sort: 'DATE', dir: 'desc', smartMode: true }); + await expect + .element(page.getByPlaceholder(SEARCH_PLACEHOLDER)) + .toHaveAttribute('maxlength', '500'); + }); + + it('omits maxlength from the search input in keyword mode', async () => { + render(SearchFilterBar, { onSearch: vi.fn(), sort: 'DATE', dir: 'desc', smartMode: false }); + await expect + .element(page.getByPlaceholder(SEARCH_PLACEHOLDER)) + .not.toHaveAttribute('maxlength'); + }); + + it('does not fire the keyword search on input while in smart mode', async () => { + const onSearch = vi.fn(); + render(SearchFilterBar, { onSearch, sort: 'DATE', dir: 'desc', smartMode: true }); + await page.getByPlaceholder(SEARCH_PLACEHOLDER).fill('Walter im Krieg'); + expect(onSearch).not.toHaveBeenCalled(); + }); + + it('fires the smart search callback on Enter in smart mode', async () => { + const onSmartSearch = vi.fn(); + render(SearchFilterBar, { + onSearch: vi.fn(), + onSmartSearch, + sort: 'DATE', + dir: 'desc', + smartMode: true + }); + const input = page.getByPlaceholder(SEARCH_PLACEHOLDER); + await input.fill('Walter im Krieg'); + await input.click(); + // Enter submits the NL query in smart mode + (document.activeElement as HTMLElement).dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }) + ); + await vi.waitFor(() => expect(onSmartSearch).toHaveBeenCalled()); + }); +}); -- 2.49.1 From f2f42ed41571440e3ea3ca411e16606c5bade713 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 17:54:07 +0200 Subject: [PATCH 07/12] feat(search): orchestrate NL search on the documents page (#739) Lift smartMode to documents/+page.svelte and drive the full smart-search lifecycle: POST /api/search/nl via csrfFetch, loading/error panels, chip row, single-select disambiguation, and a transparent empty state. Chip removal and disambiguation selection map the interpretation to keyword params and re-run via GET (Option A in-page fallback). Mode toggle and new queries reset prior interpretation. Co-Authored-By: Claude Opus 4.8 --- frontend/src/routes/documents/+page.svelte | 331 ++++++++++++++++----- 1 file changed, 259 insertions(+), 72 deletions(-) diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte index 5006d9eb..2d287140 100644 --- a/frontend/src/routes/documents/+page.svelte +++ b/frontend/src/routes/documents/+page.svelte @@ -8,9 +8,20 @@ import DocumentList from '../DocumentList.svelte'; import Pagination from '$lib/shared/primitives/Pagination.svelte'; import BulkSelectionBar from '$lib/document/BulkSelectionBar.svelte'; import TimelineDensityFilter from '$lib/document/TimelineDensityFilter.svelte'; +import SmartSearchStatus from '../search/SmartSearchStatus.svelte'; +import InterpretationChipRow from '../search/InterpretationChipRow.svelte'; +import DisambiguationPicker from '../search/DisambiguationPicker.svelte'; import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte'; import { getErrorMessage, parseBackendError } from '$lib/shared/errors'; +import { csrfFetch } from '$lib/shared/cookies'; import * as m from '$lib/paraglide/messages.js'; +import type { components } from '$lib/generated/api'; + +type NlQueryInterpretation = components['schemas']['NlQueryInterpretation']; +type NlSearchResponse = components['schemas']['NlSearchResponse']; +type DocumentSearchResult = components['schemas']['DocumentSearchResult']; +type PersonHint = components['schemas']['PersonHint']; +type SmartSearchErrorCode = 'SMART_SEARCH_UNAVAILABLE' | 'SMART_SEARCH_RATE_LIMITED'; let { data } = $props(); @@ -34,6 +45,18 @@ let tagQ = $state(untrack(() => data.tagQ || '')); let tagOperator = $state<'AND' | 'OR'>(untrack(() => (data.tagOp as 'AND' | 'OR') || 'AND')); let undated = $state(untrack(() => data.undated ?? false)); +// Smart (NL) search — UI-local state, resets on real page navigation (away + back). +let smartMode = $state(false); +let nlSubmitted = $state(false); +let nlLoading = $state(false); +let nlError = $state(null); +let nlInterpretation = $state(null); +let nlResult = $state(null); + +const showNlView = $derived(smartMode && nlSubmitted); +const nlHasResults = $derived((nlResult?.items.length ?? 0) > 0); +const nlIsAmbiguous = $derived((nlInterpretation?.ambiguousPersons.length ?? 0) > 0); + function hasAdvancedFilters() { return ( (data.tags?.length ?? 0) > 0 || @@ -164,6 +187,121 @@ function handleImmediateSearch() { triggerSearchKeepZoom(); } +function resetNlState() { + nlSubmitted = false; + nlLoading = false; + nlError = null; + nlInterpretation = null; + nlResult = null; +} + +/** Toggling the mode (either direction) always clears any prior NL interpretation. */ +function onModeToggle() { + resetNlState(); +} + +/** Submit the natural-language query to the server-side parser. */ +async function runSmartSearch() { + const query = q.trim(); + if (query.length < 3) return; + nlSubmitted = true; + nlLoading = true; + nlError = null; + nlInterpretation = null; + nlResult = null; + try { + const res = await csrfFetch('/api/search/nl', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }) + }); + if (!res.ok) { + const backend = await parseBackendError(res); + nlError = + backend?.code === 'SMART_SEARCH_RATE_LIMITED' + ? 'SMART_SEARCH_RATE_LIMITED' + : 'SMART_SEARCH_UNAVAILABLE'; + return; + } + const body: NlSearchResponse = await res.json(); + nlInterpretation = body.interpretation; + nlResult = body.result; + } catch { + nlError = 'SMART_SEARCH_UNAVAILABLE'; + } finally { + nlLoading = false; + } +} + +/** Option A empty/error fallback: drop NL mode, keep the raw query, run a keyword search. */ +function switchToKeywordMode() { + resetNlState(); + smartMode = false; + handleImmediateSearch(); +} + +/** Applies a resolved param set to the keyword filters and re-runs via GET. */ +function applyResolvedAndSearch(p: { + senderId: string; + receiverId: string; + from: string; + to: string; + q: string; +}) { + resetNlState(); + smartMode = false; + senderId = p.senderId; + receiverId = p.receiverId; + from = p.from; + to = p.to; + q = p.q; + handleImmediateSearch(); +} + +function paramsFromInterpretation(interp: NlQueryInterpretation) { + const resolved = interp.resolvedPersons; + return { + senderId: resolved.length >= 1 ? resolved[0].id : '', + receiverId: resolved.length >= 2 ? resolved[1].id : '', + from: interp.dateFrom ?? '', + to: interp.dateTo ?? '', + q: interp.keywordsApplied ? interp.keywords.join(' ') : '' + }; +} + +type ChipType = 'sender' | 'directional' | 'date' | 'keyword'; + +function removeChip(type: ChipType, value?: string) { + if (!nlInterpretation) return; + const p = paramsFromInterpretation(nlInterpretation); + if (type === 'sender') { + p.senderId = ''; + } else if (type === 'directional') { + p.senderId = ''; + p.receiverId = ''; + } else if (type === 'date') { + p.from = ''; + p.to = ''; + } else if (type === 'keyword' && value) { + const remaining = nlInterpretation.keywords.filter((keyword) => keyword !== value); + p.q = remaining.join(' '); + } + applyResolvedAndSearch(p); +} + +/** Single-select disambiguation: resolved person becomes sender, chosen becomes receiver. */ +function selectDisambiguated(person: PersonHint) { + if (!nlInterpretation) return; + const resolved = nlInterpretation.resolvedPersons; + applyResolvedAndSearch({ + senderId: resolved.length >= 1 ? resolved[0].id : person.id, + receiverId: resolved.length >= 1 ? person.id : '', + from: nlInterpretation.dateFrom ?? '', + to: nlInterpretation.dateTo ?? '', + q: nlInterpretation.keywordsApplied ? nlInterpretation.keywords.join(' ') : '' + }); +} + // Trigger search reactively when the tag list changes. let prevTagStr = untrack(() => tagNames.map((t) => t.name).join(',')); $effect(() => { @@ -268,6 +406,7 @@ $effect(() => { bind:tagQ={tagQ} bind:tagOperator={tagOperator} bind:undated={undated} + bind:smartMode={smartMode} undatedCount={data.undatedCount ?? 0} initialSenderName={initialSenderName} initialReceiverName={initialReceiverName} @@ -275,93 +414,141 @@ $effect(() => { isLoading={navigating.to !== null} onSearch={handleTextSearch} onSearchImmediate={handleImmediateSearch} + onSmartSearch={runSmartSearch} + onModeToggle={onModeToggle} onfocus={() => (qFocused = true)} onblur={() => (qFocused = false)} /> - + {#if showNlView} + + {#if nlLoading} + + {:else if nlError} + + {:else if nlInterpretation} + {#key nlInterpretation} +
+ {#if nlIsAmbiguous} + + {:else} + + {/if} +
-
-

- {#if data.totalElements > 0}{m.docs_result_count({ count: data.totalElements })}{/if} -

- {#if data.canWrite} -
-
- {#if data.totalElements > 0} - +
+ {/if} + {/if} + {/key} + {/if} + {:else} + + +
+

+ {#if data.totalElements > 0}{m.docs_result_count({ count: data.totalElements })}{/if} +

+ {#if data.canWrite} +
+
+ {#if data.totalElements > 0} + + {/if} + - {m.bulk_edit_all_x({ count: data.totalElements })} - + {m.docs_btn_new()} + +
+ {#if editAllError} + {/if} - - - {m.docs_btn_new()} -
- {#if editAllError} - - {/if} -
- {/if} -
+ {/if} +
- + - + + {/if} -- 2.49.1 From 169e1ad9de06879f027acbae63eabeb534adbd03 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 17:54:25 +0200 Subject: [PATCH 08/12] test(search): cover smart-mode chip lifecycle hooks (#739) SearchFilterBar drives chip-clearing via onModeToggle (mode switch) and onSmartSearch (new query); pin that callback contract. Co-Authored-By: Claude Opus 4.8 --- .../src/routes/SearchFilterBar.svelte.spec.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/frontend/src/routes/SearchFilterBar.svelte.spec.ts b/frontend/src/routes/SearchFilterBar.svelte.spec.ts index 446cd046..fa389d8c 100644 --- a/frontend/src/routes/SearchFilterBar.svelte.spec.ts +++ b/frontend/src/routes/SearchFilterBar.svelte.spec.ts @@ -195,3 +195,39 @@ describe('SearchFilterBar – tagQ live filter', () => { vi.unstubAllGlobals(); }); }); + +describe('SearchFilterBar – smart-mode chip lifecycle hooks', () => { + // The interpretation chips live in the result area (parent page). SearchFilterBar + // drives chip-clearing through callbacks: onModeToggle (mode switch) and + // onSmartSearch (new query). These tests pin that contract. + it('invokes onModeToggle when toggling back to keyword mode (parent clears chips)', async () => { + const onModeToggle = vi.fn(); + render(SearchFilterBar, { + ...defaultProps, + sort: 'DATE', + dir: 'desc', + smartMode: true, + onModeToggle + }); + await page.getByRole('button', { name: /KI/ }).click(); + expect(onModeToggle).toHaveBeenCalledOnce(); + }); + + it('invokes onSmartSearch when a new query is submitted in smart mode (parent resets chips)', async () => { + const onSmartSearch = vi.fn(); + render(SearchFilterBar, { + ...defaultProps, + sort: 'DATE', + dir: 'desc', + smartMode: true, + onSmartSearch + }); + const input = page.getByPlaceholder('Titel, Personen, Tags durchsuchen…'); + await input.fill('Walter im Krieg'); + await input.click(); + (document.activeElement as HTMLElement).dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }) + ); + await vi.waitFor(() => expect(onSmartSearch).toHaveBeenCalled()); + }); +}); -- 2.49.1 From e604967a3f3c545f8980f67e71683471da40eb74 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 17:57:59 +0200 Subject: [PATCH 09/12] docs(search): document src/routes/search/ component directory (#739) Add the smart-search sub-component directory to the frontend Project Structure tree (merge blocker per #739). Co-Authored-By: Claude Opus 4.8 --- frontend/CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index a6fa8df7..3301675e 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -28,6 +28,7 @@ src/ │ ├── +layout.server.ts # Loads current user, injects auth cookie │ ├── +page.svelte # Home / document search dashboard │ ├── documents/ # Document CRUD, detail, edit, upload +│ ├── search/ # Smart (NL) search sub-components — SmartModeToggle, InterpretationChipRow, SmartSearchStatus, DisambiguationPicker (no +page; consumed by documents/ and SearchFilterBar) │ ├── persons/ # Person directory (filtered, paginated), detail, edit, merge, review (triage) │ ├── aktivitaeten/ # Unified activity feed (Chronik) │ ├── admin/ # User, group, tag, OCR, system management -- 2.49.1 From 230f23e37ce63e690c87381ab62d3aa55bc72a93 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 17:58:15 +0200 Subject: [PATCH 10/12] test(search): add NL search happy-path Playwright E2E (#739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mock POST /api/search/nl (delayed fixture: 2-name directional + applied keyword), assert loading announcement → chips render → axe-clean in light and dark → removing the keyword chip re-runs a keyword GET with the remaining sender+receiver params. Adds a data-testid wrapper on the NL results region for axe scoping. Co-Authored-By: Claude Opus 4.8 --- frontend/e2e/nl-search.spec.ts | 83 ++++++++++++++++++++++ frontend/src/routes/documents/+page.svelte | 82 ++++++++++----------- 2 files changed, 125 insertions(+), 40 deletions(-) create mode 100644 frontend/e2e/nl-search.spec.ts diff --git a/frontend/e2e/nl-search.spec.ts b/frontend/e2e/nl-search.spec.ts new file mode 100644 index 00000000..bd869582 --- /dev/null +++ b/frontend/e2e/nl-search.spec.ts @@ -0,0 +1,83 @@ +import AxeBuilder from '@axe-core/playwright'; +import { test, expect } from '@playwright/test'; + +// NL search is mocked at the network boundary — Ollama is not required in CI. +// CSRF enforcement is bypassed by page.route (the real request is never sent), +// so it is only verified in manual full-stack runs (see issue #739 DevOps notes). +const interpretation = { + resolvedPersons: [ + { id: '11111111-1111-1111-1111-111111111111', displayName: 'Walter Raddatz' }, + { id: '22222222-2222-2222-2222-222222222222', displayName: 'Emma Raddatz' } + ], + ambiguousPersons: [], + dateFrom: '1914-01-01', + dateTo: '1918-12-31', + keywords: ['krieg'], + rawQuery: 'Was hat Walter an Emma im Krieg geschrieben?', + keywordsApplied: true +}; + +const nlResponse = { + result: { + items: [], + totalElements: 0, + pageNumber: 0, + pageSize: 20, + totalPages: 0, + undatedCount: 0 + }, + interpretation +}; + +test.describe('NL (smart) search — happy path', () => { + test('toggle → loading → chips → remove chip re-runs keyword search; axe clean light + dark', async ({ + page + }) => { + // Deliberate delay so the loading state is assertable before the response arrives. + await page.route('**/api/search/nl', async (route) => { + await new Promise((resolve) => setTimeout(resolve, 150)); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(nlResponse) + }); + }); + + await page.goto('/documents'); + await page.waitForSelector('[data-hydrated]'); + + // Switch to smart mode via the toggle pill (keyword label = "Text"). + await page.getByRole('button', { name: /Text/ }).click(); + + const input = page.getByPlaceholder('Titel, Personen, Tags durchsuchen…'); + await input.fill('Was hat Walter an Emma im Krieg geschrieben?'); + await input.press('Enter'); + + // Loading panel announced to screen readers. + await expect(page.getByText(/Archiv wird befragt/)).toBeVisible(); + + // Directional chip (Walter → Emma) + keyword chip render once the fixture resolves. + await expect(page.getByText('→')).toBeVisible(); + await expect(page.getByText('Stichwort: krieg')).toBeVisible(); + + // Accessibility — light mode. + const lightScan = await new AxeBuilder({ page }) + .include('[data-testid="smart-search-results"]') + .analyze(); + expect(lightScan.violations).toEqual([]); + + // Accessibility — dark mode. + await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark')); + const darkScan = await new AxeBuilder({ page }) + .include('[data-testid="smart-search-results"]') + .analyze(); + expect(darkScan.violations).toEqual([]); + await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'light')); + + // Removing the keyword chip re-runs a keyword GET with the remaining resolved + // params (sender + receiver from the directional pair). + await page.getByRole('button', { name: 'Filter entfernen: Stichwort: krieg' }).click(); + await page.waitForURL(/senderId=11111111-1111-1111-1111-111111111111/); + await expect(page).toHaveURL(/receiverId=22222222-2222-2222-2222-222222222222/); + }); +}); diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte index 2d287140..b9a2ece5 100644 --- a/frontend/src/routes/documents/+page.svelte +++ b/frontend/src/routes/documents/+page.svelte @@ -422,48 +422,50 @@ $effect(() => { {#if showNlView} - {#if nlLoading} - - {:else if nlError} - - {:else if nlInterpretation} - {#key nlInterpretation} -
- {#if nlIsAmbiguous} - - {:else} - - {/if} -
+
+ {#if nlLoading} + + {:else if nlError} + + {:else if nlInterpretation} + {#key nlInterpretation} +
+ {#if nlIsAmbiguous} + + {:else} + + {/if} +
- {#if !nlIsAmbiguous} - {#if nlHasResults} -

- {m.docs_result_count({ count: nlResult?.totalElements ?? 0 })} -

- - {:else} -
-

{m.search_empty_nl()}

- -
+ {#if !nlIsAmbiguous} + {#if nlHasResults} +

+ {m.docs_result_count({ count: nlResult?.totalElements ?? 0 })} +

+ + {:else} +
+

{m.search_empty_nl()}

+ +
+ {/if} {/if} - {/if} - {/key} - {/if} + {/key} + {/if} +
{:else}

{m.search_loading_nl()}

-

+

{m.search_loading_nl_sub()}

-- 2.49.1 From 87af9ab4466f22dee5b3f9da56669dc89df3c9d4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 18:27:00 +0200 Subject: [PATCH 12/12] docs(c4): add smart-search components to l3-frontend diagram (#739 review) Markus (architect): document SearchFilterBar + the search/ components (SmartModeToggle, InterpretationChipRow, SmartSearchStatus, DisambiguationPicker) and the POST /api/search/nl relation. Co-Authored-By: Claude Opus 4.8 --- .../c4/l3-frontend-3b-document-workflows.puml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/architecture/c4/l3-frontend-3b-document-workflows.puml b/docs/architecture/c4/l3-frontend-3b-document-workflows.puml index 71407f27..4b521b0b 100644 --- a/docs/architecture/c4/l3-frontend-3b-document-workflows.puml +++ b/docs/architecture/c4/l3-frontend-3b-document-workflows.puml @@ -10,6 +10,11 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") { Component(homePage, "/ (Home / Search)", "SvelteKit Route", "Loader: parses URL params (q, from, to, senderId, receiverId, tags), fetches /api/documents/search and /api/persons. Renders search form with full-text, date range, sender/receiver typeahead, and tag filters.") Component(docsListPageTs, "/documents/+page.ts", "SvelteKit Client Loader", "Client-side load gated by matchMedia('(min-width: 1024px)') and ?view query. Fetches /api/documents/density only on desktop (Tailwind lg breakpoint) and outside calendar view; degrades to empty buckets on network failure.") Component(timelineFilter, "TimelineDensityFilter.svelte", "Svelte Component", "Per-month density bars above the document list. Click selects a single month, emits onchange({from, to}) using YYYY-MM-DD boundaries. Hidden on mobile and tablet (below lg, 1024px) and in calendar view.") + Component(searchFilterBar, "SearchFilterBar.svelte", "Svelte Component", "Search/filter card on /documents. Hosts the keyword input, sort, advanced filters, and the smart-mode toggle. In smart mode submits the NL query on Enter via onSmartSearch instead of the live keyword search.") + Component(smartToggle, "search/SmartModeToggle.svelte", "Svelte Component", "Toggle pill (KI/Text) inside the search input. aria-pressed; switches between keyword and NL (smart) search modes.") + Component(chipRow, "search/InterpretationChipRow.svelte", "Svelte Component", "Renders NL interpretation chips (Absender / directional / Zeitraum / Stichwort). Removing a chip emits onRemoveChip; the page re-runs a keyword GET with the remaining params.") + Component(smartStatus, "search/SmartSearchStatus.svelte", "Svelte Component", "Full-area panels for NL search: loading (role=status), 503 SMART_SEARCH_UNAVAILABLE (with keyword fallback), 429 SMART_SEARCH_RATE_LIMITED.") + Component(disambig, "search/DisambiguationPicker.svelte", "Svelte Component", "Accessible single-select disclosure for ambiguous person names; selecting a candidate re-runs the search via GET.") Component(docDetail, "/documents/[id]", "SvelteKit Route", "Loader: GET /api/documents/{id}. Page: metadata panel, inline file viewer, transcription editor, annotation layer, and comment thread.") Component(docEdit, "/documents/[id]/edit", "SvelteKit Route", "Edit form with PersonTypeahead, TagInput, date/location fields. Form action: PUT /api/documents/{id}.") Component(docNew, "/documents/new", "SvelteKit Route", "Upload form for a new document. Loader: GET /api/persons. Form action: POST /api/documents with multipart file.") @@ -25,6 +30,12 @@ Rel(user, homePage, "Searches and browses", "HTTPS / Browser") Rel(homePage, backend, "GET /api/documents/search, GET /api/persons", "HTTP / JSON") Rel(docsListPageTs, backend, "GET /api/documents/density (desktop only, ≥1024px)", "HTTP / JSON") Rel(homePage, timelineFilter, "Mounts above the result list") +Rel(homePage, searchFilterBar, "Mounts the search/filter card") +Rel(searchFilterBar, smartToggle, "Embeds the smart-mode toggle in the input") +Rel(homePage, backend, "POST /api/search/nl (smart mode)", "HTTP / JSON") +Rel(homePage, smartStatus, "Renders loading / 503 / 429 panels") +Rel(homePage, chipRow, "Renders interpretation chips; handles chip removal") +Rel(homePage, disambig, "Renders the picker when names are ambiguous") Rel(docsListPageTs, timelineFilter, "Provides density / minDate / maxDate props") Rel(docDetail, backend, "GET /api/documents/{id}, GET /api/documents/{id}/file", "HTTP / JSON + Binary") Rel(docEdit, backend, "PUT /api/documents/{id}", "HTTP / Multipart") -- 2.49.1