feat(search): NL search frontend — toggle, chips, disambiguation, empty state (#739) #757

Merged
marcel merged 12 commits from feat/issue-739-nl-search-frontend into main 2026-06-06 18:40:35 +02:00
Owner

Closes #739.

Depends on #738 (merged) + npm run generate:api already run — NlQueryInterpretation, NlSearchResponse, PersonHint exist in src/lib/generated/api.ts. #738's PR updated CLAUDE.md, the l3-backend-search.puml diagram for the new search/ package, and added the Prometheus histogram for /api/search/nl.

What this adds

A smart-search mode on the /documents page. One input, one toggle pill inside it; keyword mode is unchanged. LLM parsing happens server-side — the frontend sends the query, renders interpretation chips, and handles loading / empty / error states.

New components (src/routes/search/)

  • SmartModeToggle.svelte — toggle pill with aria-pressed, active style matching the AND/OR operator button, mobile-expanded KI/Text labels.
  • InterpretationChipRow.svelte — type-prefixed chips (Absender: / Zeitraum: / Stichwort:), a single directional chip for 2-name queries (Walter → Emma), keyword chips gated on keywordsApplied, onRemoveChip(type, value?), truncating name spans with a 44px × button, focus ring on the chip wrapper.
  • SmartSearchStatus.svelte — full-area panels: loading (role="status", motion-safe: spinner + pulse), 503 (red icon + switch-to-keyword button), 429 (amber clock, no button).
  • DisambiguationPicker.svelte — accessible single-select disclosure: aria-expanded/aria-controls, focus moves into the list on open, Escape/click-outside close and return focus to the trigger.

Wiring

  • SearchFilterBar.svelte gains smartMode ($bindable) + onSmartSearch/onModeToggle. Smart mode disables the live oninput keyword search, adds maxlength="500", and submits the NL query on Enter.
  • documents/+page.svelte lifts smartMode, runs POST /api/search/nl via csrfFetch + parseBackendError, and renders the NL view (status / chips / disambiguation / results / empty) vs. the normal DocumentList. Chip removal and disambiguation selection map the interpretation to keyword params and re-run via GET /api/documents/search (Option A in-page fallback).
  • i18n keys added to messages/{de,en,es}.json (formal Sie per project standard).
  • frontend/CLAUDE.md Project Structure documents src/routes/search/.

Resolved open decision (disambiguation)

The shipped backend (#738) added only single-person searchDocumentsByPersonId — there is no senderIds[] array param, so multi-sender OR is not supported. Per discussion with the maintainer, the picker is single-select: choosing a candidate sets senderId (and receiverId when a resolved partner exists) and re-runs via GET. One sender + one receiver (directional) is fully supported by the existing GET search.

Tests

  • 44 vitest-browser-svelte specs across the 5 components (SmartModeToggle 8, InterpretationChipRow 9, SmartSearchStatus 5, DisambiguationPicker 5, SearchFilterBar 17 incl. smart-mode lifecycle).
  • 1 Playwright E2E happy-path (e2e/nl-search.spec.ts): mocked /api/search/nl → loading → chips → axe-clean (light + dark) → remove chip re-runs GET with remaining params. Ollama not required in CI.
  • npm run build clean; svelte-check introduces no new errors (the single onclickoutside typing gap matches the established pattern in 9 existing components).

Notes

  • Smart mode resets on page navigation (UI-local) — accepted limitation per the issue.
  • CSRF is exercised in manual full-stack runs only; page.route intercepts before the real request in CI.

🤖 Generated with Claude Code

Closes #739. Depends on #738 (merged) + `npm run generate:api` already run — `NlQueryInterpretation`, `NlSearchResponse`, `PersonHint` exist in `src/lib/generated/api.ts`. #738's PR updated `CLAUDE.md`, the `l3-backend-search.puml` diagram for the new `search/` package, and added the Prometheus histogram for `/api/search/nl`. ## What this adds A smart-search mode on the `/documents` page. One input, one toggle pill inside it; keyword mode is unchanged. LLM parsing happens server-side — the frontend sends the query, renders interpretation chips, and handles loading / empty / error states. ### New components (`src/routes/search/`) - **`SmartModeToggle.svelte`** — toggle pill with `aria-pressed`, active style matching the AND/OR operator button, mobile-expanded KI/Text labels. - **`InterpretationChipRow.svelte`** — type-prefixed chips (`Absender:` / `Zeitraum:` / `Stichwort:`), a single directional chip for 2-name queries (`Walter → Emma`), keyword chips gated on `keywordsApplied`, `onRemoveChip(type, value?)`, truncating name spans with a 44px × button, focus ring on the chip wrapper. - **`SmartSearchStatus.svelte`** — full-area panels: loading (`role="status"`, `motion-safe:` spinner + pulse), 503 (red icon + switch-to-keyword button), 429 (amber clock, no button). - **`DisambiguationPicker.svelte`** — accessible single-select disclosure: `aria-expanded`/`aria-controls`, focus moves into the list on open, Escape/click-outside close and return focus to the trigger. ### Wiring - `SearchFilterBar.svelte` gains `smartMode` (`$bindable`) + `onSmartSearch`/`onModeToggle`. Smart mode disables the live `oninput` keyword search, adds `maxlength="500"`, and submits the NL query on Enter. - `documents/+page.svelte` lifts `smartMode`, runs `POST /api/search/nl` via `csrfFetch` + `parseBackendError`, and renders the NL view (status / chips / disambiguation / results / empty) vs. the normal `DocumentList`. Chip removal and disambiguation selection map the interpretation to keyword params and re-run via `GET /api/documents/search` (Option A in-page fallback). - i18n keys added to `messages/{de,en,es}.json` (formal **Sie** per project standard). - `frontend/CLAUDE.md` Project Structure documents `src/routes/search/`. ## Resolved open decision (disambiguation) The shipped backend (#738) added only single-person `searchDocumentsByPersonId` — there is **no `senderIds[]` array param**, so multi-sender OR is not supported. Per discussion with the maintainer, the picker is **single-select**: choosing a candidate sets `senderId` (and `receiverId` when a resolved partner exists) and re-runs via GET. One sender + one receiver (directional) is fully supported by the existing GET search. ## Tests - 44 vitest-browser-svelte specs across the 5 components (`SmartModeToggle` 8, `InterpretationChipRow` 9, `SmartSearchStatus` 5, `DisambiguationPicker` 5, `SearchFilterBar` 17 incl. smart-mode lifecycle). - 1 Playwright E2E happy-path (`e2e/nl-search.spec.ts`): mocked `/api/search/nl` → loading → chips → axe-clean (light + dark) → remove chip re-runs GET with remaining params. Ollama not required in CI. - `npm run build` clean; `svelte-check` introduces no new errors (the single `onclickoutside` typing gap matches the established pattern in 9 existing components). ## Notes - Smart mode resets on page navigation (UI-local) — accepted limitation per the issue. - CSRF is exercised in manual full-stack runs only; `page.route` intercepts before the real request in CI. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 10 commits 2026-06-06 17:59:50 +02:00
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
SearchFilterBar drives chip-clearing via onModeToggle (mode switch) and
onSmartSearch (new query); pin that callback contract.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the smart-search sub-component directory to the frontend Project
Structure tree (merge blocker per #739).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
test(search): add NL search happy-path Playwright E2E (#739)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m17s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m47s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 24s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
230f23e37c
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 <noreply@anthropic.com>
Author
Owner

🎨 Leonie Voss — Senior UX Designer & Accessibility Strategist

Verdict: 🚫 Changes requested

I reviewed brand compliance, WCAG AA, touch targets at 320px, focus visibility, redundant cues, dark mode, and the icon-button labelling. The keyboard/ARIA wiring is genuinely strong — the disambiguation disclosure (focus-into-list on open, Escape returns focus to trigger, aria-expanded/aria-controls) is textbook, every icon-only × button carries an aria-label, focus rings use focus-visible, and the loading panel respects motion-safe:. But two font sizes break my hardest constraint, and that blocks merge.

Blockers

  1. Font size text-[7.5px] on the toggle pill — SmartModeToggle.svelte:18. My rule is absolute: no visible text below 12px, and the senior audience (60+) needs ≥16px for chrome they must read. 7.5px is unreadable for the people this archive exists for. The toggle is also the primary affordance for the whole feature — it cannot be the smallest text on the page. Fix: bump to at least text-xs (12px). If the pill then crowds the input, shrink horizontal padding or move the keyword/AI suffix behind sm: rather than shrinking the glyph.

  2. Font size text-[9px] on the loading sub-text — SmartSearchStatus.svelte:43. Same criterion. "Die KI analysiert Ihre Anfrage…" is exactly the reassurance a nervous senior user needs during a 15-second wait, and at 9px they cannot read it. Fix: text-xs minimum, text-sm preferred. Also: the motion-safe:animate-pulse on body copy makes small text harder to read — consider dropping the pulse on the text and keeping it only on the spinner.

Suggestions

  • Contrast of border-primary/12 (loading spinner track, SmartSearchStatus.svelte:38) — decorative only, so not a blocker, but verify the spinner is still perceivable in dark mode where the track may vanish against the surface.
  • Directional chip arrow is aria-hidden and the chip wrapper carries search_chip_directional_label — good. But the visible chip has no type prefix (unlike "Absender:"/"Stichwort:"). A sighted user sees "Walter → Emma" with no cue it's a sender→receiver relationship. Consider a small leading icon or label so the meaning isn't carried by the arrow alone.
  • Empty-state and error panels use min-h-[44px] buttons and redundant icon+text cues (the 503 ! / 429 clock) — nicely done, exactly the redundant-cue pattern I ask for.
  • The axe scan in the E2E runs light and dark — appreciated. It only covers the happy-path chips area though; the error/empty/loading panels are never axe-scanned (see Sara's note).

Fix the two sub-12px sizes and I approve.

## 🎨 Leonie Voss — Senior UX Designer & Accessibility Strategist **Verdict: 🚫 Changes requested** I reviewed brand compliance, WCAG AA, touch targets at 320px, focus visibility, redundant cues, dark mode, and the icon-button labelling. The keyboard/ARIA wiring is genuinely strong — the disambiguation disclosure (focus-into-list on open, Escape returns focus to trigger, `aria-expanded`/`aria-controls`) is textbook, every icon-only × button carries an `aria-label`, focus rings use `focus-visible`, and the loading panel respects `motion-safe:`. But two font sizes break my hardest constraint, and that blocks merge. ### Blockers 1. **Font size `text-[7.5px]` on the toggle pill — `SmartModeToggle.svelte:18`.** My rule is absolute: no visible text below 12px, and the senior audience (60+) needs ≥16px for chrome they must read. 7.5px is unreadable for the people this archive exists for. The toggle is also the *primary affordance* for the whole feature — it cannot be the smallest text on the page. Fix: bump to at least `text-xs` (12px). If the pill then crowds the input, shrink horizontal padding or move the keyword/AI suffix behind `sm:` rather than shrinking the glyph. 2. **Font size `text-[9px]` on the loading sub-text — `SmartSearchStatus.svelte:43`.** Same criterion. "Die KI analysiert Ihre Anfrage…" is exactly the reassurance a nervous senior user needs during a 15-second wait, and at 9px they cannot read it. Fix: `text-xs` minimum, `text-sm` preferred. Also: the `motion-safe:animate-pulse` on body copy makes small text *harder* to read — consider dropping the pulse on the text and keeping it only on the spinner. ### Suggestions - **Contrast of `border-primary/12`** (loading spinner track, `SmartSearchStatus.svelte:38`) — decorative only, so not a blocker, but verify the spinner is still perceivable in dark mode where the track may vanish against the surface. - **Directional chip arrow `→` is `aria-hidden`** and the chip wrapper carries `search_chip_directional_label` — good. But the *visible* chip has no type prefix (unlike "Absender:"/"Stichwort:"). A sighted user sees "Walter → Emma" with no cue it's a sender→receiver relationship. Consider a small leading icon or label so the meaning isn't carried by the arrow alone. - **Empty-state and error panels** use `min-h-[44px]` buttons and redundant icon+text cues (the 503 `!` / 429 clock) — nicely done, exactly the redundant-cue pattern I ask for. - The axe scan in the E2E runs light **and** dark — appreciated. It only covers the happy-path chips area though; the error/empty/loading panels are never axe-scanned (see Sara's note). Fix the two sub-12px sizes and I approve.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

I checked TDD evidence, naming, function size, guard clauses, Svelte 5 idioms (keyed {#each}, $derived over $effect, reactive collections), component splitting, and error handling. This is clean, idiomatic Svelte 5. Components are split by visual region (SmartModeToggle, InterpretationChipRow, SmartSearchStatus, DisambiguationPicker), every {#each} is keyed ((person.id), (chip.key)), $derived/$derived.by is used throughout (no $state+$effect derivation anti-pattern), and SvelteSet is used for the reactive removed set. Tests precede behaviour and cover happy + error + empty + edge (100-char name). Good work.

Blockers

None.

Suggestions

  1. Hardcoded element id in DisambiguationPicker.svelte:14const panelId = 'disambiguation-panel'. This is fine today because only one picker mounts. But it's a latent bug: if two pickers ever co-exist, aria-controls/id collide and the disclosure breaks. Cheap fix: const panelId = $props.id?.() isn't available, so use import { nanoid } or $state(crypto.randomUUID()) / Svelte's $props-free unique id. Low cost, removes a footgun.

  2. runSmartSearch (documents/+page.svelte) bypasses the typed createApiClient and uses csrfFetch + res.json() with a manual cast body: NlSearchResponse. I understand why — this is an interactive client-side POST, not an SSR load, and csrfFetch is the established client-hook pattern. But you lose the openapi-typed response contract: await res.json() is any, cast to NlSearchResponse. If the backend shape drifts, TS won't catch it. Not a blocker (the cast is at least named), but consider whether createApiClient(fetch).POST('/api/search/nl', …) works here for compile-time safety. You do correctly check !res.ok before reading the body — good, that's the rule.

  3. runSmartSearch resets state in two places — lines set nlInterpretation = null; nlResult = null; both in the function body and resetNlState() exists separately. The manual inline resets at the top of runSmartSearch duplicate most of resetNlState() except nlSubmitted/nlLoading. Consider calling resetNlState() then setting nlSubmitted = true; nlLoading = true; to keep one source of truth for "what fields make up NL state."

  4. selectDisambiguated and removeChip both rebuild params via paramsFromInterpretation except selectDisambiguated inlines its own object literal rather than reusing the helper. Minor DRY: it could start from paramsFromInterpretation(nlInterpretation) and override sender/receiver. Reads as two slightly-different copies of the same mapping right now.

  5. text-[7.5px]/text-[9px] — flagged by Leonie as a11y blockers; from my side they're also magic arbitrary values that will look out of place next to the token scale. Prefer text-xs.

Nothing here blocks merge from a code-correctness standpoint; address #1 before the picker is ever reused.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** I checked TDD evidence, naming, function size, guard clauses, Svelte 5 idioms (keyed `{#each}`, `$derived` over `$effect`, reactive collections), component splitting, and error handling. This is clean, idiomatic Svelte 5. Components are split by visual region (`SmartModeToggle`, `InterpretationChipRow`, `SmartSearchStatus`, `DisambiguationPicker`), every `{#each}` is keyed (`(person.id)`, `(chip.key)`), `$derived`/`$derived.by` is used throughout (no `$state`+`$effect` derivation anti-pattern), and `SvelteSet` is used for the reactive `removed` set. Tests precede behaviour and cover happy + error + empty + edge (100-char name). Good work. ### Blockers None. ### Suggestions 1. **Hardcoded element id in `DisambiguationPicker.svelte:14` — `const panelId = 'disambiguation-panel'`.** This is fine today because only one picker mounts. But it's a latent bug: if two pickers ever co-exist, `aria-controls`/`id` collide and the disclosure breaks. Cheap fix: `const panelId = $props.id?.()` isn't available, so use `import { nanoid }` or `$state(crypto.randomUUID())` / Svelte's `$props`-free unique id. Low cost, removes a footgun. 2. **`runSmartSearch` (documents/+page.svelte) bypasses the typed `createApiClient` and uses `csrfFetch` + `res.json()` with a manual cast `body: NlSearchResponse`.** I understand why — this is an interactive client-side POST, not an SSR load, and `csrfFetch` is the established client-hook pattern. But you lose the openapi-typed response contract: `await res.json()` is `any`, cast to `NlSearchResponse`. If the backend shape drifts, TS won't catch it. Not a blocker (the cast is at least named), but consider whether `createApiClient(fetch).POST('/api/search/nl', …)` works here for compile-time safety. You do correctly check `!res.ok` before reading the body — good, that's the rule. 3. **`runSmartSearch` resets state in two places** — lines set `nlInterpretation = null; nlResult = null;` both in the function body and `resetNlState()` exists separately. The manual inline resets at the top of `runSmartSearch` duplicate most of `resetNlState()` except `nlSubmitted`/`nlLoading`. Consider calling `resetNlState()` then setting `nlSubmitted = true; nlLoading = true;` to keep one source of truth for "what fields make up NL state." 4. **`selectDisambiguated` and `removeChip` both rebuild params via `paramsFromInterpretation`** except `selectDisambiguated` inlines its own object literal rather than reusing the helper. Minor DRY: it could start from `paramsFromInterpretation(nlInterpretation)` and override sender/receiver. Reads as two slightly-different copies of the same mapping right now. 5. **`text-[7.5px]`/`text-[9px]`** — flagged by Leonie as a11y blockers; from my side they're also magic arbitrary values that will look out of place next to the token scale. Prefer `text-xs`. Nothing here blocks merge from a code-correctness standpoint; address #1 before the picker is ever reused.
Author
Owner

🏛️ Markus Keller — Senior Application Architect

Verdict: ⚠️ Approved with concerns

I reviewed module boundaries, accidental complexity, layer placement, transport choices, and — per my checklist — documentation currency. Structurally this is sound: the new search/ route folder houses presentation-only sub-components (no +page), consumed by documents/+page.svelte and SearchFilterBar.svelte. No new transport — it reuses HTTP/REST (POST /api/search/nl from #738, GET /api/documents/search for the fallback), which is the correct default. The page orchestrates state and children render; that's the right altitude.

Blockers

  1. Doc currency: l3-frontend-*.puml not updated for the new route folder. My rule table is explicit: "New SvelteKit route → CLAUDE.md route table + matching docs/architecture/c4/l3-frontend-*.puml." frontend/CLAUDE.md was updated (good — the search/ line is there), but the PR body and diff show no corresponding C4 L3 frontend diagram change. Even though search/ has no +page (it's a sub-component bundle), it is a new structural unit consumed across two routes and warrants a node on the frontend container diagram. If the team's convention is that +page-less folders are exempt, say so in the PR and reference the convention; otherwise update the diagram. A doc omission is a blocker until the diagram matches the code, or the exemption is documented.

Suggestions

  1. Decision worth an ADR-lite note (not necessarily a full ADR). The "single-select disambiguation, no senderIds[] array" decision (because #738 shipped only single-person search) is exactly the kind of constraint-driven design choice that future developers will reverse out of ignorance — "why didn't they support multi-sender OR?" The PR body explains it well; capturing one paragraph in docs/GLOSSARY.md or a short ADR ("NL search resolves to single sender + single receiver; multi-sender OR deferred pending backend senderIds[]") preserves the why. Lightweight, high future-value.

  2. Client-side fetch vs. SSR load — boundary observation, not a violation. The codebase's default is data-via-+page.server.ts. This feature deliberately fetches client-side (csrfFetch) because NL search is an interactive, post-load action. That's a legitimate "interactive island" exception to SSR-first, and the existing csrfFetch helper exists precisely for this. No change needed — flagging only so the boundary deviation is a conscious, documented one rather than drift.

  3. The in-page "Option A" fallback (map interpretation → keyword params → re-run via GET) keeps all NL complexity in one orchestrator and reuses the existing search endpoint instead of inventing new ones. Good restraint — no premature backend surface added.

Fix the diagram (or document the exemption) and this is an approve.

## 🏛️ Markus Keller — Senior Application Architect **Verdict: ⚠️ Approved with concerns** I reviewed module boundaries, accidental complexity, layer placement, transport choices, and — per my checklist — documentation currency. Structurally this is sound: the new `search/` route folder houses presentation-only sub-components (no `+page`), consumed by `documents/+page.svelte` and `SearchFilterBar.svelte`. No new transport — it reuses HTTP/REST (`POST /api/search/nl` from #738, `GET /api/documents/search` for the fallback), which is the correct default. The page orchestrates state and children render; that's the right altitude. ### Blockers 1. **Doc currency: `l3-frontend-*.puml` not updated for the new route folder.** My rule table is explicit: *"New SvelteKit route → `CLAUDE.md` route table + matching `docs/architecture/c4/l3-frontend-*.puml`."* `frontend/CLAUDE.md` was updated (good — the `search/` line is there), but the PR body and diff show no corresponding C4 L3 frontend diagram change. Even though `search/` has no `+page` (it's a sub-component bundle), it is a new structural unit consumed across two routes and warrants a node on the frontend container diagram. If the team's convention is that `+page`-less folders are exempt, say so in the PR and reference the convention; otherwise update the diagram. A doc omission is a blocker until the diagram matches the code, or the exemption is documented. ### Suggestions 1. **Decision worth an ADR-lite note (not necessarily a full ADR).** The "single-select disambiguation, no `senderIds[]` array" decision (because #738 shipped only single-person search) is exactly the kind of constraint-driven design choice that future developers will reverse out of ignorance — "why didn't they support multi-sender OR?" The PR body explains it well; capturing one paragraph in `docs/GLOSSARY.md` or a short ADR ("NL search resolves to single sender + single receiver; multi-sender OR deferred pending backend `senderIds[]`") preserves the *why*. Lightweight, high future-value. 2. **Client-side fetch vs. SSR load — boundary observation, not a violation.** The codebase's default is data-via-`+page.server.ts`. This feature deliberately fetches client-side (`csrfFetch`) because NL search is an interactive, post-load action. That's a legitimate "interactive island" exception to SSR-first, and the existing `csrfFetch` helper exists precisely for this. No change needed — flagging only so the boundary deviation is a conscious, documented one rather than drift. 3. The in-page "Option A" fallback (map interpretation → keyword params → re-run via GET) keeps all NL complexity in one orchestrator and reuses the existing search endpoint instead of inventing new ones. Good restraint — no premature backend surface added. Fix the diagram (or document the exemption) and this is an approve.
Author
Owner

🧪 Sara Holt — Senior QA Engineer

Verdict: ⚠️ Approved with concerns

I reviewed the test pyramid shape, coverage of happy/error/empty/edge states, determinism, and the E2E scope. The unit layer is strong: 44 vitest-browser-svelte specs against real DOM, behaviour-focused (getByRole/getByText, not internal state), with factory helpers (makeInterpretation, makePerson) and one-behaviour-per-test naming that reads as sentences. Error and empty states are covered at the unit layer (503 panel, 429 panel, empty keyword chips, keywordsApplied=false, 100-char name). That's the right layer for permutations. Good pyramid discipline.

Blockers

None — but two gaps below are close to the line.

Suggestions

  1. E2E covers only the happy path; the two error states and the disambiguation flow have no integration/E2E coverage. nl-search.spec.ts tests toggle → loading → chips → remove-chip-re-runs-GET, which is the right critical journey. But the 503 "switch to keyword" fallback and the 429 rate-limit panel are user-facing recovery paths — the kind of thing that silently breaks when someone refactors the error mapping in runSmartSearch. They're unit-tested in isolation (SmartSearchStatus.svelte.spec.ts), but nothing verifies that a real 503 from /api/search/nl actually renders that panel wired through the page. One extra page.route fulfilling { status: 503, body: { code: 'SMART_SEARCH_UNAVAILABLE' } } and asserting the panel + clicking the fallback would close the highest-value gap. Likewise a disambiguation page.route returning ambiguousPersons and asserting selectDisambiguated re-runs the GET. Both are cheap; the integration wiring is exactly where unit tests can't reach.

  2. axe scan scope is narrow. The E2E runs axe light+dark but only .include('[data-testid="smart-search-results"]') in the chips state. The loading, error, and empty panels never get an axe pass in any test (Leonie noted the same). Since they all render inside the same testid container, scanning during the loading delay and in an error fixture would cover them at near-zero cost.

  3. Determinism — good. The 150ms page.route delay to make the loading state assertable is the correct technique (deterministic, not Thread.sleep-equivalent racing), and waitForURL/toHaveURL use Playwright auto-wait. The dispatchEvent(KeyboardEvent('Enter')) workaround in the component specs matches the established TipTap/vitest-browser pattern. No flakiness concerns.

  4. CSRF is explicitly not exercised in CI (page.route intercepts first). The PR is honest about this. Acceptable for a mocked-boundary E2E, but it means the csrfFetch header path on /api/search/nl is only ever verified manually — worth a backend @WebMvcTest/integration assertion on the #738 side if not already present.

Add the 503 + disambiguation E2E paths when convenient; not a merge blocker given the solid unit layer.

## 🧪 Sara Holt — Senior QA Engineer **Verdict: ⚠️ Approved with concerns** I reviewed the test pyramid shape, coverage of happy/error/empty/edge states, determinism, and the E2E scope. The unit layer is strong: 44 `vitest-browser-svelte` specs against real DOM, behaviour-focused (`getByRole`/`getByText`, not internal state), with factory helpers (`makeInterpretation`, `makePerson`) and one-behaviour-per-test naming that reads as sentences. Error and empty states *are* covered at the unit layer (503 panel, 429 panel, empty keyword chips, keywordsApplied=false, 100-char name). That's the right layer for permutations. Good pyramid discipline. ### Blockers None — but two gaps below are close to the line. ### Suggestions 1. **E2E covers only the happy path; the two error states and the disambiguation flow have no integration/E2E coverage.** `nl-search.spec.ts` tests toggle → loading → chips → remove-chip-re-runs-GET, which is the right critical journey. But the 503 "switch to keyword" fallback and the 429 rate-limit panel are user-facing recovery paths — the kind of thing that silently breaks when someone refactors the error mapping in `runSmartSearch`. They're unit-tested in isolation (`SmartSearchStatus.svelte.spec.ts`), but nothing verifies that a real 503 from `/api/search/nl` actually renders that panel *wired through the page*. One extra `page.route` fulfilling `{ status: 503, body: { code: 'SMART_SEARCH_UNAVAILABLE' } }` and asserting the panel + clicking the fallback would close the highest-value gap. Likewise a disambiguation `page.route` returning `ambiguousPersons` and asserting `selectDisambiguated` re-runs the GET. Both are cheap; the integration wiring is exactly where unit tests can't reach. 2. **axe scan scope is narrow.** The E2E runs axe light+dark but only `.include('[data-testid="smart-search-results"]')` in the chips state. The loading, error, and empty panels never get an axe pass in any test (Leonie noted the same). Since they all render inside the same testid container, scanning during the loading delay and in an error fixture would cover them at near-zero cost. 3. **Determinism — good.** The 150ms `page.route` delay to make the loading state assertable is the correct technique (deterministic, not `Thread.sleep`-equivalent racing), and `waitForURL`/`toHaveURL` use Playwright auto-wait. The `dispatchEvent(KeyboardEvent('Enter'))` workaround in the component specs matches the established TipTap/vitest-browser pattern. No flakiness concerns. 4. **CSRF is explicitly not exercised in CI** (page.route intercepts first). The PR is honest about this. Acceptable for a mocked-boundary E2E, but it means the `csrfFetch` header path on `/api/search/nl` is only ever verified manually — worth a backend `@WebMvcTest`/integration assertion on the #738 side if not already present. Add the 503 + disambiguation E2E paths when convenient; not a merge blocker given the solid unit layer.
Author
Owner

🛡️ Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

I reviewed this through an adversarial lens: XSS via interpretation rendering, CSRF on the new POST, error-message leakage, input bounds, and target=_blank/external-link handling. This is a front-end-only PR against a backend (#738) that already owns auth, rate limiting, and the LLM call, so my surface is the rendering and the client request. It holds up well.

What I checked and found clean

  1. No XSS sink. Interpretation data (displayName, keywords, rawQuery) flows into the template via {...} text interpolation only — Svelte auto-escapes. No {@html}, no innerHTML, no eval. Attacker-controlled person names / keywords from the LLM are rendered as inert text. CWE-79: not present.

  2. CSRF correctly applied. runSmartSearch uses csrfFetch('/api/search/nl', { method: 'POST', … }), which injects X-XSRF-TOKEN from the XSRF-TOKEN cookie on mutating verbs. This is the project's established CSRF pattern and it's wired correctly for the one state-changing call. The PR is also honest that CSRF is only exercised in manual full-stack runs (page.route intercepts in CI) — acceptable, but I'd echo Sara: keep a backend-side CSRF/permission assertion on /api/search/nl so the contract is enforced somewhere automated.

  3. No raw backend error reflected to the user. parseBackendError(res) reads code and maps to a closed set (SMART_SEARCH_RATE_LIMITED else SMART_SEARCH_UNAVAILABLE), and the panels render i18n strings — never error.message, never SQL/stack/class names. Defaults fail closed to the generic "unavailable" copy. Good. CWE-209: not present.

  4. Input bound at the boundary. maxlength={500} in smart mode caps the NL query client-side, and runSmartSearch early-returns on query.length < 3. Client-side bounds are defense-in-depth only — the real cap must live on the backend (#738) — but this is the right belt-and-suspenders.

  5. No target="_blank" introduced, so no window.opener reverse-tabnabbing concern in this diff.

Suggestions (non-blocking)

  • The manual cast await res.json() as NlSearchResponse trusts the response shape. Not a security issue per se (Svelte still escapes on render), but if you ever move interpretation values into an attribute context or a URL, re-audit — keywords.join(' ') becoming q in a GET is currently safe because SvelteKit URL-encodes params, just keep that in mind.
  • Worth a one-line threat-model comment above runSmartSearch noting "user/LLM-derived strings are rendered as escaped text only" so the next person doesn't reach for {@html} to "render rich chips."

No vulnerabilities found. Approved.

## 🛡️ Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** I reviewed this through an adversarial lens: XSS via interpretation rendering, CSRF on the new POST, error-message leakage, input bounds, and `target=_blank`/external-link handling. This is a front-end-only PR against a backend (#738) that already owns auth, rate limiting, and the LLM call, so my surface is the rendering and the client request. It holds up well. ### What I checked and found clean 1. **No XSS sink.** Interpretation data (`displayName`, `keywords`, `rawQuery`) flows into the template via `{...}` text interpolation only — Svelte auto-escapes. No `{@html}`, no `innerHTML`, no `eval`. Attacker-controlled person names / keywords from the LLM are rendered as inert text. CWE-79: not present. 2. **CSRF correctly applied.** `runSmartSearch` uses `csrfFetch('/api/search/nl', { method: 'POST', … })`, which injects `X-XSRF-TOKEN` from the `XSRF-TOKEN` cookie on mutating verbs. This is the project's established CSRF pattern and it's wired correctly for the one state-changing call. The PR is also honest that CSRF is only exercised in manual full-stack runs (page.route intercepts in CI) — acceptable, but I'd echo Sara: keep a backend-side CSRF/permission assertion on `/api/search/nl` so the contract is enforced somewhere automated. 3. **No raw backend error reflected to the user.** `parseBackendError(res)` reads `code` and maps to a closed set (`SMART_SEARCH_RATE_LIMITED` else `SMART_SEARCH_UNAVAILABLE`), and the panels render i18n strings — never `error.message`, never SQL/stack/class names. Defaults fail closed to the generic "unavailable" copy. Good. CWE-209: not present. 4. **Input bound at the boundary.** `maxlength={500}` in smart mode caps the NL query client-side, and `runSmartSearch` early-returns on `query.length < 3`. Client-side bounds are defense-in-depth only — the real cap must live on the backend (#738) — but this is the right belt-and-suspenders. 5. **No `target="_blank"`** introduced, so no `window.opener` reverse-tabnabbing concern in this diff. ### Suggestions (non-blocking) - The manual cast `await res.json() as NlSearchResponse` trusts the response shape. Not a security issue per se (Svelte still escapes on render), but if you ever move interpretation values into an attribute context or a URL, re-audit — `keywords.join(' ')` becoming `q` in a GET is currently safe because SvelteKit URL-encodes params, just keep that in mind. - Worth a one-line threat-model comment above `runSmartSearch` noting "user/LLM-derived strings are rendered as escaped text only" so the next person doesn't reach for `{@html}` to "render rich chips." No vulnerabilities found. Approved.
Author
Owner

📋 Elicit — Requirements Engineer & Business Analyst

Verdict: ⚠️ Approved with concerns

I assessed this against the issue's intent and the Definition of Done: are all states handled, are unhappy paths covered, is the behaviour testable and unambiguous, and does anything contradict a stated requirement. The feature delivers a coherent end-to-end slice — toggle → query → interpret → act — with all four UI states (loading / populated / empty / error) present. That's the visibility-of-system-status and error-recovery heuristic satisfied. Strong functional completeness.

Requirements observations (blockers)

None at the requirements level — scope matches #739 and the deferred items are explicitly named.

Open questions / ambiguities to resolve (not merge blockers, but log them)

  1. OQ — Mode persistence is a silent behaviour change. "Smart mode resets on page navigation (away + back)" is documented as an accepted limitation. But from a user's mental model (Nielsen #2, match with real world; #6, recognition over recall), a user who ran an AI search, clicked a result, and hit Back will find themselves in keyword mode with their query gone. Is that the intended behaviour, or a known debt item to ticket? Recommend a follow-up issue so it's a tracked decision, not an accident.

  2. OQ — The query.length < 3 minimum is a silent no-op. runSmartSearch returns early on queries under 3 chars with no feedback — the user presses Enter and nothing happens. That violates error-prevention/visibility heuristics: the system should tell the user why nothing happened ("Bitte geben Sie mindestens 3 Zeichen ein"). Right now it's an invisible dead-end. Small AC gap worth a chip of inline hint text.

  3. OQ — Disambiguation semantics need a one-line acceptance criterion. selectDisambiguated: when one person is already resolved, the chosen ambiguous person becomes the receiver; when none is resolved, the chosen becomes the sender. This is a reasonable rule, but it's an implicit business rule with no AC and no user-visible explanation. A user picking "Walter Müller" from a list has no way to know whether they just set a sender or a receiver. Recommend: (a) write the rule as an explicit AC, and (b) consider surfacing it in the picker label.

  4. NFR check — i18n complete (de/en/es), formal Sie used consistently — verified across all three message files. Good. One nit: search_toggle_smart_label_suffix is "-Suche" (de) vs " search" (en) — the German concatenation KI + -Suche = "KI-Suche" works, but this label-splitting-for-concatenation pattern is fragile for translators; flag for the localisation reviewer, not a blocker.

  5. NFR check — empty state has a recovery action ("Repeat as full-text search"), error states have appropriate actions (503 → switch button; 429 → no button, correct since retrying won't help). This is exactly the error-recovery affordance I look for. Well specified.

The feature is releasable. Please convert OQ #1–#3 into tracked issues or inline ACs so these implicit decisions don't resurface as "bugs" later.

## 📋 Elicit — Requirements Engineer & Business Analyst **Verdict: ⚠️ Approved with concerns** I assessed this against the issue's intent and the Definition of Done: are all states handled, are unhappy paths covered, is the behaviour testable and unambiguous, and does anything contradict a stated requirement. The feature delivers a coherent end-to-end slice — toggle → query → interpret → act — with all four UI states (loading / populated / empty / error) present. That's the visibility-of-system-status and error-recovery heuristic satisfied. Strong functional completeness. ### Requirements observations (blockers) None at the requirements level — scope matches #739 and the deferred items are explicitly named. ### Open questions / ambiguities to resolve (not merge blockers, but log them) 1. **OQ — Mode persistence is a silent behaviour change.** "Smart mode resets on page navigation (away + back)" is documented as an accepted limitation. But from a *user's* mental model (Nielsen #2, match with real world; #6, recognition over recall), a user who ran an AI search, clicked a result, and hit Back will find themselves in keyword mode with their query gone. Is that the intended behaviour, or a known debt item to ticket? Recommend a follow-up issue so it's a tracked decision, not an accident. 2. **OQ — The `query.length < 3` minimum is a silent no-op.** `runSmartSearch` returns early on queries under 3 chars with **no feedback** — the user presses Enter and nothing happens. That violates error-prevention/visibility heuristics: the system should tell the user *why* nothing happened ("Bitte geben Sie mindestens 3 Zeichen ein"). Right now it's an invisible dead-end. Small AC gap worth a chip of inline hint text. 3. **OQ — Disambiguation semantics need a one-line acceptance criterion.** `selectDisambiguated`: when one person is already resolved, the chosen ambiguous person becomes the *receiver*; when none is resolved, the chosen becomes the *sender*. This is a reasonable rule, but it's an *implicit* business rule with no AC and no user-visible explanation. A user picking "Walter Müller" from a list has no way to know whether they just set a sender or a receiver. Recommend: (a) write the rule as an explicit AC, and (b) consider surfacing it in the picker label. 4. **NFR check — i18n complete (de/en/es), formal Sie used consistently** — verified across all three message files. Good. One nit: `search_toggle_smart_label_suffix` is `"-Suche"` (de) vs `" search"` (en) — the German concatenation `KI` + `-Suche` = "KI-Suche" works, but this label-splitting-for-concatenation pattern is fragile for translators; flag for the localisation reviewer, not a blocker. 5. **NFR check — empty state has a recovery action** ("Repeat as full-text search"), error states have appropriate actions (503 → switch button; 429 → no button, correct since retrying won't help). This is exactly the error-recovery affordance I look for. Well specified. The feature is releasable. Please convert OQ #1–#3 into tracked issues or inline ACs so these implicit decisions don't resurface as "bugs" later.
Author
Owner

⚙️ Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

I checked this for anything touching my domain: CI workflow changes, Docker/Compose, image tags, secrets, deprecated actions, and the runtime/operational footprint of the new code path. This is a frontend-only PR — no Compose, no CI YAML, no infra files in the diff — so my surface is small, but a few operational notes are worth recording.

What I checked

  • No infra changes. No new Docker service, no env var, no port, no Caddy/firewall implication. The feature reuses the existing POST /api/search/nl (added in #738) and GET /api/documents/search. Nothing for me to size or operate.
  • No secrets, no hardcoded credentials. The E2E uses synthetic UUID fixtures and mocks the network at page.route — no real Ollama, no real backend, no creds in the spec. Good.
  • CI cost. One new Playwright spec with a deliberate 150ms route delay + two axe scans (light/dark). Negligible — well inside the <8min E2E budget. The deliberate delay is bounded and won't flake the runner.
  • Ollama dependency stays out of CI. Confirmed: the LLM service is mocked at the boundary, so the pipeline has no new external dependency and no new failure mode. This is exactly the right call — Ollama in CI would be a heavy, slow, non-deterministic liability.

Suggestions (non-blocking)

  1. Production observability hook. #738 reportedly added a Prometheus histogram for /api/search/nl. From the frontend side, the 503/429 paths are now user-visible — make sure there's a Grafana panel / alert on /api/search/nl 5xx rate and p95 latency so that "AI search is down" surfaces on the dashboard before a family member reports it. The 15s client-side expectation ("can take up to 15 seconds") implies the backend timeout budget should be aligned and alertable. That's a #738/observability follow-up, not this PR.
  2. CSRF only verified manually (noted in the PR). Operationally fine since CI mocks the boundary, but it means a prod CSRF misconfig on the new endpoint wouldn't be caught by the pipeline — a post-deploy smoke check hitting /api/search/nl with and without the token would close that gap cheaply.

Nothing in my domain blocks this. Approved.

## ⚙️ Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** I checked this for anything touching my domain: CI workflow changes, Docker/Compose, image tags, secrets, deprecated actions, and the runtime/operational footprint of the new code path. This is a frontend-only PR — no Compose, no CI YAML, no infra files in the diff — so my surface is small, but a few operational notes are worth recording. ### What I checked - **No infra changes.** No new Docker service, no env var, no port, no Caddy/firewall implication. The feature reuses the existing `POST /api/search/nl` (added in #738) and `GET /api/documents/search`. Nothing for me to size or operate. - **No secrets, no hardcoded credentials.** The E2E uses synthetic UUID fixtures and mocks the network at `page.route` — no real Ollama, no real backend, no creds in the spec. Good. - **CI cost.** One new Playwright spec with a deliberate 150ms route delay + two axe scans (light/dark). Negligible — well inside the <8min E2E budget. The deliberate delay is bounded and won't flake the runner. - **Ollama dependency stays out of CI.** Confirmed: the LLM service is mocked at the boundary, so the pipeline has no new external dependency and no new failure mode. This is exactly the right call — Ollama in CI would be a heavy, slow, non-deterministic liability. ### Suggestions (non-blocking) 1. **Production observability hook.** #738 reportedly added a Prometheus histogram for `/api/search/nl`. From the frontend side, the 503/429 paths are now user-visible — make sure there's a Grafana panel / alert on `/api/search/nl` 5xx rate and p95 latency so that "AI search is down" surfaces on the dashboard before a family member reports it. The 15s client-side expectation ("can take up to 15 seconds") implies the backend timeout budget should be aligned and alertable. That's a #738/observability follow-up, not this PR. 2. **CSRF only verified manually** (noted in the PR). Operationally fine since CI mocks the boundary, but it means a prod CSRF misconfig on the new endpoint wouldn't be caught by the pipeline — a post-deploy smoke check hitting `/api/search/nl` with and without the token would close that gap cheaply. Nothing in my domain blocks this. Approved.
marcel added 2 commits 2026-06-06 18:27:51 +02:00
Leonie (UX): the toggle pill (text-[7.5px]) and loading subtitle
(text-[9px]) were below the 12px floor for the 60+ audience. Bump both
to text-xs and the toggle icon to h-3.5/w-3.5. Overrides the visual
spec's tokens, which conflicted with the issue's own legibility mandate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
docs(c4): add smart-search components to l3-frontend diagram (#739 review)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m22s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m45s
CI / fail2ban Regex (pull_request) Successful in 42s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
CI / Unit & Component Tests (push) Successful in 3m19s
CI / OCR Service Tests (push) Successful in 25s
CI / Backend Unit Tests (push) Successful in 3m51s
CI / fail2ban Regex (push) Successful in 48s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m6s
87af9ab446
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 <noreply@anthropic.com>
Author
Owner

🔁 Blocker re-check

Clean re-check of the two prior blockers (comments #14648 Leonie, #14651 Markus). Verified against latest commits 0058b297 and 87af9ab4.

1. Sub-12px fonts (Leonie) — RESOLVED

  • frontend/src/routes/search/SmartModeToggle.svelte:22 — toggle pill now uses text-xs (no more text-[7.5px]).
  • frontend/src/routes/search/SmartSearchStatus.svelte:36-37 — loading subtitle now uses text-xs (no more text-[9px]).
  • Repo-wide grep text-\[[0-9]+(\.[0-9]+)?px\] across frontend/src/routes/search/ returns zero matches. No sub-12px text remains in the new search components.

2. C4 docs (Markus) — RESOLVED

docs/architecture/c4/l3-frontend-3b-document-workflows.puml now documents all four new components plus the NL relation:

  • L14 Component(smartToggle, "search/SmartModeToggle.svelte", ...)
  • L15 Component(chipRow, "search/InterpretationChipRow.svelte", ...)
  • L16 Component(smartStatus, "search/SmartSearchStatus.svelte", ...)
  • L17 Component(disambig, "search/DisambiguationPicker.svelte", ...)
  • L35 Rel(homePage, backend, "POST /api/search/nl (smart mode)", "HTTP / JSON")

Overall

Both prior blockers are cleared.

## 🔁 Blocker re-check Clean re-check of the two prior blockers (comments #14648 Leonie, #14651 Markus). Verified against latest commits `0058b297` and `87af9ab4`. ### 1. Sub-12px fonts (Leonie) — ✅ RESOLVED - `frontend/src/routes/search/SmartModeToggle.svelte:22` — toggle pill now uses `text-xs` (no more `text-[7.5px]`). - `frontend/src/routes/search/SmartSearchStatus.svelte:36-37` — loading subtitle now uses `text-xs` (no more `text-[9px]`). - Repo-wide grep `text-\[[0-9]+(\.[0-9]+)?px\]` across `frontend/src/routes/search/` returns **zero** matches. No sub-12px text remains in the new search components. ### 2. C4 docs (Markus) — ✅ RESOLVED `docs/architecture/c4/l3-frontend-3b-document-workflows.puml` now documents all four new components plus the NL relation: - L14 `Component(smartToggle, "search/SmartModeToggle.svelte", ...)` - L15 `Component(chipRow, "search/InterpretationChipRow.svelte", ...)` - L16 `Component(smartStatus, "search/SmartSearchStatus.svelte", ...)` - L17 `Component(disambig, "search/DisambiguationPicker.svelte", ...)` - L35 `Rel(homePage, backend, "POST /api/search/nl (smart mode)", "HTTP / JSON")` ### Overall **Both prior blockers are cleared.** ✅
marcel merged commit 87af9ab446 into main 2026-06-06 18:40:35 +02:00
marcel deleted branch feat/issue-739-nl-search-frontend 2026-06-06 18:40:35 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#757