From 5458ca9bae0398556c5154afcd86cdbd296cf12d Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 16:23:47 +0200 Subject: [PATCH 01/39] =?UTF-8?q?feat(stammbaum):=20add=20clampZoom=20with?= =?UTF-8?q?=20resolved=200.25=E2=80=933.0=20zoom=20bounds=20(#692)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../src/lib/person/genealogy/panZoom.test.ts | 26 +++++++++++++++++++ frontend/src/lib/person/genealogy/panZoom.ts | 19 ++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 frontend/src/lib/person/genealogy/panZoom.test.ts create mode 100644 frontend/src/lib/person/genealogy/panZoom.ts diff --git a/frontend/src/lib/person/genealogy/panZoom.test.ts b/frontend/src/lib/person/genealogy/panZoom.test.ts new file mode 100644 index 00000000..509acdd8 --- /dev/null +++ b/frontend/src/lib/person/genealogy/panZoom.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { clampZoom, MIN_ZOOM, MAX_ZOOM } from './panZoom'; + +describe('clampZoom', () => { + it('returns the value unchanged when within range', () => { + expect(clampZoom(1)).toBe(1); + expect(clampZoom(0.5)).toBe(0.5); + expect(clampZoom(2.75)).toBe(2.75); + }); + + it('clamps below MIN_ZOOM up to MIN_ZOOM', () => { + expect(clampZoom(0.1)).toBe(MIN_ZOOM); + expect(clampZoom(0)).toBe(MIN_ZOOM); + expect(clampZoom(-5)).toBe(MIN_ZOOM); + }); + + it('clamps above MAX_ZOOM down to MAX_ZOOM', () => { + expect(clampZoom(5)).toBe(MAX_ZOOM); + expect(clampZoom(3.0001)).toBe(MAX_ZOOM); + }); + + it('exposes the resolved zoom bounds', () => { + expect(MIN_ZOOM).toBe(0.25); + expect(MAX_ZOOM).toBe(3.0); + }); +}); diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts new file mode 100644 index 00000000..7b107440 --- /dev/null +++ b/frontend/src/lib/person/genealogy/panZoom.ts @@ -0,0 +1,19 @@ +/** + * Pan/zoom geometry for the Stammbaum canvas (#692). + * + * The Stammbaum renders zoom by deriving the SVG `viewBox` rather than applying + * a CSS transform (see `StammbaumTree.svelte`). This module is the single source + * of truth for the zoom bounds, the view-state shape, and every pure geometry + * helper used by the gesture action, the URL serialiser, and the page. Keeping + * the math here (and free of DOM access) makes it unit-testable in the node + * project. See ADR-026 for why this is custom rather than a third-party library. + */ + +/** Resolved zoom bounds (OQ-001). */ +export const MIN_ZOOM = 0.25; +export const MAX_ZOOM = 3.0; + +/** Clamp a zoom factor into the supported range. */ +export function clampZoom(z: number): number { + return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z)); +} -- 2.49.1 From a7d0e96613710a99c84d56e4d52b7eb2dd9e7127 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 16:25:11 +0200 Subject: [PATCH 02/39] feat(stammbaum): parse + sanitise URL pan/zoom params (#692) Degrade Infinity/NaN/overflow per axis and clamp zoom into bounds so a crafted ?cx/?cy/?z shared link cannot blank the SVG (Nora's review). Co-Authored-By: Claude Opus 4.8 --- .../src/lib/person/genealogy/panZoom.test.ts | 37 +++++++++++++++++- frontend/src/lib/person/genealogy/panZoom.ts | 39 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/person/genealogy/panZoom.test.ts b/frontend/src/lib/person/genealogy/panZoom.test.ts index 509acdd8..fed80448 100644 --- a/frontend/src/lib/person/genealogy/panZoom.test.ts +++ b/frontend/src/lib/person/genealogy/panZoom.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect } from 'vitest'; -import { clampZoom, MIN_ZOOM, MAX_ZOOM } from './panZoom'; +import { + clampZoom, + parsePanZoomParams, + DEFAULT_VIEW, + DEFAULT_ZOOM, + MIN_ZOOM, + MAX_ZOOM +} from './panZoom'; describe('clampZoom', () => { it('returns the value unchanged when within range', () => { @@ -24,3 +31,31 @@ describe('clampZoom', () => { expect(MAX_ZOOM).toBe(3.0); }); }); + +describe('parsePanZoomParams', () => { + it('parses well-formed cx/cy/z params', () => { + expect(parsePanZoomParams({ cx: '120', cy: '-40', z: '1.5' })).toEqual({ + x: 120, + y: -40, + z: 1.5 + }); + }); + + it('falls back to DEFAULT_VIEW when params are absent', () => { + expect(parsePanZoomParams({})).toEqual(DEFAULT_VIEW); + expect(DEFAULT_VIEW).toEqual({ x: 0, y: 0, z: DEFAULT_ZOOM }); + }); + + it('rejects Infinity and NaN, degrading each axis to its default (Nora #692)', () => { + expect(parsePanZoomParams({ z: 'Infinity' }).z).toBe(DEFAULT_ZOOM); + expect(parsePanZoomParams({ z: 'NaN' }).z).toBe(DEFAULT_ZOOM); + expect(parsePanZoomParams({ cx: 'NaN', cy: 'Infinity' })).toEqual(DEFAULT_VIEW); + expect(parsePanZoomParams({ cx: '1e500' }).x).toBe(0); + }); + + it('clamps an out-of-range zoom into the supported bounds', () => { + expect(parsePanZoomParams({ z: '99' }).z).toBe(MAX_ZOOM); + expect(parsePanZoomParams({ z: '0.01' }).z).toBe(MIN_ZOOM); + expect(parsePanZoomParams({ z: '-3' }).z).toBe(MIN_ZOOM); + }); +}); diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts index 7b107440..1ec304ed 100644 --- a/frontend/src/lib/person/genealogy/panZoom.ts +++ b/frontend/src/lib/person/genealogy/panZoom.ts @@ -12,8 +12,47 @@ /** Resolved zoom bounds (OQ-001). */ export const MIN_ZOOM = 0.25; export const MAX_ZOOM = 3.0; +export const DEFAULT_ZOOM = 1; + +/** + * The canvas view state. `x`/`y` are pan offsets applied to the viewBox centre + * (SVG user units); `z` is the zoom factor. The default `{0, 0, 1}` frames the + * whole tree (fit-to-screen) because the base viewBox already encloses the + * layout bounding box at z=1. + */ +export type PanZoomState = { x: number; y: number; z: number }; + +/** Fit-to-screen / initial view (US-PAN-004). */ +export const DEFAULT_VIEW: PanZoomState = { x: 0, y: 0, z: DEFAULT_ZOOM }; /** Clamp a zoom factor into the supported range. */ export function clampZoom(z: number): number { return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z)); } + +/** Parse a raw value to a finite number, or return `fallback` for NaN/Infinity/absent. */ +function finiteOr(raw: string | null | undefined, fallback: number): number { + if (raw == null) return fallback; + const n = Number(raw); + return Number.isFinite(n) ? n : fallback; +} + +/** + * Parse URL-supplied pan/zoom params into a safe {@link PanZoomState} (OQ-003). + * + * Every axis is sanitised independently: `Infinity`, `NaN`, overflow (`1e500`), + * and absent values degrade to the default for that axis, and the zoom is + * clamped into [MIN_ZOOM, MAX_ZOOM]. This guards against a crafted shared link + * (`?z=Infinity`, `?cx=NaN`) rendering the SVG blank — see Nora's review (#692). + */ +export function parsePanZoomParams(raw: { + cx?: string | null; + cy?: string | null; + z?: string | null; +}): PanZoomState { + return { + x: finiteOr(raw.cx, DEFAULT_VIEW.x), + y: finiteOr(raw.cy, DEFAULT_VIEW.y), + z: clampZoom(finiteOr(raw.z, DEFAULT_ZOOM)) + }; +} -- 2.49.1 From 369a0213e5a4321175f7bee5b40461cd975ff541 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 16:26:07 +0200 Subject: [PATCH 03/39] feat(stammbaum): serialise pan/zoom state to URL params (#692) Co-Authored-By: Claude Opus 4.8 --- .../src/lib/person/genealogy/panZoom.test.ts | 16 ++++++++++++++++ frontend/src/lib/person/genealogy/panZoom.ts | 5 +++++ 2 files changed, 21 insertions(+) diff --git a/frontend/src/lib/person/genealogy/panZoom.test.ts b/frontend/src/lib/person/genealogy/panZoom.test.ts index fed80448..4e1a8fd6 100644 --- a/frontend/src/lib/person/genealogy/panZoom.test.ts +++ b/frontend/src/lib/person/genealogy/panZoom.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { clampZoom, parsePanZoomParams, + serializePanZoomParams, DEFAULT_VIEW, DEFAULT_ZOOM, MIN_ZOOM, @@ -59,3 +60,18 @@ describe('parsePanZoomParams', () => { expect(parsePanZoomParams({ z: '-3' }).z).toBe(MIN_ZOOM); }); }); + +describe('serializePanZoomParams', () => { + it('produces string cx/cy/z keys', () => { + expect(serializePanZoomParams({ x: 120, y: -40, z: 1.5 })).toEqual({ + cx: '120', + cy: '-40', + z: '1.5' + }); + }); + + it('round-trips through parsePanZoomParams', () => { + const state = { x: 87.5, y: -12.25, z: 2.4 }; + expect(parsePanZoomParams(serializePanZoomParams(state))).toEqual(state); + }); +}); diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts index 1ec304ed..714764f5 100644 --- a/frontend/src/lib/person/genealogy/panZoom.ts +++ b/frontend/src/lib/person/genealogy/panZoom.ts @@ -56,3 +56,8 @@ export function parsePanZoomParams(raw: { z: clampZoom(finiteOr(raw.z, DEFAULT_ZOOM)) }; } + +/** Serialise a view state into URL query params (the inverse of {@link parsePanZoomParams}). */ +export function serializePanZoomParams(state: PanZoomState): { cx: string; cy: string; z: string } { + return { cx: String(state.x), cy: String(state.y), z: String(state.z) }; +} -- 2.49.1 From 0170f7969044d9f781feb4965656a6531beafa22 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 16:27:14 +0200 Subject: [PATCH 04/39] feat(stammbaum): convert pointer pixel delta to SVG units (#692) Co-Authored-By: Claude Opus 4.8 --- .../src/lib/person/genealogy/panZoom.test.ts | 16 +++++++++++++++ frontend/src/lib/person/genealogy/panZoom.ts | 20 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/frontend/src/lib/person/genealogy/panZoom.test.ts b/frontend/src/lib/person/genealogy/panZoom.test.ts index 4e1a8fd6..42282a65 100644 --- a/frontend/src/lib/person/genealogy/panZoom.test.ts +++ b/frontend/src/lib/person/genealogy/panZoom.test.ts @@ -3,6 +3,7 @@ import { clampZoom, parsePanZoomParams, serializePanZoomParams, + screenDeltaToSvg, DEFAULT_VIEW, DEFAULT_ZOOM, MIN_ZOOM, @@ -75,3 +76,18 @@ describe('serializePanZoomParams', () => { expect(parsePanZoomParams(serializePanZoomParams(state))).toEqual(state); }); }); + +describe('screenDeltaToSvg', () => { + it('scales a pixel delta by the viewBox-to-element ratio per axis', () => { + // viewBox is 2x the element in width, 2x in height → 1px == 2 SVG units. + expect(screenDeltaToSvg(10, 5, 1000, 800, 500, 400)).toEqual({ dx: 20, dy: 10 }); + }); + + it('is identity when the viewBox matches the element pixel size', () => { + expect(screenDeltaToSvg(7, -3, 600, 600, 600, 600)).toEqual({ dx: 7, dy: -3 }); + }); + + it('returns zero when the element has no measured size', () => { + expect(screenDeltaToSvg(10, 10, 1000, 800, 0, 0)).toEqual({ dx: 0, dy: 0 }); + }); +}); diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts index 714764f5..07a934d7 100644 --- a/frontend/src/lib/person/genealogy/panZoom.ts +++ b/frontend/src/lib/person/genealogy/panZoom.ts @@ -61,3 +61,23 @@ export function parsePanZoomParams(raw: { export function serializePanZoomParams(state: PanZoomState): { cx: string; cy: string; z: string } { return { cx: String(state.x), cy: String(state.y), z: String(state.z) }; } + +/** + * Convert a pointer delta in CSS pixels into SVG user units, using the current + * viewBox-to-element ratio per axis. This is the distance the pointer traversed + * expressed in the tree's coordinate space; the gesture handler subtracts it + * from the pan offset so the canvas follows the finger (US-PAN-001). + */ +export function screenDeltaToSvg( + dxPx: number, + dyPx: number, + viewBoxW: number, + viewBoxH: number, + elPxW: number, + elPxH: number +): { dx: number; dy: number } { + return { + dx: elPxW > 0 ? dxPx * (viewBoxW / elPxW) : 0, + dy: elPxH > 0 ? dyPx * (viewBoxH / elPxH) : 0 + }; +} -- 2.49.1 From 5d752fcc0f9c8bcfb9275149eb6bcfa802b9e143 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 16:28:41 +0200 Subject: [PATCH 05/39] feat(stammbaum): centroid-anchored zoom (zoomAtPoint) (#692) Co-Authored-By: Claude Opus 4.8 --- .../src/lib/person/genealogy/panZoom.test.ts | 29 +++++++++++++++++++ frontend/src/lib/person/genealogy/panZoom.ts | 25 ++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/frontend/src/lib/person/genealogy/panZoom.test.ts b/frontend/src/lib/person/genealogy/panZoom.test.ts index 42282a65..52782c3b 100644 --- a/frontend/src/lib/person/genealogy/panZoom.test.ts +++ b/frontend/src/lib/person/genealogy/panZoom.test.ts @@ -4,6 +4,7 @@ import { parsePanZoomParams, serializePanZoomParams, screenDeltaToSvg, + zoomAtPoint, DEFAULT_VIEW, DEFAULT_ZOOM, MIN_ZOOM, @@ -91,3 +92,31 @@ describe('screenDeltaToSvg', () => { expect(screenDeltaToSvg(10, 10, 1000, 800, 0, 0)).toEqual({ dx: 0, dy: 0 }); }); }); + +describe('zoomAtPoint', () => { + // The anchor is expressed as an offset (in SVG units) from the base viewBox + // centre. The fraction of the anchor across the viewBox must not change. + const anchorScreenFraction = (state: { x: number; z: number }, anchorOffsetX: number) => { + const baseW = 1000; + const w = baseW / state.z; + const centreOffset = anchorOffsetX - state.x; // anchor relative to viewBox centre + return centreOffset / w + 0.5; + }; + + it('keeps the canvas centre fixed when the anchor is the centre', () => { + const next = zoomAtPoint({ x: 0, y: 0, z: 1 }, 2, 0, 0); + expect(next).toEqual({ x: 0, y: 0, z: 2 }); + }); + + it('keeps an off-centre anchor at the same screen position across a zoom-in', () => { + const before = { x: 0, y: 0, z: 1 }; + const after = zoomAtPoint(before, 2, 100, 50); + expect(after.z).toBe(2); + expect(anchorScreenFraction(after, 100)).toBeCloseTo(anchorScreenFraction(before, 100), 10); + }); + + it('clamps the target zoom and makes no move when already at the bound', () => { + const next = zoomAtPoint({ x: 30, y: 10, z: MAX_ZOOM }, 99, 200, 200); + expect(next).toEqual({ x: 30, y: 10, z: MAX_ZOOM }); + }); +}); diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts index 07a934d7..5ef7ef4a 100644 --- a/frontend/src/lib/person/genealogy/panZoom.ts +++ b/frontend/src/lib/person/genealogy/panZoom.ts @@ -81,3 +81,28 @@ export function screenDeltaToSvg( dy: elPxH > 0 ? dyPx * (viewBoxH / elPxH) : 0 }; } + +/** + * Zoom to `newZoom` while keeping a given anchor point fixed on screen + * (pinch-centroid zoom — US-PAN-002 AC1 / US-PAN-003 AC1). + * + * `anchorX`/`anchorY` are the anchor point expressed as an offset, in SVG units, + * from the base viewBox centre. Because the viewBox width scales as `1/z`, the + * ratio of old-to-new width is exactly `z / newZoom` independent of the base + * size, so the new pan offset that preserves the anchor's screen fraction is + * `anchor - (anchor - pan) * (z / newZoom)`. + */ +export function zoomAtPoint( + state: PanZoomState, + newZoom: number, + anchorX: number, + anchorY: number +): PanZoomState { + const z = clampZoom(newZoom); + const ratio = state.z / z; + return { + x: anchorX - (anchorX - state.x) * ratio, + y: anchorY - (anchorY - state.y) * ratio, + z + }; +} -- 2.49.1 From 197b668f20e9d32e437e9b31326b1a7308ef59b4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 16:29:55 +0200 Subject: [PATCH 06/39] feat(stammbaum): recentre-on-node with legible auto-zoom (#692) Co-Authored-By: Claude Opus 4.8 --- .../src/lib/person/genealogy/panZoom.test.ts | 25 +++++++++++++++++++ frontend/src/lib/person/genealogy/panZoom.ts | 23 +++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/frontend/src/lib/person/genealogy/panZoom.test.ts b/frontend/src/lib/person/genealogy/panZoom.test.ts index 52782c3b..bea6f2d9 100644 --- a/frontend/src/lib/person/genealogy/panZoom.test.ts +++ b/frontend/src/lib/person/genealogy/panZoom.test.ts @@ -5,8 +5,10 @@ import { serializePanZoomParams, screenDeltaToSvg, zoomAtPoint, + recentreOn, DEFAULT_VIEW, DEFAULT_ZOOM, + LEGIBLE_ZOOM, MIN_ZOOM, MAX_ZOOM } from './panZoom'; @@ -120,3 +122,26 @@ describe('zoomAtPoint', () => { expect(next).toEqual({ x: 30, y: 10, z: MAX_ZOOM }); }); }); + +describe('recentreOn', () => { + const node = { x: 300, y: 200 }; + const base = { x: 100, y: 100 }; + + it('pans so the node sits at the viewBox centre, keeping the current zoom', () => { + expect(recentreOn(node, base, { x: 0, y: 0, z: 1 }, false)).toEqual({ x: 200, y: 100, z: 1 }); + }); + + it('auto-zooms up to LEGIBLE_ZOOM when zoomed out (OQ-005)', () => { + const next = recentreOn(node, base, { x: 0, y: 0, z: 0.4 }, true); + expect(next.z).toBe(LEGIBLE_ZOOM); + expect({ x: next.x, y: next.y }).toEqual({ x: 200, y: 100 }); + }); + + it('does not reduce an already-legible zoom when auto-zooming', () => { + expect(recentreOn(node, base, { x: 0, y: 0, z: 2 }, true).z).toBe(2); + }); + + it('leaves zoom untouched when auto-zoom is off', () => { + expect(recentreOn(node, base, { x: 0, y: 0, z: 0.4 }, false).z).toBe(0.4); + }); +}); diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts index 5ef7ef4a..7777a615 100644 --- a/frontend/src/lib/person/genealogy/panZoom.ts +++ b/frontend/src/lib/person/genealogy/panZoom.ts @@ -14,6 +14,9 @@ export const MIN_ZOOM = 0.25; export const MAX_ZOOM = 3.0; export const DEFAULT_ZOOM = 1; +/** Minimum zoom a recentre will snap up to so the focal node's text is legible (OQ-005). */ +export const LEGIBLE_ZOOM = 1; + /** * The canvas view state. `x`/`y` are pan offsets applied to the viewBox centre * (SVG user units); `z` is the zoom factor. The default `{0, 0, 1}` frames the @@ -106,3 +109,23 @@ export function zoomAtPoint( z }; } + +/** + * Pan so a node sits at the viewBox centre (US-PAN-005). Because the viewBox + * centre is `baseCentre + pan` independent of zoom, centring is a pure pan: + * `pan = nodeCentre - baseCentre`. When `autoZoom` is set, a zoomed-out view is + * snapped up to {@link LEGIBLE_ZOOM} so the focal node's text is readable + * (OQ-005); an already-legible zoom is preserved. + */ +export function recentreOn( + nodeCentre: { x: number; y: number }, + baseCentre: { x: number; y: number }, + state: PanZoomState, + autoZoom: boolean +): PanZoomState { + return { + x: nodeCentre.x - baseCentre.x, + y: nodeCentre.y - baseCentre.y, + z: autoZoom ? clampZoom(Math.max(state.z, LEGIBLE_ZOOM)) : state.z + }; +} -- 2.49.1 From 0422af89800c51690a2ccf06fd71bc6f1ca52915 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 16:35:49 +0200 Subject: [PATCH 07/39] feat(stammbaum): drive viewBox from PanZoomState (pan + zoom) (#692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the scalar zoom prop with a {x,y,z} PanZoomState. The viewBox centre is offset by the pan and width/height scaled by zoom; the default {0,0,1} frames the whole tree (fit-to-screen). Page header buttons now step view.z through clampZoom over the resolved 0.25–3.0 range. Co-Authored-By: Claude Opus 4.8 --- .../lib/person/genealogy/StammbaumTree.svelte | 23 ++++-- .../genealogy/StammbaumTree.svelte.test.ts | 73 ++++++++++++------- frontend/src/lib/person/genealogy/panZoom.ts | 3 + frontend/src/routes/stammbaum/+page.svelte | 14 +++- 4 files changed, 77 insertions(+), 36 deletions(-) diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte b/frontend/src/lib/person/genealogy/StammbaumTree.svelte index 8ab4af76..3fa83409 100644 --- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte +++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte @@ -8,6 +8,7 @@ import { ROW_GAP, type Layout } from '$lib/person/genealogy/layout/buildLayout'; +import type { PanZoomState } from '$lib/person/genealogy/panZoom'; type PersonNodeDTO = components['schemas']['PersonNodeDTO']; type RelationshipDTO = components['schemas']['RelationshipDTO']; @@ -16,7 +17,7 @@ interface Props { nodes: PersonNodeDTO[]; edges: RelationshipDTO[]; selectedId: string | null; - zoom: number; + panZoom: PanZoomState; onSelect: (id: string) => void; /** * Force-show or force-hide the generation gutter. When undefined, falls @@ -27,7 +28,7 @@ interface Props { showGutter?: boolean; } -let { nodes, edges, selectedId, zoom, onSelect, showGutter }: Props = $props(); +let { nodes, edges, selectedId, panZoom, onSelect, showGutter }: Props = $props(); const layout = $derived.by(() => buildLayout(nodes, edges)); @@ -79,12 +80,20 @@ const gutterRows = $derived.by(() => { return rows; }); +// Base viewBox geometry at z=1, no pan — the whole tree framed (#692). Pan +// offsets shift the centre; zoom scales width/height inversely. The default +// {x:0,y:0,z:1} therefore fits the tree to the element (fit-to-screen). +const baseDims = $derived({ w: layout.viewW + gutterWidth, h: layout.viewH }); +const baseCentre = $derived({ + x: layout.viewX - gutterWidth + baseDims.w / 2, + y: layout.viewY + layout.viewH / 2 +}); + const viewBox = $derived.by(() => { - const totalW = layout.viewW + gutterWidth; - const w = totalW / zoom; - const h = layout.viewH / zoom; - const cx = layout.viewX - gutterWidth + totalW / 2; - const cy = layout.viewY + layout.viewH / 2; + const w = baseDims.w / panZoom.z; + const h = baseDims.h / panZoom.z; + const cx = baseCentre.x + panZoom.x; + const cy = baseCentre.y + panZoom.y; return `${cx - w / 2} ${cy - h / 2} ${w} ${h}`; }); diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts b/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts index 57c2bd48..a0dacba6 100644 --- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts +++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts @@ -36,12 +36,35 @@ function rectsCentroid(svg: SVGElement): { x: number; y: number } { } describe('StammbaumTree viewBox', () => { + it('offsets the viewBox origin by the pan state (#692)', async () => { + render(StammbaumTree, { + nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }], + edges: [], + selectedId: null, + panZoom: { x: 100, y: 40, z: 1 }, + showGutter: false, + onSelect: () => {} + }); + + const svg = document.querySelector('svg')!; + const [x, y, w, h] = parseViewBox(svg); + + // Same dimensions as the unpanned default (z=1)… + expect(w).toBe(1200); + expect(h).toBe(800); + + // …but the origin is shifted by the pan offset. + const unpannedX = -(1200 / 2 - 160 / 2); // single 160-wide node centred + expect(x).toBeCloseTo(unpannedX + 100, 6); + expect(y).toBeCloseTo(-(800 / 2 - 56 / 2) + 40, 6); + }); + it('uses the minimum size and centers a single node', async () => { render(StammbaumTree, { nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }], edges: [], selectedId: null, - zoom: 1, + panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); @@ -114,7 +137,7 @@ describe('StammbaumTree viewBox', () => { } ], selectedId: null, - zoom: 1, + panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); @@ -174,7 +197,7 @@ describe('StammbaumTree viewBox', () => { } ], selectedId: null, - zoom: 1, + panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); @@ -277,7 +300,7 @@ describe('StammbaumTree viewBox', () => { } ], selectedId: null, - zoom: 1, + panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); @@ -335,7 +358,7 @@ describe('StammbaumTree viewBox', () => { } ], selectedId: null, - zoom: 1, + panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); @@ -368,7 +391,7 @@ describe('StammbaumTree viewBox', () => { } ], selectedId: null, - zoom: 1, + panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); @@ -393,7 +416,7 @@ describe('StammbaumTree node rendering branches', () => { ], edges: [], selectedId: ID_A, - zoom: 1, + panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); @@ -409,7 +432,7 @@ describe('StammbaumTree node rendering branches', () => { ], edges: [], selectedId: null, - zoom: 1, + panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); @@ -422,7 +445,7 @@ describe('StammbaumTree node rendering branches', () => { nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true, deathYear: 1950 }], edges: [], selectedId: null, - zoom: 1, + panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); @@ -434,7 +457,7 @@ describe('StammbaumTree node rendering branches', () => { nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }], edges: [], selectedId: null, - zoom: 1, + panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); @@ -447,7 +470,7 @@ describe('StammbaumTree node rendering branches', () => { nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }], edges: [], selectedId: null, - zoom: 1, + panZoom: { x: 0, y: 0, z: 1 }, onSelect }); @@ -462,7 +485,7 @@ describe('StammbaumTree node rendering branches', () => { nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }], edges: [], selectedId: null, - zoom: 1, + panZoom: { x: 0, y: 0, z: 1 }, onSelect }); @@ -478,7 +501,7 @@ describe('StammbaumTree node rendering branches', () => { nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }], edges: [], selectedId: null, - zoom: 1, + panZoom: { x: 0, y: 0, z: 1 }, onSelect }); @@ -493,7 +516,7 @@ describe('StammbaumTree node rendering branches', () => { nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }], edges: [], selectedId: null, - zoom: 1, + panZoom: { x: 0, y: 0, z: 1 }, onSelect }); @@ -520,7 +543,7 @@ describe('StammbaumTree node rendering branches', () => { } ], selectedId: null, - zoom: 1, + panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); @@ -547,7 +570,7 @@ describe('StammbaumTree node rendering branches', () => { } ], selectedId: null, - zoom: 1, + panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); @@ -575,7 +598,7 @@ describe('StammbaumTree node rendering branches', () => { } ], selectedId: null, - zoom: 1, + panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); @@ -588,7 +611,7 @@ describe('StammbaumTree node rendering branches', () => { nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }], edges: [], selectedId: null, - zoom: 1, + panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); @@ -607,7 +630,7 @@ describe('StammbaumTree node rendering branches', () => { nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }], edges: [], selectedId: null, - zoom: 1, + panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); @@ -636,7 +659,7 @@ describe('StammbaumTree node rendering branches', () => { ], edges: [], selectedId: null, - zoom: 1, + panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); @@ -653,7 +676,7 @@ describe('StammbaumTree node rendering branches', () => { ], edges: [], selectedId: ID_A, - zoom: 1, + panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); @@ -674,7 +697,7 @@ describe('StammbaumTree node rendering branches', () => { ], edges: [], selectedId: ID_A, - zoom: 1, + panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); @@ -696,7 +719,7 @@ describe('StammbaumTree generation gutter (#689)', () => { ], edges: [], selectedId: null, - zoom: 1, + panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {}, showGutter: true }); @@ -713,7 +736,7 @@ describe('StammbaumTree generation gutter (#689)', () => { nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }], edges: [], selectedId: null, - zoom: 1, + panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {}, showGutter: true }); @@ -730,7 +753,7 @@ describe('StammbaumTree generation gutter (#689)', () => { nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }], edges: [], selectedId: null, - zoom: 1, + panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {}, showGutter: false }); diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts index 7777a615..9110bb10 100644 --- a/frontend/src/lib/person/genealogy/panZoom.ts +++ b/frontend/src/lib/person/genealogy/panZoom.ts @@ -17,6 +17,9 @@ export const DEFAULT_ZOOM = 1; /** Minimum zoom a recentre will snap up to so the focal node's text is legible (OQ-005). */ export const LEGIBLE_ZOOM = 1; +/** Fixed zoom increment per keyboard `+`/`-` press and per control-button click (OQ-002). */ +export const ZOOM_STEP_KB = 0.1; + /** * The canvas view state. `x`/`y` are pan offsets applied to the viewBox centre * (SVG user units); `z` is the zoom factor. The default `{0, 0, 1}` frames the diff --git a/frontend/src/routes/stammbaum/+page.svelte b/frontend/src/routes/stammbaum/+page.svelte index f2c774dc..2c3ee9db 100644 --- a/frontend/src/routes/stammbaum/+page.svelte +++ b/frontend/src/routes/stammbaum/+page.svelte @@ -3,6 +3,12 @@ import { m } from '$lib/paraglide/messages.js'; import { page } from '$app/state'; import StammbaumTree from '$lib/person/genealogy/StammbaumTree.svelte'; import StammbaumSidePanel from '$lib/person/genealogy/StammbaumSidePanel.svelte'; +import { + type PanZoomState, + DEFAULT_VIEW, + clampZoom, + ZOOM_STEP_KB +} from '$lib/person/genealogy/panZoom'; import type { components } from '$lib/generated/api'; type PersonNodeDTO = components['schemas']['PersonNodeDTO']; @@ -23,12 +29,12 @@ let selectedId = $state( const selectedNode = $derived(data.nodes.find((n) => n.id === selectedId) ?? null); -let zoom = $state(1); +let view = $state(DEFAULT_VIEW); function zoomIn() { - zoom = Math.min(2, zoom + 0.1); + view = { ...view, z: clampZoom(view.z + ZOOM_STEP_KB) }; } function zoomOut() { - zoom = Math.max(0.4, zoom - 0.1); + view = { ...view, z: clampZoom(view.z - ZOOM_STEP_KB) }; } @@ -97,7 +103,7 @@ function zoomOut() { nodes={data.nodes} edges={data.edges} selectedId={selectedId} - zoom={zoom} + panZoom={view} onSelect={(id) => (selectedId = id)} /> -- 2.49.1 From da1984b916b6b098743b2818b6fb1509e0c03804 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 16:39:55 +0200 Subject: [PATCH 08/39] feat(stammbaum): keyboard pan/zoom on the canvas (#692) +/- zoom by the fixed step and arrow keys pan by a tenth of the visible extent, emitted via onPanZoom. Provides the keyboard-only alternative path required by NFR-A11Y-002. Nodes keep their own Enter/Space selection. Co-Authored-By: Claude Opus 4.8 --- .../lib/person/genealogy/StammbaumTree.svelte | 53 ++++++++++++++++++- .../genealogy/StammbaumTree.svelte.test.ts | 39 ++++++++++++++ frontend/src/routes/stammbaum/+page.svelte | 1 + 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte b/frontend/src/lib/person/genealogy/StammbaumTree.svelte index 3fa83409..db74a366 100644 --- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte +++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte @@ -8,7 +8,7 @@ import { ROW_GAP, type Layout } from '$lib/person/genealogy/layout/buildLayout'; -import type { PanZoomState } from '$lib/person/genealogy/panZoom'; +import { type PanZoomState, clampZoom, ZOOM_STEP_KB } from '$lib/person/genealogy/panZoom'; type PersonNodeDTO = components['schemas']['PersonNodeDTO']; type RelationshipDTO = components['schemas']['RelationshipDTO']; @@ -18,6 +18,8 @@ interface Props { edges: RelationshipDTO[]; selectedId: string | null; panZoom: PanZoomState; + /** Emitted when the keyboard, a gesture, or a recentre changes the view. */ + onPanZoom?: (state: PanZoomState) => void; onSelect: (id: string) => void; /** * Force-show or force-hide the generation gutter. When undefined, falls @@ -28,7 +30,15 @@ interface Props { showGutter?: boolean; } -let { nodes, edges, selectedId, panZoom, onSelect, showGutter }: Props = $props(); +let { + nodes, + edges, + selectedId, + panZoom, + onPanZoom = () => {}, + onSelect, + showGutter +}: Props = $props(); const layout = $derived.by(() => buildLayout(nodes, edges)); @@ -112,6 +122,38 @@ function handleNodeKey(event: KeyboardEvent, id: string) { } } +// Canvas-level keyboard: `+`/`-` zoom by the fixed step (OQ-002), arrows pan by +// a tenth of the visible extent. Nodes keep their own Enter/Space selection. +function handleCanvasKey(event: KeyboardEvent) { + const stepX = (baseDims.w / panZoom.z) * 0.1; + const stepY = (baseDims.h / panZoom.z) * 0.1; + switch (event.key) { + case '+': + case '=': + onPanZoom({ ...panZoom, z: clampZoom(panZoom.z + ZOOM_STEP_KB) }); + break; + case '-': + case '_': + onPanZoom({ ...panZoom, z: clampZoom(panZoom.z - ZOOM_STEP_KB) }); + break; + case 'ArrowLeft': + onPanZoom({ ...panZoom, x: panZoom.x - stepX }); + break; + case 'ArrowRight': + onPanZoom({ ...panZoom, x: panZoom.x + stepX }); + break; + case 'ArrowUp': + onPanZoom({ ...panZoom, y: panZoom.y - stepY }); + break; + case 'ArrowDown': + onPanZoom({ ...panZoom, y: panZoom.y + stepY }); + break; + default: + return; + } + event.preventDefault(); +} + const parentEdges = $derived(edges.filter((e) => e.relationType === 'PARENT_OF')); const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF')); @@ -182,11 +224,18 @@ const parentLinks = $derived.by(() => { }); + + + -- 2.49.1 From 396c87f8ab78f6aaab79e78c6d7fcb4bb5e5c13d Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 16:54:34 +0200 Subject: [PATCH 13/39] feat(stammbaum): animate fit-to-screen, snap under reduced motion (#692) Fit-to-screen tweens to the default view over 300ms via animateView (eased, lerpView-driven) and snaps instantly when prefers-reduced-motion is set (US-PAN-004 AC2, NFR-A11Y-003). Co-Authored-By: Claude Opus 4.8 --- .../lib/person/genealogy/animateView.test.ts | 17 +++++++++ .../src/lib/person/genealogy/animateView.ts | 38 +++++++++++++++++++ .../src/lib/person/genealogy/panZoom.test.ts | 15 ++++++++ frontend/src/lib/person/genealogy/panZoom.ts | 9 +++++ frontend/src/routes/stammbaum/+page.svelte | 7 +++- 5 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/person/genealogy/animateView.test.ts create mode 100644 frontend/src/lib/person/genealogy/animateView.ts diff --git a/frontend/src/lib/person/genealogy/animateView.test.ts b/frontend/src/lib/person/genealogy/animateView.test.ts new file mode 100644 index 00000000..f25fda18 --- /dev/null +++ b/frontend/src/lib/person/genealogy/animateView.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect, vi } from 'vitest'; +import { animateView } from './animateView'; + +describe('animateView (reduced motion)', () => { + const from = { x: 0, y: 0, z: 1 }; + const to = { x: 80, y: -30, z: 2 }; + + it('snaps straight to the target in a single frame when reduced motion is on', () => { + const onFrame = vi.fn(); + const cancel = animateView(from, to, onFrame, { reducedMotion: true }); + + expect(onFrame).toHaveBeenCalledTimes(1); + expect(onFrame).toHaveBeenCalledWith(to); + expect(typeof cancel).toBe('function'); + cancel(); + }); +}); diff --git a/frontend/src/lib/person/genealogy/animateView.ts b/frontend/src/lib/person/genealogy/animateView.ts new file mode 100644 index 00000000..72754899 --- /dev/null +++ b/frontend/src/lib/person/genealogy/animateView.ts @@ -0,0 +1,38 @@ +import { lerpView, type PanZoomState } from '$lib/person/genealogy/panZoom'; + +/** Fit / recentre animation duration (US-PAN-004 AC2: ≤ 300 ms). */ +export const VIEW_ANIM_MS = 300; + +const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3); + +/** Snapshot of the user's reduced-motion preference (non-reactive, browser-only). */ +export function prefersReducedMotion(): boolean { + return typeof window !== 'undefined' && typeof window.matchMedia === 'function' + ? window.matchMedia('(prefers-reduced-motion: reduce)').matches + : false; +} + +/** + * Tween the view from `from` to `to`, calling `onFrame` with each interpolated + * state. Honors reduced motion by snapping straight to the target (NFR-A11Y-003, + * REQ-PAN-005). Returns a cancel function. + */ +export function animateView( + from: PanZoomState, + to: PanZoomState, + onFrame: (state: PanZoomState) => void, + opts: { reducedMotion?: boolean; durationMs?: number } = {} +): () => void { + if (opts.reducedMotion) { + onFrame(to); + return () => {}; + } + const duration = opts.durationMs ?? VIEW_ANIM_MS; + const start = performance.now(); + let raf = requestAnimationFrame(function step(now: number) { + const t = Math.min(1, (now - start) / duration); + onFrame(lerpView(from, to, easeOutCubic(t))); + if (t < 1) raf = requestAnimationFrame(step); + }); + return () => cancelAnimationFrame(raf); +} diff --git a/frontend/src/lib/person/genealogy/panZoom.test.ts b/frontend/src/lib/person/genealogy/panZoom.test.ts index 441c3b76..fcde710b 100644 --- a/frontend/src/lib/person/genealogy/panZoom.test.ts +++ b/frontend/src/lib/person/genealogy/panZoom.test.ts @@ -7,6 +7,7 @@ import { zoomAtPoint, recentreOn, clampPan, + lerpView, DEFAULT_VIEW, DEFAULT_ZOOM, LEGIBLE_ZOOM, @@ -166,3 +167,17 @@ describe('clampPan', () => { expect(clampPan({ x: 100, y: -50, z: 2 }, 1000, 800)).toEqual({ x: 100, y: -50, z: 2 }); }); }); + +describe('lerpView', () => { + const from = { x: 0, y: 0, z: 1 }; + const to = { x: 100, y: -40, z: 2 }; + + it('returns the start at t=0 and the end at t=1', () => { + expect(lerpView(from, to, 0)).toEqual(from); + expect(lerpView(from, to, 1)).toEqual(to); + }); + + it('interpolates each axis linearly at t=0.5', () => { + expect(lerpView(from, to, 0.5)).toEqual({ x: 50, y: -20, z: 1.5 }); + }); +}); diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts index 3190ed3c..9b83cd27 100644 --- a/frontend/src/lib/person/genealogy/panZoom.ts +++ b/frontend/src/lib/person/genealogy/panZoom.ts @@ -113,6 +113,15 @@ export function zoomAtPoint( }; } +/** Linearly interpolate between two view states (drives fit/recentre tweening). */ +export function lerpView(from: PanZoomState, to: PanZoomState, t: number): PanZoomState { + return { + x: from.x + (to.x - from.x) * t, + y: from.y + (to.y - from.y) * t, + z: from.z + (to.z - from.z) * t + }; +} + /** * Clamp the pan offset so the canvas cannot be dragged off the edge (US-PAN-001 * AC4 — no infinite scroll). The pannable range on each axis is half the diff --git a/frontend/src/routes/stammbaum/+page.svelte b/frontend/src/routes/stammbaum/+page.svelte index ad14c2be..24f9a98d 100644 --- a/frontend/src/routes/stammbaum/+page.svelte +++ b/frontend/src/routes/stammbaum/+page.svelte @@ -10,6 +10,7 @@ import { clampZoom, ZOOM_STEP_KB } from '$lib/person/genealogy/panZoom'; +import { animateView, prefersReducedMotion } from '$lib/person/genealogy/animateView'; import type { components } from '$lib/generated/api'; type PersonNodeDTO = components['schemas']['PersonNodeDTO']; @@ -37,8 +38,12 @@ function zoomIn() { function zoomOut() { view = { ...view, z: clampZoom(view.z - ZOOM_STEP_KB) }; } +let cancelAnimation = () => {}; function fitToScreen() { - view = DEFAULT_VIEW; + cancelAnimation(); + cancelAnimation = animateView(view, DEFAULT_VIEW, (v) => (view = v), { + reducedMotion: prefersReducedMotion() + }); } -- 2.49.1 From 8d29bb10e25c1a93707be4c62b75db12c1621a39 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 16:58:36 +0200 Subject: [PATCH 14/39] feat(stammbaum): server-clamped initial view from ?cx&cy&z (#692) The server load parses and sanitises the shareable pan/zoom params (degrading Infinity/NaN, clamping zoom) into initialView, which seeds the page view. A crafted link can no longer blank the SVG (Nora). US-PANEL-002 AC2 groundwork. Co-Authored-By: Claude Opus 4.8 --- frontend/src/routes/stammbaum/+page.server.ts | 14 ++++- frontend/src/routes/stammbaum/+page.svelte | 4 +- .../src/routes/stammbaum/page.server.test.ts | 56 +++++++++++++++++++ .../src/routes/stammbaum/page.svelte.test.ts | 21 +++++-- 4 files changed, 85 insertions(+), 10 deletions(-) create mode 100644 frontend/src/routes/stammbaum/page.server.test.ts diff --git a/frontend/src/routes/stammbaum/+page.server.ts b/frontend/src/routes/stammbaum/+page.server.ts index f060df29..f9d5a2a5 100644 --- a/frontend/src/routes/stammbaum/+page.server.ts +++ b/frontend/src/routes/stammbaum/+page.server.ts @@ -1,8 +1,9 @@ import { error, redirect } from '@sveltejs/kit'; import { createApiClient, extractErrorCode } from '$lib/shared/api.server'; import { getErrorMessage } from '$lib/shared/errors'; +import { parsePanZoomParams } from '$lib/person/genealogy/panZoom'; -export async function load({ fetch }) { +export async function load({ fetch, url }) { const api = createApiClient(fetch); const result = await api.GET('/api/network'); @@ -12,6 +13,15 @@ export async function load({ fetch }) { throw error(result.response.status, getErrorMessage(extractErrorCode(result.error))); } + // Sanitise the shareable pan/zoom params server-side so a crafted link + // (?z=Infinity, ?cx=NaN) degrades to a safe view before reaching layout + // geometry (Nora #692). + const initialView = parsePanZoomParams({ + cx: url.searchParams.get('cx'), + cy: url.searchParams.get('cy'), + z: url.searchParams.get('z') + }); + const network = result.data!; - return { nodes: network.nodes ?? [], edges: network.edges ?? [] }; + return { nodes: network.nodes ?? [], edges: network.edges ?? [], initialView }; } diff --git a/frontend/src/routes/stammbaum/+page.svelte b/frontend/src/routes/stammbaum/+page.svelte index 24f9a98d..69ed0cfc 100644 --- a/frontend/src/routes/stammbaum/+page.svelte +++ b/frontend/src/routes/stammbaum/+page.svelte @@ -17,7 +17,7 @@ type PersonNodeDTO = components['schemas']['PersonNodeDTO']; type RelationshipDTO = components['schemas']['RelationshipDTO']; interface Props { - data: { nodes: PersonNodeDTO[]; edges: RelationshipDTO[] }; + data: { nodes: PersonNodeDTO[]; edges: RelationshipDTO[]; initialView: PanZoomState }; } let { data }: Props = $props(); @@ -31,7 +31,7 @@ let selectedId = $state( const selectedNode = $derived(data.nodes.find((n) => n.id === selectedId) ?? null); -let view = $state(DEFAULT_VIEW); +let view = $state(data.initialView); function zoomIn() { view = { ...view, z: clampZoom(view.z + ZOOM_STEP_KB) }; } diff --git a/frontend/src/routes/stammbaum/page.server.test.ts b/frontend/src/routes/stammbaum/page.server.test.ts new file mode 100644 index 00000000..f755ad14 --- /dev/null +++ b/frontend/src/routes/stammbaum/page.server.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +vi.mock('$lib/shared/api.server', () => ({ + createApiClient: vi.fn(), + extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code +})); + +import { createApiClient } from '$lib/shared/api.server'; +import { DEFAULT_VIEW, MAX_ZOOM } from '$lib/person/genealogy/panZoom'; + +beforeEach(() => vi.clearAllMocks()); + +function mockNetwork() { + vi.mocked(createApiClient).mockReturnValue({ + GET: vi.fn().mockResolvedValue({ response: { ok: true }, data: { nodes: [], edges: [] } }) + } as unknown as ReturnType); +} + +function loadEvent(query = '') { + const url = new URL(`http://localhost/stammbaum${query}`); + return { + fetch: vi.fn() as unknown as typeof fetch, + request: new Request(url), + url + }; +} + +describe('/stammbaum +page.server load — initialView', () => { + it('returns DEFAULT_VIEW when no pan/zoom params are present', async () => { + mockNetwork(); + const { load } = await import('./+page.server'); + const result = await load(loadEvent() as never); + expect(result.initialView).toEqual(DEFAULT_VIEW); + }); + + it('parses and returns valid ?cx&cy&z params', async () => { + mockNetwork(); + const { load } = await import('./+page.server'); + const result = await load(loadEvent('?cx=120&cy=-40&z=1.5') as never); + expect(result.initialView).toEqual({ x: 120, y: -40, z: 1.5 }); + }); + + it('degrades a crafted ?z=Infinity to a safe view (Nora #692)', async () => { + mockNetwork(); + const { load } = await import('./+page.server'); + const result = await load(loadEvent('?z=Infinity&cx=NaN') as never); + expect(result.initialView).toEqual(DEFAULT_VIEW); + }); + + it('clamps an out-of-range zoom server-side', async () => { + mockNetwork(); + const { load } = await import('./+page.server'); + const result = await load(loadEvent('?z=99') as never); + expect(result.initialView.z).toBe(MAX_ZOOM); + }); +}); diff --git a/frontend/src/routes/stammbaum/page.svelte.test.ts b/frontend/src/routes/stammbaum/page.svelte.test.ts index a788c102..a1a4bcc7 100644 --- a/frontend/src/routes/stammbaum/page.svelte.test.ts +++ b/frontend/src/routes/stammbaum/page.svelte.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; +import { DEFAULT_VIEW } from '$lib/person/genealogy/panZoom'; const mockPage = { url: new URL('http://localhost/stammbaum'), @@ -28,7 +29,7 @@ describe('stammbaum page', () => { it('shows the empty state when there are no family nodes', async () => { mockPage.url = new URL('http://localhost/stammbaum'); const Stammbaum = await loadComponent(); - render(Stammbaum, { props: { data: { nodes: [], edges: [] } } }); + render(Stammbaum, { props: { data: { nodes: [], edges: [], initialView: DEFAULT_VIEW } } }); await expect .element(page.getByRole('heading', { name: /noch keine familienmitglieder/i })) @@ -41,7 +42,7 @@ describe('stammbaum page', () => { it('hides zoom controls when there are no nodes', async () => { mockPage.url = new URL('http://localhost/stammbaum'); const Stammbaum = await loadComponent(); - render(Stammbaum, { props: { data: { nodes: [], edges: [] } } }); + render(Stammbaum, { props: { data: { nodes: [], edges: [], initialView: DEFAULT_VIEW } } }); await expect.element(page.getByRole('button', { name: /vergrößern/i })).not.toBeInTheDocument(); await expect @@ -52,7 +53,9 @@ describe('stammbaum page', () => { it('renders the page heading and zoom controls when nodes are present', async () => { mockPage.url = new URL('http://localhost/stammbaum'); const Stammbaum = await loadComponent(); - render(Stammbaum, { props: { data: { nodes: sampleNodes, edges: [] } } }); + render(Stammbaum, { + props: { data: { nodes: sampleNodes, edges: [], initialView: DEFAULT_VIEW } } + }); await expect.element(page.getByRole('heading', { name: /stammbaum/i })).toBeVisible(); await expect.element(page.getByRole('button', { name: /vergrößern/i })).toBeVisible(); @@ -62,7 +65,9 @@ describe('stammbaum page', () => { it('preselects a node when the URL has a focus query param matching an existing node', async () => { mockPage.url = new URL('http://localhost/stammbaum?focus=p-1'); const Stammbaum = await loadComponent(); - render(Stammbaum, { props: { data: { nodes: sampleNodes, edges: [] } } }); + render(Stammbaum, { + props: { data: { nodes: sampleNodes, edges: [], initialView: DEFAULT_VIEW } } + }); await expect.element(page.getByRole('complementary')).toBeVisible(); }); @@ -70,7 +75,9 @@ describe('stammbaum page', () => { it('does not preselect when the focus param does not match any node', async () => { mockPage.url = new URL('http://localhost/stammbaum?focus=missing'); const Stammbaum = await loadComponent(); - render(Stammbaum, { props: { data: { nodes: sampleNodes, edges: [] } } }); + render(Stammbaum, { + props: { data: { nodes: sampleNodes, edges: [], initialView: DEFAULT_VIEW } } + }); await expect.element(page.getByRole('complementary')).not.toBeInTheDocument(); }); @@ -78,7 +85,9 @@ describe('stammbaum page', () => { it('clamps the zoom level when the zoom-out button is clicked many times', async () => { mockPage.url = new URL('http://localhost/stammbaum'); const Stammbaum = await loadComponent(); - render(Stammbaum, { props: { data: { nodes: sampleNodes, edges: [] } } }); + render(Stammbaum, { + props: { data: { nodes: sampleNodes, edges: [], initialView: DEFAULT_VIEW } } + }); const zoomOut = page.getByRole('button', { name: /verkleinern/i }); for (let i = 0; i < 10; i++) await zoomOut.click(); -- 2.49.1 From 289c3bbfb598eea9c2c98d12e7e577754c53cb3d Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 17:02:47 +0200 Subject: [PATCH 15/39] feat(stammbaum): sync view to shareable ?cx&cy&z URL (#692) A view-keyed effect mirrors pan/zoom into the URL via replaceState (URL read untracked to avoid a feedback loop). State survives panel open/close (US-PANEL-002 AC1) and a shared link reproduces the view (AC2). Co-Authored-By: Claude Opus 4.8 --- .../genealogy/StammbaumTree.svelte.test.ts | 3 ++- frontend/src/routes/stammbaum/+page.svelte | 18 +++++++++++++++ .../src/routes/stammbaum/page.svelte.test.ts | 22 +++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts b/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts index 737d595f..6584eb1c 100644 --- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts +++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { render } from 'vitest-browser-svelte'; import StammbaumTree from './StammbaumTree.svelte'; +import type { PanZoomState } from './panZoom'; const ID_A = '00000000-0000-0000-0000-000000000001'; const ID_B = '00000000-0000-0000-0000-000000000002'; @@ -512,7 +513,7 @@ describe('StammbaumTree node rendering branches', () => { }); describe('StammbaumTree keyboard pan/zoom (#692)', () => { - const renderTree = (onPanZoom: ReturnType) => + const renderTree = (onPanZoom: (state: PanZoomState) => void) => render(StammbaumTree, { nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }], edges: [], diff --git a/frontend/src/routes/stammbaum/+page.svelte b/frontend/src/routes/stammbaum/+page.svelte index 69ed0cfc..8d8923e2 100644 --- a/frontend/src/routes/stammbaum/+page.svelte +++ b/frontend/src/routes/stammbaum/+page.svelte @@ -1,6 +1,8 @@ + + + diff --git a/frontend/src/lib/person/genealogy/StammbaumBottomSheet.svelte.test.ts b/frontend/src/lib/person/genealogy/StammbaumBottomSheet.svelte.test.ts new file mode 100644 index 00000000..a0c1f72c --- /dev/null +++ b/frontend/src/lib/person/genealogy/StammbaumBottomSheet.svelte.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import StammbaumBottomSheet from './StammbaumBottomSheet.svelte'; + +const node = { id: 'p-1', displayName: 'Anna Schmidt', familyMember: true }; + +describe('StammbaumBottomSheet (#692)', () => { + it('renders as a dialog with the person name as its accessible name', async () => { + render(StammbaumBottomSheet, { node, canWrite: false, onClose: () => {} }); + const dialog = document.querySelector('[role="dialog"]')!; + expect(dialog).toBeTruthy(); + expect(dialog.getAttribute('aria-label')).toBe('Anna Schmidt'); + }); + + it('dismisses on Escape', async () => { + const onClose = vi.fn(); + render(StammbaumBottomSheet, { node, canWrite: false, onClose }); + const dialog = document.querySelector('[role="dialog"]') as HTMLElement; + dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(onClose).toHaveBeenCalled(); + }); + + it('dismisses when the backdrop is tapped', async () => { + const onClose = vi.fn(); + render(StammbaumBottomSheet, { node, canWrite: false, onClose }); + const backdrop = document.querySelector('button[aria-label]') as HTMLButtonElement; + backdrop.click(); + expect(onClose).toHaveBeenCalled(); + }); + + it('dismisses on a downward swipe past the threshold', async () => { + const onClose = vi.fn(); + render(StammbaumBottomSheet, { node, canWrite: false, onClose }); + const handle = document.querySelector('[role="dialog"] > div') as HTMLElement; + handle.dispatchEvent( + new PointerEvent('pointerdown', { pointerId: 1, clientY: 100, bubbles: true }) + ); + handle.dispatchEvent( + new PointerEvent('pointermove', { pointerId: 1, clientY: 220, bubbles: true }) + ); + handle.dispatchEvent( + new PointerEvent('pointerup', { pointerId: 1, clientY: 220, bubbles: true }) + ); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/routes/stammbaum/+page.svelte b/frontend/src/routes/stammbaum/+page.svelte index 8d8923e2..084f60db 100644 --- a/frontend/src/routes/stammbaum/+page.svelte +++ b/frontend/src/routes/stammbaum/+page.svelte @@ -5,6 +5,7 @@ import { page } from '$app/state'; import { replaceState } from '$app/navigation'; import StammbaumTree from '$lib/person/genealogy/StammbaumTree.svelte'; import StammbaumSidePanel from '$lib/person/genealogy/StammbaumSidePanel.svelte'; +import StammbaumBottomSheet from '$lib/person/genealogy/StammbaumBottomSheet.svelte'; import StammbaumControls from '$lib/person/genealogy/StammbaumControls.svelte'; import { type PanZoomState, @@ -127,16 +128,12 @@ $effect(() => { onClose={() => (selectedId = null)} /> - -
- (selectedId = null)} - /> -
+ + (selectedId = null)} + /> {/if} {/if} -- 2.49.1 From 1dffb430ace74e6031fa14c510f7a3d6b600a5f7 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 17:10:49 +0200 Subject: [PATCH 18/39] feat(stammbaum): centre-on-person control in the panel title row (#692) Add an onCentre control to StammbaumSidePanel (title row, both desktop aside and mobile sheet). The page drives a one-shot centreOnId so StammbaumTree recentres the canvas on the focal node (US-PAN-005). Also tighten the panel spec's deathYear fixture to a valid type. Co-Authored-By: Claude Opus 4.8 --- .../genealogy/StammbaumBottomSheet.svelte | 5 +- .../genealogy/StammbaumSidePanel.svelte | 58 +++++++++++++------ .../StammbaumSidePanel.svelte.spec.ts | 19 +++++- frontend/src/routes/stammbaum/+page.svelte | 15 ++++- 4 files changed, 76 insertions(+), 21 deletions(-) diff --git a/frontend/src/lib/person/genealogy/StammbaumBottomSheet.svelte b/frontend/src/lib/person/genealogy/StammbaumBottomSheet.svelte index 22cf3234..ac9ab3c2 100644 --- a/frontend/src/lib/person/genealogy/StammbaumBottomSheet.svelte +++ b/frontend/src/lib/person/genealogy/StammbaumBottomSheet.svelte @@ -10,9 +10,10 @@ interface Props { node: PersonNodeDTO; canWrite: boolean; onClose: () => void; + onCentre?: () => void; } -let { node, canWrite, onClose }: Props = $props(); +let { node, canWrite, onClose, onCentre }: Props = $props(); // Swipe the sheet down past this threshold to dismiss it (Leonie). const SWIPE_DISMISS_PX = 80; @@ -70,5 +71,5 @@ function onKeydown(event: KeyboardEvent) { - + diff --git a/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte b/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte index c243de71..f0c9f429 100644 --- a/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte +++ b/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte @@ -14,10 +14,12 @@ type InferredRelationshipWithPersonDTO = components['schemas']['InferredRelation interface Props { node: PersonNodeDTO; onClose: () => void; + /** When provided, a "centre on this person" control appears in the title row (US-PAN-005). */ + onCentre?: () => void; canWrite?: boolean; } -let { node, onClose, canWrite = false }: Props = $props(); +let { node, onClose, onCentre, canWrite = false }: Props = $props(); let directRels = $state([]); let derivedRels = $state([]); @@ -95,23 +97,45 @@ const topDerived = $derived(

{/if} - + + + {#if error} diff --git a/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte.spec.ts b/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte.spec.ts index 4dda616b..142ccce2 100644 --- a/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte.spec.ts +++ b/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte.spec.ts @@ -11,7 +11,7 @@ const makeNode = () => ({ id: 'person-1', displayName: 'Alice Müller', birthYear: 1900, - deathYear: null, + deathYear: undefined, familyMember: true }); @@ -50,6 +50,23 @@ describe('StammbaumSidePanel', () => { await expect.element(page.getByText('Alice Müller')).toBeInTheDocument(); }); + it('hides the centre control when onCentre is not provided', async () => { + render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: false }); + await expect + .element(page.getByRole('button', { name: 'Auf diese Person zentrieren' })) + .not.toBeInTheDocument(); + }); + + it('calls onCentre when the centre control is clicked (US-PAN-005)', async () => { + const onCentre = vi.fn(); + render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), onCentre, canWrite: false }); + const btn = [...document.querySelectorAll('button')].find( + (b) => b.getAttribute('aria-label') === 'Auf diese Person zentrieren' + ); + btn!.click(); + expect(onCentre).toHaveBeenCalledOnce(); + }); + it('shows empty-relationships message when no direct relationships are loaded', async () => { render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: false }); await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument(); diff --git a/frontend/src/routes/stammbaum/+page.svelte b/frontend/src/routes/stammbaum/+page.svelte index 084f60db..5e166e43 100644 --- a/frontend/src/routes/stammbaum/+page.svelte +++ b/frontend/src/routes/stammbaum/+page.svelte @@ -1,5 +1,5 @@ + +{#if visible} +
+
+ {m.stammbaum_affordance_hint()} + +
+
+{/if} diff --git a/frontend/src/lib/person/genealogy/StammbaumAffordance.svelte.test.ts b/frontend/src/lib/person/genealogy/StammbaumAffordance.svelte.test.ts new file mode 100644 index 00000000..d8cfff1c --- /dev/null +++ b/frontend/src/lib/person/genealogy/StammbaumAffordance.svelte.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import StammbaumAffordance from './StammbaumAffordance.svelte'; + +const STORAGE_KEY = 'stammbaumAffordanceDismissedAt'; + +describe('StammbaumAffordance (#692)', () => { + beforeEach(() => localStorage.clear()); + + it('shows the hint on a touch device that has not dismissed it', async () => { + render(StammbaumAffordance, { touch: true }); + await vi.waitFor(() => expect(document.querySelector('[role="status"]')).not.toBeNull()); + expect(document.body.textContent).toContain('Ziehen'); + }); + + it('does not show on non-touch devices (OQ-008)', async () => { + render(StammbaumAffordance, { touch: false }); + expect(document.querySelector('[role="status"]')).toBeNull(); + }); + + it('hides and records dismissal when the close button is clicked', async () => { + render(StammbaumAffordance, { touch: true }); + const dismiss = [...document.querySelectorAll('button')][0]; + dismiss.click(); + await vi.waitFor(() => expect(document.querySelector('[role="status"]')).toBeNull()); + expect(localStorage.getItem(STORAGE_KEY)).toBeTruthy(); + }); + + it('does not reappear within the 30-day window (NFR-USE-001)', async () => { + localStorage.setItem(STORAGE_KEY, String(Date.now())); + render(StammbaumAffordance, { touch: true }); + expect(document.querySelector('[role="status"]')).toBeNull(); + }); +}); diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte b/frontend/src/lib/person/genealogy/StammbaumTree.svelte index 9dd3c29f..0fc9e0be 100644 --- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte +++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte @@ -29,6 +29,8 @@ interface Props { onPanZoom?: (state: PanZoomState) => void; /** When set to a node id, the canvas recentres on that node (US-PAN-005). */ centreOnId?: string | null; + /** Fired on the first pointer interaction with the canvas (affordance dismiss). */ + onActivity?: () => void; onSelect: (id: string) => void; /** * Force-show or force-hide the generation gutter. When undefined, falls @@ -46,6 +48,7 @@ let { panZoom, onPanZoom = () => {}, centreOnId = null, + onActivity, onSelect, showGutter }: Props = $props(); @@ -295,7 +298,8 @@ const parentLinks = $derived.by(() => { baseCentreX: baseCentre.x, baseCentreY: baseCentre.y, reducedMotion, - onPanZoom + onPanZoom, + onGestureStart: onActivity }} class="block h-full w-full" > diff --git a/frontend/src/routes/stammbaum/+page.svelte b/frontend/src/routes/stammbaum/+page.svelte index 5e166e43..a7964372 100644 --- a/frontend/src/routes/stammbaum/+page.svelte +++ b/frontend/src/routes/stammbaum/+page.svelte @@ -7,6 +7,7 @@ import StammbaumTree from '$lib/person/genealogy/StammbaumTree.svelte'; import StammbaumSidePanel from '$lib/person/genealogy/StammbaumSidePanel.svelte'; import StammbaumBottomSheet from '$lib/person/genealogy/StammbaumBottomSheet.svelte'; import StammbaumControls from '$lib/person/genealogy/StammbaumControls.svelte'; +import StammbaumAffordance from '$lib/person/genealogy/StammbaumAffordance.svelte'; import { type PanZoomState, DEFAULT_VIEW, @@ -36,6 +37,7 @@ let selectedId = $state( const selectedNode = $derived(data.nodes.find((n) => n.id === selectedId) ?? null); let view = $state(data.initialView); +let canvasActivity = $state(false); function zoomIn() { view = { ...view, z: clampZoom(view.z + ZOOM_STEP_KB) }; } @@ -124,8 +126,10 @@ $effect(() => { panZoom={view} centreOnId={centreOnId} onPanZoom={(v) => (view = v)} + onActivity={() => (canvasActivity = true)} onSelect={(id) => (selectedId = id)} /> + {#if selectedNode} -- 2.49.1 From 80f5e0b14790d2594ab5c35645dae9653ca2ba16 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 17:15:36 +0200 Subject: [PATCH 20/39] test(stammbaum): mobile visual + structural e2e at 320/414/768 (#692) VISUAL-gated screenshots of the first-load affordance + control cluster at each width and the bottom-sheet-open state at 414px, plus always-on structural assertions. New snapshots; the #361 desktop baselines are untouched. Baselines regenerate in CI via --update-snapshots. Co-Authored-By: Claude Opus 4.8 --- frontend/e2e/stammbaum-mobile.visual.spec.ts | 67 ++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 frontend/e2e/stammbaum-mobile.visual.spec.ts diff --git a/frontend/e2e/stammbaum-mobile.visual.spec.ts b/frontend/e2e/stammbaum-mobile.visual.spec.ts new file mode 100644 index 00000000..6de8c48b --- /dev/null +++ b/frontend/e2e/stammbaum-mobile.visual.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from '@playwright/test'; + +// Visual + structural coverage for the #692 mobile read path (pan/zoom/fit, +// first-load affordance, bottom-sheet person panel). +// +// Snapshot assertions are gated on VISUAL=1 because they need pre-captured +// baselines — regenerate in CI with `playwright test --update-snapshots` after +// intentional UI changes. Structural assertions run unconditionally. The whole +// suite is also subject to the project-wide Chromium-in-CI gate (#363); it +// captures new snapshots rather than replacing the #361 desktop baselines. +const VISUAL = process.env.VISUAL === '1'; + +const WIDTHS = [320, 414, 768] as const; + +test.describe('Stammbaum — mobile read path (#692)', () => { + // Touch emulation so the canvas reports pointer:coarse and the first-load + // affordance appears; reduced-motion is already forced project-wide. + test.use({ hasTouch: true, isMobile: true }); + + for (const width of WIDTHS) { + test(`affordance + controls render at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 720 }); + await page.addInitScript(() => localStorage.removeItem('stammbaumAffordanceDismissedAt')); + await page.goto('/stammbaum'); + await expect(page.getByRole('heading', { name: 'Stammbaum' })).toBeVisible(); + + const empty = page.getByRole('heading', { name: 'Noch keine Familienmitglieder' }); + if (await empty.isVisible().catch(() => false)) { + test.skip(true, 'no seeded family tree in this environment'); + } + + // Bottom-right control cluster with the fit-to-screen affordance. + await expect(page.getByTestId('fit-to-screen')).toBeVisible(); + // First-load interactive hint (touch only). + await expect(page.getByRole('status')).toBeVisible(); + + if (VISUAL) { + await expect(page).toHaveScreenshot(`stammbaum-affordance-${width}.png`, { + animations: 'disabled' + }); + } + }); + } + + test('bottom sheet opens on node tap at 414px and preserves the canvas', async ({ page }) => { + await page.setViewportSize({ width: 414, height: 720 }); + await page.goto('/stammbaum'); + await expect(page.getByRole('heading', { name: 'Stammbaum' })).toBeVisible(); + + const node = page.locator('svg[aria-label="Stammbaum"] g[role="button"]').first(); + if ((await node.count()) === 0) test.skip(true, 'no seeded nodes to tap'); + + await node.tap(); + const sheet = page.getByRole('dialog'); + await expect(sheet).toBeVisible(); + + if (VISUAL) { + await expect(page).toHaveScreenshot('stammbaum-bottom-sheet-414.png', { + animations: 'disabled' + }); + } + + // Dismiss via the backdrop and confirm the sheet closes (state survives). + await page.getByRole('button', { name: 'Schließen' }).first().click(); + await expect(sheet).toBeHidden(); + }); +}); -- 2.49.1 From ba053b3c23d894a8d66be5056c8708b9ba24d737 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 17:17:10 +0200 Subject: [PATCH 21/39] docs(stammbaum): ADR-026 custom viewBox pan/zoom + glossary terms (#692) Record the reversal of OQ-007 (build custom over the existing viewBox rather than adopt the panzoom library) and add pan/zoom view-state + fit-to-screen glossary entries. Co-Authored-By: Claude Opus 4.8 --- docs/GLOSSARY.md | 4 ++ .../026-stammbaum-custom-viewbox-pan-zoom.md | 56 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 docs/adr/026-stammbaum-custom-viewbox-pan-zoom.md diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 6a6f840d..dcfc8ad6 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -125,6 +125,10 @@ _Not to be confused with [parented](#parented-layout)_ — loose is the absence **canonical fixture** (Stammbaum) — `frontend/src/lib/person/genealogy/__fixtures__/stammbaum.json`, a pinned `/api/network` snapshot used by `buildLayout.test.ts` for structural-property assertions against real data. Captured locally via `frontend/scripts/capture-network-fixture.mjs` with explicit credentials and a localhost backend; never invoked from CI. Sanity-gated by `validateFixture.ts` (≥ 50 nodes / ≥ 5 generations / ≥ 1 SPOUSE_OF edge / ≥ 1 multi-spouse person). +**pan/zoom view state** `[#692]` — the `{ x, y, z }` triple (`PanZoomState` in `frontend/src/lib/person/genealogy/panZoom.ts`) describing the Stammbaum canvas position: `x`/`y` are pan offsets applied to the SVG viewBox centre, `z` is the zoom factor (clamped 0.25–3.0). Mirrored into the shareable URL as `?cx&cy&z` and seeded server-side from those params. See [ADR-026](adr/026-stammbaum-custom-viewbox-pan-zoom.md). + +**fit-to-screen** `[user-facing, #692]` — the Stammbaum control (`⤢`) and initial state that frames the whole tree in the viewport. Because the base viewBox already encloses the layout at `z=1`, fit-to-screen is simply the default view `{x:0, y:0, z:1}`. + --- ## Other Domain Terms diff --git a/docs/adr/026-stammbaum-custom-viewbox-pan-zoom.md b/docs/adr/026-stammbaum-custom-viewbox-pan-zoom.md new file mode 100644 index 00000000..65b2ae9d --- /dev/null +++ b/docs/adr/026-stammbaum-custom-viewbox-pan-zoom.md @@ -0,0 +1,56 @@ +# ADR-026 — Stammbaum pan/zoom is a custom viewBox layer, not a third-party library + +**Date:** 2026-05-29 +**Status:** Accepted +**Issue:** #692 (mobile read path — pan, zoom, fit-to-view); supersedes OQ-007 +**Milestone:** Stammbaum mobile read path + +--- + +## Context + +#692 makes `/stammbaum` usable on phones: drag-to-pan, pinch/keyboard/wheel zoom, +fit-to-screen, recentre-on-person, a shareable URL view state, and an edge-fade +affordance. During issue grooming, **OQ-007 was resolved to adopt the `panzoom` +library** (timmywil v4.x) on the team's recommendation, pinned per NFR-MAINT-001. + +That recommendation predated a load-bearing implementation detail: `StammbaumTree.svelte` +already renders zoom by **deriving the SVG `viewBox`** (`w = baseW / z`, centred on the +layout bounding box, `preserveAspectRatio="xMidYMid meet"`) — not by applying a CSS +`transform`. The `panzoom` library operates by writing `transform` to a DOM node. Adopting +it would mean: + +- abandoning the proven viewBox derivation and the in-SVG generation gutter (#689), which + lives in SVG user-space coordinates and would have to be reconciled with a CSS-transformed + parent; +- re-deriving fit-to-screen, recentre, and the `?cx&cy&z` URL state against the library's + transform coordinate system; +- a client-only lazy import to keep the SSR-rendered tree from touching `window` at module + load; and +- ~8 KB of bundle for behaviour we can express in a few pure functions. + +## Decision + +**Build pan/zoom as a thin custom layer over the existing viewBox**, with no third-party +dependency. This reverses OQ-007. + +- All geometry is pure and unit-tested in `frontend/src/lib/person/genealogy/panZoom.ts`: + `clampZoom`, `parsePanZoomParams`/`serializePanZoomParams`, `screenDeltaToSvg`, + `zoomAtPoint` (centroid-anchored), `clampPan` (edge-clamp), `recentreOn`, `lerpView`. +- Pan offsets shift the viewBox centre; zoom scales its width/height. The default + `{x:0, y:0, z:1}` already frames the whole tree, so **fit-to-screen is a reset to the + default** — no bounding-box recomputation. +- DOM event wiring lives in the `panZoomGestures` action (pointer/wheel/pinch + inertia, + reduced-motion aware) and a keyboard handler on the SVG; both delegate to the pure module. + +## Consequences + +- **NFR-MAINT-001 (library pinning + feature-flag fallback) is moot** — no library is + adopted. The "swap-out point" is `panZoom.ts` + `panZoomGestures.ts`. +- Text stays vector-crisp at any zoom (SVG-native scaling), satisfying US-PAN-002 AC5. +- The #689 gutter and the #361 seeded-rank invariant are untouched by the pan/zoom layer. +- Geometry is testable in the fast node project; only the DOM glue needs the browser project. +- Trade-off: we own the inertia/pinch code (~a few hundred lines across the action) rather + than delegating it. This is acceptable given the testability and zero-dependency wins. + +The issue body's OQ-007 row is updated to point at this ADR. -- 2.49.1 From 7e859252a34be41de694aac7d4d6845da3992c1c Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 17:48:42 +0200 Subject: [PATCH 22/39] =?UTF-8?q?docs(stammbaum):=20renumber=20pan/zoom=20?= =?UTF-8?q?ADR=20026=E2=86=92027=20(collision=20with=20#361)=20(#692)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #361 layout ADR already owns 026; renumber the custom-viewBox pan/zoom ADR to 027 and update the glossary + panZoom.ts references (Elicit review). Co-Authored-By: Claude Opus 4.8 --- docs/GLOSSARY.md | 2 +- ...box-pan-zoom.md => 027-stammbaum-custom-viewbox-pan-zoom.md} | 2 +- frontend/src/lib/person/genealogy/panZoom.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename docs/adr/{026-stammbaum-custom-viewbox-pan-zoom.md => 027-stammbaum-custom-viewbox-pan-zoom.md} (97%) diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index dcfc8ad6..a3e1b359 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -125,7 +125,7 @@ _Not to be confused with [parented](#parented-layout)_ — loose is the absence **canonical fixture** (Stammbaum) — `frontend/src/lib/person/genealogy/__fixtures__/stammbaum.json`, a pinned `/api/network` snapshot used by `buildLayout.test.ts` for structural-property assertions against real data. Captured locally via `frontend/scripts/capture-network-fixture.mjs` with explicit credentials and a localhost backend; never invoked from CI. Sanity-gated by `validateFixture.ts` (≥ 50 nodes / ≥ 5 generations / ≥ 1 SPOUSE_OF edge / ≥ 1 multi-spouse person). -**pan/zoom view state** `[#692]` — the `{ x, y, z }` triple (`PanZoomState` in `frontend/src/lib/person/genealogy/panZoom.ts`) describing the Stammbaum canvas position: `x`/`y` are pan offsets applied to the SVG viewBox centre, `z` is the zoom factor (clamped 0.25–3.0). Mirrored into the shareable URL as `?cx&cy&z` and seeded server-side from those params. See [ADR-026](adr/026-stammbaum-custom-viewbox-pan-zoom.md). +**pan/zoom view state** `[#692]` — the `{ x, y, z }` triple (`PanZoomState` in `frontend/src/lib/person/genealogy/panZoom.ts`) describing the Stammbaum canvas position: `x`/`y` are pan offsets applied to the SVG viewBox centre, `z` is the zoom factor (clamped 0.25–3.0). Mirrored into the shareable URL as `?cx&cy&z` and seeded server-side from those params. See [ADR-027](adr/027-stammbaum-custom-viewbox-pan-zoom.md). **fit-to-screen** `[user-facing, #692]` — the Stammbaum control (`⤢`) and initial state that frames the whole tree in the viewport. Because the base viewBox already encloses the layout at `z=1`, fit-to-screen is simply the default view `{x:0, y:0, z:1}`. diff --git a/docs/adr/026-stammbaum-custom-viewbox-pan-zoom.md b/docs/adr/027-stammbaum-custom-viewbox-pan-zoom.md similarity index 97% rename from docs/adr/026-stammbaum-custom-viewbox-pan-zoom.md rename to docs/adr/027-stammbaum-custom-viewbox-pan-zoom.md index 65b2ae9d..9baf44f3 100644 --- a/docs/adr/026-stammbaum-custom-viewbox-pan-zoom.md +++ b/docs/adr/027-stammbaum-custom-viewbox-pan-zoom.md @@ -1,4 +1,4 @@ -# ADR-026 — Stammbaum pan/zoom is a custom viewBox layer, not a third-party library +# ADR-027 — Stammbaum pan/zoom is a custom viewBox layer, not a third-party library **Date:** 2026-05-29 **Status:** Accepted diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts index 9b83cd27..21296590 100644 --- a/frontend/src/lib/person/genealogy/panZoom.ts +++ b/frontend/src/lib/person/genealogy/panZoom.ts @@ -6,7 +6,7 @@ * of truth for the zoom bounds, the view-state shape, and every pure geometry * helper used by the gesture action, the URL serialiser, and the page. Keeping * the math here (and free of DOM access) makes it unit-testable in the node - * project. See ADR-026 for why this is custom rather than a third-party library. + * project. See ADR-027 for why this is custom rather than a third-party library. */ /** Resolved zoom bounds (OQ-001). */ -- 2.49.1 From 578bebbd8b6b82a772c4cc4aea674ae0d363bf72 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 17:56:22 +0200 Subject: [PATCH 23/39] =?UTF-8?q?fix(stammbaum):=20URL=20pan/zoom=20sync?= =?UTF-8?q?=20never=20fired=20=E2=80=94=20gate=20replaceState=20on=20route?= =?UTF-8?q?r-ready=20(#692)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit replaceState throws 'before the router is initialized' during hydration, which killed the sync $effect on its first tick so the URL never updated on pan/zoom. Gate the write behind a flag flipped after the first post-mount tick() (router started) plus a defensive try/catch. Verified live: zoom now updates ?z=. The prior component test mocked replaceState and masked this. Co-Authored-By: Claude Opus 4.8 --- frontend/src/routes/stammbaum/+page.svelte | 27 +++++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/frontend/src/routes/stammbaum/+page.svelte b/frontend/src/routes/stammbaum/+page.svelte index a7964372..1232827c 100644 --- a/frontend/src/routes/stammbaum/+page.svelte +++ b/frontend/src/routes/stammbaum/+page.svelte @@ -1,5 +1,5 @@ -- 2.49.1 From bb2a89da58581e8a114ff871482697ab594850dd Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 18:00:17 +0200 Subject: [PATCH 24/39] feat(stammbaum): land a fresh visit at readable z=3, keep fit-to-screen at z=1 (#692) A fresh visit (no URL state) now opens at INITIAL_VIEW (z=3) so node tiles and generation labels are legible on arrival; the fit-to-screen control still zooms out to the whole tree (DEFAULT_VIEW, z=1). Shared links with ?z still win. Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/person/genealogy/panZoom.ts | 10 ++++++++- frontend/src/routes/stammbaum/+page.server.ts | 21 +++++++++++-------- .../src/routes/stammbaum/page.server.test.ts | 6 +++--- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts index 21296590..0d0d5500 100644 --- a/frontend/src/lib/person/genealogy/panZoom.ts +++ b/frontend/src/lib/person/genealogy/panZoom.ts @@ -28,9 +28,17 @@ export const ZOOM_STEP_KB = 0.1; */ export type PanZoomState = { x: number; y: number; z: number }; -/** Fit-to-screen / initial view (US-PAN-004). */ +/** Fit-to-screen target — frames the whole tree at z=1 (US-PAN-004). */ export const DEFAULT_VIEW: PanZoomState = { x: 0, y: 0, z: DEFAULT_ZOOM }; +/** + * Landing zoom for a fresh visit (no URL state). Higher than fit so node tiles + * and generation labels are legible on arrival; the fit-to-screen control + * (DEFAULT_VIEW, z=1) zooms back out to the whole tree. + */ +export const INITIAL_ZOOM = 3; +export const INITIAL_VIEW: PanZoomState = { x: 0, y: 0, z: INITIAL_ZOOM }; + /** Clamp a zoom factor into the supported range. */ export function clampZoom(z: number): number { return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z)); diff --git a/frontend/src/routes/stammbaum/+page.server.ts b/frontend/src/routes/stammbaum/+page.server.ts index f9d5a2a5..8c4fbff6 100644 --- a/frontend/src/routes/stammbaum/+page.server.ts +++ b/frontend/src/routes/stammbaum/+page.server.ts @@ -1,7 +1,7 @@ import { error, redirect } from '@sveltejs/kit'; import { createApiClient, extractErrorCode } from '$lib/shared/api.server'; import { getErrorMessage } from '$lib/shared/errors'; -import { parsePanZoomParams } from '$lib/person/genealogy/panZoom'; +import { parsePanZoomParams, INITIAL_VIEW } from '$lib/person/genealogy/panZoom'; export async function load({ fetch, url }) { const api = createApiClient(fetch); @@ -13,14 +13,17 @@ export async function load({ fetch, url }) { throw error(result.response.status, getErrorMessage(extractErrorCode(result.error))); } - // Sanitise the shareable pan/zoom params server-side so a crafted link - // (?z=Infinity, ?cx=NaN) degrades to a safe view before reaching layout - // geometry (Nora #692). - const initialView = parsePanZoomParams({ - cx: url.searchParams.get('cx'), - cy: url.searchParams.get('cy'), - z: url.searchParams.get('z') - }); + // A fresh visit (no shared pan/zoom state) lands at the readable INITIAL_VIEW + // (z=3). When a link carries a zoom param we honour it, sanitising server-side + // so a crafted link (?z=Infinity, ?cx=NaN) degrades to a safe view before + // reaching layout geometry (Nora #692). + const initialView = url.searchParams.has('z') + ? parsePanZoomParams({ + cx: url.searchParams.get('cx'), + cy: url.searchParams.get('cy'), + z: url.searchParams.get('z') + }) + : INITIAL_VIEW; const network = result.data!; return { nodes: network.nodes ?? [], edges: network.edges ?? [], initialView }; diff --git a/frontend/src/routes/stammbaum/page.server.test.ts b/frontend/src/routes/stammbaum/page.server.test.ts index f755ad14..786ede67 100644 --- a/frontend/src/routes/stammbaum/page.server.test.ts +++ b/frontend/src/routes/stammbaum/page.server.test.ts @@ -6,7 +6,7 @@ vi.mock('$lib/shared/api.server', () => ({ })); import { createApiClient } from '$lib/shared/api.server'; -import { DEFAULT_VIEW, MAX_ZOOM } from '$lib/person/genealogy/panZoom'; +import { DEFAULT_VIEW, INITIAL_VIEW, MAX_ZOOM } from '$lib/person/genealogy/panZoom'; beforeEach(() => vi.clearAllMocks()); @@ -26,11 +26,11 @@ function loadEvent(query = '') { } describe('/stammbaum +page.server load — initialView', () => { - it('returns DEFAULT_VIEW when no pan/zoom params are present', async () => { + it('returns the readable INITIAL_VIEW (z=3) for a fresh visit with no params', async () => { mockNetwork(); const { load } = await import('./+page.server'); const result = await load(loadEvent() as never); - expect(result.initialView).toEqual(DEFAULT_VIEW); + expect(result.initialView).toEqual(INITIAL_VIEW); }); it('parses and returns valid ?cx&cy&z params', async () => { -- 2.49.1 From a458d3508b53d77b5aac9d27bde055fcbb6dbe68 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 18:39:22 +0200 Subject: [PATCH 25/39] feat(stammbaum): pinned generation-label rail on all viewports (#692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generation labels are no longer drawn in-SVG (where they panned/zoomed off screen and were desktop-only). A new StammbaumGenerationRail overlays the canvas left edge, mapping each generation row's centre through the SVG's live getScreenCTM so chips stay pinned horizontally and track their row vertically at any pan/zoom — on phones too. The desktop stripe underlay stays (gated on the gutter breakpoint); the #689 label tests are rewritten against the rail. Verified live: labels stay at left=4px while the canvas pans. Co-Authored-By: Claude Opus 4.8 --- .../genealogy/StammbaumGenerationRail.svelte | 60 +++ .../StammbaumGenerationRail.svelte.test.ts | 32 ++ .../lib/person/genealogy/StammbaumTree.svelte | 413 +++++++++--------- .../genealogy/StammbaumTree.svelte.test.ts | 48 +- 4 files changed, 322 insertions(+), 231 deletions(-) create mode 100644 frontend/src/lib/person/genealogy/StammbaumGenerationRail.svelte create mode 100644 frontend/src/lib/person/genealogy/StammbaumGenerationRail.svelte.test.ts diff --git a/frontend/src/lib/person/genealogy/StammbaumGenerationRail.svelte b/frontend/src/lib/person/genealogy/StammbaumGenerationRail.svelte new file mode 100644 index 00000000..a5b294c2 --- /dev/null +++ b/frontend/src/lib/person/genealogy/StammbaumGenerationRail.svelte @@ -0,0 +1,60 @@ + + + +
+ {#each chips as chip (chip.rank)} + {#if chip.visible} +
+ G{chip.label} +
+ {/if} + {/each} +
diff --git a/frontend/src/lib/person/genealogy/StammbaumGenerationRail.svelte.test.ts b/frontend/src/lib/person/genealogy/StammbaumGenerationRail.svelte.test.ts new file mode 100644 index 00000000..6a3b03a3 --- /dev/null +++ b/frontend/src/lib/person/genealogy/StammbaumGenerationRail.svelte.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import StammbaumGenerationRail from './StammbaumGenerationRail.svelte'; + +const rows = [ + { rank: 0, label: 0, centerY: 100 }, + { rank: 1, label: 1, centerY: 300 }, + { rank: 2, label: 3, centerY: 500 } +]; + +describe('StammbaumGenerationRail (#692)', () => { + it('renders one labelled chip per generation row', async () => { + render(StammbaumGenerationRail, { svg: null, rows, panZoom: { x: 0, y: 0, z: 1 } }); + + await vi.waitFor(() => { + const labels = Array.from(document.querySelectorAll('[role="text"]')).map((el) => ({ + aria: el.getAttribute('aria-label'), + text: el.textContent?.trim() + })); + expect(labels).toEqual([ + { aria: 'Generation 0', text: 'G0' }, + { aria: 'Generation 1', text: 'G1' }, + { aria: 'Generation 3', text: 'G3' } + ]); + }); + }); + + it('renders nothing when there are no labelled rows', async () => { + render(StammbaumGenerationRail, { svg: null, rows: [], panZoom: { x: 0, y: 0, z: 1 } }); + await vi.waitFor(() => expect(document.querySelectorAll('[role="text"]')).toHaveLength(0)); + }); +}); diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte b/frontend/src/lib/person/genealogy/StammbaumTree.svelte index 0fc9e0be..72009b4a 100644 --- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte +++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte @@ -16,6 +16,7 @@ import { ZOOM_STEP_KB } from '$lib/person/genealogy/panZoom'; import { panZoomGestures } from '$lib/person/genealogy/panZoomGestures'; +import StammbaumGenerationRail from '$lib/person/genealogy/StammbaumGenerationRail.svelte'; type PersonNodeDTO = components['schemas']['PersonNodeDTO']; type RelationshipDTO = components['schemas']['RelationshipDTO']; @@ -97,8 +98,9 @@ $effect(() => { }); type GutterRow = { rank: number; y: number; label: number | null }; +// Computed on all viewports (not gated on the desktop gutter) so the pinned +// generation rail can show labels on phones too (#692). const gutterRows = $derived.by(() => { - if (gutterWidth === 0) return []; const byId = new SvelteMap(nodes.map((n) => [n.id, n])); const rows: GutterRow[] = []; const sortedRanks = [...layout.generations.keys()].sort((a, b) => a - b); @@ -128,6 +130,15 @@ const baseCentre = $derived({ y: layout.viewY + layout.viewH / 2 }); +// Labelled generation rows for the pinned rail, with each row's centre in SVG +// coordinates (the rail maps these through the live screen transform). +let svgEl = $state(null); +const railRows = $derived( + gutterRows + .filter((r): r is GutterRow & { label: number } => r.label != null) + .map((r) => ({ rank: r.rank, label: r.label, centerY: r.y + NODE_H / 2 })) +); + const viewBox = $derived.by(() => { const w = baseDims.w / panZoom.z; const h = baseDims.h / panZoom.z; @@ -278,243 +289,227 @@ const parentLinks = $derived.by(() => { }); - - - - - - {#each gutterRows as row, i (`stripe-${row.rank}`)} - - {/each} - - - {#each gutterRows as row (`label-${row.rank}`)} - {#if row.label != null} - - - G{row.label} - - + +
+ + + + + + {#if gutterVisible} + {#each gutterRows as row, i (`stripe-${row.rank}`)} + + {/each} {/if} - {/each} - - {#each parentLinks.shared as group (group.key)} - {@const aCenter = nodeCenter(group.parentA)} - {@const bCenter = nodeCenter(group.parentB)} - {@const childCenters = group.childIds + {#each parentLinks.shared as group (group.key)} + {@const aCenter = nodeCenter(group.parentA)} + {@const bCenter = nodeCenter(group.parentB)} + {@const childCenters = group.childIds .map((id) => nodeCenter(id)) .filter((c): c is { x: number; y: number } => c !== null)} - {#if aCenter && bCenter && childCenters.length > 0} - {@const midX = (aCenter.x + bCenter.x) / 2} - {@const parentBottomY = aCenter.y + NODE_H / 2} - {@const childTopY = childCenters[0].y - NODE_H / 2} - {@const barY = (parentBottomY + childTopY) / 2} - {@const xs = childCenters.map((c) => c.x)} - {@const minX = Math.min(midX, ...xs)} - {@const maxX = Math.max(midX, ...xs)} - - {#if minX !== maxX} + {#if aCenter && bCenter && childCenters.length > 0} + {@const midX = (aCenter.x + bCenter.x) / 2} + {@const parentBottomY = aCenter.y + NODE_H / 2} + {@const childTopY = childCenters[0].y - NODE_H / 2} + {@const barY = (parentBottomY + childTopY) / 2} + {@const xs = childCenters.map((c) => c.x)} + {@const minX = Math.min(midX, ...xs)} + {@const maxX = Math.max(midX, ...xs)} + {#if minX !== maxX} + + {/if} + {#each childCenters as cc, i (group.childIds[i])} + + {/each} {/if} - {#each childCenters as cc, i (group.childIds[i])} + {/each} + + + {#each parentLinks.single as link (link.key)} + {@const parentCenter = nodeCenter(link.parentId)} + {@const childCenter = nodeCenter(link.childId)} + {#if parentCenter && childCenter} + {@const parentBottomY = parentCenter.y + NODE_H / 2} + {@const childTopY = childCenter.y - NODE_H / 2} + {@const barY = (parentBottomY + childTopY) / 2} + {#if parentCenter.x !== childCenter.x} + + {/if} + - {/each} - {/if} - {/each} + {/if} + {/each} - - {#each parentLinks.single as link (link.key)} - {@const parentCenter = nodeCenter(link.parentId)} - {@const childCenter = nodeCenter(link.childId)} - {#if parentCenter && childCenter} - {@const parentBottomY = parentCenter.y + NODE_H / 2} - {@const childTopY = childCenter.y - NODE_H / 2} - {@const barY = (parentBottomY + childTopY) / 2} - - {#if parentCenter.x !== childCenter.x} + + {#each spouseEdges as e (e.id)} + {@const aCenter = nodeCenter(e.personId)} + {@const bCenter = nodeCenter(e.relatedPersonId)} + {#if aCenter && bCenter} + {/if} - - {/if} - {/each} + {/each} - - {#each spouseEdges as e (e.id)} - {@const aCenter = nodeCenter(e.personId)} - {@const bCenter = nodeCenter(e.relatedPersonId)} - {#if aCenter && bCenter} - - - {/if} - {/each} - - - {#each nodes as node (node.id)} - {@const pos = layout.positions.get(node.id)} - {#if pos} - {@const isSelected = selectedId === node.id} - {@const isFocused = focusedId === node.id} - onSelect(node.id)} - onkeydown={(e) => handleNodeKey(e, node.id)} - onfocus={() => (focusedId = node.id)} - onblur={() => (focusedId = null)} - class="cursor-pointer focus:outline-none" - > - {#if isFocused} - - {/if} - - {#if isSelected} - - {/if} - onSelect(node.id)} + onkeydown={(e) => handleNodeKey(e, node.id)} + onfocus={() => (focusedId = node.id)} + onblur={() => (focusedId = null)} + class="cursor-pointer focus:outline-none" > - {node.displayName} - - {#if node.birthYear || node.deathYear} + {#if isFocused} + + {/if} + + {#if isSelected} + + {/if} - {node.birthYear ?? '?'}–{node.deathYear ?? ''} + {node.displayName} - {/if} - - {/if} - {/each} - + {#if node.birthYear || node.deathYear} + + {node.birthYear ?? '?'}–{node.deathYear ?? ''} + + {/if} + + {/if} + {/each} + + + +
diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts b/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts index 6584eb1c..9b67f862 100644 --- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts +++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts @@ -831,10 +831,13 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => { }); }); -describe('StammbaumTree generation gutter (#689)', () => { - it('renders a G{n} label per occupied generation row when at least one node carries generation', async () => { - // showGutter overrides the matchMedia detection so the test never - // depends on the vitest-browser iframe viewport width. +describe('StammbaumTree generation rail (#689, #692)', () => { + const railLabels = () => + Array.from(document.querySelectorAll('[role="text"]')).map((el) => + el.getAttribute('aria-label') + ); + + it('renders a G{n} label per occupied generation row on the pinned rail', async () => { render(StammbaumTree, { nodes: [ { id: ID_A, displayName: 'Walter', familyMember: true, generation: 2 }, @@ -843,35 +846,37 @@ describe('StammbaumTree generation gutter (#689)', () => { edges: [], selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, - onSelect: () => {}, - showGutter: true + onSelect: () => {} }); - const labels = Array.from(document.querySelectorAll('g[role="text"]')).map((g) => - g.getAttribute('aria-label') - ); - expect(labels).toContain('Generation 2'); - expect(labels).toContain('Generation 3'); + await vi.waitFor(() => { + const labels = railLabels(); + expect(labels).toContain('Generation 2'); + expect(labels).toContain('Generation 3'); + }); }); - it('wraps the visible G3 text inside an aria-labelled group so screen readers announce "Generation"', async () => { + it('labels the chip so screen readers announce "Generation" and shows the G{n} glyph', async () => { render(StammbaumTree, { nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }], edges: [], selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, - onSelect: () => {}, - showGutter: true + onSelect: () => {} }); - const g3 = Array.from(document.querySelectorAll('g[role="text"]')).find( - (g) => g.getAttribute('aria-label') === 'Generation 3' - ); - expect(g3).toBeDefined(); - expect(g3!.querySelector('text')!.textContent).toMatch(/G\s*3/); + await vi.waitFor(() => { + const g3 = Array.from(document.querySelectorAll('[role="text"]')).find( + (el) => el.getAttribute('aria-label') === 'Generation 3' + ); + expect(g3).toBeDefined(); + expect(g3!.textContent).toMatch(/G\s*3/); + }); }); - it('omits the gutter when showGutter is false (mobile breakpoint case)', async () => { + it('keeps showing generation labels on the pinned rail even on mobile (showGutter false)', async () => { + // The rail is viewport-independent (the #692 point); only the desktop + // stripe underlay is gated on the gutter breakpoint. render(StammbaumTree, { nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }], edges: [], @@ -881,7 +886,6 @@ describe('StammbaumTree generation gutter (#689)', () => { showGutter: false }); - const labelGroups = Array.from(document.querySelectorAll('g[role="text"]')); - expect(labelGroups).toHaveLength(0); + await vi.waitFor(() => expect(railLabels()).toContain('Generation 3')); }); }); -- 2.49.1 From c1dd6d299f3def1263b09da2f8584bcedf73043c Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 18:42:11 +0200 Subject: [PATCH 26/39] feat(stammbaum): round pan/zoom URL params for readable shared links (#692) Pan rounded to 2 decimals, zoom to 3, so ?cx/?cy/?z no longer carry float noise like cx=457.8300882631206. Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/person/genealogy/panZoom.test.ts | 8 ++++++++ frontend/src/lib/person/genealogy/panZoom.ts | 13 +++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/person/genealogy/panZoom.test.ts b/frontend/src/lib/person/genealogy/panZoom.test.ts index fcde710b..66030333 100644 --- a/frontend/src/lib/person/genealogy/panZoom.test.ts +++ b/frontend/src/lib/person/genealogy/panZoom.test.ts @@ -80,6 +80,14 @@ describe('serializePanZoomParams', () => { const state = { x: 87.5, y: -12.25, z: 2.4 }; expect(parsePanZoomParams(serializePanZoomParams(state))).toEqual(state); }); + + it('rounds noisy floats so shared URLs stay readable', () => { + expect(serializePanZoomParams({ x: 457.8300882631206, y: 0, z: 1.2000000000000002 })).toEqual({ + cx: '457.83', + cy: '0', + z: '1.2' + }); + }); }); describe('screenDeltaToSvg', () => { diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts index 0d0d5500..2811ecb5 100644 --- a/frontend/src/lib/person/genealogy/panZoom.ts +++ b/frontend/src/lib/person/genealogy/panZoom.ts @@ -71,9 +71,18 @@ export function parsePanZoomParams(raw: { }; } -/** Serialise a view state into URL query params (the inverse of {@link parsePanZoomParams}). */ +/** Format a number with at most `dp` decimals, dropping trailing zeros. */ +function round(n: number, dp: number): string { + return String(Number(n.toFixed(dp))); +} + +/** + * Serialise a view state into URL query params (the inverse of + * {@link parsePanZoomParams}). Pan is rounded to 2 decimals and zoom to 3 so + * shared links stay readable (no `cx=457.8300882631206` float noise). + */ export function serializePanZoomParams(state: PanZoomState): { cx: string; cy: string; z: string } { - return { cx: String(state.x), cy: String(state.y), z: String(state.z) }; + return { cx: round(state.x, 2), cy: round(state.y, 2), z: round(state.z, 3) }; } /** -- 2.49.1 From f4b631e1bc0e987230f899c38c627993b839d82b Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 18:47:29 +0200 Subject: [PATCH 27/39] refactor(stammbaum): extract + unit-test pinch and inertia math (#692) Move the pinch-zoom (pinchZoom) and inertia-step (stepInertia) geometry out of the panZoomGestures DOM glue into pure, unit-tested helpers in panZoom.ts, with named FRAME_MS/INERTIA_* constants. Addresses the QA blocker that the gesture module's core math was untested. No behaviour change. Co-Authored-By: Claude Opus 4.8 --- .../src/lib/person/genealogy/panZoom.test.ts | 31 ++++++++++++++++ frontend/src/lib/person/genealogy/panZoom.ts | 37 +++++++++++++++++++ .../lib/person/genealogy/panZoomGestures.ts | 12 +++--- 3 files changed, 75 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/person/genealogy/panZoom.test.ts b/frontend/src/lib/person/genealogy/panZoom.test.ts index 66030333..ad9e8371 100644 --- a/frontend/src/lib/person/genealogy/panZoom.test.ts +++ b/frontend/src/lib/person/genealogy/panZoom.test.ts @@ -5,6 +5,8 @@ import { serializePanZoomParams, screenDeltaToSvg, zoomAtPoint, + pinchZoom, + stepInertia, recentreOn, clampPan, lerpView, @@ -133,6 +135,35 @@ describe('zoomAtPoint', () => { }); }); +describe('pinchZoom', () => { + it('scales zoom by the finger-distance ratio around the centroid', () => { + // Fingers spread 100→200 → 2× zoom; centroid at canvas centre → no pan. + expect(pinchZoom({ x: 0, y: 0, z: 1 }, 1, 100, 200, 0, 0)).toEqual({ x: 0, y: 0, z: 2 }); + }); + + it('zooms out when fingers pinch together', () => { + expect(pinchZoom({ x: 0, y: 0, z: 2 }, 2, 200, 100, 0, 0).z).toBe(1); + }); + + it('clamps the scaled zoom into bounds', () => { + expect(pinchZoom({ x: 0, y: 0, z: 2 }, 2, 100, 1000, 0, 0).z).toBe(MAX_ZOOM); + }); + + it('treats a zero start distance as no zoom change', () => { + expect(pinchZoom({ x: 5, y: 5, z: 1.5 }, 1.5, 0, 200, 0, 0).z).toBe(1.5); + }); +}); + +describe('stepInertia', () => { + it('advances the pan by velocity × frame duration in the drag direction', () => { + expect(stepInertia({ x: 100, y: 50, z: 1 }, 0.5, 0.25, 16)).toEqual({ x: 92, y: 46, z: 1 }); + }); + + it('leaves zoom untouched', () => { + expect(stepInertia({ x: 0, y: 0, z: 2.5 }, 1, 1, 16).z).toBe(2.5); + }); +}); + describe('recentreOn', () => { const node = { x: 300, y: 200 }; const base = { x: 100, y: 100 }; diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts index 2811ecb5..69c4af40 100644 --- a/frontend/src/lib/person/genealogy/panZoom.ts +++ b/frontend/src/lib/person/genealogy/panZoom.ts @@ -130,6 +130,43 @@ export function zoomAtPoint( }; } +/** Assumed milliseconds per animation frame, used to scale inertia velocity. */ +export const FRAME_MS = 16; +/** Per-frame velocity decay for pan inertia (OQ-004). */ +export const INERTIA_DECAY = 0.92; +/** Inertia stops once the velocity (svg units per ms) drops below this. */ +export const INERTIA_MIN_SPEED = 0.02; + +/** + * Pinch zoom around the gesture centroid (US-PAN-002/003). The new zoom is the + * start zoom scaled by the finger-distance ratio (clamped); the anchor offset + * keeps the centroid fixed via {@link zoomAtPoint}. + */ +export function pinchZoom( + state: PanZoomState, + startZoom: number, + startDist: number, + currentDist: number, + anchorX: number, + anchorY: number +): PanZoomState { + const ratio = startDist > 0 ? currentDist / startDist : 1; + return zoomAtPoint(state, clampZoom(startZoom * ratio), anchorX, anchorY); +} + +/** + * Advance the pan by one inertia frame: continue the release velocity (svg units + * per ms) in the drag direction, scaled by the frame duration. Zoom is untouched. + */ +export function stepInertia( + state: PanZoomState, + velX: number, + velY: number, + frameMs: number = FRAME_MS +): PanZoomState { + return { x: state.x - velX * frameMs, y: state.y - velY * frameMs, z: state.z }; +} + /** Linearly interpolate between two view states (drives fit/recentre tweening). */ export function lerpView(from: PanZoomState, to: PanZoomState, t: number): PanZoomState { return { diff --git a/frontend/src/lib/person/genealogy/panZoomGestures.ts b/frontend/src/lib/person/genealogy/panZoomGestures.ts index d5955645..8173d93c 100644 --- a/frontend/src/lib/person/genealogy/panZoomGestures.ts +++ b/frontend/src/lib/person/genealogy/panZoomGestures.ts @@ -4,6 +4,11 @@ import { clampZoom, screenDeltaToSvg, zoomAtPoint, + pinchZoom, + stepInertia, + FRAME_MS, + INERTIA_DECAY, + INERTIA_MIN_SPEED, ZOOM_STEP_KB, type PanZoomState } from '$lib/person/genealogy/panZoom'; @@ -25,8 +30,6 @@ export interface PanZoomGesturesParams { /** Pointer movement (px) below which a drag is treated as a tap, not a pan. */ const DRAG_THRESHOLD_PX = 4; -const INERTIA_DECAY = 0.92; -const INERTIA_MIN_SPEED = 0.02; // svg units per ms /** * Touch/mouse/wheel pan & zoom for the Stammbaum canvas (#692). Thin DOM glue: @@ -86,7 +89,7 @@ export const panZoomGestures: Action = (no if (Math.hypot(velX, velY) < INERTIA_MIN_SPEED) return; const step = () => { const before = current; - emit({ ...current, x: current.x - velX * 16, y: current.y - velY * 16 }); + emit(stepInertia(current, velX, velY, FRAME_MS)); velX *= INERTIA_DECAY; velY *= INERTIA_DECAY; const stalled = current.x === before.x && current.y === before.y; @@ -135,8 +138,7 @@ export const panZoomGestures: Action = (no const dist = distance(a, b) || 1; const centroid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; const anchor = anchorOffset(centroid.x, centroid.y); - const targetZoom = clampZoom(pinchStartZoom * (dist / pinchStartDist)); - emit(zoomAtPoint(current, targetZoom, anchor.x, anchor.y)); + emit(pinchZoom(current, pinchStartZoom, pinchStartDist, dist, anchor.x, anchor.y)); moved = true; return; } -- 2.49.1 From 53660eadc93a1c69458f1a368b44b8e47fd612db Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 18:49:03 +0200 Subject: [PATCH 28/39] test(stammbaum): assert drag-pan before release to avoid inertia flake (#692) Read the pan emission from the pointermove (deterministic) instead of the post-pointerup last call, which inertia could perturb when reduced-motion is not forced in vitest-browser (QA blocker). Co-Authored-By: Claude Opus 4.8 --- .../src/lib/person/genealogy/StammbaumTree.svelte.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts b/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts index 9b67f862..073e7acb 100644 --- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts +++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts @@ -568,12 +568,15 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => { const opts = (x: number) => ({ pointerId: 1, clientX: x, clientY: 100, bubbles: true }); svg.dispatchEvent(new PointerEvent('pointerdown', opts(100))); svg.dispatchEvent(new PointerEvent('pointermove', opts(160))); - svg.dispatchEvent(new PointerEvent('pointerup', opts(160))); + // Assert on the move's emission *before* releasing: inertia starts on + // pointerup and could otherwise perturb the last recorded call. expect(onPanZoom).toHaveBeenCalled(); // Dragging right reveals content to the left → pan x decreases. expect(onPanZoom.mock.calls.at(-1)![0].x).toBeLessThan(0); + svg.dispatchEvent(new PointerEvent('pointerup', opts(160))); + // The synthetic click after a real drag must not select the node. node.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(onSelect).not.toHaveBeenCalled(); -- 2.49.1 From d5a7974f3a1431281fa10c69966d0e1628061b72 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 18:50:54 +0200 Subject: [PATCH 29/39] fix(shared): trapFocus restores focus to the opener on destroy (#692) When the bottom sheet closes, focus returns to the element that was focused before it opened instead of being dropped to document.body (WCAG 2.4.3, Architect + UX review). Co-Authored-By: Claude Opus 4.8 --- .../lib/shared/actions/trapFocus.svelte.spec.ts | 15 +++++++++++++++ frontend/src/lib/shared/actions/trapFocus.ts | 4 ++++ 2 files changed, 19 insertions(+) diff --git a/frontend/src/lib/shared/actions/trapFocus.svelte.spec.ts b/frontend/src/lib/shared/actions/trapFocus.svelte.spec.ts index c35cf01d..fcca10a6 100644 --- a/frontend/src/lib/shared/actions/trapFocus.svelte.spec.ts +++ b/frontend/src/lib/shared/actions/trapFocus.svelte.spec.ts @@ -57,4 +57,19 @@ describe('trapFocus action', () => { // No trap after destroy → focus stays on the last button. expect(document.activeElement).toBe(buttons[1]); }); + + it('restores focus to the previously-focused element on destroy (WCAG 2.4.3)', () => { + const opener = document.createElement('button'); + document.body.appendChild(opener); + nodes.push(opener); + opener.focus(); + expect(document.activeElement).toBe(opener); + + const { node } = makeContainer(['one', 'two']); + const handle = trapFocus(node); + expect(document.activeElement).not.toBe(opener); + + handle.destroy(); + expect(document.activeElement).toBe(opener); + }); }); diff --git a/frontend/src/lib/shared/actions/trapFocus.ts b/frontend/src/lib/shared/actions/trapFocus.ts index 6c941978..fd393843 100644 --- a/frontend/src/lib/shared/actions/trapFocus.ts +++ b/frontend/src/lib/shared/actions/trapFocus.ts @@ -14,6 +14,9 @@ const FOCUSABLE_SELECTOR = [ ].join(','); export function trapFocus(node: HTMLElement) { + // Remember what had focus so it can be restored when the overlay closes + // (WCAG 2.4.3 — don't strand keyboard/AT users at the top of the page). + const previouslyFocused = document.activeElement as HTMLElement | null; const focusable = () => Array.from(node.querySelectorAll(FOCUSABLE_SELECTOR)); function onKeydown(event: KeyboardEvent) { @@ -38,6 +41,7 @@ export function trapFocus(node: HTMLElement) { return { destroy() { node.removeEventListener('keydown', onKeydown); + previouslyFocused?.focus?.(); } }; } -- 2.49.1 From b170085311b484b112eadac09412a46708e8ec40 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 18:54:48 +0200 Subject: [PATCH 30/39] =?UTF-8?q?fix(stammbaum):=20node=20tap=20stopped=20?= =?UTF-8?q?selecting=20=E2=80=94=20defer=20pointer=20capture=20to=20drag?= =?UTF-8?q?=20start=20(#692)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capturing the pointer on pointerdown made the browser dispatch the trailing click at the SVG instead of the node under the finger, so node taps silently stopped opening the person panel. Capture only once a drag crosses the threshold; a tap now reaches the node's onclick. Verified live. Co-Authored-By: Claude Opus 4.8 --- .../lib/person/genealogy/panZoomGestures.ts | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/person/genealogy/panZoomGestures.ts b/frontend/src/lib/person/genealogy/panZoomGestures.ts index 8173d93c..45f6bf52 100644 --- a/frontend/src/lib/person/genealogy/panZoomGestures.ts +++ b/frontend/src/lib/person/genealogy/panZoomGestures.ts @@ -104,11 +104,11 @@ export const panZoomGestures: Action = (no const onPointerDown = (e: PointerEvent) => { cancelInertia(); - try { - node.setPointerCapture(e.pointerId); - } catch { - /* pointer not capturable (e.g. synthetic event) — drag still works */ - } + // NB: do NOT capture the pointer here — capturing on pointerdown makes the + // browser dispatch the trailing `click` at this element instead of the + // node under the pointer, which silently breaks node selection (a tap must + // still reach the node's onclick). Capture is deferred to the first move + // that crosses the drag threshold (see onPointerMove). pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); p.onGestureStart?.(); @@ -146,7 +146,17 @@ export const panZoomGestures: Action = (no if (!dragging) return; const dxPx = e.clientX - lastX; const dyPx = e.clientY - lastY; - if (!moved && Math.hypot(dxPx, dyPx) >= DRAG_THRESHOLD_PX) moved = true; + if (!moved && Math.hypot(dxPx, dyPx) >= DRAG_THRESHOLD_PX) { + // A real drag has started — now capture so we keep receiving move/up + // even if the pointer leaves the canvas. (Deferred from pointerdown so + // taps still select nodes.) + moved = true; + try { + node.setPointerCapture(e.pointerId); + } catch { + /* pointer not capturable (e.g. synthetic event) — drag still works */ + } + } const { dx, dy } = screenDeltaToSvg( dxPx, -- 2.49.1 From 8f836dfefb2cd0a54d275c90461fd6c4d7c11585 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 18:58:38 +0200 Subject: [PATCH 31/39] =?UTF-8?q?feat(stammbaum):=20raise=20MAX=5FZOOM=203?= =?UTF-8?q?=E2=86=9210=20so=20phones=20can=20zoom=20in=20to=20read=20(#692?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zoom is normalised to the whole tree, so z=3 still renders a wide tree too small on a phone. Raise the ceiling to 10 (revises OQ-001); SVG stays crisp at any zoom so a generous max is harmless. Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/person/genealogy/panZoom.test.ts | 6 +++--- frontend/src/lib/person/genealogy/panZoom.ts | 9 +++++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/person/genealogy/panZoom.test.ts b/frontend/src/lib/person/genealogy/panZoom.test.ts index ad9e8371..e881f6a4 100644 --- a/frontend/src/lib/person/genealogy/panZoom.test.ts +++ b/frontend/src/lib/person/genealogy/panZoom.test.ts @@ -31,13 +31,13 @@ describe('clampZoom', () => { }); it('clamps above MAX_ZOOM down to MAX_ZOOM', () => { - expect(clampZoom(5)).toBe(MAX_ZOOM); - expect(clampZoom(3.0001)).toBe(MAX_ZOOM); + expect(clampZoom(99)).toBe(MAX_ZOOM); + expect(clampZoom(MAX_ZOOM + 0.0001)).toBe(MAX_ZOOM); }); it('exposes the resolved zoom bounds', () => { expect(MIN_ZOOM).toBe(0.25); - expect(MAX_ZOOM).toBe(3.0); + expect(MAX_ZOOM).toBe(10); }); }); diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts index 69c4af40..b306d095 100644 --- a/frontend/src/lib/person/genealogy/panZoom.ts +++ b/frontend/src/lib/person/genealogy/panZoom.ts @@ -9,9 +9,14 @@ * project. See ADR-027 for why this is custom rather than a third-party library. */ -/** Resolved zoom bounds (OQ-001). */ +/** + * Zoom bounds. OQ-001 originally resolved the ceiling to 3.0, but because zoom + * is normalised to the whole tree, z=3 still shows too much of a wide tree to be + * legible on a phone — so the ceiling was raised to 10 (product-owner revision, + * #692). SVG stays vector-crisp at any zoom, so a generous max is harmless. + */ export const MIN_ZOOM = 0.25; -export const MAX_ZOOM = 3.0; +export const MAX_ZOOM = 10; export const DEFAULT_ZOOM = 1; /** Minimum zoom a recentre will snap up to so the focal node's text is legible (OQ-005). */ -- 2.49.1 From 03060236100bffce290b24f075fb3d4c796d8136 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 19:00:58 +0200 Subject: [PATCH 32/39] fix(stammbaum): 44x44 touch targets for panel + affordance icon buttons (#692) Enlarge the centre-on-person, panel-close, and affordance-dismiss icon buttons to 44x44 hit areas (WCAG 2.5.8, UX review) while keeping the small glyphs. Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/person/genealogy/StammbaumAffordance.svelte | 2 +- frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/person/genealogy/StammbaumAffordance.svelte b/frontend/src/lib/person/genealogy/StammbaumAffordance.svelte index 044b7e1f..1c38ea1a 100644 --- a/frontend/src/lib/person/genealogy/StammbaumAffordance.svelte +++ b/frontend/src/lib/person/genealogy/StammbaumAffordance.svelte @@ -72,7 +72,7 @@ $effect(() => { type="button" onclick={hide} aria-label={m.stammbaum_affordance_dismiss()} - class="rounded-sm p-0.5 text-ink-3 transition hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none" + class="-my-2 inline-flex h-11 w-11 items-center justify-center rounded-sm text-ink-3 transition hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none" > Date: Fri, 29 May 2026 19:04:22 +0200 Subject: [PATCH 33/39] test(stammbaum): cover animateView rAF tween + server 401/500 paths (#692) Add a deterministic stubbed-rAF test for animateView's animated path (was only covering the reduced-motion branch) and assert the server load redirects on 401 and throws on a network 500 (QA review). Co-Authored-By: Claude Opus 4.8 --- .../lib/person/genealogy/animateView.test.ts | 25 +++++++++++++++++++ .../src/routes/stammbaum/page.server.test.ts | 21 ++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/frontend/src/lib/person/genealogy/animateView.test.ts b/frontend/src/lib/person/genealogy/animateView.test.ts index f25fda18..8c5e44ad 100644 --- a/frontend/src/lib/person/genealogy/animateView.test.ts +++ b/frontend/src/lib/person/genealogy/animateView.test.ts @@ -15,3 +15,28 @@ describe('animateView (reduced motion)', () => { cancel(); }); }); + +describe('animateView (animated path)', () => { + const from = { x: 0, y: 0, z: 1 }; + const to = { x: 100, y: 0, z: 2 }; + + it('tweens across frames and lands exactly on the target', () => { + const frames: { x: number }[] = []; + const callbacks: FrameRequestCallback[] = []; + vi.stubGlobal('performance', { now: () => 0 }); + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => callbacks.push(cb)); + vi.stubGlobal('cancelAnimationFrame', () => {}); + + animateView(from, to, (v) => frames.push(v), { durationMs: 100 }); + + callbacks[0](50); // t = 0.5 → an interpolated frame + callbacks[callbacks.length - 1](100); // t = 1 → exact target + + expect(frames.length).toBeGreaterThanOrEqual(2); + expect(frames[0].x).toBeGreaterThan(0); + expect(frames[0].x).toBeLessThan(100); + expect(frames.at(-1)).toEqual(to); + + vi.unstubAllGlobals(); + }); +}); diff --git a/frontend/src/routes/stammbaum/page.server.test.ts b/frontend/src/routes/stammbaum/page.server.test.ts index 786ede67..7efa0783 100644 --- a/frontend/src/routes/stammbaum/page.server.test.ts +++ b/frontend/src/routes/stammbaum/page.server.test.ts @@ -16,6 +16,12 @@ function mockNetwork() { } as unknown as ReturnType); } +function mockNetworkResponse(status: number) { + vi.mocked(createApiClient).mockReturnValue({ + GET: vi.fn().mockResolvedValue({ response: { ok: false, status }, error: { code: 'X' } }) + } as unknown as ReturnType); +} + function loadEvent(query = '') { const url = new URL(`http://localhost/stammbaum${query}`); return { @@ -53,4 +59,19 @@ describe('/stammbaum +page.server load — initialView', () => { const result = await load(loadEvent('?z=99') as never); expect(result.initialView.z).toBe(MAX_ZOOM); }); + + it('redirects to /login when the network API returns 401', async () => { + mockNetworkResponse(401); + const { load } = await import('./+page.server'); + await expect(load(loadEvent() as never)).rejects.toMatchObject({ + status: 302, + location: '/login' + }); + }); + + it('throws an HTTP error when the network API fails', async () => { + mockNetworkResponse(500); + const { load } = await import('./+page.server'); + await expect(load(loadEvent() as never)).rejects.toMatchObject({ status: 500 }); + }); }); -- 2.49.1 From 01b902e885ba140b780bfd09699d80b18e1e26ab Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 19:06:06 +0200 Subject: [PATCH 34/39] test(stammbaum): assert zoom-out floor via mirrored ?z; e2e affordance beforeEach (#692) Strengthen the zoom-clamp test to assert z floors at 0.25 in the URL (was a 'does not throw' smoke test) and move the affordance localStorage reset to a beforeEach so the e2e tests are order-independent (QA review). Co-Authored-By: Claude Opus 4.8 --- frontend/e2e/stammbaum-mobile.visual.spec.ts | 7 ++++++- frontend/src/routes/stammbaum/page.svelte.test.ts | 13 +++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/frontend/e2e/stammbaum-mobile.visual.spec.ts b/frontend/e2e/stammbaum-mobile.visual.spec.ts index 6de8c48b..4cb7b196 100644 --- a/frontend/e2e/stammbaum-mobile.visual.spec.ts +++ b/frontend/e2e/stammbaum-mobile.visual.spec.ts @@ -17,10 +17,15 @@ test.describe('Stammbaum — mobile read path (#692)', () => { // affordance appears; reduced-motion is already forced project-wide. test.use({ hasTouch: true, isMobile: true }); + // Clear the affordance-dismissed flag before every test so the first-load + // hint state is deterministic regardless of test order. + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => localStorage.removeItem('stammbaumAffordanceDismissedAt')); + }); + for (const width of WIDTHS) { test(`affordance + controls render at ${width}px`, async ({ page }) => { await page.setViewportSize({ width, height: 720 }); - await page.addInitScript(() => localStorage.removeItem('stammbaumAffordanceDismissedAt')); await page.goto('/stammbaum'); await expect(page.getByRole('heading', { name: 'Stammbaum' })).toBeVisible(); diff --git a/frontend/src/routes/stammbaum/page.svelte.test.ts b/frontend/src/routes/stammbaum/page.svelte.test.ts index bdeacd5b..839f66c9 100644 --- a/frontend/src/routes/stammbaum/page.svelte.test.ts +++ b/frontend/src/routes/stammbaum/page.svelte.test.ts @@ -88,17 +88,22 @@ describe('stammbaum page', () => { await expect.element(page.getByRole('complementary')).not.toBeInTheDocument(); }); - it('clamps the zoom level when the zoom-out button is clicked many times', async () => { + it('clamps zoom-out at MIN_ZOOM (0.25), reflected in the mirrored ?z param', async () => { mockPage.url = new URL('http://localhost/stammbaum'); + replaceState.mockClear(); const Stammbaum = await loadComponent(); render(Stammbaum, { props: { data: { nodes: sampleNodes, edges: [], initialView: DEFAULT_VIEW } } }); const zoomOut = page.getByRole('button', { name: /verkleinern/i }); - for (let i = 0; i < 10; i++) await zoomOut.click(); - // Just verify that repeated clicks don't throw — branch coverage - await expect.element(zoomOut).toBeVisible(); + // Default z=1; well over (1 - 0.25) / 0.1 = 8 steps to reach the floor. + for (let i = 0; i < 15; i++) await zoomOut.click(); + + await vi.waitFor(() => { + const url = replaceState.mock.calls.at(-1)![0] as URL; + expect(url.searchParams.get('z')).toBe('0.25'); + }); }); it('mirrors the view into ?cx&cy&z when zoomed (US-PANEL-002 AC2)', async () => { -- 2.49.1 From b1309db8db9d81c2c5028f89cd4d65cfe9359bce Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 19:25:03 +0200 Subject: [PATCH 35/39] feat(stammbaum): land a fresh visit on the tree's top-left corner (#692) At z=3 a pan of {0,0} centres on the tree midpoint; a fresh visit (no shared ?z) now anchors the viewBox to the tree's top-left corner via topLeftView (the negative clamp limit), emitted on mount. Shared links still win. Verified live: lands at cx<0, cy<0. Co-Authored-By: Claude Opus 4.8 --- .../src/lib/person/genealogy/StammbaumTree.svelte | 12 +++++++++++- frontend/src/lib/person/genealogy/panZoom.test.ts | 13 +++++++++++++ frontend/src/lib/person/genealogy/panZoom.ts | 9 +++++++++ frontend/src/routes/stammbaum/+page.svelte | 1 + 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte b/frontend/src/lib/person/genealogy/StammbaumTree.svelte index 72009b4a..811b2c9f 100644 --- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte +++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte @@ -1,5 +1,5 @@
G{chip.label} -- 2.49.1 From ecae789be2e513db8619f989f75a730f90a114ed Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 20:42:50 +0200 Subject: [PATCH 38/39] test(stammbaum): fix two CI-only browser-test failures (#692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - page.svelte.test.ts mocked $app/navigation with only replaceState, dropping invalidateAll (imported by StammbaumSidePanel) → the module errored and failed all 7 tests in the file. Mock now exports invalidateAll + goto too. - StammbaumTree viewBox 'offsets origin' test hard-coded a wrong unpanned-x; assert the robust relationship instead (viewBox centre − content centroid == pan). Co-Authored-By: Claude Opus 4.8 --- .../lib/person/genealogy/StammbaumTree.svelte.test.ts | 10 ++++++---- frontend/src/routes/stammbaum/page.svelte.test.ts | 6 +++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts b/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts index 073e7acb..7b1bc107 100644 --- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts +++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts @@ -54,10 +54,12 @@ describe('StammbaumTree viewBox', () => { expect(w).toBe(1200); expect(h).toBe(800); - // …but the origin is shifted by the pan offset. - const unpannedX = -(1200 / 2 - 160 / 2); // single 160-wide node centred - expect(x).toBeCloseTo(unpannedX + 100, 6); - expect(y).toBeCloseTo(-(800 / 2 - 56 / 2) + 40, 6); + // …but the viewBox centre is the content centroid shifted by the pan + // offset (at pan {0,0} the centre sits on the centroid — see the test + // below). This avoids hard-coding the layout's absolute coordinates. + const c = rectsCentroid(svg); + expect(x + w / 2 - c.x).toBeCloseTo(100, 6); + expect(y + h / 2 - c.y).toBeCloseTo(40, 6); }); it('uses the minimum size and centers a single node', async () => { diff --git a/frontend/src/routes/stammbaum/page.svelte.test.ts b/frontend/src/routes/stammbaum/page.svelte.test.ts index 839f66c9..b9f58e20 100644 --- a/frontend/src/routes/stammbaum/page.svelte.test.ts +++ b/frontend/src/routes/stammbaum/page.svelte.test.ts @@ -17,7 +17,11 @@ vi.mock('$app/state', () => ({ const replaceState = vi.fn(); vi.mock('$app/navigation', () => ({ - replaceState: (...args: unknown[]) => replaceState(...args) + replaceState: (...args: unknown[]) => replaceState(...args), + // StammbaumSidePanel (rendered transitively) imports invalidateAll/goto, so + // the mock must provide every export the module graph uses. + invalidateAll: vi.fn(), + goto: vi.fn() })); afterEach(cleanup); -- 2.49.1 From 8cc6031ef0ed36923410c9c12ece4652269a5974 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 21:42:53 +0200 Subject: [PATCH 39/39] refactor(stammbaum): split StammbaumTree into Connectors + Node components (#692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the three SVG connector layers (+ the parent-link graph computation) into StammbaumConnectors.svelte and the node into StammbaumNode.svelte (which now owns its own focus-ring state). StammbaumTree drops 546→308 lines and is now an orchestrator: layout, gutter/reduced-motion state, viewBox, gestures, rail, anchor. Rendered SVG is byte-identical, so the existing browser tests are unchanged. Verified live: 62 nodes + 58 connector lines render, node-tap selects. Co-Authored-By: Claude Opus 4.8 --- .../genealogy/StammbaumConnectors.svelte | 189 +++++++++++++ .../lib/person/genealogy/StammbaumNode.svelte | 90 ++++++ .../lib/person/genealogy/StammbaumTree.svelte | 258 +----------------- 3 files changed, 289 insertions(+), 248 deletions(-) create mode 100644 frontend/src/lib/person/genealogy/StammbaumConnectors.svelte create mode 100644 frontend/src/lib/person/genealogy/StammbaumNode.svelte diff --git a/frontend/src/lib/person/genealogy/StammbaumConnectors.svelte b/frontend/src/lib/person/genealogy/StammbaumConnectors.svelte new file mode 100644 index 00000000..0a647b70 --- /dev/null +++ b/frontend/src/lib/person/genealogy/StammbaumConnectors.svelte @@ -0,0 +1,189 @@ + + + +{#each parentLinks.shared as group (group.key)} + {@const aCenter = nodeCenter(group.parentA)} + {@const bCenter = nodeCenter(group.parentB)} + {@const childCenters = group.childIds + .map((id) => nodeCenter(id)) + .filter((c): c is { x: number; y: number } => c !== null)} + {#if aCenter && bCenter && childCenters.length > 0} + {@const midX = (aCenter.x + bCenter.x) / 2} + {@const parentBottomY = aCenter.y + NODE_H / 2} + {@const childTopY = childCenters[0].y - NODE_H / 2} + {@const barY = (parentBottomY + childTopY) / 2} + {@const xs = childCenters.map((c) => c.x)} + {@const minX = Math.min(midX, ...xs)} + {@const maxX = Math.max(midX, ...xs)} + + {#if minX !== maxX} + + {/if} + {#each childCenters as cc, i (group.childIds[i])} + + {/each} + {/if} +{/each} + + +{#each parentLinks.single as link (link.key)} + {@const parentCenter = nodeCenter(link.parentId)} + {@const childCenter = nodeCenter(link.childId)} + {#if parentCenter && childCenter} + {@const parentBottomY = parentCenter.y + NODE_H / 2} + {@const childTopY = childCenter.y - NODE_H / 2} + {@const barY = (parentBottomY + childTopY) / 2} + + {#if parentCenter.x !== childCenter.x} + + {/if} + + {/if} +{/each} + + +{#each spouseEdges as e (e.id)} + {@const aCenter = nodeCenter(e.personId)} + {@const bCenter = nodeCenter(e.relatedPersonId)} + {#if aCenter && bCenter} + + + {/if} +{/each} diff --git a/frontend/src/lib/person/genealogy/StammbaumNode.svelte b/frontend/src/lib/person/genealogy/StammbaumNode.svelte new file mode 100644 index 00000000..f2877994 --- /dev/null +++ b/frontend/src/lib/person/genealogy/StammbaumNode.svelte @@ -0,0 +1,90 @@ + + + onSelect(node.id)} + onkeydown={handleKey} + onfocus={() => (focused = true)} + onblur={() => (focused = false)} + class="cursor-pointer focus:outline-none" +> + {#if focused} + + {/if} + + {#if selected} + + {/if} + + {node.displayName} + + {#if node.birthYear || node.deathYear} + + {node.birthYear ?? '?'}–{node.deathYear ?? ''} + + {/if} + diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte b/frontend/src/lib/person/genealogy/StammbaumTree.svelte index 711dc6f2..7d6caab8 100644 --- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte +++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte @@ -1,6 +1,6 @@ @@ -364,180 +288,18 @@ const parentLinks = $derived.by(() => { {/each} {/if} - - {#each parentLinks.shared as group (group.key)} - {@const aCenter = nodeCenter(group.parentA)} - {@const bCenter = nodeCenter(group.parentB)} - {@const childCenters = group.childIds - .map((id) => nodeCenter(id)) - .filter((c): c is { x: number; y: number } => c !== null)} - {#if aCenter && bCenter && childCenters.length > 0} - {@const midX = (aCenter.x + bCenter.x) / 2} - {@const parentBottomY = aCenter.y + NODE_H / 2} - {@const childTopY = childCenters[0].y - NODE_H / 2} - {@const barY = (parentBottomY + childTopY) / 2} - {@const xs = childCenters.map((c) => c.x)} - {@const minX = Math.min(midX, ...xs)} - {@const maxX = Math.max(midX, ...xs)} - - {#if minX !== maxX} - - {/if} - {#each childCenters as cc, i (group.childIds[i])} - - {/each} - {/if} - {/each} - - - {#each parentLinks.single as link (link.key)} - {@const parentCenter = nodeCenter(link.parentId)} - {@const childCenter = nodeCenter(link.childId)} - {#if parentCenter && childCenter} - {@const parentBottomY = parentCenter.y + NODE_H / 2} - {@const childTopY = childCenter.y - NODE_H / 2} - {@const barY = (parentBottomY + childTopY) / 2} - - {#if parentCenter.x !== childCenter.x} - - {/if} - - {/if} - {/each} - - - {#each spouseEdges as e (e.id)} - {@const aCenter = nodeCenter(e.personId)} - {@const bCenter = nodeCenter(e.relatedPersonId)} - {#if aCenter && bCenter} - - - {/if} - {/each} + {#each nodes as node (node.id)} {@const pos = layout.positions.get(node.id)} {#if pos} - {@const isSelected = selectedId === node.id} - {@const isFocused = focusedId === node.id} - onSelect(node.id)} - onkeydown={(e) => handleNodeKey(e, node.id)} - onfocus={() => (focusedId = node.id)} - onblur={() => (focusedId = null)} - class="cursor-pointer focus:outline-none" - > - {#if isFocused} - - {/if} - - {#if isSelected} - - {/if} - - {node.displayName} - - {#if node.birthYear || node.deathYear} - - {node.birthYear ?? '?'}–{node.deathYear ?? ''} - - {/if} - + {/if} {/each} -- 2.49.1