diff --git a/CLAUDE.md b/CLAUDE.md
index 362baeac..8364399a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -194,7 +194,6 @@ frontend/src/routes/
│ ├── [id]/edit/ Person edit form
│ ├── new/ Create person form
│ └── review/ Triage view — confirm/rename/merge/delete provisional persons
-├── briefwechsel/ Bilateral conversation timeline (Briefwechsel)
├── aktivitaeten/ Unified activity feed (Chronik)
├── geschichten/ Stories — list, [id], [id]/edit, new
├── stammbaum/ Family tree (Stammbaum)
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 5bc46261..1b54eacf 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -48,8 +48,6 @@ Both stacks are organised **package-by-domain**: each domain owns its entities,
A **derived domain** has its own routes and UI but no database tables of its own; it is assembled from data owned by Tier-1 domains.
-**`conversation`** (route: `/briefwechsel`) — bilateral letter timeline between two `Person`s. Derived from `Document` sender/receiver relationships. The `DocumentRepository` bidirectional query is the only data source.
-
**`activity`** (route: `/aktivitaeten`) — family activity feed. Derived from `audit_log`, `notifications`, and document events. No aggregation table; computed on-the-fly by `DashboardService` and composed in the SvelteKit load function.
---
diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md
index 2c0f3aba..0632fa6e 100644
--- a/docs/GLOSSARY.md
+++ b/docs/GLOSSARY.md
@@ -139,9 +139,6 @@ _Not to be confused with [parented](#parented-layout)_ — loose is the absence
**Aktivität / Aktivitäten** `[user-facing]` — the family activity feed accessible at `/aktivitaeten`. Shows recent documents, transcriptions, comments, and Geschichten as a chronological timeline.
_See also [Chronik](#chronik-internal)._
-**Briefwechsel** `[user-facing]` — the bilateral conversation timeline between two `Person`s, derived from `Document` sender/receiver relationships. Accessible at `/briefwechsel`. Not a persistent entity — data is computed from existing `Document` records.
-_See also [Derived domain](#derived-domain)._
-
**Chronik** `[internal]` — the conceptual and code-level name for the unified activity feed (per ADR-003 `003-chronik-unified-activity-feed.md`). Used in code, architecture documents, and ADRs. The user-facing label for the same concept is [Aktivität](#aktivitat--aktivitaten-user-facing).
**Geschichte** (`Geschichte`) `[user-facing]` — a narrative story or article published in the archive, linking `Person`s and `Document`s. Lifecycle: `DRAFT → PUBLISHED` (see `GeschichteStatus`). DRAFT stories are hidden from users without the `BLOG_WRITE` permission.
@@ -156,8 +153,7 @@ _See also [Derived domain](#derived-domain)._
**Cross-cutting** — code that lives in `lib/shared/` (frontend) or cross-domain packages (backend) because it has no entity of its own, no user-facing CRUD, AND is used by two or more domains OR is framework infrastructure (error handling, API client, i18n utilities).
-**Derived domain** — a Tier-2 frontend domain that has its own UI but no backend entities of its own. Data is computed from Tier-1 domain records. Current derived domains: `conversation` (from `Document` sender/receivers) and `activity` (from audit, notifications, document events).
-_See also [Briefwechsel](#briefwechsel-user-facing)._
+**Derived domain** — a Tier-2 frontend domain that has its own UI but no backend entities of its own. Data is computed from Tier-1 domain records. The current derived domain is `activity` (from audit, notifications, document events).
**Domain** — a Tier-1 bounded context with its own entities, controller, service, repository, and DTOs. Backend domains: `document`, `person`, `tag`, `user`, `geschichte`, `notification`, `ocr`, `audit`, `dashboard`. Frontend domains mirror this structure under `src/lib/`.
diff --git a/docs/architecture/c4-diagrams.md b/docs/architecture/c4-diagrams.md
index d01cdfaa..59eaa220 100644
--- a/docs/architecture/c4-diagrams.md
+++ b/docs/architecture/c4-diagrams.md
@@ -104,7 +104,7 @@ C4Component
ContainerDb(minio, "MinIO")
System_Boundary(backend, "API Backend (Spring Boot)") {
- Component(docCtrl, "DocumentController", "Spring MVC — /api/documents", "CRUD for documents: search, get by ID, update metadata, upload/download file, conversation thread, and batch metadata updates.")
+ Component(docCtrl, "DocumentController", "Spring MVC — /api/documents", "CRUD for documents: search, get by ID, update metadata, upload/download file, and batch metadata updates.")
Component(adminCtrl, "AdminController", "Spring MVC — /api/admin", "Triggers the asynchronous canonical import (requires ADMIN permission). Reports import state via GET /api/admin/import-status (IDLE/RUNNING/DONE/FAILED).")
Component(docSvc, "DocumentService", "Spring Service", "Core document business logic: store, update, search. Resolves persons and tags, delegates file I/O to FileService, builds dynamic JPA Specifications, and integrates with audit logging.")
@@ -112,7 +112,7 @@ C4Component
Component(importOrch, "CanonicalImportOrchestrator", "Spring Service — @Async", "Runs four idempotent loaders (TagTree → PersonRegister → PersonTree → Document) in a fixed DAG over the normalizer's committed canonical artifacts (canonical-*.xlsx + canonical-persons-tree.json) from /import — see diagram 3b. Owns the IDLE/RUNNING/DONE/FAILED state machine.")
Component(minioConf, "MinioConfig", "Spring @Configuration", "Creates the S3Client and S3Presigner beans with path-style access for MinIO. Validates MinIO connectivity on startup.")
- Component(docRepo, "DocumentRepository", "Spring Data JPA", "Queries documents with Specification-based dynamic search, bidirectional conversation thread queries, full-text search with ranking and match highlighting, and transcription pipeline queue projections.")
+ Component(docRepo, "DocumentRepository", "Spring Data JPA", "Queries documents with Specification-based dynamic search, full-text search with ranking and match highlighting, and transcription pipeline queue projections.")
Component(docSpec, "DocumentSpecifications", "JPA Criteria API", "Factory for composable predicates: hasText (full-text), hasSender, hasReceiver, isBetween (date range), hasTags (subquery AND/OR logic).")
}
@@ -442,7 +442,7 @@ C4Component
### 3c — People, Stories & Discovery
-Person directory, bilateral conversations, activity feed, stories, family tree, and user profiles.
+Person directory, activity feed, stories, family tree, and user profiles.
```mermaid
C4Component
@@ -454,7 +454,6 @@ C4Component
System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
Component(personsPage, "/persons and /persons/[id]", "SvelteKit Routes", "Person directory and detail. Detail: metadata, document list sent/received, correspondents, explicit and inferred family relationships.")
Component(personEdit, "/persons/[id]/edit and /persons/new", "SvelteKit Routes", "Create and edit person forms. Edit: metadata, aliases, explicit relationships. Actions: PUT/POST /api/persons.")
- Component(briefwechsel, "/briefwechsel", "SvelteKit Route", "Bilateral conversation timeline. Selects two persons via PersonTypeahead, fetches GET /api/documents/conversation, displays chronological exchange.")
Component(aktivitaeten, "/aktivitaeten", "SvelteKit Route", "Unified activity feed (Chronik). Loader: GET /api/dashboard/activity and GET /api/notifications?read=false.")
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story list and detail pages. Loader: GET /api/geschichten?status=PUBLISHED.")
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor with rich text, person and document linking. Actions: PUT/POST /api/geschichten. Requires BLOG_WRITE permission.")
@@ -466,7 +465,6 @@ C4Component
Rel(user, personsPage, "Browses family members", "HTTPS / Browser")
Rel(personsPage, backend, "GET /api/persons, GET /api/persons/{id}", "HTTP / JSON")
Rel(personEdit, backend, "GET /api/persons/{id}, PUT /api/persons/{id}, POST /api/persons", "HTTP / JSON")
- Rel(briefwechsel, backend, "GET /api/documents/conversation", "HTTP / JSON")
Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications", "HTTP / JSON")
Rel(geschichten, backend, "GET /api/geschichten", "HTTP / JSON")
Rel(geschichtenEdit, backend, "GET/PUT/POST /api/geschichten", "HTTP / JSON")
diff --git a/docs/architecture/c4/l3-backend-3b-document-management.puml b/docs/architecture/c4/l3-backend-3b-document-management.puml
index ac2f0208..65049e7e 100644
--- a/docs/architecture/c4/l3-backend-3b-document-management.puml
+++ b/docs/architecture/c4/l3-backend-3b-document-management.puml
@@ -8,7 +8,7 @@ ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
ContainerDb(minio, "Object Storage", "MinIO (S3-compatible)")
System_Boundary(backend, "API Backend (Spring Boot)") {
- Component(docCtrl, "DocumentController", "Spring MVC — /api/documents", "CRUD for documents: search, get by ID, update metadata, upload/download file, conversation thread, batch metadata updates, and per-month density aggregation for the timeline filter widget.")
+ Component(docCtrl, "DocumentController", "Spring MVC — /api/documents", "CRUD for documents: search, get by ID, update metadata, upload/download file, batch metadata updates, and per-month density aggregation for the timeline filter widget.")
Component(adminCtrl, "AdminController", "Spring MVC — /api/admin", "Triggers the asynchronous canonical import (requires ADMIN permission). Reports import state (IDLE/RUNNING/DONE/FAILED).")
Component(docSvc, "DocumentService", "Spring Service", "Core document business logic: store, update, search. Resolves persons and tags, delegates file I/O to FileService, builds dynamic JPA Specifications, and integrates with audit logging.")
Component(fileSvc, "FileService", "Spring Service", "Wraps AWS SDK v2 S3Client. Uploads files with UUID-keyed paths, computes SHA-256 hash, downloads with content-type detection, and generates presigned URLs for OCR access.")
@@ -20,7 +20,7 @@ System_Boundary(backend, "API Backend (Spring Boot)") {
Component(titleFmt, "DocumentTitleFormatter", "Pure helper", "Formats the date label baked into an import title at exactly the data's precision (MONTH -> 'Juni 1916', never a fabricated day). Mirrors the frontend formatDocumentDate; both are pinned to docs/date-label-fixtures.json (#666).")
Component(sheetReader, "CanonicalSheetReader", "POI helper", "Maps a canonical .xlsx by header name (no positional indices), splits pipe-delimited list columns, fails closed (IMPORT_ARTIFACT_INVALID) on a missing required header.")
Component(minioConf, "MinioConfig", "Spring @Configuration", "Creates the S3Client and S3Presigner beans with path-style access for MinIO. Validates MinIO connectivity on startup.")
- Component(docRepo, "DocumentRepository", "Spring Data JPA", "Queries documents with Specification-based dynamic search, bidirectional conversation thread queries, full-text search with ranking and match highlighting, and transcription pipeline queue projections.")
+ Component(docRepo, "DocumentRepository", "Spring Data JPA", "Queries documents with Specification-based dynamic search, full-text search with ranking and match highlighting, and transcription pipeline queue projections.")
Component(docSpec, "DocumentSpecifications", "JPA Criteria API", "Factory for composable predicates: hasText (full-text), hasSender, hasReceiver, isBetween (date range), hasTags (subquery AND/OR logic).")
}
diff --git a/docs/architecture/c4/l3-frontend-3c-people-stories.puml b/docs/architecture/c4/l3-frontend-3c-people-stories.puml
index b64539ab..a73ecc4a 100644
--- a/docs/architecture/c4/l3-frontend-3c-people-stories.puml
+++ b/docs/architecture/c4/l3-frontend-3c-people-stories.puml
@@ -10,7 +10,6 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
Component(personsPage, "/persons and /persons/[id]", "SvelteKit Routes", "Person directory (server-side filtered + paginated) and detail. Directory: type/family/has-documents chips, reader default (familyMember OR documentCount > 0), writer-only show-all toggle. Detail: metadata, document list sent/received, correspondents, family relationships.")
Component(personEdit, "/persons/[id]/edit and /persons/new", "SvelteKit Routes", "Create and edit person forms. Edit: metadata, aliases, explicit relationships. Actions: PUT/POST /api/persons.")
Component(personReview, "/persons/review", "SvelteKit Route", "Transcriber triage view (WRITE-gated link). Lists provisional persons; per-row Merge / Umbenennen / Bestätigen / Löschen. Actions: POST /merge, PUT /{id}, PATCH /{id}/confirm, DELETE /{id}.")
- Component(briefwechsel, "/briefwechsel", "SvelteKit Route", "Bilateral conversation timeline. Selects two persons via PersonTypeahead, fetches GET /api/documents/conversation, displays chronological exchange.")
Component(aktivitaeten, "/aktivitaeten", "SvelteKit Route", "Unified activity feed (Chronik). Loader: GET /api/dashboard/activity and GET /api/notifications?read=false.")
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story list and detail pages. Loader: GET /api/geschichten?status=PUBLISHED.")
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor with rich text, person and document linking. Actions: PUT/POST /api/geschichten. Requires BLOG_WRITE permission.")
@@ -24,7 +23,6 @@ Rel(user, personsPage, "Browses family members", "HTTPS / Browser")
Rel(personsPage, backend, "GET /api/persons (filter + page params -> PersonSearchResult), GET /api/persons/{id}", "HTTP / JSON")
Rel(personEdit, backend, "GET /api/persons/{id}, PUT /api/persons/{id}, POST /api/persons", "HTTP / JSON")
Rel(personReview, backend, "GET /api/persons?provisional=true, PATCH /api/persons/{id}/confirm, DELETE /api/persons/{id}, POST /api/persons/{id}/merge", "HTTP / JSON")
-Rel(briefwechsel, backend, "GET /api/documents/conversation", "HTTP / JSON")
Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications", "HTTP / JSON")
Rel(geschichten, backend, "GET /api/geschichten", "HTTP / JSON")
Rel(geschichtenEdit, backend, "GET/PUT/POST /api/geschichten", "HTTP / JSON")
diff --git a/docs/specs/briefwechsel-thumbnail-rows-spec.html b/docs/specs/briefwechsel-thumbnail-rows-spec.html
deleted file mode 100644
index 00d79449..00000000
--- a/docs/specs/briefwechsel-thumbnail-rows-spec.html
+++ /dev/null
@@ -1,1073 +0,0 @@
-
-
-
-
-
-Briefwechsel — Thumbnail Rows · Final Design Spec · Familienarchiv
-
-
-
-
-
-
-
-
-
-
Briefwechsel — Thumbnail Rows
-
Final row design for /briefwechsel. PDF thumbnail anchors each row; summary reads as a quote; no status lifecycle, no script-type indicator. Designed for fun discovery, not dense scanning. Scales from 320 px mobile to 1440 px desktop, light and dark. Serves both the millennial audience (25–42) and the senior family audience (60 +) — the senior constraint drives touch targets, line height, and summary legibility.
-
-
FINAL
-
-
-
Route
/briefwechsel · list surface
-
Row height (desktop)
128 px · comfortable
-
Thumbnail
82×106 portrait · 104×72 landscape
-
Removed
status dot · script type · archive box
-
-
-
-
- Reading this spec. Mockups in Section 02 are scaled to ~55 % of real pixel values so that multiple viewports fit on one page. Never copy pixel sizes from the mockups. Use the impl-ref tables for exact Tailwind class + pixel value. Close-ups in Section 03 are rendered at ~100 % scale for pixel-accurate reference.
-
-
-
-
-
Inhalt
-
- - 01 Page anatomy default · 1440 px
- - 02 Content states × 3 viewports 5 states · 15 frames
- - 03 Row anatomy close-ups 4 row types @ real size
- - 04 Distribution bar bilateral mode only
- - 05 Accessibility contract WCAG AA/AAA
- - 06 Implementation notes data · thumbnails · routing
-
-
-
-
-
- 01Page Anatomy — Default State at 1440 px (single-person)
- The page is a single vertical column (max-w-7xl). Filter card sticks to the top of the content region; the row list starts immediately below, grouped by year dividers. All viewports render the same regions in the same order — they only adapt spacing and thumbnail size, never rearrange.
-
-
-
-
-
familienarchiv.de/briefwechsel?senderId=…
-
-
-
Familienarchiv
-
DokumentePersonenBriefwechselChronik
-
-
-
-
-
-
-
Korrespondent — optional
Alle Korrespondenten
-
-
-
Newest ↓
-
▾ Filter
-
851 Briefe
-
-
📋 Alle Briefe von Walter de Gruyter — wähle einen Korrespondenten oben um einzugrenzen
-
-
-
19401 Brief
-
-
-
-
-
-
Demo leserlicher Brief
-
letzte Lebenstage von W. Dörpfeld in Griechenland — ausführlicher Bericht aus Belgard
-
← eingehendGertrud von Rofden·📍 Belgard·DörpfeldGriechenland
-
-
31. Mai 1940
vor 85 Jahren
-
-
-
-
19235 Briefe
-
-
-
-
-
W-0397 – 2. September 1923 – B.Lichterfelde
-
von Elsbeth geschriebener Kommentar, den Herbert zum Brief erzählte
-
→ ausgehendan Herbert Cram·📍 B.Lichterfelde·VerlagFamilie
-
-
2. September 1923
vor 102 Jahren
-
-
-
-
-
Ansichtskarte – 2. September 1923 – B.Lichterfelde
-
kurze Grüße aus B.Lichterfelde, Hinweis auf den kommenden Besuch
-
→ ausgehendan Herbert Cram·📍 B.Lichterfelde·✉ Postkarte
-
-
2. September 1923
vor 102 Jahren
-
-
-
-
-
W-0524 – 31. Juli 1923 – Berlin
-
Glückwunsch zum 60. Geburtstag, Bericht über den Verlag und den Umzug
-
→ ausgehendan Walter Dieckmann·📍 Berlin·Geburtstag
-
-
31. Juli 1923
vor 102 Jahren
-
-
-
-
-
-
-
-
A · Filter card
Two inputs (person required, correspondent optional) + action row + hint bar. Uses bg-surface wrapper, not a card — the hint bar gives it closure.
-
B · Year divider
Sticky-looking band between year groups. Large navy numeral + brief count. Uses bg-muted and a 1 px rule above/below.
-
C · Row list
Single <ul> per year group. Each row is an <a> with role="listitem" ancestor. Border-left accent colors direction: navy = outgoing, mint-darker = incoming.
-
Row · Thumbnail cell
Fixed 104 × 120 px cell on desktop; portrait and landscape both centered in the same cell so row height stays consistent across mixed media.
-
Row · Body
Serif title · italic serif summary (with mint quote glyphs) · sans meta line with direction + counterpart + location + tags. Summary omitted entirely when empty.
-
Row · Right column
Date (serif, bold) + relative age ("vor 102 Jahren"). No status, no archive location — deliberately calm.
-
-
-
-
-
Implementation Reference — Page ShellTailwind 4 · tokens from layout.css
-
- | Element | Classes | Real | Note |
-
- | Page container | mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8 | max 80rem | Matches production /briefwechsel |
- | Filter card wrapper | mb-8 rounded-sm border border-line bg-surface p-6 shadow-sm | padding 24 px | Existing CorrespondenzPersonBar container |
- | Year divider | flex items-baseline gap-3 border-y border-line bg-muted px-[14px] py-[8px] | border 1 px both sides | Keep production styling — only row changes |
- | Year numeral | font-serif text-2xl font-black tracking-tight text-primary | 24 px / 900 / -0.025em | Merriweather Black |
- | Year count | text-sm font-bold text-ink-3 | 14 px / 700 | "5 Briefe" / Paraglide plural |
- | Row list wrapper | overflow-hidden rounded-sm border border-line bg-surface | 1 px border | Hides row borders at ends |
- | Row | group grid grid-cols-[104px_1fr_auto] gap-5 items-center px-5 py-[14px] border-b border-line-2 border-l-[3px] min-h-[128px] cursor-pointer transition-colors hover:bg-muted | 128 px min · 20 × 14 padding | border-l-primary out · border-l-accent in |
- | Touch target | Full row is clickable; row height 128 px > WCAG 44 px minimum × ~3 | 128 ≥ 44 | Senior audience: comfort over density |
-
-
-
-
-
-
-
- 02Content States × 3 Viewports
-
- Five states covering the combinations that matter. Every frame renders the full page shell (header → filter card → list). Reading order per state: 320 px (mobile S) → 768 px (tablet) → 1440 px (desktop). Watch for filter card wrap at 320, thumbnail shrinkage, and the right-column behaviour under content pressure.
-
-
-
-
-
01Default · Single person with mixed row types
-
The happy path. Four rows shown: incoming typed letter, outgoing handwritten letter, outgoing postcard (landscape thumbnail), outgoing multi-page letter (page badge). Summaries present on three of four — the fourth row shows the clean no-summary variant.
-
-
-
-
320 px · Mobile176 px @ 55%
-
-
-
-
-
-
-
19235
-
-
W-0397
Elsbeths Kommentar
→H. Cram
-
-
W-0524
Geburtstag & Umzug
→W. Dieckmann
-
-
-
-
-
-
-
-
-
768 px · Tablet422 px @ 55%
-
-
familienarchiv.de/briefwechsel
-
-
Familienarchiv
DokumenteBriefwechsel
-
-
Korrespondent — optional
Alle Korrespondenten
Newest ↓
▾ Filter
851 Briefe
📋 Alle Briefe von Walter de Gruyter
-
19235 Briefe
-
-
W-0397 – 2. September 1923
von Elsbeth geschriebener Kommentar, den Herbert zum Brief erzählte
→an Herbert CramVerlag
-
Ansichtskarte – 2. September 1923
kurze Grüße aus B.Lichterfelde
→an Herbert Cram✉ Postkarte
-
W-0524 – 31. Juli 1923 – Berlin
Glückwunsch zum 60. Geburtstag, Bericht über den Verlag
→an Walter DieckmannGeburtstag
-
W-0396 – 2. September 1923
→an Herbert Cram
-
-
-
-
-
-
-
-
1440 px · Desktop720 px @ 55%
-
-
familienarchiv.de/briefwechsel?senderId=…
-
-
Familienarchiv
DokumentePersonenBriefwechselChronik
-
-
Korrespondent — optional
Alle Korrespondenten
Newest ↓
▾ Filter
851 Briefe
📋 Alle Briefe von Walter de Gruyter — wähle einen Korrespondenten oben um einzugrenzen
-
19235 Briefe
-
-
W-0397 – 2. September 1923 – B.Lichterfelde
von Elsbeth geschriebener Kommentar, den Herbert zum Brief erzählte
→ ausgehendan Herbert Cram·📍 B.Lichterfelde·VerlagFamilie
2. September 1923
vor 102 Jahren
-
Ansichtskarte – 2. September 1923 – B.Lichterfelde
kurze Grüße aus B.Lichterfelde, Hinweis auf den kommenden Besuch
→ ausgehendan Herbert Cram·📍 B.Lichterfelde·✉ Postkarte
2. September 1923
vor 102 Jahren
-
W-0524 – 31. Juli 1923 – Berlin
Glückwunsch zum 60. Geburtstag, Bericht über den Verlag und den Umzug
→ ausgehendan Walter Dieckmann·📍 Berlin·GeburtstagVerlag
31. Juli 1923
vor 102 Jahren
-
W-0396 – 2. September 1923 – B.Lichterfelde
→ ausgehendan Herbert Cram·📍 B.Lichterfelde
2. September 1923
vor 102 Jahren
-
-
-
-
-
-
-
Layout-Beobachtungen.
-
- - 320 px: Filter card collapses to a single column. Title truncates with ellipsis (
W-0397), summary keeps 1 line max; counterpart shortens to initials+last (H. Cram). Date format is 2. Sep — no year (year dividers provide it).
- - 768 px: Two-column filter returns. Title shows full label; summary gets 2 lines; date is
2. Sep 1923; location meta omitted (kept to 2 items), tags trimmed to one.
- - 1440 px: Full meta (direction word, counterpart, location, 2 tags). Relative date appears below the absolute date.
- - Row 4 (no summary) retains the exact same row height as others — the row grid is
min-h-[128px] at desktop so mixed-summary lists don't visually jump.
-
-
-
-
-
-
-
02Bilateral · Both filters set + distribution bar
-
Sender and receiver both selected. A distribution bar appears above the row list, pattern lifted from production ConversationTimeline. Rows show compact direction glyph instead of the word — the bar above already established direction semantics.
-
-
-
320 px · Mobile176 px @ 55%
-
-
-
-
-
-
-
-
-
W-0397
Elsbeths Kommentar
→B.Lichterfelde
-
H-0213
Antwort zur Herbstlieferung
←Leipzig
-
Ansichtskarte
←Thür. Wald✉
-
-
-
-
-
-
-
768 px · Tablet422 px @ 55%
-
-
…/briefwechsel?senderId=&receiverId=
-
-
Familienarchiv
DokumenteBriefwechsel
-
-
Korrespondent
Herbert Cram
⇄ Tauschen
Newest ↓
▾ Filter
143 Briefe
-
87 von Walter de Gruyter →← 56 von Herbert Cram
-
-
W-0397 – 2. September 1923
von Elsbeth geschriebener Kommentar
→Walter an HerbertVerlag
-
H-0213 – 29. August 1923 – Leipzig
Antwort auf Walters Anfrage zur Herbstauslieferung
←Herbert an WalterVerlag
-
Ansichtskarte – 20. August 1923
Urlaubsgruß aus Thüringen
←Herbert an Walter✉ Postkarte
-
-
-
-
-
-
-
1440 px · Desktop720 px @ 55%
-
-
familienarchiv.de/briefwechsel?senderId=…&receiverId=…
-
-
Familienarchiv
DokumentePersonenBriefwechselChronik
-
-
Korrespondent
Herbert Cram
⇄ Tauschen
Newest ↓
▾ Filter
143 Briefe im Zeitraum
-
87 von Walter de Gruyter →← 56 von Herbert Cram
-
-
W-0397 – 2. September 1923 – B.Lichterfelde
von Elsbeth geschriebener Kommentar, den Herbert zum Brief erzählte
→Walter an Herbert·📍 B.Lichterfelde·Verlag
2. September 1923
vor 102 Jahren
-
H-0213 – 29. August 1923 – Leipzig
Antwort auf Walters Anfrage zur Herbstauslieferung
←Herbert an Walter·📍 Leipzig·Verlag
29. August 1923
vor 102 Jahren
-
Ansichtskarte – 20. August 1923 – Thüringer Wald
Urlaubsgruß, kurze Notiz über Wetter und geplante Rückkehr
←Herbert an Walter·📍 Thüringer Wald·✉ Postkarte
20. August 1923
vor 102 Jahren
-
-
-
-
-
-
-
Distribution bar — only renders when both senderId and receiverId are set.
-
- - Labels are right/left-aligned matching the bar direction (out on left, in on right). Bar widths come from backend-calculated counts, not percentages on the client.
- role="img" with a descriptive aria-label — screen readers hear the full distribution in one sentence.
- - Below 320 px: labels stack vertically with a 4 px gap. Never truncate a count.
- - In meta line, direction word collapses to glyph ("→ / ←") because the distribution bar above has already named the parties.
-
-
-
-
-
-
-
03Loading · Skeleton (all three viewports render the same pattern)
-
SSR renders without thumbnails. While thumbnails are fetching, show a paper-coloured skeleton in the thumbnail cell. Title, summary and meta remain as normal text (the data is already present). No spinner, no pulse on the text — only the thumbnail shimmers.
-
-
-
320 px · Mobile176 px @ 55%
-
-
-
-
-
-
-
19235
-
-
W-0397
Elsbeths Kommentar
→H. Cram
-
-
-
-
-
-
-
-
768 px · Tablet422 px @ 55%
-
-
-
-
-
-
-
19235 Briefe
-
-
W-0397 – 2. September 1923
Elsbeths Kommentar
→Herbert Cram
-
W-0396 – 2. September 1923
→Herbert Cram
-
-
-
-
-
-
-
1440 px · Desktop720 px @ 55%
-
-
familienarchiv.de/briefwechsel
-
-
-
-
Newest ↓
▾ Filter
851 Briefe
-
19235 Briefe
-
-
W-0397 – 2. September 1923 – B.Lichterfelde
von Elsbeth geschriebener Kommentar, den Herbert zum Brief erzählte
→ ausgehendan Herbert Cram
-
W-0396 – 2. September 1923 – B.Lichterfelde
→ ausgehendan Herbert Cram
-
-
-
-
-
-
-
-
-
-
-
04Empty · No results matching current filters
-
Filter combination returns zero letters. Empty card sits below the filter card. Primary use case: date range that excludes all letters. Message gives the user a clear reset path.
-
-
-
320 px · Mobile176 px @ 55%
-
-
-
-
-
-
-
Keine Briefe
Für diesen Filter gibt es keine Einträge. Zeitraum anpassen oder Filter zurücksetzen.
-
-
-
-
-
-
768 px · Tablet422 px @ 55%
-
-
…/briefwechsel?from=1950&to=1960
-
-
-
-
-
Keine Briefe in diesem Zeitraum
Von 1950 bis 1960 gibt es keine Korrespondenz. Zeitraum erweitern oder Filter zurücksetzen.
-
-
-
-
-
-
1440 px · Desktop720 px @ 55%
-
-
familienarchiv.de/briefwechsel?from=1950&to=1960
-
-
-
-
-
Keine Briefe in diesem Zeitraum
Von 1950 bis 1960 gibt es keine Korrespondenz mit Walter de Gruyter. Passe den Zeitraum an oder setze die Filter zurück.
-
-
-
-
-
-
-
-
-
-
05Single-person hint — reminder to narrow
-
Already shown in production. Stays exactly as is. Re-rendered here so developers confirm it still renders above the first year divider when only senderId is set. Not shown in bilateral mode.
-
Kein Redesign. Die bestehende SinglePersonHintBar.svelte bleibt unverändert und rendert zwischen Filter-Card und erster Jahres-Trennlinie. Nur in Single-Person-Modus, nicht bilateral.
-
-
-
-
Implementation Reference — Content Stateslist rendering + skeleton
-
- | Element | Classes | Real | Note |
-
- | Skeleton thumb | animate-pulse bg-gradient-to-r from-[#f5f4ef] via-[#eceae4] to-[#f5f4ef] rounded-[1px] | shimmer 1.4 s | Applied only to .bw-thumb, never to text |
- | Empty card | flex flex-col items-center justify-center rounded-sm border border-line bg-muted py-24 text-center shadow-sm | padding 96 px y | Matches production empty state |
- | Empty title | font-serif text-ink | 18 px desktop | Paraglide: m.conv_no_results_heading() |
- | Empty body | mt-2 text-sm text-ink-3 max-w-prose mx-auto | 14 px | Paraglide: m.conv_no_results_text() |
- | Distribution bar | flex flex-col gap-1 border-b border-line bg-muted px-[18px] py-2 | role="img" | aria-label: "Briefverteilung: X von A, Y von B" |
- | Distbar labels | flex justify-between text-sm font-bold · .out text-primary · .in text-accent | 14 px / 700 | Counts in tabular-nums |
- | Distbar bar | flex h-[5px] overflow-hidden rounded-full bg-line | 5 px | Segments animated with transition-[width] |
-
-
-
-
-
-
-
- 03Row Anatomy · Close-Ups at ~100% Scale
- Four row types at near-real pixel sizes. These are the reference renderings developers check against when implementing ConversationTimeline.svelte (or its successor ThumbnailRow.svelte).
-
-
-
-
Type A · Portrait letter with summary + tags
-
-
-
-
W-0397 – 2. September 1923 – B.Lichterfelde
-
von Elsbeth geschriebener Kommentar, den Herbert zum Brief erzählte — Notiz auf der Rückseite
-
→ ausgehendan Herbert Cram·📍 B.Lichterfelde·VerlagFamilie
-
-
2. September 1923
vor 102 Jahren
-
-
-
Type A — Portrait Letter with Summaryrendered from Document + thumbnail URL
-
- | Part | Classes | Real | Note |
-
- | Row container | group grid grid-cols-[104px_1fr_auto] gap-5 items-center px-5 py-[14px] min-h-[128px] border-b border-line-2 border-l-[3px] border-l-primary transition-colors hover:bg-muted | 128 px min | <a href="/documents/{id}"> · keyboard reachable |
- | Thumbnail cell | w-[104px] h-[120px] flex items-center justify-center shrink-0 | 104 × 120 | Centers any aspect ratio |
- | Thumbnail img | w-[82px] h-[106px] rounded-[1px] shadow-sm ring-1 ring-white/80 transition-transform group-hover:-translate-y-[1px] group-hover:shadow-md | 82 × 106 portrait | loading="lazy" · alt="" (decorative, title covers meaning) |
- | Title | font-serif text-base font-bold text-ink leading-[1.35] truncate | 16 px / 700 | Merriweather Bold |
- | Summary | font-serif italic text-sm text-ink-2 leading-[1.55] line-clamp-2 | 14 px italic | Omit element entirely when doc.summary is empty — no placeholder |
- | Summary quote marks | ::before & ::after pseudos, color text-accent | 22 px | „…" (German curly quotes) |
- | Meta row | mt-0.5 flex flex-wrap gap-x-3 gap-y-1 text-xs text-ink-3 items-center | 12 px | Separators use · with text-line |
- | Direction chip | text-[13px] font-extrabold text-primary (out) · text-accent (in) | 13 px / 800 | "→ ausgehend" / "← eingehend" (word omitted in bilateral mode) |
- | Tag chip | inline-flex items-center text-[10px] font-bold bg-accent/80 text-primary px-[7px] py-0.5 rounded-full | 10 px / 700 | Max 2 tags visible at 1440; 1 at 768; 0 at 320 |
- | Right column — date | font-serif text-sm font-bold text-ink-2 whitespace-nowrap text-right | 14 px / 700 | Intl.DateTimeFormat de-DE (see CLAUDE.md) |
- | Right column — relative | text-[10px] text-ink-3 font-semibold | 10 px | "vor X Jahren" — calculated client-side |
-
-
-
-
-
-
-
-
Type B · Portrait letter without summary (clean variant)
-
-
-
-
W-0396 – 2. September 1923 – B.Lichterfelde
-
→ ausgehendan Herbert Cram·📍 B.Lichterfelde
-
-
2. September 1923
vor 102 Jahren
-
-
No placeholder when summary is missing. The summary element is not rendered at all — row height still hits min-h-[128px] so the list stays rhythmic. Tags are also omitted when empty (no empty chip row).
-
-
-
-
-
Type C · Postcard · landscape thumbnail with stamp + postmark
-
-
-
-
Ansichtskarte – 20. August 1923 – Thüringer Wald
-
Urlaubsgruß, kurze Notiz über Wetter und geplante Rückkehr
-
← eingehendvon Herbert Cram·📍 Thüringer Wald·✉ Postkarte
-
-
20. August 1923
vor 102 Jahren
-
-
-
Type C — Postcard (landscape)aspect ratio detection + kind chip
-
- | Part | Classes | Real | Note |
-
- | Thumbnail | w-[104px] h-[72px] rounded-[1px] shadow-sm ring-1 ring-white/80 | 104 × 72 landscape | Aspect ratio detected server-side from PDF page 1 dimensions (w/h > 1.1 → landscape) |
- | Kind chip | inline-flex items-center text-[10px] font-bold uppercase tracking-wide bg-line text-ink-2 px-[7px] py-0.5 rounded-full | 10 px / 700 uppercase | Paraglide: m.doc_kind_postcard() — shown only when thumbnail is landscape |
- | Stamp corner | CSS pseudo-element on thumbnail — 16×18 px gradient square top-right 5 px | decorative | In production: rendered by the thumbnail service as part of the real scan; the CSS is only for spec rendering |
-
-
-
-
-
-
-
-
Type D · Multi-page letter with "N Seiten" badge
-
-
-
-
W-0524 – 31. Juli 1923 – Berlin
-
Glückwunsch zum 60. Geburtstag, Bericht über den Verlag und den anstehenden Umzug nach B.Lichterfelde
-
→ ausgehendan Walter Dieckmann·📍 Berlin·GeburtstagVerlag
-
-
31. Juli 1923
vor 102 Jahren
-
-
-
Type D — Page-count Badgeonly when pages > 1
-
- | Part | Classes | Real | Note |
-
- | Badge container | absolute top-1 -right-1 bg-primary text-primary-fg text-[10px] font-bold px-[7px] py-0.5 rounded-full ring-2 ring-white | 10 px / 700 | Overlaps the thumbnail by 4 px right |
- | Label | Paraglide: m.doc_pages_count({ count }) | "4 S." | Abbreviated form for the badge; full "4 Seiten" appears in the document detail page |
- | Visibility rule | Render {#if doc.pageCount > 1} | — | Never show "1 S." |
-
-
-
-
-
-
-
-
- 04Distribution Bar · Close-Up
- Only rendered in bilateral mode (both senderId and receiverId set). This component already exists in production as part of ConversationTimeline.svelte — this spec keeps its API and visual treatment identical but moves it out of the timeline header into a standalone component above the row list, so it can sit between the filter card and the year dividers.
-
-
-
Distribution bar · bilateral Walter ↔ Herbert
-
-
- 87 von Walter de Gruyter →
- ← 56 von Herbert Cram
-
-
-
-
-
-
-
-
Distribution Barrole="img" + aria-label carries the data
-
- | Part | Classes | Real | Note |
-
- | Wrapper | flex flex-col gap-1 border-b border-line bg-muted px-[18px] py-2 | 8 px y padding | role="img" · aria-label describes full distribution |
- | Out label | inline-flex items-center gap-1 text-primary text-sm font-bold tabular-nums | 14 px / 700 | Format: "{count} von {sender} →" |
- | In label | inline-flex items-center gap-1 text-accent text-sm font-bold tabular-nums | 14 px / 700 | Format: "← {count} von {receiver}" |
- | Bar | flex h-[5px] overflow-hidden rounded-full bg-line | 5 px tall | Segments use transition-[width] duration-300 ease-out |
- | Out segment | bg-primary h-full | width from API | Percentage computed backend-side from counts |
- | In segment | bg-accent h-full | complementary | Never use 100% - out; both come from the API separately |
- | Mobile (320 px) | Labels stack with flex-col gap-1; bar stays full-width | — | No truncation of counts — numbers must always be legible |
-
-
-
-
-
-
-
-
- 05Accessibility Contract · WCAG AA/AAA
- Every colour pair on the rendered row has been measured. AAA where reasonably achievable; AA is the floor. The row is a link, not a button — keyboard navigation is native tab-through-list semantics.
-
-
-
Light Mode — Contrast Verificationlayout.css tokens
-
- | Pair | Value | Ratio | WCAG |
-
- | Title (ink on surface) | #1A1A1A on #ffffff | 19.6:1 | AAA ✓ |
- | Summary (ink-2 on surface) | #444444 on #ffffff | 9.7:1 | AAA ✓ (body) |
- | Meta (ink-3 on surface) | #666666 on #ffffff | 5.7:1 | AA ✓ |
- | Direction out (primary on surface) | #002850 on #ffffff | 14.5:1 | AAA ✓ |
- | Direction in (accent on surface) | #2F9E95 on #ffffff | 4.6:1 | AA ✓ (normal) |
- | Tag chip (primary on mint) | #002850 on #a6dad8 | 8.1:1 | AAA ✓ |
- | Quote marks (accent on surface) | #a6dad8 decorative | n/a | Decorative — summary text carries meaning |
- | Focus ring (primary on surface) | #002850 on #ffffff, 2px offset | 14.5:1 | AAA ✓ |
-
-
-
-
-
-
Dark Mode — Contrast Verificationremap via data-theme="dark"
-
- | Pair | Value | Ratio | WCAG |
-
- | Title (ink on surface-dark) | #f0efe9 on #011a30 | 15.1:1 | AAA ✓ |
- | Summary (ink-2 on surface-dark) | #c5cbd4 on #011a30 | 11.2:1 | AAA ✓ |
- | Meta (ink-3 on surface-dark) | #9ca3af on #011a30 | 7.8:1 | AAA ✓ (body) |
- | Direction out (mint on canvas) | #a1dcd8 on #010e1e | 9.6:1 | AAA ✓ |
- | Direction in (turquoise on canvas) | #00c7b1 on #010e1e | 6.8:1 | AA ✓ |
- | Tag chip (turquoise on tint) | #00c7b1 on rgba(0,199,177,.2) | 6.3:1 | AA ✓ |
-
-
-
-
- Non-negotiable accessibility rules.
-
- - Row is rendered as
<a href="/documents/{id}"> — never <div onclick>. Keyboard Tab enters, Enter opens, Shift-Tab leaves.
- - Focus ring:
focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 — always visible on keyboard focus, never on mouse click.
- - Thumbnail
<img alt=""> — empty alt because the title next to it names the letter. A descriptive alt would be announced twice.
- - Direction glyph is color and shape (arrow direction). Never rely on color alone — the arrow "→" vs "←" carries meaning even in monochrome.
- - Distribution bar uses
role="img" with a full-sentence aria-label. Screen readers hear the whole distribution in one announcement, not each half.
- - Minimum body text 14 px; minimum meta text 12 px. Never below 12 px for any visible text.
- - Touch target: 128 px row height ≫ 44 px WCAG minimum. Comfortable for senior users on phones.
- prefers-reduced-motion: hover lift on thumbnail collapses to transition-duration: 0.01ms. Required (project CLAUDE.md + WCAG 2.3.3).
-
-
-
-
-
-
- 06Implementation Notes — Data, Thumbnails, Routing
-
-
-
Data contract — fields read per row/api/documents/conversation
-
- | Field | From | Used for | Fallback |
-
- id | Document | Row key, href | required |
- title | Document | Row title | originalFilename |
- summary | Document | Quote line (omit when empty) | element not rendered |
- documentDate | Document | Year group, right-column date, relative time | "—" placeholder, year group "Ohne Datum" |
- location | Document | Meta line | hidden |
- sender / receivers | Document | Direction + counterpart name | direction omitted, name = m.conv_no_party() |
- tags[] | Document | Meta line (max 2 at 1440, 1 at 768, 0 at 320) | no chips rendered |
- pageCount | Document (new, from thumbnail service) | Badge when > 1 | no badge |
- thumbnailUrl | Document (new, from thumbnail service) | <img src> | skeleton until fetched |
- thumbnailAspect | Document (new, from thumbnail service) | portrait / landscape class | defaults to portrait |
-
-
-
-
-
-
Thumbnail service — new endpointsdepends on open issue "thumbnail generation"
-
- | Concern | Decision | Note |
-
- | Storage | MinIO bucket thumbnails | Mirrors document ID path; WEBP at 2× target resolution |
- | URL | /api/documents/{id}/thumbnail | Redirects (302) to a presigned MinIO URL · Cache-Control: public, max-age=2592000 (30 d) |
- | Aspect | Computed once on generation, persisted as Document.thumbnailAspect enum PORTRAIT \| LANDSCAPE | Threshold w/h > 1.1 → LANDSCAPE |
- | Page count | Persisted as Document.pageCount on upload / reprocess | Not computed client-side |
- | Loading strategy | <img loading="lazy" decoding="async"> with intersection observer for rows below the fold | Skeleton state until onload fires |
- | Fallback | Paper-coloured placeholder (matches thumbnail gradient) with document icon | Never break the row layout |
-
-
-
-
-
-
Component structurenew files
-
- | File | Responsibility | Replaces |
-
- ThumbnailRow.svelte | Single row with thumbnail, title, summary, meta, right column | Row rendering inside ConversationTimeline.svelte |
- DistributionBar.svelte | The bilateral distribution bar | Lifts existing markup out of ConversationTimeline.svelte |
- YearDivider.svelte | Year number + Briefe count | Already exists; no change required |
- ConversationTimeline.svelte | Orchestrator · renders distribution bar + year dividers + ThumbnailRows | Simplified — no longer does row markup directly |
- DocumentThumbnail.svelte | Reusable thumbnail element with lazy-load + aspect + page badge | new · also usable on /documents list pages |
-
-
-
-
- Shipping order.
-
- - Phase 1 — land
ThumbnailRow, DistributionBar (extracted), and new typography/spacing without real thumbnails. Thumbnail cell renders the skeleton permanently. Ship and observe.
- - Phase 2 — wire up thumbnail service (open issue "PDF thumbnail generation"). Replace skeleton with real thumbnails. Add
thumbnailAspect + pageCount to the Document entity and the /api/documents/conversation response.
- - Phase 3 — add lazy-loading + intersection observer for rows outside viewport. Measure perf on 851-letter lists.
-
-
-
-
-
-
-
diff --git a/docs/specs/conversations-narrow-column.html b/docs/specs/conversations-narrow-column.html
deleted file mode 100644
index c2f1be18..00000000
--- a/docs/specs/conversations-narrow-column.html
+++ /dev/null
@@ -1,1252 +0,0 @@
-
-
-
-
-
-Conversations — Narrow Column Redesign Spec · Familienarchiv
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- | 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).
-
-
-
-
-
-
-
-
-
-
-
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
-
-
-
-
-
-
-
-
AM
-
-
Brief an Heinrich, April 1928
-
12.04.1928 · München
-
-
-
-
-
-
-
HR
-
-
Antwort vom 3. Mai
-
03.05.1928 · Berlin
-
-
-
-
-
-
-
AM
-
-
Postkarte, Sommer 1928
-
15.07.1928
-
-
-
-
-
-
-
-
-
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
-
-
-
-
-
-
-
Anna Müller ⇄ Heinrich Raddatz
-
Anpassen
-
-
-
-
Filter anpassen
-
-
-
Person A
-
Anna Müller
-
-
⇅
-
-
Person B
-
Heinrich Raddatz
-
-
-
-
Anwenden
-
Abbrechen
-
-
-
-
-
-
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
-
-
-
-
-
-
-
-
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
-
-
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().
-
-
-
-
-
-
-
-
-
-
-
-
-
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
-
-
-
Anna Müller ⇄ Heinrich Raddatz
47 Dokumente · 1928–1965
Anpassen
-
47 Dokumente · 1928–1965
+ Neues Dokument
-
-
-
-
-
-
-
-
AM
-
-
Brief an Heinrich, April 1928
-
12.04.1928 · München
-
-
-
-
-
-
-
HR
-
-
Antwort vom 3. Mai 1928
-
03.05.1928 · Berlin
-
-
-
-
-
-
-
AM
-
-
Postkarte, Sommer 1928
-
15.07.1928
-
-
-
-
-
-
-
-
HR
-
-
Neujahrskarte 1929
-
01.01.1929
-
-
-
-
-
-
-
⟵ 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
-
-
-
-
-
-
A. Müller ⇄ H. Raddatz
47 Dok. · 1928–1965
-
Anpassen
-
-
47 Dokumente · 1928–1965
-
-
-
-
-
-
AM
-
-
Brief an Heinrich, April
-
12.04.1928
-
-
-
-
-
-
-
HR
-
-
Antwort, Mai 1928
-
03.05.1928
-
-
-
-
-
-
-
-
-
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
-
-
-
-
-
-
-
-
-
-
Brief an Heinrich, April 1928
-
12.04.1928
-
-
-
-
-
-
-
-
Antwort, Mai 1928
-
03.05.1928
-
-
-
-
-
-
-
-
Postkarte, Sommer
-
15.07.1928
-
-
-
-
-
-
-
-
-
Mobile 375 px. No avatars (hidden sm:flex). Bubbles max-w-[85%]. Column fills full width naturally — no mx-auto padding needed.
-
-
-
-
-
-
-
-
-
-
-
-
3a — Sender bubble (right-aligned, navy)
-
-
-
-
-
AM
-
-
Brief an Heinrich Raddatz, April 1928
-
12.04.1928 · München
-
-
-
-
-
-
-
- | 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
-
-
-
-
-
-
- | 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.
-
-
-
-
-
-
-
-
-
-
4a — Year divider in narrow column
-
-
-
-
-
-
Brief, Dezember 1928
18.12.1928
-
-
-
-
-
-
-
-
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.
-
-
-
-
-
-
-
-
-
-
-
-
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.
-
-
-
-
-
-
-
-
-
-
-
-
6a — No conversation selected (senderId or receiverId missing)
-
-
familienarchiv.local/conversations
-
-
-
Gespräche
Briefwechsel zwischen Familienmitgliedern
-
-
-
👥
-
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…
-
-
-
-
-
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.
-
-
-
-
-
-
-
-
-
-
-
-
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 |
-
-
-
-
-
-
-
-
-
-
-
-
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%]
-
-
-
-
-
-
-
-
diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md
index d6a7915a..a6fa8df7 100644
--- a/frontend/CLAUDE.md
+++ b/frontend/CLAUDE.md
@@ -29,7 +29,6 @@ src/
│ ├── +page.svelte # Home / document search dashboard
│ ├── documents/ # Document CRUD, detail, edit, upload
│ ├── persons/ # Person directory (filtered, paginated), detail, edit, merge, review (triage)
-│ ├── briefwechsel/ # Bilateral conversation timeline
│ ├── aktivitaeten/ # Unified activity feed (Chronik)
│ ├── admin/ # User, group, tag, OCR, system management
│ ├── api/ # Internal API proxies (server-side only)