What changed from REV 1. REV 1 shipped a six-block dashboard on every person page. REV 1's "Sparse" state (`letterCount < 10`) was treated as the edge case; in reality it is the archive's baseline. REV 2 makes Compact the default, drops the histogram and tag cloud from the Standard tier, and gates the original six-block treatment behind `letterCount ≥ 50`. Backend aggregations for the heavy blocks are skipped entirely at Compact and Standard tiers.
Reading this spec. Mockups in Sections 03–05 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 classes and real pixel values.

## 01Data Reality — Why We Rebuilt This

A good dashboard respects the shape of the archive it describes. As of 2026-04-23 the distribution of letters per person in the Familienarchiv looks like this:

Tier | Criterion | Share | Example | What it needs
--- | --- | --- | --- | ---
Empty | `letterCount == 0` | — | Newly imported person · no documents yet | Reassurance card. Charts of nothing are noise.
Compact | `1 ≤ letterCount ≤ 5` | ~ 90 % | Elsbeth Brandt · 3 letters · 1919–1922 | A single stat line. The sent/received lists below already answer "which letters?".
Standard | `6 ≤ letterCount ≤ 49` | ~ 6 % | Herbert Cram · 18 letters · 8 correspondents | Stats strip · direction bar · top‑5 correspondents tile list.
Rich | `letterCount ≥ 50` | < 1 % | Walter de Gruyter · 851 letters · 87 corresp. · 42 years | Everything in Standard plus activity histogram · top locations · tag cloud.

The cost of REV 1 and the savings of REV 2.

- REV 1 planned six eager components on every person page — stats strip, activity histogram, direction split, top correspondents, top locations, tag cloud.
- For the 90 % of persons with ≤ 5 letters, four of those blocks were either hidden or collapsed to placeholders. Build cost went to invisible UI.
- Backend aggregations for the heavy blocks (`activityByYear`, `topLocations`, `topTags`) ran for every person page load regardless of relevance.
- REV 2 ships three tiers: Compact is trivial to build and serves 90 %, Standard serves the middle, Rich is the only tier that earns the original six-block treatment.

## 02Tier Decision Logic

The tier is derived from one number: `letterCount = outCount + inCount`. The backend uses the same rule to decide which aggregations to compute, so expensive queries (per-year histogram, tag tallies) are skipped for ~95 % of persons.

Tier thresholdsserver + client use the same rule

letterCount | Tier | Blocks shown | Expensive queries?
--- | --- | --- | ---
0 | EMPTY | Reassurance card only | No
1 – 5 | COMPACT | Shared dashboard chrome: header + CTA + 4-cell stats strip | No
6 – 49 | STANDARD | Stats strip · direction bar · top correspondents (≤ 5) | Partial: top correspondents only
≥ 50 | RICH | All of Standard · activity histogram · top locations · tag cloud | Yes: full set

Why 5 and why 50?

- 5 is the point below which a histogram has fewer bars than x-axis ticks, a "top list" is just the list, and a tag cloud shows ≤ 3 tags total. Below 5 letters, the sent/received document lists immediately below the stats strip are the per-letter detail view.
- 50 is the point at which per-year density begins to average ≥ 1 letter / year across a ~40-year lifespan — the histogram finally reveals shape rather than scattered dots. It is also the threshold at which "top locations" holds more than 2–3 entries and a tag cloud distinguishes sizes meaningfully.
- Between 5 and 50 the data is too thin for histograms but thick enough that raw document lists become hard to scan — that is what Standard's stats strip and top-correspondents tile solve.

## 03Compact Tier Compact · ~90 %

The shared dashboard chrome is the anchor of every non-Empty tier — navy header with the title and "↗ Briefwechsel öffnen" CTA, followed by the 4-cell stats strip. At Compact, that's where the dashboard ends. No other sections, no extra widgets. The sent & received `PersonDocumentList` components below are unchanged and carry the per-letter detail.

