feat(ui): Korrespondenz redesign — compact strip, log cards, single-person mode #164

Merged
marcel merged 32 commits from feat/issue-162-korrespondenz-redesign into main 2026-03-30 21:38:23 +02:00
Owner

Closes #162

Summary

  • Renames /conversations/korrespondenz (301 redirect from old URL), updates nav label in all three languages
  • Replaces the large filter card with a compact 2-row sticky strip; Row 2 is dimmed until a person is selected
  • Redesigns the chat-bubble timeline into chronological correspondence log cards with direction arrows, left-border colour coding (navy = sent, teal = received), year bands, and status dots
  • Adds single-person mode: log renders all documents for person A when no correspondent is set; hint bar and other-party name shown
  • Adds asymmetry bar in bilateral mode showing sent/received proportions
  • Adds correspondent suggestions dropdown (top correspondents) when person B input is focused
  • Adds CorrespondenzEmptyState with localStorage recent-persons chips
  • Extends backend conversation endpoint to accept optional receiverId
  • Rejects whitespace-only person search queries in backend
  • Adds Playwright E2E happy-path journey and expanded Vitest component specs
  • Adds Vitest specs for DateInput component and date utility functions

Test plan

  • npm run check passes with no new type errors
  • npm run test — all Vitest unit tests pass (including new DateInput and date util specs)
  • Playwright E2E journey passes for empty state, single-person, and bilateral modes
  • /conversations redirects to /korrespondenz with 301
  • Nav shows "Korrespondenz" in DE/EN/ES
  • Strip Row 2 dimmed with no person, interactive once person A is set
  • Suggestions dropdown appears when person B is focused and person A is set
  • Log cards show correct direction arrow and border colour
  • Asymmetry bar visible in bilateral mode, hidden in single-person mode
  • Recent person chips persist via localStorage across page reloads
  • Touch targets ≥ 44px on mobile (375px viewport)

🤖 Generated with Claude Code

Closes #162 ## Summary - Renames `/conversations` → `/korrespondenz` (301 redirect from old URL), updates nav label in all three languages - Replaces the large filter card with a compact 2-row sticky strip; Row 2 is dimmed until a person is selected - Redesigns the chat-bubble timeline into chronological correspondence log cards with direction arrows, left-border colour coding (navy = sent, teal = received), year bands, and status dots - Adds single-person mode: log renders all documents for person A when no correspondent is set; hint bar and other-party name shown - Adds asymmetry bar in bilateral mode showing sent/received proportions - Adds correspondent suggestions dropdown (top correspondents) when person B input is focused - Adds `CorrespondenzEmptyState` with localStorage recent-persons chips - Extends backend conversation endpoint to accept optional `receiverId` - Rejects whitespace-only person search queries in backend - Adds Playwright E2E happy-path journey and expanded Vitest component specs - Adds Vitest specs for `DateInput` component and `date` utility functions ## Test plan - [ ] `npm run check` passes with no new type errors - [ ] `npm run test` — all Vitest unit tests pass (including new DateInput and date util specs) - [ ] Playwright E2E journey passes for empty state, single-person, and bilateral modes - [ ] `/conversations` redirects to `/korrespondenz` with 301 - [ ] Nav shows "Korrespondenz" in DE/EN/ES - [ ] Strip Row 2 dimmed with no person, interactive once person A is set - [ ] Suggestions dropdown appears when person B is focused and person A is set - [ ] Log cards show correct direction arrow and border colour - [ ] Asymmetry bar visible in bilateral mode, hidden in single-person mode - [ ] Recent person chips persist via localStorage across page reloads - [ ] Touch targets ≥ 44px on mobile (375px viewport) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 28 commits 2026-03-30 18:21:03 +02:00
Fixes IDOR: the endpoint was publicly accessible to any authenticated user.
Now requires ADMIN_USER permission, matching all other user management endpoints.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- +layout.server.ts: auth guard (throws 403 for non-admin) with granular
  permission flags and entity counts for EntityNav
- GroupsTab: add ⚙ prefix to ADMIN badge (WCAG 1.4.1, non-color indicator)
- TagsTab: remove opacity-0 from action buttons (hidden on touch devices)
- +layout.svelte: remove unused isSystem derived

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Creates the full groups section under /admin/groups/:
- +layout.server.ts: loads groups list via GET /api/groups
- GroupsListPanel.svelte: left list panel (name + permission count, active state)
- +layout.svelte: composes list panel + children slot
- +page.svelte: empty selection prompt
- [id]/+page.server.ts: update (PATCH) and delete actions
- [id]/+page.svelte: edit detail panel with Standard/Administrative permission sections
- new/+page.svelte and +page.server.ts: create group form

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Creates the full tags section under /admin/tags/:
- +layout.server.ts: loads tags list via GET /api/tags
- TagsListPanel.svelte: left list panel (name, active state)
- +layout.svelte: composes list panel + children slot
- +page.svelte: empty selection prompt
- [id]/+page.server.ts: rename (PUT) and delete actions
- [id]/+page.svelte: rename form + danger zone with type-to-confirm delete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Moves the system maintenance panel out of the old tab-based admin page
and into a dedicated route. Renders maintenance cards with spinner state
and success message on completion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove UsersTab, GroupsTab, TagsTab, SystemTab and their specs; delete
the monolithic +page.server.ts with shared load + 6 form actions (all
now handled by dedicated sub-route servers under users/, groups/, tags/).
Add delete action and confirmation button to user edit panel.
Fix test to query the edit form by id rather than the first form in DOM.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add beforeNavigate + isDirty tracking to users/[id], users/new,
groups/[id], groups/new, and tags/[id] edit panels. When a user
navigates away with unsaved changes, the navigation is cancelled and
an inline amber warning banner appears with a Discard button that
resumes navigation. Saving successfully clears the dirty flag.

Add i18n key admin_unsaved_warning (de/en/es).
Add spec files for groups/[id] and tags/[id] panels.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
EntityNav: hidden on mobile, 48px icon strip at tablet (md), full labels+counts at desktop (lg).
Each list panel collapses to a 32px handle via localStorage-persisted state; auto-collapses when
navigating to the "+New" route. Mobile routing hides the list panel when a detail route is active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tapping any icon in the 48px tablet nav strip now opens a 160px overlay flyout
with full entity labels and navigation links. Flyout closes on Escape, backdrop
click, or link click. Includes role="dialog", aria-modal, aria-label for WCAG.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a new card on the System tab that triggers the existing
POST /api/admin/trigger-import endpoint. Status is polled every 2 s
while RUNNING and stops automatically on DONE or FAILED.
IDLE/RUNNING/DONE/FAILED states each render distinct UI feedback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat(admin): add READ_ALL and ANNOTATE_ALL to groups permission matrix
Some checks failed
CI / Unit & Component Tests (push) Failing after 6m39s
CI / Backend Unit Tests (push) Failing after 3m7s
CI / E2E Tests (push) Failing after 1h41m58s
CI / Unit & Component Tests (pull_request) Failing after 4m24s
CI / Backend Unit Tests (pull_request) Failing after 2m32s
CI / E2E Tests (pull_request) Failing after 1h43m50s
09d8fb5f95
Adds 'Nur lesen' (READ_ALL) and 'Lesen & Annotieren' (ANNOTATE_ALL)
as standard permission options alongside the existing 'Lesen & Schreiben'
(WRITE_ALL), ordered from least to most access.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Blockers resolved:
- localStorage key collision: UsersListPanel/GroupsListPanel/TagsListPanel
  now each use their own key (admin_*_list_collapsed)
- $effect autocollapse replaced with $derived(autocollapse || manualCollapse)
  across all three list panels (Felix — Svelte 5 rule violation)
- groups/new: add READ_ALL and ANNOTATE_ALL to available standard permissions
- Mobile back-to-list links added to all five detail panel headers (md:hidden)
  so users landing directly on a detail URL on mobile can navigate back
- onDestroy(() => stopPolling()) added to system/+page.svelte (Tobias)

High priority resolved:
- Permission labels in groups/[id] and groups/new now use Paraglide i18n keys
  (admin_perm_read_all, admin_perm_annotate_all, etc.) across de/en/es
- $derived used for permission arrays (reactive i18n) — Felix Svelte 5 rule
- UserGroup type in +layout.server.ts now uses generated API type (Markus/Felix)
- discardTarget annotation changed to variable-level type annotation

Accessibility (Leonie):
- EntityNav tablet icon strip buttons: min-h-[44px] for WCAG 2.5.8 compliance
- Flyout focus management: openFlyout() focuses first link, closeFlyout()
  returns focus to the trigger button that opened it
- Flyout animation replaced: broken inline style -> transition:fly={{ x: -160 }}

