diff --git a/docs/specs/briefwechsel-thumbnail-rows-spec.html b/docs/specs/briefwechsel-thumbnail-rows-spec.html new file mode 100644 index 00000000..00d79449 --- /dev/null +++ b/docs/specs/briefwechsel-thumbnail-rows-spec.html @@ -0,0 +1,1073 @@ + + +
+ + +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.Final design for the dashboard block that extends /persons/[id]. Gives every person page a correspondence-at-a-glance view — stats, activity per year, direction split, top correspondents, top locations, tag cloud — and turns each element into a filter shortcut into /briefwechsel. Replaces the current CoCorrespondentsList block.
impl-ref tables for exact Tailwind class + pixel value. Close-ups in Section 03 render each dashboard block at ~100 % scale for pixel-accurate reference.
+The page is a 35% / 65% split (existing lg:grid-cols-[35%_65%]). Left column keeps PersonCard and NameHistoryCard. Right column replaces CoCorrespondentsList with the new PersonDashboard block at the top, followed by the existing sent/received document lists.
bg-primary, others bg-accent/60. Hovering a bar shows "{year} · {count} Briefe" tooltip; clicking filters /briefwechsel?senderId=…&from=YYYY-01-01&to=YYYY-12-31./briefwechsel?senderId=<this>&receiverId=<other> (bilateral view). "Alle N Korrespondenten →" link below.xl > 100, l > 50, m > 20, muted ≤ 20. Click on any tag: /briefwechsel?senderId=<this>&tag=<id>.| Element | Classes | Real | Note |
|---|---|---|---|
| Page container | mx-auto max-w-6xl px-4 py-10 | max 72rem | Unchanged · existing route shell |
| 2-column grid | lg:grid lg:grid-cols-[35%_65%] lg:gap-8 | 32 px gap | Existing · unchanged |
| Left column stack | PersonCard → NameHistoryCard (mt-6) | 24 px mt | Existing · unchanged |
| Right column | PersonDashboard → PersonDocumentList(sent) → PersonDocumentList(received) | new + existing | PersonDashboard replaces CoCorrespondentsList |
| Dashboard container | overflow-hidden rounded-sm border border-line bg-surface shadow-sm | 1 px border | Matches card pattern from CLAUDE.md |
| Dashboard header | flex items-center justify-between gap-3 bg-primary text-primary-fg px-5 py-3 | 12 px y padding | Dark navy strip — sets dashboard apart from body card patterns |
| Dashboard title | font-serif text-base font-bold | 16 px / 700 | Merriweather |
| "Briefwechsel öffnen" CTA | bg-accent text-primary text-xs font-extrabold uppercase tracking-wide px-3 py-1.5 rounded-sm min-h-[44px] min-w-[44px] inline-flex items-center | 44 px min | WCAG 2.2 AA touch target; Paraglide m.person_open_conversation() |
Four states. Every frame renders the page shell (header → back link → split grid). Reading order per state: 320 px → 768 px → 1440 px. At 320 and 768, the grid stacks and the dashboard flows below the person card.
+ + +letterCount >= 10 and yearSpan >= 3./api/persons/{id} payload (fast); the dashboard shows skeleton rectangles for each section in the same grid slots.Six blocks rendered at near-real pixel sizes. These are the reference renderings developers check against when implementing PersonDashboard.svelte and its sub-components.
| Part | Classes | Real | Note |
|---|---|---|---|
| Strip container | grid grid-cols-2 sm:grid-cols-4 gap-px bg-line border-b border-line | 1 px gap (shows as lines) | Separators are the background showing through |
| Cell | bg-muted px-4 py-3.5 text-center | 14 px y padding | Uses bg-muted not bg-surface so separators read |
| Number | font-serif text-[22px] font-black text-primary leading-none tabular-nums tracking-tight | 22 px / 900 | Merriweather Black · .out = primary, .in = accent |
| Label | mt-1 text-[10px] font-bold uppercase tracking-wide text-ink-3 | 10 px | Direction labels match number colour |
| Mobile formatter | Abbreviate "Briefe gesamt" → "gesamt" | m.person_stats_total_short() | Paraglide key with _short suffix |
| Part | Classes | Real | Note |
|---|---|---|---|
| Container | flex items-end gap-0.5 h-[72px] pt-1 | 72 px height | Height tuned to comfortable reading — not too small |
| Bar (normal) | flex-1 bg-accent/60 rounded-t-sm hover:bg-accent/90 transition-colors cursor-pointer | variable width | Min-width 3 px for ≤ 30 years; thinner for longer ranges |
| Bar (peak) | flex-1 bg-primary rounded-t-sm | max height | Exactly one peak bar highlighted |
| Bar link | <a href="/briefwechsel?senderId={id}&from={year}-01-01&to={year}-12-31"> | — | Whole bar is the link; tooltip announces "Jahr {year} · {count} Briefe" |
| Year labels | flex justify-between text-[10px] font-bold text-ink-3 mt-1.5 | 10 px | Only show: earliest year · peak year · latest year |
| Tooltip | native title attribute on bar | — | "1922 · 78 Briefe" — Paraglide pluralized |
| Empty-year bars | rendered as min-height: 2px placeholder | 2 px | Keeps bar spacing regular across decades with gaps |
DistributionBar.svelte component from the thumbnail rows spec is also used here. Same props (outCount, outLabel, inCount, inLabel), same aria-label pattern.| Part | Classes | Real | Note |
|---|---|---|---|
| Wrapper | <ol> · flex flex-col gap-2 | 8 px gap | Ordered list — rank order matters, screen readers announce "1 of 6" |
| Item | <li> containing <a> · flex items-center gap-3 text-sm px-1.5 py-1 rounded-sm min-h-[32px] hover:bg-muted | 32 px min | Not 44 px because these are secondary links inside a card — desktop focus. Mobile bumps to 44 via md:min-h-[32px] min-h-[44px] |
| Name | flex-1 font-semibold text-ink truncate | 14 px / 600 | Truncate middle-ellipsis on very long German names at < 768 px |
| Proportional bar wrapper | w-[120px] h-[7px] bg-line rounded overflow-hidden shrink-0 hidden sm:block | 120 × 7 | Hidden on mobile — the number carries the data |
| Proportional bar fill | h-full bg-primary rounded | width = value ÷ max | Widths computed client-side from the list's max value |
| Count | w-10 text-right text-sm text-ink-3 font-bold tabular-nums shrink-0 | 14 px / 700 | Tabular figures so columns align |
| "Alle N anzeigen →" | mt-2.5 text-xs font-bold text-primary border-b border-dashed border-primary/60 hover:border-primary | 12 px / 700 | Appears when total > shown count |
::before with aria-hidden="true". The location string alone carries semantic meaning. Future iteration may replace with a map icon component.| Size | Classes | Count threshold | Note |
|---|---|---|---|
| xl | text-[15px] font-bold px-3.5 py-1 bg-accent text-primary rounded-full | ≥ 100 | Max 2 tags at this size — visual anchor |
| l | text-[13px] font-bold px-3 py-1 bg-accent text-primary rounded-full | 50–99 | Up to 4 tags |
| m | text-xs font-bold px-2.5 py-0.5 bg-accent text-primary rounded-full | 20–49 | Up to 6 tags |
| regular | text-xs font-bold px-2.5 py-0.5 bg-accent text-primary rounded-full | 5–19 | All tags meeting threshold |
| muted | text-xs font-semibold px-2.5 py-0.5 bg-line text-ink-3 rounded-full | < 5 | Up to 8 muted tags, sorted alphabetically |
| Click target | min-h-[28px] min-w-[44px] inline-flex items-center | 44 px min width | Very short tag names ("Kur") still meet touch target |
| Hover | hover:-translate-y-px transition-transform | 1 px lift | Bypassed when prefers-reduced-motion |
The dashboard is the discovery surface; /briefwechsel is the reading surface. Every clickable element in the dashboard adds filters to the existing /briefwechsel query string so the transition feels continuous.
| Element | Link | Note |
|---|---|---|
| Header "↗ Briefwechsel öffnen" | /briefwechsel?senderId={id}&dir=DESC | Opens all letters for this person · no date filter · newest first |
| Stats — "gesamt" | Same as header | Entire count is tap-to-open |
| Stats — "ausgehend" | /briefwechsel?senderId={id}&direction=OUT&dir=DESC | Requires new direction query param on /briefwechsel |
| Stats — "eingehend" | /briefwechsel?senderId={id}&direction=IN&dir=DESC | Same |
| Histogram bar | /briefwechsel?senderId={id}&from={year}-01-01&to={year}-12-31 | Opens bilateral or single view scoped to that year |
| Top correspondent row | /briefwechsel?senderId={id}&receiverId={otherId}&dir=DESC | Opens the bilateral view for the pair |
| Top location row | /briefwechsel?senderId={id}&location={locSlug} | Requires new location query param on /briefwechsel |
| Tag chip | /briefwechsel?senderId={id}&tagId={tagId} | Requires new tagId query param on /briefwechsel |
direction=OUT|IN — filter to letters in one direction only (existing endpoint already distinguishes sender vs receiver; this adds symmetry for the single-person view).location=<slug> — case-insensitive match on Document.location. Slug because German locations can contain spaces and dots ("B.Lichterfelde", "Bad Kissingen").tagId=<uuid> — filter to letters that reference this tag. If omitted, no tag filter is applied.Every colour pair on the dashboard has been measured. Semantics matter as much as colour: the stats use real numbers (not SVG), the histogram has tooltip text, the top lists are ordered lists, the tag cloud is a list of links.
+ +| Pair | Value | Ratio | WCAG |
|---|---|---|---|
| Stat number (primary on muted) | #002850 on #f7f5f2 | 14.0:1 | AAA ✓ |
| Stat label (ink-3 on muted) | #666666 on #f7f5f2 | 5.4:1 | AA ✓ |
| Stat "in" (accent on muted) | #2F9E95 on #f7f5f2 | 4.5:1 | AA ✓ (borderline — number is 22 px / 900 qualifies as large) |
| Dashboard header title (surface on primary) | #ffffff on #002850 | 14.5:1 | AAA ✓ |
| CTA "Briefwechsel öffnen" (primary on accent) | #002850 on #a6dad8 | 8.1:1 | AAA ✓ |
| Histogram bar (accent/60 on surface) | rgba(47,158,149,.6) on #ffffff | 2.8:1 | Decorative (bars have titles; not text) |
| Histogram peak bar (primary on surface) | #002850 on #ffffff | 14.5:1 | AAA ✓ |
| Tag chip (primary on accent) | #002850 on #a6dad8 | 8.1:1 | AAA ✓ |
| Muted tag (ink-3 on line) | #666666 on #eee8dc | 5.1:1 | AA ✓ |
| Focus ring (primary on surface, 2 px offset) | #002850 | 14.5:1 | AAA ✓ |
| Pair | Value | Ratio | WCAG |
|---|---|---|---|
| Stat number (ink on canvas-2) | #f0efe9 on #011526 | 15.1:1 | AAA ✓ |
| Stat "out" (mint on canvas-2) | #a1dcd8 on #011526 | 9.6:1 | AAA ✓ |
| Stat "in" (turquoise on canvas-2) | #00c7b1 on #011526 | 6.8:1 | AA ✓ |
| Stat label (ink-3 on canvas-2) | #8b97a5 on #011526 | 7.1:1 | AAA ✓ |
| Dashboard header (ink on navy-2) | #f0efe9 on #01223f | 13.8:1 | AAA ✓ |
| CTA (primary on mint) | #012851 on #a1dcd8 | 9.6:1 | AAA ✓ |
| Histogram peak (mint on canvas) | #a1dcd8 on #010e1e | 9.2:1 | AAA ✓ |
| Tag (turquoise on tint) | #00c7b1 on rgba(0,199,177,.2) | 6.3:1 | AA ✓ |
<dl> / <dt> / <dd> pairs. Screen readers announce "Briefe gesamt: 851, ausgehend: 612, …".role="img" with aria-label="Aktivität über 42 Jahre, Spitzenjahr 1922 mit 78 Briefen". Each bar has a title attribute for sighted tooltip.<ol> elements — screen readers announce "Top Korrespondenten, list 6 items, 1 of 6 Walter Dieckmann, 184 Briefe".<ul> of <li><a>. The visual size does not carry meaning that text cannot — the count is not exposed to screen readers via size, only via the tooltip / aria-label "Schlagwort Verlag, {count} Briefe".focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 on every interactive element (histogram bars, top-list rows, tag chips, header CTA).prefers-reduced-motion: disable bar width animation on first render and the tag-chip hover lift. The skeleton shimmer collapses to a static gradient.| Field | Value | Note |
|---|---|---|
| Route | GET /api/persons/{id}/dashboard | Separate from /api/persons/{id} to keep person entity lean and let the dashboard query be cache-friendly |
| Permission | @RequirePermission(Permission.READ_ALL) | Same as /api/persons/{id} |
| Cache | Server-side cache keyed on (personId, dataVersion) | Invalidate on Document write that references this person (see DocumentService update hooks) |
| Response schema | see next table | All counts server-computed — never client-computed |
| Field | Type | Note |
|---|---|---|
totalCount | int | outCount + inCount |
outCount | int | Letters where this person is sender |
inCount | int | Letters where this person is in receivers |
yearSpan | int | latestYear - earliestYear + 1 (null when no letters) |
correspondentCount | int | Distinct counterparts |
activityByYear | Map<int, int> | year → count · always contiguous (missing years = 0 · dashboard decides display) |
peakYear | int | Year with most letters (null when no letters) |
peakYearCount | int | |
topCorrespondents | List<CorrespondentTileDTO> | Max 10 · sorted desc · each has personId · displayName · count |
topLocations | List<LocationTileDTO> | Max 10 · each has location · count |
topTags | List<TagTileDTO> | Max 20 · each has tagId · label · count · frontend buckets into size tiers |
| File | Responsibility | Change |
|---|---|---|
PersonDashboard.svelte | Orchestrator · renders header + stats + sections | new |
StatStrip.svelte | 4-cell stats grid with direction colouring | new |
ActivityHistogram.svelte | One bar per year, peak highlight, hover tooltip | new |
DistributionBar.svelte | Already introduced in briefwechsel-thumbnail-rows-spec.html · re-used here with different labels | shared |
TopTileList.svelte | Ordered list of tiles (name + bar + count) — used for correspondents and locations | new · generic |
TagCloud.svelte | Frequency-sized chips with size buckets | new |
PersonPageShell.svelte / +page.svelte | Renders 2-column grid | Replace CoCorrespondentsList with PersonDashboard. Remove coCorrespondents derivation in +page.svelte — dashboard owns it. |
+page.server.ts | Loads person data | Add parallel call to /api/persons/{id}/dashboard; keep error handling identical |
GET /api/persons/{id}/dashboard with PersonDashboardDTO. Cache on person-write hooks. Tests for empty / sparse / full.PersonDashboard.svelte + its six sub-components. Replaces CoCorrespondentsList in the right column./briefwechsel: direction, location, tagId. Wire every dashboard element to them.