From 9f73c2ee4a4229352eea551872eca5e9789bbad4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 29 Mar 2026 18:28:22 +0200 Subject: [PATCH] docs: add conversations page Narrow Column redesign spec Co-Authored-By: Claude Sonnet 4.6 --- docs/specs/conversations-narrow-column.html | 1252 +++++++++++++++++++ 1 file changed, 1252 insertions(+) create mode 100644 docs/specs/conversations-narrow-column.html diff --git a/docs/specs/conversations-narrow-column.html b/docs/specs/conversations-narrow-column.html new file mode 100644 index 00000000..c2f1be18 --- /dev/null +++ b/docs/specs/conversations-narrow-column.html @@ -0,0 +1,1252 @@ + + + + + +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.
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileWhat changesApprox. lines touched
frontend/src/routes/conversations/ConversationFilterBar.svelteAdd 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.svelteRemove 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.svelteAdd 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%]
+
+
+
+
+ +
+ +