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

Open
opened 2026-05-28 18:41:45 +02:00 by marcel · 8 comments
Owner

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:

  • AC1 commits to all-spouses-adjacent on one row (option a).
  • The 320 px snapshot in #361 only catches regressions — it does not "solve" mobile.
  • The side panel's mobile behaviour was also deferred to this issue.

Vision

Make /stammbaum a viable read surface for the younger phone audience without compromising the desktop/tablet researcher experience that is currently the primary one.

Personas

Persona Role Device Tech (1–5) Primary goal Top frustration today
Senior researcher (60+) Family transcriber & reader Laptop / 10″ tablet 2–3 Read full family context while transcribing None for Stammbaum — this audience is already served on desktop/tablet
Younger reader (25–42) Family member, casually exploring Phone (≤ 414 px) 4 "Show me how I'm related to grandpa Albert" in under a minute Tree is clipped at viewport edge; only sees the first 1–2 nodes; cannot reach ancestors off-screen

Jobs to be done

  • JTBD-1 — Phone reader: When I open the family tree on my phone, I want to navigate freely around large generations, so I can see ancestors who would otherwise be off-screen.
  • JTBD-2 — Phone reader: When I find a person on the tree, I want to read their details without losing my place in the tree, so I can keep exploring afterward.
  • JTBD-3 — Senior researcher: When I open the tree on a 10″ tablet, I want the same navigation gestures I use everywhere else, so I don't have to learn a new interaction.

Story map

Activity 1 — Find a person on the tree (on a phone)

  • US-PAN-001 — pan the canvas with one finger
  • US-PAN-002 — zoom out to see the whole tree at once
  • US-PAN-003 — zoom in to read names and dates on a dense generation
  • US-PAN-004 — reset to a sensible starting view ("fit to screen")

Activity 2 — Read a person's detail without losing the tree

  • US-PANEL-001 — open the person panel as an overlay that does not consume the tree's space
  • US-PANEL-002 — dismiss the panel and return to the same pan/zoom state

Activity 3 — Recover from getting lost

  • US-PAN-005 — recentre on a chosen focal person at any time
  • US-PAN-006 — discover that the canvas is interactive (affordance)

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:

  1. Given the tree is wider than the viewport, when I touch-drag horizontally, then the canvas follows my finger with no perceptible lag.
  2. Given the tree is taller than the viewport, when I touch-drag vertically, then the canvas follows my finger vertically.
  3. Given I lift my finger after a drag, when OQ-004 is resolved as "with inertia", then the canvas continues with momentum and decays to a stop. Otherwise: motion stops immediately on finger-up.
  4. Given the canvas reaches its edge, when I continue dragging in that direction, then the canvas stops at the edge (no infinite scroll).
  5. Given I am using a desktop mouse, when I left-click-and-drag the canvas, then the same panning behaviour applies.

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:

  1. Given the canvas is at default zoom, when I pinch with two fingers, then the canvas scales smoothly between configured min and max levels (OQ-001).
  2. Given I am on a desktop, when I hold Ctrl and scroll the mouse wheel, then the canvas zooms.
  3. Given I am using a keyboard, when I press + or -, then the canvas zooms in or out by a fixed step (OQ-002).
  4. Given the canvas is at max or min zoom, when I try to zoom further, then the action has no effect and is not signalled as an error.
  5. Given I have zoomed in, when I read a node's name, then the text remains legible (no pixelation; SVG-native scaling).

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:

  1. Given I pinch-zoom-in, when the gesture lands, then the zoom centre is the midpoint between my two fingers (not the canvas centre).
  2. Given the zoom level exceeds 100 %, when I pan, then US-PAN-001 still applies at the higher zoom.

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:

  1. Given the canvas is interactive, when the page first loads, then the initial view is "fit to screen" (the whole tree visible at once on the current viewport).
  2. Given I am at any zoom/pan state, when I tap the "fit to screen" control, then the canvas animates back to the fit-to-screen state in ≤ 300 ms (or instantly if prefers-reduced-motion: reduce).
  3. Given the tree is small enough to fit at 100 % zoom, when the page loads, then the canvas stays at 100 % (no down-scaling for the sake of fitting).

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:

  1. Given I have opened a person panel (US-PANEL-001), when I tap "centre on this person", then the canvas animates so that the chosen node is in the visual centre of the viewport at the current zoom level.
  2. Given I am zoomed out beyond a threshold, when I recentre on a person, then the canvas also zooms to a level where the node's text is legible (OQ-005).

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:

  1. Given I land on /stammbaum for 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).
  2. Given I have dismissed the affordance once, when I return to /stammbaum on the same device, then it does not reappear (preference stored client-side).
  3. Given I have never interacted with the canvas, when 10 seconds pass without interaction, then a low-contrast scroll-direction cue appears at the canvas edge (e.g., a fade-out gradient).

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:

  1. Given I tap a node at viewport width ≤ 768 px, when the person detail loads, then it appears as a bottom sheet covering the lower half of the viewport (OQ-006 may revise to side-drawer).
  2. Given the bottom sheet is open, when I swipe it down OR tap the close control, then the sheet dismisses and the tree remains at its prior pan/zoom state.
  3. Given the bottom sheet is open, when I tap outside the sheet, then the sheet dismisses (same outcome as the explicit close).
  4. Given viewport width > 768 px, when I tap a node, then the existing desktop side-panel behaviour applies (no change to today).
  5. Given the sheet is open, when screen-reader focus is set, then focus moves to the sheet's first focusable element and is trapped within the sheet until dismissal.

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:

  1. Given I have panned and zoomed to a specific position, when I open and close a person panel, then the canvas returns to the exact same pan/zoom state.
  2. Given the URL contains a zoom/pan state, when I share the URL with a family member and they open it, then they see the same starting view as me.

System-level rules (EARS)

  • REQ-PAN-001 (Event): When the user performs a single-finger drag on the canvas at viewport width ≤ 768 px, the system shall translate the canvas by the drag delta.
  • REQ-PAN-002 (Event): When the user performs a two-finger pinch on the canvas, the system shall scale the canvas around the centroid of the two touch points.
  • REQ-PAN-003 (State): While the canvas is at a non-default pan/zoom state, the system shall display a visible "fit to screen" control.
  • REQ-PAN-004 (Optional): Where the user agent supports keyboard input, the system shall map arrow keys to pan and + / - to zoom.
  • REQ-PAN-005 (Unwanted): If the user agent reports prefers-reduced-motion: reduce, then the system shall not animate pan/zoom transitions and shall snap directly to the target state.
  • REQ-PANEL-001 (Event): When the user taps a node at viewport width ≤ 768 px, the system shall open the person panel as a bottom sheet overlay.
  • REQ-PANEL-002 (Ubiquitous): The person-panel overlay shall trap keyboard focus until dismissed.

