feat(stammbaum): render generation gutter on the family tree (#689)

The gutter sits 100 px to the left of the tree canvas on md+ viewports
(hidden entirely below md to preserve scrollable area on phones — see
spec's deliberate dual-audience trade-off). Per occupied generation
row it draws:

- A full-width decorative stripe rect alternating transparent and
  var(--c-gutter-stripe). aria-hidden because it carries no meaning.
- The label `G{n}` at the left edge, sourced from the un-shifted
  node.generation value (never the post-normalise rank), wrapped in
  `<g role="text" aria-label="Generation N">` so screen readers
  announce the full word instead of "G three".

CSS adds --c-gutter-stripe in both the light root and the dark mode
blocks (8% / 14% mint over canvas — decorative contrast carve-out).

Browser tests cover label rendering, the ARIA wrapper, and the
viewport-below-md absent-gutter path via a matchMedia stub. Existing
StammbaumTree structural-invariant tests still pass since none of
them assert anything inside the gutter region.

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-28 15:49:23 +02:00
parent cb8c85a742
commit c0b500b692
3 changed files with 168 additions and 3 deletions

View File

@@ -1,7 +1,13 @@
<script lang="ts">
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import type { components } from '$lib/generated/api';
import { buildLayout, NODE_W, NODE_H, type Layout } from '$lib/person/genealogy/layout/buildLayout';
import {
buildLayout,
NODE_W,
NODE_H,
ROW_GAP,
type Layout
} from '$lib/person/genealogy/layout/buildLayout';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
@@ -17,10 +23,52 @@ interface Props {
let { nodes, edges, selectedId, zoom, onSelect }: Props = $props();
const layout = $derived.by<Layout>(() => buildLayout(nodes, edges));
// Stammbaum gutter (#689). 100 px column on the left of the canvas on md+
// viewports, carrying the G{n} label per generation row. Hidden entirely on
// phones (canvas is already overflow-scroll; 100 px of permanent chrome is
// too costly on a 320 px screen).
const GUTTER_WIDTH_DESKTOP = 100;
const GUTTER_MEDIA_QUERY = '(min-width: 768px)';
let isMdOrUp = $state(false);
$effect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
const mq = window.matchMedia(GUTTER_MEDIA_QUERY);
isMdOrUp = mq.matches;
const handler = (e: MediaQueryListEvent) => (isMdOrUp = e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
});
const gutterWidth = $derived(isMdOrUp ? GUTTER_WIDTH_DESKTOP : 0);
type GutterRow = { rank: number; y: number; label: number | null };
const gutterRows = $derived.by<GutterRow[]>(() => {
if (gutterWidth === 0) return [];
const byId = new SvelteMap(nodes.map((n) => [n.id, n]));
const rows: GutterRow[] = [];
const sortedRanks = [...layout.generations.keys()].sort((a, b) => a - b);
for (const rank of sortedRanks) {
const ids = layout.generations.get(rank)!;
const firstPos = layout.positions.get(ids[0]);
if (!firstPos) continue;
let label: number | null = null;
for (const id of ids) {
const g = byId.get(id)?.generation;
if (g != null) {
label = g;
break;
}
}
rows.push({ rank, y: firstPos.y, label });
}
return rows;
});
const viewBox = $derived.by(() => {
const w = layout.viewW / zoom;
const totalW = layout.viewW + gutterWidth;
const w = totalW / zoom;
const h = layout.viewH / zoom;
const cx = layout.viewX + layout.viewW / 2;
const cx = layout.viewX - gutterWidth + totalW / 2;
const cy = layout.viewY + layout.viewH / 2;
return `${cx - w / 2} ${cy - h / 2} ${w} ${h}`;
});
@@ -117,6 +165,44 @@ const parentLinks = $derived.by<ParentLinks>(() => {
aria-label="Stammbaum"
class="block h-full w-full"
>
<!-- Gutter stripe underlay (#689) — decorative full-row bands alternating
transparent / var(--c-gutter-stripe). aria-hidden because they carry
no meaning; the row's generation is announced by the label group below. -->
{#each gutterRows as row, i (`stripe-${row.rank}`)}
<rect
aria-hidden="true"
x={layout.viewX - gutterWidth}
y={row.y - ROW_GAP / 2}
width={layout.viewW + gutterWidth}
height={NODE_H + ROW_GAP}
fill={i % 2 === 0 ? 'transparent' : 'var(--c-gutter-stripe)'}
/>
{/each}
<!-- Gutter labels (#689) — `G{node.generation}` per occupied row at the
un-shifted source-truth value. Wrapped in <g role="text"> so screen
readers announce "Generation three" instead of "G three". -->
{#each gutterRows as row (`label-${row.rank}`)}
{#if row.label != null}
<g role="text" aria-label={`Generation ${row.label}`}>
<text
x={layout.viewX - gutterWidth + 12}
y={row.y + NODE_H / 2}
text-anchor="start"
dominant-baseline="middle"
font-family="var(--font-sans)"
font-size="12"
font-weight="700"
letter-spacing="0.08em"
fill="var(--c-ink-2)"
style:text-transform="uppercase"
>
G{row.label}
</text>
</g>
{/if}
{/each}
<!-- Shared parent-pair → children: drop from spouse midpoint to a sibling
bar, then short verticals from the bar to each child top. -->
{#each parentLinks.shared as group (group.key)}

View File

@@ -648,3 +648,69 @@ describe('StammbaumTree node rendering branches', () => {
expect(accentRects.length).toBe(1);
});
});
describe('StammbaumTree generation gutter (#689)', () => {
it('renders a G{n} label per occupied generation row when at least one node carries generation', async () => {
render(StammbaumTree, {
nodes: [
{ id: ID_A, displayName: 'Walter', familyMember: true, generation: 2 },
{ id: ID_B, displayName: 'Herbert', familyMember: true, generation: 3 }
],
edges: [],
selectedId: null,
zoom: 1,
onSelect: () => {}
});
const labels = Array.from(document.querySelectorAll('g[role="text"]')).map((g) =>
g.getAttribute('aria-label')
);
expect(labels).toContain('Generation 2');
expect(labels).toContain('Generation 3');
});
it('wraps the visible G3 text inside an aria-labelled group so screen readers announce "Generation"', async () => {
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }],
edges: [],
selectedId: null,
zoom: 1,
onSelect: () => {}
});
const g3 = Array.from(document.querySelectorAll('g[role="text"]')).find(
(g) => g.getAttribute('aria-label') === 'Generation 3'
);
expect(g3).toBeDefined();
expect(g3!.querySelector('text')!.textContent).toMatch(/G\s*3/);
});
it('omits the gutter when matchMedia (min-width: 768px) is false', async () => {
const originalMatchMedia = window.matchMedia;
window.matchMedia = ((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false
})) as unknown as typeof window.matchMedia;
try {
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }],
edges: [],
selectedId: null,
zoom: 1,
onSelect: () => {}
});
const labelGroups = Array.from(document.querySelectorAll('g[role="text"]'));
expect(labelGroups).toHaveLength(0);
} finally {
window.matchMedia = originalMatchMedia;
}
});
});

