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}