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.
impl-ref tables for exact Tailwind class + pixel value. Close-ups in Section 03 are rendered at ~100 % scale for pixel-accurate reference.
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.
bg-surface wrapper, not a card — the hint bar gives it closure.bg-muted and a 1 px rule above/below.<ul> per year group. Each row is an <a> with role="listitem" ancestor. Border-left accent colors direction: navy = outgoing, mint-darker = incoming.| 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 |
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.
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).2. Sep 1923; location meta omitted (kept to 2 items), tags trimmed to one.min-h-[128px] at desktop so mixed-summary lists don't visually jump.ConversationTimeline. Rows show compact direction glyph instead of the word — the bar above already established direction semantics.senderId and receiverId are set.
role="img" with a descriptive aria-label — screen readers hear the full distribution in one sentence.senderId is set. Not shown in bilateral mode.SinglePersonHintBar.svelte bleibt unverändert und rendert zwischen Filter-Card und erster Jahres-Trennlinie. Nur in Single-Person-Modus, nicht bilateral.| 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] |
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).
| 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 |
min-h-[128px] so the list stays rhythmic. Tags are also omitted when empty (no empty chip row).| 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 |
| 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." |
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.
| 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 |
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.
| 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 ✓ |
| 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 ✓ |
<a href="/documents/{id}"> — never <div onclick>. Keyboard Tab enters, Enter opens, Shift-Tab leaves.focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 — always visible on keyboard focus, never on mouse click.<img alt=""> — empty alt because the title next to it names the letter. A descriptive alt would be announced twice.role="img" with a full-sentence aria-label. Screen readers hear the whole distribution in one announcement, not each half.prefers-reduced-motion: hover lift on thumbnail collapses to transition-duration: 0.01ms. Required (project CLAUDE.md + WCAG 2.3.3).| 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 |
| 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 |
| 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 |
ThumbnailRow, DistributionBar (extracted), and new typography/spacing without real thumbnails. Thumbnail cell renders the skeleton permanently. Ship and observe.thumbnailAspect + pageCount to the Document entity and the /api/documents/conversation response.