diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte b/frontend/src/lib/person/genealogy/StammbaumTree.svelte index 738e19b5..9dd3c29f 100644 --- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte +++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte @@ -133,6 +133,19 @@ const viewBox = $derived.by(() => { return `${cx - w / 2} ${cy - h / 2} ${w} ${h}`; }); +// Permanent edge-fade affordance (#692, replaces US-PAN-006 AC3). When the tree +// is zoomed past fit, content is clipped at the viewport edges, so a 24px fade +// on all four edges cues that more tree exists off-screen. Zero JS beyond this +// reactive style; nothing fades at fit (z <= 1, whole tree visible). +const EDGE_FADE = 24; +const maskStyle = $derived( + panZoom.z > 1 + ? `-webkit-mask-image:linear-gradient(to right,transparent,#000 ${EDGE_FADE}px,#000 calc(100% - ${EDGE_FADE}px),transparent),linear-gradient(to bottom,transparent,#000 ${EDGE_FADE}px,#000 calc(100% - ${EDGE_FADE}px),transparent);` + + `mask-image:linear-gradient(to right,transparent,#000 ${EDGE_FADE}px,#000 calc(100% - ${EDGE_FADE}px),transparent),linear-gradient(to bottom,transparent,#000 ${EDGE_FADE}px,#000 calc(100% - ${EDGE_FADE}px),transparent);` + + `-webkit-mask-composite:source-in;mask-composite:intersect;` + : '' +); + function nodeCenter(id: string): { x: number; y: number } | null { const p = layout.positions.get(id); if (!p) return null; @@ -273,6 +286,7 @@ const parentLinks = $derived.by(() => { role="img" aria-label="Stammbaum" tabindex="0" + style={maskStyle} onkeydown={handleCanvasKey} use:panZoomGestures={{ state: panZoom, diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts b/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts index e0c07987..737d595f 100644 --- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts +++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts @@ -610,6 +610,28 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => { expect(view.z).toBe(1); }); + it('omits the edge-fade mask at fit (z = 1) (#692)', async () => { + render(StammbaumTree, { + nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }], + edges: [], + selectedId: null, + panZoom: { x: 0, y: 0, z: 1 }, + onSelect: () => {} + }); + expect(document.querySelector('svg')!.getAttribute('style') ?? '').not.toContain('mask-image'); + }); + + it('applies the edge-fade mask when zoomed past fit (#692)', async () => { + render(StammbaumTree, { + nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }], + edges: [], + selectedId: null, + panZoom: { x: 0, y: 0, z: 2 }, + onSelect: () => {} + }); + expect(document.querySelector('svg')!.getAttribute('style') ?? '').toContain('mask-image'); + }); + it('does not call onSelect for other keys', async () => { const onSelect = vi.fn(); render(StammbaumTree, {