# ADR-030 — Stammbaum Bloodline-Contiguous Tidy-Tree Layout

**Date:** 2026-06-04
**Status:** Accepted
**Issue:** #724
**Supersedes:** ADR-026 (in part — the per-generation block-packer decision and its
position-within-rank fix path; the in-house / no-dagre decision and the seeded-rank
invariant from #689 are retained)

---

## Context

ADR-026 kept Stammbaum horizontal placement in-house with a **per-generation block
packer** and pre-committed a successor ADR "if any acceptance criterion stops
converging in-house." Its single UX stop-trigger was Albert de Gruyter's marriages
failing the read test.

The block packer hit a worse, structural failure: it placed each generation
**independently**, centring sibling blocks under already-placed parents and only ever
**shoving right** on collision. Two consequences followed — a deep branch that could
not fit at its ideal centre dragged everything downstream rightward and stranded the
ancestor at the **left edge** of its own descendants; and a parent placed before its
descendants existed could never be re-centred over them. Extreme symptom: Albert de
Gruyter (G0) far left, a great-great-grandchild far right — one bloodline smeared
across the full canvas. That is exactly the "UX failure against the canonical fixture"
ADR-026 named as the trigger to revisit the layout.

## Decision

**Replace the per-generation block packer with a bottom-up "tidy tree"
(Reingold–Tilford / Walker contour pack), still in-house, no new dependency.**

The horizontal `x` rewrite is split into three reviewable, unit-tested modules
(mirroring the `panZoom.ts` / `panZoomGestures.ts` / `animateView.ts` split):

- **`layout/tidyTree.ts`** — domain-agnostic contour packer over abstract
  `{ id, width, children, level? }` nodes, zero generated-API imports. Contours are
  indexed by **absolute generation level**, not tree depth, so unrelated roots at
  different generations share x-columns instead of smearing the forest wide.
- **`layout/familyForest.ts`** — all genealogy semantics: the **unit** model (a
  bloodline-carrying primary plus the spouse(s) absorbed into its run),
  `pickStructuralOwner` (lower birthYear, then stable id), loose-spouse absorption,
  multi-spouse runs (#361), sibling/branch order (birthYear ASC NULLS LAST →
  displayName → id), intra-family resolution, and cross-link classification.
- **`layout/buildLayout.ts`** — orchestrates forest → tidy-pack → per-person
  positions. `assignRanks` (y from rank, #689 seeding), the `generations` map, and
  `computeViewBox` are reused **unchanged**; `x` comes from structure, `y` from rank.

Two decisions taken during implementation and confirmed with the maintainer:

1. **Intra-family marriage = hybrid.** A couple is always exactly adjacent in the
   owner's run. When the two spouses' parents sit at the **same structural level** the
   displaced parent edge renders as a normal solid connector (the "adjacency" case);
   when they are **cross-level** (e.g. the canonical Clara⚭Herbert, where one parent
   is nested under Albert and the other hangs off a separate root), the structural
   owner keeps the hierarchy edge and the other parent→spouse edge renders as a
   distinct cross-link.
2. **Cross-link is rendered with a distinct `2 6` dash at 0.7 opacity** in
   `StammbaumConnectors.svelte` — never the `4 4` ended-marriage cadence. Geometry
   still lands on the correct child top, so meaning is carried redundantly (WCAG
   1.4.1); the 0.7 opacity clears the WCAG 1.4.11 3:1 non-text floor.

## Consequences

### Accepted

- **Ancestor centring** — every unit is centred over its child-units' span (named-bug
  guard `great_great_grandparent_is_not_stranded_left_of_descendants` + a fixture-wide
  loop over canonical and synthetic trees).
- **Bloodline contiguity** — each bloodline is one band with no foreign node
  interleaved. Albert de Gruyter's bloodline shrank from a full ~4860px smear to
  ~960px.
- **#361 / #689 preserved** — multi-spouse runs in marriage-year order, seeded ranks,
  spouse pull-down; the existing `buildLayout.test.ts` cases stay green.
- **Determinism** — every comparator ends in a stable id; a seeded permutation of
  nodes/edges yields byte-identical positions.
- **Fail-closed on cycles** — `assignRanks`' iteration ceiling plus a forest structure
  (each unit has ≤1 hierarchy parent, cycles are unreachable from roots) guarantee a
  finite layout with every node placed exactly once.

### Trade-off — total canvas width replaces the ADR-026 width assumption

Centring every ancestor inherently makes a forest of ~24 root-bands **wider** overall
than the old per-generation left-packer that interleaved everyone into compact shared
rows (canonical: ~7960px vs the old ~4860px). Total canvas width is therefore the
wrong success metric; **per-bloodline span** is. The width regression test asserts
each contiguous bloodline stays far under the old full-canvas smear. The wider canvas
is navigated by the pan/zoom from #692 (ADR-027) and is an accepted trade-off for
readability.

### Operational

- **No CI, image, compose, or dependency change.** Pure frontend layout. The
  `d3-flextree` escape hatch from #724 was not needed.

### Deferred (follow-ups, per #724)

- Connector legibility at 320px — the issue's "open verification"; a manual
  `/stammbaum` pass, with a connector-clarity issue spun only if drops/cross-links
  tangle.
- Polished cross-link routing + a relationship tooltip.

## Notes

- ADR-026's retained parts: the **no-dagre / in-house** decision and the seeded-rank
  invariant (#689) still hold — this ADR changes only _position-within / across_ rank,
  not rank assignment, and adds no dependency.
- The `validateFixture` sanity gates and the AC3 revisit probe from ADR-026 are
  unchanged.
