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
-
    -
  1. 01 Page anatomy default · 1440 px
  2. -
  3. 02 Content states × 3 viewports 5 states · 15 frames
  4. -
  5. 03 Row anatomy close-ups 4 row types @ real size
  6. -
  7. 04 Distribution bar bilateral mode only
  8. -
  9. 05 Accessibility contract WCAG AA/AAA
  10. -
  11. 06 Implementation notes data · thumbnails · routing
  12. -
-
- - -
-

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=…
-
-
- -
DokumentePersonenBriefwechselChronik
-
-
- -
-
-
Person
Walter de Gruyter
-
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
-
-
-
4 S.
-
-
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
- - - - - - - - - - - - -
ElementClassesRealNote
Page containermx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8max 80remMatches production /briefwechsel
Filter card wrappermb-8 rounded-sm border border-line bg-surface p-6 shadow-smpadding 24 pxExisting CorrespondenzPersonBar container
Year dividerflex items-baseline gap-3 border-y border-line bg-muted px-[14px] py-[8px]border 1 px both sidesKeep production styling — only row changes
Year numeralfont-serif text-2xl font-black tracking-tight text-primary24 px / 900 / -0.025emMerriweather Black
Year counttext-sm font-bold text-ink-314 px / 700"5 Briefe" / Paraglide plural
Row list wrapperoverflow-hidden rounded-sm border border-line bg-surface1 px borderHides row borders at ends
Rowgroup 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-muted128 px min · 20 × 14 paddingborder-l-primary out · border-l-accent in
Touch targetFull row is clickable; row height 128 px > WCAG 44 px minimum × ~3128 ≥ 44Senior 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% -
-
9:41
-
-
Briefwechsel
-
-
Person
Walter de Gruyter
Korrespondent
alle
851
-
19235
-
-
W-0397
Elsbeths Kommentar
H. Cram
2. Sep
-
Ansichtskarte
H. Cram
2. Sep
-
4
W-0524
Geburtstag & Umzug
W. Dieckmann
31. Jul
-
W-0396
H. Cram
2. Sep
-
-
-
-
-
- -
- 768 px · Tablet422 px @ 55% -
-
familienarchiv.de/briefwechsel
-
-
DokumenteBriefwechsel
-
-
Person
Walter de Gruyter
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
2. Sep 1923
vor 102 J.
-
Ansichtskarte – 2. September 1923
kurze Grüße aus B.Lichterfelde
an Herbert Cram✉ Postkarte
2. Sep 1923
vor 102 J.
-
4 S.
W-0524 – 31. Juli 1923 – Berlin
Glückwunsch zum 60. Geburtstag, Bericht über den Verlag
an Walter DieckmannGeburtstag
31. Jul 1923
vor 102 J.
-
W-0396 – 2. September 1923
an Herbert Cram
2. Sep 1923
vor 102 J.
-
-
-
-
-
- -
- 1440 px · Desktop720 px @ 55% -
-
familienarchiv.de/briefwechsel?senderId=…
-
-
DokumentePersonenBriefwechselChronik
-
-
Person
Walter de Gruyter
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
-
4 S.
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% -
-
9:41
-
-
Briefwechsel
-
-
Person
Walter
Korrespondent
Herbert
143
-
87 Walter →← 56 Herbert
-
-
W-0397
Elsbeths Kommentar
B.Lichterfelde
2. Sep
-
H-0213
Antwort zur Herbstlieferung
Leipzig
29. Aug
-
Ansichtskarte
Thür. Wald
20. Aug
-
-
-
-
-
-
- 768 px · Tablet422 px @ 55% -
-
…/briefwechsel?senderId=&receiverId=
-
-
DokumenteBriefwechsel
-
-
Person
Walter de Gruyter
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
2. Sep 1923
vor 102 J.
-
H-0213 – 29. August 1923 – Leipzig
Antwort auf Walters Anfrage zur Herbstauslieferung
Herbert an WalterVerlag
29. Aug 1923
vor 102 J.
-
Ansichtskarte – 20. August 1923
Urlaubsgruß aus Thüringen
Herbert an Walter✉ Postkarte
20. Aug 1923
vor 102 J.
-
-
-
-
-
-
- 1440 px · Desktop720 px @ 55% -
-
familienarchiv.de/briefwechsel?senderId=…&receiverId=…
-
-
DokumentePersonenBriefwechselChronik
-
-
Person
Walter de Gruyter
Korrespondent
Herbert Cram
⇄ Tauschen
Newest ↓
▾ Filter
143 Briefe im Zeitraum
- -
-
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% -
-
9:41
-
-
-
-
Person
Walter
Korresp.
alle
851
-
19235
-
-
W-0397
Elsbeths Kommentar
H. Cram
2. Sep
-
W-0396
H. Cram
2. Sep
-
-
-
-
-
-
- 768 px · Tablet422 px @ 55% -
-
…/briefwechsel
-
-
-
-
Person
Walter de Gruyter
Korresp.
Alle
Newest ↓
851 Briefe
-
19235 Briefe
-
-
W-0397 – 2. September 1923
Elsbeths Kommentar
Herbert Cram
2. Sep 1923
-
W-0396 – 2. September 1923
Herbert Cram
2. Sep 1923
-
-
-
-
-
-
- 1440 px · Desktop720 px @ 55% -
-
familienarchiv.de/briefwechsel
-
-
-
-
Person
Walter de Gruyter
Korrespondent
Alle
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
2. September 1923
-
W-0396 – 2. September 1923 – B.Lichterfelde
→ ausgehendan Herbert Cram
2. September 1923
-
-
-
-
-
-
-
- - -
-
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% -
-
9:41
-
-
-
-
Person
Walter
Korresp.
alle
0
-
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
-
-
-
-
Person
Walter de Gruyter
Korresp.
Alle
Newest ↓
▾ Filter
0 Briefe
-
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
-
-
-
-
Person
Walter de Gruyter
Korrespondent
Alle
Newest ↓
▾ Filter
0 Briefe
-
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
- - - - - - - - - - - -
ElementClassesRealNote
Skeleton thumbanimate-pulse bg-gradient-to-r from-[#f5f4ef] via-[#eceae4] to-[#f5f4ef] rounded-[1px]shimmer 1.4 sApplied only to .bw-thumb, never to text
Empty cardflex flex-col items-center justify-center rounded-sm border border-line bg-muted py-24 text-center shadow-smpadding 96 px yMatches production empty state
Empty titlefont-serif text-ink18 px desktopParaglide: m.conv_no_results_heading()
Empty bodymt-2 text-sm text-ink-3 max-w-prose mx-auto14 pxParaglide: m.conv_no_results_text()
Distribution barflex flex-col gap-1 border-b border-line bg-muted px-[18px] py-2role="img"aria-label: "Briefverteilung: X von A, Y von B"
Distbar labelsflex justify-between text-sm font-bold · .out text-primary · .in text-accent14 px / 700Counts in tabular-nums
Distbar barflex h-[5px] overflow-hidden rounded-full bg-line5 pxSegments 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
- - - - - - - - - - - - - - - -
PartClassesRealNote
Row containergroup 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-muted128 px min<a href="/documents/{id}"> · keyboard reachable
Thumbnail cellw-[104px] h-[120px] flex items-center justify-center shrink-0104 × 120Centers any aspect ratio
Thumbnail imgw-[82px] h-[106px] rounded-[1px] shadow-sm ring-1 ring-white/80 transition-transform group-hover:-translate-y-[1px] group-hover:shadow-md82 × 106 portraitloading="lazy" · alt="" (decorative, title covers meaning)
Titlefont-serif text-base font-bold text-ink leading-[1.35] truncate16 px / 700Merriweather Bold
Summaryfont-serif italic text-sm text-ink-2 leading-[1.55] line-clamp-214 px italicOmit element entirely when doc.summary is empty — no placeholder
Summary quote marks::before & ::after pseudos, color text-accent22 px„…" (German curly quotes)
Meta rowmt-0.5 flex flex-wrap gap-x-3 gap-y-1 text-xs text-ink-3 items-center12 pxSeparators use · with text-line
Direction chiptext-[13px] font-extrabold text-primary (out) · text-accent (in)13 px / 800"→ ausgehend" / "← eingehend" (word omitted in bilateral mode)
Tag chipinline-flex items-center text-[10px] font-bold bg-accent/80 text-primary px-[7px] py-0.5 rounded-full10 px / 700Max 2 tags visible at 1440; 1 at 768; 0 at 320
Right column — datefont-serif text-sm font-bold text-ink-2 whitespace-nowrap text-right14 px / 700Intl.DateTimeFormat de-DE (see CLAUDE.md)
Right column — relativetext-[10px] text-ink-3 font-semibold10 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
- - - - - - - -
PartClassesRealNote
Thumbnailw-[104px] h-[72px] rounded-[1px] shadow-sm ring-1 ring-white/80104 × 72 landscapeAspect ratio detected server-side from PDF page 1 dimensions (w/h > 1.1 → landscape)
Kind chipinline-flex items-center text-[10px] font-bold uppercase tracking-wide bg-line text-ink-2 px-[7px] py-0.5 rounded-full10 px / 700 uppercaseParaglide: m.doc_kind_postcard() — shown only when thumbnail is landscape
Stamp cornerCSS pseudo-element on thumbnail — 16×18 px gradient square top-right 5 pxdecorativeIn 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
-
-
-
- 4 S. -
-
-
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
- - - - - - - -
PartClassesRealNote
Badge containerabsolute top-1 -right-1 bg-primary text-primary-fg text-[10px] font-bold px-[7px] py-0.5 rounded-full ring-2 ring-white10 px / 700Overlaps the thumbnail by 4 px right
LabelParaglide: m.doc_pages_count({ count })"4 S."Abbreviated form for the badge; full "4 Seiten" appears in the document detail page
Visibility ruleRender {#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
- - - - - - - - - - - -
PartClassesRealNote
Wrapperflex flex-col gap-1 border-b border-line bg-muted px-[18px] py-28 px y paddingrole="img" · aria-label describes full distribution
Out labelinline-flex items-center gap-1 text-primary text-sm font-bold tabular-nums14 px / 700Format: "{count} von {sender} →"
In labelinline-flex items-center gap-1 text-accent text-sm font-bold tabular-nums14 px / 700Format: "← {count} von {receiver}"
Barflex h-[5px] overflow-hidden rounded-full bg-line5 px tallSegments use transition-[width] duration-300 ease-out
Out segmentbg-primary h-fullwidth from APIPercentage computed backend-side from counts
In segmentbg-accent h-fullcomplementaryNever use 100% - out; both come from the API separately
Mobile (320 px)Labels stack with flex-col gap-1; bar stays full-widthNo 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
- - - - - - - - - - - - -
PairValueRatioWCAG
Title (ink on surface)#1A1A1A on #ffffff19.6:1AAA ✓
Summary (ink-2 on surface)#444444 on #ffffff9.7:1AAA ✓ (body)
Meta (ink-3 on surface)#666666 on #ffffff5.7:1AA ✓
Direction out (primary on surface)#002850 on #ffffff14.5:1AAA ✓
Direction in (accent on surface)#2F9E95 on #ffffff4.6:1AA ✓ (normal)
Tag chip (primary on mint)#002850 on #a6dad88.1:1AAA ✓
Quote marks (accent on surface)#a6dad8 decorativen/aDecorative — summary text carries meaning
Focus ring (primary on surface)#002850 on #ffffff, 2px offset14.5:1AAA ✓
-
- -
-
Dark Mode — Contrast Verificationremap via data-theme="dark"
- - - - - - - - - - -
PairValueRatioWCAG
Title (ink on surface-dark)#f0efe9 on #011a3015.1:1AAA ✓
Summary (ink-2 on surface-dark)#c5cbd4 on #011a3011.2:1AAA ✓
Meta (ink-3 on surface-dark)#9ca3af on #011a307.8:1AAA ✓ (body)
Direction out (mint on canvas)#a1dcd8 on #010e1e9.6:1AAA ✓
Direction in (turquoise on canvas)#00c7b1 on #010e1e6.8:1AA ✓
Tag chip (turquoise on tint)#00c7b1 on rgba(0,199,177,.2)6.3:1AA ✓
-
- -
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
- - - - - - - - - - - - - - -
FieldFromUsed forFallback
idDocumentRow key, hrefrequired
titleDocumentRow titleoriginalFilename
summaryDocumentQuote line (omit when empty)element not rendered
documentDateDocumentYear group, right-column date, relative time"—" placeholder, year group "Ohne Datum"
locationDocumentMeta linehidden
sender / receiversDocumentDirection + counterpart namedirection omitted, name = m.conv_no_party()
tags[]DocumentMeta line (max 2 at 1440, 1 at 768, 0 at 320)no chips rendered
pageCountDocument (new, from thumbnail service)Badge when > 1no badge
thumbnailUrlDocument (new, from thumbnail service)<img src>skeleton until fetched
thumbnailAspectDocument (new, from thumbnail service)portrait / landscape classdefaults to portrait
-
- -
-
Thumbnail service — new endpointsdepends on open issue "thumbnail generation"
- - - - - - - - - - -
ConcernDecisionNote
StorageMinIO bucket thumbnailsMirrors document ID path; WEBP at 2× target resolution
URL/api/documents/{id}/thumbnailRedirects (302) to a presigned MinIO URL · Cache-Control: public, max-age=2592000 (30 d)
AspectComputed once on generation, persisted as Document.thumbnailAspect enum PORTRAIT \| LANDSCAPEThreshold w/h > 1.1 → LANDSCAPE
Page countPersisted as Document.pageCount on upload / reprocessNot computed client-side
Loading strategy<img loading="lazy" decoding="async"> with intersection observer for rows below the foldSkeleton state until onload fires
FallbackPaper-coloured placeholder (matches thumbnail gradient) with document iconNever break the row layout
-
- -
-
Component structurenew files
- - - - - - - - - -
FileResponsibilityReplaces
ThumbnailRow.svelteSingle row with thumbnail, title, summary, meta, right columnRow rendering inside ConversationTimeline.svelte
DistributionBar.svelteThe bilateral distribution barLifts existing markup out of ConversationTimeline.svelte
YearDivider.svelteYear number + Briefe countAlready exists; no change required
ConversationTimeline.svelteOrchestrator · renders distribution bar + year dividers + ThumbnailRowsSimplified — no longer does row markup directly
DocumentThumbnail.svelteReusable thumbnail element with lazy-load + aspect + page badgenew · 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 - - - -
- - -
-
-

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%]
-
-
-
-
- -
- - 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)