01Elsbeth Brandt · 3 Briefe · 1919 – 1922 Compact
Three total letters — two sent, one received, across a 4-year span. The dashboard renders header + stats and stops. The four stat cells (`gesamt · ausgehend · eingehend · Jahre`) are the same four cells shown at Standard and Rich — exactly the same markup, just without the sections that would render below when the data warrants.
320 px · Mobile176 px @ 55%
9:41
FA
← Zurück
EB
Elsbeth Brandt
1890 – 1963
Briefwechsel

## Überblick

↗
3
ges.
2
→
1
←
4J
Jahre
Gesendet · 2
1919Brief an W.
1922Brief an H.
Empfangen · 1
1920Antwort
768 px · Tablet422 px @ 55%
…/persons/elsbeth
Familienarchiv
← Zurück
EB
Elsbeth Brandt
1890 – 1963
Bearbeiten
Briefwechsel

## Korrespondenz-Überblick

↗ Öffnen
3
gesamt
2
ausgehend
1
eingehend
4
Jahre
Gesendet · 2Empfangen · 1
26.03.1919An Walter de Gruyter — Genesungswünsche
14.08.1922An Herbert Cram — Einladung
02.11.1920Von Walter de Gruyter — Dank
1440 px · Desktop780 px @ 55%
familienarchiv.de/persons/elsbeth-…
Familienarchiv
← Zurück
EB
Elsbeth Brandt
1890 – 1963
Bearbeiten
Briefwechsel

## Korrespondenz-Überblick

↗ Briefwechsel öffnen
3
Briefe gesamt
2
ausgehend
1
eingehend
4
Jahre
Gesendet · 2 Briefe
26.03.1919An Walter de Gruyter — Genesungswünsche nach Lazarettaufenthalt
14.08.1922An Herbert Cram — Einladung zur Sommerreise nach Bad Kissingen
Empfangen · 1 Brief
02.11.1920Von Walter de Gruyter — Dank für Geburtstagsgrüße
Why not more sections? At 3 letters, a histogram would be 3 dots on a 4-year axis — decorative, not informative. A top-correspondents list would be 2 rows with no proportional meaning. A tag cloud would show at most 2 tags. The document rows below already surface every atom of per-letter data; adding derived views on top of 3 data points wastes vertical space and introduces noise.
Design invariant. The dashboard block is the same component across Compact, Standard, and Rich — same chrome, same stats strip as the always-rendered baseline. Higher tiers append more sections below the stats. This is the system's consistency contract — you never look at two person pages and see two fundamentally different dashboards, only the same dashboard with more or fewer sections.
Shared dashboard chromerenders at every non-Empty tier · the Compact tier renders only this

Part | Classes | Real | Note
--- | --- | --- | ---
Container | `overflow-hidden rounded-sm border border-line bg-surface shadow-sm` | 1 px border | Matches the card pattern from CLAUDE.md
Header strip | `flex items-center justify-between gap-3 bg-primary text-primary-fg px-5 py-3` | 12 px y padding | Dark navy — the visual anchor that makes this block recognisable at every tier
Title | `font-serif text-base font-bold` · text: `m.person_dashboard_title()` → "Korrespondenz-Überblick" | 16 px / 700 | Merriweather. Abbreviates to "Überblick" at < 640 px
CTA "↗ Briefwechsel öffnen" | `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 gap-1.5` | 44 px min | WCAG 2.2 touch target. Mobile shows "↗" only; tablet+ shows the full label
Stats strip container | `grid grid-cols-2 sm:grid-cols-4 gap-px bg-line` | 1 px gap | Separators are the parent background showing through
Stat cell | `bg-muted px-4 py-3.5 text-center` | 14 px y padding | Uses `bg-muted` (not `bg-surface`) so the gap-px separators read
Stat number | `font-serif text-[22px] font-black leading-none tabular-nums tracking-tight` | 22 px / 900 | Merriweather Black · `.out` = primary, `.in` = accent-fg
Stat label | `mt-1 text-[10px] font-bold uppercase tracking-wide text-ink-3` | 10 px | Responsive abbreviation: "Briefe gesamt" → "gesamt" → "ges." via Paraglide `_short` keys
Semantic wrapper | `<dl>` with `<dt>/<dd>` pairs | — | Screen readers announce "Briefe gesamt: 3, ausgehend: 2, eingehend: 1, Jahre: 4"

