Files
familienarchiv/frontend/src/lib/person/genealogy/StammbaumAffordance.svelte
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

91 lines
2.3 KiB
Svelte

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