# Conversations — Narrow Column Redesign Spec · Familienarchiv

# 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
FAMILIENARCHIV
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 Conversation active · collapsed
familienarchiv.local/conversations?senderId=…&receiverId=…
FAMILIENARCHIV
Dokumente
Gespräche
Personen
Gespräche
Briefwechsel zwischen Familienmitgliedern
Anna Müller ⇄ Heinrich Raddatz
47 Dokumente · 1928–1965
Anpassen
47 Dokumente · 1928–1965
＋ Neues Dokument
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=…
FAMILIENARCHIV
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
FAMILIENARCHIV
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
FAMILIENARCHIV
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
FAMILIENARCHIV
Dokumente
Gespräche
Personen
Gespräche
Anna Müller ⇄ Heinrich Raddatz
47 Dokumente · 1928–1965
Anpassen
47 Dokumente · 1928–1965
＋ Neues Dokument
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
FAMILIENARCHIV
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
FAMILIENARCHIV
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

Container | `max-w-[80%]` (was `md:max-w-[70%]`). No breakpoint prefix needed — column is already narrow.
Corner cut | `rounded rounded-br-none` — WhatsApp-style tail at bottom-right
Background | `bg-primary` (#002850 navy)
Title | `font-serif text-sm font-medium text-primary-fg`
Meta row | `font-sans text-[10px] tracking-wider uppercase text-primary-fg/70`
Status — NEW | `flex items-center gap-1` → dot + text label (see note below)
Hover | `hover:-translate-y-0.5 hover:shadow-md` — retained unchanged
Avatar | `hidden 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 cut | `rounded rounded-bl-none` — tail at bottom-left
Background | `bg-muted/50` (light grey, ~#E8E4DF)
Title | `font-serif text-sm font-medium text-ink`
Meta row | `font-sans text-[10px] tracking-wider uppercase text-ink-2`
Status — NEW | Same structure, label colour `text-ink-2` (dark enough for contrast)
Avatar | `hidden sm:flex` — 32×32, white bg, ink initials, border

Status dot → label mapping (ConversationTimeline.svelte)

DocumentStatus | Dot colour class | Label key (i18n) | de | Sender text colour | Receiver text colour
--- | --- | --- | --- | --- | ---
`PLACEHOLDER` | `bg-yellow-400` | `conv_status_placeholder` | Platzhalter | `text-primary-fg/60` | `text-ink-2`
`UPLOADED` | `bg-accent` (green) | `conv_status_uploaded` | Hochgeladen | `text-primary-fg/65` | `text-ink-2`
`TRANSCRIBED` | `bg-blue-400` | `conv_status_transcribed` | Transkribiert | `text-primary-fg/65` | `text-ink-2`
`REVIEWED` | `bg-purple-400` | `conv_status_reviewed` | Geprüft | `text-primary-fg/65` | `text-ink-2`
`ARCHIVED` | `bg-gray-400` | `conv_status_archived` | Archiviert | `text-primary-fg/50` | `text-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
FAMILIENARCHIV
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…
FAMILIENARCHIV
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)

Key | de | en | es
--- | --- | --- | ---
`conv_filter_adjust` | Anpassen | Adjust | Ajustar
`conv_filter_apply` | Anwenden | Apply | Aplicar
`conv_filter_cancel` | Abbrechen | Cancel | Cancelar
`conv_status_placeholder` | Platzhalter | Placeholder | Marcador
`conv_status_uploaded` | Hochgeladen | Uploaded | Subido
`conv_status_transcribed` | Transkribiert | Transcribed | Transcrito
`conv_status_reviewed` | Geprüft | Reviewed | Revisado
`conv_status_archived` | Archiviert | Archived | Archivado

Δ
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%]`