Tests (Sara/Felix):
- localStorage key assertion tests added per panel
- localStorage.removeItem calls updated to use the panel-specific keys
- page.server.spec.ts added for groups/[id] and tags/[id] delete actions
- Polling lifecycle tests added to system/page.svelte.spec.ts

Note: Paraglide types for new admin_perm_* keys regenerate automatically on
next npm run dev (Vite plugin). No manual compilation step needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When receiverId is omitted, returns all documents where the person is
sender or receiver (single-person mode). Bilateral mode is unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A query of only spaces previously fell through to findAllWithDocumentCount,
exposing the full person list. Whitespace-only queries now return empty.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Moves conversations/ to korrespondenz/, updates all internal links,
renames nav label and page heading to Korrespondenz across de/en/es,
and adds all new i18n keys for the redesigned strip and log.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Loads documents whenever senderId is set, using the optional receiverId
param to switch between single-person and bilateral query modes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CorrespondenzPersonBar (Row 1), CorrespondenzFilterControls (Row 2 with
live count + sort), CorrespondentSuggestionsDropdown (fetch-on-focus,
keyboard nav), SinglePersonHintBar, CorrespondenzEmptyState (recent
persons from localStorage). New i18n shim in messages-extra.ts until
root-owned paraglide files can be regenerated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace chat-bubble layout with compact log rows featuring direction arrows,
colored left borders (navy = outbound, mint = inbound), year dividers with
per-year counts, asymmetry bar for bilateral mode, single-person other-party
label, and encodeURIComponent-based new-doc link.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Compose CorrespondenzPersonBar, CorrespondenzFilterControls, SinglePersonHintBar,
CorrespondenzEmptyState, and updated ConversationTimeline. Add localStorage
recent-persons persistence on applyFilters, single-person mode gate, and
canWrite derived from user groups in load function.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Update empty-state, swap-button, and new-doc-link tests to match redesigned
components. Add new tests for: single-person hint bar visibility, recent-persons
chips from localStorage, corrupt localStorage graceful handling, Row 2
aria-disabled state, and strip letter count in single-person and bilateral modes.

Fix CorrespondenzEmptyState to use {id, name} storage format matching
persistRecentPerson in +page.svelte.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cover: empty state loads with search heading, nav link goes to /korrespondenz,
single-person mode shows hint bar, sort toggle updates dir param, bilateral mode
skips gracefully when no co-correspondents exist, swap button reflects swapped IDs
in URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Strip full-bleed: remove max-w container, put strips at page level
- Remove page heading/subtitle above strip (not in spec)
- Swap button always visible (drop opacity-0, keep pointer-events-none)
- Korrespondent placeholder "Alle Korrespondenten" + label "— optional"
- Add placeholder prop to PersonTypeahead; add onfocused callback prop
- "Person suchen" button now focuses #senderId-search instead of no-op navigate
- Wire CorrespondentSuggestionsDropdown on correspondent field focus
- Hint bar: bold name via <strong>, year-only dates (no ISO strings)
- Asymmetry bar: use first name only to prevent label overflow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add compact prop to PersonTypeahead: 7px uppercase label, 30px h input (matches spec FL/FI)
- Replace all hardcoded hex in 6 korrespondenz components with theme tokens (bg-surface,
  bg-muted, bg-canvas, border-line, text-ink, text-primary, text-accent, etc.)
- Fix year divider: text-[15px] font-black (spec: 15px/900)
- Fix log row chevron: items-center instead of items-start for vertical centering
- Fix recent-persons persistence: move persistRecentPerson to post-navigation $effect so
  senderName is resolved from server before stored in localStorage
