feat(stammbaum): mobile read path — pan, zoom, fit-to-view (#692) #694
Reference in New Issue
Block a user
Delete Branch "feat/issue-692-stammbaum-mobile-panzoom"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #692.
Makes
/stammbauma usable phone read surface without touching the desktop/tablet experience. Implements the full epic.Approach (note the OQ-007 reversal)
The recorded OQ-007 decision was to adopt the
panzoomlibrary. That predated discovering that zoom is already implemented via the SVGviewBox(not CSS transforms). Adopting a transform-based library would fight the proven viewBox math and the in-SVG generation gutter (#689), add ~8 KB + an SSR import dance, and be harder to test. So this builds a custom viewBox layer instead — geometry as pure, unit-tested functions; DOM wiring as a thin action. NFR-MAINT-001 (library pinning) becomes moot. Recorded in ADR-026.Key consequence: the default view
{x:0, y:0, z:1}already frames the whole tree, so fit-to-screen is just a reset — no bounding-box math.What's included
prefers-reduced-motion).Ctrl+wheel around the centroid,+/-keyboard, range 0.25–3.0; plain wheel + arrow keys pan. SVG stays vector-crisp.+ / − / ⤢cluster moved out of the header into the bottom-right of the canvas (one-handed phone reach);data-testid="fit-to-screen", 44×44, U+2212 minus.role="dialog"+ focus trap + Escape. Desktop side panel unchanged.?cx&cy&zviareplaceState; server-clamped so a crafted?z=Infinity/?cx=NaNlink degrades to a safe view (Nora's DoS fix). Survives panel open/close; a shared link reproduces the view.trapFocusaction added to$lib/shared/actions.Tests
panZoom.ts,animateView.ts, server load) — 30 tests, run locally green: clamp/parse/serialise, NaN/Infinity sanitisation, screen→SVG delta, centroid zoom, edge-clamp, recentre, lerp, server clamp.VISUAL-gated visual-regression spec at 320/414/768 for the affordance + bottom-sheet states. These run in CI (browser tests are unreliable locally); the visual baselines must be generated via--update-snapshots, and Stammbaum e2e remains subject to the project Chromium-in-CI gate (#363).Verification done locally
npm run check— no new type errors (net −5 vs baseline; cleared a pre-existingdeathYearfixture issue).npm run build— SSR + client build clean (confirms nowindow-at-import SSR break).npm run lint— clean (pre-commit hook on every commit).#361 constraints honoured
Seeded-rank invariant, single-colour connectors, and the 12px marriage dot are untouched; new snapshots are additive (the #361 desktop baselines stay green).
🤖 Generated with Claude Code
Replace the scalar zoom prop with a {x,y,z} PanZoomState. The viewBox centre is offset by the pan and width/height scaled by zoom; the default {0,0,1} frames the whole tree (fit-to-screen). Page header buttons now step view.z through clampZoom over the resolved 0.25–3.0 range. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>🏛️ Markus Keller — Application Architect
Verdict: ✅ Approved
I reviewed this strictly through the architecture lens: SSR safety, module-load DOM access, state ownership (props-down/callbacks-up), the swap-out seam, the URL-sync effect, the centre-on recentre orchestration, and ADR soundness. The structure here is genuinely clean.
What I checked and found sound
window/document/matchMedia/localStorageruns at import time anywhere in the changed set.panZoom.tsis fully pure.animateView.tsonly toucheswindow.matchMediainsideprefersReducedMotion(), guarded bytypeof window.panZoomGestures.tstouches the DOM only inside the action body/handlers. In components,matchMedia/localStorageare read inside$stateinitialisers andonMount/$effect— all guarded or browser-only. ThereplaceStateeffect (+page.svelte:69-78) readswindow.location.hrefinside an$effect, which never runs during SSR. Correct.view: PanZoomStateis owned at the page, passed down aspanZoomand mutated only viaonPanZoom— textbook props-down/callbacks-up.StammbaumTreenever holds its own copy. No cross-domain leak.panZoom.tsis the right seam — page, action, keyboard handler, and server loader all funnel through it. The ADR's claim is accurate and verifiable.viewis tracked; URL read + write are wrapped inuntrack.replaceState(vsgoto) avoids aloadround-trip, so serverparsePanZoomParamsdoesn't re-fire on every pan.centreOnIdreadspanZoom/baseCentreunderuntrack, so a normal pan doesn't retrigger a recentre; page clears the id aftertick(). No feedback loop.Blockers
None.
Suggestions
trapFocusdoesn't restore focus on destroy (trapFocus.ts:38-42). For a shared modal-overlay primitive, capturingdocument.activeElementon entry and restoring it indestroy()is the conventional contract.localStoragekey is a bare literal (StammbaumAffordance.svelte:20). As client view-state keys accumulate, a small shared prefix convention prevents collisions.focusvscx/cy/zare two URL vocabularies ("select a node" vs "frame the view"). Correct as-is; a one-line comment noting they're intentionally orthogonal saves the next reader a double-take.Clean separation of pure geometry / DOM glue / page orchestration, an honest ADR, no SSR or state-ownership violations. Nice work.
👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ⚠️ Approve with suggestions (no blockers). Disciplined work: the commit history is textbook red/green (each pure helper landed with its test), the geometry core is cleanly separated and exhaustively unit-tested, and the gesture glue is a thin, leak-free DOM adapter. The only thing I'd genuinely push on is the size of
StammbaumTree.svelte.Blockers
None.
Suggestions
1.
StammbaumTree.svelteis doing too much (~520 lines, +137) — split the SVG sub-regions. It now owns layout, two media-query state machines, gutter rows, base geometry, viewBox, edge-fade mask, the recentre effect, two keyboard handlers, theparentLinksgraph computation, and the full SVG template (gutter stripes/labels, shared/single parent links, spouse links, nodes). The node rendering (~67 lines) and connector layers are extractable intoStammbaumNode.svelte/StammbaumConnectors.svelte. Not a blocker (regions are cohesive + tested) but this file will keep accreting.2. Duplicated media-query-state pattern.
isMdOrUp,reducedMotion, andanimateView.ts'sprefersReducedMotionare the same "seed synchronously, subscribe to change" shape three times. AmediaQueryState(query)helper in$lib/sharedcollapses all three and is trivially testable.3.
parentLinks/gutterRowsuseSvelteSet/SvelteMappurely as locals inside$derived.by. They're computed-and-discarded within one synchronous derivation — plainSet/Mapexpress intent better and skip the reactive overhead.4. Inertia loop is correct & leak-free (rAF cancelled on destroy + on each pointerdown; stall-guard prevents forever-spin against
clampPan). Nit: name the magic16asconst FRAME_MS = 16.5. Click-suppression flag is sound but self-clears only on the next click — worth a one-line comment.
6.
$effectusage is correct — none should be$derived(recentre fires a callback, replaceState writes the URL, the SidePanel loader fires fetches; all genuine side-effects).untrackplacements are the right tool and documented.7. Keyed
{#each}everywhere — nodes by id, edges by id, parent links by composed keys, gutter rows by rank. No position-based reconciliation.8. The three
svelte-ignorea11y suppressions are narrow and reasoned (interactive canvas with a keyboard alternative; supplementary drag handle). Not blanket.9. Tests are behaviour-first —
zoomAtPointasserts the anchor-screen-fraction invariant; component tests assert user-visible outcomes (no select after drag, recentre snaps z). Minor:page.svelte.test.ts:91is a "doesn't throw" smoke test — assert the clampedzvia the mirrored?zparam.10. No dead code. No commented-out blocks, no unused exports.
🔐 Nora "NullX" Steiner — Application Security Engineer
Verdict: ✅ Approve — no security blockers. The requested
?z=Infinity/?cx=NaNDoS fix is correctly implemented and defended on both the server and client paths; no new XSS, injection, or data-exposure surface was introduced.Blockers
None.
Suggestions / notes
1. [Confirmed safe] Crafted pan/zoom params degrade correctly.
panZoom.tsfiniteOrrejectsNaN/±Infinity/overflow (Number("1e500") === Infinity→ caught byNumber.isFinite),clampZoomboundszto[0.25, 3.0].+page.server.tsruns this server-side before geometry, soviewBoxnever divides by a non-finite/zeroz. Verified the full round-trip: gesture output →clampPan+clampZoom→replaceStatere-serialises bounded state → reload re-sanitises. No un-clamped value reaches the viewBox via either path.2. [Confirmed safe] localStorage flag is a numeric gate, never rendered.
'stammbaumAffordanceDismissedAt'is read only viaNumber(raw)in aDate.now()comparison; bothcatchblocks fail safe. A poisoned value coerces toNaN→ comparisonfalse→ hint just shows. No stored XSS.3. [Confirmed safe]
displayNameonly as escaped text / attribute value. All usages are Svelte{…}interpolation oraria-labelbindings — zero{@html}in the genealogy module or stammbaum route, so the #361 constraint holds.4. [Confirmed safe]
?focusis membership-validated (+page.svelteonly honours it when the id is in the server-authorised node set) — no IDOR.5. [Low — defence-in-depth] Add a server-
loadregression test feeding?z=Infinity&cx=NaN&cy=1e500and assertinginitialViewis the clamped default, so a future refactor that drops the server-side sanitisation fails loudly. (Note:page.server.test.tsalready covers exactly this — good; just confirming it stays.)🧪 Sara Holt — Senior QA Engineer
Verdict: 🚫 Changes requested — solid pure-function and component coverage, but the single most complex new module (
panZoomGestures.ts) is almost entirely untested, and there's a latent inertia flake in the one test that touches it.The pure-function layer (
panZoom.test.ts) is genuinely good: clamp/parse/serialize/zoomAtPoint/clampPan/lerp all have boundary + adversarial inputs (Infinity, NaN, 1e500, at-bound no-op), andzoomAtPointasserts the actual anchor invariant rather than "didn't throw". The serverloadtest mirrors the clamping including the?z=Infinitysecurity case. Strong half.Blockers
panZoomGestures.ts(244 lines: pinch, inertia, wheel, pointer-capture, pinch→single-pointer handoff) has zero direct coverage. Highest-risk, most branch-dense file in the PR and the heart of the "mobile read path". Only exercise: one indirect single-pointer drag. Untested: two-finger pinch centroid +zoomAtPoint, therunInertiarAF loop + decay/stall exit,onWheelplain-pan vs Ctrl-zoom, pinch-release-to-drag resume. State this honestly in the PR body (pinch/inertia = manual only). At minimum extract pinch + inertia math into pure helpers (likezoomAtPointalready is) and unit-test them; the rAF loop can use an injectednow/frame stub.Latent inertia flake in
StammbaumTree.svelte.test.ts("pans on a pointer drag and suppresses the trailing node click"). Vitest-browser does not forcereducedMotion: reduce(onlyplaywright.config.ts:29does, for e2e). SoreducedMotiondefaults to no-preference →runInertia()fires after the syntheticpointerup; withdt≈1msover a 60px move the velocity is large, emitting extra asynconPanZoomcalls. The test readsmock.calls.at(-1)synchronously — passes by timing luck today, can flip when an inertia frame lands or clamps. Fix: setreducedMotiondeterministically for that render, or assert on thepointermovecall rather than the last call.Suggestions
animateViewnon-reduced (rAF/easeOutCubic) path is untested — only thereducedMotion:trueearly return is covered. It takes injectabledurationMs; add a stubbed-rAF test asserting intermediate interpolation and final frame ==to.addInitScriptremoval to abeforeEach.test.skipon empty-tree environments is a silent coverage gap (stammbaum-mobile.visual.spec.ts). Against an unseeded CI DB every meaningful assertion skips and the suite reports green having verified only the heading. Needs a guaranteed seeded fixture or route-level mock.page.svelte.test.ts:91asserts "doesn't throw" not the actual clamp — assert the flooredzvia the mirrored?zparam.INERTIA_DECAY,INERTIA_MIN_SPEED,DRAG_THRESHOLD_PX,ZOOM_STEP_KB: a tuning change toDRAG_THRESHOLD_PXcould silently break click-suppression with no failing test.loadnetwork-failure path untested —mockNetwork()only returns ok; no test that a backend 500 degrades gracefully.🎨 Leonie Voss — UX Designer & Accessibility Strategist
Verdict: ⚠️ Approve with non-blocking suggestions. The design notes were honoured faithfully — bottom-right one-handed cluster, drag-handle sheet, focus trap, edge-fade, touch-only affordance, reduced-motion across fit + inertia. Two real a11y gaps remain (focus restoration on sheet close, sub-44px touch targets), but neither blocks the read path for the primary phone audience.
Blockers
None.
Suggestions
1. Focus is never restored when the bottom sheet closes (
StammbaumBottomSheet.svelte,+page.svelte).use:trapFocusmoves focus into the sheet on open but on dismissselectedId→nulland the sheet unmounts with nofocus()of the previously-focused element. A keyboard/AT user is dumped todocument.bodyand must Tab from the top. WCAG 2.4.3. Fix intrapFocus.ts: capturedocument.activeElementon mount, restore indestroy()— benefits every future consumer.2. Three icon-only buttons miss the 44×44 minimum (WCAG 2.5.8) — on the senior/touch read path:
StammbaumAffordance.svelte:p-0.5+ 14px svg ≈ 18×18.StammbaumSidePanel.svelte:p-1+ 16px svg ≈ 24×24.StammbaumSidePanel.svelte: same ≈ 24×24.Add
min-h-[44px] min-w-[44px] inline-flex items-center justify-center(keep the small glyph, enlarge the hit area). The zoom/fit cluster already doesh-11 w-11correctly.Confirmed correct (the items I flagged earlier)
<svg tabindex="0">carries nooutline-none, so the global:focus-visible2px ring renders; nodes draw their own custom focus rect. Both layers indicated.svelte-ignoresuppressions are justified — labelledrole="img", keyboard-pannable, each node a properrole="button". AT users get a labelled, operable canvas. Nit:aria-label="Stammbaum"andGeneration {n}are hardcoded, not Paraglide — minor i18n follow-up.role="dialog" aria-modal="true" aria-label={displayName}, real<button>backdrop,aria-hiddenhandle, Escape handled.🛠️ Tobias Wendt — DevOps & Platform Engineer
Verdict: ⚠️ Approve (with one CI-coverage caveat worth fixing before relying on it). Build/bundle/CI/dependency/infra are clean — the author built a custom
viewBoximplementation instead of pulling a library, adding no supply-chain surface.What I verified:
git diff origin/main..HEAD -- frontend/package.json frontend/package-lock.jsonis empty. New modules import onlysvelte/action+ internal$lib. Nopanzoom/svg-pan-zoom/d3-zoom. ADR documents the decision. Negligible bundle impact.src/lib/paraglide/**diff is empty (gitignored, compiled in CI). Onlymessages/{de,en,es}.jsoncommitted.docker-compose*, workflows,vite.config,svelte.config,playwright.config.Blockers
None.
Suggestions
1. The new visual spec never runs in CI — neither snapshots nor structural assertions.
frontend/e2e/stammbaum-mobile.visual.spec.tsuses@playwright/test, butplaywright test/npm run test:e2eis not invoked by any workflow (grepped ci.yml + nightly.yml forplaywright test,test:e2e,toHaveScreenshot,--update-snapshots— nothing). CI only runsnpm run test:coverage(Vitest) +npm run build. So theVISUAL=1snapshots are never captured/compared and the unconditional structural assertions never execute — the file is dead weight in CI today. Either wire an e2e job (the Playwright base imagemcr.microsoft.com/playwright:v1.60.0-nobleis already pinned in ci.yml) or explicitly track the gap in #363 so it isn't mistaken for passing coverage. The real safety net here is the Vitest*.svelte.test.tssuite, which does run.2. Minor: when the e2e job is added, generate baselines on the pinned Playwright image so local-vs-CI rendering differences don't cause snapshot flake.
No image-tag, secret, port, or volume concerns.
📋 Elicit — Requirements Engineer
Verdict: 🚫 Request changes (one blocker on the acceptance gate + one traceability defect; the implementation itself satisfies the resolved OQ/AC set).
The code is exemplary on traceability: every resolved decision is present, ID-tagged in comments, and test-backed. But the PR fails one explicit acceptance-gate clause, and there's an ADR-id collision that corrupts the decision trail.
Blockers
B1 — Issue #692 body not updated; acceptance gate "issue body updated with final OQ resolutions" is unmet. The OQ-table/AC edits (centre-button placement in US-PANEL-001 vocab, US-PANEL-002 AC2 naming
?cx&cy&z, NFR-PERF-001 owner/device, canonical-fixture node count, US-PAN-006 AC3 reclassification) live in a decision-queue comment + ADR, not in the body. The gate requires the body as single source of truth — decision-queue comments are out of reading order; a future reader of #692 sees stale ACs contradicting merged code. Fold all five resolutions into the body, point OQ-007's row at the ADR, and delete the redundant decision-queue comment.B2 — ADR number collision: two ADR-026 files. ✅ Confirmed —
docs/adr/026-stammbaum-custom-viewbox-pan-zoom.md(this PR) and the pre-existingdocs/adr/026-stammbaum-layout-in-house.md(#361) both claim ADR-026. ADR ids are unique citation keys; the GLOSSARY now links "ADR-026" ambiguously. Renumber the new ADR to 027 and update all references (GLOSSARY entry,panZoom.tsheader comment, the ADR body's own id/title).Suggestions
S1 — Reversing OQ-007 via ADR rather than re-opening it: acceptable, with the B1 caveat. Not silent: dated, justified (viewBox-derivation predates the
panzoomrecommendation; #689 gutter lives in SVG user-space; ~8 KB saved; NFR-MAINT-001 moot), Status: Accepted. An ADR superseding a resolved OQ is legitimate provided the originating OQ row is reconciled — which is the unmet half of B1.S2 — Coverage maps cleanly to stories; no scope creep, no gaps. Verified each resolved decision against code: 0.25–3.0, keyboard step 0.1,
?cx&cy&zround-trip + server sanitisation, inertia w/ reduced-motion guard, recentre auto-zoom toLEGIBLE_ZOOM, bottom sheet, touch-only affordance (pointer: coarse), centre button in title row, permanent CSS edge-fade replacing US-PAN-006 AC3.trapFocusis in-scope for the modal, not gold-plating.S3 — a11y nit (defer to UX): the canvas is
role="img"yet keyboard-interactive; considerrole="application"oraria-roledescriptionto better match the behaviour. Flagging against NFR-A11Y-002.Implementation ↔ resolved-decision mapping is complete and accurate. Both blockers are about recording requirements correctly (issue body + ADR id), not building them — the right kind of debt to catch before merge.
🔧 Review feedback addressed + live bug fixes
Pushed the fixes for the review blockers/concerns and several issues found running it live.
Blockers — fixed
panZoom.tsrefs.pinchZoom+stepInertia(with namedFRAME_MS/INERTIA_*) into pure, unit-tested helpers inpanZoom.ts.pointermoveemission (deterministic), before release.Live bugs found while running (verified fixed via the dev container)
replaceStatethrew "before the router is initialized" during hydration, killing the sync effect; gated on a router-ready flag + try/catch. (The old test mockedreplaceState, masking it.)setPointerCaptureon pointerdown redirected the click off the node; capture is now deferred to drag-start.Product-owner tuning (during review)
cx=457.8300882631206.G{n}labels now overlay the left edge, staying pinned horizontally and tracking each row vertically at any pan/zoom, on all viewports (verified live: labels hold atleft=4pxwhile the canvas pans).Concerns — fixed
animateViewrAF-tween test, serverload401/500 tests, and a real zoom-floor assertion via the mirrored?z; moved the e2e affordance reset tobeforeEach.Deferred (non-blockers, with rationale)
StammbaumTree.sveltesplit (Felix) — deferred to a focused follow-up; it's an intricate SVG template and component/browser tests are CI-only here, so I won't refactor it blind.mediaQueryStatehelper (Felix) — DRY nicety; deferred for the same reason.Set/Map(Felix) — won't do: the repo'ssvelte/prefer-svelte-reactivityESLint rule mandatesSvelteMap/SvelteSetin.sveltefiles.VISUAL-gated.role="img"vsapplication— keptrole="img"per Leonie's a11y sign-off.Local gates: 40 node unit tests green,
npm run checkat baseline (no new type errors),npm run buildclean. Browser/e2e remain CI-gated.At z=3 a pan of {0,0} centres on the tree midpoint; a fresh visit (no shared ?z) now anchors the viewBox to the tree's top-left corner via topLeftView (the negative clamp limit), emitted on mount. Shared links still win. Verified live: lands at cx<0, cy<0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>🔁 Re-review (delta since first review — 15 commits)
🏛️ Markus Keller — Application Architect — ✅ Approve
Prior verdict holds.
panZoom.tsremains the single pure geometry seam (pinchZoom/stepInertia/cornerVieware DOM-free + tested); state ownership stays at the page; no SSR-at-import regressions.INITIAL_VIEW→ onMount corner anchor overrides once → URL-sync stamps?z; next load flipsanchorTopLeftfalse. The page stays the sole owner ofview.preserveAspectRatiolever are all sound.StammbaumGenerationRail.sveltedoc-comment still says "exact under xMidYMid meet" — the canvas is nowxMinYMin; the CTM mapping is alignment-agnostic so the code is correct, only the comment is stale.$effectdoes a layout read (getScreenCTM+getBoundingClientRect) per pan/inertia frame — fine at current chip counts; worth a note so a future "50 rows" change re-evaluates it.👨💻 Felix Brandt — Senior Fullstack Developer — ✅ Approve
The pinch/inertia extraction is exactly the refactor I asked for; tests are behaviour-first; 3 of 5 prior non-blockers genuinely resolved, and the
SvelteMapone is legitimately closed (verifiedsvelte/prefer-svelte-reactivity: errormandates it).reactKey = x+y+z+heightin the rail does double duty (dependency tracking + NaN guard) — split into explicit reads + acoordsFinitecheck. The$effectcorrectly should NOT be a$derived(CTM must read post-flush).24 + i*28.{#each}regions are extractable.🧪 Sara Holt — Senior QA Engineer — ✅ Approve
Both prior blockers resolved: gesture math now unit-tested (
pinchZoom/stepInertia), drag-flake fixed (asserts the pre-releasepointermove).replaceState(so it can't reproduce the router-init throw), and the node-tap test uses a synthetic click (bypasses pointer capture). The only real guards are e2e, which isn't wired in (#363).getScreenCTMpositioning isn't unit-tested (only fallback existence); a stubbed-svgtest could cover the CTM branch in node.🎨 Leonie Voss — UX & Accessibility — ⚠️ Approve with suggestions
Both prior items resolved (trapFocus restore ✓, real 44px targets ✓, affordance
-my-2technique correct). Reduced-motion respected on all new paths; rail chips can't trap focus.text-ink-3onbg-surface/85(15% transparent) can drop below 4.5:1 over dark node content — and at the new z=3 top-left default the leftmost column sits right under the chips. Make the chipbg-surfaceopaque (ortext-ink-2).pointer-events-noneso taps pass through, but the chip visually occludes the first node's name — consider a small left content inset on mobile.xMinYMinmakes fit-to-screen top-left-align instead of centre. Right call for a root-at-top tree, but it breaks the centred-"fit" mental model — worth a note.🔐 Nora "NullX" Steiner — Security — ✅ Approve
No new surface. Re-verified: crafted
?z=Infinity/?cx=NaN/1e500still degrade throughparsePanZoomParamsclamp regardless of thehas('z')branch;MAX_ZOOM=10is still hard-clamped (viewBox divisor can't hit 0/∞);cornerViewoutput isclampPan'd at the call site; raillabelis anumberrendered as escaped text (no{@html}); rounding + trapFocus-restore introduce no vectors.📋 Elicit — Requirements — ⚠️ Approve with traceability fixes
Both prior blockers resolved (ADR renumbered to 027 cleanly; issue-body banner added). Scope revisions (zoom 3→10; z=3 top-left landing vs fit-to-screen control; rail) are documented at code/ADR/commit level and defensible.
xMidYMid meet— stale after thexMinYMinchange.🛠️ Tobias Wendt — DevOps — ✅ Approve
Zero new dependencies (confirmed empty
package.json/lock diff), no infra/CI/config touched, no new i18n keys. Coverage is better than I first implied: the rail, anchor, and both regression fixes each have ≥1 CI-gated test (nodeserver+ vitest browser projects both run intest:coverage); only the pixel/gesture*.visual.spec.tsstays unwired (#363).Net: approved across all 7 personas, no blockers. Actioning the quick correctness items (stale rail comment, GLOSSARY 0.25–10, ADR-027 xMinYMin, opaque rail bg) next.
- Rail chip background opaque (was /85) so G{n} labels stay AA-legible over tree content (Leonie). - Rail effect: replace the reactKey hack with an inputsFinite guard that both tracks deps and guards NaN; name the fallback-stack magics; correct the stale 'xMidYMid' comment (the CTM mapping is preserveAspectRatio-agnostic) (Felix/Markus). - GLOSSARY zoom range 0.25–3.0 → 0.25–10; ADR-027 preserveAspectRatio note xMidYMid → xMinYMin (Elicit traceability). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>