feat(stammbaum): mobile read path — pan, zoom, fit-to-view (#692) #694

Merged
marcel merged 39 commits from feat/issue-692-stammbaum-mobile-panzoom into main 2026-05-30 07:43:44 +02:00
Owner

Closes #692.

Makes /stammbaum a 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 panzoom library. That predated discovering that zoom is already implemented via the SVG viewBox (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

  • Pan — one-finger / left-button drag, edge-clamped (no infinite scroll), drag suppresses the trailing node click, inertia on release (skipped under prefers-reduced-motion).
  • Zoom — two-finger pinch + Ctrl+wheel around the centroid, +/- keyboard, range 0.25–3.0; plain wheel + arrow keys pan. SVG stays vector-crisp.
  • Fit-to-screen — animated reset (≤300ms, instant under reduced motion); the page loads fit-to-screen.
  • Controls+ / − / ⤢ 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.
  • Bottom sheet (≤768px) — drag-handle grip with swipe-down dismiss, backdrop tap-outside, role="dialog" + focus trap + Escape. Desktop side panel unchanged.
  • Recentre — "centre on this person" in the panel title row; auto-zooms to a legible level when zoomed out.
  • URL state — view mirrored into ?cx&cy&z via replaceState; server-clamped so a crafted ?z=Infinity / ?cx=NaN link degrades to a safe view (Nora's DoS fix). Survives panel open/close; a shared link reproduces the view.
  • Affordance — touch-only first-load "drag to explore · pinch to zoom" hint, auto-dismisses on first interaction, 30-day localStorage gate. Permanent CSS edge-fade replaces the dropped US-PAN-006 AC3 idle cue.
  • i18n — 5 new keys in de/en/es. trapFocus action added to $lib/shared/actions.

Tests

  • Node unit (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.
  • Component / e2e (vitest-browser + Playwright) — keyboard zoom, drag + click-suppression, recentre, edge-fade, bottom-sheet dismiss paths, trapFocus, affordance lifecycle, URL sync, and a 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-existing deathYear fixture issue).
  • npm run build — SSR + client build clean (confirms no window-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

Closes #692. Makes `/stammbaum` a 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 `panzoom` library**. That predated discovering that zoom is already implemented via the SVG **`viewBox`** (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](docs/adr/026-stammbaum-custom-viewbox-pan-zoom.md)**. 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 - **Pan** — one-finger / left-button drag, edge-clamped (no infinite scroll), drag suppresses the trailing node click, inertia on release (skipped under `prefers-reduced-motion`). - **Zoom** — two-finger pinch + `Ctrl`+wheel around the centroid, `+`/`-` keyboard, range **0.25–3.0**; plain wheel + arrow keys pan. SVG stays vector-crisp. - **Fit-to-screen** — animated reset (≤300ms, instant under reduced motion); the page loads fit-to-screen. - **Controls** — `+ / − / ⤢` 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. - **Bottom sheet** (≤768px) — drag-handle grip with swipe-down dismiss, backdrop tap-outside, `role="dialog"` + focus trap + Escape. Desktop side panel unchanged. - **Recentre** — "centre on this person" in the panel title row; auto-zooms to a legible level when zoomed out. - **URL state** — view mirrored into `?cx&cy&z` via `replaceState`; **server-clamped** so a crafted `?z=Infinity` / `?cx=NaN` link degrades to a safe view (Nora's DoS fix). Survives panel open/close; a shared link reproduces the view. - **Affordance** — touch-only first-load "drag to explore · pinch to zoom" hint, auto-dismisses on first interaction, 30-day localStorage gate. Permanent CSS edge-fade replaces the dropped US-PAN-006 AC3 idle cue. - **i18n** — 5 new keys in de/en/es. **`trapFocus`** action added to `$lib/shared/actions`. ## Tests - **Node unit (`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. - **Component / e2e (vitest-browser + Playwright)** — keyboard zoom, drag + click-suppression, recentre, edge-fade, bottom-sheet dismiss paths, trapFocus, affordance lifecycle, URL sync, and a `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-existing `deathYear` fixture issue). - `npm run build` — SSR + client build clean (confirms no `window`-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](https://claude.com/claude-code)
marcel added 21 commits 2026-05-29 17:18:58 +02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Degrade Infinity/NaN/overflow per axis and clamp zoom into bounds so a crafted
?cx/?cy/?z shared link cannot blank the SVG (Nora's review).

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>
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>
+/- zoom by the fixed step and arrow keys pan by a tenth of the visible
extent, emitted via onPanZoom. Provides the keyboard-only alternative path
required by NFR-A11Y-002. Nodes keep their own Enter/Space selection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a panZoomGestures action: one-finger/left-button drag pans, two-finger
pinch and Ctrl+wheel zoom around the centroid, plain wheel pans. Pan is
edge-clamped via clampPan (no infinite scroll), a real drag suppresses the
trailing node click, and inertia decays after release unless prefers-reduced-
motion. Canvas container switches from native scroll to overflow-hidden.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Permanent 4-edge mask-image gradient cues off-screen content when the tree is
zoomed in; nothing fades at fit. Replaces the dropped US-PAN-006 AC3 idle cue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move zoom controls out of the page header into a docked bottom-right cluster
inside the canvas (one-handed phone reach, Leonie) and add a fit-to-screen
button (data-testid=fit-to-screen). Add the 5 new i18n keys to de/en/es.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fit-to-screen tweens to the default view over 300ms via animateView (eased,
lerpView-driven) and snaps instantly when prefers-reduced-motion is set
(US-PAN-004 AC2, NFR-A11Y-003).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The server load parses and sanitises the shareable pan/zoom params (degrading
Infinity/NaN, clamping zoom) into initialView, which seeds the page view. A
crafted link can no longer blank the SVG (Nora). US-PANEL-002 AC2 groundwork.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A view-keyed effect mirrors pan/zoom into the URL via replaceState (URL read
untracked to avoid a feedback loop). State survives panel open/close
(US-PANEL-002 AC1) and a shared link reproduces the view (AC2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Focuses the first focusable on mount and wraps Tab/Shift+Tab within the node.
Used by the Stammbaum mobile bottom sheet (NFR-A11Y-004).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wrap the mobile person panel in StammbaumBottomSheet: drag-handle grip with
swipe-down-to-dismiss (≥80px), full-screen backdrop button for tap-outside
dismiss, role=dialog + aria-label, focus trap, and Escape (NFR-A11Y-004).
Pan/zoom state is untouched by open/close (US-PANEL-001/002).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an onCentre control to StammbaumSidePanel (title row, both desktop aside
and mobile sheet). The page drives a one-shot centreOnId so StammbaumTree
recentres the canvas on the focal node (US-PAN-005). Also tighten the panel
spec's deathYear fixture to a valid type.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add StammbaumAffordance: a touch-only "drag to explore · pinch to zoom" hint
that auto-dismisses on the first canvas pointer interaction (wired via the
gesture action's onGestureStart) or the explicit close, and stays dismissed for
30 days via a localStorage timestamp (boolean gate only, never rendered).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
VISUAL-gated screenshots of the first-load affordance + control cluster at
each width and the bottom-sheet-open state at 414px, plus always-on structural
assertions. New snapshots; the #361 desktop baselines are untouched. Baselines
regenerate in CI via --update-snapshots.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
docs(stammbaum): ADR-026 custom viewBox pan/zoom + glossary terms (#692)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m35s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m41s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
ba053b3c23
Record the reversal of OQ-007 (build custom over the existing viewBox rather
than adopt the panzoom library) and add pan/zoom view-state + fit-to-screen
glossary entries.

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

🏛️ 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

  • SSR safety / module-load DOM access. No window/document/matchMedia/localStorage runs at import time anywhere in the changed set. panZoom.ts is fully pure. animateView.ts only touches window.matchMedia inside prefersReducedMotion(), guarded by typeof window. panZoomGestures.ts touches the DOM only inside the action body/handlers. In components, matchMedia/localStorage are read inside $state initialisers and onMount/$effect — all guarded or browser-only. The replaceState effect (+page.svelte:69-78) reads window.location.href inside an $effect, which never runs during SSR. Correct.
  • State ownership. view: PanZoomState is owned at the page, passed down as panZoom and mutated only via onPanZoom — textbook props-down/callbacks-up. StammbaumTree never holds its own copy. No cross-domain leak.
  • The single swap-out seam. panZoom.ts is the right seam — page, action, keyboard handler, and server loader all funnel through it. The ADR's claim is accurate and verifiable.
  • replaceState loop risk. Correctly defended: only view is tracked; URL read + write are wrapped in untrack. replaceState (vs goto) avoids a load round-trip, so server parsePanZoomParams doesn't re-fire on every pan.
  • centre-on orchestration. One-shot centreOnId reads panZoom/baseCentre under untrack, so a normal pan doesn't retrigger a recentre; page clears the id after tick(). No feedback loop.
  • ADR-026 soundness. Well-formed, documents the OQ-007 reversal with a concrete, load-bearing reason (viewBox-based zoom + the #689 in-SVG gutter would fight a CSS-transform library). Reversing a recorded decision with a fresh ADR is exactly the discipline I want. Endorsed.
  • Doc currency. No new backend domain/route/migration/Permission/Docker service → no C4/DB-diagram updates triggered. ADR + glossary cover the obligations.

Blockers

None.

Suggestions

  • trapFocus doesn't restore focus on destroy (trapFocus.ts:38-42). For a shared modal-overlay primitive, capturing document.activeElement on entry and restoring it in destroy() is the conventional contract.
  • Affordance localStorage key is a bare literal (StammbaumAffordance.svelte:20). As client view-state keys accumulate, a small shared prefix convention prevents collisions.
  • focus vs cx/cy/z are 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.

## 🏛️ 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 - **SSR safety / module-load DOM access.** No `window`/`document`/`matchMedia`/`localStorage` runs at import time anywhere in the changed set. `panZoom.ts` is fully pure. `animateView.ts` only touches `window.matchMedia` inside `prefersReducedMotion()`, guarded by `typeof window`. `panZoomGestures.ts` touches the DOM only inside the action body/handlers. In components, `matchMedia`/`localStorage` are read inside `$state` initialisers and `onMount`/`$effect` — all guarded or browser-only. The `replaceState` effect (`+page.svelte:69-78`) reads `window.location.href` inside an `$effect`, which never runs during SSR. Correct. - **State ownership.** `view: PanZoomState` is owned at the page, passed down as `panZoom` and mutated only via `onPanZoom` — textbook props-down/callbacks-up. `StammbaumTree` never holds its own copy. No cross-domain leak. - **The single swap-out seam.** `panZoom.ts` is the right seam — page, action, keyboard handler, and server loader all funnel through it. The ADR's claim is accurate and verifiable. - **replaceState loop risk.** Correctly defended: only `view` is tracked; URL read + write are wrapped in `untrack`. `replaceState` (vs `goto`) avoids a `load` round-trip, so server `parsePanZoomParams` doesn't re-fire on every pan. - **centre-on orchestration.** One-shot `centreOnId` reads `panZoom`/`baseCentre` under `untrack`, so a normal pan doesn't retrigger a recentre; page clears the id after `tick()`. No feedback loop. - **ADR-026 soundness.** Well-formed, documents the OQ-007 reversal with a concrete, load-bearing reason (viewBox-based zoom + the #689 in-SVG gutter would fight a CSS-transform library). Reversing a recorded decision with a fresh ADR is exactly the discipline I want. Endorsed. - **Doc currency.** No new backend domain/route/migration/Permission/Docker service → no C4/DB-diagram updates triggered. ADR + glossary cover the obligations. ### Blockers None. ### Suggestions - **`trapFocus` doesn't restore focus on destroy** (`trapFocus.ts:38-42`). For a shared modal-overlay primitive, capturing `document.activeElement` on entry and restoring it in `destroy()` is the conventional contract. - **Affordance `localStorage` key is a bare literal** (`StammbaumAffordance.svelte:20`). As client view-state keys accumulate, a small shared prefix convention prevents collisions. - **`focus` vs `cx/cy/z` are 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.
Author
Owner

👨‍💻 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.svelte is 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, the parentLinks graph 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 into StammbaumNode.svelte / StammbaumConnectors.svelte. Not a blocker (regions are cohesive + tested) but this file will keep accreting.

2. Duplicated media-query-state pattern. isMdOrUp, reducedMotion, and animateView.ts's prefersReducedMotion are the same "seed synchronously, subscribe to change" shape three times. A mediaQueryState(query) helper in $lib/shared collapses all three and is trivially testable.

3. parentLinks/gutterRows use SvelteSet/SvelteMap purely as locals inside $derived.by. They're computed-and-discarded within one synchronous derivation — plain Set/Map express 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 magic 16 as const FRAME_MS = 16.

5. Click-suppression flag is sound but self-clears only on the next click — worth a one-line comment.

6. $effect usage is correct — none should be $derived (recentre fires a callback, replaceState writes the URL, the SidePanel loader fires fetches; all genuine side-effects). untrack placements 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-ignore a11y suppressions are narrow and reasoned (interactive canvas with a keyboard alternative; supplementary drag handle). Not blanket.

9. Tests are behaviour-firstzoomAtPoint asserts the anchor-screen-fraction invariant; component tests assert user-visible outcomes (no select after drag, recentre snaps z). Minor: page.svelte.test.ts:91 is a "doesn't throw" smoke test — assert the clamped z via the mirrored ?z param.

10. No dead code. No commented-out blocks, no unused exports.

## 👨‍💻 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.svelte` is 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, the `parentLinks` graph 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 into `StammbaumNode.svelte` / `StammbaumConnectors.svelte`. Not a blocker (regions are cohesive + tested) but this file will keep accreting. **2. Duplicated media-query-state pattern.** `isMdOrUp`, `reducedMotion`, and `animateView.ts`'s `prefersReducedMotion` are the same "seed synchronously, subscribe to change" shape three times. A `mediaQueryState(query)` helper in `$lib/shared` collapses all three and is trivially testable. **3. `parentLinks`/`gutterRows` use `SvelteSet`/`SvelteMap` purely as locals inside `$derived.by`.** They're computed-and-discarded within one synchronous derivation — plain `Set`/`Map` express 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 magic `16` as `const FRAME_MS = 16`. **5. Click-suppression flag is sound** but self-clears only on the next click — worth a one-line comment. **6. `$effect` usage is correct** — none should be `$derived` (recentre fires a callback, replaceState writes the URL, the SidePanel loader fires fetches; all genuine side-effects). `untrack` placements 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-ignore` a11y suppressions are narrow and reasoned** (interactive canvas with a keyboard alternative; supplementary drag handle). Not blanket. **9. Tests are behaviour-first** — `zoomAtPoint` asserts the anchor-screen-fraction invariant; component tests assert user-visible outcomes (no select after drag, recentre snaps z). Minor: `page.svelte.test.ts:91` is a "doesn't throw" smoke test — assert the clamped `z` via the mirrored `?z` param. **10. No dead code.** No commented-out blocks, no unused exports.
Author
Owner

🔐 Nora "NullX" Steiner — Application Security Engineer

Verdict: Approve — no security blockers. The requested ?z=Infinity / ?cx=NaN DoS 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.ts finiteOr rejects NaN/±Infinity/overflow (Number("1e500") === Infinity → caught by Number.isFinite), clampZoom bounds z to [0.25, 3.0]. +page.server.ts runs this server-side before geometry, so viewBox never divides by a non-finite/zero z. Verified the full round-trip: gesture output → clampPan+clampZoomreplaceState re-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 via Number(raw) in a Date.now() comparison; both catch blocks fail safe. A poisoned value coerces to NaN → comparison false → hint just shows. No stored XSS.

3. [Confirmed safe] displayName only as escaped text / attribute value. All usages are Svelte {…} interpolation or aria-label bindings — zero {@html} in the genealogy module or stammbaum route, so the #361 constraint holds.

4. [Confirmed safe] ?focus is membership-validated (+page.svelte only honours it when the id is in the server-authorised node set) — no IDOR.

5. [Low — defence-in-depth] Add a server-load regression test feeding ?z=Infinity&cx=NaN&cy=1e500 and asserting initialView is the clamped default, so a future refactor that drops the server-side sanitisation fails loudly. (Note: page.server.test.ts already covers exactly this — good; just confirming it stays.)

## 🔐 Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approve** — no security blockers. The requested `?z=Infinity` / `?cx=NaN` DoS 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.ts` `finiteOr` rejects `NaN`/`±Infinity`/overflow (`Number("1e500") === Infinity` → caught by `Number.isFinite`), `clampZoom` bounds `z` to `[0.25, 3.0]`. `+page.server.ts` runs this server-side before geometry, so `viewBox` never divides by a non-finite/zero `z`. Verified the full round-trip: gesture output → `clampPan`+`clampZoom` → `replaceState` re-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 via `Number(raw)` in a `Date.now()` comparison; both `catch` blocks fail safe. A poisoned value coerces to `NaN` → comparison `false` → hint just shows. No stored XSS. **3. [Confirmed safe] `displayName` only as escaped text / attribute value.** All usages are Svelte `{…}` interpolation or `aria-label` bindings — zero `{@html}` in the genealogy module or stammbaum route, so the #361 constraint holds. **4. [Confirmed safe] `?focus` is membership-validated** (`+page.svelte` only honours it when the id is in the server-authorised node set) — no IDOR. **5. [Low — defence-in-depth] Add a server-`load` regression test** feeding `?z=Infinity&cx=NaN&cy=1e500` and asserting `initialView` is the clamped default, so a future refactor that drops the server-side sanitisation fails loudly. (Note: `page.server.test.ts` already covers exactly this — good; just confirming it stays.)
Author
Owner

🧪 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), and zoomAtPoint asserts the actual anchor invariant rather than "didn't throw". The server load test mirrors the clamping including the ?z=Infinity security 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, the runInertia rAF loop + decay/stall exit, onWheel plain-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 (like zoomAtPoint already is) and unit-test them; the rAF loop can use an injected now/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 force reducedMotion: reduce (only playwright.config.ts:29 does, for e2e). So reducedMotion defaults to no-preference → runInertia() fires after the synthetic pointerup; with dt≈1ms over a 60px move the velocity is large, emitting extra async onPanZoom calls. The test reads mock.calls.at(-1) synchronously — passes by timing luck today, can flip when an inertia frame lands or clamps. Fix: set reducedMotion deterministically for that render, or assert on the pointermove call rather than the last call.

Suggestions

  • animateView non-reduced (rAF/easeOutCubic) path is untested — only the reducedMotion:true early return is covered. It takes injectable durationMs; add a stubbed-rAF test asserting intermediate interpolation and final frame == to.
  • e2e affordance localStorage not cleared per-test — only the first loop test removes the key; the bottom-sheet test doesn't. Move the addInitScript removal to a beforeEach.
  • test.skip on 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:91 asserts "doesn't throw" not the actual clamp — assert the floored z via the mirrored ?z param.
  • Magic constants unpinnedINERTIA_DECAY, INERTIA_MIN_SPEED, DRAG_THRESHOLD_PX, ZOOM_STEP_KB: a tuning change to DRAG_THRESHOLD_PX could silently break click-suppression with no failing test.
  • load network-failure path untestedmockNetwork() only returns ok; no test that a backend 500 degrades gracefully.
  • NFR-PERF-001 (50fps): correctly downgraded to best-effort with no harness — honest call, no objection.
## 🧪 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), and `zoomAtPoint` asserts the actual anchor invariant rather than "didn't throw". The server `load` test mirrors the clamping including the `?z=Infinity` security 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`, the `runInertia` rAF loop + decay/stall exit, `onWheel` plain-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 (like `zoomAtPoint` already is) and unit-test them; the rAF loop can use an injected `now`/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** force `reducedMotion: reduce` (only `playwright.config.ts:29` does, for e2e). So `reducedMotion` defaults to no-preference → `runInertia()` fires after the synthetic `pointerup`; with `dt≈1ms` over a 60px move the velocity is large, emitting extra async `onPanZoom` calls. The test reads `mock.calls.at(-1)` synchronously — passes by timing luck today, can flip when an inertia frame lands or clamps. Fix: set `reducedMotion` deterministically for that render, or assert on the `pointermove` call rather than the last call. ### Suggestions - **`animateView` non-reduced (rAF/easeOutCubic) path is untested** — only the `reducedMotion:true` early return is covered. It takes injectable `durationMs`; add a stubbed-rAF test asserting intermediate interpolation and final frame == `to`. - **e2e affordance localStorage not cleared per-test** — only the first loop test removes the key; the bottom-sheet test doesn't. Move the `addInitScript` removal to a `beforeEach`. - **`test.skip` on 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:91` asserts "doesn't throw"** not the actual clamp — assert the floored `z` via the mirrored `?z` param. - **Magic constants unpinned** — `INERTIA_DECAY`, `INERTIA_MIN_SPEED`, `DRAG_THRESHOLD_PX`, `ZOOM_STEP_KB`: a tuning change to `DRAG_THRESHOLD_PX` could silently break click-suppression with no failing test. - **`load` network-failure path untested** — `mockNetwork()` only returns ok; no test that a backend 500 degrades gracefully. - **NFR-PERF-001 (50fps):** correctly downgraded to best-effort with no harness — honest call, no objection.
Author
Owner

🎨 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:trapFocus moves focus into the sheet on open but on dismiss selectedIdnull and the sheet unmounts with no focus() of the previously-focused element. A keyboard/AT user is dumped to document.body and must Tab from the top. WCAG 2.4.3. Fix in trapFocus.ts: capture document.activeElement on mount, restore in destroy() — benefits every future consumer.

2. Three icon-only buttons miss the 44×44 minimum (WCAG 2.5.8) — on the senior/touch read path:

  • Affordance × — StammbaumAffordance.svelte: p-0.5 + 14px svg ≈ 18×18.
  • Centre-on-person — StammbaumSidePanel.svelte: p-1 + 16px svg ≈ 24×24.
  • Close × — 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 does h-11 w-11 correctly.

Confirmed correct (the items I flagged earlier)

  • Visible focus indicator on the focusable svg — kept. The <svg tabindex="0"> carries no outline-none, so the global :focus-visible 2px ring renders; nodes draw their own custom focus rect. Both layers indicated.
  • The svg svelte-ignore suppressions are justified — labelled role="img", keyboard-pannable, each node a proper role="button". AT users get a labelled, operable canvas. Nit: aria-label="Stammbaum" and Generation {n} are hardcoded, not Paraglide — minor i18n follow-up.
  • Bottom sheet announced correctlyrole="dialog" aria-modal="true" aria-label={displayName}, real <button> backdrop, aria-hidden handle, Escape handled.
  • Reduced-motion coverage complete — fit snaps, inertia skipped, sheet has no slide transition to gate.
  • i18n: all 5 keys present in de/en/es with idiomatic translations; safe-area inset + U+2212 minus + 44px cluster all honoured.
## 🎨 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:trapFocus` moves focus into the sheet on open but on dismiss `selectedId` → `null` and the sheet unmounts with no `focus()` of the previously-focused element. A keyboard/AT user is dumped to `document.body` and must Tab from the top. WCAG 2.4.3. Fix in `trapFocus.ts`: capture `document.activeElement` on mount, restore in `destroy()` — benefits every future consumer. **2. Three icon-only buttons miss the 44×44 minimum (WCAG 2.5.8) — on the senior/touch read path:** - Affordance × — `StammbaumAffordance.svelte`: `p-0.5` + 14px svg ≈ 18×18. - Centre-on-person — `StammbaumSidePanel.svelte`: `p-1` + 16px svg ≈ 24×24. - Close × — `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 does `h-11 w-11` correctly. ### Confirmed correct (the items I flagged earlier) - **Visible focus indicator on the focusable svg — kept.** The `<svg tabindex="0">` carries no `outline-none`, so the global `:focus-visible` 2px ring renders; nodes draw their own custom focus rect. Both layers indicated. - **The svg `svelte-ignore` suppressions are justified** — labelled `role="img"`, keyboard-pannable, each node a proper `role="button"`. AT users get a labelled, operable canvas. *Nit:* `aria-label="Stammbaum"` and `Generation {n}` are hardcoded, not Paraglide — minor i18n follow-up. - **Bottom sheet announced correctly** — `role="dialog" aria-modal="true" aria-label={displayName}`, real `<button>` backdrop, `aria-hidden` handle, Escape handled. - **Reduced-motion coverage complete** — fit snaps, inertia skipped, sheet has no slide transition to gate. - **i18n: all 5 keys present in de/en/es** with idiomatic translations; safe-area inset + U+2212 minus + 44px cluster all honoured.
Author
Owner

🛠️ 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 viewBox implementation instead of pulling a library, adding no supply-chain surface.

What I verified:

  • No new npm dependency. git diff origin/main..HEAD -- frontend/package.json frontend/package-lock.json is empty. New modules import only svelte/action + internal $lib. No panzoom/svg-pan-zoom/d3-zoom. ADR documents the decision. Negligible bundle impact.
  • No generated files committed. src/lib/paraglide/** diff is empty (gitignored, compiled in CI). Only messages/{de,en,es}.json committed.
  • i18n parity + validity — all three locales parse and gained the same 5 keys; compile via the Paraglide Vite plugin without a missing-key fallback.
  • No infra drift — zero changes to Dockerfiles, 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.ts uses @playwright/test, but playwright test / npm run test:e2e is not invoked by any workflow (grepped ci.yml + nightly.yml for playwright test, test:e2e, toHaveScreenshot, --update-snapshots — nothing). CI only runs npm run test:coverage (Vitest) + npm run build. So the VISUAL=1 snapshots 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 image mcr.microsoft.com/playwright:v1.60.0-noble is 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.ts suite, 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.

## 🛠️ 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 `viewBox` implementation instead of pulling a library, adding no supply-chain surface. What I verified: - **No new npm dependency.** `git diff origin/main..HEAD -- frontend/package.json frontend/package-lock.json` is empty. New modules import only `svelte/action` + internal `$lib`. No `panzoom`/`svg-pan-zoom`/`d3-zoom`. ADR documents the decision. Negligible bundle impact. - **No generated files committed.** `src/lib/paraglide/**` diff is empty (gitignored, compiled in CI). Only `messages/{de,en,es}.json` committed. - **i18n parity + validity** — all three locales parse and gained the same 5 keys; compile via the Paraglide Vite plugin without a missing-key fallback. - **No infra drift** — zero changes to Dockerfiles, `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.ts` uses `@playwright/test`, but `playwright test` / `npm run test:e2e` is **not invoked by any workflow** (grepped ci.yml + nightly.yml for `playwright test`, `test:e2e`, `toHaveScreenshot`, `--update-snapshots` — nothing). CI only runs `npm run test:coverage` (Vitest) + `npm run build`. So the `VISUAL=1` snapshots 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 image `mcr.microsoft.com/playwright:v1.60.0-noble` is 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.ts` suite, 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.
Author
Owner

📋 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. Confirmeddocs/adr/026-stammbaum-custom-viewbox-pan-zoom.md (this PR) and the pre-existing docs/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.ts header 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 panzoom recommendation; #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&z round-trip + server sanitisation, inertia w/ reduced-motion guard, recentre auto-zoom to LEGIBLE_ZOOM, bottom sheet, touch-only affordance (pointer: coarse), centre button in title row, permanent CSS edge-fade replacing US-PAN-006 AC3. trapFocus is in-scope for the modal, not gold-plating.

S3 — a11y nit (defer to UX): the canvas is role="img" yet keyboard-interactive; consider role="application" or aria-roledescription to 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.

## 📋 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-existing `docs/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.ts` header 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 `panzoom` recommendation; #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&z` round-trip + server sanitisation, inertia w/ reduced-motion guard, recentre auto-zoom to `LEGIBLE_ZOOM`, bottom sheet, touch-only affordance (`pointer: coarse`), centre button in title row, permanent CSS edge-fade replacing US-PAN-006 AC3. `trapFocus` is in-scope for the modal, not gold-plating. **S3 — a11y nit (defer to UX):** the canvas is `role="img"` yet keyboard-interactive; consider `role="application"` or `aria-roledescription` to 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.
marcel added 5 commits 2026-05-29 18:43:17 +02:00
The #361 layout ADR already owns 026; renumber the custom-viewBox pan/zoom ADR
to 027 and update the glossary + panZoom.ts references (Elicit review).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
replaceState throws 'before the router is initialized' during hydration, which
killed the sync $effect on its first tick so the URL never updated on pan/zoom.
Gate the write behind a flag flipped after the first post-mount tick() (router
started) plus a defensive try/catch. Verified live: zoom now updates ?z=.
The prior component test mocked replaceState and masked this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A fresh visit (no URL state) now opens at INITIAL_VIEW (z=3) so node tiles and
generation labels are legible on arrival; the fit-to-screen control still zooms
out to the whole tree (DEFAULT_VIEW, z=1). Shared links with ?z still win.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Generation labels are no longer drawn in-SVG (where they panned/zoomed off
screen and were desktop-only). A new StammbaumGenerationRail overlays the canvas
left edge, mapping each generation row's centre through the SVG's live
getScreenCTM so chips stay pinned horizontally and track their row vertically at
any pan/zoom — on phones too. The desktop stripe underlay stays (gated on the
gutter breakpoint); the #689 label tests are rewritten against the rail.
Verified live: labels stay at left=4px while the canvas pans.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(stammbaum): round pan/zoom URL params for readable shared links (#692)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m36s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m30s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
c1dd6d299f
Pan rounded to 2 decimals, zoom to 3, so ?cx/?cy/?z no longer carry float
noise like cx=457.8300882631206.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
marcel added 4 commits 2026-05-29 18:55:47 +02:00
Move the pinch-zoom (pinchZoom) and inertia-step (stepInertia) geometry out of
the panZoomGestures DOM glue into pure, unit-tested helpers in panZoom.ts, with
named FRAME_MS/INERTIA_* constants. Addresses the QA blocker that the gesture
module's core math was untested. No behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Read the pan emission from the pointermove (deterministic) instead of the
post-pointerup last call, which inertia could perturb when reduced-motion is
not forced in vitest-browser (QA blocker).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the bottom sheet closes, focus returns to the element that was focused
before it opened instead of being dropped to document.body (WCAG 2.4.3,
Architect + UX review).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(stammbaum): node tap stopped selecting — defer pointer capture to drag start (#692)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m34s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m41s
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 1m7s
b170085311
Capturing the pointer on pointerdown made the browser dispatch the trailing
click at the SVG instead of the node under the finger, so node taps silently
stopped opening the person panel. Capture only once a drag crosses the
threshold; a tap now reaches the node's onclick. Verified live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
marcel added 4 commits 2026-05-29 19:11:24 +02:00
Zoom is normalised to the whole tree, so z=3 still renders a wide tree too
small on a phone. Raise the ceiling to 10 (revises OQ-001); SVG stays crisp at
any zoom so a generous max is harmless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Enlarge the centre-on-person, panel-close, and affordance-dismiss icon buttons
to 44x44 hit areas (WCAG 2.5.8, UX review) while keeping the small glyphs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a deterministic stubbed-rAF test for animateView's animated path (was only
covering the reduced-motion branch) and assert the server load redirects on 401
and throws on a network 500 (QA review).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
test(stammbaum): assert zoom-out floor via mirrored ?z; e2e affordance beforeEach (#692)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m35s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m40s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
01b902e885
Strengthen the zoom-clamp test to assert z floors at 0.25 in the URL (was a
'does not throw' smoke test) and move the affordance localStorage reset to a
beforeEach so the e2e tests are order-independent (QA review).

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

🔧 Review feedback addressed + live bug fixes

Pushed the fixes for the review blockers/concerns and several issues found running it live.

Blockers — fixed

  • [Elicit] ADR-026 collision → renumbered to ADR-027, updated glossary + panZoom.ts refs.
  • [QA] gesture math untested → extracted pinchZoom + stepInertia (with named FRAME_MS/INERTIA_*) into pure, unit-tested helpers in panZoom.ts.
  • [QA] inertia flake → drag test now asserts on the pointermove emission (deterministic), before release.
  • [Elicit] issue body not updated → folded a resolutions banner into the #692 body (OQ table, AC clarifications, OQ-007→ADR-027).

Live bugs found while running (verified fixed via the dev container)

  • URL never updated on pan/zoomreplaceState threw "before the router is initialized" during hydration, killing the sync effect; gated on a router-ready flag + try/catch. (The old test mocked replaceState, masking it.)
  • Node tap stopped opening the panelsetPointerCapture on pointerdown redirected the click off the node; capture is now deferred to drag-start.

Product-owner tuning (during review)

  • Default landing zoom → z=3 (fit-to-screen still resets to whole tree).
  • MAX_ZOOM 3 → 10 (z is normalised to the whole tree, so 3 was too small to read on a phone).
  • URL params rounded (pan 2dp, zoom 3dp) — no more cx=457.8300882631206.
  • Pinned generation railG{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 at left=4px while the canvas pans).

Concerns — fixed

  • [Architect/UX] trapFocus now restores focus to the opener on destroy (WCAG 2.4.3).
  • [UX] 44×44 touch targets for the centre/close/affordance icon buttons.
  • [QA] coverage — added animateView rAF-tween test, server load 401/500 tests, and a real zoom-floor assertion via the mirrored ?z; moved the e2e affordance reset to beforeEach.

Deferred (non-blockers, with rationale)

  • StammbaumTree.svelte split (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.
  • mediaQueryState helper (Felix) — DRY nicety; deferred for the same reason.
  • plain Set/Map (Felix) — won't do: the repo's svelte/prefer-svelte-reactivity ESLint rule mandates SvelteMap/SvelteSet in .svelte files.
  • e2e in CI (Tobias) — infra, tracked under #363; the spec is authored and VISUAL-gated.
  • role="img" vs application — kept role="img" per Leonie's a11y sign-off.

Local gates: 40 node unit tests green, npm run check at baseline (no new type errors), npm run build clean. Browser/e2e remain CI-gated.

## 🔧 Review feedback addressed + live bug fixes Pushed the fixes for the review blockers/concerns and several issues found running it live. ### Blockers — fixed - **[Elicit] ADR-026 collision** → renumbered to **ADR-027**, updated glossary + `panZoom.ts` refs. - **[QA] gesture math untested** → extracted `pinchZoom` + `stepInertia` (with named `FRAME_MS`/`INERTIA_*`) into pure, unit-tested helpers in `panZoom.ts`. - **[QA] inertia flake** → drag test now asserts on the `pointermove` emission (deterministic), before release. - **[Elicit] issue body not updated** → folded a resolutions banner into the #692 body (OQ table, AC clarifications, OQ-007→ADR-027). ### Live bugs found while running (verified fixed via the dev container) - **URL never updated on pan/zoom** — `replaceState` threw "before the router is initialized" during hydration, killing the sync effect; gated on a router-ready flag + try/catch. (The old test mocked `replaceState`, masking it.) - **Node tap stopped opening the panel** — `setPointerCapture` on pointerdown redirected the click off the node; capture is now deferred to drag-start. ### Product-owner tuning (during review) - **Default landing zoom → z=3** (fit-to-screen still resets to whole tree). - **MAX_ZOOM 3 → 10** (z is normalised to the whole tree, so 3 was too small to read on a phone). - **URL params rounded** (pan 2dp, zoom 3dp) — no more `cx=457.8300882631206`. - **Pinned generation rail** — `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 at `left=4px` while the canvas pans). ### Concerns — fixed - **[Architect/UX] trapFocus** now restores focus to the opener on destroy (WCAG 2.4.3). - **[UX] 44×44 touch targets** for the centre/close/affordance icon buttons. - **[QA] coverage** — added `animateView` rAF-tween test, server `load` 401/500 tests, and a real zoom-floor assertion via the mirrored `?z`; moved the e2e affordance reset to `beforeEach`. ### Deferred (non-blockers, with rationale) - **`StammbaumTree.svelte` split** (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. - **`mediaQueryState` helper** (Felix) — DRY nicety; deferred for the same reason. - **plain `Set`/`Map`** (Felix) — **won't do**: the repo's `svelte/prefer-svelte-reactivity` ESLint rule mandates `SvelteMap`/`SvelteSet` in `.svelte` files. - **e2e in CI** (Tobias) — infra, tracked under #363; the spec is authored and `VISUAL`-gated. - **`role="img"` vs `application`** — kept `role="img"` per Leonie's a11y sign-off. Local gates: 40 node unit tests green, `npm run check` at baseline (no new type errors), `npm run build` clean. Browser/e2e remain CI-gated.
marcel added 1 commit 2026-05-29 19:25:24 +02:00
feat(stammbaum): land a fresh visit on the tree's top-left corner (#692)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m35s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m33s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
b1309db8db
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>
marcel added 1 commit 2026-05-29 19:40:25 +02:00
fix(stammbaum): anchor fresh visit to content top-left, drop space above row 1 (#692)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m35s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m41s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
11dc25ef31
The frame-corner anchor + xMidYMid letterboxing left ~290px of empty space
above the first row on desktop. Anchor to the content corner (first row /
leftmost node, small margin) via cornerView, and switch the canvas to
xMinYMin meet so a wide/short tree pins to the top-left instead of centring
vertically. Verified live: gap above row 1 is now ~20px.

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

🔁 Re-review (delta since first review — 15 commits)

🏛️ Markus Keller — Application Architect — Approve

Prior verdict holds. panZoom.ts remains the single pure geometry seam (pinchZoom/stepInertia/cornerView are DOM-free + tested); state ownership stays at the page; no SSR-at-import regressions.

  • No dual-writer conflict: fresh visit → server seeds INITIAL_VIEW → onMount corner anchor overrides once → URL-sync stamps ?z; next load flips anchorTopLeft false. The page stays the sole owner of view.
  • URL-sync gating, click-capture-defer, and the preserveAspectRatio lever are all sound.
  • Suggestion: StammbaumGenerationRail.svelte doc-comment still says "exact under xMidYMid meet" — the canvas is now xMinYMin; the CTM mapping is alignment-agnostic so the code is correct, only the comment is stale.
  • Suggestion: the rail's $effect does 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 SvelteMap one is legitimately closed (verified svelte/prefer-svelte-reactivity: error mandates it).

  • Suggestion: reactKey = x+y+z+height in the rail does double duty (dependency tracking + NaN guard) — split into explicit reads + a coordsFinite check. The $effect correctly should NOT be a $derived (CTM must read post-flush).
  • Suggestion: name the fallback magics 24 + i*28.
  • Suggestion: StammbaumTree.svelte split still stands and grew (~546 lines) — open a follow-up; the 4 connector/node {#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-release pointermove).

  • Key residual risk (honest gap): the two live regressions remain guarded only by mechanisms that can't run in CI — the URL test mocks 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).
  • Suggestion: the rail's real getScreenCTM positioning isn't unit-tested (only fallback existence); a stubbed-svg test 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-2 technique correct). Reduced-motion respected on all new paths; rail chips can't trap focus.

  • AA-edge contrast: rail chip text-ink-3 on bg-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 chip bg-surface opaque (or text-ink-2).
  • Rail overlaps the leftmost nodes at the top-left default (mobile has no gutter reservation). Container is pointer-events-none so taps pass through, but the chip visually occludes the first node's name — consider a small left content inset on mobile.
  • Trade-off flag: xMinYMin makes 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/1e500 still degrade through parsePanZoomParams clamp regardless of the has('z') branch; MAX_ZOOM=10 is still hard-clamped (viewBox divisor can't hit 0/∞); cornerView output is clampPan'd at the call site; rail label is a number rendered 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.

  • GLOSSARY still says "clamped 0.25–3.0" — stale, should be 0.25–10.
  • ADR-027 still describes xMidYMid meet — stale after the xMinYMin change.
  • Note: US-PAN-004 AC1 now governs the fit-to-screen control, not the landing — make sure the body AC reflects landing-vs-control.

🛠️ 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 (node server + vitest browser projects both run in test:coverage); only the pixel/gesture *.visual.spec.ts stays 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.

## 🔁 Re-review (delta since first review — 15 commits) ### 🏛️ Markus Keller — Application Architect — ✅ Approve Prior verdict holds. `panZoom.ts` remains the single pure geometry seam (`pinchZoom`/`stepInertia`/`cornerView` are DOM-free + tested); state ownership stays at the page; no SSR-at-import regressions. - **No dual-writer conflict:** fresh visit → server seeds `INITIAL_VIEW` → onMount corner anchor overrides once → URL-sync stamps `?z`; next load flips `anchorTopLeft` false. The page stays the sole owner of `view`. - **URL-sync gating, click-capture-defer, and the `preserveAspectRatio` lever are all sound.** - *Suggestion:* `StammbaumGenerationRail.svelte` doc-comment still says "exact under xMidYMid meet" — the canvas is now `xMinYMin`; the CTM mapping is alignment-agnostic so the **code is correct**, only the comment is stale. - *Suggestion:* the rail's `$effect` does 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 `SvelteMap` one is **legitimately closed** (verified `svelte/prefer-svelte-reactivity: error` mandates it). - *Suggestion:* `reactKey = x+y+z+height` in the rail does double duty (dependency tracking + NaN guard) — split into explicit reads + a `coordsFinite` check. The `$effect` correctly should NOT be a `$derived` (CTM must read post-flush). - *Suggestion:* name the fallback magics `24 + i*28`. - *Suggestion:* **StammbaumTree.svelte split still stands and grew** (~546 lines) — open a follow-up; the 4 connector/node `{#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-release `pointermove`). - *Key residual risk (honest gap):* the **two live regressions remain guarded only by mechanisms that can't run in CI** — the URL test mocks `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). - *Suggestion:* the rail's real `getScreenCTM` positioning isn't unit-tested (only fallback existence); a stubbed-`svg` test 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-2` technique correct). Reduced-motion respected on all new paths; rail chips can't trap focus. - **AA-edge contrast:** rail chip `text-ink-3` on `bg-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 chip `bg-surface` opaque (or `text-ink-2`). - **Rail overlaps the leftmost nodes** at the top-left default (mobile has no gutter reservation). Container is `pointer-events-none` so taps pass through, but the chip visually occludes the first node's name — consider a small left content inset on mobile. - *Trade-off flag:* `xMinYMin` makes **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`/`1e500` still degrade through `parsePanZoomParams` clamp regardless of the `has('z')` branch; `MAX_ZOOM=10` is still hard-clamped (viewBox divisor can't hit 0/∞); `cornerView` output is `clampPan`'d at the call site; rail `label` is a `number` rendered 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. - **GLOSSARY still says "clamped 0.25–3.0"** — stale, should be 0.25–10. - **ADR-027 still describes `xMidYMid meet`** — stale after the `xMinYMin` change. - *Note:* US-PAN-004 AC1 now governs the fit-to-screen **control**, not the **landing** — make sure the body AC reflects landing-vs-control. ### 🛠️ 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 (node `server` + vitest browser projects both run in `test:coverage`); only the pixel/gesture `*.visual.spec.ts` stays 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.
marcel added 1 commit 2026-05-29 20:21:33 +02:00
fix(stammbaum): address re-review nits — opaque rail, stale docs, rail clarity (#692)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m35s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m38s
CI / fail2ban Regex (pull_request) Successful in 41s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
95d35c20b2
- 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>
marcel added 1 commit 2026-05-29 20:43:11 +02:00
test(stammbaum): fix two CI-only browser-test failures (#692)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m18s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m36s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
ecae789be2
- page.svelte.test.ts mocked $app/navigation with only replaceState, dropping
  invalidateAll (imported by StammbaumSidePanel) → the module errored and failed
  all 7 tests in the file. Mock now exports invalidateAll + goto too.
- StammbaumTree viewBox 'offsets origin' test hard-coded a wrong unpanned-x; assert
  the robust relationship instead (viewBox centre − content centroid == pan).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
marcel added 1 commit 2026-05-29 21:43:13 +02:00
refactor(stammbaum): split StammbaumTree into Connectors + Node components (#692)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m21s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m37s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
CI / Unit & Component Tests (push) Successful in 3m30s
CI / OCR Service Tests (push) Successful in 23s
CI / Backend Unit Tests (push) Successful in 3m47s
CI / fail2ban Regex (push) Successful in 45s
CI / Semgrep Security Scan (push) Successful in 21s
CI / Compose Bucket Idempotency (push) Successful in 1m5s
8cc6031ef0
Extract the three SVG connector layers (+ the parent-link graph computation)
into StammbaumConnectors.svelte and the node <g> into StammbaumNode.svelte (which
now owns its own focus-ring state). StammbaumTree drops 546→308 lines and is now
an orchestrator: layout, gutter/reduced-motion state, viewBox, gestures, rail,
anchor. Rendered SVG is byte-identical, so the existing browser tests are
unchanged. Verified live: 62 nodes + 58 connector lines render, node-tap selects.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
marcel merged commit 8cc6031ef0 into main 2026-05-30 07:43:44 +02:00
marcel deleted branch feat/issue-692-stammbaum-mobile-panzoom 2026-05-30 07:43:45 +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#694