Stammbaum — Relationship Badge · Document Detail

Spec for Part C of issue #358. A "Verwandtschaft" metadata row appears inside the metadata drawer's Personen column when a family kinship can be inferred between the document's sender and its single receiver. Implemented as a new presentational component RelationshipBadge.svelte.

Issue #358 Part C — Badge only Depends on PersonSummaryDTO.familyMember

1 · Design tokens

All colour values used by the badge and its context. The component uses only semantic Tailwind tokens — no hardcoded hex. Light and dark themes are handled automatically by layout.css.

Light theme — bg-surface = #ffffff
text-ink-3 #6b7280 — "Verwandtschaft" label4.8:1 AA ✓
text-ink #012851 — label values ("Sohn", "Vater")14.5:1 AAA ✓
text-accent #a1dcd8 — decorative arrow aria-hidden1.5:1 — non-text only
font-serif Tinos — used for label values (matches person card names in same column)
font-sans Montserrat — used for "Verwandtschaft" label (matches "Von" / "An" labels above)
Dark theme — bg-surface = #011526
text-ink-3 #8b97a5 — "Verwandtschaft" label7.1:1 AAA ✓
text-ink #f0efe9 — label values ("Sohn", "Vater")14.5:1 AAA ✓
text-accent #00c7b1 — decorative arrow aria-hiddennon-text only
bg-surface #011526 — drawer background in dark mode

