Conversations — Narrow Column Redesign

Developer specification for the WhatsApp/SMS-inspired chat layout. Constrains all bubble content to a centred 640 px column so opposing bubbles are close together — familiar to older users, easier to follow on wide screens.

v1.0 · 2026-03-29 3 files changed feature/153-notification-history branch
0
Architecture
Files, routes, and constraints
Three files. One route. No new dependencies. All changes are presentational — the data layer, server load functions, and URL query-param contract are untouched.
File What changes Approx. lines touched
frontend/src/routes/conversations/ConversationFilterBar.svelte Add expanded bindable prop. Render either a collapsed strip or the full form depending on state. Add "Adjust" button. Add "Apply"/"Cancel" controls for overlay mode. +38 / −2
frontend/src/routes/conversations/ConversationTimeline.svelte Remove central vertical line. Wrap bubble list in max-w-[640px] mx-auto column. Change bubble max-width. Add status text labels alongside status dots. +14 / −6
frontend/src/routes/conversations/+page.svelte Add filterExpanded reactive state. Auto-collapse when both persons are selected and documents load. Pass bind:expanded to FilterBar. +8 / −1
frontend/src/lib/paraglide/messages/*.json (de / en / es) Add 6 new i18n keys: conv_filter_adjust + 5 status label keys. +6 per file
🏗 Architecture decisions
  • Route stays at /conversations — query params (senderId, receiverId, from, to, dir) are the only data contract. No changes to +page.server.ts.
  • The filterExpanded flag lives in +page.svelte (not FilterBar) so the parent controls auto-collapse behaviour after navigation.
  • No JS animation libraries. Collapse uses a CSS transition: max-height or a Svelte #if block — developer's choice, but the spec uses #if for simplicity.
  • Breakpoints: mobile 375 px, tablet 768 px, desktop 1440 px. The 640 px column is achieved with Tailwind max-w-[640px] — no new CSS variables needed.

Unchanged: +page.server.ts, the API client, all repository/service code, URL query-param names, Paraglide message IDs already in use, canWrite guard, and all existing test IDs (data-testid).

1
Filter Bar
Collapsible filter bar — 4 states
When no conversation is active the full form stays visible (nothing to collapse yet). Once both persons are selected and results load, the bar auto-collapses to a single-line strip. Tapping "Adjust" re-expands it as an inline overlay.
1a Desktop 1440 No conversation selected
familienarchiv.local/conversations
Dokumente
Gespräche
Personen
Gespräche
Briefwechsel zwischen Familienmitgliedern
Person A
Name eingeben…
Person B
Name eingeben…
Von Datum
TT.MM.JJJJ
Bis Datum
TT.MM.JJJJ
 
Sortierung: Neueste zuerst ▾
↑ No collapse button — conversation not yet active
👥
Wähle zwei Personen aus
Wähle Person A und Person B aus, um ihre Korrespondenz anzuzeigen
Full filter form always expanded when senderId or receiverId is absent. No collapse/strip needed — there is no conversation to preserve context from.
1b Desktop 1440
familienarchiv.local/conversations?senderId=…&receiverId=…
Dokumente
Gespräche
Personen
Gespräche
Briefwechsel zwischen Familienmitgliedern
Anna Müller ⇄ Heinrich Raddatz
47 Dokumente · 1928–1965
Anpassen
47 Dokumente · 1928–1965
1928
AM
Brief an Heinrich, April 1928
12.04.1928 · München
Hochgeladen
HR
Antwort vom 3. Mai
03.05.1928 · Berlin
Transkribiert
AM
Postkarte, Sommer 1928
15.07.1928
Geprüft
Auto-collapsed to single strip after results load. "Anpassen" button re-expands. Year + count visible in strip for quick orientation without opening filters.
1c Desktop 1440 Overlay expanded after "Adjust"
familienarchiv.local/conversations?senderId=…
Dokumente
Gespräche
Gespräche
Anna Müller ⇄ Heinrich Raddatz
Anpassen
Filter anpassen
Person A
Anna Müller
Person B
Heinrich Raddatz
Von Datum
Bis Datum
 
Neueste zuerst ▾
Anwenden
Abbrechen
Brief an Heinrich…
Overlay drops inline below the strip (not a floating modal). Strip remains visible at reduced opacity. "Abbrechen" dismisses back to collapsed. "Anwenden" fires applyFilters() and collapses.
1d Mobile 375 Mobile — both states
Collapsed
Gespräche
A. Müller → H. Raddatz
47 Dok. · 1928–1965
Anpassen
1928
Brief, April 1928
12.04.1928
Antwort, Mai 1928
03.05.1928
Expanded
Gespräche
Person A
Anna Müller
⇅ Tauschen
Person B
Heinrich Raddatz
Von
Bis
Neueste zuerst ▾
Anwenden
Abbrechen
Mobile: full-width tap target on collapsed strip. Expanded mode stacks all fields vertically. Swap button sits between Person A and B. "Abbrechen" collapses without navigating.
📐 Filter bar prop contract (updated)
  • Add expanded = $bindable(true) to FilterBar's prop definition. Default true so existing tests that don't pass the prop still see the full form.
  • In +page.svelte: let filterExpanded = $state(!data.filters.senderId || !data.filters.receiverId). After successful load with results, set filterExpanded = false inside the $effect that syncs filter values.
  • "Anwenden" inside the expanded overlay calls onapplyFilters() then sets expanded = false (local) — the parent's bound state updates via Svelte 5 bindable.
  • "Abbrechen" sets expanded = false without calling onapplyFilters().
2
Chat Column
Narrow column layout — 3 breakpoints
The structural change. All bubble content is centred in a max-w-[640px] column inside the existing chat container. The outer container (border, shadow, surface bg) stays at full page width. No central dividing line.
Before — full-width split
Brief, April 1928
12.04.1928
Antwort, Mai 1928
03.05.1928
Postkarte, Sommer
15.07.1928
← wide gap →
After — narrow 640 px column
Brief, April 1928
12.04.1928
Antwort, Mai 1928
03.05.1928
Postkarte, Sommer
15.07.1928
max-w-[640px] · mx-auto
2a Desktop 1440
familienarchiv.local/conversations?senderId=a1&receiverId=b2
Dokumente
Gespräche
Personen
Gespräche
Anna Müller ⇄ Heinrich Raddatz
47 Dokumente · 1928–1965
Anpassen
47 Dokumente · 1928–1965
1928
AM
Brief an Heinrich, April 1928
12.04.1928 · München
Hochgeladen
HR
Antwort vom 3. Mai 1928
03.05.1928 · Berlin
Transkribiert
AM
Postkarte, Sommer 1928
15.07.1928
Geprüft
1929
HR
Neujahrskarte 1929
01.01.1929
Archiviert
⟵ outer container: full page width (max-w-5xl) · chat column: max-w-[640px] centred ⟶
Desktop 1440 px. Chat outer container spans to page max-w-5xl (unchanged). Bubble column is max-w-[640px] mx-auto inside. The grey padding on each side is empty space — visually frames the conversation. No central line.
2b Tablet 768
Gespräche
A. Müller ⇄ H. Raddatz
47 Dok. · 1928–1965
Anpassen
47 Dokumente · 1928–1965
1928
AM
Brief an Heinrich, April
12.04.1928
Hochgeladen
HR
Antwort, Mai 1928
03.05.1928
Transkribiert
Tablet 768 px. Column narrows to max-w-[560px]. Avatars visible (sm:flex). Filter strip collapsed.
2c Mobile 375
Gespräche
A. Müller → H. Raddatz
Anpassen
1928
Brief an Heinrich, April 1928
12.04.1928
Hochgeladen
Antwort, Mai 1928
03.05.1928
Transkribiert
Postkarte, Sommer
15.07.1928
Geprüft
Mobile 375 px. No avatars (hidden sm:flex). Bubbles max-w-[85%]. Column fills full width naturally — no mx-auto padding needed.
3
Bubble Card
Bubble card anatomy — sender & receiver
The cards are links (<a href="/documents/{id}">). The key WCAG fix: status indicator gains a text label next to the coloured dot so colour is no longer the sole information channel.
3a — Sender bubble (right-aligned, navy)
AM
Brief an Heinrich Raddatz, April 1928
12.04.1928 · München
Hochgeladen
Containermax-w-[80%] (was md:max-w-[70%]). No breakpoint prefix needed — column is already narrow.
Corner cutrounded rounded-br-none — WhatsApp-style tail at bottom-right
Backgroundbg-primary (#002850 navy)
Titlefont-serif text-sm font-medium text-primary-fg
Meta rowfont-sans text-[10px] tracking-wider uppercase text-primary-fg/70
Status — NEWflex items-center gap-1 → dot + text label (see note below)
Hoverhover:-translate-y-0.5 hover:shadow-md — retained unchanged
Avatarhidden sm:flex — 32×32, navy fill, white initials
3b — Receiver bubble (left-aligned, grey)
HR
Antwort vom 3. Mai 1928
03.05.1928 · Berlin
Transkribiert
Corner cutrounded rounded-bl-none — tail at bottom-left
Backgroundbg-muted/50 (light grey, ~#E8E4DF)
Titlefont-serif text-sm font-medium text-ink
Meta rowfont-sans text-[10px] tracking-wider uppercase text-ink-2
Status — NEWSame structure, label colour text-ink-2 (dark enough for contrast)
Avatarhidden sm:flex — 32×32, white bg, ink initials, border
Status dot → label mapping (ConversationTimeline.svelte)
DocumentStatusDot colour classLabel key (i18n)deSender text colourReceiver text colour
PLACEHOLDERbg-yellow-400conv_status_placeholderPlatzhaltertext-primary-fg/60text-ink-2
UPLOADEDbg-accent (green)conv_status_uploadedHochgeladentext-primary-fg/65text-ink-2
TRANSCRIBEDbg-blue-400conv_status_transcribedTranskribierttext-primary-fg/65text-ink-2
REVIEWEDbg-purple-400conv_status_reviewedGeprüfttext-primary-fg/65text-ink-2
ARCHIVEDbg-gray-400conv_status_archivedArchivierttext-primary-fg/50text-ink-3

Implement as a const statusLabels: Record<string, string> in ConversationTimeline.svelte using m.conv_status_* functions. Fall back to doc.status for unknown values.

4
Year Dividers
Year dividers inside the narrow column
No HTML change needed. The existing flex items-center with flex-grow border-t lines auto-sizes to whatever container it sits in. Moving the bubble list inside the 640 px column automatically constrains dividers too.
4a — Year divider in narrow column
Brief, Dezember 1928
18.12.1928
1929
Neujahrskarte 1929
01.01.1929
Unchanged Svelte code:
<div class="relative flex items-center py-2 text-center">
  <div class="flex-grow border-t border-line"></div>
  <span class="mx-4 font-sans text-xs font-bold tracking-widest text-ink/40 uppercase">{year}</span>
  <div class="flex-grow border-t border-line"></div>
</div>

The only structural change is that this element now lives inside the max-w-[640px] mx-auto wrapper div instead of the previous full-width flex column.
5
Summary Bar
Summary bar — above the narrow column
The summary bar (count + year range + new doc link) sits outside the narrow column at full page width. It remains at max-w-5xl because it is UI chrome, not conversation content.
5a — Summary bar placement
max-w-5xl (page column)
47 Dokumente · 1928–1965 + Neues Dokument
chat container — full width, bg-surface border shadow
max-w-[640px] · mx-auto
bubble list lives here

The mb-4 flex items-center justify-between summary div in ConversationTimeline.svelte is unchanged — it is already outside the chat container div. No code change needed for the summary bar itself.

6
Empty States
Two empty state variants
The "no conversation selected" state keeps the full expanded filter bar (no strip). The "conversation selected but no results" state uses the collapsed strip and a compact empty message.
6a — No conversation selected (senderId or receiverId missing)
familienarchiv.local/conversations
Gespräche
Gespräche
Briefwechsel zwischen Familienmitgliedern
Person A
Name eingeben…
Person B
Heinrich Raddatz
Von Datum
TT.MM.JJJJ
Bis Datum
TT.MM.JJJJ
 
Neueste zuerst ▾
👥
Wähle zwei Personen aus
Wähle Person A und Person B aus, um ihren Briefwechsel anzuzeigen
Full filter bar expanded (filterExpanded = true). "conv_empty_heading" / "conv_empty_text" message keys unchanged. Empty state uses dashed border to signal "waiting for input".
6b — Conversation selected, no results found
familienarchiv.local/conversations?senderId=a1&receiverId=b2&from=1960…
Gespräche
Gespräche
Anna Müller ⇄ Heinrich Raddatz
Von 1960 · Neueste zuerst
Anpassen
🔍
Keine Dokumente gefunden
Für diesen Zeitraum wurden keine Dokumente gefunden. Passe die Filter an.
Anpassen
Collapsed strip still shows who the conversation is between. Compact empty state with an inline "Anpassen" CTA that triggers filterExpanded = true. Uses "conv_no_results_heading" / "conv_no_results_text" keys unchanged.
7
Implementation
Implementation notes — developer checklist
Numbered rules. Each rule maps to a specific line-level change in one of the three files.
1
Narrow column container (ConversationTimeline.svelte)
Wrap the flex flex-col gap-4 div (currently inside p-6 md:p-8) with <div class="max-w-[640px] mx-auto">. The outer relative overflow-hidden rounded-sm border border-line bg-surface shadow-sm container stays unchanged at full page width.
2
Remove central vertical line (ConversationTimeline.svelte)
Delete the entire <div class="absolute top-0 bottom-0 left-1/2 hidden w-px -translate-x-1/2 transform bg-muted md:block"></div> element. It served as a decorative lane divider on wide screens; inside a narrow column it would bisect the bubbles incorrectly.
3
Bubble max-width (ConversationTimeline.svelte)
Change max-w-[90%] md:max-w-[70%] to max-w-[80%]. No breakpoint prefix is needed — the column itself is narrow so 80 % of 640 px (~512 px) is the effective max on all screens.
4
Status text label (ConversationTimeline.svelte — WCAG fix)
Replace the lone <span class="mt-1.5 h-1.5 w-1.5 flex-shrink-0 rounded-full … " title={doc.status}></span> with:
<span class="flex items-center gap-1"><span class="h-1.5 w-1.5 flex-shrink-0 rounded-full {colorClass}"></span><span class="text-[9px] font-sans uppercase tracking-wider {labelColorClass}">{statusLabel}</span></span>
Define const statusLabels: Record<string, string> mapping status codes to m.conv_status_*() calls. Derive statusLabel as statusLabels[doc.status] ?? doc.status.
5
filterExpanded state (+page.svelte)
Add let filterExpanded = $state(!data.filters.senderId || !data.filters.receiverId) after the existing state declarations. Inside the existing $effect that syncs filter values, append: if (senderId && receiverId) filterExpanded = false;. This auto-collapses after a successful navigation that loads results.
6
Pass expanded to FilterBar (+page.svelte)
Add bind:expanded={filterExpanded} to the <ConversationFilterBar …> element. This is the only change to the JSX-like template in +page.svelte beyond step 5.
7
Collapsed strip rendering (ConversationFilterBar.svelte)
Add expanded = $bindable(true) to the prop definition. Wrap the existing form content in {#if expanded}…{/if}. Add a new {:else} branch that renders the single-line strip: <div class="flex items-center justify-between px-4 py-2 border-b border-line bg-surface mb-10"> containing person names + "Anpassen" button. The button's onclick sets expanded = true.
8
Apply / Cancel in expanded overlay mode (ConversationFilterBar.svelte)
When expanded is true and a conversation was previously active (i.e. senderId && receiverId are non-empty at mount time), render "Anwenden" and "Abbrechen" buttons at the bottom of the form. "Anwenden" calls onapplyFilters() then sets expanded = false. "Abbrechen" only sets expanded = false. When no conversation is active, these extra buttons are not shown — the person typeahead's onchange already fires onapplyFilters() automatically.
9
Avatar visibility unchanged
Keep hidden sm:block on the avatar wrapper. On mobile (375 px) avatars are hidden; on tablet+ they show at 32×32. No change needed — this class already exists in ConversationTimeline.svelte.
10
Year divider — no change
The existing data-testid="year-divider" element uses flex-grow border-t which auto-fills its container. Moving it inside the max-w-[640px] wrapper is the only structural change, and that is covered by rule 1. No attribute or class change needed on the divider element itself.
11
i18n keys to add
Add 6 keys to messages/de.json, en.json, and es.json. See i18n table below.
12
Keyboard / accessibility
"Anpassen" is a <button> — natively focusable, responds to Enter/Space. No ARIA additions needed beyond what a standard button provides. The status text label (rule 4) resolves the only existing WCAG colour-only information issue on this page.
13
canWrite guard unchanged
The {#if canWrite} block around the "+ Neues Dokument" link in ConversationTimeline.svelte is outside the narrow column wrapper (it sits in the summary bar). No change needed.
14
data-testid attributes preserved
Existing test IDs must not be removed: conv-swap-btn, conv-summary, conv-new-doc-link, year-divider. The new "Anpassen" button should receive data-testid="conv-filter-adjust-btn".
i18n keys to add (messages/de.json · en.json · es.json)
Keydeenes
conv_filter_adjustAnpassenAdjustAjustar
conv_filter_applyAnwendenApplyAplicar
conv_filter_cancelAbbrechenCancelCancelar
conv_status_placeholderPlatzhalterPlaceholderMarcador
conv_status_uploadedHochgeladenUploadedSubido
conv_status_transcribedTranskribiertTranscribedTranscrito
conv_status_reviewedGeprüftReviewedRevisado
conv_status_archivedArchiviertArchivedArchivado
Δ
Comparison
Before / after — full diff summary
Side-by-side summary of every meaningful behavioural and visual change.
Before
Full filter form always visible, never collapses
Bubbles race to opposite edges of 100 % screen width
Central vertical grey line bisects wide chat container
Bubble max-width: md:max-w-[70%] — up to 70 % of full page at desktop
Status: colour-only dot, title tooltip (WCAG failure)
Status dot classes: only bg-accent (uploaded) vs bg-yellow-400 (all else)
No "Anpassen" / "Anwenden" / "Abbrechen" controls
Mobile: avatars hidden by hidden sm:block — correct but max-width too wide
After
Filter auto-collapses to single strip when conversation loads; re-expands on demand
All bubbles centred in max-w-[640px] column — tight WhatsApp-style gap
Central line removed — irrelevant inside narrow column
Bubble max-width: max-w-[80%] — 80 % of 640 px = ~512 px, same visual weight across breakpoints
Status: dot + text label side-by-side (WCAG 1.4.1 satisfied)
Status dot has 5 distinct classes mapping to full DocumentStatus lifecycle
FilterBar gains "Anpassen" (strip), "Anwenden" + "Abbrechen" (overlay)
Mobile unchanged in avatar logic; bubble group max-width explicitly set to max-w-[85%]