diff --git a/docs/adr/026-stammbaum-layout-in-house.md b/docs/adr/026-stammbaum-layout-in-house.md new file mode 100644 index 00000000..9ffd0660 --- /dev/null +++ b/docs/adr/026-stammbaum-layout-in-house.md @@ -0,0 +1,129 @@ +# ADR-026 — In-House Stammbaum Layout, dagre Evaluated and Deferred + +**Date:** 2026-05-28 +**Status:** Accepted +**Issue:** #361 +**Supersedes:** _none_ +**Supersedes-on-trigger:** A future ADR-027 if any acceptance criterion below stops converging in-house. + +--- + +## Context + +After #689 shipped the seeded-rank invariant — `buildLayout.ts` treats imported +`persons.generation` as a strict row anchor and the iterative heuristic only +runs for unseeded nodes — the question "should we adopt +[@dagrejs/dagre](https://www.npmjs.com/package/@dagrejs/dagre) for Stammbaum +layout?" had to be re-evaluated. + +dagre's headline value is **rank assignment** via `network-simplex` / +`longest-path`. That value is now mostly redundant: curated import data already +pins ranks for the family graph, and the residual heuristic only places +unseeded nodes (today: family members imported without a `generation` column, +spouses with no parents in the graph). + +What remains are **position-within-rank** problems: + +1. Multi-spouse persons (canonical case: Albert de Gruyter, 4 marriages) whose + secondary marriages were silently dropped by a `Map` shape. +2. Intra-family marriages — two persons in different sibling blocks at the + same rank who marry each other (latent; zero cases in current data). +3. Unseeded loose spouses whose parents are also in the graph (latent; zero + cases — 0 of 942 unseeded persons match the predicate in the May-2026 + snapshot). + +Six persona walkthroughs on #361 (Leonie/UX, Felix/Dev, Markus/Architect, +Nora/Security, Sara/QA, Tobias/DevOps, Elicit/Requirements) converged on the +same recommendation: try the in-house fix path first, against the canonical +dataset, with quantitative exit triggers — adopt dagre only if any acceptance +criterion fails to converge. + +--- + +## Decision + +**Keep Stammbaum layout in-house. Do not adopt dagre at this time.** + +The fix path lands as six commits on #361: + +1. Spec geometry reconcile (`NODE_W=160, NODE_H=56` matches `buildLayout.ts`) + and an explicit seeded-rank-invariant Layout-rules line. +2. Canonical `/api/network` fixture capture script + pinned snapshot for + structural assertions in `buildLayout.test.ts`. +3. `spousePairs: Map` → `Map>`. Preserves + all marriages; closes Nora's robustness gap (edges referencing IDs outside + `allNodes` are guarded at ingestion). +4. Multi-spouse ordering: `(fromYear ASC NULLS LAST, displayName ASC)`, + inserted to the right of the parented focal — matches Leonie's UX rule. +5. Intra-family-marriage block merge across same-rank parented sibling blocks + (AC2) — adjacent placement at the join boundary. +6. Marriage-line midpoint dot enlarged from `r=4.5` to `r=6` (WCAG 1.4.11 + informational contrast — the dot disambiguates stacked marriages and is + no longer decorative). + +The block-packer + AC2 merge stays well under Markus's 80-LoC extraction +threshold, so `packBlocks.ts` is **not** yet warranted. + +--- + +## Consequences + +### Accepted today + +- **AC1 (multi-spouse preservation)** is now a property of `buildLayout`, verified + by both synthetic and canonical fixture tests. +- **AC2 (intra-family marriage)** ships latent but covered by a synthetic + two-family regression test. +- **AC4 (seeded-rank invariant)** preserved end-to-end by every `buildLayout.test.ts` + case from #689. +- **AC5 (spec ↔ code geometry)** reconciled in commit 1. + +### Deferred with revisit triggers + +- **AC3 — Unseeded loose spouse with parents-in-graph.** Database verification: + 0 of 942 unseeded persons match the predicate today. Structurally, every + realistic case maps to a **curation/import gap** (P's parents were imported + with `generation`, P themselves was not) and belongs in the canonical import + sheet rather than `buildLayout`. **Revisit trigger:** first canonical fixture + containing a parented unseeded spouse — at which point this ADR is updated + in place or superseded by an ADR-027. +- **AC6 — Bundle-impact gate (≤ 40 kB gzipped on `/stammbaum`).** Moot under + this ADR; reactivates only under ADR-027 (dagre adoption). +- **AC7 — Visual regression at 320 / 768 / 1440.** `toHaveScreenshot()` + permanently dropped (high running cost, speculative coverage). The axe-core + 3:1 contrast check for the enlarged marriage dot is verified one-shot at + PR time, not committed; the permanent contrast/breakpoint gate lands with + #692 (mobile pan/zoom epic) alongside the breakpoint visual-regression + infrastructure. + +### UX-signal-only stop trigger for dagre adoption + +There is **no LoC cap** on the in-house path. The only divergence signal that +warrants reopening the dagre decision is a **UX failure against the canonical +fixture** — specifically, Albert de Gruyter's 4 marriages failing the read +test ("can a 67-year-old researcher unambiguously see all four spouses?"). +If that ever happens, Felix posts a divergence-evidence comment on #361 (or +the equivalent successor issue), the team re-runs brainstorming with the +dagre option on the table, and adoption proceeds under the supply-chain +controls already documented in #361's body (`@dagrejs/dagre` exact-pinned, no +auto-merge, try/catch fallback with structured log, deterministic input sort). + +### Operational + +- **No CI, image, or compose changes.** Pure frontend layout work; standard + frontend rebuild covers the deploy. +- **No service topology changes.** No new env vars, ports, resource limits. + +--- + +## Notes + +- `frontend/scripts/capture-network-fixture.mjs` is a **local-only developer + utility**, never invoked from CI. Re-run intentionally; commit the resulting + JSON in one atomic commit when a new structural case appears (new edge type, + new marriage configuration, new generation range). +- The canonical fixture contains real family names. Repository is private; + scrubbing is a single-commit migration if it ever opens. +- Brand-mint enforcement on SVG strokes (Leonie's "all connectors render in + brand-navy, hierarchy comes from shape") stays a **code-review check at PR + time**. No CI grep, no custom ESLint rule.