feat(stammbaum): mobile read path — pan, zoom, fit-to-view #692
Reference in New Issue
Block a user
Delete Branch "%!s()"
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?
Context
This issue captures a deliberate scope cut made in #361 (Stammbaum layout limits). The discussion there resolved that multi-spouse cases render all spouses adjacent on a single row, which makes rows physically wider than a 320 px viewport. Pan / zoom / fit-to-view were declared out of scope for #361 and parked here, so the deferred mobile experience is tracked rather than lost.
See #361 comment by Leonie (UX) for the design decisions that produced this dependency:
Vision
Make
/stammbauma viable read surface for the younger phone audience without compromising the desktop/tablet researcher experience that is currently the primary one.Personas
Jobs to be done
Story map
Activity 1 — Find a person on the tree (on a phone)
Activity 2 — Read a person's detail without losing the tree
Activity 3 — Recover from getting lost
MVP slice: US-PAN-001, US-PAN-002, US-PAN-004, US-PANEL-001 — the minimum that lets a phone reader actually use the tree.
User stories with acceptance criteria
US-PAN-001 — Pan the canvas by dragging
Story: As a phone reader, I want to drag the canvas with one finger, so that I can move around generations that extend beyond my viewport.
MoSCoW: Must · Kano: Basic
Acceptance criteria:
US-PAN-002 — Zoom out to fit more of the tree
Story: As a phone reader, I want to pinch the canvas to zoom out, so that I can see how a generation fits into the bigger family structure.
MoSCoW: Must · Kano: Performance
Acceptance criteria:
Ctrland scroll the mouse wheel, then the canvas zooms.+or-, then the canvas zooms in or out by a fixed step (OQ-002).US-PAN-003 — Zoom in to read dense generations
Story: As a phone reader, I want to zoom in on a dense row, so that I can read names and dates that are too small at default zoom.
MoSCoW: Must · Kano: Performance
Acceptance criteria:
US-PAN-004 — Fit-to-screen at any time
Story: As a phone reader, I want a "fit tree to screen" control, so that I can reset to a sensible starting view if I get lost.
MoSCoW: Must · Kano: Basic
Acceptance criteria:
prefers-reduced-motion: reduce).US-PAN-005 — Recentre on a chosen focal person
Story: As a phone reader, I want to tap "centre on this person" from a person panel, so that I can navigate the tree by jumping ancestor-to-ancestor.
MoSCoW: Should · Kano: Performance
Acceptance criteria:
US-PAN-006 — Discover that the canvas is interactive
Story: As a first-time phone reader, I want a visible cue that the tree can be dragged and pinched, so that I do not assume the canvas is a static image.
MoSCoW: Must · Kano: Basic
Acceptance criteria:
/stammbaumfor the first time on a touch device, when the canvas finishes loading, then a transient affordance indicates the tree is interactive (e.g., a "drag to explore · pinch to zoom" hint that auto-dismisses or a one-tap dismiss)./stammbaumon the same device, then it does not reappear (preference stored client-side).US-PANEL-001 — Person panel as a non-blocking overlay on phone
Story: As a phone reader, I want the person detail to appear as a bottom sheet (or equivalent overlay), so that I can read it without losing my place in the tree.
MoSCoW: Must · Kano: Performance
Acceptance criteria:
US-PANEL-002 — State preservation across panel open/close
Story: As a phone reader, I want my pan/zoom state to survive opening and closing the person panel, so that I can compare several people without losing context.
MoSCoW: Must · Kano: Basic
Acceptance criteria:
System-level rules (EARS)
+/-to zoom.prefers-reduced-motion: reduce, then the system shall not animate pan/zoom transitions and shall snap directly to the target state.Non-functional requirements
prefers-reduced-motion: reduce.Escapeshall dismiss it.{ viewport_width, node_count, edge_count }so the long-tail performance question can be answered from production data.Wireframe-vocabulary description (no design tokens)
+(zoom in),−(zoom out),⤢(fit to screen). Cluster docks to safe-area-inset on iOS.×to dismiss.Out of scope
Open questions / TBD register
?cx=…&cy=…&z=…? Or a single base-64 token?prefers-reduced-motionoverride)Constraints inherited from #361
These are technical guarantees the implementer (Felix) must honour when this issue is picked up — not requirements per se, but boundaries Leonie and the architect already agreed on:
node.generation != null→ fixed row) survives any pan/zoom layer.brand-navy); pan/zoom does not introduce a second hue.{@html …}on user-controlled strings — Svelte default escaping remains the only XSS guard for the SVG render path.Acceptance gate
This issue is "done" when:
— Drafted by Elicit (Requirements Engineer). Designs deferred to Leonie; layout/perf trade-offs deferred to Felix and Linus.
👨💻 Felix Brandt — Senior Fullstack Developer
Observations
+page.svelte:116–124):fixed inset-x-0 bottom-0 z-40 max-h-[60dvh] md:hidden. Missing: drag-handle grip, swipe-to-dismiss gesture, tap-outside dismiss, and focus trap (NFR-A11Y-004). These are all Must-have items.0.4–2.0(zoomOut/zoomInin+page.svelte:27–32). OQ-001 resolves to0.25–3.0. Named constantsMIN_ZOOM = 0.25/MAX_ZOOM = 3.0belong in a newpanZoom.tsmodule.overflow-autoon the container div (:95). For touch-drag + inertia (OQ-004: yes), pan state needs pointer-event handlers on the SVG wrapper, not just CSS overflow.panzoomby timmywil (v4.x, ~8 KB gzipped). Reasons: SVG-native, TypeScript types, touch pinch, mouse-wheel,onTransformcallback for URL sync, actively maintained. Avoids the 70 KB weight of d3-zoom. Per NFR-MAINT-001, pin to exact version and wrap in a thin$lib/person/genealogy/panZoom.tsabstraction so it can be feature-flag-swapped.panZoomState: { x, y, z }must live at the page level (+page.svelte) — not insideStammbaumTree. US-PANEL-002 requires the state to survive panel open/close, and OQ-003 requires it to drive?cx=…&cy=…&z=…URL params viareplaceState.{ viewport_width, node_count, edge_count }on mount"): a$effect(() => { /* fire once when layout stabilises */ })inStammbaumTree.sveltewith a fire-and-forgetfetchor structuredconsole.debughandles this cleanly.layoutis derived on mount. A$effectthat callspanzoom.fit()(or the equivalent) on first render is the right hook.+/-, OQ-002: 0.1× step): needs akeydownlistener on the SVG container wrapper, not on the individual node<g>elements — the nodes already useEnter/Spacefor selection.page.svelte.test.ts:47) — theclamps zoomtest covers repeated clicks but does not assert the actual zoom value. Worth tightening once the constants are extracted.Recommendations
MIN_ZOOM = 0.25,MAX_ZOOM = 3.0,ZOOM_STEP_KB = 0.1intopanZoom.ts. Both the page and any future library wrapper use these.let panZoomState = $state<{ x: number; y: number; z: number }>({ x: 0, y: 0, z: 1 })in+page.svelte. Bind it to URL params withreplaceStateinside$effect.$lib/shared/actions/— if notrapFocusaction exists, add one (it's ~30 lines). There's no existing focus-trap action in the codebase.const { default: Panzoom } = await import('panzoom')inside the$effectinit block — keeps the SSR bundle clean sinceStammbaumTreerenders server-side.Open Decisions
panzoomv4.x over d3-zoom or a custom implementation. Custom would avoid the dependency but the inertia (requestAnimationFrameloop) + pinch handling is ~300 lines that are hard to test thoroughly. Please confirm so the issue body OQ-007 row can be closed.🏛️ Markus Keller — Application Architect
Observations
StammbaumTree.svelteis server-rendered. Any panzoom library that accessesdocumentorwindowat module-import time will break SSR with aReferenceError. The library import must happen inside$effectoronMount(both are client-only). Verify this before the PR is opened.?cx=…&cy=…&z=…): SvelteKit'shistory.replaceState(viagoto('?cx=…', { replaceState: true, noScroll: true, keepFocus: true }) is the right mechanism — it updates the URL without creating a back-stack entry. The existing?focus=…param in+page.svelte:19already sets a precedent; the pan/zoom params sit alongside it.+page.server.ts(or the$derivedin the page) should parse and clampcx/cy/zbefore use. A crafted?z=Infinityor?cx=NaNflowing into the viewBox calculation will produce an invisible or crashing canvas.$lib/person/genealogy/panZoom.tsexports acreatePanZoom(el, state)function. Internally it imports the library. If the library is removed in future, only this module changes —StammbaumTree.svelteis unaffected.panZoomStatebelongs at the+page.sveltelevel (same level asselectedId). This guarantees US-PANEL-002 (state survives panel open/close) without prop-drilling —StammbaumTreereceivespanZoomStateas a prop and calls back viaonPanZoom.Recommendations
type PanZoomState = { x: number; y: number; z: number }inpanZoom.ts— the single authoritative shape used by URL serialisation, the library wrapper, and the test factory.+page.server.ts, readcx/cy/zfromurl.searchParamsand pass clamped defaults to the page data. This way a shared URL with out-of-range values degrades gracefully server-side, not with a client-side crash.docs/adr/ADR-0XX-stammbaum-panzoom-library.md) recording the library choice and the NFR-MAINT-001 reasoning. This is an architectural decision with lasting maintenance consequences.🔐 Nora "NullX" Steiner — Application Security Engineer
Observations
No new backend surface, so the threat model is narrow. Three things need attention.
1. URL param injection into SVG geometry (Medium)
OQ-003 resolves to
?cx=…&cy=…&z=…. These user-controlled floats will flow into the SVGviewBoxcalculation inStammbaumTree.svelte. FeedingInfinity,NaN,-999999, or1e308intoviewBoxcauses the SVG to render invisibly or as a blank rectangle — a trivial DoS on any user who opens a shared link.Fix: clamp before use.
Do this in
+page.server.tson the incomingurl.searchParams, not in the component.2. localStorage affordance flag (Low)
US-PAN-006 AC2 uses
localStorageto remember that the affordance was dismissed. The patternlocalStorage.setItem('stammbaumAffordanceDismissed', '1')is safe as long as the stored value is only used as a boolean gate, never rendered into the DOM. If the code ever doesel.innerHTML = localStorage.getItem(...), that becomes stored XSS. Enforce this in code review: the only valid read islocalStorage.getItem(key) === '1'.3. The #361
{@html}constraint (Confirmed safe)The issue lists this as an inherited constraint: "No
{@html}on user-controlled strings — Svelte default escaping is the only XSS guard for the SVG render path." I've checkedStammbaumTree.svelte— this constraint is currently honoured. The pan/zoom layer adds no new user-content interpolation, so the constraint remains satisfied as long as affordance hint text and control labels come from the i18n catalogue (Paraglide), not from user data.Recommendations
parsePanZoomParams({ z: 'Infinity' })returnsDEFAULT_ZOOM, and thatparsePanZoomParams({ z: 'NaN' })does the same. These are the two realistic attack inputs from a crafted URL.// boolean flag only — never rendered to DOMso a future reviewer doesn't accidentally change the pattern.🧪 Sara Holt — Senior QA Engineer
Observations
page.touchscreen.tap()covers single-touch; pinch requiresCDPSession.send('Input.dispatchTouchEvent', ...)which is fragile and version-sensitive. Recommendation: unit-test the state machine (zoom clamp, pan boundary, fit-to-screen calculation) in vitest against thepanZoom.tsmodule, and use Playwright only for visual regression and the fit-to-screen button interaction. This avoids brittle CDPSession hacks.beforeEachfor any test that visits/stammbaumand checks the affordance. Without this, tests are order-dependent: the first test dismisses the affordance, all subsequent tests never see it.viewBoxor CSS transform matches the pre-open value.page.svelte.test.ts) tests zoom button visibility and the focus preselect, which is good baseline coverage. The new work adds URL-param-driven initial state and the bottom-sheet dismiss paths — both need new test cases.Recommendations
Test strategy per layer:
parsePanZoomParams(clamping, NaN, Infinity),calculateFitToScreen(returns correct zoom for given node bounds),MIN_ZOOM/MAX_ZOOMconstantsStammbaumTree: keyboard+/-changes viewBox;showGutterprop still works; bottom-sheet appears on node click at 375px viewport widthdata-testid="fit-to-screen"to the fit-to-screen button so selectors don't break when i18n label text changes.@Disabledtest with a linked ticket is acceptable; a silent skip is not.Open Decisions
🎨 Leonie Voss — UX Designer & Accessibility Strategist
Observations
Control placement mismatch
The wireframe specifies the zoom cluster (+/−/⤢) in the bottom-right of the canvas, docked to
safe-area-inset-bottomon iOS. The current+page.svelte(lines 44–62) places the zoom buttons in the page header (top-right). For one-handed phone use, bottom-right is the primary reachable zone; the header is the hardest to reach on a phone being held naturally. This placement must change as part of this issue, not be left for a follow-up.Bottom sheet gaps
The existing bottom sheet (
+page.svelte:116–124) has the right proportions (max-h-[60dvh]) and correct breakpoint (md:hidden). It is missing:<div class="mx-auto mt-2 mb-1 h-1 w-10 rounded-full bg-line" aria-hidden="true" />at the sheet's top edgepointerdown/pointermovehandler that closes the sheet when dragged down by ≥80px<div>behind the sheet that callsonCloserole="dialog"+aria-label: the wrapper div needsrole="dialog" aria-labelledby="panel-title". The person name in the panel content should carryid="panel-title".Escapedismiss: both are NFR-A11Y-004 and WCAG 2.1 requirements. Without these, keyboard users and screen-reader users are stranded in the panel.Touch targets
Current zoom buttons are
h-11 w-11(44px) — meets the NFR-A11Y-001 minimum. The fit-to-screen button (not yet present) must also be 44×44 minimum, 48×48 preferred. All three controls in the bottom-right cluster need adequate spacing between them (4px minimum) to avoid mis-taps.Discovery affordance (US-PAN-006)
The "drag to explore · pinch to zoom" hint should auto-dismiss on the first
pointerdownevent on the canvas — not require an explicit × tap. Requiring a tap to dismiss is a friction point for seniors. The explicit × button should remain as a secondary dismiss path. US-PAN-006 AC3 ("edge gradient after 10s") is currently listed under Must-have but reads like a Should — see the requirements review comment for this.Reduced motion
All transitions — bottom sheet slide-in, fit-to-screen animate, inertia decay — must snap instantly when
prefers-reduced-motion: reduceis detected. This is already specified in REQ-PAN-005. The pan/zoom composable should readwindow.matchMedia('(prefers-reduced-motion: reduce)').matchesand skip all animation when true.Edge-fade gradient
The wireframe describes a 24px fade at canvas edges where content overflows. CSS
mask-image: linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), transparent)on the canvas container achieves this with zero JS.Recommendations
position: absoluteoverlay inside the canvas container:bottom: max(env(safe-area-inset-bottom, 0px) + 1rem, 1rem); right: 1rem;— three buttons stacked vertically withgap-1.de/en/esbefore implementation:stammbaum_fit_to_screen,stammbaum_affordance_hint("Ziehen zum Erkunden · Zusammendrücken zum Zoomen"),stammbaum_affordance_dismiss,stammbaum_close_panel.zoomlabel buttons currently use−(ASCII hyphen-minus) for zoom-out. Replace with−(U+2212 MINUS SIGN) for typographic correctness — or use SVG icons that aren't ambiguous on screen readers.Open Decisions
🛠️ Tobias Wendt — DevOps & Platform Engineer
Observations
No infrastructure changes required — this is entirely a frontend concern. Docker Compose, CI pipeline, and server config are unaffected.
Bundle size
Adding
panzoom(timmywil v4.x, ~8 KB gzipped) increases the frontend bundle. Runnpm run buildbefore and after to confirm the delta is acceptable. 8 KB is fine; it should be lazy-imported so it stays out of the SSR bundle:If the build output shows it in the SSR chunk, something is wrong with the import path.
NFR-OBS-001 — structured canvas-mount event
The requirement says "log a single structured event per canvas-mount with
{ viewport_width, node_count, edge_count }." There are two valid implementation paths, and they are not equivalent:console.debug(JSON.stringify(...))fetch('/api/telemetry/canvas-mount', ...){app="familienarchiv"}For this NFR to be useful ("answer the long-tail performance question from production data"), the event needs to reach Loki. The simplest path: a new
POST /api/telemetry/canvas-mountendpoint in the backend that accepts{ viewportWidth, nodeCount, edgeCount }and logs it as a structured JSON line via SLF4J. No auth required (it's internal telemetry). Alternatively, GlitchTip can receive this as a breadcrumb — but that conflates error tracking with analytics.I'd suggest clarifying the intent before implementation so Felix builds the right thing on both ends.
i18n
New Paraglide message keys will be compiled automatically by the Vite plugin during
npm run dev. CI runsnpm run checkwhich will fail if a key is referenced in code but missing from any of the three catalogues (de/en/es). This is the right gate — no additional CI step needed.No Flyway migration — correct. Pan/zoom state is URL-only and localStorage; nothing persists in the database.
Recommendations
package.json(e.g."panzoom": "4.5.1") per NFR-MAINT-001 — not a range.console.debug, document why that's sufficient.Open Decisions
console.debugonly. The right choice depends on whether the performance question is production-answerable or dev-only. Please decide so Felix knows what to build.📋 Elicit — Requirements Engineer
Observations
OQ resolutions must be folded into the issue body before implementation
The user has resolved OQ-001 through OQ-008 with the recommended defaults. Per project convention, the acceptance gate requires the issue body to reflect final OQ resolutions. OQ-007 (library choice) is the one remaining OQ that awaits Felix's final answer — once that lands, all eight rows can be marked resolved in the table.
Gap: US-PAN-005 button placement is undefined
The wireframe vocabulary section describes the bottom-right zoom cluster (+/−/⤢) and the bottom sheet structure (title row + close control). It does not say where the "centre on this person" action (US-PAN-005) lives inside the person panel. The desktop side panel and the bottom sheet both need this button, and its placement affects the layout of
StammbaumSidePanel.svelte. This is a missing UI placement decision that will block Felix during implementation.Gap: US-PANEL-002 AC2 does not name the URL params
OQ-003 resolves to
?cx=…&cy=…&z=…but this resolution is not reflected in the acceptance criteria. AC2 currently reads: "when they open it, they see the same starting view." This is untestable as written. It should read: "when shared via URL containing?cx={float}&cy={float}&z={float}params, the recipient sees the same pan/zoom state."Gap: NFR-PERF-001 verification path is undefined
The acceptance gate lists this NFR as a required check, but there is no named owner, no named device, and no documented benchmark procedure. Without these, the gate will be silently skipped. The NFR should include:
Verified by: Marcel on [device model] using [tool] before closing issue.Gap: "canonical fixture" for NFR-PERF-001 is unnamed
NFR-PERF-001 references "the canonical fixture's tree size" but no fixture is named. The vitest component tests for
StammbaumTreealmost certainly define a fixture — its node and edge count should be explicitly stated here (e.g., "the 12-node, 10-edge fixture inStammbaumTree.svelte.test.ts").MoSCoW misclassification: US-PAN-006 AC3
AC3 ("after 10 seconds without interaction, a low-contrast scroll-direction cue appears at the canvas edge") is listed under the Must-have user story US-PAN-006, but it is a conditional enhancement — it only triggers after 10 idle seconds and is a "low-contrast" cue. This is a Kano Delighter and a MoSCoW Should, not a Must. Classifying it as Must forces it into the MVP slice alongside the basic pan interaction.
Recommendations
Before implementation starts, update the issue body with:
Resolved: [default value]; mark OQ-007 asResolved: [library name]once Felix confirms.?cx=…&cy=…&z=…URL state produces the same canvas position and zoom level."🗳️ Decision Queue — Action Required
5 decisions need your input before implementation starts.
Architecture
panzoomby timmywil (v4.x, ~8 KB gzipped) — SVG-native, TypeScript support, touch pinch, mouse-wheel, inertia,onTransformcallback for URL sync. Alternative is a customrequestAnimationFrameloop (~300 lines, no dependency). Please confirm the library so OQ-007 can be closed in the issue body. (Raised by: Felix)Observability
{ viewport_width, node_count, edge_count }on canvas-mount" is only useful in production if it reaches Loki. Three options: (a) newPOST /api/telemetry/canvas-mountbackend endpoint → Loki/Grafana queryable; (b) GlitchTip breadcrumb (mixes analytics with error tracking); (c)console.debugonly (dev-only, disappears in production). Option (a) means Felix also builds a small backend endpoint. Please decide which path is intended. (Raised by: Tobias)UX / Requirements
US-PAN-005 "centre on this person" button placement: the wireframe vocabulary section describes the bottom-right zoom cluster and the bottom-sheet chrome but does not specify where this action lives in the person panel (either the desktop side panel or the mobile bottom sheet). Without this, Felix cannot implement
StammbaumSidePanel.sveltefor this feature. (Raised by: Elicit)US-PAN-006 AC3 MoSCoW classification: AC3 ("after 10 seconds without interaction, a low-contrast cue appears at canvas edge") is currently grouped under a Must-have story but is a Kano Delighter — it only triggers after an idle timeout and is deliberately low-contrast. Leonie also notes that a permanent CSS
mask-imageedge-fade (zero JS) would make the timer redundant. Options: (a) reclassify as Should and keep the 10s timer; (b) replace with permanent CSS edge-fade and remove from Must gate; (c) drop entirely. (Raised by: Leonie, Elicit)QA