ui: Korrespondenz view — unclear empty state, misplaced search header, focus misdirection, small text #179
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
User Feedback Summary
First-time users were disoriented in the Korrespondenz view. Three distinct problems were identified in a usability session.
Problem 1 — Search header placed too high / low visual hierarchy
The search bar sits in a position that makes it feel disconnected from the empty content area. Users didn't immediately understand that the search bar is the primary entry point into the view.
Fix: The search bar (or a clear call-to-action label) should be visually centred in the empty state — not anchored to the top. Consider a hero-style empty state with the search bar as the focal point, similar to a start page.
Problem 2 — Empty state search triggers wrong input field
When the user types in the empty-state search prompt, focus is incorrectly moved to a search input inside a row element instead of staying on the empty-state search bar itself. The search should execute from — and stay anchored to — the empty-state input.
Fix: The empty-state search bar must retain focus throughout the interaction. Do not steal focus or delegate keystrokes to another input. The search should run directly from the empty-state field.
Problem 3 — Text in the empty/initial view is too small
Body text and hints in the empty/initial Korrespondenz view are rendered at a size that falls below the 16px minimum required for our dual-audience design (seniors 60+).
Fix: All visible text in the empty state must be
text-base(16px) minimum. Hint/helper text may usetext-sm(14px) only if it is supplementary, never for primary messaging.Acceptance Criteria
text-base)text-sm)🎨 Leonie Voss — UX Design Lead
Design discussion outcomes — interaction model and layout decisions agreed with the product owner.
Resolved
Interaction model — sequential hero: Empty state shows a single centred Person A typeahead (
h-14/ 56px). After Person A is selected, it becomes a locked chip and Person B typeahead appears in its place. Once both are selected, the hero collapses and the top filter bar becomes active with results below.Top bar prominence: After results load, the filter bar stays at the top but inputs are bumped to
h-12(48px) — more prominent than the current ~42px height.Progressive disclosure: Date range and sort controls are only shown in the top bar state. They are irrelevant before two people are selected and should not appear in the hero empty state.
Text sizes: Primary empty state text
text-baseminimum (16px). Supplementary/hint texttext-sm(14px) is acceptable for non-primary messaging only.Touch targets: Hero search input
h-14(56px) — comfortably exceeds the 44px minimum for seniors and mobile users.Implementation notes
ConversationFilterBaralways renders as a full panel. It will need to be split into two display states: hero (single typeahead, centred) and compact top bar (full controls,h-12inputs).senderIdandreceiverIdseparately — these can drive which hero step is active (!senderId→ show Person A input,senderId && !receiverId→ show Person B input, both set → show results + top bar).The current filter-panel-always-visible pattern was the root cause of all three reported problems. The sequential hero removes that ambiguity entirely.
👨💻 Felix Brandt — Senior Fullstack Developer
Component decomposition
The current
ConversationFilterBar.sveltedoes one job today (full filter panel) but will need to serve two visual states after this change. I'd split it before writing a line of logic:ConversationHero.svelte— the centred hero, owns the sequential step displayConversationHeroStep.svelte— a single step (label + PersonTypeahead + locked chip), reused for both Person A and BConversationFilterBar.svelte— the existing top bar, bumped toh-12inputs, unchanged in structure+page.sveltealready holdssenderIdandreceiverIdas$state. The three render cases (!senderId,senderId && !receiverId,both) map directly to{#if}branches selecting either the hero or the bar.Auto-focus on Person B
When Person A is selected and Person B's input appears, it needs to receive focus automatically — otherwise users on desktop have to click, and keyboard users are lost. This requires
bind:thison the Person B typeahead + atick()+element.focus()after the state transition. Flag this as required in the AC, it's easy to forget.Bookmarked / deep-linked URLs
+page.sveltereadssenderIdandreceiverIdfrom URL params via thedataprop. If both are already set on initial load (e.g. a bookmarked conversation), the hero should be skipped entirely and results rendered immediately. The current logic already handles this — just confirm it still holds after the refactor.Test surface
ontoggleSort,onswapPersons,onapplyFiltersstill fire correctly from the top bar statekeepFocus: trueingoto()is preserved — removing it will break typeahead keyboard navigation🏗️ Markus Keller — Application Architect
No backend changes required
This is a pure frontend refactor. No new API endpoints, no schema changes, no Spring Boot work. The existing
senderId/receiverIdquery params drive everything — the three UI states are entirely derived from those two values.SSR behaviour on initial load
SvelteKit renders
+page.svelteserver-side on first request. If a bookmarked URL arrives with?senderId=X&receiverId=Y, the server load function populates both, and the page should hydrate directly into the results view — hero never rendered. This is the correct behaviour and requires no special handling, but it should be validated during implementation since it affects whether the hero component is ever mounted server-side.State machine is clean
The three states (
!senderId,senderId && !receiverId,both) map to a linear state machine with no cycles. No$effectchains needed —$derivedbooleans fromsenderIdandreceiverIddrive the{#if}branches cleanly. Keep it that way.One coupling risk
ConversationFilterBarcurrently holds the swap-persons button, which is only meaningful when both persons are selected. After the split, ensure the swap button does not accidentally appear in the hero state. The existing{senderId && receiverId ? 'flex' : 'hidden md:flex'}conditional handles this today — make sure it survives the refactor.No ADR needed
This is a UX improvement within existing module boundaries, not an architectural decision. No ADR required.
🧪 Sara Holt — QA Engineer
Missing acceptance criteria for the sequential flow
The current AC covers the end states but not the transitions. I'd add:
/conversations?senderId=X&receiverId=Yskips the hero and renders results directlyTest plan
Vitest (component layer):
ConversationHerorenders Person A input whensenderIdis emptyConversationHerorenders locked Person A chip + Person B input whensenderIdis set butreceiverIdis emptyConversationHerois not rendered when both are setPlaywright (E2E):
/conversations→ select Person A → verify step 2 appears with focus → select Person B → verify top bar visible and results load/conversations?senderId=X&receiverId=Y→ verify hero is never showncheckA11yon all three states (hero step 1, hero step 2, results view)Edge cases not covered in the issue
restrictToCorrespondentsOfprop on the Person B typeahead already handles filtering — but if it returns zero results, the user is stuck in step 2 with no way to proceed unless they can clear Person A.🔒 Nora "NullX" Steiner — Security Engineer
No new attack surface introduced
This is a UI-only refactor. No new endpoints, no new data flows, no auth boundary changes. Nothing to flag on the change itself.
Pre-existing smell worth noting while we're here
The Person B typeahead uses
restrictToCorrespondentsOf={senderId}to filter suggestions to only people who have corresponded with Person A. This is a UX convenience — but if the filtering is enforced only on the frontend, an attacker can call the persons search API directly without the filter parameter and enumerate all persons in the archive regardless of correspondence history.This isn't introduced by this issue, but since the hero redesign makes the filter more prominent (Person B explicitly restricted to Person A's correspondents), it's worth confirming the backend enforces the same constraint — or at minimum acknowledges that persons search is intentionally unrestricted.
Recommendation: Check whether
GET /api/persons?correspondentOf={id}is the enforced query, or if the typeahead just filters client-side. If it's client-side only, that's an information disclosure finding against the existing implementation. Low severity for a family archive with known users, but worth tracking.🚀 Tobias Wendt — DevOps & Platform Engineer
No infrastructure impact
Pure SvelteKit frontend change. No new environment variables, no new services, no Docker Compose changes, no CI pipeline changes needed.
One thing to watch: SSR bundle size
The hero adds a new visual state with its own component tree. At this scale it's negligible, but if the hero includes any new animation library imports, check the bundle size delta with
npm run buildbefore merging — SvelteKit's output includes per-route chunk sizes in the build log.CI note
The existing Gitea Actions workflow runs
npm run check(svelte-check) andnpm run buildon every push. The new components will be type-checked automatically. No workflow changes needed.That's all from my side — clean change from an ops perspective.
🎨 Leonie Voss — UX Design Lead
The sequential hero design is agreed (see earlier discussion comment). A few implementation details that need to be defined before the developer starts:
Person A chip design (unspecified)
Once Person A is selected and Person B's input appears, Person A must be shown as a chip or label — not just disappear. The chip needs:
text-base(16px)Without this, users who pick the wrong person have no recovery path.
Transition between steps
No animation library needed — a simple CSS opacity + translate transition on the entering element is enough. Keep it under 200ms so it doesn't feel sluggish for senior users. Do not use
prefers-reduced-motion-breaking animations."No correspondents" empty state in step 2
Sara raised this too — if Person A has no correspondents, Person B's typeahead is empty and the user is stuck. The hero step 2 should show a fallback message inline: e.g. "Keine Korrespondenz für [Name] gefunden" with a link back to step 1. This is a usability blocker if unaddressed.
Top bar input height
The agreed
h-12(48px) applies to thePersonTypeaheadinputs inConversationFilterBar. The date inputs and sort button should match this height for visual consistency — currently the sort button ish-[42px]hardcoded. Align everything toh-12.🎨 Leonie Voss — UX Design Lead
Design direction update — sequential hero dropped, discovery framing adopted. Supersedes the earlier interaction model comment.
Resolved
Sequential hero dropped — The UI works with one person selected. No need for a two-step sequential input. The hero shows a single centred PersonTypeahead (
h-14/ 56px). Once a person is selected, results load immediately.Person B is optional — After selection, the view collapses to the top bar. Both PersonTypeaheads are shown symmetrically: Person A pre-filled and editable, Person B empty with placeholder "Optional: zweite Person". Person B narrows the results to a specific exchange but is never required.
Hero headline: "Wessen Briefe möchten Sie lesen?" — warm, personal, action-oriented. Signals exploration, not search.
Cross-link above the typeahead: "Suchen Sie ein bestimmtes Dokument? → Zur Dokumentensuche" —
text-sm, placed above the typeahead in the hero. Helps users who land here looking for a specific document find their way to the document search page.Nav label + page title: "Briefwechsel" — replaces "Korrespondenz" throughout. Warmer, more accessible to non-archivists and senior users.
Superseded from previous discussion
Sequential hero (Person A chip → Person B typeahead)— dropped entirelyThree UI states (— now two states: hero (no person selected) and results (person selected, top bar visible)!senderId,senderId && !receiverId,both)The discovery framing is the right mental model for this page. "Briefwechsel" positions it clearly alongside the document search — one page for exploration, one for targeted lookup. The cross-link makes the distinction explicit for users who arrive at the wrong entry point.
Implementation complete —
feat/issue-179-briefwechsel-heroAll acceptance criteria addressed. Here's what was implemented:
Commits
a9228d1— Rename route/korrespondenz→/briefwechsel(all internal links updated)efac704— Rename labels "Korrespondenz" → "Briefwechsel" in de/en/es messagese9acd44— NewCorrespondenzHerocomponent with discovery headline, cross-link,h-14PersonTypeahead, recent persons chips. Addslargeprop toPersonTypeahead.f39d9e6— Two render states: hero (no senderId) vs results (senderId set). Unified padding withmax-w-7xl. Removed focus delegation hack.7b2324e— Bump person bar inputs toh-12, unify strip paddingd5e3de5— Constrain results state tomax-w-7xllike other overview pagesfbf5e9f— Remove oldCorrespondenzEmptyState(fully replaced)822a2fa— Add inner padding to strip componentsAcceptance criteria
CorrespondenzHerowithh-14typeahead)text-base/text-2xl)text-sm)px-4 sm:px-6 lg:px-8)h-14= 56px)Test coverage
38 tests across 3 test files — all green:
CorrespondenzHero.svelte.spec.ts(5 tests): headline, cross-link, typeahead, recent persons, chip clickpage.svelte.spec.ts(27 tests): hero/results state switching, recent persons, hint bar, filter controls, swap, year dividers, new doc linkpage.server.spec.ts(6 tests): server load function