text-accent (#a1dcd8 light / #00c7b1 dark) must only be used for the decorative arrow SVG. It fails WCAG at small text sizes. The arrow carries no information — it is purely visual and is marked aria-hidden="true".

2 · Visual mockup — light & dark

The badge renders as the last item in the Personen column of the expandable metadata drawer. Shown at ~65% scale. Drawer is open. Both themes side by side.

Light theme
M
Brief an die Eltern, 15. März 1923
15. März 1923
Details ▾
Bearbeiten
Details
Datum
15. März 1923
Ort
München
Status
Transkribiert
Personen
Von
KR
Karl Raddatz
An
HR
Heinrich Raddatz
Verwandtschaft
Sohn Vater
Schlagwörter
Familie Krieg

Drawer open. "Verwandtschaft" row sits below the single receiver card, using identical label and value typography to "Von" / "An". Arrow is mint accent, aria-hidden.

Dark theme
M
Brief an die Eltern, 15. März 1923
15. März 1923
Details ▾
Bearbeiten
Details
Datum
15. März 1923
Ort
München
Status
Transkribiert
Personen
Von
KR
Karl Raddatz
An
HR
Heinrich Raddatz
Verwandtschaft
Sohn Vater
Schlagwörter
Familie Krieg

Dark mode. Same semantic tokens; surface flips to #011526, ink to #f0efe9, accent arrow to #00c7b1 (turquoise). Both label and value pass WCAG AAA on the dark surface.

3 · Edge cases — when the badge is silent

All three cases result in no "Verwandtschaft" row. The drawer's Personen column is unaffected.

Not a family member
Von
KR
Karl Raddatz
An
NE
N.N. Engel
— no Verwandtschaft row —
Receiver does not have familyMember = true. Inference endpoint is never called.
Multiple receivers
Von
KR
Karl Raddatz
An
HR
Heinrich Raddatz
ER
Elfriede Raddatz
— no Verwandtschaft row —
receivers.length > 1: badge is silently omitted. Multi-receiver documents are rare in this archive; revisit if data shows otherwise.
No kinship path found
Von
KR
Karl Raddatz
An
HR
Heinrich Raddatz
— no Verwandtschaft row —
Both are family members, but the backend returns 404 (no path in graph). inferredRelationship is set to null. No error shown.

4 · Data flow

inferredRelationship is loaded server-side and passed as a separate prop alongside the document — it is not added to the document object.

+page.server.ts
Loads document as today. Then, if sender.familyMember && receivers.length === 1 && receivers[0].familyMember, calls GET /api/persons/{senderId}/relationship-to/{receiverId}. 404 → null. Returns { document, inferredRelationship }.
Modified
+page.svelte
Receives data.inferredRelationship. Passes to <DocumentTopBar> as a new optional prop.
Modified
DocumentTopBar.svelte
New optional prop inferredRelationship?: { labelFromA: string; labelFromB: string } | null. Passes through to <DocumentMetadataDrawer>.
Modified
DocumentMetadataDrawer.svelte
New optional prop. Renders {#if inferredRelationship} <RelationshipBadge .../> {/if} at the bottom of the Personen column.
Modified
RelationshipBadge.sveltesrc/lib/components/RelationshipBadge.svelte
Props: labelFromA: string, labelFromB: string. Purely presentational — no logic, no API calls.
New
Conditional in +page.server.ts

sender.familyMember requires PersonSummaryDTO.familyMember: boolean on the backend (part of issue #358 backend work). Until that field ships, the condition evaluates to false and the badge never renders — silent, correct fallback. No feature flag needed.

5 · RelationshipBadge.svelte — exact markup

src/lib/components/RelationshipBadge.svelte
<script lang="ts">
  import { m } from '$lib/paraglide/messages.js';

  type Props = { labelFromA: string; labelFromB: string };
  let { labelFromA, labelFromB }: Props = $props();
</script>

<div>
  <p class="mb-1 font-sans text-xs font-medium text-ink-3">
    {m.doc_details_field_relationship()}
  </p>
  <div class="flex items-center gap-1.5 px-2 font-serif text-sm text-ink">
    <span class="font-semibold">{labelFromA}</span>
    <svg
      class="h-3 w-3 shrink-0 text-accent"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      stroke-width="2.5"
      aria-hidden="true"
    >
      <path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5-5 5M6 12h12" />
    </svg>
    <span class="font-semibold">{labelFromB}</span>
  </div>
</div>
Placement in DocumentMetadataDrawer.svelte — Personen column, end of block
<!-- existing receiver section -->
{#if receivers.length > 0}
  <div>
    <p class="mb-1 font-sans text-xs font-medium text-ink-3">
      {m.doc_details_field_receivers()}
    </p>
    <!-- ... receiver cards ... -->
  </div>
{/if}

<!-- new: relationship badge —— after receivers -->
{#if inferredRelationship}
  <RelationshipBadge
    labelFromA={inferredRelationship.labelFromA}
    labelFromB={inferredRelationship.labelFromB}
  />
{/if}

6 · i18n

One new key. The relationship label strings (labelFromA / labelFromB) come pre-translated from the backend — no additional frontend keys needed for them.

Keyde (default)enes
doc_details_field_relationshipVerwandtschaftRelationshipParentesco

7 · Implementation notes

Arrow is decorative The SVG arrow carries no semantic information — the directional meaning is already in the label order (sender's label first, receiver's label second). Always aria-hidden="true". Screen readers announce: "Verwandtschaft: Sohn. Vater." which is unambiguous.
text-accent on arrow Use text-accent (maps to #a1dcd8 light / #00c7b1 dark) only on the arrow SVG stroke. Never on any text element. Both values fail WCAG at body text sizes — they are only safe for non-text decorative elements.
No logic in component RelationshipBadge.svelte has no conditional logic and no API calls. All conditions (familyMember flags, receiver count, 404 handling) live in +page.server.ts. If inferredRelationship is non-null, the badge renders — full stop.
Backend prerequisite PersonSummaryDTO must expose familyMember: boolean. Until it ships, sender.familyMember is undefined → condition is false → no API call made → badge silently absent. No code changes needed on both sides of the ship date.
Multiple receivers Check receivers.length === 1 in +page.server.ts before calling the inference endpoint. Do not call it for 0 or 2+ receivers. Badge is absent on multi-receiver documents regardless of family membership.
Prop threading inferredRelationship is typed as { labelFromA: string; labelFromB: string } | null on DocumentTopBar and DocumentMetadataDrawer. The path field from InferredRelationshipDTO is loaded in the server but not passed to the component — reserved for a future tooltip.
Future tooltip The inference path (data.inferredRelationship.path) is available server-side and can be surfaced as a tooltip on the badge in a follow-up. No design work needed now — just thread the prop when the time comes.