@Elicit on PR #693: two doc gaps that block traceability on this PR. 1. docs/GLOSSARY.md: add a Stammbaum section with the layout vocabulary introduced by #689 and #361 — Stammbaum, seeded rank, sibling block, loose spouse, parented, anchor index, intra-family marriage, marriage dot, canonical fixture. Removes the Pending placeholder. 2. docs/adr/026: commit the AC3 reachability probe (the SQL that returned "0 of 942 unseeded persons match the predicate" in May 2026) directly into the ADR. A future architect re-evaluating the deferral can rerun it verbatim — reproducibility of the decision is itself a requirement. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
6.7 KiB
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 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:
- Multi-spouse persons (canonical case: Albert de Gruyter, 4 marriages) whose
secondary marriages were silently dropped by a
Map<string, string>shape. - Intra-family marriages — two persons in different sibling blocks at the same rank who marry each other (latent; zero cases in current data).
- 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:
- Spec geometry reconcile (
NODE_W=160, NODE_H=56matchesbuildLayout.ts) and an explicit seeded-rank-invariant Layout-rules line. - Canonical
/api/networkfixture capture script + pinned snapshot for structural assertions inbuildLayout.test.ts. spousePairs: Map<string, string>→Map<string, Set<string>>. Preserves all marriages; closes Nora's robustness gap (edges referencing IDs outsideallNodesare guarded at ingestion).- Multi-spouse ordering:
(fromYear ASC NULLS LAST, displayName ASC), inserted to the right of the parented focal — matches Leonie's UX rule. - Intra-family-marriage block merge across same-rank parented sibling blocks (AC2) — adjacent placement at the join boundary.
- Marriage-line midpoint dot enlarged from
r=4.5tor=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.tscase 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 thanbuildLayout. 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. Reproducible verification query (PostgreSQL — paste into a read-only psql session againstfamilienarchiv_archive):-- AC3 reachability probe. Returns one row per unseeded person who has at -- least one parent edge whose parent IS seeded. A non-zero count means the -- AC3 layout branch becomes reachable for that person and ADR-026 should -- be revisited. Last run May 2026: 0 rows. SELECT p.id, p.display_name FROM persons p WHERE p.generation IS NULL AND EXISTS ( SELECT 1 FROM person_relationships r JOIN persons parent ON parent.id = r.person_id WHERE r.relation_type = 'PARENT_OF' AND r.related_person_id = p.id AND parent.generation IS NOT NULL ); -
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.mjsis 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.