` block through the closing `{/if}`, unwrapped. The result should be:
-
-```svelte
-
-
-
-
-
- ... (keep as-is)
-
-
-
-
-
-```
-
-- [ ] **Step 6: Verify frontend type-checks cleanly for this file**
-
-```bash
-cd frontend && source ~/.nvm/nvm.sh && npm run check 2>&1 | grep "documents/+page.svelte"
-```
-
-Expected: no errors for `+page.svelte` (pre-existing errors in other files are acceptable).
-
-- [ ] **Step 7: Commit**
-
-```bash
-git add frontend/src/routes/documents/+page.svelte
-git commit -m "refactor(search): remove NLP smart search from documents page"
-```
-
----
-
-### Task 6: Remove error codes from frontend errors.ts
-
-**Files:**
-- Modify: `frontend/src/lib/shared/errors.ts`
-
-- [ ] **Step 1: Remove error code union members (lines 56–57)**
-
-Remove from the `ErrorCode` type union:
-```typescript
- | 'SMART_SEARCH_UNAVAILABLE'
- | 'SMART_SEARCH_RATE_LIMITED'
-```
-
-- [ ] **Step 2: Remove switch cases (lines 183–186)**
-
-Remove from the `getErrorMessage()` switch:
-```typescript
- case 'SMART_SEARCH_UNAVAILABLE':
- return m.error_smart_search_unavailable();
- case 'SMART_SEARCH_RATE_LIMITED':
- return m.error_smart_search_rate_limited();
-```
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add frontend/src/lib/shared/errors.ts
-git commit -m "refactor(search): remove smart search error codes from frontend"
-```
-
----
-
-### Task 7: Remove i18n messages from all three language files
-
-**Files:**
-- Modify: `frontend/messages/de.json`
-- Modify: `frontend/messages/en.json`
-- Modify: `frontend/messages/es.json`
-
-Remove the same set of keys from each file. The keys to remove are:
-
-```
-"error_smart_search_unavailable"
-"error_smart_search_rate_limited"
-"smart_search_keywords_not_applied"
-"search_toggle_smart_label"
-"search_toggle_smart_label_suffix"
-"search_toggle_keyword_label"
-"search_toggle_keyword_label_suffix"
-"search_error_unavailable"
-"search_error_unavailable_body"
-"search_error_rate_limited"
-"search_error_rate_limited_body"
-"search_empty_nl"
-"search_empty_retry_keyword"
-"search_disambiguation_did_you_mean"
-"search_disambiguation_heading"
-```
-
-Note: not all keys may exist in all three files — remove whichever are present.
-
-- [ ] **Step 1: Remove smart-search keys from de.json**
-
-Open `frontend/messages/de.json` and delete every key listed above.
-
-- [ ] **Step 2: Remove smart-search keys from en.json**
-
-Open `frontend/messages/en.json` and delete every key listed above.
-
-- [ ] **Step 3: Remove smart-search keys from es.json**
-
-Open `frontend/messages/es.json` and delete every key listed above.
-
-- [ ] **Step 4: Verify JSON is still valid**
-
-```bash
-cd frontend && node -e "
- ['messages/de.json','messages/en.json','messages/es.json'].forEach(f => {
- try { JSON.parse(require('fs').readFileSync(f,'utf8')); console.log(f+': OK'); }
- catch(e) { console.error(f+': INVALID - '+e.message); process.exit(1); }
- })
-"
-```
-
-Expected: all three files print `OK`.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add frontend/messages/de.json frontend/messages/en.json frontend/messages/es.json
-git commit -m "refactor(search): remove smart search i18n keys from all language files"
-```
-
----
-
-### Task 8: Remove nlp-service from docker-compose files
-
-**Files:**
-- Modify: `docker-compose.yml`
-- Modify: `docker-compose.prod.yml`
-
-#### docker-compose.yml
-
-- [ ] **Step 1: Remove the nlp-service service block**
-
-Delete the entire `nlp-service:` top-level service definition (lines ~148–179, from ` nlp-service:` through its closing line before the next service).
-
-- [ ] **Step 2: Remove nlp-service from backend depends_on**
-
-Find the `backend:` service's `depends_on:` list and remove the `nlp-service:` entry.
-
-- [ ] **Step 3: Remove APP_NLP_BASE_URL from backend environment**
-
-Find the `backend:` service's `environment:` section and remove:
-```yaml
- APP_NLP_BASE_URL: "http://nlp-service:8001"
-```
-
-#### docker-compose.prod.yml
-
-- [ ] **Step 4: Remove the nlp-service service block**
-
-Delete the entire `nlp-service:` top-level service definition (lines ~206–221, from ` nlp-service:` through its closing line).
-
-- [ ] **Step 5: Remove nlp-service from backend depends_on**
-
-Find the `backend:` service's `depends_on:` list and remove the `nlp-service:` entry.
-
-- [ ] **Step 6: Remove APP_NLP_BASE_URL from backend environment**
-
-Find the `backend:` service's `environment:` section and remove:
-```yaml
- APP_NLP_BASE_URL: http://nlp-service:8001
-```
-
-- [ ] **Step 7: Validate compose files parse correctly**
-
-```bash
-docker compose -f docker-compose.yml config --quiet && echo "dev: OK"
-docker compose -f docker-compose.prod.yml config --quiet && echo "prod: OK"
-```
-
-Expected: both print `OK` (warnings are acceptable, errors are not).
-
-- [ ] **Step 8: Commit**
-
-```bash
-git add docker-compose.yml docker-compose.prod.yml
-git commit -m "refactor(infra): remove nlp-service from docker-compose files"
-```
-
----
-
-### Task 9: Remove observability config for Ollama/NLP
-
-**Files:**
-- Modify: `infra/observability/prometheus/prometheus.yml`
-- Delete: `infra/observability/grafana/provisioning/dashboards/ollama.json`
-
-- [ ] **Step 1: Remove ollama scrape job from prometheus.yml**
-
-Delete these lines from `infra/observability/prometheus/prometheus.yml`:
-```yaml
- - job_name: ollama
- static_configs:
- - targets: ['ollama:11434']
-```
-
-- [ ] **Step 2: Delete the Grafana Ollama dashboard**
-
-```bash
-rm infra/observability/grafana/provisioning/dashboards/ollama.json
-```
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add infra/observability/prometheus/prometheus.yml
-git add -A infra/observability/grafana/provisioning/dashboards/
-git commit -m "refactor(infra): remove Ollama/NLP observability config"
-```
-
----
-
-### Task 10: Delete nlp-service directory and ADR docs
-
-**Files:**
-- Delete: `nlp-service/` (entire directory)
-- Delete: `docs/adr/028-nl-search-ollama.md`
-- Delete: `docs/adr/028-ollama-docker-compose-service.md`
-- Delete: `docs/adr/034-ollama-production-deployment-and-keep-alive.md`
-- Delete: `docs/adr/035-rule-based-nlp-service.md`
-
-- [ ] **Step 1: Delete the nlp-service microservice**
-
-```bash
-rm -rf nlp-service/
-```
-
-- [ ] **Step 2: Delete NLP/Ollama ADRs**
-
-```bash
-rm docs/adr/028-nl-search-ollama.md \
- docs/adr/028-ollama-docker-compose-service.md \
- docs/adr/034-ollama-production-deployment-and-keep-alive.md \
- docs/adr/035-rule-based-nlp-service.md
-```
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add -A
-git commit -m "refactor(search): delete nlp-service microservice and Ollama ADRs"
-```
-
----
-
-### Task 11: Update CLAUDE.md files
-
-**Files:**
-- Modify: `CLAUDE.md`
-- Modify: `frontend/CLAUDE.md`
-
-#### CLAUDE.md (root)
-
-- [ ] **Step 1: Remove search package from backend package structure table**
-
-Delete this row from the Package Structure section:
-```
-├── search/ NL search domain — NlSearchController, NlQueryParserService, RestClientOllamaClient, NlSearchRateLimiter
-```
-
-- [ ] **Step 2: Remove NLP error codes from Error Handling section**
-
-In the **Error Handling** LLM reminder, remove:
-- `SMART_SEARCH_UNAVAILABLE (HTTP 503 — Ollama inference service offline or timed out)`
-- `SMART_SEARCH_RATE_LIMITED (HTTP 429 — user exceeded 5 NL search requests per minute)`
-
-from both the Backend Architecture and Frontend Architecture sections.
-
-#### frontend/CLAUDE.md
-
-- [ ] **Step 3: Update routes/search/ description**
-
-Replace:
-```
-│ ├── search/ # Smart (NL) search sub-components — SmartModeToggle, InterpretationChipRow, SmartSearchStatus, DisambiguationPicker (no +page; consumed by documents/ and SearchFilterBar)
-```
-
-With:
-```
-│ ├── search/ # (empty — NL search removed)
-```
-
-Or delete the line entirely if the directory will be empty after removing all component files.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add CLAUDE.md frontend/CLAUDE.md
-git commit -m "docs(claude): remove NLP search references from CLAUDE.md files"
-```
-
----
-
-### Task 12: Verify and regenerate API types
-
-- [ ] **Step 1: Install frontend dependencies if needed**
-
-```bash
-cd frontend && source ~/.nvm/nvm.sh && npm install
-```
-
-- [ ] **Step 2: Run frontend vitest to verify no regressions**
-
-```bash
-cd frontend && source ~/.nvm/nvm.sh && npm run test -- --reporter=verbose 2>&1 | tail -30
-```
-
-Expected: all tests pass. The deleted spec files are gone and no longer run.
-
-- [ ] **Step 3: Start backend to regenerate API types (requires Docker stack)**
-
-If backend is running locally:
-```bash
-cd frontend && source ~/.nvm/nvm.sh && npm run generate:api
-```
-
-If not running, skip — the generated types remain valid until the next backend change (no NLP types are referenced after this plan).
-
-- [ ] **Step 4: Run a final backend compile**
-
-```bash
-cd backend && source ~/.sdkman/bin/sdkman-init.sh && ./mvnw compile -q
-```
-
-Expected: BUILD SUCCESS.
-
-- [ ] **Step 5: Commit regenerated API types (if generate:api was run)**
-
-```bash
-git add frontend/src/lib/generated/
-git commit -m "chore(api): regenerate API types after NLP search removal"
-```
-
----
-
-### Task 13: Check and clean the empty search/ directory
-
-- [ ] **Step 1: Check if routes/search/ is now empty**
-
-```bash
-ls frontend/src/routes/search/
-```
-
-If only `chip-types.ts` was in there (already deleted in Task 3) and all Svelte files are gone, the directory should be empty.
-
-- [ ] **Step 2: Delete the empty directory**
-
-```bash
-rmdir frontend/src/routes/search/ 2>/dev/null && echo "deleted" || echo "not empty — check contents"
-```
-
-If not empty, list what remains and delete the stray files.
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add -A
-git commit -m "refactor(search): remove empty search/ route directory"
-```
diff --git a/frontend/e2e/nl-search.spec.ts b/frontend/e2e/nl-search.spec.ts
deleted file mode 100644
index 2bebecee..00000000
--- a/frontend/e2e/nl-search.spec.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-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'],
- resolvedTags: [{ id: '33333333-3333-3333-3333-333333333333', name: 'Weltkrieg', color: 'sage' }],
- rawQuery: 'Was hat Walter an Emma im Krieg geschrieben?',
- keywordsApplied: true,
- tagsApplied: 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) => {
- const body = route.request().postDataJSON();
- expect(body.lang).toBeTruthy();
- 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 + theme chip render once the fixture resolves.
- await expect(page.getByText('→')).toBeVisible();
- await expect(page.getByText('Stichwort: krieg')).toBeVisible();
- await expect(page.getByText(/Thema:.*Weltkrieg/)).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/);
- });
-
- test('removing the last theme chip drops tag/tagOp but keeps person params', async ({ page }) => {
- await page.route('**/api/search/nl', async (route) => {
- await route.fulfill({
- status: 200,
- contentType: 'application/json',
- body: JSON.stringify(nlResponse)
- });
- });
-
- await page.goto('/documents');
- await page.waitForSelector('[data-hydrated]');
- 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');
-
- await expect(page.getByText(/Thema:.*Weltkrieg/)).toBeVisible();
-
- // Remove the single theme chip — URL must carry sender UUID but no tag/tagOp.
- await page.getByRole('button', { name: 'Filter entfernen: Thema: Weltkrieg' }).click();
- await page.waitForURL(/senderId=11111111-1111-1111-1111-111111111111/);
- const url = page.url();
- expect(url).not.toMatch(/tag=/);
- expect(url).not.toMatch(/tagOp=/);
- });
-});
diff --git a/frontend/messages/de.json b/frontend/messages/de.json
index eaa970eb..32f6e464 100644
--- a/frontend/messages/de.json
+++ b/frontend/messages/de.json
@@ -22,18 +22,6 @@
"error_forbidden": "Sie haben keine Berechtigung für diese Aktion.",
"error_csrf_token_missing": "Sitzungsfehler. Bitte laden Sie die Seite neu.",
"error_too_many_login_attempts": "Zu viele Anmeldeversuche. Bitte versuchen Sie es später erneut.",
- "search_loading_nl": "Archiv wird befragt…",
- "search_loading_nl_sub": "Die Anfrage wird analysiert…",
- "search_switch_to_keyword": "Zur Volltextsuche wechseln",
- "search_filter_remove_label": "Filter entfernen: {label}",
- "search_chip_sender": "Absender",
- "search_chip_date": "Zeitraum",
- "search_chip_keyword": "Stichwort",
- "search_chip_theme_prefix": "Thema",
- "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_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 1534ebc7..435860e1 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -22,18 +22,6 @@
"error_forbidden": "You do not have permission for this action.",
"error_csrf_token_missing": "Session error. Please reload the page.",
"error_too_many_login_attempts": "Too many login attempts. Please try again later.",
- "search_loading_nl": "Querying the archive…",
- "search_loading_nl_sub": "Your request is being analysed…",
- "search_switch_to_keyword": "Switch to full-text search",
- "search_filter_remove_label": "Remove filter: {label}",
- "search_chip_sender": "Sender",
- "search_chip_date": "Period",
- "search_chip_keyword": "Keyword",
- "search_chip_theme_prefix": "Topic",
- "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_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 60f7a2a8..88e2affb 100644
--- a/frontend/messages/es.json
+++ b/frontend/messages/es.json
@@ -22,18 +22,6 @@
"error_forbidden": "No tiene permiso para realizar esta acción.",
"error_csrf_token_missing": "Error de sesión. Recargue la página.",
"error_too_many_login_attempts": "Demasiados intentos. Por favor, inténtelo más tarde.",
- "search_loading_nl": "Consultando el archivo…",
- "search_loading_nl_sub": "Su solicitud está siendo analizada…",
- "search_switch_to_keyword": "Cambiar a 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_theme_prefix": "Tema",
- "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_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",
diff --git a/frontend/src/routes/SearchFilterBar.svelte b/frontend/src/routes/SearchFilterBar.svelte
index 4817d0c6..62a9ee11 100644
--- a/frontend/src/routes/SearchFilterBar.svelte
+++ b/frontend/src/routes/SearchFilterBar.svelte
@@ -81,7 +81,7 @@ $effect(() => {
onblur={onblur}
aria-label={m.docs_search_placeholder()}
placeholder={m.docs_search_placeholder()}
- class="block w-full border-line py-2.5 pr-20 pl-10 placeholder-ink-3 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
+ class="block w-full border-line py-2.5 pr-4 pl-10 placeholder-ink-3 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
{#if isLoading}