Non-functional requirements

ID Category Statement
NFR-PERF-001 Performance Pan and zoom shall sustain ≥ 50 fps on a mid-tier 2022 Android device (Snapdragon 6-series equivalent) at the canonical fixture's tree size.
NFR-PERF-002 Performance Initial fit-to-screen render after canvas mount shall complete in ≤ 200 ms on the same device.
NFR-A11Y-001 Accessibility All new controls (fit-to-screen, zoom +/–, panel close) shall meet WCAG 2.5.5 AA touch-target size (24 × 24 minimum; 44 × 44 preferred).
NFR-A11Y-002 Accessibility All pan/zoom interactions shall have a keyboard-only alternative path; the page shall pass an axe-core sweep in both light and dark mode.
NFR-A11Y-003 Accessibility All transient animations (US-PAN-004 AC2, US-PANEL-001 enter/exit) shall respect prefers-reduced-motion: reduce.
NFR-A11Y-004 Accessibility The bottom-sheet panel shall be announced by screen readers as a dialog with accessible name, and Escape shall dismiss it.
NFR-USE-001 Usability First-time interactive affordance (US-PAN-006) shall be dismissible in one action and shall not reappear within a 30-day window once dismissed.
NFR-RESP-001 Responsiveness The interactive canvas behaviour shall apply at viewport widths 320 / 375 / 414 / 768 px and shall degrade gracefully (no JavaScript errors) at 1024 / 1440 px.
NFR-I18N-001 Localization All new visible strings (zoom controls, affordance hint, panel chrome, keyboard help) shall be added to de/en/es message catalogues.
NFR-COMPAT-001 Compatibility Touch gestures shall work on Safari iOS 16+, Chrome Android 110+, and Firefox Android 110+.
NFR-OBS-001 Observability The system shall log a single structured event per canvas-mount with { viewport_width, node_count, edge_count } so the long-tail performance question can be answered from production data.
NFR-MAINT-001 Maintainability If a third-party library is adopted for pan/zoom, it shall be pinned to an exact version and gated behind a feature-flag-style fallback to the unenhanced canvas.

Wireframe-vocabulary description (no design tokens)

  • Canvas region fills the available space below the page header.
  • Bottom-right of the canvas: a vertical cluster of three controls — + (zoom in), (zoom out), (fit to screen). Cluster docks to safe-area-inset on iOS.
  • First-load overlay: a single-line hint at the bottom of the canvas — "drag to explore · pinch to zoom" — with a small × to dismiss.
  • Person panel (phone, ≤ 768 px): appears as a bottom sheet covering ~50–60 % of viewport height, with a drag-handle grip at the top edge for swipe-dismiss. Title row contains person name + close control. Content scrolls within the sheet.
  • Person panel (tablet/desktop, > 768 px): unchanged — existing right-docked side panel.
  • Edge fade affordance: when canvas content extends beyond a viewport edge, a 24-px-wide gradient at that edge cues that more content exists in that direction.

Out of scope

  • Drag-to-rearrange the tree — layout is read-only.
  • Persist per-user pan/zoom state across sessions — only URL-shareable state (US-PANEL-002 AC2) is in scope.
  • Mini-map / overview rectangle — defer until tree exceeds ~150 visible nodes.
  • Multi-tree split-view — separate feature request.
  • Marriage-dot interactive enlargement (44 × 44 hit ring) — covered by #361's design note; only triggered when the dot becomes interactive (not part of this issue).
  • Pinch-to-zoom on the person-panel content — sheet scrolls vertically only.

Open questions / TBD register

