Compare commits

...

10 Commits

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 17:17:10 +02:00
Marcel
80f5e0b147 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 <noreply@anthropic.com>
2026-05-29 17:15:36 +02:00
Marcel
11b70d814f feat(stammbaum): first-load touch affordance hint (#692)
Add StammbaumAffordance: a touch-only "drag to explore · pinch to zoom" hint
that auto-dismisses on the first canvas pointer interaction (wired via the
gesture action's onGestureStart) or the explicit close, and stays dismissed for
30 days via a localStorage timestamp (boolean gate only, never rendered).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 17:13:36 +02:00
Marcel
1dffb430ac 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 <noreply@anthropic.com>
2026-05-29 17:10:49 +02:00
Marcel
1e5a45a027 feat(stammbaum): dismissible accessible mobile bottom sheet (#692)
Wrap the mobile person panel in StammbaumBottomSheet: drag-handle grip with
swipe-down-to-dismiss (≥80px), full-screen backdrop button for tap-outside
dismiss, role=dialog + aria-label, focus trap, and Escape (NFR-A11Y-004).
Pan/zoom state is untouched by open/close (US-PANEL-001/002).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 17:06:55 +02:00
Marcel
ccc37fe1bb feat(shared): add trapFocus action for modal overlays (#692)
Focuses the first focusable on mount and wraps Tab/Shift+Tab within the node.
Used by the Stammbaum mobile bottom sheet (NFR-A11Y-004).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 17:04:12 +02:00
Marcel
289c3bbfb5 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 <noreply@anthropic.com>
2026-05-29 17:02:47 +02:00
Marcel
8d29bb10e2 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 <noreply@anthropic.com>
2026-05-29 16:58:36 +02:00
Marcel
396c87f8ab 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 <noreply@anthropic.com>
2026-05-29 16:54:34 +02:00
Marcel
7a6c2e877f feat(stammbaum): bottom-right zoom + fit-to-screen control cluster (#692)
Move zoom controls out of the page header into a docked bottom-right cluster
inside the canvas (one-handed phone reach, Leonie) and add a fit-to-screen
button (data-testid=fit-to-screen). Add the 5 new i18n keys to de/en/es.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 16:52:32 +02:00
25 changed files with 833 additions and 61 deletions

View File

@@ -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.253.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

View File

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

View File

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

View File

@@ -1106,6 +1106,11 @@
"stammbaum_relationships_heading": "Stammbaum & Beziehungen",
"stammbaum_zoom_in": "Vergrößern",
"stammbaum_zoom_out": "Verkleinern",
"stammbaum_fit_to_screen": "An Bildschirm anpassen",
"stammbaum_affordance_hint": "Ziehen zum Erkunden · Zusammendrücken zum Zoomen",
"stammbaum_affordance_dismiss": "Hinweis schließen",
"stammbaum_close_panel": "Schließen",
"stammbaum_centre_on_person": "Auf diese Person zentrieren",
"relation_error_duplicate": "Diese Beziehung gibt es bereits.",
"relation_error_circular": "Diese Beziehung würde einen Kreis erzeugen.",
"relation_error_self": "Eine Person kann nicht mit sich selbst verbunden werden.",

View File

@@ -1106,6 +1106,11 @@
"stammbaum_relationships_heading": "Family tree & relationships",
"stammbaum_zoom_in": "Zoom in",
"stammbaum_zoom_out": "Zoom out",
"stammbaum_fit_to_screen": "Fit to screen",
"stammbaum_affordance_hint": "Drag to explore · pinch to zoom",
"stammbaum_affordance_dismiss": "Dismiss hint",
"stammbaum_close_panel": "Close",
"stammbaum_centre_on_person": "Centre on this person",
"relation_error_duplicate": "This relationship already exists.",
"relation_error_circular": "This relationship would form a cycle.",
"relation_error_self": "A person cannot be related to themselves.",

View File

@@ -1106,6 +1106,11 @@
"stammbaum_relationships_heading": "Árbol genealógico & relaciones",
"stammbaum_zoom_in": "Acercar",
"stammbaum_zoom_out": "Alejar",
"stammbaum_fit_to_screen": "Ajustar a la pantalla",
"stammbaum_affordance_hint": "Arrastra para explorar · pellizca para ampliar",
"stammbaum_affordance_dismiss": "Cerrar aviso",
"stammbaum_close_panel": "Cerrar",
"stammbaum_centre_on_person": "Centrar en esta persona",
"relation_error_duplicate": "Esta relación ya existe.",
"relation_error_circular": "Esta relación crearía un ciclo.",
"relation_error_self": "Una persona no puede estar relacionada consigo misma.",

View File

@@ -0,0 +1,90 @@
<script lang="ts">
import { onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
interface Props {
/** Set true once the canvas receives its first pointer interaction. */
dismissed?: boolean;
/**
* Force touch mode on/off. When undefined, falls back to a
* `matchMedia('(pointer: coarse)')` check so the hint only appears on touch
* devices (OQ-008). Tests pass an explicit boolean.
*/
touch?: boolean;
}
let { dismissed = false, touch }: Props = $props();
// Boolean gate only — the stored timestamp is compared, never rendered to the
// DOM (Nora #692). 30-day re-show window (NFR-USE-001).
const STORAGE_KEY = 'stammbaumAffordanceDismissedAt';
const RESHOW_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
function recentlyDismissed(): boolean {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return false;
return Date.now() - Number(raw) < RESHOW_AFTER_MS;
} catch {
return false;
}
}
function isTouch(): boolean {
if (touch !== undefined) return touch;
return (
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(pointer: coarse)').matches
);
}
let visible = $state(false);
onMount(() => {
visible = isTouch() && !recentlyDismissed();
});
function hide() {
try {
localStorage.setItem(STORAGE_KEY, String(Date.now()));
} catch {
/* storage unavailable — hide anyway for this session */
}
visible = false;
}
// First canvas interaction auto-dismisses the hint (Leonie).
$effect(() => {
if (dismissed && visible) hide();
});
</script>
{#if visible}
<div
class="pointer-events-none absolute inset-x-0 bottom-4 z-20 flex justify-center px-4"
role="status"
>
<div
class="pointer-events-auto flex items-center gap-2 rounded-full border border-line bg-surface/95 px-4 py-2 text-sm text-ink-2 shadow-sm"
>
<span>{m.stammbaum_affordance_hint()}</span>
<button
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"
>
<svg
class="h-3.5 w-3.5"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path stroke-linecap="round" d="M3 3l10 10M13 3L3 13" />
</svg>
</button>
</div>
</div>
{/if}

View File

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

View File

@@ -0,0 +1,75 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { trapFocus } from '$lib/shared/actions/trapFocus';
import StammbaumSidePanel from '$lib/person/genealogy/StammbaumSidePanel.svelte';
import type { components } from '$lib/generated/api';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
interface Props {
node: PersonNodeDTO;
canWrite: boolean;
onClose: () => void;
onCentre?: () => void;
}
let { node, canWrite, onClose, onCentre }: Props = $props();
// Swipe the sheet down past this threshold to dismiss it (Leonie).
const SWIPE_DISMISS_PX = 80;
let dragY = $state(0);
let dragging = false;
let startY = 0;
function onHandleDown(event: PointerEvent) {
dragging = true;
startY = event.clientY;
(event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId);
}
function onHandleMove(event: PointerEvent) {
if (dragging) dragY = Math.max(0, event.clientY - startY);
}
function onHandleUp() {
if (!dragging) return;
dragging = false;
if (dragY >= SWIPE_DISMISS_PX) onClose();
dragY = 0;
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose();
}
</script>
<!-- Backdrop: a full-screen button so tap-outside dismiss is keyboard- and
screen-reader-accessible without a static-element click handler. -->
<button
type="button"
class="fixed inset-0 z-30 bg-black/30 md:hidden"
aria-label={m.stammbaum_close_panel()}
onclick={onClose}
></button>
<div
role="dialog"
aria-modal="true"
aria-label={node.displayName}
class="fixed inset-x-0 bottom-0 z-40 max-h-[60dvh] overflow-y-auto rounded-t-xl border-t border-line bg-surface shadow-lg md:hidden"
style="transform: translateY({dragY}px);"
use:trapFocus
onkeydown={onKeydown}
>
<!-- Drag handle grip — swipe down to dismiss. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex cursor-grab justify-center py-2 active:cursor-grabbing"
onpointerdown={onHandleDown}
onpointermove={onHandleMove}
onpointerup={onHandleUp}
onpointercancel={onHandleUp}
>
<div class="h-1 w-10 rounded-full bg-line" aria-hidden="true"></div>
</div>
<StammbaumSidePanel node={node} canWrite={canWrite} onClose={onClose} onCentre={onCentre} />
</div>

View File

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

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
interface Props {
onZoomIn: () => void;
onZoomOut: () => void;
onFit: () => void;
}
let { onZoomIn, onZoomOut, onFit }: Props = $props();
// Docked bottom-right inside the canvas — the primary one-handed reach zone on a
// phone (Leonie). The container ignores pointer events so canvas gestures pass
// through the gaps; only the buttons capture taps.
const buttonClass =
'pointer-events-auto inline-flex h-11 w-11 items-center justify-center rounded-sm border border-line bg-surface text-lg text-ink-2 shadow-sm transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none';
</script>
<div
class="pointer-events-none absolute right-4 z-30 flex flex-col gap-1"
style="bottom: max(calc(env(safe-area-inset-bottom, 0px) + 1rem), 1rem);"
>
<button type="button" onclick={onZoomIn} aria-label={m.stammbaum_zoom_in()} class={buttonClass}>
+
</button>
<button type="button" onclick={onZoomOut} aria-label={m.stammbaum_zoom_out()} class={buttonClass}>
</button>
<button
type="button"
data-testid="fit-to-screen"
onclick={onFit}
aria-label={m.stammbaum_fit_to_screen()}
class={buttonClass}
>
</button>
</div>

View File

@@ -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<RelationshipDTO[]>([]);
let derivedRels = $state<InferredRelationshipWithPersonDTO[]>([]);
@@ -95,23 +97,45 @@ const topDerived = $derived(
</p>
{/if}
</div>
<button
type="button"
onclick={onClose}
aria-label={m.comp_dismiss()}
class="shrink-0 rounded-sm p-1 text-ink-3 transition hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
>
<svg
class="h-4 w-4"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
<div class="flex shrink-0 items-center gap-1">
{#if onCentre}
<button
type="button"
onclick={onCentre}
aria-label={m.stammbaum_centre_on_person()}
class="rounded-sm p-1 text-ink-3 transition hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
>
<svg
class="h-4 w-4"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<circle cx="8" cy="8" r="2" />
<path stroke-linecap="round" d="M8 1v2M8 13v2M1 8h2M13 8h2" />
</svg>
</button>
{/if}
<button
type="button"
onclick={onClose}
aria-label={m.comp_dismiss()}
class="rounded-sm p-1 text-ink-3 transition hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
>
<path stroke-linecap="round" d="M3 3l10 10M13 3L3 13" />
</svg>
</button>
<svg
class="h-4 w-4"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path stroke-linecap="round" d="M3 3l10 10M13 3L3 13" />
</svg>
</button>
</div>
</div>
{#if error}

View File

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

View File

@@ -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<ParentLinks>(() => {
baseCentreX: baseCentre.x,
baseCentreY: baseCentre.y,
reducedMotion,
onPanZoom
onPanZoom,
onGestureStart: onActivity
}}
class="block h-full w-full"
>

View File

@@ -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<typeof vi.fn>) =>
const renderTree = (onPanZoom: (state: PanZoomState) => void) =>
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,60 @@
import { describe, it, expect, afterEach } from 'vitest';
const { trapFocus } = await import('./trapFocus');
describe('trapFocus action', () => {
const nodes: HTMLElement[] = [];
function makeContainer(buttonLabels: string[]): {
node: HTMLElement;
buttons: HTMLButtonElement[];
} {
const node = document.createElement('div');
const buttons = buttonLabels.map((label) => {
const b = document.createElement('button');
b.textContent = label;
node.appendChild(b);
return b;
});
document.body.appendChild(node);
nodes.push(node);
return { node, buttons };
}
afterEach(() => {
nodes.forEach((n) => n.remove());
nodes.length = 0;
});
it('focuses the first focusable element on mount', () => {
const { node, buttons } = makeContainer(['one', 'two']);
trapFocus(node);
expect(document.activeElement).toBe(buttons[0]);
});
it('wraps Tab from the last focusable back to the first', () => {
const { node, buttons } = makeContainer(['one', 'two']);
trapFocus(node);
buttons[1].focus();
node.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
expect(document.activeElement).toBe(buttons[0]);
});
it('wraps Shift+Tab from the first focusable to the last', () => {
const { node, buttons } = makeContainer(['one', 'two']);
trapFocus(node);
buttons[0].focus();
node.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true }));
expect(document.activeElement).toBe(buttons[1]);
});
it('removes its listener on destroy', () => {
const { node, buttons } = makeContainer(['one', 'two']);
const handle = trapFocus(node);
handle.destroy();
buttons[1].focus();
node.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
// No trap after destroy → focus stays on the last button.
expect(document.activeElement).toBe(buttons[1]);
});
});

View File

@@ -0,0 +1,43 @@
/**
* Trap keyboard focus within a node and move focus to its first focusable
* element on mount. Used by modal-style overlays such as the Stammbaum mobile
* bottom sheet (#692, NFR-A11Y-004). Tab from the last focusable wraps to the
* first and Shift+Tab from the first wraps to the last.
*/
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(',');
export function trapFocus(node: HTMLElement) {
const focusable = () => Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'Tab') return;
const items = focusable();
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
if (event.shiftKey && active === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
}
node.addEventListener('keydown', onKeydown);
focusable()[0]?.focus();
return {
destroy() {
node.removeEventListener('keydown', onKeydown);
}
};
}

View File

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

View File

@@ -1,21 +1,28 @@
<script lang="ts">
import { untrack, tick } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
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 StammbaumAffordance from '$lib/person/genealogy/StammbaumAffordance.svelte';
import {
type PanZoomState,
DEFAULT_VIEW,
clampZoom,
serializePanZoomParams,
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'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
interface Props {
data: { nodes: PersonNodeDTO[]; edges: RelationshipDTO[] };
data: { nodes: PersonNodeDTO[]; edges: RelationshipDTO[]; initialView: PanZoomState };
}
let { data }: Props = $props();
@@ -29,13 +36,46 @@ let selectedId = $state<string | null>(
const selectedNode = $derived(data.nodes.find((n) => n.id === selectedId) ?? null);
let view = $state<PanZoomState>(DEFAULT_VIEW);
let view = $state<PanZoomState>(data.initialView);
let canvasActivity = $state(false);
function zoomIn() {
view = { ...view, z: clampZoom(view.z + ZOOM_STEP_KB) };
}
function zoomOut() {
view = { ...view, z: clampZoom(view.z - ZOOM_STEP_KB) };
}
// One-shot recentre trigger: set the focal id, let StammbaumTree's effect read
// it and emit the recentred view, then clear so the same person can be
// re-centred on a later click (US-PAN-005).
let centreOnId = $state<string | null>(null);
async function centreOnSelected() {
centreOnId = selectedId;
await tick();
centreOnId = null;
}
let cancelAnimation = () => {};
function fitToScreen() {
cancelAnimation();
cancelAnimation = animateView(view, DEFAULT_VIEW, (v) => (view = v), {
reducedMotion: prefersReducedMotion()
});
}
// Mirror the view into shareable ?cx&cy&z params (OQ-003). Only `view` is a
// tracked dependency; the current URL is read untracked so the replaceState
// write does not retrigger the effect. The state thus survives panel open/close
// (US-PANEL-002 AC1) and a shared link reproduces it (AC2).
$effect(() => {
const { cx, cy, z } = serializePanZoomParams(view);
untrack(() => {
const url = new URL(window.location.href);
url.searchParams.set('cx', cx);
url.searchParams.set('cy', cy);
url.searchParams.set('z', z);
replaceState(url, page.state);
});
});
</script>
<!-- 4.25rem = 4rem navbar (h-16) + 0.25rem accent strip (h-1).
@@ -46,26 +86,6 @@ function zoomOut() {
class="flex shrink-0 items-center justify-between border-b border-line bg-surface px-6 py-4"
>
<h1 class="font-serif text-2xl text-ink">{m.nav_stammbaum()}</h1>
{#if data.nodes.length > 0}
<div class="flex items-center gap-2">
<button
type="button"
onclick={zoomOut}
aria-label={m.stammbaum_zoom_out()}
class="inline-flex h-11 w-11 items-center justify-center rounded-sm border border-line bg-surface text-ink-2 transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
>
</button>
<button
type="button"
onclick={zoomIn}
aria-label={m.stammbaum_zoom_in()}
class="inline-flex h-11 w-11 items-center justify-center rounded-sm border border-line bg-surface text-ink-2 transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
>
+
</button>
</div>
{/if}
</header>
{#if data.nodes.length === 0}
@@ -98,15 +118,19 @@ function zoomOut() {
</div>
{:else}
<div class="flex flex-1 overflow-hidden">
<div class="flex-1 overflow-hidden bg-muted/20">
<div class="relative flex-1 overflow-hidden bg-muted/20">
<StammbaumTree
nodes={data.nodes}
edges={data.edges}
selectedId={selectedId}
panZoom={view}
centreOnId={centreOnId}
onPanZoom={(v) => (view = v)}
onActivity={() => (canvasActivity = true)}
onSelect={(id) => (selectedId = id)}
/>
<StammbaumAffordance dismissed={canvasActivity} />
<StammbaumControls onZoomIn={zoomIn} onZoomOut={zoomOut} onFit={fitToScreen} />
</div>
{#if selectedNode}
<!-- Desktop: side panel on the right -->
@@ -117,18 +141,16 @@ function zoomOut() {
node={selectedNode}
canWrite={canWrite}
onClose={() => (selectedId = null)}
onCentre={centreOnSelected}
/>
</aside>
<!-- Mobile: fixed bottom sheet -->
<div
class="fixed inset-x-0 bottom-0 z-40 max-h-[60dvh] overflow-y-auto border-t border-line bg-surface shadow-lg md:hidden"
>
<StammbaumSidePanel
node={selectedNode}
canWrite={canWrite}
onClose={() => (selectedId = null)}
/>
</div>
<!-- Mobile: dismissible bottom sheet (overlay, preserves pan/zoom) -->
<StammbaumBottomSheet
node={selectedNode}
canWrite={canWrite}
onClose={() => (selectedId = null)}
onCentre={centreOnSelected}
/>
{/if}
</div>
{/if}

View File

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

View File

@@ -1,9 +1,11 @@
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'),
state: {},
data: { canWrite: false } as { canWrite: boolean }
};
@@ -13,6 +15,11 @@ vi.mock('$app/state', () => ({
}
}));
const replaceState = vi.fn();
vi.mock('$app/navigation', () => ({
replaceState: (...args: unknown[]) => replaceState(...args)
}));
afterEach(cleanup);
async function loadComponent() {
@@ -28,7 +35,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 +48,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 +59,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 +71,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 +81,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,11 +91,29 @@ 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();
// Just verify that repeated clicks don't throw — branch coverage
await expect.element(zoomOut).toBeVisible();
});
it('mirrors the view into ?cx&cy&z when zoomed (US-PANEL-002 AC2)', async () => {
mockPage.url = new URL('http://localhost/stammbaum');
replaceState.mockClear();
const Stammbaum = await loadComponent();
render(Stammbaum, {
props: { data: { nodes: sampleNodes, edges: [], initialView: DEFAULT_VIEW } }
});
await page.getByRole('button', { name: /vergrößern/i }).click();
await vi.waitFor(() => expect(replaceState).toHaveBeenCalled());
const url = replaceState.mock.calls.at(-1)![0] as URL;
expect(url.searchParams.get('z')).toBeTruthy();
expect(url.searchParams.has('cx')).toBe(true);
expect(url.searchParams.has('cy')).toBe(true);
});
});