Compare commits

..

4 Commits

Author SHA1 Message Date
Marcel
b170085311 fix(stammbaum): node tap stopped selecting — defer pointer capture to drag start (#692)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m34s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m41s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
Capturing the pointer on pointerdown made the browser dispatch the trailing
click at the SVG instead of the node under the finger, so node taps silently
stopped opening the person panel. Capture only once a drag crosses the
threshold; a tap now reaches the node's onclick. Verified live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 18:54:48 +02:00
Marcel
d5a7974f3a 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 <noreply@anthropic.com>
2026-05-29 18:50:54 +02:00
Marcel
53660eadc9 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 <noreply@anthropic.com>
2026-05-29 18:49:03 +02:00
Marcel
f4b631e1bc 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 <noreply@anthropic.com>
2026-05-29 18:47:29 +02:00
6 changed files with 114 additions and 12 deletions

View File

@@ -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();

View File

@@ -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 };

View File

@@ -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 {

View File

@@ -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<SVGSVGElement, PanZoomGesturesParams> = (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;
@@ -101,11 +104,11 @@ export const panZoomGestures: Action<SVGSVGElement, PanZoomGesturesParams> = (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?.();
@@ -135,8 +138,7 @@ export const panZoomGestures: Action<SVGSVGElement, PanZoomGesturesParams> = (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;
}
@@ -144,7 +146,17 @@ export const panZoomGestures: Action<SVGSVGElement, PanZoomGesturesParams> = (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,

View File

@@ -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);
});
});

View File

@@ -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<HTMLElement>(FOCUSABLE_SELECTOR));
function onKeydown(event: KeyboardEvent) {
@@ -38,6 +41,7 @@ export function trapFocus(node: HTMLElement) {
return {
destroy() {
node.removeEventListener('keydown', onKeydown);
previouslyFocused?.focus?.();
}
};
}