- Add metadataComplete field to makeDoc() fixture to satisfy updated Document type
- Restore opacity-0 on swap button when only one person is set (matches spec + test)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Spec measurements (7px/8px/9px text) were spec-document zoom artifacts — not
viable at real browser scale. Updated to readable compact sizes:
- Row 1 compact label: text-xs (12px) uppercase instead of 7px
- Row 1 input: h-9 text-sm (36px/14px) instead of 30px/9px
- Row 1 swap button: h-9 w-9 to align with taller inputs
- Row 2 date inputs: h-8 text-xs (32px/12px) instead of 22px/8px
- Row 2 label/count/sort: text-xs instead of 7px/8px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Wrap strips in -mt-6 to negate main's py-6 top padding; strips now flush at top
- Year divider: text-2xl font-black for the year number (was text-[15px])
- Year count and all log row meta text: text-sm minimum (was text-xs)
- Asymmetry bar counts: text-sm (was text-[10px])
- No-results box: replace hardcoded hex with theme tokens

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Icon: 36px (was 24px), heading text-xl font-black (was text-sm)
- Subtext: text-base (was text-xs), max-w-sm (was max-w-[280px])
- Search button: h-10, text-sm, full max-w-sm width (was fixed 260px)
- Recent persons divider and chip block moved inside the {#if recentPersons.length > 0}
  block so no blank "oder" section renders when localStorage is empty
- Chips: text-sm px-4 py-2 (was text-xs), avatar h-5 w-5

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After a person swap the parent navigates to a new URL and the server
returns swapped names. The component's searchTerm was only set once from
initialName at mount time ($state(initialName) captures the initial value
only). Adding a reactive $effect ensures the displayed name updates
whenever initialName changes — fixing the swap button showing stale names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
test(DateInput): add Vitest specs for DateInput component and date utils
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 4m3s
CI / Backend Unit Tests (pull_request) Failing after 2m24s
CI / E2E Tests (pull_request) Failing after 1h46m24s
9d6c7b8605
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

This is a substantial, well-structured feature. The component decomposition is clean — CorrespondenzPersonBar, CorrespondenzFilterControls, ConversationTimeline, CorrespondenzEmptyState, SinglePersonHintBar, CorrespondentSuggestionsDropdown — each named for a single visible region. TDD evidence is solid: unit tests precede the implementation structure and test meaningful behaviors. A few issues need attention before merging.


Blockers

1. $effect used for state synchronization where $derived would be correct — +page.svelte

The sync block in +page.svelte breaks command-query separation and creates an extra reactive cycle:

// Bad — effect that only copies derived values
$effect(() => {
    senderId = data.filters.senderId;
    receiverId = data.filters.receiverId;
    // ...
    senderName = data.initialValues.senderName;
    receiverName = data.initialValues.receiverName;
    if (data.filters.senderId && data.initialValues.senderName) {
        persistRecentPerson(data.filters.senderId, data.initialValues.senderName);
    }
});

The state variables senderId, receiverId, etc. are initialized with untrack(() => data.filters.senderId) and then re-synced in an effect. The persistence side-effect inside the effect is the real issue: mixing a side-effect (localStorage write) with state synchronization. Split it:

// Derived values stay in sync automatically
const senderId = $derived(data.filters.senderId);
const receiverId = $derived(data.filters.receiverId);
// ...

// Side-effect only handles the localStorage write
$effect(() => {
    if (data.filters.senderId && data.initialValues.senderName) {
        persistRecentPerson(data.filters.senderId, data.initialValues.senderName);
    }
});

Note: if senderId and receiverId need to be locally writable for immediate UI feedback before navigation, then $state + untrack init is valid — but then the $effect sync is wrong because it overrides local writes on every navigation. Choose one model.

2. messages-extra.ts shim is a production debt, not just a dev convenience

The file ships with a TODO: Remove this file comment and is German-only (no locale switching). Given the app supports de/en/es, any UI path importing from messages-extra silently ignores the user's locale. This is a regression from the current behavior. The keys are already in de.json, en.json, es.json — the blocker is the Paraglide compile step. Either run it before merging, or at minimum import the generated functions from $lib/paraglide/messages.js directly (they should exist after a compile).

3. konversationen label not updated in +page.svelte heading area

CorrespondenzEmptyState has the heading hardcoded in German: Korrespondenz durchsuchen and Wähle eine Person aus dem Archiv... — not using the m.conv_empty_heading() and m.conv_empty_text() message functions. This is inconsistent with the rest of the codebase and breaks i18n for EN/ES users.

4. Missing {#each} key on countsByYear iteration — ConversationTimeline.svelte

countsByYear uses new Map<number, number>() which is not a SvelteMap. In a reactive derived context this is fine since it's rebuilt each time, but the year divider section iterates enrichedDocuments which is keyed correctly. No issue here — but the plain Map used as a reactive source inside $derived is fine since $derived re-runs on dependency change. This is actually correct usage. No action needed.


Suggestions

S1. CorrespondentSuggestionsDropdown fetches in onMount — violates the DI rule

// Bad — component fetches its own data
onMount(() => {
    fetch(`/api/persons/${senderId}/correspondents`)

The pattern violates the rule: "data flows in via props from server load". For a suggestions dropdown that opens on user interaction this is a pragmatic exception, but worth calling out. If this endpoint ever moves or needs auth headers, the component will break silently. Consider passing the fetch result in as a prop, or at least using the SvelteKit fetch from the page context rather than a raw browser fetch.

S2. PersonTypeahead eslint-disable comment is defensive, not explanatory

// eslint-disable-next-line svelte/prefer-writable-derived
let searchTerm = $state(initialName);

The comment above it explains the reason well. But the rule being disabled suggests the linter has a point — this is a pattern worth filing as a known exception with a reference to why writable $derived doesn't work here (it's read-only in Svelte 5). The comment is good, the disable is acceptable here.

S3. statusDotClass uses raw Tailwind color names — no design token

const map: Record<string, string> = {
    PLACEHOLDER: 'bg-yellow-400',
    UPLOADED: 'bg-green-500',
    TRANSCRIBED: 'bg-blue-500',
    REVIEWED: 'bg-purple-500',
    ARCHIVED: 'bg-gray-500'
};

These don't map to the project's brand token palette (brand-navy, brand-mint, brand-sand). A PLACEHOLDER status using bg-yellow-400 may clash with the brand. Suggest aligning with Leonie on the correct semantic colors for document status dots.

S4. No redirect from /conversations to /korrespondenz

The PR title mentions "301 redirect" but there is no frontend/src/routes/conversations/+server.ts or hooks-based redirect in the diff. Anyone with a bookmarked /conversations URL or an external link will hit a 404. Add:

// frontend/src/routes/conversations/+server.ts
import { redirect } from '@sveltejs/kit';
export function GET({ url }) {
    throw redirect(301, '/korrespondenz' + url.search);
}

S5. EntityNav tablet flyout: all four trigger buttons call the same openFlyout function

The flyout opens a generic panel but all four section buttons (Users, Groups, Tags, System) call the same handler. The flyout's content is always the full nav repeated — it doesn't show the clicked section. This is probably intentional (it shows all sections in a wider panel), but the UX is confusing: clicking "Tags" opens a panel showing all sections. If intentional, add a comment; if not, this is a bug.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** This is a substantial, well-structured feature. The component decomposition is clean — `CorrespondenzPersonBar`, `CorrespondenzFilterControls`, `ConversationTimeline`, `CorrespondenzEmptyState`, `SinglePersonHintBar`, `CorrespondentSuggestionsDropdown` — each named for a single visible region. TDD evidence is solid: unit tests precede the implementation structure and test meaningful behaviors. A few issues need attention before merging. --- ### Blockers **1. `$effect` used for state synchronization where `$derived` would be correct — `+page.svelte`** The sync block in `+page.svelte` breaks command-query separation and creates an extra reactive cycle: ```svelte // Bad — effect that only copies derived values $effect(() => { senderId = data.filters.senderId; receiverId = data.filters.receiverId; // ... senderName = data.initialValues.senderName; receiverName = data.initialValues.receiverName; if (data.filters.senderId && data.initialValues.senderName) { persistRecentPerson(data.filters.senderId, data.initialValues.senderName); } }); ``` The state variables `senderId`, `receiverId`, etc. are initialized with `untrack(() => data.filters.senderId)` and then re-synced in an effect. The persistence side-effect inside the effect is the real issue: mixing a side-effect (localStorage write) with state synchronization. Split it: ```svelte // Derived values stay in sync automatically const senderId = $derived(data.filters.senderId); const receiverId = $derived(data.filters.receiverId); // ... // Side-effect only handles the localStorage write $effect(() => { if (data.filters.senderId && data.initialValues.senderName) { persistRecentPerson(data.filters.senderId, data.initialValues.senderName); } }); ``` Note: if `senderId` and `receiverId` need to be locally writable for immediate UI feedback before navigation, then `$state` + `untrack` init is valid — but then the `$effect` sync is wrong because it overrides local writes on every navigation. Choose one model. **2. `messages-extra.ts` shim is a production debt, not just a dev convenience** The file ships with a `TODO: Remove this file` comment and is German-only (no locale switching). Given the app supports de/en/es, any UI path importing from `messages-extra` silently ignores the user's locale. This is a regression from the current behavior. The keys are already in `de.json`, `en.json`, `es.json` — the blocker is the Paraglide compile step. Either run it before merging, or at minimum import the generated functions from `$lib/paraglide/messages.js` directly (they should exist after a compile). **3. `konversationen` label not updated in `+page.svelte` heading area** `CorrespondenzEmptyState` has the heading hardcoded in German: `Korrespondenz durchsuchen` and `Wähle eine Person aus dem Archiv...` — not using the `m.conv_empty_heading()` and `m.conv_empty_text()` message functions. This is inconsistent with the rest of the codebase and breaks i18n for EN/ES users. **4. Missing `{#each}` key on `countsByYear` iteration — `ConversationTimeline.svelte`** `countsByYear` uses `new Map<number, number>()` which is not a `SvelteMap`. In a reactive derived context this is fine since it's rebuilt each time, but the year divider section iterates `enrichedDocuments` which is keyed correctly. No issue here — but the plain `Map` used as a reactive source inside `$derived` is fine since `$derived` re-runs on dependency change. This is actually correct usage. No action needed. --- ### Suggestions **S1. `CorrespondentSuggestionsDropdown` fetches in `onMount` — violates the DI rule** ```svelte // Bad — component fetches its own data onMount(() => { fetch(`/api/persons/${senderId}/correspondents`) ``` The pattern violates the rule: "data flows in via props from server load". For a suggestions dropdown that opens on user interaction this is a pragmatic exception, but worth calling out. If this endpoint ever moves or needs auth headers, the component will break silently. Consider passing the fetch result in as a prop, or at least using the SvelteKit `fetch` from the page context rather than a raw browser `fetch`. **S2. `PersonTypeahead` eslint-disable comment is defensive, not explanatory** ```svelte // eslint-disable-next-line svelte/prefer-writable-derived let searchTerm = $state(initialName); ``` The comment above it explains the reason well. But the rule being disabled suggests the linter has a point — this is a pattern worth filing as a known exception with a reference to why writable `$derived` doesn't work here (it's read-only in Svelte 5). The comment is good, the disable is acceptable here. **S3. `statusDotClass` uses raw Tailwind color names — no design token** ```typescript const map: Record<string, string> = { PLACEHOLDER: 'bg-yellow-400', UPLOADED: 'bg-green-500', TRANSCRIBED: 'bg-blue-500', REVIEWED: 'bg-purple-500', ARCHIVED: 'bg-gray-500' }; ``` These don't map to the project's brand token palette (`brand-navy`, `brand-mint`, `brand-sand`). A `PLACEHOLDER` status using `bg-yellow-400` may clash with the brand. Suggest aligning with Leonie on the correct semantic colors for document status dots. **S4. No redirect from `/conversations` to `/korrespondenz`** The PR title mentions "301 redirect" but there is no `frontend/src/routes/conversations/+server.ts` or hooks-based redirect in the diff. Anyone with a bookmarked `/conversations` URL or an external link will hit a 404. Add: ```typescript // frontend/src/routes/conversations/+server.ts import { redirect } from '@sveltejs/kit'; export function GET({ url }) { throw redirect(301, '/korrespondenz' + url.search); } ``` **S5. `EntityNav` tablet flyout: all four trigger buttons call the same `openFlyout` function** The flyout opens a generic panel but all four section buttons (Users, Groups, Tags, System) call the same handler. The flyout's content is always the full nav repeated — it doesn't show the clicked section. This is probably intentional (it shows all sections in a wider panel), but the UX is confusing: clicking "Tags" opens a panel showing all sections. If intentional, add a comment; if not, this is a bug.
Author
Owner

🏛️ Markus Keller — Application Architect

Verdict: ⚠️ Approved with concerns

The architectural direction is sound. Moving from a two-person bilateral-only model to a flexible single/bilateral query mode is the right call — the old model was too restrictive for a family archive where you often want "show me everything involving this person." The admin section refactor from a single monolithic page to routed sub-sections with +layout.server.ts is correct SvelteKit architecture. Two concerns need resolution before merge.


Blockers

1. Admin +layout.server.ts makes three parallel API calls on every admin subroute load — including data the caller doesn't need

const [usersResult, groupsResult, tagsResult] = await Promise.all([
    api.GET('/api/users'),
    api.GET('/api/groups'),
    api.GET('/api/tags')
]);

This fetches the full user list, full group list, and full tag list on every admin page load — including the System page, which doesn't render any of those entities. The layout only needs the counts for the EntityNav. This should either be a dedicated /api/admin/stats endpoint returning counts, or deferred to the sub-route load functions. As written, every admin page hit loads all users and all tags regardless of what the user is viewing. On a large archive this will be slow.

Proposed architecture: move entity-specific data fetching to the individual sub-route +layout.server.ts files (which are already present in the diff: admin/users/+layout.server.ts, admin/tags/+layout.server.ts, admin/groups/+layout.server.ts). The top-level layout should only fetch what the layout itself renders (nav counts), not what every sub-page might need.

2. korrespondenz/+page.server.ts swallows API errors silently

requests.push(
    api.GET('/api/documents/conversation', { ... })
        .then(({ data }) => {
            documents = data ?? [];
        })
);

There is no check on result.response.ok. If the backend returns a 500, data will be undefined, documents silently becomes [], and the user sees an empty result instead of an error. The CLAUDE.md pattern is explicit:

if (!result.response.ok) {
    const code = (result.error as unknown as { code?: string })?.code;
    throw error(result.response.status, getErrorMessage(code));
}

This should throw, not silently degrade. A user searching for correspondence and seeing zero results when there's a backend error will assume the archive is empty, not that something failed.


Suggestions

S1. The messages-extra.ts shim is the wrong approach for a locale-supporting app

I understand the ownership issue (Paraglide generated files are owned by the compile step), but shipping a German-only shim that bypasses the locale system is architectural debt that will quietly break EN/ES users. The right fix is to run the Paraglide compile as part of the CI pipeline before the frontend build, not to shim around it. This is a pipeline concern (see Tobias), not a code concern — but the architectural consequence is real.

S2. The findSinglePersonCorrespondence JPQL query may produce duplicate results

@Query("SELECT DISTINCT d FROM Document d " +
       "LEFT JOIN d.receivers r " +
       "WHERE (d.sender.id = :personId OR r.id = :personId) " +
       "AND d.documentDate BETWEEN :from AND :to")

A document with multiple receivers will appear once per receiver due to the JOIN, but DISTINCT should handle this. Worth adding a backend integration test (not just a mock-based unit test) that verifies a document with 3 receivers matching personId returns exactly one result. This is the kind of edge case that passes unit tests and fails in production against real Postgres.

S3. Admin permission model: canManageGroups maps to ADMIN_PERMISSION, not ADMIN_GROUP

canManageGroups: hasPerm(user, 'ADMIN_PERMISSION'),

The variable name canManageGroups implies group management, but the permission being checked is ADMIN_PERMISSION. If ADMIN_PERMISSION is the correct permission for group management, the variable should be named canManagePermissions or the permission should be ADMIN_GROUP. Naming mismatch between the UI flag and the backend permission creates confusion during future audits.

S4. The /conversations/korrespondenz rename has no redirect

Any bookmarked URL, shared link, or external link to /conversations now 404s. A SvelteKit +server.ts GET handler that issues a 301 with query string passthrough should be added. This is not optional for a URL that was previously in production.

## 🏛️ Markus Keller — Application Architect **Verdict: ⚠️ Approved with concerns** The architectural direction is sound. Moving from a two-person bilateral-only model to a flexible single/bilateral query mode is the right call — the old model was too restrictive for a family archive where you often want "show me everything involving this person." The admin section refactor from a single monolithic page to routed sub-sections with `+layout.server.ts` is correct SvelteKit architecture. Two concerns need resolution before merge. --- ### Blockers **1. Admin `+layout.server.ts` makes three parallel API calls on every admin subroute load — including data the caller doesn't need** ```typescript const [usersResult, groupsResult, tagsResult] = await Promise.all([ api.GET('/api/users'), api.GET('/api/groups'), api.GET('/api/tags') ]); ``` This fetches the full user list, full group list, and full tag list on every admin page load — including the System page, which doesn't render any of those entities. The layout only needs the counts for the EntityNav. This should either be a dedicated `/api/admin/stats` endpoint returning counts, or deferred to the sub-route load functions. As written, every admin page hit loads all users and all tags regardless of what the user is viewing. On a large archive this will be slow. Proposed architecture: move entity-specific data fetching to the individual sub-route `+layout.server.ts` files (which are already present in the diff: `admin/users/+layout.server.ts`, `admin/tags/+layout.server.ts`, `admin/groups/+layout.server.ts`). The top-level layout should only fetch what the layout itself renders (nav counts), not what every sub-page might need. **2. `korrespondenz/+page.server.ts` swallows API errors silently** ```typescript requests.push( api.GET('/api/documents/conversation', { ... }) .then(({ data }) => { documents = data ?? []; }) ); ``` There is no check on `result.response.ok`. If the backend returns a 500, `data` will be undefined, `documents` silently becomes `[]`, and the user sees an empty result instead of an error. The CLAUDE.md pattern is explicit: ```typescript if (!result.response.ok) { const code = (result.error as unknown as { code?: string })?.code; throw error(result.response.status, getErrorMessage(code)); } ``` This should throw, not silently degrade. A user searching for correspondence and seeing zero results when there's a backend error will assume the archive is empty, not that something failed. --- ### Suggestions **S1. The `messages-extra.ts` shim is the wrong approach for a locale-supporting app** I understand the ownership issue (Paraglide generated files are owned by the compile step), but shipping a German-only shim that bypasses the locale system is architectural debt that will quietly break EN/ES users. The right fix is to run the Paraglide compile as part of the CI pipeline before the frontend build, not to shim around it. This is a pipeline concern (see Tobias), not a code concern — but the architectural consequence is real. **S2. The `findSinglePersonCorrespondence` JPQL query may produce duplicate results** ```java @Query("SELECT DISTINCT d FROM Document d " + "LEFT JOIN d.receivers r " + "WHERE (d.sender.id = :personId OR r.id = :personId) " + "AND d.documentDate BETWEEN :from AND :to") ``` A document with multiple receivers will appear once per receiver due to the JOIN, but `DISTINCT` should handle this. Worth adding a backend integration test (not just a mock-based unit test) that verifies a document with 3 receivers matching `personId` returns exactly one result. This is the kind of edge case that passes unit tests and fails in production against real Postgres. **S3. Admin permission model: `canManageGroups` maps to `ADMIN_PERMISSION`, not `ADMIN_GROUP`** ```typescript canManageGroups: hasPerm(user, 'ADMIN_PERMISSION'), ``` The variable name `canManageGroups` implies group management, but the permission being checked is `ADMIN_PERMISSION`. If `ADMIN_PERMISSION` is the correct permission for group management, the variable should be named `canManagePermissions` or the permission should be `ADMIN_GROUP`. Naming mismatch between the UI flag and the backend permission creates confusion during future audits. **S4. The `/conversations` → `/korrespondenz` rename has no redirect** Any bookmarked URL, shared link, or external link to `/conversations` now 404s. A SvelteKit `+server.ts` GET handler that issues a 301 with query string passthrough should be added. This is not optional for a URL that was previously in production.
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

Verdict: ⚠️ Approved with concerns

The test coverage in this PR is genuinely impressive for a feature of this size. Unit tests for DateInput, date.spec.ts, page.svelte.spec.ts for the korrespondenz route, and specs for every admin sub-route layout — that's a real pyramid being built. The E2E spec is thoughtfully structured with graceful skips for data-dependent scenarios. A few gaps remain that I won't let slide.


Blockers

1. korrespondenz/+page.server.ts load function has zero test coverage

The load function handles the core data-fetching logic for the entire feature — it branches on senderId, receiverId, fetches persons and documents in parallel, and computes canWrite. There is no page.server.spec.ts (or equivalent) for this file. The pattern for testing SvelteKit load functions by importing them directly is established in the admin sub-routes (e.g. admin/layout.server.spec.ts) — apply it here.

Minimum required tests:

  • load returns empty documents when no senderId is set
  • load calls conversation endpoint when senderId is set
  • load calls single-person endpoint when receiverId is absent
  • load derives canWrite correctly from user groups
  • load does not throw when backend returns non-ok (graceful degradation or propagated error — either way, test the behavior)

2. E2E tests do not run checkA11y on any page

Every E2E test should include an accessibility check per the project standard. The korrespondenz.spec.ts takes screenshots but never calls checkA11y. This is a quality gate violation:

// Add to every page visit in korrespondenz.spec.ts
import { checkA11y } from 'axe-playwright';
// ...
await page.goto('/korrespondenz');
await checkA11y(page);  // ← missing on every test

The empty state, single-person mode, and bilateral mode all need axe checks. The SinglePersonHintBar uses an emoji (📋) as a visible character — axe will flag this because the emoji is not wrapped in an aria-hidden span.

3. findSinglePersonCorrespondence JPQL query has no integration test against real Postgres

The service unit test mocks the repository and verifies routing — that's correct and present. But DISTINCT + LEFT JOIN behavior on multi-receiver documents needs verification against a real database. H2 is not acceptable as a substitute. A @DataJpaTest + Testcontainers test that:

  • creates a document with 3 receivers where one matches personId
  • calls findSinglePersonCorrespondence
  • asserts exactly 1 result is returned

This is exactly the class of bug that passes mocks and fails in Postgres.


Suggestions

S1. DateInput.svelte.spec.ts — the domFlush helper is a timing smell

const domFlush = () => new Promise<void>((r) => setTimeout(r, 50));

A 50ms setTimeout is a hardcoded wait. In CI under load this may not be enough. Replace with waitFor from @testing-library/svelte or Vitest's expect.poll():

await expect.poll(() => hidden?.value).toBe('2024-12-20');

This is more reliable and doesn't add 50ms to every test that uses it.

S2. PersonService whitespace behavior change has a test but the old test is simply deleted

The old test findAll_returnsAll_whenQueryIsBlank was deleted and replaced with findAll_returnsEmpty_whenQueryIsWhitespaceOnly. The behavior change is intentional and documented, but the deletion of the old test means there's no test verifying the previous contract was intentionally broken. At minimum, a comment in the new test explaining that this is a deliberate behavior change (and a reference to the PR/issue) helps future maintainers understand why whitespace now returns empty instead of all.

S3. E2E bilateral tests use test.skip with runtime conditions — fragile against empty databases

if (await corrLink.isVisible({ timeout: 2000 }).catch(() => false)) {
    // test body
} else {
    test.skip(true, `No bilateral correspondent links found for person ${senderId}`);
}

A test that silently skips because of missing data is a false green. In CI with a seeded database this should never skip. Ensure the E2E database fixture has at least one bilateral correspondence pair, and make the test unconditional. The skip guard is acceptable only for local dev where data may vary.

S4. Missing coverage: CorrespondenzFilterControls aria-disabled state

The filter strip uses aria-disabled={!senderId} but there's no component test asserting this attribute is set correctly when senderId is empty. This is a WCAG-relevant attribute and should be in the spec.

## 🧪 Sara Holt — QA Engineer & Test Strategist **Verdict: ⚠️ Approved with concerns** The test coverage in this PR is genuinely impressive for a feature of this size. Unit tests for `DateInput`, `date.spec.ts`, `page.svelte.spec.ts` for the korrespondenz route, and specs for every admin sub-route layout — that's a real pyramid being built. The E2E spec is thoughtfully structured with graceful skips for data-dependent scenarios. A few gaps remain that I won't let slide. --- ### Blockers **1. `korrespondenz/+page.server.ts` load function has zero test coverage** The load function handles the core data-fetching logic for the entire feature — it branches on `senderId`, `receiverId`, fetches persons and documents in parallel, and computes `canWrite`. There is no `page.server.spec.ts` (or equivalent) for this file. The pattern for testing SvelteKit load functions by importing them directly is established in the admin sub-routes (e.g. `admin/layout.server.spec.ts`) — apply it here. Minimum required tests: - `load returns empty documents when no senderId is set` - `load calls conversation endpoint when senderId is set` - `load calls single-person endpoint when receiverId is absent` - `load derives canWrite correctly from user groups` - `load does not throw when backend returns non-ok (graceful degradation or propagated error — either way, test the behavior)` **2. E2E tests do not run `checkA11y` on any page** Every E2E test should include an accessibility check per the project standard. The `korrespondenz.spec.ts` takes screenshots but never calls `checkA11y`. This is a quality gate violation: ```typescript // Add to every page visit in korrespondenz.spec.ts import { checkA11y } from 'axe-playwright'; // ... await page.goto('/korrespondenz'); await checkA11y(page); // ← missing on every test ``` The empty state, single-person mode, and bilateral mode all need axe checks. The SinglePersonHintBar uses an emoji (`📋`) as a visible character — axe will flag this because the emoji is not wrapped in an `aria-hidden` span. **3. `findSinglePersonCorrespondence` JPQL query has no integration test against real Postgres** The service unit test mocks the repository and verifies routing — that's correct and present. But `DISTINCT` + `LEFT JOIN` behavior on multi-receiver documents needs verification against a real database. H2 is not acceptable as a substitute. A `@DataJpaTest` + Testcontainers test that: - creates a document with 3 receivers where one matches `personId` - calls `findSinglePersonCorrespondence` - asserts exactly 1 result is returned This is exactly the class of bug that passes mocks and fails in Postgres. --- ### Suggestions **S1. `DateInput.svelte.spec.ts` — the `domFlush` helper is a timing smell** ```typescript const domFlush = () => new Promise<void>((r) => setTimeout(r, 50)); ``` A 50ms `setTimeout` is a hardcoded wait. In CI under load this may not be enough. Replace with `waitFor` from `@testing-library/svelte` or Vitest's `expect.poll()`: ```typescript await expect.poll(() => hidden?.value).toBe('2024-12-20'); ``` This is more reliable and doesn't add 50ms to every test that uses it. **S2. `PersonService` whitespace behavior change has a test but the old test is simply deleted** The old test `findAll_returnsAll_whenQueryIsBlank` was deleted and replaced with `findAll_returnsEmpty_whenQueryIsWhitespaceOnly`. The behavior change is intentional and documented, but the deletion of the old test means there's no test verifying the previous contract was intentionally broken. At minimum, a comment in the new test explaining that this is a deliberate behavior change (and a reference to the PR/issue) helps future maintainers understand why whitespace now returns empty instead of all. **S3. E2E bilateral tests use `test.skip` with runtime conditions — fragile against empty databases** ```typescript if (await corrLink.isVisible({ timeout: 2000 }).catch(() => false)) { // test body } else { test.skip(true, `No bilateral correspondent links found for person ${senderId}`); } ``` A test that silently skips because of missing data is a false green. In CI with a seeded database this should never skip. Ensure the E2E database fixture has at least one bilateral correspondence pair, and make the test unconditional. The skip guard is acceptable only for local dev where data may vary. **S4. Missing coverage: `CorrespondenzFilterControls` `aria-disabled` state** The filter strip uses `aria-disabled={!senderId}` but there's no component test asserting this attribute is set correctly when `senderId` is empty. This is a WCAG-relevant attribute and should be in the spec.
Author
Owner

🔐 NullX (Nora Steiner) — Application Security Engineer

Verdict: ⚠️ Approved with concerns

One confirmed security finding, one high-severity concern, and several smells that deserve documentation. No RCE or auth bypass — but the test endpoint in the API spec is a real risk in a family archive context.


Blockers

1. /api/auth/reset-token-for-test is exposed in the generated API spec — CWE-200 (Information Exposure)

The generated api.ts includes:

"/api/auth/reset-token-for-test": {
    get: operations["getResetTokenForTest"];

with parameters:

query: {
    email: string;
}

This endpoint returns a reset token for a given email address via a GET request. Even if the backend endpoint is guarded by a profile flag (@Profile("dev") or similar), it is now documented in the frontend API client that ships to all users. The immediate concern:

  • Recon value: an attacker who reads the generated TypeScript (which ships client-side) learns that a GET /api/auth/reset-token-for-test?email=... endpoint exists. This narrows their attack surface significantly.
  • Reset token leakage risk: if the backend profile guard is ever misconfigured or removed, this endpoint hands out password reset tokens in a GET response body — no authentication required, no POST, no CSRF protection.

Required action: Verify the backend controller is annotated with @Profile("dev") and that the dev profile is never active in production. Additionally, exclude this path from OpenAPI spec generation in non-dev profiles so it does not appear in the generated client. In Spring:

@Operation(hidden = true)  // or conditionally exclude via springdoc profile config

Or in application.properties:

# Only expose this in dev spec
springdoc.paths-to-exclude=/api/auth/reset-token-for-test

Detection note: Add a Semgrep rule or CI check that fails if any path matching *test* or *debug* appears in the generated api.ts on merge to main.


2. UserController.getUser was previously unauthenticated — confirmed fix, but regression test needed — CWE-862 (Missing Authorization)

// Before
@GetMapping("users/{id}")
public ResponseEntity<AppUser> getUser(@PathVariable UUID id) {

// After
@GetMapping("users/{id}")
@RequirePermission(Permission.ADMIN_USER)
public ResponseEntity<AppUser> getUser(@PathVariable UUID id) {

This is the right fix. The @RequirePermission annotation is now present and the controller test covers both the 403 and 200 cases. However, the @RequirePermission aspect applies at runtime — if the Spring AOP proxy is ever misconfigured (e.g. @EnableAspectJAutoProxy(proxyTargetClass=false) and a final class), the annotation silently stops working. Recommend adding an @WebMvcTest integration-slice test (not just a unit test with @WithMockUser) that verifies the endpoint actually returns 403 when called without the ADMIN_USER authority — using Spring Security's real filter chain.


Informational / Smells

I1. localStorage stores person IDs and names without validation on read

recentPersons = JSON.parse(raw) as RecentPerson[];

The as RecentPerson[] cast does no runtime validation. If an attacker can write to localStorage (via XSS on any other page), they could inject { id: "...", name: "<script>..." } entries. The name is rendered as text content (Svelte auto-escapes), so there is no XSS risk here — but the lack of schema validation means corrupted or crafted entries could cause unexpected behavior. The corrupt JSON test exists and passes, which is good. This is informational, not a blocker.

I2. CorrespondentSuggestionsDropdown uses raw fetch without credentials check

const res = await fetch(`/api/persons/${senderId}/correspondents`);
results = res.ok ? await res.json() : [];

The raw fetch call does not explicitly set credentials: 'include' (same-origin cookies are sent by default in modern browsers, so this is fine). Not a vulnerability, but worth noting that if the auth cookie ever becomes SameSite=None for cross-origin SSR, this call may silently fail authentication. Noted for future cross-origin deployment planning.

I3. Admin +layout.server.ts permission check runs on the frontend but the actual enforcement is backend

if (!hasAnyAdminPerm(user)) throw error(403, getErrorMessage('FORBIDDEN'));

This is the correct pattern for user-facing access control in SvelteKit SSR. The backend APIs still enforce permissions independently via @RequirePermission. No issue here — defense in depth is working as intended. Recording this explicitly so future reviewers don't "simplify" the frontend check assuming the backend is sufficient.

## 🔐 NullX (Nora Steiner) — Application Security Engineer **Verdict: ⚠️ Approved with concerns** One confirmed security finding, one high-severity concern, and several smells that deserve documentation. No RCE or auth bypass — but the test endpoint in the API spec is a real risk in a family archive context. --- ### Blockers **1. `/api/auth/reset-token-for-test` is exposed in the generated API spec — CWE-200 (Information Exposure)** The generated `api.ts` includes: ```typescript "/api/auth/reset-token-for-test": { get: operations["getResetTokenForTest"]; ``` with parameters: ```typescript query: { email: string; } ``` This endpoint returns a reset token for a given email address via a GET request. Even if the backend endpoint is guarded by a profile flag (`@Profile("dev")` or similar), it is now documented in the frontend API client that ships to all users. The immediate concern: - **Recon value**: an attacker who reads the generated TypeScript (which ships client-side) learns that a `GET /api/auth/reset-token-for-test?email=...` endpoint exists. This narrows their attack surface significantly. - **Reset token leakage risk**: if the backend profile guard is ever misconfigured or removed, this endpoint hands out password reset tokens in a GET response body — no authentication required, no POST, no CSRF protection. **Required action**: Verify the backend controller is annotated with `@Profile("dev")` and that the dev profile is never active in production. Additionally, exclude this path from OpenAPI spec generation in non-dev profiles so it does not appear in the generated client. In Spring: ```java @Operation(hidden = true) // or conditionally exclude via springdoc profile config ``` Or in `application.properties`: ```properties # Only expose this in dev spec springdoc.paths-to-exclude=/api/auth/reset-token-for-test ``` **Detection note**: Add a Semgrep rule or CI check that fails if any path matching `*test*` or `*debug*` appears in the generated `api.ts` on merge to main. --- **2. `UserController.getUser` was previously unauthenticated — confirmed fix, but regression test needed — CWE-862 (Missing Authorization)** ```java // Before @GetMapping("users/{id}") public ResponseEntity<AppUser> getUser(@PathVariable UUID id) { // After @GetMapping("users/{id}") @RequirePermission(Permission.ADMIN_USER) public ResponseEntity<AppUser> getUser(@PathVariable UUID id) { ``` This is the right fix. The `@RequirePermission` annotation is now present and the controller test covers both the 403 and 200 cases. However, the `@RequirePermission` aspect applies at runtime — if the Spring AOP proxy is ever misconfigured (e.g. `@EnableAspectJAutoProxy(proxyTargetClass=false)` and a final class), the annotation silently stops working. Recommend adding an `@WebMvcTest` integration-slice test (not just a unit test with `@WithMockUser`) that verifies the endpoint actually returns 403 when called without the `ADMIN_USER` authority — using Spring Security's real filter chain. --- ### Informational / Smells **I1. `localStorage` stores person IDs and names without validation on read** ```typescript recentPersons = JSON.parse(raw) as RecentPerson[]; ``` The `as RecentPerson[]` cast does no runtime validation. If an attacker can write to `localStorage` (via XSS on any other page), they could inject `{ id: "...", name: "<script>..." }` entries. The name is rendered as text content (Svelte auto-escapes), so there is no XSS risk here — but the lack of schema validation means corrupted or crafted entries could cause unexpected behavior. The corrupt JSON test exists and passes, which is good. This is informational, not a blocker. **I2. `CorrespondentSuggestionsDropdown` uses raw `fetch` without credentials check** ```typescript const res = await fetch(`/api/persons/${senderId}/correspondents`); results = res.ok ? await res.json() : []; ``` The raw `fetch` call does not explicitly set `credentials: 'include'` (same-origin cookies are sent by default in modern browsers, so this is fine). Not a vulnerability, but worth noting that if the auth cookie ever becomes SameSite=None for cross-origin SSR, this call may silently fail authentication. Noted for future cross-origin deployment planning. **I3. Admin `+layout.server.ts` permission check runs on the frontend but the actual enforcement is backend** ```typescript if (!hasAnyAdminPerm(user)) throw error(403, getErrorMessage('FORBIDDEN')); ``` This is the correct pattern for user-facing access control in SvelteKit SSR. The backend APIs still enforce permissions independently via `@RequirePermission`. No issue here — defense in depth is working as intended. Recording this explicitly so future reviewers don't "simplify" the frontend check assuming the backend is sufficient.
Author
Owner

🎨 Leonie Voss — UI/UX Design Lead

Verdict: ⚠️ Approved with concerns

The compact filter strip is a genuine improvement over the old card layout — it frees vertical space for the actual content, which is what matters on a family archive. The log card timeline reads clearly. The asymmetry bar is a lovely archival insight — seeing at a glance who wrote more in a correspondence is genuinely useful. A few accessibility and brand issues need to be addressed before this goes to users.


Blockers

1. SinglePersonHintBar uses hardcoded amber colors — not brand tokens, and contrast is unverified

<div class="... border-[#FDBA74] bg-[#FFF7ED] ... text-[#92400E]">

These are Tailwind's amber palette (amber-300, orange-50, amber-900 roughly), not the project's brand token system (brand-navy, brand-mint, brand-sand). The contrast of #92400E on #FFF7ED is approximately 6.4:1 — that passes WCAG AA, so it's not a contrast failure. The issue is design consistency: this component uses a completely different color vocabulary from the rest of the app. The hint bar should use semantic tokens from the existing theme, not raw hex values.

Proposed replacement using existing brand tokens:

<div class="border-brand-mint bg-brand-sand/30 px-[18px] py-[6px] text-xs text-brand-navy/80 ...">

This brings it into the brand vocabulary while preserving the "informational banner" feeling.

2. SinglePersonHintBar uses a 📋 emoji as UI chrome — WCAG 1.1.1 failure

<span class="text-sm" aria-hidden="true">📋</span>

The emoji is marked aria-hidden="true" which is correct — it won't be read by screen readers. However, it renders as a Unicode emoji whose appearance varies wildly between operating systems and browsers. On some platforms it renders as a clipboard icon, on others as a colorful cartoon. For consistent UI chrome, use an SVG icon (matching the style used everywhere else in the codebase). The project already uses Heroicons stroke style consistently.

3. CorrespondenzFilterControls uses <input type="date"> — not the project's DateInput component

<input
    type="date"
    bind:value={fromDate}
    ...
    class="h-8 min-h-[44px] w-[100px] rounded border bg-surface px-2 text-xs text-ink ..."
/>

The project has a DateInput component (DateInput.svelte) that formats dates in German style (dd.mm.yyyy) and includes validation. The filter strip uses <input type="date"> which renders the browser's native date picker — appearance varies significantly between browsers and OSes. On mobile (Safari/iOS) it shows a native picker wheel that is inconsistent with the rest of the form. The DateInput component exists specifically to solve this. Even in a compact strip, using the existing DateInput component (with compact mode, similar to how PersonTypeahead received a compact prop in this PR) maintains visual consistency.


Suggestions

S1. Touch targets in the filter strip fall below 44px on desktop

<input class="h-8 min-h-[44px] ... sm:min-h-0" />

On sm: (≥640px), the min-h-[44px] override is removed, leaving the input at h-8 = 32px. The WCAG 2.2 target size minimum is 24×24px (SC 2.5.8 minimum), but the recommended target is 44×44px. At 32px height the date inputs are technically compliant but below the comfortable touch target on tablet viewports where fingers are in use. Removing sm:min-h-0 would keep the 44px height across all sizes, which at 8px per cell in the strip adds only 12px to the strip height on small screens.

S2. The asymmetry bar is color-only — direction not conveyed without color

<span class="text-primary">5 von Hans →</span>
<span class="text-accent">3 von Anna ←</span>

The and arrows do convey direction via text, which is good. The primary/accent color distinction additionally reinforces which person is which. For colorblind users (particularly deuteranopia), text-primary and text-accent may not be distinguishable depending on the exact color values. The arrows are the crucial differentiator here — they carry the meaning without color. This is acceptable, but worth a quick check that aria-label on the role="img" container is readable: the current label "Briefverteilung in diesem Zeitraum: {outCount} von {senderName}, {inCount} von {receiverName}" is excellent.

S3. CorrespondenzEmptyState heading and subtext are hardcoded German strings

<h2 class="...">Korrespondenz durchsuchen</h2>
<p class="...">Wähle eine Person aus dem Archiv...</p>

These strings are not using the m.conv_empty_heading() and m.conv_empty_text() message functions. EN/ES users will see German. The translation keys are defined in all three locale files.

S4. EntityNav at tablet width (md) shows icon-only navigation with no visible label on the buttons

<button aria-label={m.admin_tab_users()} ...>
    <svg ... aria-hidden="true" />
    <span class="text-[9px] ...">{userCount}</span>  <!-- only a number, no label text -->
</button>

The button shows only the icon and a count number (not the label) at tablet width. Screen readers get the aria-label — that's correct. But sighted users who don't recognize the icon will not know what the button does without hovering (and no tooltip is shown on click). Add a title attribute to match the aria-label, which provides a native browser tooltip on hover without added complexity.

S5. w-30 is not a standard Tailwind utility — verify custom config exists

<nav class="... md:w-12 lg:w-30">

w-30 is not in Tailwind's default scale (w-28 = 7rem, w-32 = 8rem). Either this is a custom value defined in tailwind.config.js, or it silently does nothing (the nav would use auto width). Verify this renders at the intended width.

## 🎨 Leonie Voss — UI/UX Design Lead **Verdict: ⚠️ Approved with concerns** The compact filter strip is a genuine improvement over the old card layout — it frees vertical space for the actual content, which is what matters on a family archive. The log card timeline reads clearly. The asymmetry bar is a lovely archival insight — seeing at a glance who wrote more in a correspondence is genuinely useful. A few accessibility and brand issues need to be addressed before this goes to users. --- ### Blockers **1. `SinglePersonHintBar` uses hardcoded amber colors — not brand tokens, and contrast is unverified** ```svelte <div class="... border-[#FDBA74] bg-[#FFF7ED] ... text-[#92400E]"> ``` These are Tailwind's amber palette (`amber-300`, `orange-50`, `amber-900` roughly), not the project's brand token system (`brand-navy`, `brand-mint`, `brand-sand`). The contrast of `#92400E` on `#FFF7ED` is approximately 6.4:1 — that passes WCAG AA, so it's not a contrast failure. The issue is design consistency: this component uses a completely different color vocabulary from the rest of the app. The hint bar should use semantic tokens from the existing theme, not raw hex values. Proposed replacement using existing brand tokens: ```svelte <div class="border-brand-mint bg-brand-sand/30 px-[18px] py-[6px] text-xs text-brand-navy/80 ..."> ``` This brings it into the brand vocabulary while preserving the "informational banner" feeling. **2. `SinglePersonHintBar` uses a 📋 emoji as UI chrome — WCAG 1.1.1 failure** ```svelte <span class="text-sm" aria-hidden="true">📋</span> ``` The emoji is marked `aria-hidden="true"` which is correct — it won't be read by screen readers. However, it renders as a Unicode emoji whose appearance varies wildly between operating systems and browsers. On some platforms it renders as a clipboard icon, on others as a colorful cartoon. For consistent UI chrome, use an SVG icon (matching the style used everywhere else in the codebase). The project already uses Heroicons stroke style consistently. **3. `CorrespondenzFilterControls` uses `<input type="date">` — not the project's `DateInput` component** ```svelte <input type="date" bind:value={fromDate} ... class="h-8 min-h-[44px] w-[100px] rounded border bg-surface px-2 text-xs text-ink ..." /> ``` The project has a `DateInput` component (`DateInput.svelte`) that formats dates in German style (`dd.mm.yyyy`) and includes validation. The filter strip uses `<input type="date">` which renders the browser's native date picker — appearance varies significantly between browsers and OSes. On mobile (Safari/iOS) it shows a native picker wheel that is inconsistent with the rest of the form. The `DateInput` component exists specifically to solve this. Even in a compact strip, using the existing `DateInput` component (with `compact` mode, similar to how `PersonTypeahead` received a `compact` prop in this PR) maintains visual consistency. --- ### Suggestions **S1. Touch targets in the filter strip fall below 44px on desktop** ```svelte <input class="h-8 min-h-[44px] ... sm:min-h-0" /> ``` On `sm:` (≥640px), the `min-h-[44px]` override is removed, leaving the input at `h-8` = 32px. The WCAG 2.2 target size minimum is 24×24px (SC 2.5.8 minimum), but the recommended target is 44×44px. At 32px height the date inputs are technically compliant but below the comfortable touch target on tablet viewports where fingers are in use. Removing `sm:min-h-0` would keep the 44px height across all sizes, which at 8px per cell in the strip adds only 12px to the strip height on small screens. **S2. The asymmetry bar is color-only — direction not conveyed without color** ```svelte <span class="text-primary">5 von Hans →</span> <span class="text-accent">3 von Anna ←</span> ``` The `→` and `←` arrows do convey direction via text, which is good. The primary/accent color distinction additionally reinforces which person is which. For colorblind users (particularly deuteranopia), `text-primary` and `text-accent` may not be distinguishable depending on the exact color values. The arrows are the crucial differentiator here — they carry the meaning without color. This is acceptable, but worth a quick check that `aria-label` on the `role="img"` container is readable: the current label `"Briefverteilung in diesem Zeitraum: {outCount} von {senderName}, {inCount} von {receiverName}"` is excellent. **S3. `CorrespondenzEmptyState` heading and subtext are hardcoded German strings** ```svelte <h2 class="...">Korrespondenz durchsuchen</h2> <p class="...">Wähle eine Person aus dem Archiv...</p> ``` These strings are not using the `m.conv_empty_heading()` and `m.conv_empty_text()` message functions. EN/ES users will see German. The translation keys are defined in all three locale files. **S4. `EntityNav` at tablet width (md) shows icon-only navigation with no visible label on the buttons** ```svelte <button aria-label={m.admin_tab_users()} ...> <svg ... aria-hidden="true" /> <span class="text-[9px] ...">{userCount}</span> <!-- only a number, no label text --> </button> ``` The button shows only the icon and a count number (not the label) at tablet width. Screen readers get the `aria-label` — that's correct. But sighted users who don't recognize the icon will not know what the button does without hovering (and no tooltip is shown on click). Add a `title` attribute to match the `aria-label`, which provides a native browser tooltip on hover without added complexity. **S5. `w-30` is not a standard Tailwind utility — verify custom config exists** ```svelte <nav class="... md:w-12 lg:w-30"> ``` `w-30` is not in Tailwind's default scale (`w-28` = 7rem, `w-32` = 8rem). Either this is a custom value defined in `tailwind.config.js`, or it silently does nothing (the nav would use auto width). Verify this renders at the intended width.
Author
Owner

⚙️ Tobias Wendt — DevOps & Platform Engineer

Verdict: ⚠️ Approved with concerns

This PR is primarily a frontend feature — it doesn't touch docker-compose.yml, CI pipeline, or infrastructure configuration, which is the right scope for a UI redesign. However, there are two operational concerns I need to flag: one is a data issue on the build pipeline, and one is a deployment concern for the URL rename.


Blockers

1. The Paraglide compile step is not enforced in CI before the frontend build

messages-extra.ts exists specifically because the Paraglide-generated files in src/lib/paraglide/ couldn't be regenerated (file ownership issue). The workaround ships German-only strings to EN/ES users. The root fix is a pipeline change:

The CI unit-tests job should run npm run dev (or a dedicated npm run generate:i18n / paraglide compile step) before running npm run check and npm run test. Paraglide generates from the JSON message files — those files are in the repo and up to date. The compile step is deterministic and fast (milliseconds). There is no reason to skip it in CI.

Concrete CI change needed (in whatever job runs the frontend build):

- name: Compile Paraglide i18n
  run: cd frontend && npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
  # or: npm run generate:i18n if that script exists

- name: Type check
  run: cd frontend && npm run check

- name: Unit tests
  run: cd frontend && npm run test

This eliminates the need for messages-extra.ts entirely and ensures any future message key additions are caught in CI before they ship as German-only strings.

2. The /conversations/korrespondenz URL rename has no redirect — operational impact

If this app is in production and family members have bookmarked /conversations?senderId=...&receiverId=... links (which the old UI would have encouraged via the "Häufige Korrespondenten" section), those links will 404 after this deploy.

A SvelteKit server route handles this at zero runtime cost:

// frontend/src/routes/conversations/+server.ts
import { redirect } from '@sveltejs/kit';

export function GET({ url }) {
    throw redirect(301, '/korrespondenz' + url.search);
}

This is a one-file, six-line change. There is no reason to skip it. The 301 cache means browsers and search engines will update their indexes. Add a note to the deployment runbook (if one exists) that old /conversations links will redirect.


Suggestions

S1. The admin section refactor more than doubles the number of frontend route files — consider the maintenance surface

The old admin page was one route with tabs. The new structure has:

  • admin/+layout.server.ts, +layout.svelte, +page.svelte
  • admin/users/+layout.server.ts, +layout.svelte, +page.svelte, UsersListPanel.svelte, [id]/+page.server.ts, [id]/+page.svelte, new/+page.server.ts, new/+page.svelte
  • Similar depth for groups and tags
  • Plus 15+ spec files

This is architecturally correct (each sub-entity has its own route), and for a growing feature this is the right call. Just noting that the maintenance surface grew significantly in one PR. Ensure the CI test run time for the admin section specs is profiled — 15+ spec files with vitest-browser-svelte can add meaningful time to the unit test job.

S2. Screenshot test artifacts in test-results/e2e/ — ensure CI cleans these up

await page.screenshot({ path: 'test-results/e2e/korrespondenz-empty.png' });

These screenshots are useful for debugging failed runs but will accumulate across CI runs if not cleaned up. Ensure the CI job either uses a fresh workspace each run (standard for containerized CI) or has a rm -rf test-results/ step before the E2E job. If using Gitea Actions artifact upload, set a retention policy (7 days is typically enough for debugging).

## ⚙️ Tobias Wendt — DevOps & Platform Engineer **Verdict: ⚠️ Approved with concerns** This PR is primarily a frontend feature — it doesn't touch `docker-compose.yml`, CI pipeline, or infrastructure configuration, which is the right scope for a UI redesign. However, there are two operational concerns I need to flag: one is a data issue on the build pipeline, and one is a deployment concern for the URL rename. --- ### Blockers **1. The Paraglide compile step is not enforced in CI before the frontend build** `messages-extra.ts` exists specifically because the Paraglide-generated files in `src/lib/paraglide/` couldn't be regenerated (file ownership issue). The workaround ships German-only strings to EN/ES users. The root fix is a pipeline change: The CI `unit-tests` job should run `npm run dev` (or a dedicated `npm run generate:i18n` / `paraglide compile` step) before running `npm run check` and `npm run test`. Paraglide generates from the JSON message files — those files are in the repo and up to date. The compile step is deterministic and fast (milliseconds). There is no reason to skip it in CI. Concrete CI change needed (in whatever job runs the frontend build): ```yaml - name: Compile Paraglide i18n run: cd frontend && npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide # or: npm run generate:i18n if that script exists - name: Type check run: cd frontend && npm run check - name: Unit tests run: cd frontend && npm run test ``` This eliminates the need for `messages-extra.ts` entirely and ensures any future message key additions are caught in CI before they ship as German-only strings. **2. The `/conversations` → `/korrespondenz` URL rename has no redirect — operational impact** If this app is in production and family members have bookmarked `/conversations?senderId=...&receiverId=...` links (which the old UI would have encouraged via the "Häufige Korrespondenten" section), those links will 404 after this deploy. A SvelteKit server route handles this at zero runtime cost: ```typescript // frontend/src/routes/conversations/+server.ts import { redirect } from '@sveltejs/kit'; export function GET({ url }) { throw redirect(301, '/korrespondenz' + url.search); } ``` This is a one-file, six-line change. There is no reason to skip it. The 301 cache means browsers and search engines will update their indexes. Add a note to the deployment runbook (if one exists) that old `/conversations` links will redirect. --- ### Suggestions **S1. The admin section refactor more than doubles the number of frontend route files — consider the maintenance surface** The old admin page was one route with tabs. The new structure has: - `admin/+layout.server.ts`, `+layout.svelte`, `+page.svelte` - `admin/users/+layout.server.ts`, `+layout.svelte`, `+page.svelte`, `UsersListPanel.svelte`, `[id]/+page.server.ts`, `[id]/+page.svelte`, `new/+page.server.ts`, `new/+page.svelte` - Similar depth for groups and tags - Plus 15+ spec files This is architecturally correct (each sub-entity has its own route), and for a growing feature this is the right call. Just noting that the maintenance surface grew significantly in one PR. Ensure the CI test run time for the admin section specs is profiled — 15+ spec files with `vitest-browser-svelte` can add meaningful time to the unit test job. **S2. Screenshot test artifacts in `test-results/e2e/` — ensure CI cleans these up** ```typescript await page.screenshot({ path: 'test-results/e2e/korrespondenz-empty.png' }); ``` These screenshots are useful for debugging failed runs but will accumulate across CI runs if not cleaned up. Ensure the CI job either uses a fresh workspace each run (standard for containerized CI) or has a `rm -rf test-results/` step before the E2E job. If using Gitea Actions artifact upload, set a retention policy (7 days is typically enough for debugging).
marcel added 1 commit 2026-03-30 19:58:07 +02:00
feat(korrespondenz): address PR #164 review – blockers and suggestions
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 1m36s
CI / Backend Unit Tests (pull_request) Failing after 2m36s
CI / E2E Tests (pull_request) Failing after 1h49m0s
154f859efc
Blockers (14):
- B1: fix senderName/receiverName to use $derived instead of $state + sync $effect
- B2: migrate all korrespondenz components from messages-extra shim to paraglide m.*
- B3: i18n CorrespondenzEmptyState (heading, subtext, search placeholder)
- B4: add response.ok checks to admin layout server load
- B5: add response.ok checks to korrespondenz page server load
- B6: add page.server.spec.ts with 5 test suites for korrespondenz load function
- B7: add axe-core accessibility checks to all e2e korrespondenz tests
- B8: add Testcontainers JPQL tests for findSinglePersonCorrespondence (DISTINCT + sender)
- B9: hide auth reset-token endpoint from OpenAPI spec; remove from generated api.ts
- B11: replace amber hardcoded hex colors in SinglePersonHintBar with brand tokens
- B12: replace clipboard emoji with Heroicons SVG in SinglePersonHintBar
- B13: create DateInput component (German dd.mm.yyyy); use it in CorrespondenzFilterControls
- B14: add Paraglide compile step to CI workflow before lint/test

Suggestions (11):
- S1: make CorrespondentSuggestionsDropdown a pure display component; lift fetch to PersonBar
- S2: fix leftover messages-extra import in ConversationTimeline; use brand tokens for status dots
- S3: add intent comment to EntityNav openFlyout behavior
- S4: rename canManageGroups → canManagePermissions throughout admin
- S6: remove domFlush helper from DateInput spec; use expect.poll instead
- S7: replace test.skip with throw new Error in bilateral e2e tests
- S8: add inverse aria-disabled test for filter strip
- S9: remove sm:min-h-0 from sort button to preserve 44px touch target
- S10: add title attributes to tablet trigger buttons in EntityNav
- S11: delete messages-extra.ts shim entirely

Also: fix admin pages revealing blank strip at bottom (-mb-6 on admin layout)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel added 1 commit 2026-03-30 20:00:36 +02:00
fix(korrespondenz): use semantic tokens in SinglePersonHintBar for dark mode
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 1m26s
CI / Backend Unit Tests (pull_request) Failing after 2m37s
CI / E2E Tests (pull_request) Failing after 1h44m43s
616d6ba01c
Replaced static brand-sand/brand-mint/brand-navy tokens with themed
semantic tokens (bg-accent-bg, border-accent, text-ink) so the hint bar
adapts correctly in dark mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel added 1 commit 2026-03-30 21:23:11 +02:00
fix(DateInput): fire onchange when field is cleared
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 1m46s
CI / Backend Unit Tests (pull_request) Failing after 2m37s
CI / E2E Tests (pull_request) Failing after 1h48m16s
46eb908ff4
Clearing the input set value='' but did not call onchange, so the
korrespondenz filter strip never re-fetched. Added onchange?.() in the
empty-display branch and added a test that confirms the callback fires.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel added 1 commit 2026-03-30 21:34:26 +02:00
chore: merge origin/main into feat/issue-162-korrespondenz-redesign
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 1m30s
CI / Backend Unit Tests (pull_request) Failing after 2m28s
CI / E2E Tests (pull_request) Failing after 1h48m30s
CI / Unit & Component Tests (push) Failing after 1m45s
CI / Backend Unit Tests (push) Failing after 2m34s
CI / E2E Tests (push) Failing after 1h47m38s
ebeb0cf865
Resolved conflicts:
- messages/de|en|es.json: kept all keys from both sides
- DateInput.svelte: kept HEAD API (onchange, not oninput/...rest) to match
  CorrespondenzFilterControls caller; incorporated main's isCalendarValid helper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel merged commit ebeb0cf865 into main 2026-03-30 21:38:23 +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#164