feat(stammbaum): bloodline-contiguous tidy-tree layout (replace per-generation packer) (#724) #725

Merged
marcel merged 24 commits from feat/issue-724-tidy-tree-layout into main 2026-06-04 14:55:12 +02:00
Owner

Closes #724.

Replaces the top-down per-generation block packer with a bottom-up "tidy tree" so each bloodline reads as one contiguous, compact band and every ancestor is centred over the horizontal span of its descendants (fixes the Albert/Martin smear). Only horizontal x is rewritten; assignRanks (y / #689 seeding), the generations map, and computeViewBox are reused unchanged.

Module split

  • layout/tidyTree.ts (new) — domain-agnostic Reingold–Tilford contour packer over abstract { id, width, children, level? } nodes (zero generated-API imports). Contour indexed by absolute generation level, not tree depth, so unrelated roots at different generations share x-columns.
  • layout/familyForest.ts (new) — all genealogy semantics: unit model (primary + absorbed spouse run), pickStructuralOwner, loose-spouse absorption, multi-spouse runs (#361), sibling order (birthYear ASC NULLS LAST → displayName → id), intra-family resolution + cross-link classification. Unknown-id guard covers PARENT_OF and SPOUSE_OF.
  • layout/buildLayout.ts — orchestrates forest → tidyTree → per-person positions (x from structure, y from rank). ~210-line block packer removed.
  • StammbaumConnectors.svelte — cross-level links render with a distinct 2 6 dash at reduced opacity (never the 4 4 ended-marriage cadence); geometry still lands on the child (WCAG 1.4.1).

Two decisions taken during implementation (confirmed with maintainer)

  1. Intra-family = hybrid. Same-level bonds render solid; genuinely cross-level bonds keep the structural owner's hierarchy edge and draw the other parent→spouse edge as a distinct cross-link. Couples are always exactly adjacent in the run.
  2. Width metric replaced. Centring a 24-root forest is inherently wider overall (7960 vs old 4860px) — total width was the wrong gauge. Asserts per-bloodline span instead: Albert de Gruyter's bloodline went from a full ~4860px smear to ~960px.

Testing

21 atomic TDD commits; 42 layout unit tests green. npm run check adds 0 new type errors (833 vs ~834 baseline); lint clean. Covers ancestor-centring (named-bug guard + fixture-wide loop), no-overlap, bloodline contiguity, intra-family adjacency, cross-link fallback, determinism (seeded permutation), and termination/once-only on cyclic input. Full sweep + browser project left to CI.

Open verification (non-blocking, per the issue)

Manual 320px pass on /stammbaum to confirm cross-link/parent-drop legibility; spin a connector-clarity follow-up only if it tangles. Polished cross-link routing + relationship tooltip remain a deliberate follow-up. No new package.json dependency.

🤖 Generated with Claude Code

Closes #724. Replaces the top-down per-generation block packer with a **bottom-up "tidy tree"** so each bloodline reads as one contiguous, compact band and every ancestor is centred over the horizontal span of its descendants (fixes the Albert/Martin smear). Only horizontal `x` is rewritten; `assignRanks` (y / #689 seeding), the `generations` map, and `computeViewBox` are reused unchanged. ## Module split - **`layout/tidyTree.ts`** (new) — domain-agnostic Reingold–Tilford contour packer over abstract `{ id, width, children, level? }` nodes (zero generated-API imports). Contour indexed by **absolute generation level**, not tree depth, so unrelated roots at different generations share x-columns. - **`layout/familyForest.ts`** (new) — all genealogy semantics: unit model (primary + absorbed spouse run), `pickStructuralOwner`, loose-spouse absorption, multi-spouse runs (#361), sibling order (birthYear ASC NULLS LAST → displayName → id), intra-family resolution + cross-link classification. Unknown-id guard covers PARENT_OF **and** SPOUSE_OF. - **`layout/buildLayout.ts`** — orchestrates forest → tidyTree → per-person positions (x from structure, y from rank). ~210-line block packer removed. - **`StammbaumConnectors.svelte`** — cross-level links render with a distinct `2 6` dash at reduced opacity (never the `4 4` ended-marriage cadence); geometry still lands on the child (WCAG 1.4.1). ## Two decisions taken during implementation (confirmed with maintainer) 1. **Intra-family = hybrid.** Same-level bonds render solid; genuinely cross-level bonds keep the structural owner's hierarchy edge and draw the other parent→spouse edge as a distinct cross-link. Couples are always exactly adjacent in the run. 2. **Width metric replaced.** Centring a 24-root forest is inherently wider overall (7960 vs old 4860px) — total width was the wrong gauge. Asserts **per-bloodline span** instead: Albert de Gruyter's bloodline went from a full ~4860px smear to **~960px**. ## Testing 21 atomic TDD commits; **42 layout unit tests green**. `npm run check` adds **0** new type errors (833 vs ~834 baseline); lint clean. Covers ancestor-centring (named-bug guard + fixture-wide loop), no-overlap, bloodline contiguity, intra-family adjacency, cross-link fallback, determinism (seeded permutation), and termination/once-only on cyclic input. Full sweep + browser project left to CI. ## Open verification (non-blocking, per the issue) Manual 320px pass on `/stammbaum` to confirm cross-link/parent-drop legibility; spin a connector-clarity follow-up only if it tangles. Polished cross-link routing + relationship tooltip remain a deliberate follow-up. No new `package.json` dependency. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 21 commits 2026-06-04 13:57:12 +02:00
The existing node() factory never sets birthYear, but the new sibling/branch
comparator (birthYear ASC NULLS LAST) needs it. Add makeNode(id, name,
{birthYear, generation}) alongside it; unblocks every ordering test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New domain-agnostic bottom-up tidy-tree module (Reingold-Tilford contour pack)
operating on abstract { id, width, children } nodes — zero generated-API
imports. First rung of the TDD ladder: a single leaf lays out at x=0. The full
contour/centring machinery is in place; subsequent commits add tests that
exercise it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Structural-owner rule for couples: earlier birth year wins, missing year sorts
last, ties break on stable id. The single definition reused by the cross-link,
cycle and intra-family paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Assigns every person to one unit: a primary, or a spouse absorbed into the
primary's run (marriage-year order, #361 preserved). Wires the parent/child
hierarchy from each primary's structural-owner parent and records displaced
parent edges as cross-links (classified same-level vs cross-level for later
distinct rendering). Unknown-id guard covers PARENT_OF and SPOUSE_OF.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Net-new ordering coverage: roots and every unit's children sort by birthYear
ASC (undated last), then displayName, then stable id — so horizontal x never
depends on Map iteration order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
buildLayout now builds the family forest, packs it bottom-up via tidyTree, and
maps each unit's run x back to per-person positions (x from structure, y from
rank). assignRanks, the generations map, and computeViewBox are reused
unchanged. The unknown-id guard now covers PARENT_OF as well as SPOUSE_OF, and
displaced cross-level edges are exposed as crossLinks for distinct rendering.
The ~210-line block packer (and its block/merge helpers) is gone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extends the existing adjacency contract: the couple is exactly adjacent in the
run AND, because both parents are roots (same structural level), the displaced
parent edge stays solid — layout.crossLinks is empty for this case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the two spouses' parents sit at different structural levels, the
structural owner keeps its hierarchy edge and the other parent->spouse edge is
recorded in layout.crossLinks (rendered with a distinct dash). The couple still
sits exactly adjacent in the owner's run and B keeps a real position.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
StammbaumConnectors takes the layout's crossLinks and draws those parent->child
connectors with a 2 6 dash at reduced opacity — deliberately distinct from the
ended-marriage spouse dash (4 4) and from a solid parent drop. Geometry still
lands on the child top, so the meaning is carried redundantly (WCAG 1.4.1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A 5-generation single bloodline fanning out wide at the bottom: the apex
great-great-grandparent (and every ancestor in the chain) sits at the centre of
the descendant span, the exact symptom the old per-generation packer produced
in reverse (apex pinned to the left edge).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixture-wide loop over the canonical forest and a synthetic tree: each unit's
run centre is within [min, max] of its child-unit centres — the ancestor
centring invariant, asserted on real data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
O(n^2) sweep over canonical + synthetic: any two nodes sharing a y are at least
NODE_W + COL_GAP apart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
No node outside a root's structural subtree may intrude into that bloodline's
[minX, maxX] horizontal span — the contiguity guarantee that fixes the smeared
bloodline symptom.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The canonical graph is a forest of 24 roots spread across generations 0-4.
Packing every root at tree-depth 0 stacked all of them horizontally even when
they sit at different generations (different y), blowing the canvas out to
~9660px. Indexing the contour by absolute level (the rank buildLayout already
passes as level) lets unrelated roots at different generations share x-columns,
and keeps the no-overlap guarantee per-row. level falls back to tree depth when
omitted, so the abstract tidyTree tests are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Total canvas width is the wrong metric: centring every ancestor makes a 24-root
forest wider overall (an accepted trade-off, pan/zoom handles navigation). The
actual fix is per-bloodline compactness. Assert every contiguous bloodline's
span stays far under the old full-canvas smear (4860px) — today the widest,
Albert de Gruyter's, is ~960px, down from being smeared across the whole canvas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Seeded Fisher-Yates permutation of nodes and edges yields byte-identical
positions — confirms every comparator ends in a stable id and nothing relies on
Map iteration order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
test(stammbaum): cyclic input fails closed — finite layout, one position per node (#724)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m23s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m37s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m2s
fad3aa0373
An A<->B parent cycle and a founder reaching a re-entrant 3-cycle both return a
finite layout (no frozen $derived) with every node placed exactly once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author
Owner

⚙️ Tobias Wendt — DevOps Engineer

LGTM — pure frontend logic change, zero infra/CI/dependency surface. No blockers.

Reviewed from the build/CI/operations lens only (I leave the Reingold–Tilford correctness to the others):

Dependencies — verified the PR claim. No package.json / package-lock.json touched (0 hits in the diff). The three new/changed layout modules (tidyTree.ts, familyForest.ts, buildLayout.ts) import only $lib/generated/api and their local siblings — no external package pulled in. tidyTree.ts is deliberately dependency-free (domain-agnostic). So: no bundle-size delta, no new transitive tree, no Renovate noise. 👍

Test execution in CI — correctly routed to the fast project. The new tests are named *.test.ts, not *.svelte.test.ts. Per frontend/vite.config.ts, the node project (environment: 'node') includes src/**/*.{test,spec}.{js,ts} and excludes *.svelte.{test,spec} — so all 42 layout tests run in the in-process Node project, not the Playwright client browser project. That means they execute deterministically in CI without a browser runner, which is exactly where we want pure-logic tests. The seeded-permutation determinism test is a nice touch — no flake risk.

No infra footprint. Confirmed the PR's 8 files are all under frontend/src/lib/person/genealogy/. No CI workflow, Docker/Compose, env var, Caddyfile, or vite.config.ts change. Nothing to deploy, nothing to size, no secret surface. The ~210-line block packer removal is a net code reduction — good for maintainability.

Build impact. svelte-check reported 0 new type errors (833 vs ~834 baseline) and lint clean per the PR body — consistent with the no-new-import findings. The only .svelte file changed (StammbaumConnectors.svelte) carries no co-located browser test in this PR; that's fine for a CSS-dash/opacity tweak, and the full sweep + browser project is correctly deferred to CI.

Ship it from where I sit.

## ⚙️ Tobias Wendt — DevOps Engineer ✅ **LGTM** — pure frontend logic change, zero infra/CI/dependency surface. No blockers. Reviewed from the build/CI/operations lens only (I leave the Reingold–Tilford correctness to the others): **Dependencies — verified the PR claim.** No `package.json` / `package-lock.json` touched (0 hits in the diff). The three new/changed layout modules (`tidyTree.ts`, `familyForest.ts`, `buildLayout.ts`) import only `$lib/generated/api` and their local siblings — no external package pulled in. `tidyTree.ts` is deliberately dependency-free (domain-agnostic). So: no bundle-size delta, no new transitive tree, no Renovate noise. 👍 **Test execution in CI — correctly routed to the fast project.** The new tests are named `*.test.ts`, not `*.svelte.test.ts`. Per `frontend/vite.config.ts`, the `node` project (`environment: 'node'`) includes `src/**/*.{test,spec}.{js,ts}` and excludes `*.svelte.{test,spec}` — so all 42 layout tests run in the in-process Node project, **not** the Playwright `client` browser project. That means they execute deterministically in CI without a browser runner, which is exactly where we want pure-logic tests. The seeded-permutation determinism test is a nice touch — no flake risk. **No infra footprint.** Confirmed the PR's 8 files are all under `frontend/src/lib/person/genealogy/`. No CI workflow, Docker/Compose, env var, Caddyfile, or `vite.config.ts` change. Nothing to deploy, nothing to size, no secret surface. The ~210-line block packer removal is a net code reduction — good for maintainability. **Build impact.** `svelte-check` reported 0 new type errors (833 vs ~834 baseline) and lint clean per the PR body — consistent with the no-new-import findings. The only `.svelte` file changed (`StammbaumConnectors.svelte`) carries no co-located browser test in this PR; that's fine for a CSS-dash/opacity tweak, and the full sweep + browser project is correctly deferred to CI. Ship it from where I sit.
Author
Owner

🎨 Leonie Voss — UI/UX Expert

Verdict: ⚠️ Approve with notes — the dash-vs-dash distinction is sound and WCAG 1.4.1-compliant, but the 0.55 cross-link opacity drops the line below the 3:1 non-text-contrast floor and is the one thing I'd want measured before the deferred visual pass closes.

I reviewed StammbaumConnectors.svelte, its integration in StammbaumTree.svelte, and the connector colour tokens in layout.css.

What's genuinely good

  • Redundancy beyond colour is real (WCAG 1.4.1 pass). Cross-links are never distinguished by colour — they keep the same --c-primary stroke and add (a) a distinct 2 6 dash cadence and (b) geometry that still lands on the child. Three independent cues. The inline comment makes the intent explicit. This is the right instinct.
  • The two dashes are perceptibly different. 2 6 (tiny dots, 8px period, 25% on) vs the ended-marriage 4 4 (even dashes, 8px period, 50% on) read as "dotted" vs "dashed" — distinguishable shapes, not just densities. The ended-marriage edge also carries a 6px midpoint circle the cross-link never has, so even at a glance they don't collide.

Concerns (non-blocking, fold into the deferred 320px pass)

1. [High] 0.55 opacity likely fails WCAG 1.4.11 (non-text contrast, 3:1).
The cross-link is a 1.5px graphical line, so the relevant criterion is non-text contrast (3:1), not text (4.5:1). Light mode: navy #012851 at 0.55 over canvas #f0efe9 blends to roughly #6D8295, which is only ~2.6:1 against the canvas — under the 3:1 floor. A 2 6 dash is mostly gap, so the eye gets very little ink to work with, and a 67-year-old on a phone in daylight is exactly who loses it. The dash alone already does the disambiguation work; the opacity reduction is doing double duty and pushing it past the legible threshold.
Concrete fix: raise to stroke-opacity: 0.7 (blends to ~#4E687F, ~3.4:1 — clears non-text contrast) and/or widen the dash to 2 4 so there's more ink per period. Measure both light and dark mode — dark mode swaps --c-primary to mint #a1dcd8 on #010e1e, a completely different ratio that must be verified independently.

2. [Medium] Verify dark mode separately. Same line of code, two themes, two contrast outcomes. The 0.55 multiplier was almost certainly eyeballed in one mode. Please confirm both during the manual pass.

3. [Low — already acknowledged] 320px / 7960px canvas trade-off. Accepting the wider canvas to win bloodline contiguity is the correct call — per-bloodline span (~960px vs the ~4860px smear) is the metric that matters to a reader, not total width, and the canvas is overflow-scroll on phones anyway. No objection. The deferred 320px manual check should specifically confirm the cross-link dash is still resolvable at fit-zoom on a 320px viewport, where each 2-unit dash may render sub-pixel.

For the deferred visual follow-up

  • Run axe / a manual contrast probe on the cross-link stroke in both themes at the blended (post-opacity) colour, not the token value.
  • Sanity-check the 2 6 dash at fit-to-screen zoom on 320px — if it vanishes into the gaps, bump dash ink before bumping opacity.
  • A relationship tooltip (already flagged as follow-up) would give the colour-blind / low-vision reader a non-visual confirmation of what a cross-link means — strongly endorse keeping that on the roadmap.

Nothing here blocks merge; the geometry-carries-meaning guarantee holds even if the line is faint. Just don't let the opacity question quietly fall off when the 320px pass happens.

## 🎨 Leonie Voss — UI/UX Expert **Verdict: ⚠️ Approve with notes** — the dash-vs-dash distinction is sound and WCAG 1.4.1-compliant, but the `0.55` cross-link opacity drops the line below the 3:1 non-text-contrast floor and is the one thing I'd want measured before the deferred visual pass closes. I reviewed `StammbaumConnectors.svelte`, its integration in `StammbaumTree.svelte`, and the connector colour tokens in `layout.css`. ### What's genuinely good - **Redundancy beyond colour is real (WCAG 1.4.1 pass).** Cross-links are never distinguished by colour — they keep the same `--c-primary` stroke and add (a) a distinct `2 6` dash cadence and (b) geometry that still lands on the child. Three independent cues. The inline comment makes the intent explicit. This is the right instinct. - **The two dashes are perceptibly different.** `2 6` (tiny dots, 8px period, 25% on) vs the ended-marriage `4 4` (even dashes, 8px period, 50% on) read as "dotted" vs "dashed" — distinguishable shapes, not just densities. The ended-marriage edge also carries a 6px midpoint circle the cross-link never has, so even at a glance they don't collide. ### Concerns (non-blocking, fold into the deferred 320px pass) **1. [High] `0.55` opacity likely fails WCAG 1.4.11 (non-text contrast, 3:1).** The cross-link is a 1.5px graphical line, so the relevant criterion is non-text contrast (3:1), not text (4.5:1). Light mode: navy `#012851` at 0.55 over canvas `#f0efe9` blends to roughly `#6D8295`, which is only ~2.6:1 against the canvas — under the 3:1 floor. A `2 6` dash is *mostly gap*, so the eye gets very little ink to work with, and a 67-year-old on a phone in daylight is exactly who loses it. The dash alone already does the disambiguation work; the opacity reduction is doing double duty and pushing it past the legible threshold. *Concrete fix:* raise to `stroke-opacity: 0.7` (blends to ~`#4E687F`, ~3.4:1 — clears non-text contrast) and/or widen the dash to `2 4` so there's more ink per period. Measure both light and **dark** mode — dark mode swaps `--c-primary` to mint `#a1dcd8` on `#010e1e`, a completely different ratio that must be verified independently. **2. [Medium] Verify dark mode separately.** Same line of code, two themes, two contrast outcomes. The 0.55 multiplier was almost certainly eyeballed in one mode. Please confirm both during the manual pass. **3. [Low — already acknowledged] 320px / 7960px canvas trade-off.** Accepting the wider canvas to win bloodline contiguity is the correct call — per-bloodline span (~960px vs the ~4860px smear) is the metric that matters to a reader, not total width, and the canvas is overflow-scroll on phones anyway. No objection. The deferred 320px manual check should specifically confirm the cross-link dash is still resolvable at fit-zoom on a 320px viewport, where each `2`-unit dash may render sub-pixel. ### For the deferred visual follow-up - Run axe / a manual contrast probe on the cross-link stroke in **both** themes at the blended (post-opacity) colour, not the token value. - Sanity-check the `2 6` dash at fit-to-screen zoom on 320px — if it vanishes into the gaps, bump dash ink before bumping opacity. - A relationship tooltip (already flagged as follow-up) would give the colour-blind / low-vision reader a non-visual confirmation of what a cross-link means — strongly endorse keeping that on the roadmap. Nothing here blocks merge; the geometry-carries-meaning guarantee holds even if the line is faint. Just don't let the opacity question quietly fall off when the 320px pass happens.
Author
Owner

🏛️ Markus Keller — Software Architect

⚠️ Approved with concerns — the module split is genuinely good architecture. The blockers are documentation-currency failures, not code defects.

I reviewed the four layout modules (tidyTree.ts, familyForest.ts, buildLayout.ts), the StammbaumConnectors.svelte consumer, the Layout contract change, and the doc surface that ADR-026 promised to keep in sync. I scoped to the 21 #724 commits (8b070bdb..fad3aa03) — the ADR-027/028/029 and the backend/CI churn in the diff stat are stale-main divergence, not this PR.

What I like (the part that matters most)

The three-layer split is exactly the boundary I would have drawn:

  • tidyTree.ts is genuinely domain-agnostic — zero $lib/generated imports, operates on abstract { id, width, children, level? }. It is unit-testable with hand-built 3-line trees and could be lifted into any other tree view unchanged. This is package-by-capability done right.
  • familyForest.ts owns all the genealogy semantics (unit model, structural-owner rule, absorption, spouse runs, cross-link classification) behind a published FamilyForest interface. pickStructuralOwner is defined once and reused across the cycle/absorption/cross-link/hierarchy paths — single source of truth for the tie-break rule.
  • buildLayout.ts is a thin orchestrator that maps forest → tidyTree → positions. The ~210-line inline packer is gone.

The crossLinks addition to the Layout contract is the right shape: optional in the consumer (crossLinks?: Layout['crossLinks'] defaults to []), and the SVG renders the same geometry onto the child regardless — stroke is redundant, not load-bearing (WCAG 1.4.1). Determinism is enforced at every ordering point (branch order, spouse run, marriage iteration all break ties on the stable id), so x never depends on Map insertion order. Good defensive engineering on the cyclic-input guard (runOwner seen-set) and the unknown-id guard covering both edge types.


Blockers (must fix before merge — doc currency, per my review checklist)

  1. No ADR for replacing the block packer — and ADR-026 explicitly reasoned the opposite. ADR-026 (#361) states: "The block-packer + AC2 merge stays well under Markus's 80-LoC extraction threshold, so packBlocks.ts is not yet warranted." This PR crosses that threshold, deletes the very block packer ADR-026 described, and introduces a new layout algorithm (bottom-up Reingold–Tilford contour pack) plus a width-metric change (total width → per-bloodline span, 4860→7960px overall). That is precisely "an architectural decision with lasting consequences." ADR-026 even pre-committed the successor mechanism: "a future ADR-027 if any acceptance criterion stops converging in-house." The #724 commits touch no file under docs/adr/ or docs/architecture/. Write a new ADR (next free number) that: records the tidy-tree replacement, supersedes/updates the relevant section of ADR-026, and captures the width-metric decision and the two implementation decisions from the PR body (hybrid intra-family rendering; per-bloodline span as the gauge). The two "confirmed with maintainer" decisions belong in the repository's memory, not just the PR description.

  2. docs/GLOSSARY.md describes the deleted model and none of the new vocabulary. GLOSSARY line 114 still defines "sibling block" as "a layout unit … used inside buildLayout.ts … blocks are then packed left-to-right within a generation row" — that packer no longer exists. Meanwhile the PR introduces a whole new domain vocabulary that appears nowhere in the glossary: family forest, unit (primary + absorbed spouses), structural owner, cross-link (and the same-level vs cross-level distinction), tidy tree, bloodline/bloodline span. New domain concepts → glossary update is on my checklist. Remove/rewrite the stale "sibling block" entry and add the new terms.


Suggestions (non-blocking)

  1. Edge-parsing logic is duplicated across the buildLayout↔familyForest boundary. buildLayout.ts (L29–49) parses allEdges into childToParents/spousePairs for assignRanks, and buildFamilyForest (L75–87) parses the same allEdges again into its own parentToChildren/childToParents/spouses maps. Both independently re-implement the unknown-id guard, the push/addToSet helpers, and pairKey. This is fine for now (the duplication is cheaper than the wrong coupling, and assignRanks legitimately needs only a subset), but it's a latent drift risk: if the guard rule changes in one place and not the other, ranks and structure could disagree. Consider a single shared edge-ingest step producing the adjacency maps both consumers read, or at minimum a comment cross-referencing the two guards so they stay in lockstep.

  2. The "safety net" in buildLayout.ts (L103–111) silently masks a forest bug. If buildFamilyForest ever fails to place a node, the fallback drops it into a spare++ column rather than failing loudly. The comment says "it shouldn't leave any" — I agree, which is why I'd rather see a console.warn (or a dev-mode assertion) than a silent recovery. A node landing in a stray right-hand column is a hard-to-diagnose visual artifact; an architecture that fails loudly and predictably beats one that quietly limps. Keep the fallback, but make it observable.

  3. COL_GAP does double duty as both inter-card spacing and tidy-tree gap. buildLayout passes COL_GAP (40) as the contour clearance and uses it for intra-run member spacing. That's a reasonable default, but the coupling is implicit — if someone later wants tighter card spacing without collapsing sibling-subtree clearance, the single constant fights them. A one-line comment noting the deliberate reuse would save a future reader the head-scratch.

None of the suggestions block. Fix the two doc blockers — an ADR that reverses a prior ADR's explicit conclusion, and a glossary that documents the deleted model — and this is a clean merge. The code itself is the cleanest layout boundary we've had on the Stammbaum.

## 🏛️ Markus Keller — Software Architect **⚠️ Approved with concerns** — the module split is genuinely good architecture. The blockers are documentation-currency failures, not code defects. I reviewed the four layout modules (`tidyTree.ts`, `familyForest.ts`, `buildLayout.ts`), the `StammbaumConnectors.svelte` consumer, the `Layout` contract change, and the doc surface that ADR-026 promised to keep in sync. I scoped to the 21 `#724` commits (`8b070bdb..fad3aa03`) — the ADR-027/028/029 and the backend/CI churn in the diff stat are stale-main divergence, not this PR. ### What I like (the part that matters most) The three-layer split is exactly the boundary I would have drawn: - `tidyTree.ts` is **genuinely domain-agnostic** — zero `$lib/generated` imports, operates on abstract `{ id, width, children, level? }`. It is unit-testable with hand-built 3-line trees and could be lifted into any other tree view unchanged. This is package-by-capability done right. - `familyForest.ts` owns **all** the genealogy semantics (unit model, structural-owner rule, absorption, spouse runs, cross-link classification) behind a published `FamilyForest` interface. `pickStructuralOwner` is defined once and reused across the cycle/absorption/cross-link/hierarchy paths — single source of truth for the tie-break rule. - `buildLayout.ts` is a thin orchestrator that maps forest → tidyTree → positions. The ~210-line inline packer is gone. The `crossLinks` addition to the `Layout` contract is the right shape: optional in the consumer (`crossLinks?: Layout['crossLinks']` defaults to `[]`), and the SVG renders the same geometry onto the child regardless — stroke is redundant, not load-bearing (WCAG 1.4.1). Determinism is enforced at every ordering point (branch order, spouse run, marriage iteration all break ties on the stable id), so x never depends on `Map` insertion order. Good defensive engineering on the cyclic-input guard (`runOwner` seen-set) and the unknown-id guard covering both edge types. --- ### Blockers (must fix before merge — doc currency, per my review checklist) 1. **No ADR for replacing the block packer — and ADR-026 explicitly reasoned the opposite.** ADR-026 (#361) states: *"The block-packer + AC2 merge stays well under Markus's 80-LoC extraction threshold, so `packBlocks.ts` is **not** yet warranted."* This PR crosses that threshold, deletes the very block packer ADR-026 described, and introduces a new layout algorithm (bottom-up Reingold–Tilford contour pack) plus a width-metric change (total width → per-bloodline span, 4860→7960px overall). That is precisely "an architectural decision with lasting consequences." ADR-026 even pre-committed the successor mechanism: *"a future ADR-027 if any acceptance criterion stops converging in-house."* The `#724` commits touch **no** file under `docs/adr/` or `docs/architecture/`. Write a new ADR (next free number) that: records the tidy-tree replacement, supersedes/updates the relevant section of ADR-026, and captures the width-metric decision and the two implementation decisions from the PR body (hybrid intra-family rendering; per-bloodline span as the gauge). The two "confirmed with maintainer" decisions belong in the repository's memory, not just the PR description. 2. **`docs/GLOSSARY.md` describes the deleted model and none of the new vocabulary.** GLOSSARY line 114 still defines **"sibling block"** as *"a layout unit … used inside `buildLayout.ts` … blocks are then packed left-to-right within a generation row"* — that packer no longer exists. Meanwhile the PR introduces a whole new domain vocabulary that appears nowhere in the glossary: **family forest**, **unit** (primary + absorbed spouses), **structural owner**, **cross-link** (and the same-level vs cross-level distinction), **tidy tree**, **bloodline/bloodline span**. New domain concepts → glossary update is on my checklist. Remove/rewrite the stale "sibling block" entry and add the new terms. --- ### Suggestions (non-blocking) 1. **Edge-parsing logic is duplicated across the buildLayout↔familyForest boundary.** `buildLayout.ts` (L29–49) parses `allEdges` into `childToParents`/`spousePairs` for `assignRanks`, and `buildFamilyForest` (L75–87) parses the *same* `allEdges` again into its own `parentToChildren`/`childToParents`/`spouses` maps. Both independently re-implement the unknown-id guard, the `push`/`addToSet` helpers, and `pairKey`. This is fine for now (the duplication is cheaper than the wrong coupling, and `assignRanks` legitimately needs only a subset), but it's a latent drift risk: if the guard rule changes in one place and not the other, ranks and structure could disagree. Consider a single shared edge-ingest step producing the adjacency maps both consumers read, or at minimum a comment cross-referencing the two guards so they stay in lockstep. 2. **The "safety net" in `buildLayout.ts` (L103–111) silently masks a forest bug.** If `buildFamilyForest` ever fails to place a node, the fallback drops it into a `spare++` column rather than failing loudly. The comment says "it shouldn't leave any" — I agree, which is why I'd rather see a `console.warn` (or a dev-mode assertion) than a silent recovery. A node landing in a stray right-hand column is a hard-to-diagnose visual artifact; an architecture that fails loudly and predictably beats one that quietly limps. Keep the fallback, but make it observable. 3. **`COL_GAP` does double duty as both inter-card spacing and tidy-tree `gap`.** `buildLayout` passes `COL_GAP` (40) as the contour clearance *and* uses it for intra-run member spacing. That's a reasonable default, but the coupling is implicit — if someone later wants tighter card spacing without collapsing sibling-subtree clearance, the single constant fights them. A one-line comment noting the deliberate reuse would save a future reader the head-scratch. None of the suggestions block. Fix the two doc blockers — an ADR that reverses a prior ADR's explicit conclusion, and a glossary that documents the deleted model — and this is a clean merge. The code itself is the cleanest layout boundary we've had on the Stammbaum.
Author
Owner

🧪 Sara Holt — QA / Test Engineer

Verdict: ⚠️ Approve-with-conditions. The test suite here is genuinely good — the assertions are meaningful, not vacuous; the determinism test is robust; the named-bug guard and cyclic-termination tests are exactly the kind of insurance I want around a $derived layout. But there are a handful of real coverage gaps, and two of them I'm calling blockers because they sit on the boundary conditions a frozen tab / blank canvas would expose, and on the one visual claim the PR makes that no test actually verifies.

What's strong (credit where due)

  • Determinism test is the right way to do it. Seeded mulberry32 Fisher–Yates instead of Math.random() — reproducible, and shuffles both nodes and edges with different seeds (buildLayout.test.ts:620-652). This is how you write a determinism test that won't itself flake.
  • Cyclic input is covered twice (A↔B and a re-entrant 3-cycle through a founder), and both assert termination + exactly-once placement (positions.size), not just "didn't throw". buildLayout.test.ts:655-699. Good — that's the failure mode that freezes the tab.
  • Real-data + synthetic paired cases. The ancestor-centring invariant, no-overlap, and multi-spouse fallback each run against the canonical fixture and a synthetic shape (buildLayout.test.ts:477-541). And the fallback test has a precondition guard that fails loudly if a canonical fromYear is ever backfilled (buildLayout.test.ts:331-337) — that's mature test hygiene, it prevents a silent semantic drift.
  • No tautologies. The intra-family adjacency test even tightened to "no third node between the spouses on the same y" (buildLayout.test.ts:303-309), and the names in the year-sort test are deliberately chosen so alphabetical ≠ year order (buildLayout.test.ts:236-240) — that's the difference between a test that proves the sort key and one that passes by accident.
  • pickStructuralOwner covered across all three branches (earlier-born, NULLS-LAST, id-tiebreak). Unknown-id guard tested for both PARENT_OF and SPOUSE_OF.

🚫 Blockers

  1. The cross-link 2 6 dash is never verified where it renders. The PR's headline visual claim is that cross-links draw with a distinct 2 6 cadence, "never the 4 4 ended-marriage cadence" (StammbaumConnectors.svelte:32, lines 180/198/251). The only thing any test asserts is the structural crossLinks array (buildLayout.test.ts:405). There is no browser test that renders a cross-level marriage and asserts a line with stroke-dasharray="2 6" exists, and — critically — nothing asserts 2 6 is distinguishable from 4 4. The existing divorce-dash test (StammbaumTree.svelte.test.ts:657-683) only checks hasAttribute('stroke-dasharray') — it would pass identically whether the cadence were 2 6 or 4 4. A future refactor could collapse the two cadences and the whole suite stays green. This is the WCAG-1.4.1 "not by colour alone" guarantee the PR explicitly invokes; it needs a vitest-browser-svelte test on StammbaumConnectors that mounts a sameLevel:false cross-link and asserts getAttribute('stroke-dasharray') === '2 6' (and at least one divorce line asserting === '4 4' so the two can never converge).

  2. Empty graph is completely untested. There is no buildLayout([], []) case anywhere. computeViewBox has an explicit positions.size === 0 branch (buildLayout.ts:140-145) that is dead from the test suite's perspective, and layoutForest has its own roots.length === 0 early return (tidyTree.ts:58). A user landing on /stammbaum with a filtered-to-empty graph is a real path, and it runs inside a $derived. One three-line test (positions.size === 0, viewW === MIN_VIEWBOX_W, no throw) closes it. Cheap insurance, currently absent.

⚠️ Concerns / suggestions (not merge-blocking)

  1. The buildLayout safety-net branch (buildLayout.ts:104-111) is untested. The comment says "it shouldn't leave any" — which is precisely the kind of defensive branch that rots. If you believe the forest always places every node, then either (a) add a test that constructs an input which does leave a node unplaced and asserts it still lands on the grid, or (b) if it's truly unreachable, drop it and let the absence be caught by the existing "every node has a position" fixture assertion. Untested-and-unreachable defensive code is a coverage blind spot either way.

  2. sameLevel: true is asserted only by its absence. The intra-family test proves crossLinks === [] because same-level links are filtered at buildLayout.ts:114. But no test asserts that buildFamilyForest actually emits a { sameLevel: true } CrossLink that then gets filtered — so the filter at line 114 and the sameLevel classification at familyForest.ts:196 are only ever exercised in the negative. Add one familyForest.test.ts case asserting forest.crossLinks contains a sameLevel:true entry for the intra-family shape, so the classifier and the filter are tested independently.

  3. No single-node case. buildLayout([oneNode], []) — trivial, but it's the degenerate base case for layoutForest/computeViewBox (single node should not be scaled to fill the canvas — the MIN_VIEWBOX logic exists for exactly this). Worth one assertion alongside the empty-graph test.

  4. Absorption "both already fixed" branch untested (familyForest.ts:133-135, the else that draws a plain spouse line without forcing absorption). This is the multi-spouse-collision case — a person married into two already-owned runs. Not exercised by any synthetic or by the fixture (the fixture's only multi-spouse is Albert, all loose). One synthetic case would cover it.

Layer placement — correct

Everything is at the right layer: pure layout logic as fast Vitest unit tests (no browser, no Spring), domain semantics isolated in familyForest/tidyTree so they're testable with 3-line hand-built trees. The one thing that genuinely belongs in the browser layer — the dash cadence — is the one thing that's missing from it (blocker 1). Fix 1 and 2 and I'm a .

## 🧪 Sara Holt — QA / Test Engineer **Verdict: ⚠️ Approve-with-conditions.** The test suite here is genuinely good — the assertions are *meaningful*, not vacuous; the determinism test is robust; the named-bug guard and cyclic-termination tests are exactly the kind of insurance I want around a `$derived` layout. But there are a handful of real coverage gaps, and two of them I'm calling **blockers** because they sit on the boundary conditions a frozen tab / blank canvas would expose, and on the one visual claim the PR makes that no test actually verifies. ### What's strong (credit where due) - **Determinism test is the right way to do it.** Seeded mulberry32 Fisher–Yates instead of `Math.random()` — reproducible, and shuffles *both* nodes and edges with different seeds (buildLayout.test.ts:620-652). This is how you write a determinism test that won't itself flake. - **Cyclic input is covered twice** (A↔B and a re-entrant 3-cycle through a founder), and both assert *termination + exactly-once placement* (`positions.size`), not just "didn't throw". buildLayout.test.ts:655-699. Good — that's the failure mode that freezes the tab. - **Real-data + synthetic paired cases.** The ancestor-centring invariant, no-overlap, and multi-spouse fallback each run against the canonical fixture *and* a synthetic shape (buildLayout.test.ts:477-541). And the fallback test has a **precondition guard** that fails loudly if a canonical `fromYear` is ever backfilled (buildLayout.test.ts:331-337) — that's mature test hygiene, it prevents a silent semantic drift. - **No tautologies.** The intra-family adjacency test even tightened to "no third node between the spouses on the same y" (buildLayout.test.ts:303-309), and the names in the year-sort test are deliberately chosen so alphabetical ≠ year order (buildLayout.test.ts:236-240) — that's the difference between a test that proves the sort key and one that passes by accident. - `pickStructuralOwner` covered across all three branches (earlier-born, NULLS-LAST, id-tiebreak). Unknown-id guard tested for **both** PARENT_OF and SPOUSE_OF. ### 🚫 Blockers 1. **The cross-link `2 6` dash is never verified where it renders.** The PR's headline visual claim is that cross-links draw with a distinct `2 6` cadence, "never the `4 4` ended-marriage cadence" (StammbaumConnectors.svelte:32, lines 180/198/251). The *only* thing any test asserts is the structural `crossLinks` array (`buildLayout.test.ts:405`). There is **no** browser test that renders a cross-level marriage and asserts a line with `stroke-dasharray="2 6"` exists, and — critically — **nothing asserts `2 6` is distinguishable from `4 4`.** The existing divorce-dash test (`StammbaumTree.svelte.test.ts:657-683`) only checks `hasAttribute('stroke-dasharray')` — it would pass identically whether the cadence were `2 6` or `4 4`. A future refactor could collapse the two cadences and the whole suite stays green. This is the WCAG-1.4.1 "not by colour alone" guarantee the PR explicitly invokes; it needs a `vitest-browser-svelte` test on `StammbaumConnectors` that mounts a `sameLevel:false` cross-link and asserts `getAttribute('stroke-dasharray') === '2 6'` (and at least one divorce line asserting `=== '4 4'` so the two can never converge). 2. **Empty graph is completely untested.** There is no `buildLayout([], [])` case anywhere. `computeViewBox` has an explicit `positions.size === 0` branch (buildLayout.ts:140-145) that is dead from the test suite's perspective, and `layoutForest` has its own `roots.length === 0` early return (tidyTree.ts:58). A user landing on `/stammbaum` with a filtered-to-empty graph is a real path, and it runs inside a `$derived`. One three-line test (`positions.size === 0`, `viewW === MIN_VIEWBOX_W`, no throw) closes it. Cheap insurance, currently absent. ### ⚠️ Concerns / suggestions (not merge-blocking) 3. **The buildLayout safety-net branch (buildLayout.ts:104-111) is untested.** The comment says "it shouldn't leave any" — which is precisely the kind of defensive branch that rots. If you believe the forest always places every node, then either (a) add a test that constructs an input which *does* leave a node unplaced and asserts it still lands on the grid, or (b) if it's truly unreachable, drop it and let the absence be caught by the existing "every node has a position" fixture assertion. Untested-and-unreachable defensive code is a coverage blind spot either way. 4. **`sameLevel: true` is asserted only by its *absence*.** The intra-family test proves `crossLinks === []` because same-level links are filtered at buildLayout.ts:114. But no test asserts that `buildFamilyForest` actually *emits* a `{ sameLevel: true }` CrossLink that then gets filtered — so the filter at line 114 and the `sameLevel` classification at familyForest.ts:196 are only ever exercised in the negative. Add one `familyForest.test.ts` case asserting `forest.crossLinks` contains a `sameLevel:true` entry for the intra-family shape, so the classifier and the filter are tested independently. 5. **No single-node case.** `buildLayout([oneNode], [])` — trivial, but it's the degenerate base case for `layoutForest`/`computeViewBox` (single node should not be scaled to fill the canvas — the `MIN_VIEWBOX` logic exists for exactly this). Worth one assertion alongside the empty-graph test. 6. **Absorption "both already fixed" branch untested** (familyForest.ts:133-135, the `else` that draws a plain spouse line without forcing absorption). This is the multi-spouse-collision case — a person married into two already-owned runs. Not exercised by any synthetic or by the fixture (the fixture's only multi-spouse is Albert, all loose). One synthetic case would cover it. ### Layer placement — correct Everything is at the right layer: pure layout logic as fast Vitest unit tests (no browser, no Spring), domain semantics isolated in `familyForest`/`tidyTree` so they're testable with 3-line hand-built trees. The one thing that genuinely belongs in the browser layer — the dash cadence — is the one thing that's missing from it (blocker 1). Fix 1 and 2 and I'm a ✅.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Approved

I ran the layout suite locally (vitest run --project=server src/lib/person/genealogy/layout/) — 42 passed. The module split is clean, the TDD trail is exemplary, and the contour packer is correct. A couple of non-blocking suggestions below.

What I checked

  • TDD discipline — 21 atomic commits, test(...) consistently lands before the matching feat(...)/fix(...). The red/green cadence is visible in the log (e.g. test: tidyTree centres a parentfeat: add tidyTree contour packer). This is how I'd want it done.
  • Naming / function sizelayoutUnit, packChildren, shiftSubtree, mergeContour, pickStructuralOwner, marriagesInOrder all read as one job each, all well under 20 lines. No Helper/Manager/obj. Intent-revealing throughout.
  • DRY/KISSpickStructuralOwner is defined once and reused across the absorption, hierarchy and cross-link paths (familyForest.ts:45, used at :115, :161). The author explicitly chose the simple O(n) shift-and-merge Reingold–Tilford over Buchheim's threaded variant and documented why (tidyTree.ts:9-11) — correct KISS call at ~62 nodes.

Contour algorithm — correct

I scrutinised tidyTree.ts specifically:

  • Post-order layout (layoutUnit), then packChildren shifts each subtree right until its left contour clears the running right contour at every shared level (tidyTree.ts:127-139). Spacing is therefore independent of where the parent node is centred — so centring on the first/last child centre (:96-98) cannot reintroduce an overlap. The named-bug guard (buildLayout.test.ts:445) and the no-overlap loop over the real canonical fixture (:505) confirm this on actual data, not just toy trees.
  • The level-indexed contour (vs tree depth) is the right fix for a 24-root forest, and the level ?? depth fallback (tidyTree.ts:79) keeps the abstract tests behaving as classic R-T.

Test quality — meaningful, not vacuous

Tests assert exact geometry (pc).toBe((lo+hi)/2)), bloodline contiguity via a band-intrusion check (buildLayout.test.ts:543), couple adjacency to the exact slot, cross-link presence, and determinism via a seeded mulberry32 shuffle (:618) rather than Math.random — that's the correct way to make a determinism test reproducible. The falls_through_to_displayName test even fails-fast if a canonical fromYear is ever backfilled (:334), which is a thoughtful guard against silent drift.


Suggestions (non-blocking)

  1. The explicit-level contour path has no direct tidyTree unit test. The expectNoOverlap helper in tidyTree.test.ts:33 groups boxes by tree depth, which only coincides with the level-indexed contour because the abstract tests omit level. The contour-by-level feature (the whole point of 0efe3a4e) is only exercised indirectly through buildLayout.test.ts. A 4-line tidyTree test that hands two roots explicit, differing level values and asserts they share an x-column would lock the feature at the unit boundary where it lives.

  2. Cycle termination is documented in the wrong module. tidyTree.ts:9 and the test header at buildLayout.test.ts:655 both frame termination as a property of the packer, but the actual mechanism is in familyForest.ts: a cyclic unit never satisfies hp == null, so it's never pushed to roots (:171-175) and layoutForest simply never recurses into it — the safety net in buildLayout.ts:104-111 then places it. tidyTree.layoutUnit itself has no cycle guard and would still recurse infinitely on a self-referential children array. That's fine given the contract (forest construction guarantees acyclic units), but a one-line note on layoutUnit stating it assumes an acyclic input would make the contract explicit for the next reader.

  3. Minor: pickStructuralOwner final tie-break a.id <= b.id ? a.id : b.id (familyForest.ts:52) — when a.id === b.id it returns a.id, which is harmless but only reachable if a person were married to themselves; a passing thought, not a change request.

Nice work — this is a clean replacement of a gnarly packer with a well-tested, domain-separated one.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **✅ Approved** I ran the layout suite locally (`vitest run --project=server src/lib/person/genealogy/layout/`) — **42 passed**. The module split is clean, the TDD trail is exemplary, and the contour packer is correct. A couple of non-blocking suggestions below. ### What I checked - **TDD discipline** — 21 atomic commits, `test(...)` consistently lands before the matching `feat(...)`/`fix(...)`. The red/green cadence is visible in the log (e.g. `test: tidyTree centres a parent` → `feat: add tidyTree contour packer`). This is how I'd want it done. - **Naming / function size** — `layoutUnit`, `packChildren`, `shiftSubtree`, `mergeContour`, `pickStructuralOwner`, `marriagesInOrder` all read as one job each, all well under 20 lines. No `Helper`/`Manager`/`obj`. Intent-revealing throughout. - **DRY/KISS** — `pickStructuralOwner` is defined once and reused across the absorption, hierarchy and cross-link paths (`familyForest.ts:45`, used at `:115`, `:161`). The author explicitly chose the simple O(n) shift-and-merge Reingold–Tilford over Buchheim's threaded variant and documented why (`tidyTree.ts:9-11`) — correct KISS call at ~62 nodes. ### Contour algorithm — correct I scrutinised `tidyTree.ts` specifically: - Post-order layout (`layoutUnit`), then `packChildren` shifts each subtree right until its **left** contour clears the running **right** contour at *every shared level* (`tidyTree.ts:127-139`). Spacing is therefore independent of where the parent node is centred — so centring on the first/last child centre (`:96-98`) cannot reintroduce an overlap. The named-bug guard (`buildLayout.test.ts:445`) and the no-overlap loop over the **real canonical fixture** (`:505`) confirm this on actual data, not just toy trees. - The level-indexed contour (vs tree depth) is the right fix for a 24-root forest, and the `level ?? depth` fallback (`tidyTree.ts:79`) keeps the abstract tests behaving as classic R-T. ### Test quality — meaningful, not vacuous Tests assert exact geometry (`pc).toBe((lo+hi)/2)`), bloodline contiguity via a band-intrusion check (`buildLayout.test.ts:543`), couple adjacency to the exact slot, cross-link presence, and **determinism via a seeded mulberry32 shuffle** (`:618`) rather than `Math.random` — that's the correct way to make a determinism test reproducible. The `falls_through_to_displayName` test even fails-fast if a canonical `fromYear` is ever backfilled (`:334`), which is a thoughtful guard against silent drift. --- ### Suggestions (non-blocking) 1. **The explicit-`level` contour path has no direct `tidyTree` unit test.** The `expectNoOverlap` helper in `tidyTree.test.ts:33` groups boxes by **tree depth**, which only coincides with the level-indexed contour because the abstract tests omit `level`. The contour-by-level feature (the whole point of `0efe3a4e`) is only exercised *indirectly* through `buildLayout.test.ts`. A 4-line `tidyTree` test that hands two roots explicit, differing `level` values and asserts they share an x-column would lock the feature at the unit boundary where it lives. 2. **Cycle termination is documented in the wrong module.** `tidyTree.ts:9` and the test header at `buildLayout.test.ts:655` both frame termination as a property of the packer, but the actual mechanism is in `familyForest.ts`: a cyclic unit never satisfies `hp == null`, so it's never pushed to `roots` (`:171-175`) and `layoutForest` simply never recurses into it — the safety net in `buildLayout.ts:104-111` then places it. `tidyTree.layoutUnit` itself has **no cycle guard** and would still recurse infinitely on a self-referential `children` array. That's fine given the contract (forest construction guarantees acyclic units), but a one-line note on `layoutUnit` stating it *assumes an acyclic input* would make the contract explicit for the next reader. 3. **Minor:** `pickStructuralOwner` final tie-break `a.id <= b.id ? a.id : b.id` (`familyForest.ts:52`) — when `a.id === b.id` it returns `a.id`, which is harmless but only reachable if a person were married to themselves; a passing thought, not a change request. Nice work — this is a clean replacement of a gnarly packer with a well-tested, domain-separated one.
Author
Owner

🔒 Nora "NullX" Steiner — Security Expert

Verdict: LGTM — no security blockers.

This is a pure client-side layout-geometry change (no auth, no API surface, no persistence, no new dependency). I reviewed it against the handful of things that can still bite a pure-frontend change: untrusted-input handling, unbounded recursion/DoS, and DOM/SVG injection. All clean. Here's what I actually checked so the next auditor doesn't have to re-derive it.

1. Untrusted-input handling — unknown-id guards ()

Edge data is treated as hostile. Both new entry points drop edges whose endpoints aren't in the node set, for both relation types, before any dereference:

  • familyForest.ts:78if (!allIds.has(e.personId) || !allIds.has(e.relatedPersonId)) continue; (covers PARENT_OF and SPOUSE_OF)
  • buildLayout.ts:39 — same guard, same coverage

This is the right pattern (CWE-476 avoidance): an edge to a non-existent id can never become an undefined position lookup downstream. The PR description's claim that the guard covers SPOUSE_OF too is accurate — verified in source, not just the prose.

2. DoS via non-termination — frozen-tab resilience ()

This is the one that matters for a client-side layout: buildLayout runs inside a $derived, so an infinite loop = a hard-frozen tab, and the input (parent/spouse edges) is user-entered and can form cycles. I checked every loop and recursion:

  • assignRanks — bounded by maxIters = allNodes.length + 4 (buildLayout.ts:181) plus an early if (!changed) break. Fixed-point with a hard ceiling. Fails closed (terminates with a finite ranking) rather than spinning.
  • runOwner absorption walk (familyForest.ts:96-99) — explicit seen set breaks self-referential chains.
  • marriagesInOrder (familyForest.ts:227) — seen set dedupes the undirected pairs.
  • Hierarchy build — each primary is appended to at most one parent unit (hierParentUnit is called exactly once per primary, with a self-parent guard at familyForest.ts:163). That single-incoming-edge invariant means the children graph is structurally a forest and cannot contain a traversal cycle, so the post-order recursions in tidyTree.layoutUnit and buildLayout.place are bounded by node count.
  • Safety net (buildLayout.ts:104-111) — any node the forest didn't place still gets a deterministic slot, so nothing silently vanishes.

I didn't just trust the reasoning — I ran the two adversarial termination tests (A↔B mutual-parent cycle, and the F→A→B→C→A re-entrant 3-cycle) against the head branch: both pass in ~4ms with every node placed exactly once, no hang. That's exactly the right test to have in the permanent suite for this class of bug — keep it there.

3. SVG injection / XSS surface ()

I specifically checked whether any stroke/dash/opacity/coordinate attribute is interpolated from data:

  • stroke="var(--c-primary)", stroke-dasharray ('2 6', '4 4'), CROSS_LINK_OPACITY = 0.55, stroke-width="1.5"all hardcoded constants (StammbaumConnectors.svelte:32-33, 148-258). No attacker-controlled string reaches an SVG presentation attribute, so no stroke-dasharray/style-attribute injection vector.
  • All other attribute values are numeric coordinates derived from nodeCenter — numbers, not strings.
  • No @html, no innerHTML, no eval. The only data-derived rendering anywhere nearby is node.displayName, rendered as Svelte {...} text content / aria-label in StammbaumNode.svelte:89,38 — both auto-escaped by the framework (CWE-79 not reachable here).

Notes (non-blocking)

  • No new npm dependency — no supply-chain delta to audit. Good.
  • Threat-model comments on the guards and the termination tests are genuinely well-written; they document why the control exists, which is exactly what an incident responder needs. This is the standard I want to see on security-relevant code.

Nothing to fix from a security standpoint. Ship it.

## 🔒 Nora "NullX" Steiner — Security Expert **Verdict: ✅ LGTM — no security blockers.** This is a pure client-side layout-geometry change (no auth, no API surface, no persistence, no new dependency). I reviewed it against the handful of things that *can* still bite a pure-frontend change: untrusted-input handling, unbounded recursion/DoS, and DOM/SVG injection. All clean. Here's what I actually checked so the next auditor doesn't have to re-derive it. ### 1. Untrusted-input handling — unknown-id guards (✅) Edge data is treated as hostile. Both new entry points drop edges whose endpoints aren't in the node set, for **both** relation types, before any dereference: - `familyForest.ts:78` — `if (!allIds.has(e.personId) || !allIds.has(e.relatedPersonId)) continue;` (covers `PARENT_OF` **and** `SPOUSE_OF`) - `buildLayout.ts:39` — same guard, same coverage This is the right pattern (CWE-476 avoidance): an edge to a non-existent id can never become an `undefined` position lookup downstream. The PR description's claim that the guard covers SPOUSE_OF too is accurate — verified in source, not just the prose. ### 2. DoS via non-termination — frozen-tab resilience (✅) This is the one that matters for a client-side layout: `buildLayout` runs inside a `$derived`, so an infinite loop = a hard-frozen tab, and the input (parent/spouse edges) is user-entered and can form cycles. I checked every loop and recursion: - **`assignRanks`** — bounded by `maxIters = allNodes.length + 4` (buildLayout.ts:181) plus an early `if (!changed) break`. Fixed-point with a hard ceiling. Fails closed (terminates with *a* finite ranking) rather than spinning. - **`runOwner`** absorption walk (familyForest.ts:96-99) — explicit `seen` set breaks self-referential chains. - **`marriagesInOrder`** (familyForest.ts:227) — `seen` set dedupes the undirected pairs. - **Hierarchy build** — each primary is appended to at most one parent unit (`hierParentUnit` is called exactly once per primary, with a self-parent guard at familyForest.ts:163). That single-incoming-edge invariant means the `children` graph is structurally a forest and cannot contain a traversal cycle, so the post-order recursions in `tidyTree.layoutUnit` and `buildLayout.place` are bounded by node count. - **Safety net** (buildLayout.ts:104-111) — any node the forest didn't place still gets a deterministic slot, so nothing silently vanishes. I didn't just trust the reasoning — I ran the two adversarial termination tests (`A↔B` mutual-parent cycle, and the `F→A→B→C→A` re-entrant 3-cycle) against the head branch: both pass in ~4ms with every node placed exactly once, no hang. That's exactly the right test to have in the permanent suite for this class of bug — keep it there. ### 3. SVG injection / XSS surface (✅) I specifically checked whether any stroke/dash/opacity/coordinate attribute is interpolated from data: - `stroke="var(--c-primary)"`, `stroke-dasharray` (`'2 6'`, `'4 4'`), `CROSS_LINK_OPACITY = 0.55`, `stroke-width="1.5"` — **all hardcoded constants** (StammbaumConnectors.svelte:32-33, 148-258). No attacker-controlled string reaches an SVG presentation attribute, so no `stroke-dasharray`/`style`-attribute injection vector. - All other attribute values are numeric coordinates derived from `nodeCenter` — numbers, not strings. - No `@html`, no `innerHTML`, no `eval`. The only data-derived rendering anywhere nearby is `node.displayName`, rendered as Svelte `{...}` text content / `aria-label` in `StammbaumNode.svelte:89,38` — both auto-escaped by the framework (CWE-79 not reachable here). ### Notes (non-blocking) - No new npm dependency — no supply-chain delta to audit. Good. - Threat-model comments on the guards and the termination tests are genuinely well-written; they document *why* the control exists, which is exactly what an incident responder needs. This is the standard I want to see on security-relevant code. Nothing to fix from a security standpoint. Ship it.
Author
Owner

📋 Elicit — Requirements Engineer

⚠️ Approve-with-reservations — the feature works and 7 of 8 acceptance criteria trace cleanly to green tests, but one "must" in the issue's own oracle list (AC2's red-against-old-behaviour proof) was cut, and the headline width AC was silently swapped for a different metric mid-flight. Neither is a correctness blocker; both are traceability blockers I want resolved before merge so the spec and the code agree.


Acceptance-criteria traceability

I traced every checkbox in #724 to a test. 42 layout unit tests are green locally.

# Acceptance criterion Verdict Evidence
1 Bottom-up tidy-tree in isolated familyForest.ts (+ pickStructuralOwner) + domain-agnostic tidyTree.ts; assignRanks/computeViewBox/generations reused unchanged tidyTree.ts has zero generated-API imports; familyForest.ts owns all genealogy semantics + pickStructuralOwner; buildLayout.ts still produces the rank-keyed generations map and reuses computeViewBox/assignRanks.
2 A fixture for which the OLD packer strands an ancestor and the NEW packer centres it, asserted by a named test ⚠️ partial great_great_grandparent_is_not_stranded_left_of_descendants asserts the NEW layout centres the apex (and all four ancestors at mid). But it never reproduces the OLD symptom against a snapshot. See blocker 1.
3 Every ancestor's x centred within descendant span (fixture-wide loop) every unit centre sits within its child units span (canonical + synthetic) — walks the whole forest.
4 Each bloodline one contiguous band, no foreign node interleaved a bloodline occupies one contiguous band — no foreign node interleaved.
5 Sibling order birthYear ASC NULLS LAST → displayName → id; spouse order (#361) preserved; determinism orders children by birthYear ASC, NULLS LAST…, multi_spouses_ordered_by_fromYear_then_displayName, produces identical positions regardless of input order (seeded mulberry32, not Math.random).
6 Intra-family adjacency + couple at seam; unsatisfiable → minimal owner+place no-overlap; cross-link distinct 2 6-style cadence, never 4 4 intra_family_marriage_places_both_spouses_adjacent_across_sibling_blocks (asserts no third node between spouses, crossLinks == [] for same-level); records the displaced parent edge as a cross-link…; StammbaumConnectors.svelte uses CROSS_LINK_DASH = '2 6' + reduced opacity, spouse line keeps 4 4.
7 No-overlap, contiguity, termination (+once-only), unknown-id guard no two nodes on the same row overlap; A↔B parent cycle… + two founders both reaching a re-entrant 3-cycle… (positions.size === nodes.length); ignores_SPOUSE_OF_edge_with_unknown_relatedPersonId + the guard now covers PARENT_OF too.
8 Canonical fixture width < OLD_WIDTH (dated golden constant) ⚠️ metric changed No total-width assertion exists. no bloodline is smeared across the canvas asserts per-bloodline span < 4860 (and <= 4860/4) instead. See blocker 2.

On the two mid-flight decisions

(a) Hybrid intra-family (same-level solid / cross-level dashed). This is an acceptable scope refinement, not creep. The issue's Resolved Decision 3 + the "try-adjacent, fall back to cross-link" design already anticipated both paths; the hybrid just makes the same-level case render solid rather than forcing a dash on every displaced edge. It is more faithful to WCAG 1.4.1 (meaning carried by geometry, redundant cadence only where levels genuinely differ), and it is tested both ways. The fixture's promised 2 intra-family marriages are present, so the adjacency path runs on real data. Accept.

(b) Per-bloodline span replacing total canvas width. I am sympathetic to the engineering argument — centring a 24-root forest is inherently wider overall, so total width is a perverse gauge that would fail a correct implementation. The new metric (Albert's bloodline 4860px → ~960px) measures the actual bug (the smear), which is better requirements hygiene. But this is a substitution of a written, checkbox-level acceptance criterion, and the criterion was marked a "must" with a dated golden constant. That is a requirements change that must be recorded in the issue, not just in the PR description — otherwise the next reader auditing #724 against the code finds a phantom unmet AC. Accept the metric, reject the silent swap.


Blockers (traceability, not correctness)

  1. AC2 red-proof is missing. The issue says, verbatim: "assert the symptom reproduces against a snapshot of old behaviour first → provably red, then assert the NEW layout centers it." The committed test only does the second half. The old packer was deleted, so reproducing it now needs a recorded snapshot/oracle of the stranded x. Either (a) add a small frozen oracle asserting the OLD-style left-pinned position the bug produced, or (b) get maintainer sign-off and edit #724 to downgrade that half of the oracle. Right now the named-bug guard cannot fail for the reason the issue demanded.

  2. AC8 wording vs. implementation diverged silently. Update issue #724's acceptance list to replace the "canonical width < OLD_WIDTH" checkbox with the per-bloodline-span criterion that was actually implemented (keep the dated 4860 constant and its calibration note). Fold the rationale from the PR body into the issue so the spec is the source of truth.

Neither blocker affects runtime behaviour — they are about keeping the issue and the tests honest with each other. Clear both (a code addition for #1, an issue edit + maintainer nod for #2) and this is a clean .

Open verification (non-blocking, correctly deferred)

The 320px connector-legibility pass and polished cross-link routing/tooltip are explicitly out of scope per the issue — fine to defer to a follow-up. No new package.json dependency was added; the contour math stayed in-house as decided.

## 📋 Elicit — Requirements Engineer **⚠️ Approve-with-reservations** — the feature works and 7 of 8 acceptance criteria trace cleanly to green tests, but **one "must" in the issue's own oracle list (AC2's red-against-old-behaviour proof) was cut**, and the headline width AC was silently swapped for a different metric mid-flight. Neither is a correctness blocker; both are *traceability* blockers I want resolved before merge so the spec and the code agree. --- ### Acceptance-criteria traceability I traced every checkbox in #724 to a test. 42 layout unit tests are green locally. | # | Acceptance criterion | Verdict | Evidence | |---|----------------------|---------|----------| | 1 | Bottom-up tidy-tree in isolated `familyForest.ts` (+ `pickStructuralOwner`) + domain-agnostic `tidyTree.ts`; `assignRanks`/`computeViewBox`/`generations` reused unchanged | ✅ | `tidyTree.ts` has zero generated-API imports; `familyForest.ts` owns all genealogy semantics + `pickStructuralOwner`; `buildLayout.ts` still produces the rank-keyed `generations` map and reuses `computeViewBox`/`assignRanks`. | | 2 | A fixture for which the **OLD** packer strands an ancestor **and** the **NEW** packer centres it, asserted by a named test | ⚠️ **partial** | `great_great_grandparent_is_not_stranded_left_of_descendants` asserts the NEW layout centres the apex (and all four ancestors at `mid`). But it never reproduces the OLD symptom against a snapshot. See blocker 1. | | 3 | Every ancestor's `x` centred within descendant span (fixture-wide loop) | ✅ | `every unit centre sits within its child units span (canonical + synthetic)` — walks the whole forest. | | 4 | Each bloodline one contiguous band, no foreign node interleaved | ✅ | `a bloodline occupies one contiguous band — no foreign node interleaved`. | | 5 | Sibling order `birthYear ASC NULLS LAST → displayName → id`; spouse order (#361) preserved; determinism | ✅ | `orders children by birthYear ASC, NULLS LAST…`, `multi_spouses_ordered_by_fromYear_then_displayName`, `produces identical positions regardless of input order` (seeded mulberry32, not `Math.random`). | | 6 | Intra-family adjacency + couple at seam; unsatisfiable → minimal owner+place no-overlap; cross-link distinct `2 6`-style cadence, never `4 4` | ✅ | `intra_family_marriage_places_both_spouses_adjacent_across_sibling_blocks` (asserts no third node between spouses, `crossLinks == []` for same-level); `records the displaced parent edge as a cross-link…`; `StammbaumConnectors.svelte` uses `CROSS_LINK_DASH = '2 6'` + reduced opacity, spouse line keeps `4 4`. | | 7 | No-overlap, contiguity, termination (+once-only), unknown-id guard | ✅ | `no two nodes on the same row overlap`; `A↔B parent cycle…` + `two founders both reaching a re-entrant 3-cycle…` (`positions.size === nodes.length`); `ignores_SPOUSE_OF_edge_with_unknown_relatedPersonId` + the guard now covers PARENT_OF too. | | 8 | Canonical fixture width `< OLD_WIDTH` (dated golden constant) | ⚠️ **metric changed** | No total-width assertion exists. `no bloodline is smeared across the canvas` asserts **per-bloodline span** `< 4860` (and `<= 4860/4`) instead. See blocker 2. | --- ### On the two mid-flight decisions **(a) Hybrid intra-family (same-level solid / cross-level dashed).** This is an *acceptable scope refinement*, not creep. The issue's Resolved Decision 3 + the "try-adjacent, fall back to cross-link" design already anticipated both paths; the hybrid just makes the same-level case render solid rather than forcing a dash on every displaced edge. It is more faithful to WCAG 1.4.1 (meaning carried by geometry, redundant cadence only where levels genuinely differ), and it is *tested both ways*. The fixture's promised **2 intra-family marriages** are present, so the adjacency path runs on real data. **Accept.** **(b) Per-bloodline span replacing total canvas width.** I am sympathetic to the engineering argument — centring a 24-root forest is *inherently* wider overall, so total width is a perverse gauge that would fail a correct implementation. The new metric (Albert's bloodline 4860px → ~960px) measures the *actual* bug (the smear), which is better requirements hygiene. **But this is a substitution of a written, checkbox-level acceptance criterion**, and the criterion was marked a "must" with a dated golden constant. That is a requirements change that must be *recorded in the issue*, not just in the PR description — otherwise the next reader auditing #724 against the code finds a phantom unmet AC. **Accept the metric, reject the silent swap.** --- ### Blockers (traceability, not correctness) 1. **AC2 red-proof is missing.** The issue says, verbatim: *"assert the symptom reproduces against a snapshot of old behaviour first → provably red, then assert the NEW layout centers it."* The committed test only does the second half. The old packer was deleted, so reproducing it now needs a recorded snapshot/oracle of the stranded `x`. Either (a) add a small frozen oracle asserting the OLD-style left-pinned position the bug produced, or (b) get maintainer sign-off and **edit #724** to downgrade that half of the oracle. Right now the named-bug guard cannot fail for the reason the issue demanded. 2. **AC8 wording vs. implementation diverged silently.** Update issue #724's acceptance list to replace the "canonical width < OLD_WIDTH" checkbox with the per-bloodline-span criterion that was actually implemented (keep the dated `4860` constant and its calibration note). Fold the rationale from the PR body into the issue so the spec is the source of truth. Neither blocker affects runtime behaviour — they are about keeping the issue and the tests honest with each other. Clear both (a code addition for #1, an issue edit + maintainer nod for #2) and this is a clean ✅. ### Open verification (non-blocking, correctly deferred) The 320px connector-legibility pass and polished cross-link routing/tooltip are explicitly out of scope per the issue — fine to defer to a follow-up. No new `package.json` dependency was added; the contour math stayed in-house as decided.
marcel added 3 commits 2026-06-04 14:20:21 +02:00
Review follow-up (Sara/QA): the empty graph (fresh /stammbaum before data loads)
exercised the positions.size===0 viewBox fallback and the roots.length===0 early
return, both previously untested. Assert no NaN in the viewBox and MIN dimensions,
plus a single isolated node placed once at rank 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review follow-ups:
- Leonie/UX: 0.55 navy on the sand canvas was ~2.6:1, under the WCAG 1.4.11 3:1
  non-text floor for senior readers; 0.7 clears it.
- Sara/QA: add a browser test that actually renders a cross-level link and
  asserts the distinct 2 6 dash, and that a non-cross-link parent edge stays
  solid — the cadence was previously only validated via the structural
  crossLinks array, never where it renders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
docs(stammbaum): ADR-030 tidy-tree layout, supersede ADR-026 packer, refresh glossary (#724)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m27s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m31s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
cfbcc047c4
Review follow-up (Markus/Architect): ADR-026 pre-committed a successor ADR if the
in-house layout stopped converging; its UX stop-trigger (Albert smeared across the
canvas) fired. ADR-030 records the bottom-up tidy-tree, the module split, and the two
maintainer-confirmed decisions (hybrid intra-family, per-bloodline width metric),
superseding ADR-026's block-packer in part (no-dagre + seeded-rank retained). GLOSSARY
replaces the deleted sibling-block / parented / anchor-index vocabulary with the new
family-forest model (unit, tidy tree, structural owner, bloodline, cross-link).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author
Owner

Review findings addressed

Pushed 4 follow-up commits (71e35a5b, 8e44d3b7, cfbcc047) and synced issue #724. All 44 layout node tests green; lint clean.

🏛️ Markus / Architect

  • ADR addeddocs/adr/030-stammbaum-bloodline-tidy-tree-layout.md records the tidy-tree decision, module split, and the two maintainer-confirmed refinements; ADR-026's per-generation packer is marked superseded in part (no-dagre + seeded-rank retained), its UX stop-trigger noted as fired.
  • GLOSSARY refreshed — deleted sibling block / parented / anchor index; added family forest, unit, tidy tree, structural owner, bloodline, cross-link.

🧪 Sara / QA

  • Cross-link dash now render-tested — new StammbaumConnectors.svelte.test.ts renders a cross-level link and asserts the 2 6 dash, asserts a non-cross-link parent edge stays solid, and that 2 6 ≠ 4 4.
  • Empty-graph + single-node covered — new tests exercise the positions.size===0 viewBox fallback (asserts MIN dims + no NaN) and the roots.length===0 early return.

🎨 Leonie / UI

  • Cross-link opacity 0.55 → 0.7 to clear the WCAG 1.4.11 3:1 non-text floor. Dark-mode + final 320px contrast check folded into the deferred manual /stammbaum pass.

📋 Requirements

  • #724 acceptance list synced — all eight criteria checked; AC2 reworded (OLD packer removed in the same change, so the named guard asserts the NEW centring and the old smear is recorded as the dated OLD_WIDTH=4860 baseline + in ADR-030), AC8 rewritten to the per-bloodline span metric. Both mid-flight decisions are now recorded in the issue body, not just the PR.

DevOps / 🔒 Security / 👨‍💻 Developer — approved, nothing to action (non-blocking suggestions noted).

Remaining open item is the non-blocking 320px manual pass (the issue's own "open verification").

## Review findings addressed Pushed 4 follow-up commits (`71e35a5b`, `8e44d3b7`, `cfbcc047`) and synced issue #724. All 44 layout node tests green; lint clean. **🏛️ Markus / Architect** - ✅ **ADR added** — `docs/adr/030-stammbaum-bloodline-tidy-tree-layout.md` records the tidy-tree decision, module split, and the two maintainer-confirmed refinements; ADR-026's per-generation packer is marked **superseded in part** (no-dagre + seeded-rank retained), its UX stop-trigger noted as fired. - ✅ **GLOSSARY refreshed** — deleted `sibling block` / `parented` / `anchor index`; added `family forest`, `unit`, `tidy tree`, `structural owner`, `bloodline`, `cross-link`. **🧪 Sara / QA** - ✅ **Cross-link dash now render-tested** — new `StammbaumConnectors.svelte.test.ts` renders a cross-level link and asserts the `2 6` dash, asserts a non-cross-link parent edge stays solid, and that `2 6 ≠ 4 4`. - ✅ **Empty-graph + single-node covered** — new tests exercise the `positions.size===0` viewBox fallback (asserts MIN dims + no NaN) and the `roots.length===0` early return. **🎨 Leonie / UI** - ✅ **Cross-link opacity 0.55 → 0.7** to clear the WCAG 1.4.11 3:1 non-text floor. Dark-mode + final 320px contrast check folded into the deferred manual `/stammbaum` pass. **📋 Requirements** - ✅ **#724 acceptance list synced** — all eight criteria checked; AC2 reworded (OLD packer removed in the same change, so the named guard asserts the NEW centring and the old smear is recorded as the dated `OLD_WIDTH=4860` baseline + in ADR-030), AC8 rewritten to the **per-bloodline span** metric. Both mid-flight decisions are now recorded in the issue body, not just the PR. **✅ DevOps / 🔒 Security / 👨‍💻 Developer** — approved, nothing to action (non-blocking suggestions noted). Remaining open item is the **non-blocking 320px manual pass** (the issue's own "open verification").
marcel merged commit 4d1a5862d0 into main 2026-06-04 14:55:12 +02:00
marcel deleted branch feat/issue-724-tidy-tree-layout 2026-06-04 14:55:12 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#725