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}
-
- {m.docs_result_count({ count: nlResult?.totalElements ?? 0 })} -
-{m.search_empty_nl()}
- -+ {m.docs_result_count({ count: nlResult?.totalElements ?? 0 })} +
+{m.search_empty_nl()}
+ +