## 04Standard Tier Standard · 6–49

For persons with meaningful volume but not enough density for charts. The dashboard grows a header strip, a 4-cell stats block, a proportional direction bar, and a top-correspondents tile list — all server-computed and cheap. The histogram, locations, and tag cloud stay off. The top-correspondents list replaces the legacy `CoCorrespondentsList` in this tier.

02Herbert Cram · 18 Briefe · 1905 – 1934 Standard
Eighteen letters across 30 years, 8 distinct correspondents. The stats strip gives exact counts; the direction bar shows the letter balance at a glance; the top-5 list reveals who Herbert wrote to or received from most often. No year-level histogram yet — density is still too thin for a readable chart.
320 px · Mobile176 px @ 55%
9:41
FA
← Zurück
HC
Herbert Cram
1881 – 1967
Briefwechsel

## Überblick

↗
18
ges.
11
→
7
←
30J
Jahre

### Richtung

→ 61%← 39%

### Top Korrespondenten

W. de Gruyter6
E. Brandt4
G. Rofden3
768 px · Tablet422 px @ 55%
…/persons/herbert-cram
Familienarchiv
← Zurück
HC
Herbert Cram
1881 – 1967
Bearbeiten
Briefwechsel

## Korrespondenz-Überblick

↗ Öffnen
18
gesamt
11
ausgehend
7
eingehend
30
Jahre

### Richtungsverteilung

→ 11 · 61%← 7 · 39%

### Top Korrespondenten 5 von 8

Walter de Gruyter6
Elsbeth Brandt4
Gertrud Rofden3
Eugenie de Gruyter2
Käthe Dieckmann1
1440 px · Desktop780 px @ 55%
familienarchiv.de/persons/herbert-cram-…
Familienarchiv
← Zurück
HC
Herbert Cram
1881 – 1967
Bearbeiten
Briefwechsel

## Korrespondenz-Überblick

↗ Briefwechsel öffnen
18
Briefe gesamt
11
ausgehend
7
eingehend
30
Jahre

### Richtungsverteilung

→ 11 ausgehend · 61%← 7 eingehend · 39%

### Top Korrespondenten 5 von 8 · Zeitraum 1905 – 1934

Walter de Gruyter6
Elsbeth Brandt4
Gertrud von Rofden3
Eugenie de Gruyter2
Käthe Dieckmann1
Why no histogram at this tier? With 18 letters over 30 years, a histogram averages 0.6 letters/year. Most bars would be empty or 1-tall — noise around a few spikes. Users glean nothing they don't already read in the "30 Jahre" stat. Histograms are a shape-reading tool; they need density to reveal shape.

## 05Rich Tier Rich · ≥ 50