ID Question Why it matters Blocks Recommended default Owner
OQ-001 What are min and max zoom levels? Suggest 0.25× to 3.0×. Constrains AC of US-PAN-002. US-PAN-002 AC1 0.25× ↔ 3.0× Marcel
OQ-002 What is the keyboard zoom step? Suggest 0.1× per keypress. Constrains AC of US-PAN-002 AC3. US-PAN-002 AC3 0.1× Marcel
OQ-003 URL-encoded pan/zoom state — what shape? ?cx=…&cy=…&z=…? Or a single base-64 token? Affects shareable-link UX and analytics. US-PANEL-002 AC2 Three query params Marcel
OQ-004 Pan inertia on touch-release: yes or no? Inertia feels native but adds complexity; without it, the canvas snaps to a halt. US-PAN-001 AC3 Yes (with prefers-reduced-motion override) Leonie
OQ-005 When recentring on a person, do we also auto-zoom-in if currently zoomed out? Affects whether US-PAN-005 produces a useful view from "fit-to-screen". US-PAN-005 AC2 Yes — clamp to minimum legible zoom (TBD threshold) Leonie
OQ-006 Bottom sheet vs side drawer vs full-screen modal for the person panel on phone? Each pattern has different one-handed-reachability and IA implications. US-PANEL-001 AC1 Bottom sheet Leonie
OQ-007 Library choice (Felix's call) — adopt an existing pan/zoom library (e.g. svg-pan-zoom, panzoom), build in-house, or hybrid? Affects bundle size and NFR-MAINT-001; implementation decision flagged for visibility. NFR-MAINT-001 Implementation decision Felix
OQ-008 Should the first-load affordance (US-PAN-006) appear on desktop too, or only on touch devices? Adds noise on desktop where drag-to-pan is less of a discovery problem. US-PAN-006 AC1 Touch devices only Leonie

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:

  • The seeded-rank invariant (node.generation != null → fixed row) survives any pan/zoom layer.
  • The 320 / 768 / 1440 visual-regression snapshots from #361 stay green; new pan/zoom interactions add their own snapshots rather than replacing.
  • All connectors remain single-colour (brand-navy); pan/zoom does not introduce a second hue.
  • The marriage-dot stays at the dimensions set by #361 (12 px); pan/zoom does not bump it.
  • No {@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:

  • All Must-have stories above pass their acceptance criteria.
  • All NFRs marked Performance and Accessibility are verified (axe-core + a benchmark on a mid-tier Android).
  • All TBDs are resolved or explicitly re-scoped.
  • A Playwright visual-regression snapshot exists at 320 / 414 / 768 px showing the new affordance + bottom-sheet state.
  • The issue body is updated with the final OQ resolutions (per project convention).

Drafted by Elicit (Requirements Engineer). Designs deferred to Leonie; layout/perf trade-offs deferred to Felix and Linus.

## 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)](https://git.raddatz.cloud/marcel/familienarchiv/issues/361#issuecomment-12718) for the design decisions that produced this dependency: - AC1 commits to all-spouses-adjacent on one row (option a). - The 320 px snapshot in #361 only catches *regressions* — it does not "solve" mobile. - The side panel's mobile behaviour was also deferred to this issue. ## Vision Make `/stammbaum` a viable read surface for the younger phone audience without compromising the desktop/tablet researcher experience that is currently the primary one. ## Personas | Persona | Role | Device | Tech (1–5) | Primary goal | Top frustration today | |---|---|---|---|---|---| | **Senior researcher** (60+) | Family transcriber & reader | Laptop / 10″ tablet | 2–3 | Read full family context while transcribing | None for Stammbaum — this audience is already served on desktop/tablet | | **Younger reader** (25–42) | Family member, casually exploring | Phone (≤ 414 px) | 4 | "Show me how I'm related to grandpa Albert" in under a minute | Tree is clipped at viewport edge; only sees the first 1–2 nodes; cannot reach ancestors off-screen | ## Jobs to be done - **JTBD-1 — Phone reader:** When I open the family tree on my phone, I want to navigate freely around large generations, so I can see ancestors who would otherwise be off-screen. - **JTBD-2 — Phone reader:** When I find a person on the tree, I want to read their details without losing my place in the tree, so I can keep exploring afterward. - **JTBD-3 — Senior researcher:** When I open the tree on a 10″ tablet, I want the same navigation gestures I use everywhere else, so I don't have to learn a new interaction. ## Story map **Activity 1 — Find a person on the tree (on a phone)** - US-PAN-001 — pan the canvas with one finger - US-PAN-002 — zoom out to see the whole tree at once - US-PAN-003 — zoom in to read names and dates on a dense generation - US-PAN-004 — reset to a sensible starting view ("fit to screen") **Activity 2 — Read a person's detail without losing the tree** - US-PANEL-001 — open the person panel as an overlay that does not consume the tree's space - US-PANEL-002 — dismiss the panel and return to the same pan/zoom state **Activity 3 — Recover from getting lost** - US-PAN-005 — recentre on a chosen focal person at any time - US-PAN-006 — discover that the canvas is interactive (affordance) **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:** 1. **Given** the tree is wider than the viewport, **when** I touch-drag horizontally, **then** the canvas follows my finger with no perceptible lag. 2. **Given** the tree is taller than the viewport, **when** I touch-drag vertically, **then** the canvas follows my finger vertically. 3. **Given** I lift my finger after a drag, **when** OQ-004 is resolved as "with inertia", **then** the canvas continues with momentum and decays to a stop. **Otherwise**: motion stops immediately on finger-up. 4. **Given** the canvas reaches its edge, **when** I continue dragging in that direction, **then** the canvas stops at the edge (no infinite scroll). 5. **Given** I am using a desktop mouse, **when** I left-click-and-drag the canvas, **then** the same panning behaviour applies. --- ### 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:** 1. **Given** the canvas is at default zoom, **when** I pinch with two fingers, **then** the canvas scales smoothly between configured min and max levels (OQ-001). 2. **Given** I am on a desktop, **when** I hold `Ctrl` and scroll the mouse wheel, **then** the canvas zooms. 3. **Given** I am using a keyboard, **when** I press `+` or `-`, **then** the canvas zooms in or out by a fixed step (OQ-002). 4. **Given** the canvas is at max or min zoom, **when** I try to zoom further, **then** the action has no effect and is not signalled as an error. 5. **Given** I have zoomed in, **when** I read a node's name, **then** the text remains legible (no pixelation; SVG-native scaling). --- ### 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:** 1. **Given** I pinch-zoom-in, **when** the gesture lands, **then** the zoom centre is the midpoint between my two fingers (not the canvas centre). 2. **Given** the zoom level exceeds 100 %, **when** I pan, **then** US-PAN-001 still applies at the higher zoom. --- ### 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:** 1. **Given** the canvas is interactive, **when** the page first loads, **then** the initial view is "fit to screen" (the whole tree visible at once on the current viewport). 2. **Given** I am at any zoom/pan state, **when** I tap the "fit to screen" control, **then** the canvas animates back to the fit-to-screen state in ≤ 300 ms (or instantly if `prefers-reduced-motion: reduce`). 3. **Given** the tree is small enough to fit at 100 % zoom, **when** the page loads, **then** the canvas stays at 100 % (no down-scaling for the sake of fitting). --- ### 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:** 1. **Given** I have opened a person panel (US-PANEL-001), **when** I tap "centre on this person", **then** the canvas animates so that the chosen node is in the visual centre of the viewport at the current zoom level. 2. **Given** I am zoomed out beyond a threshold, **when** I recentre on a person, **then** the canvas also zooms to a level where the node's text is legible (OQ-005). --- ### 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:** 1. **Given** I land on `/stammbaum` for 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). 2. **Given** I have dismissed the affordance once, **when** I return to `/stammbaum` on the same device, **then** it does not reappear (preference stored client-side). 3. **Given** I have never interacted with the canvas, **when** 10 seconds pass without interaction, **then** a low-contrast scroll-direction cue appears at the canvas edge (e.g., a fade-out gradient). --- ### 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:** 1. **Given** I tap a node at viewport width ≤ 768 px, **when** the person detail loads, **then** it appears as a bottom sheet covering the lower half of the viewport (OQ-006 may revise to side-drawer). 2. **Given** the bottom sheet is open, **when** I swipe it down OR tap the close control, **then** the sheet dismisses and the tree remains at its prior pan/zoom state. 3. **Given** the bottom sheet is open, **when** I tap outside the sheet, **then** the sheet dismisses (same outcome as the explicit close). 4. **Given** viewport width > 768 px, **when** I tap a node, **then** the existing desktop side-panel behaviour applies (no change to today). 5. **Given** the sheet is open, **when** screen-reader focus is set, **then** focus moves to the sheet's first focusable element and is trapped within the sheet until dismissal. --- ### 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:** 1. **Given** I have panned and zoomed to a specific position, **when** I open and close a person panel, **then** the canvas returns to the exact same pan/zoom state. 2. **Given** the URL contains a zoom/pan state, **when** I share the URL with a family member and they open it, **then** they see the same starting view as me. ## System-level rules (EARS) - **REQ-PAN-001 (Event):** When the user performs a single-finger drag on the canvas at viewport width ≤ 768 px, the system shall translate the canvas by the drag delta. - **REQ-PAN-002 (Event):** When the user performs a two-finger pinch on the canvas, the system shall scale the canvas around the centroid of the two touch points. - **REQ-PAN-003 (State):** While the canvas is at a non-default pan/zoom state, the system shall display a visible "fit to screen" control. - **REQ-PAN-004 (Optional):** Where the user agent supports keyboard input, the system shall map arrow keys to pan and `+` / `-` to zoom. - **REQ-PAN-005 (Unwanted):** If the user agent reports `prefers-reduced-motion: reduce`, then the system shall not animate pan/zoom transitions and shall snap directly to the target state. - **REQ-PANEL-001 (Event):** When the user taps a node at viewport width ≤ 768 px, the system shall open the person panel as a bottom sheet overlay. - **REQ-PANEL-002 (Ubiquitous):** The person-panel overlay shall trap keyboard focus until dismissed. ## Non-functional requirements | ID | Category | Statement | |---|---|---| | NFR-PERF-001 | Performance | Pan and zoom shall sustain ≥ 50 fps on a mid-tier 2022 Android device (Snapdragon 6-series equivalent) at the canonical fixture's tree size. | | NFR-PERF-002 | Performance | Initial fit-to-screen render after canvas mount shall complete in ≤ 200 ms on the same device. | | NFR-A11Y-001 | Accessibility | All new controls (fit-to-screen, zoom +/–, panel close) shall meet WCAG 2.5.5 AA touch-target size (24 × 24 minimum; 44 × 44 preferred). | | NFR-A11Y-002 | Accessibility | All pan/zoom interactions shall have a keyboard-only alternative path; the page shall pass an axe-core sweep in both light and dark mode. | | NFR-A11Y-003 | Accessibility | All transient animations (US-PAN-004 AC2, US-PANEL-001 enter/exit) shall respect `prefers-reduced-motion: reduce`. | | NFR-A11Y-004 | Accessibility | The bottom-sheet panel shall be announced by screen readers as a dialog with accessible name, and `Escape` shall dismiss it. | | NFR-USE-001 | Usability | First-time interactive affordance (US-PAN-006) shall be dismissible in one action and shall not reappear within a 30-day window once dismissed. | | NFR-RESP-001 | Responsiveness | The interactive canvas behaviour shall apply at viewport widths 320 / 375 / 414 / 768 px and shall degrade gracefully (no JavaScript errors) at 1024 / 1440 px. | | NFR-I18N-001 | Localization | All new visible strings (zoom controls, affordance hint, panel chrome, keyboard help) shall be added to de/en/es message catalogues. | | NFR-COMPAT-001 | Compatibility | Touch gestures shall work on Safari iOS 16+, Chrome Android 110+, and Firefox Android 110+. | | NFR-OBS-001 | Observability | The system shall log a single structured event per canvas-mount with `{ viewport_width, node_count, edge_count }` so the long-tail performance question can be answered from production data. | | NFR-MAINT-001 | Maintainability | If a third-party library is adopted for pan/zoom, it shall be pinned to an exact version and gated behind a feature-flag-style fallback to the unenhanced canvas. | ## Wireframe-vocabulary description (no design tokens) - **Canvas region** fills the available space below the page header. - **Bottom-right of the canvas:** a vertical cluster of three controls — `+` (zoom in), `−` (zoom out), `⤢` (fit to screen). Cluster docks to safe-area-inset on iOS. - **First-load overlay:** a single-line hint at the bottom of the canvas — "drag to explore · pinch to zoom" — with a small `×` to dismiss. - **Person panel (phone, ≤ 768 px):** appears as a bottom sheet covering ~50–60 % of viewport height, with a drag-handle grip at the top edge for swipe-dismiss. Title row contains person name + close control. Content scrolls within the sheet. - **Person panel (tablet/desktop, > 768 px):** unchanged — existing right-docked side panel. - **Edge fade affordance:** when canvas content extends beyond a viewport edge, a 24-px-wide gradient at that edge cues that more content exists in that direction. ## Out of scope - **Drag-to-rearrange the tree** — layout is read-only. - **Persist per-user pan/zoom state across sessions** — only URL-shareable state (US-PANEL-002 AC2) is in scope. - **Mini-map / overview rectangle** — defer until tree exceeds ~150 visible nodes. - **Multi-tree split-view** — separate feature request. - **Marriage-dot interactive enlargement (44 × 44 hit ring)** — covered by #361's design note; only triggered when the dot becomes interactive (not part of this issue). - **Pinch-to-zoom on the person-panel content** — sheet scrolls vertically only. ## Open questions / TBD register | ID | Question | Why it matters | Blocks | Recommended default | Owner | |---|---|---|---|---|---| | OQ-001 | What are min and max zoom levels? Suggest 0.25× to 3.0×. | Constrains AC of US-PAN-002. | US-PAN-002 AC1 | 0.25× ↔ 3.0× | Marcel | | OQ-002 | What is the keyboard zoom step? Suggest 0.1× per keypress. | Constrains AC of US-PAN-002 AC3. | US-PAN-002 AC3 | 0.1× | Marcel | | OQ-003 | URL-encoded pan/zoom state — what shape? `?cx=…&cy=…&z=…`? Or a single base-64 token? | Affects shareable-link UX and analytics. | US-PANEL-002 AC2 | Three query params | Marcel | | OQ-004 | Pan inertia on touch-release: yes or no? | Inertia feels native but adds complexity; without it, the canvas snaps to a halt. | US-PAN-001 AC3 | Yes (with `prefers-reduced-motion` override) | Leonie | | OQ-005 | When recentring on a person, do we also auto-zoom-in if currently zoomed out? | Affects whether US-PAN-005 produces a useful view from "fit-to-screen". | US-PAN-005 AC2 | Yes — clamp to minimum legible zoom (TBD threshold) | Leonie | | OQ-006 | Bottom sheet vs side drawer vs full-screen modal for the person panel on phone? | Each pattern has different one-handed-reachability and IA implications. | US-PANEL-001 AC1 | Bottom sheet | Leonie | | OQ-007 | Library choice (Felix's call) — adopt an existing pan/zoom library (e.g. svg-pan-zoom, panzoom), build in-house, or hybrid? | Affects bundle size and NFR-MAINT-001; implementation decision flagged for visibility. | NFR-MAINT-001 | Implementation decision | Felix | | OQ-008 | Should the first-load affordance (US-PAN-006) appear on desktop too, or only on touch devices? | Adds noise on desktop where drag-to-pan is less of a discovery problem. | US-PAN-006 AC1 | Touch devices only | Leonie | ## 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: - The seeded-rank invariant (`node.generation != null` → fixed row) survives any pan/zoom layer. - The 320 / 768 / 1440 visual-regression snapshots from #361 stay green; new pan/zoom interactions add their own snapshots rather than replacing. - All connectors remain single-colour (`brand-navy`); pan/zoom does not introduce a second hue. - The marriage-dot stays at the dimensions set by #361 (12 px); pan/zoom does not bump it. - No `{@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: - All Must-have stories above pass their acceptance criteria. - All NFRs marked Performance and Accessibility are verified (axe-core + a benchmark on a mid-tier Android). - All TBDs are resolved or explicitly re-scoped. - A Playwright visual-regression snapshot exists at 320 / 414 / 768 px showing the new affordance + bottom-sheet state. - The issue body is updated with the final OQ resolutions (per project convention). — *Drafted by Elicit (Requirements Engineer). Designs deferred to Leonie; layout/perf trade-offs deferred to Felix and Linus.*
marcel added the P2-mediumepicfeatureneeds-discussionui labels 2026-05-28 18:41:55 +02:00
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Observations

  • Bottom sheet is already partially there (+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.
  • Zoom range mismatch: current code clamps to 0.4–2.0 (zoomOut/zoomIn in +page.svelte:27–32). OQ-001 resolves to 0.25–3.0. Named constants MIN_ZOOM = 0.25 / MAX_ZOOM = 3.0 belong in a new panZoom.ts module.
  • Pan is not implemented. The canvas currently relies on overflow-auto on 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.
  • OQ-007 library recommendation: panzoom by timmywil (v4.x, ~8 KB gzipped). Reasons: SVG-native, TypeScript types, touch pinch, mouse-wheel, onTransform callback 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.ts abstraction so it can be feature-flag-swapped.
  • panZoomState: { x, y, z } must live at the page level (+page.svelte) — not inside StammbaumTree. US-PANEL-002 requires the state to survive panel open/close, and OQ-003 requires it to drive ?cx=…&cy=…&z=… URL params via replaceState.
  • NFR-OBS-001 ("log { viewport_width, node_count, edge_count } on mount"): a $effect(() => { /* fire once when layout stabilises */ }) in StammbaumTree.svelte with a fire-and-forget fetch or structured console.debug handles this cleanly.
  • Fit-to-screen (US-PAN-004 AC1): must compute the SVG bounding box after layout is derived on mount. A $effect that calls panzoom.fit() (or the equivalent) on first render is the right hook.
  • Keyboard zoom (+/-, OQ-002: 0.1× step): needs a keydown listener on the SVG container wrapper, not on the individual node <g> elements — the nodes already use Enter/Space for selection.
  • Existing page test (page.svelte.test.ts:47) — the clamps zoom test covers repeated clicks but does not assert the actual zoom value. Worth tightening once the constants are extracted.

Recommendations

  • Extract MIN_ZOOM = 0.25, MAX_ZOOM = 3.0, ZOOM_STEP_KB = 0.1 into panZoom.ts. Both the page and any future library wrapper use these.
  • Introduce let panZoomState = $state<{ x: number; y: number; z: number }>({ x: 0, y: 0, z: 1 }) in +page.svelte. Bind it to URL params with replaceState inside $effect.
  • For focus trap in the bottom sheet, scan the existing $lib/shared/actions/ — if no trapFocus action exists, add one (it's ~30 lines). There's no existing focus-trap action in the codebase.
  • Import panzoom lazily: const { default: Panzoom } = await import('panzoom') inside the $effect init block — keeps the SSR bundle clean since StammbaumTree renders server-side.

Open Decisions

  • Library (OQ-007): I recommend panzoom v4.x over d3-zoom or a custom implementation. Custom would avoid the dependency but the inertia (requestAnimationFrame loop) + pinch handling is ~300 lines that are hard to test thoroughly. Please confirm so the issue body OQ-007 row can be closed.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### Observations - **Bottom sheet is already partially there** (`+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. - **Zoom range mismatch:** current code clamps to `0.4–2.0` (`zoomOut`/`zoomIn` in `+page.svelte:27–32`). OQ-001 resolves to `0.25–3.0`. Named constants `MIN_ZOOM = 0.25` / `MAX_ZOOM = 3.0` belong in a new `panZoom.ts` module. - **Pan is not implemented.** The canvas currently relies on `overflow-auto` on 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. - **OQ-007 library recommendation:** `panzoom` by timmywil (v4.x, ~8 KB gzipped). Reasons: SVG-native, TypeScript types, touch pinch, mouse-wheel, `onTransform` callback 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.ts` abstraction so it can be feature-flag-swapped. - **`panZoomState: { x, y, z }` must live at the page level** (`+page.svelte`) — not inside `StammbaumTree`. US-PANEL-002 requires the state to survive panel open/close, and OQ-003 requires it to drive `?cx=…&cy=…&z=…` URL params via `replaceState`. - **NFR-OBS-001** ("log `{ viewport_width, node_count, edge_count }` on mount"): a `$effect(() => { /* fire once when layout stabilises */ })` in `StammbaumTree.svelte` with a fire-and-forget `fetch` or structured `console.debug` handles this cleanly. - **Fit-to-screen (US-PAN-004 AC1):** must compute the SVG bounding box after `layout` is derived on mount. A `$effect` that calls `panzoom.fit()` (or the equivalent) on first render is the right hook. - **Keyboard zoom** (`+`/`-`, OQ-002: 0.1× step): needs a `keydown` listener on the SVG container wrapper, not on the individual node `<g>` elements — the nodes already use `Enter`/`Space` for selection. - **Existing page test** (`page.svelte.test.ts:47`) — the `clamps zoom` test covers repeated clicks but does not assert the actual zoom value. Worth tightening once the constants are extracted. ### Recommendations - Extract `MIN_ZOOM = 0.25`, `MAX_ZOOM = 3.0`, `ZOOM_STEP_KB = 0.1` into `panZoom.ts`. Both the page and any future library wrapper use these. - Introduce `let panZoomState = $state<{ x: number; y: number; z: number }>({ x: 0, y: 0, z: 1 })` in `+page.svelte`. Bind it to URL params with `replaceState` inside `$effect`. - For focus trap in the bottom sheet, scan the existing `$lib/shared/actions/` — if no `trapFocus` action exists, add one (it's ~30 lines). There's no existing focus-trap action in the codebase. - Import panzoom lazily: `const { default: Panzoom } = await import('panzoom')` inside the `$effect` init block — keeps the SSR bundle clean since `StammbaumTree` renders server-side. ### Open Decisions - **Library (OQ-007):** I recommend `panzoom` v4.x over d3-zoom or a custom implementation. Custom would avoid the dependency but the inertia (`requestAnimationFrame` loop) + pinch handling is ~300 lines that are hard to test thoroughly. Please confirm so the issue body OQ-007 row can be closed.
Author
Owner

🏛️ Markus Keller — Application Architect

Observations

  • Minimal blast radius: this is a pure frontend change. No new backend endpoints, no Flyway migrations, no Docker services. The scope is correctly bounded.
  • SSR safety is non-negotiable: StammbaumTree.svelte is server-rendered. Any panzoom library that accesses document or window at module-import time will break SSR with a ReferenceError. The library import must happen inside $effect or onMount (both are client-only). Verify this before the PR is opened.
  • URL state architecture (OQ-003 → ?cx=…&cy=…&z=…): SvelteKit's history.replaceState (via goto('?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:19 already sets a precedent; the pan/zoom params sit alongside it.
  • Incoming URL params must be sanitised before they reach layout code. +page.server.ts (or the $derived in the page) should parse and clamp cx/cy/z before use. A crafted ?z=Infinity or ?cx=NaN flowing into the viewBox calculation will produce an invisible or crashing canvas.
  • NFR-MAINT-001 abstraction: the "feature-flag fallback" requirement is architectural. Implement it as: $lib/person/genealogy/panZoom.ts exports a createPanZoom(el, state) function. Internally it imports the library. If the library is removed in future, only this module changes — StammbaumTree.svelte is unaffected.
  • State ownership: panZoomState belongs at the +page.svelte level (same level as selectedId). This guarantees US-PANEL-002 (state survives panel open/close) without prop-drilling — StammbaumTree receives panZoomState as a prop and calls back via onPanZoom.

Recommendations

  • Define type PanZoomState = { x: number; y: number; z: number } in panZoom.ts — the single authoritative shape used by URL serialisation, the library wrapper, and the test factory.
  • In +page.server.ts, read cx/cy/z from url.searchParams and 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.
  • Write a short ADR (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.
## 🏛️ Markus Keller — Application Architect ### Observations - **Minimal blast radius:** this is a pure frontend change. No new backend endpoints, no Flyway migrations, no Docker services. The scope is correctly bounded. - **SSR safety is non-negotiable:** `StammbaumTree.svelte` is server-rendered. Any panzoom library that accesses `document` or `window` at module-import time will break SSR with a `ReferenceError`. The library import *must* happen inside `$effect` or `onMount` (both are client-only). Verify this before the PR is opened. - **URL state architecture (OQ-003 → `?cx=…&cy=…&z=…`):** SvelteKit's `history.replaceState` (via `goto('?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:19` already sets a precedent; the pan/zoom params sit alongside it. - **Incoming URL params must be sanitised before they reach layout code.** `+page.server.ts` (or the `$derived` in the page) should parse and clamp `cx`/`cy`/`z` before use. A crafted `?z=Infinity` or `?cx=NaN` flowing into the viewBox calculation will produce an invisible or crashing canvas. - **NFR-MAINT-001 abstraction:** the "feature-flag fallback" requirement is architectural. Implement it as: `$lib/person/genealogy/panZoom.ts` exports a `createPanZoom(el, state)` function. Internally it imports the library. If the library is removed in future, only this module changes — `StammbaumTree.svelte` is unaffected. - **State ownership:** `panZoomState` belongs at the `+page.svelte` level (same level as `selectedId`). This guarantees US-PANEL-002 (state survives panel open/close) without prop-drilling — `StammbaumTree` receives `panZoomState` as a prop and calls back via `onPanZoom`. ### Recommendations - Define `type PanZoomState = { x: number; y: number; z: number }` in `panZoom.ts` — the single authoritative shape used by URL serialisation, the library wrapper, and the test factory. - In `+page.server.ts`, read `cx`/`cy`/`z` from `url.searchParams` and 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. - Write a short ADR (`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.
Author
Owner

🔐 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 SVG viewBox calculation in StammbaumTree.svelte. Feeding Infinity, NaN, -999999, or 1e308 into viewBox causes 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.

const z = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, Number(raw.z) || DEFAULT_ZOOM));
const cx = isFinite(Number(raw.cx)) ? Number(raw.cx) : DEFAULT_CX;

Do this in +page.server.ts on the incoming url.searchParams, not in the component.

2. localStorage affordance flag (Low)

US-PAN-006 AC2 uses localStorage to remember that the affordance was dismissed. The pattern localStorage.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 does el.innerHTML = localStorage.getItem(...), that becomes stored XSS. Enforce this in code review: the only valid read is localStorage.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 checked StammbaumTree.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

  • Add a unit test asserting that parsePanZoomParams({ z: 'Infinity' }) returns DEFAULT_ZOOM, and that parsePanZoomParams({ z: 'NaN' }) does the same. These are the two realistic attack inputs from a crafted URL.
  • Document the localStorage usage with a one-line comment: // boolean flag only — never rendered to DOM so a future reviewer doesn't accidentally change the pattern.
## 🔐 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 SVG `viewBox` calculation in `StammbaumTree.svelte`. Feeding `Infinity`, `NaN`, `-999999`, or `1e308` into `viewBox` causes 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. ```typescript const z = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, Number(raw.z) || DEFAULT_ZOOM)); const cx = isFinite(Number(raw.cx)) ? Number(raw.cx) : DEFAULT_CX; ``` Do this in `+page.server.ts` on the incoming `url.searchParams`, not in the component. **2. localStorage affordance flag (Low)** US-PAN-006 AC2 uses `localStorage` to remember that the affordance was dismissed. The pattern `localStorage.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 does `el.innerHTML = localStorage.getItem(...)`, that becomes stored XSS. Enforce this in code review: the only valid read is `localStorage.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 checked `StammbaumTree.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 - Add a unit test asserting that `parsePanZoomParams({ z: 'Infinity' })` returns `DEFAULT_ZOOM`, and that `parsePanZoomParams({ z: 'NaN' })` does the same. These are the two realistic attack inputs from a crafted URL. - Document the localStorage usage with a one-line comment: `// boolean flag only — never rendered to DOM` so a future reviewer doesn't accidentally change the pattern.
Author
Owner

🧪 Sara Holt — Senior QA Engineer

Observations

  • Acceptance gate requires Playwright visual regression at 320/414/768px. This is non-negotiable and must include both the new affordance state (first-load) and the bottom-sheet-open state. These are new snapshots, not replacements for the existing #361 snapshots.
  • Pinch gesture simulation is hard in Playwright. page.touchscreen.tap() covers single-touch; pinch requires CDPSession.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 the panZoom.ts module, and use Playwright only for visual regression and the fit-to-screen button interaction. This avoids brittle CDPSession hacks.
  • localStorage must be cleared in beforeEach for any test that visits /stammbaum and checks the affordance. Without this, tests are order-dependent: the first test dismisses the affordance, all subsequent tests never see it.
  • NFR-PERF-001 ("≥50 fps on a mid-tier 2022 Android") cannot be automated in CI. It is in the acceptance gate but has no verification path. This will silently be skipped unless it is explicitly marked as a manual check with a named verifier and a device.
  • US-PANEL-002 AC1 ("pan/zoom state survives panel open/close") is fully testable with Playwright: pan to a known coordinate, open the side panel, close it, assert the SVG viewBox or CSS transform matches the pre-open value.
  • The existing page test (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:

Layer What to test
Unit (vitest) parsePanZoomParams (clamping, NaN, Infinity), calculateFitToScreen (returns correct zoom for given node bounds), MIN_ZOOM/MAX_ZOOM constants
Component (vitest-browser) StammbaumTree: keyboard +/- changes viewBox; showGutter prop still works; bottom-sheet appears on node click at 375px viewport width
E2E (Playwright) Visual regression at 320/414/768px; affordance visible on first visit, absent on second; fit-to-screen button present after pan; bottom sheet open/close preserves pan state
  • Add data-testid="fit-to-screen" to the fit-to-screen button so selectors don't break when i18n label text changes.
  • Mark NFR-PERF-001 as a manual gate in the issue's acceptance section: "verified on [device model] before closing issue." A @Disabled test with a linked ticket is acceptable; a silent skip is not.

Open Decisions

  • NFR-PERF-001 verification: who runs the benchmark and on which device? Without a named owner and a specific device, this gate will never be checked. Suggest adding it explicitly to the DoD for this issue.
## 🧪 Sara Holt — Senior QA Engineer ### Observations - **Acceptance gate requires Playwright visual regression at 320/414/768px.** This is non-negotiable and must include both the new affordance state (first-load) and the bottom-sheet-open state. These are new snapshots, not replacements for the existing #361 snapshots. - **Pinch gesture simulation is hard in Playwright.** `page.touchscreen.tap()` covers single-touch; pinch requires `CDPSession.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 the `panZoom.ts` module, and use Playwright only for visual regression and the fit-to-screen *button* interaction. This avoids brittle CDPSession hacks. - **localStorage must be cleared in `beforeEach`** for any test that visits `/stammbaum` and checks the affordance. Without this, tests are order-dependent: the first test dismisses the affordance, all subsequent tests never see it. - **NFR-PERF-001** ("≥50 fps on a mid-tier 2022 Android") cannot be automated in CI. It is in the acceptance gate but has no verification path. This will silently be skipped unless it is explicitly marked as a manual check with a named verifier and a device. - **US-PANEL-002 AC1** ("pan/zoom state survives panel open/close") is fully testable with Playwright: pan to a known coordinate, open the side panel, close it, assert the SVG `viewBox` or CSS transform matches the pre-open value. - **The existing page test** (`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: | Layer | What to test | |---|---| | **Unit (vitest)** | `parsePanZoomParams` (clamping, NaN, Infinity), `calculateFitToScreen` (returns correct zoom for given node bounds), `MIN_ZOOM`/`MAX_ZOOM` constants | | **Component (vitest-browser)** | `StammbaumTree`: keyboard `+`/`-` changes viewBox; `showGutter` prop still works; bottom-sheet appears on node click at 375px viewport width | | **E2E (Playwright)** | Visual regression at 320/414/768px; affordance visible on first visit, absent on second; fit-to-screen button present after pan; bottom sheet open/close preserves pan state | - Add `data-testid="fit-to-screen"` to the fit-to-screen button so selectors don't break when i18n label text changes. - Mark NFR-PERF-001 as a manual gate in the issue's acceptance section: "verified on [device model] before closing issue." A `@Disabled` test with a linked ticket is acceptable; a silent skip is not. ### Open Decisions - **NFR-PERF-001 verification:** who runs the benchmark and on which device? Without a named owner and a specific device, this gate will never be checked. Suggest adding it explicitly to the DoD for this issue.
Author
Owner

🎨 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-bottom on 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:

  • Drag handle: <div class="mx-auto mt-2 mb-1 h-1 w-10 rounded-full bg-line" aria-hidden="true" /> at the sheet's top edge
  • Swipe-to-dismiss: a pointerdown/pointermove handler that closes the sheet when dragged down by ≥80px
  • Tap-outside dismiss: a backdrop <div> behind the sheet that calls onClose
  • role="dialog" + aria-label: the wrapper div needs role="dialog" aria-labelledby="panel-title". The person name in the panel content should carry id="panel-title".
  • Focus trap and Escape dismiss: 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 pointerdown event 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: reduce is detected. This is already specified in REQ-PAN-005. The pan/zoom composable should read window.matchMedia('(prefers-reduced-motion: reduce)').matches and 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

  • Move the zoom/fit controls to a position: absolute overlay inside the canvas container: bottom: max(env(safe-area-inset-bottom, 0px) + 1rem, 1rem); right: 1rem; — three buttons stacked vertically with gap-1.
  • Add these i18n keys to de/en/es before implementation: stammbaum_fit_to_screen, stammbaum_affordance_hint ("Ziehen zum Erkunden · Zusammendrücken zum Zoomen"), stammbaum_affordance_dismiss, stammbaum_close_panel.
  • The zoom label 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

  • US-PAN-006 AC3 (edge gradient after 10s idle): this behaviour is listed in the Must section but seems like a Should. If the edge-fade CSS gradient (above) is always present when content overflows, the 10-second timeout becomes redundant. Recommend: implement the CSS fade as a permanent affordance and drop the 10-second timer entirely, saving implementation complexity.
## 🎨 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-bottom` on 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: - **Drag handle:** `<div class="mx-auto mt-2 mb-1 h-1 w-10 rounded-full bg-line" aria-hidden="true" />` at the sheet's top edge - **Swipe-to-dismiss:** a `pointerdown`/`pointermove` handler that closes the sheet when dragged down by ≥80px - **Tap-outside dismiss:** a backdrop `<div>` behind the sheet that calls `onClose` - **`role="dialog"` + `aria-label`:** the wrapper div needs `role="dialog" aria-labelledby="panel-title"`. The person name in the panel content should carry `id="panel-title"`. - **Focus trap and `Escape` dismiss:** 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 `pointerdown` event 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: reduce` is detected. This is already specified in REQ-PAN-005. The pan/zoom composable should read `window.matchMedia('(prefers-reduced-motion: reduce)').matches` and 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 - Move the zoom/fit controls to a `position: absolute` overlay inside the canvas container: `bottom: max(env(safe-area-inset-bottom, 0px) + 1rem, 1rem); right: 1rem;` — three buttons stacked vertically with `gap-1`. - Add these i18n keys to `de/en/es` before implementation: `stammbaum_fit_to_screen`, `stammbaum_affordance_hint` ("Ziehen zum Erkunden · Zusammendrücken zum Zoomen"), `stammbaum_affordance_dismiss`, `stammbaum_close_panel`. - The `zoom` label 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 - **US-PAN-006 AC3** (edge gradient after 10s idle): this behaviour is listed in the Must section but seems like a Should. If the edge-fade CSS gradient (above) is always present when content overflows, the 10-second timeout becomes redundant. Recommend: implement the CSS fade as a permanent affordance and drop the 10-second timer entirely, saving implementation complexity.
Author
Owner

🛠️ 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. Run npm run build before 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:

// Inside $effect / onMount — client-only
const Panzoom = (await import('panzoom')).default;

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:

Path What it produces Where to look
console.debug(JSON.stringify(...)) Dev-only noise, disappears in production Nowhere in prod
Fire-and-forget fetch('/api/telemetry/canvas-mount', ...) Loki log entry via Spring Boot Grafana Loki → label {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-mount endpoint 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 runs npm run check which 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

  • Pin the panzoom library to an exact version in package.json (e.g. "panzoom": "4.5.1") per NFR-MAINT-001 — not a range.
  • Decide the NFR-OBS-001 path before implementation starts. If it's a backend endpoint, Felix needs to add it; if it's a client-only console.debug, document why that's sufficient.

Open Decisions

  • NFR-OBS-001 implementation path: backend telemetry endpoint (reaches Loki, queryable in Grafana) vs. GlitchTip breadcrumb vs. client-side console.debug only. The right choice depends on whether the performance question is production-answerable or dev-only. Please decide so Felix knows what to build.
## 🛠️ 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. Run `npm run build` before 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: ```typescript // Inside $effect / onMount — client-only const Panzoom = (await import('panzoom')).default; ``` 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: | Path | What it produces | Where to look | |---|---|---| | `console.debug(JSON.stringify(...))` | Dev-only noise, disappears in production | Nowhere in prod | | Fire-and-forget `fetch('/api/telemetry/canvas-mount', ...)` | Loki log entry via Spring Boot | Grafana Loki → label `{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-mount` endpoint 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 runs `npm run check` which 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 - Pin the panzoom library to an exact version in `package.json` (e.g. `"panzoom": "4.5.1"`) per NFR-MAINT-001 — not a range. - Decide the NFR-OBS-001 path before implementation starts. If it's a backend endpoint, Felix needs to add it; if it's a client-only `console.debug`, document why that's sufficient. ### Open Decisions - **NFR-OBS-001 implementation path:** backend telemetry endpoint (reaches Loki, queryable in Grafana) vs. GlitchTip breadcrumb vs. client-side `console.debug` only. The right choice depends on whether the performance question is production-answerable or dev-only. Please decide so Felix knows what to build.
Author
Owner

📋 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 StammbaumTree almost certainly define a fixture — its node and edge count should be explicitly stated here (e.g., "the 12-node, 10-edge fixture in StammbaumTree.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:

  1. OQ table: mark OQ-001 through OQ-006 and OQ-008 as Resolved: [default value]; mark OQ-007 as Resolved: [library name] once Felix confirms.
  2. US-PANEL-001 wireframe vocabulary: add "centre on this person" button to the person panel description (bottom sheet and desktop side panel both need it).
  3. US-PANEL-002 AC2: replace "same starting view" with "same ?cx=…&cy=…&z=… URL state produces the same canvas position and zoom level."
  4. NFR-PERF-001: add named device, verification tool, and owner.
  5. Canonical fixture: name the node/edge count used for NFR-PERF-001 benchmarking.
  6. US-PAN-006 AC3: move to Should-have or out-of-scope, and remove it from the Must gate.
## 📋 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 `StammbaumTree` almost certainly define a fixture — its node and edge count should be explicitly stated here (e.g., "the 12-node, 10-edge fixture in `StammbaumTree.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: 1. **OQ table:** mark OQ-001 through OQ-006 and OQ-008 as `Resolved: [default value]`; mark OQ-007 as `Resolved: [library name]` once Felix confirms. 2. **US-PANEL-001 wireframe vocabulary:** add "centre on this person" button to the person panel description (bottom sheet and desktop side panel both need it). 3. **US-PANEL-002 AC2:** replace "same starting view" with "same `?cx=…&cy=…&z=…` URL state produces the same canvas position and zoom level." 4. **NFR-PERF-001:** add named device, verification tool, and owner. 5. **Canonical fixture:** name the node/edge count used for NFR-PERF-001 benchmarking. 6. **US-PAN-006 AC3:** move to Should-have or out-of-scope, and remove it from the Must gate.
Author
Owner

🗳️ Decision Queue — Action Required

5 decisions need your input before implementation starts.

Architecture

  • Library choice (OQ-007): Felix recommends panzoom by timmywil (v4.x, ~8 KB gzipped) — SVG-native, TypeScript support, touch pinch, mouse-wheel, inertia, onTransform callback for URL sync. Alternative is a custom requestAnimationFrame loop (~300 lines, no dependency). Please confirm the library so OQ-007 can be closed in the issue body. (Raised by: Felix)

Observability

  • NFR-OBS-001 implementation path: the requirement to "log { viewport_width, node_count, edge_count } on canvas-mount" is only useful in production if it reaches Loki. Three options: (a) new POST /api/telemetry/canvas-mount backend endpoint → Loki/Grafana queryable; (b) GlitchTip breadcrumb (mixes analytics with error tracking); (c) console.debug only (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.svelte for 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-image edge-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

  • NFR-PERF-001 verification path: the acceptance gate requires "≥50 fps on a mid-tier 2022 Android (Snapdragon 6-series equivalent)" but names no owner, device, or tool. Without this, the gate is silently untestable and will be skipped. Please add: the specific device (or emulator), the tool (e.g. Chrome DevTools Performance tab, Android GPU inspector), and who runs it before closing the issue. (Raised by: Sara, Elicit)
## 🗳️ Decision Queue — Action Required _5 decisions need your input before implementation starts._ ### Architecture - **Library choice (OQ-007):** Felix recommends `panzoom` by timmywil (v4.x, ~8 KB gzipped) — SVG-native, TypeScript support, touch pinch, mouse-wheel, inertia, `onTransform` callback for URL sync. Alternative is a custom `requestAnimationFrame` loop (~300 lines, no dependency). Please confirm the library so OQ-007 can be closed in the issue body. _(Raised by: Felix)_ ### Observability - **NFR-OBS-001 implementation path:** the requirement to "log `{ viewport_width, node_count, edge_count }` on canvas-mount" is only useful in production if it reaches Loki. Three options: (a) new `POST /api/telemetry/canvas-mount` backend endpoint → Loki/Grafana queryable; (b) GlitchTip breadcrumb (mixes analytics with error tracking); (c) `console.debug` only (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.svelte` for 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-image` edge-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 - **NFR-PERF-001 verification path:** the acceptance gate requires "≥50 fps on a mid-tier 2022 Android (Snapdragon 6-series equivalent)" but names no owner, device, or tool. Without this, the gate is silently untestable and will be skipped. Please add: the specific device (or emulator), the tool (e.g. Chrome DevTools Performance tab, Android GPU inspector), and who runs it before closing the issue. _(Raised by: Sara, Elicit)_
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#692