diff --git a/frontend/src/lib/person/genealogy/StammbaumAffordance.svelte b/frontend/src/lib/person/genealogy/StammbaumAffordance.svelte new file mode 100644 index 00000000..044b7e1f --- /dev/null +++ b/frontend/src/lib/person/genealogy/StammbaumAffordance.svelte @@ -0,0 +1,90 @@ + + +{#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}