Reserved for the archival giants — currently the four persons with correspondence volumes above 50 letters (Walter de Gruyter, Walter Dieckmann, Herbert Cram's extended record, Ella Dieckmann). Here the full six-block dashboard earns its place: histogram reveals decade-scale activity, top-locations surface travel patterns, tag cloud shows recurring themes. This is the only tier that warrants the heavy lift.

03Walter de Gruyter · 851 Briefe · 1898 – 1940 · 87 Korrespondenten Rich
The archival giant. All blocks render. The histogram has one bar per year (1898 → 1940 = 43 bars); peak bar highlighted in primary. Top correspondents & top locations sit in a 2-column grid. Tag cloud shows frequency-sized chips, with muted tags for counts < 5. Every tile is a deep link into `/briefwechsel` with filters pre-applied.
320 px · Mobile176 px @ 55%
9:41
FA
← Zurück
WG
Walter de Gruyter
1862 – 1923
Briefwechsel

## Überblick

↗
851
ges.
612
→
239
←
42J
Jahre

### Aktivität

### Top

W. Dieckmann184
H. Cram143
E. Dieckmann88

### Schlagwörter

VerlagFamilieGeburtstagKur
768 px · Tablet422 px @ 55%
…/persons/walter-de-gruyter
Familienarchiv
← Zurück
WG
Walter de Gruyter
1862 – 1923
Edit
Briefwechsel

## Korrespondenz-Überblick

↗ Öffnen
851
gesamt
612
ausgehend
239
eingehend
42
Jahre

### Aktivität 1922 · 78

189819221940

### Richtung

→ 612 · 72%← 239 · 28%

### Top Korresp.

W. Dieckmann184
H. Cram143
E. Dieckmann88
E. de Gruyter77

### Top Orte

Berlin412
B.Lichterfelde180
Bad Kissingen58
Cöln37

### Schlagwörter

VerlagFamilieGeburtstagWeihnachtenKurReiseKriegKrankheitTod
1440 px · Desktop780 px @ 55%
familienarchiv.de/persons/walter-de-gruyter-…
Familienarchiv
← Zurück
WG
Walter de Gruyter
1862 – 1923
Bearbeiten
Briefwechsel

## Korrespondenz-Überblick

↗ Briefwechsel öffnen
851
Briefe gesamt
612
ausgehend
239
eingehend
42
Jahre

### Aktivität über die Jahre Spitzenjahr 1922 · 78 Briefe

189819221940

### Richtungsverteilung

→ 612 ausgehend · 72%← 239 eingehend · 28%

### Top Korrespondenten 6 von 87

Walter Dieckmann184
Herbert Cram143
Ella Dieckmann88
Eugenie de Gruyter77
Gertrud von Rofden58

### Top Orte 5 von 42

Berlin412
B.Lichterfelde180
Bad Kissingen58
Cöln37
Belgard26

### Beliebte Schlagwörter Klick filtert den Briefwechsel

VerlagFamilieGeburtstagWeihnachtenKuraufenthaltReiseGeschäftKriegKrankheitSchuleHochzeitNeujahr
Deep-link grammar (unchanged from REV 1). Every Rich-tier tile is a link into `/briefwechsel` with filters applied: histogram bar → `?senderId={id}&from={y}-01-01&to={y}-12-31` · correspondent row → `?senderId={id}&receiverId={other}` · location row → `?senderId={id}&location={slug}` · tag chip → `?senderId={id}&tagId={tagId}`. Requires new `direction`, `location`, `tagId` query params on `/briefwechsel`.

## 06Empty & Loading States

New persons (imported but no documents yet) render the Empty state. The Loading state covers the brief window while the dashboard endpoint resolves — person card is already painted from `/api/persons/{id}` (fast), dashboard slot shows a single skeleton shape sized for the likely tier.

04Empty — person has no letters yet Empty
Single reassurance card. No stats strip (there are no stats). No CTA to the Briefwechsel (it would lead to an empty view). "Bearbeiten" remains on the PersonCard.
320 px · Mobile176 px @ 55%
9:41
FA
← Zurück
NN
Neue Person
—

## Überblick

Noch keine Briefe
Sobald ein Brief zugewiesen wird, erscheint der Überblick hier.
768 px · Tablet422 px @ 55%
…/persons/new-id
Familienarchiv
← Zurück
NN
Neue Person
—
Bearbeiten

## Korrespondenz-Überblick

Noch keine Briefe
Diese Person hat noch keine Korrespondenz im Archiv. Sobald ein Brief als Absender oder Empfänger zugewiesen wird, erscheint der Überblick hier automatisch.
1440 px · Desktop780 px @ 55%
familienarchiv.de/persons/new-id
Familienarchiv
← Zurück
NN
Neue Person
—
Bearbeiten

## Korrespondenz-Überblick

Noch keine Briefe
Diese Person hat noch keine Korrespondenz im Archiv. Sobald ein Brief als Absender oder Empfänger zugewiesen wird, erscheint der Überblick hier automatisch.
05Loading — skeleton sized for the expected tier
Dashboard endpoint in flight. Skeleton shows only the bare silhouette — a single rectangle at Compact, a stats+list silhouette at Standard+. Reduced-motion users see a static gradient instead of the shimmer animation.
320 px · Mobile176 px @ 55%
9:41
FA
← Zurück
WG
Walter de Gruyter
1862 – 1923

## Überblick

768 px · Tablet422 px @ 55%
…/persons/…
Familienarchiv
← Zurück
WG
Walter de Gruyter
1862 – 1923
Edit
Briefwechsel

## Korrespondenz-Überblick

1440 px · Desktop780 px @ 55%
familienarchiv.de/persons/…
Familienarchiv
← Zurück
WG
Walter de Gruyter
1862 – 1923

## Korrespondenz-Überblick

Anti-flicker rule. If the `letterCount` from `/api/persons/{id}` (fast) arrives before `/api/persons/{id}/dashboard`, the skeleton already knows which tier to paint — so the skeleton shape matches the final rendered shape and the page does not jump when data arrives. Use the count hint eagerly.

## 07Accessibility Contract · WCAG AA/AAA

Every rendered colour pair has been measured in light and dark mode. Semantics are chosen per tier: Compact uses a `<dl>` stat line; Standard/Rich reuse that and add `<ol>` for tile lists; Rich adds `role="img"` on the histogram with a text alternative that carries the shape information.

Light Mode — Contrast Verificationlayout.css tokens

Pair | Value | Ratio | WCAG
--- | --- | --- | ---
Stat number (primary on muted) | `#002850 on #fafaf5` | 14.0:1 | AAA ✓
Stat label (ink-3 on muted) | `#666666 on #fafaf5` | 5.4:1 | AA ✓
Stat "in" (accent on muted, 22 px/900) | `#2F9E95 on #fafaf5` | 4.5:1 | AA ✓ (large text rule)
Dashboard header (surface on primary) | `#ffffff on #002850` | 14.5:1 | AAA ✓
CTA "Briefwechsel öffnen" (primary on accent) | `#002850 on #a6dad8` | 8.1:1 | AAA ✓
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 ✓

Dark Mode — Contrast Verificationdata-theme="dark"

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 ✓
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 ✓

Non-negotiable accessibility rules per tier.

- All non-Empty tiers (shared chrome). Stats strip is a real `<dl>` — screen readers announce "Briefe gesamt: 3, ausgehend: 2, eingehend: 1, Jahre: 4". The header CTA has an `aria-label` that includes the person's name ("Briefwechsel von Elsbeth Brandt öffnen").
- Compact. Nothing renders below the stats strip. No additional semantic concerns beyond the shared chrome.
- Standard. Adds direction bar as `role="img"` with `aria-label="11 von 18 Briefen ausgehend, 7 eingehend"`. Top correspondents are a semantic `<ol>` — "Top Korrespondenten, 5 Einträge, 1 von 5 Walter de Gruyter, 6 Briefe".
- Rich. Adds histogram as `role="img"` with `aria-label` describing range & peak: "Aktivität über 42 Jahre, Spitzenjahr 1922 mit 78 Briefen". Tag cloud is a `<ul>` of `<li><a>`; the visual size does not encode meaning beyond the text — count is exposed via `title` + `aria-label`.
- All tiers. Focus ring: `focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2`. Touch targets: CTA 44 px, top-list row 44 px on mobile / 32 px on desktop, tag chip 44 px min width, histogram bar 32 px invisible click target.
- Reduced motion. Skeleton shimmer collapses to a static gradient; tag hover lift is disabled; histogram bar fade-in on first render is skipped. Honor `@media (prefers-reduced-motion: reduce)`.

## 08Implementation Notes — Tiered API, Lazy Components, Shipping Order

Endpointtier-aware response

Field | Value | Note
--- | --- | ---
Route | `GET /api/persons/{id}/dashboard` | Separate from `/api/persons/{id}` — lets person entity load stay lean and the dashboard query stay cache-friendly
Permission | `@RequirePermission(Permission.READ_ALL)` | Same as `/api/persons/{id}`
Cache key | `(personId, dataVersion)` | Invalidated on any Document write that touches this person (sender or receiver change, tag change, location change)
Expensive aggregations | Only run when computed `tier == RICH` | `activityByYear`, `topLocations`, `topTags` are `null` at lower tiers — DB query planner skips those subqueries entirely

Response schemaPersonDashboardDTO — nullable fields by tier

Field | Type | Populated at tier
--- | --- | ---
`tier` | `"EMPTY" | "COMPACT" | "STANDARD" | "RICH"` | always · advisory hint; frontend may re-derive
`totalCount` | int | always
`outCount` | int | always
`inCount` | int | always
`firstLetterYear` | int? | Compact+ (null when totalCount == 0)
`lastLetterYear` | int? | Compact+
`yearSpan` | int? | Compact+ · `last - first + 1`
`correspondentCount` | int? | Standard+
`topCorrespondents` | List<CorrespondentTileDTO>? | Standard+ · max 5
`activityByYear` | Map<int, int>? | Rich only · contiguous from first to last year
`peakYear / peakYearCount` | int? · int? | Rich only
`topLocations` | List<LocationTileDTO>? | Rich only · max 5
`topTags` | List<TagTileDTO>? | Rich only · max 12 · minimum count threshold 3

Component structurebaseline eager · Rich-only lazy

File | Tier | Load | Responsibility
--- | --- | --- | ---
`PersonDashboard.svelte` | all | eager | Orchestrator — renders header + stats always, then conditionally appends sections based on `tier`
`DashboardHeader.svelte` | Compact, Standard, Rich | eager | Navy strip: title + "↗ Briefwechsel öffnen" CTA · shared across all non-Empty tiers
`StatStrip.svelte` | Compact, Standard, Rich | eager | 4-cell stats grid with direction colouring · shared across all non-Empty tiers
`DistributionBar.svelte` | Standard, Rich | eager | Shared with `briefwechsel-thumbnail-rows-spec` — not new
`TopTileList.svelte` | Standard, Rich | eager | Ordered list of tiles (name + bar + count) · generic (used for correspondents and locations)
`ActivityHistogram.svelte` | Rich only | lazy | Dynamic import: `{#await import('./ActivityHistogram.svelte')}`
`TagCloud.svelte` | Rich only | lazy | Dynamic import; size buckets derived from counts
`EmptyNotice.svelte` | Empty | eager | Single card with reassurance text

Route changes/persons/[id] shell

File | Change
--- | ---
`+page.server.ts` | Add parallel call to `/api/persons/{id}/dashboard`. Keep existing error handling pattern (`result.response.ok` check · `getErrorMessage(code)`).
`+page.svelte` | Replace `<CoCorrespondentsList>` with `<PersonDashboard dashboard={data.dashboard} />`. Remove the local `coCorrespondents` derivation — backend owns it.
`CoCorrespondentsList.svelte` | Keep temporarily (used nowhere else after this ships). Delete in a follow-up commit once the new route is green in QA.

Shipping order.

- Phase 1 — backend endpoint `GET /api/persons/{id}/dashboard` returning the tiered `PersonDashboardDTO`. Tests for Empty / Compact / Standard / Rich. Cache keyed on person data-version. Skip expensive aggregations for non-Rich persons.
- Phase 2 — `PersonDashboard.svelte` + `DashboardHeader.svelte` + `StatStrip.svelte` + `EmptyNotice.svelte`. Replaces `CoCorrespondentsList` in the right column. This phase alone ships the Compact tier — value for ~90 % of persons.
- Phase 3 — `TopTileList.svelte` + reuse `DistributionBar.svelte`. Standard tier's direction bar and top-correspondents tiles go live for ~6 % of persons.
- Phase 4 — `ActivityHistogram.svelte`, `TagCloud.svelte` (both lazy). Wire new `direction`, `location`, `tagId` query params on `/briefwechsel`. Rich tier goes live for the 4 power-correspondence persons.
- Phase 5 — axe-playwright checks at 320 / 768 / 1440 in light + dark for each tier. Visual regression snapshots for all five states (Empty · Compact · Standard · Rich · Loading).
- Phase 6 (cleanup) — delete `CoCorrespondentsList.svelte` and any now-unused co-correspondent derivation helpers.

Migration story. The old spec's `PersonDashboard` (six eager blocks) has not shipped yet — there is no user-facing migration. The existing `CoCorrespondentsList` is what each tier replaces. Compact and Standard tiers are strict improvements over `CoCorrespondentsList`; Rich tier is the only place the original six-block vision survives, and it only renders for persons who demonstrably have the data to fill it.