Compare commits

...

5 Commits

Author SHA1 Message Date
Marcel
81224829a2 test(stammbaum): prove the AC8 mobile-centre wiring at the route layer (#703)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m38s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m36s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m3s
Sara/Elicit noted AC8 was proven only as recentreAbove geometry, never as
wired behaviour. Add route-level tests that mock window.matchMedia: a tap
recentres the canvas (mirror effect re-fires) when the mobile breakpoint
matches, and leaves the view untouched on desktop where the side panel is a
flex sibling that never overlaps the canvas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 19:21:24 +02:00
Marcel
7cc2ddc6ad refactor(stammbaum): carry child id on the connector centre object (#703)
The shared parent-pair child loop read group.childIds[i] while iterating
the filtered childCenters, so a child without a position would desync the
id from the centre — and that index now also drives the active-connector
lookup. Ride the id on the mapped {id,x,y} centre so the two never drift;
a positionless child drops out of both together.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 19:17:34 +02:00
Marcel
da3067150d test(stammbaum): assert connector dimming at the render layer (#703 AC5)
Sara/Elicit flagged that AC5 was proven only at the isConnectorActive
predicate level. Add render-layer assertions: no connector group carries a
dim opacity when nothing is selected, and selecting Vater dims exactly the
vertical feeding the collateral child Tante. Exercises the shared
parent-pair per-child <g opacity> wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 19:15:54 +02:00
Marcel
10249c33be fix(stammbaum): raise dimmed opacity to 0.45 and bind tests to the constant (#703)
Bump DIMMED_OPACITY 0.4 -> 0.45 so dimmed outlines/labels stay legible
against bg-surface in both themes (dark mode dims already-light mint, the
riskier case). Import the constant into StammbaumTree.svelte.test.ts so the
node-opacity assertions track it instead of a hard-coded '0.4'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 19:13:49 +02:00
Marcel
9c12f62345 fix(stammbaum): keep dimmed nodes opaque so connectors do not bleed through (#703)
Group opacity on the node <g> made the whole node translucent — including
its card fill — so the connector lines drawn beneath a dimmed node showed
through it. Render the card fill at full strength outside the dim group and
move the lineage focus+dim onto an inner content group (outline + labels)
only. The focus ring also leaves the dim group, so a dimmed-but-focused
node keeps a full-strength ring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 19:12:39 +02:00
5 changed files with 183 additions and 40 deletions

View File

@@ -106,8 +106,11 @@ const parentLinks = $derived.by<ParentLinks>(() => {
{@const aCenter = nodeCenter(group.parentA)}
{@const bCenter = nodeCenter(group.parentB)}
{@const childCenters = group.childIds
.map((id) => nodeCenter(id))
.filter((c): c is { x: number; y: number } => c !== null)}
.map((id) => {
const c = nodeCenter(id);
return c ? { id, x: c.x, y: c.y } : null;
})
.filter((c): c is { id: string; x: number; y: number } => c !== null)}
{#if aCenter && bCenter && childCenters.length > 0}
{@const midX = (aCenter.x + bCenter.x) / 2}
{@const parentBottomY = aCenter.y + NODE_H / 2}
@@ -138,13 +141,14 @@ const parentLinks = $derived.by<ParentLinks>(() => {
/>
{/if}
</g>
{#each childCenters as cc, i (group.childIds[i])}
{#each childCenters as cc (cc.id)}
<!-- Each vertical joins the parent pair to one child: active only when
both parents and that child are active, so the same bar can stay lit
to the lineage child while dimming to a collateral sibling. -->
to the lineage child while dimming to a collateral sibling. The child
id rides on the centre object, so it never desyncs from the filtered
centres (a child without a position drops out of both together). -->
{@const childActive =
isConnectorActive(group.parentA, group.childIds[i]) &&
isConnectorActive(group.parentB, group.childIds[i])}
isConnectorActive(group.parentA, cc.id) && isConnectorActive(group.parentB, cc.id)}
<g class="lineage-fade" opacity={connectorOpacity(childActive)}>
<line
x1={cc.x}

View File

@@ -9,7 +9,7 @@ interface Props {
node: PersonNodeDTO;
pos: { x: number; y: number };
selected: boolean;
/** Dim the whole node when a lineage is highlighted and this person is outside it. */
/** Dim the node's outline + labels when a lineage is highlighted and this person is outside it. */
dimmed?: boolean;
onSelect: (id: string) => void;
}
@@ -38,12 +38,11 @@ const datesLabel = $derived(
aria-label="{node.displayName}{datesLabel}"
aria-expanded={selected}
transform="translate({pos.x}, {pos.y})"
opacity={dimmed ? DIMMED_OPACITY : undefined}
onclick={() => onSelect(node.id)}
onkeydown={handleKey}
onfocus={() => (focused = true)}
onblur={() => (focused = false)}
class="lineage-fade cursor-pointer focus:outline-none"
class="cursor-pointer focus:outline-none"
>
{#if focused}
<rect
@@ -57,40 +56,52 @@ const datesLabel = $derived(
stroke-width="2"
/>
{/if}
<!-- Opaque card fill — full strength even when dimmed, so the connectors
drawn beneath the node never bleed through. The lineage dim lives on the
content group below; group opacity here would make the fill translucent. -->
<rect
width={NODE_W}
height={NODE_H}
rx="4"
fill={selected ? 'var(--c-primary)' : 'var(--c-surface)'}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{#if selected}
<rect width="4" height={NODE_H} rx="2" fill="var(--c-accent)" />
{/if}
<text
x={NODE_W / 2}
y={NODE_H / 2 - 6}
text-anchor="middle"
font-family="serif"
font-size="16"
fill={selected ? 'var(--c-primary-fg)' : 'var(--c-ink)'}
>
{node.displayName}
</text>
{#if node.birthYear || node.deathYear}
<!-- Outline + labels carry the lineage focus+dim; the box stays opaque. -->
<g class="lineage-fade" opacity={dimmed ? DIMMED_OPACITY : undefined}>
<rect
width={NODE_W}
height={NODE_H}
rx="4"
fill="none"
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{#if selected}
<rect width="4" height={NODE_H} rx="2" fill="var(--c-accent)" />
{/if}
<text
x={NODE_W / 2}
y={NODE_H / 2 + 12}
y={NODE_H / 2 - 6}
text-anchor="middle"
font-family="sans-serif"
font-size="12"
fill={selected ? 'var(--c-primary-fg)' : 'var(--c-ink-3)'}
opacity={selected ? 0.75 : 1}
font-family="serif"
font-size="16"
fill={selected ? 'var(--c-primary-fg)' : 'var(--c-ink)'}
>
{node.birthYear ?? '?'}{node.deathYear ?? ''}
{node.displayName}
</text>
{/if}
{#if node.birthYear || node.deathYear}
<text
x={NODE_W / 2}
y={NODE_H / 2 + 12}
text-anchor="middle"
font-family="sans-serif"
font-size="12"
fill={selected ? 'var(--c-primary-fg)' : 'var(--c-ink-3)'}
opacity={selected ? 0.75 : 1}
>
{node.birthYear ?? '?'}{node.deathYear ?? ''}
</text>
{/if}
</g>
</g>
<style>

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import StammbaumTree from './StammbaumTree.svelte';
import type { PanZoomState } from './panZoom';
import { DIMMED_OPACITY } from './layout/highlightLineage';
const ID_A = '00000000-0000-0000-0000-000000000001';
const ID_B = '00000000-0000-0000-0000-000000000002';
@@ -940,12 +941,38 @@ describe('StammbaumTree lineage highlight (#703)', () => {
edge('mutter', 'kind', 'PARENT_OF')
];
// The lineage dim lives on the inner content group (outline + labels); the
// card fill renders outside it at full strength so connectors beneath never
// bleed through a dimmed node.
function nodeOpacity(displayName: string): string | null {
const content = nodeContentGroup(displayName);
return content.getAttribute('opacity');
}
function nodeContentGroup(displayName: string): Element {
const g = document.querySelector(`g[role="button"][aria-label="${displayName}"]`);
if (!g) throw new Error(`No node group rendered for ${displayName}`);
return g.getAttribute('opacity');
const content = g.querySelector('g.lineage-fade');
if (!content) throw new Error(`No content group rendered for ${displayName}`);
return content;
}
/** The opaque card fill is a direct-child rect of the node, outside the dim group. */
function cardFillIsOpaque(displayName: string): boolean {
const g = document.querySelector(`g[role="button"][aria-label="${displayName}"]`);
if (!g) throw new Error(`No node group rendered for ${displayName}`);
const fill = g.querySelector(':scope > rect');
if (!fill) throw new Error(`No card-fill rect rendered for ${displayName}`);
return fill.getAttribute('opacity') === null && fill.getAttribute('fill-opacity') === null;
}
const DIM = String(DIMMED_OPACITY);
// Connector groups render as direct <svg> children; the node content groups
// (also .lineage-fade) are nested inside g[role="button"], so the child
// combinator scopes cleanly to connectors.
function dimmedConnectorCount(): number {
return Array.from(document.querySelectorAll('svg > g.lineage-fade')).filter(
(g) => g.getAttribute('opacity') === DIM
).length;
}
const DIM = '0.4';
it('renders every node at full strength when nothing is selected (AC1)', () => {
render(StammbaumTree, {
@@ -974,8 +1001,11 @@ describe('StammbaumTree lineage highlight (#703)', () => {
for (const name of ['Vater', 'Grossvater', 'Grossmutter', 'Kind', 'Mutter']) {
expect(nodeOpacity(name)).toBeNull();
}
// The collateral sibling dims.
// The collateral sibling dims — but its card fill stays opaque so the
// connectors drawn beneath it do not show through (the dim is on the
// outline + labels only).
expect(nodeOpacity('Tante')).toBe(DIM);
expect(cardFillIsOpaque('Tante')).toBe(true);
});
it('recomputes the highlight for a newly selected person and clears the previous one (AC6)', async () => {
@@ -1026,4 +1056,33 @@ describe('StammbaumTree lineage highlight (#703)', () => {
});
for (const n of NODES) expect(nodeOpacity(n.displayName)).toBeNull();
});
it('leaves every connector at full strength when nothing is selected (AC5)', () => {
render(StammbaumTree, {
nodes: NODES,
edges: EDGES,
selectedId: null,
panZoom: { x: 0, y: 0, z: 1 },
showGutter: false,
onSelect: () => {}
});
expect(dimmedConnectorCount()).toBe(0);
});
it('dims exactly the connector feeding the collateral child at the render layer (AC5)', () => {
render(StammbaumTree, {
nodes: NODES,
edges: EDGES,
selectedId: 'vater',
panZoom: { x: 0, y: 0, z: 1 },
showGutter: false,
onSelect: () => {}
});
// Every connector among the bloodline + spouses stays full strength; only
// the vertical joining the active parent pair (Grossvater+Grossmutter) to
// the dimmed collateral child (Tante) renders at DIMMED_OPACITY. This proves
// the <g opacity> render wiring — not just the isConnectorActive predicate —
// and exercises the shared parent-pair per-child path.
expect(dimmedConnectorCount()).toBe(1);
});
});

View File

@@ -16,11 +16,13 @@ import type { components } from '$lib/generated/api';
type RelationshipDTO = components['schemas']['RelationshipDTO'];
/**
* Opacity applied to dimmed nodes and connectors. ~0.4 keeps names legible while
* clearly de-emphasised, and works as a lightness cue in both themes (the colour
* tokens are theme-aware) so the cue does not rely on hue (WCAG 1.4.1 / NFR-A11Y-001).
* Opacity applied to dimmed node outlines/labels and connectors. 0.45 keeps names
* legible against bg-surface in both themes (dark mode dims already-light mint, the
* riskier case) while clearly de-emphasised, and works as a lightness cue so the cue
* does not rely on hue (WCAG 1.4.1 / NFR-A11Y-001). The dim is applied to the node's
* outline + labels only — the card fill stays opaque, see StammbaumNode.svelte.
*/
export const DIMMED_OPACITY = 0.4;
export const DIMMED_OPACITY = 0.45;
/** Adjacency index over the family graph, built once per edge set. */
export type LineageIndex = {

View File

@@ -24,7 +24,28 @@ vi.mock('$app/navigation', () => ({
goto: vi.fn()
}));
afterEach(cleanup);
// The page reads window.matchMedia('(max-width: 767px)').matches at init to
// decide whether to centre a tapped person above the bottom sheet (#703 AC8).
// Make the mock query-aware so only the mobile breakpoint flips; other media
// queries (e.g. prefers-reduced-motion) keep their benign default.
const originalMatchMedia = window.matchMedia;
function mockMatchMedia(isMobile: boolean) {
window.matchMedia = ((query: string) => ({
matches: query.includes('max-width') ? isMobile : false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false
})) as unknown as typeof window.matchMedia;
}
afterEach(() => {
cleanup();
window.matchMedia = originalMatchMedia;
});
async function loadComponent() {
return (await import('./+page.svelte')).default;
@@ -35,6 +56,12 @@ const sampleNodes = [
{ id: 'p-2', firstName: 'Bert', lastName: 'Schmidt', displayName: 'Bert Schmidt' }
];
// Typed family nodes for the AC8 tests — familyMember is required on the DTO.
const familyNodes = [
{ id: 'p-1', displayName: 'Anna Schmidt', familyMember: true },
{ id: 'p-2', displayName: 'Bert Schmidt', familyMember: true }
];
describe('stammbaum page', () => {
it('shows the empty state when there are no family nodes', async () => {
mockPage.url = new URL('http://localhost/stammbaum');
@@ -125,4 +152,44 @@ describe('stammbaum page', () => {
expect(url.searchParams.has('cx')).toBe(true);
expect(url.searchParams.has('cy')).toBe(true);
});
// AC8 — the tapped person must clear the bottom sheet on a phone, but the
// desktop side panel is a flex sibling that never overlaps the canvas, so no
// centring should fire there. These tests prove the matchMedia gate around
// selectPerson, not just the recentreAbove geometry (covered in panZoom.test).
it('recentres the tapped person when matchMedia reports mobile (#703 AC8)', async () => {
mockMatchMedia(true);
mockPage.url = new URL('http://localhost/stammbaum');
const Stammbaum = await loadComponent();
render(Stammbaum, {
props: { data: { nodes: familyNodes, edges: [], initialView: DEFAULT_VIEW } }
});
// Let the mount-time URL mirror settle, then isolate the tap's effect.
await vi.waitFor(() => expect(replaceState).toHaveBeenCalled());
replaceState.mockClear();
await page.getByRole('button', { name: 'Anna Schmidt' }).click();
// The mobile tap recentres the canvas → the view changes → the ?cx&cy&z
// mirror effect re-fires. (Desktop, below, leaves the view untouched.)
await vi.waitFor(() => expect(replaceState).toHaveBeenCalled());
});
it('does not recentre on tap when matchMedia reports desktop (#703 AC8)', async () => {
mockMatchMedia(false);
mockPage.url = new URL('http://localhost/stammbaum');
const Stammbaum = await loadComponent();
render(Stammbaum, {
props: { data: { nodes: familyNodes, edges: [], initialView: DEFAULT_VIEW } }
});
await vi.waitFor(() => expect(replaceState).toHaveBeenCalled());
replaceState.mockClear();
await page.getByRole('button', { name: 'Anna Schmidt' }).click();
// The tap registers — the desktop side panel opens — but no recentre fires,
// so the view never changes and the mirror effect stays silent.
await expect.element(page.getByRole('complementary')).toBeVisible();
expect(replaceState).not.toHaveBeenCalled();
});
});