View File

@@ -160,6 +160,12 @@
with axe (tracked in #480) before tweaking the palette. */
--timeline-bar-idle: rgba(161, 220, 216, 0.35);
--timeline-bar-outside: var(--c-line);
/* Stammbaum gutter stripe (issue #689) — decorative full-row underlay
alternating with transparent. Mint-tinted on canvas to mark generation
rows without competing with node fills. 8% on light surface ≈ #ECF6F4
(~1.04:1 vs canvas — decorative carve-out). */
--c-gutter-stripe: rgba(161, 220, 216, 0.08);
}
/* ─── 5. Dark mode ─────────────────────────────────────────────────────────── */
@@ -236,6 +242,10 @@
clears WCAG 1.4.11 non-text contrast for large UI elements. */
--timeline-bar-idle: #3a6e8c;
--timeline-bar-outside: #1a2735;
/* Stammbaum gutter stripe (issue #689) — 14% mint on dark canvas for
visibility parity with the 8% light-mode token. Decorative carve-out. */
--c-gutter-stripe: rgba(161, 220, 216, 0.14);
}
}
@@ -308,6 +318,9 @@
clears WCAG 1.4.11 non-text contrast for large UI elements. */
--timeline-bar-idle: #3a6e8c;
--timeline-bar-outside: #1a2735;
/* Stammbaum gutter stripe (issue #689) — KEEP IN SYNC with the @media block. */
--c-gutter-stripe: rgba(161, 220, 216, 0.14);
}
/* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */