feat(stammbaum): highlight the selected person's bloodline (#703) #704

Merged
marcel merged 13 commits from feat/issue-703-stammbaum-lineage-highlight into main 2026-05-31 20:11:07 +02:00
Owner

Closes #703.

What

Adds a focus + dim (lineage highlight) layer bound to the Stammbaum side panel. While a person is selected, their bloodline stays at full strength and everyone else fades back so a reader can trace one line of heritage.

Active set = the selected person + their full pedigree upward + their full descendant tree downward + the spouses of every one of those blood people (spouses are active leaves — we never climb into a married-in spouse's own ancestry). Everyone else, and every connector with a dimmed endpoint, renders at ~0.4 opacity. No panel open → full-strength tree.

How

  • Pure traversalfrontend/src/lib/person/genealogy/layout/highlightLineage.ts (beside buildLayout.ts): buildLineageIndex(edges) builds the adjacency once; highlightLineage(index, rootId) returns the active Set<string> and an isConnectorActive(a, b) predicate. The ancestor/descendant walk is guarded by the accumulating visited set, so cyclic PARENT_OF data terminates (REQ-STAMMBAUM-04 / AC10). SIBLING_OF and social relations are ignored, so collaterals never enter the active set.
  • Presentation-only componentsStammbaumNode gains a dimmed prop (group-level opacity on its root <g>); StammbaumConnectors gains an isConnectorActive predicate and wraps each logical connector in a <g opacity> (the shared sibling bar stays lit to a lineage child while dimming to a collateral sibling on the same row). Both fade via a lineage-fade CSS transition gated by prefers-reduced-motion.
  • BindingStammbaumTree derives the active set from the raw selectedId rune ($derived, not $effect); the index rebuilds only when edges change. ?focus={id} paints already dimmed on load (selectedId seeded server-side, AC9).
  • Mobile centring (AC8) — new pure recentreAbove helper in panZoom.ts lifts the tapped anchor above the bottom sheet; the page drives it from a matchMedia flag (desktop side panel never overlaps the canvas, so no centring there).

Opacity, not a hue swap — theme-correct in light/dark and a non-colour lightness cue (NFR-A11Y-001). View-only: no backend / endpoint / schema / generate:api change.

Tests

TDD throughout. New node-project tests (run locally, all green):

  • highlightLineage.test.ts — isolated person, ancestors-only, descendants-only, pedigree fan-out, multiple spouses/remarriage, spouse boundary, SIBLING_OF excluded, connector both-endpoints rule, cyclic-data termination with exact membership.
  • panZoom.test.tsrecentreAbove bias geometry.
  • StammbaumTree.svelte.test.ts — AC1 (no dimming unselected), AC2 (bloodline + spouses full, collaterals dim), AC6 (re-select recomputes), AC7 (close clears). Browser project — runs in CI.

npm run build and npm run check (no new errors in touched files) pass.

Acceptance criteria

AC1–AC7, AC9, AC10 covered by automated tests; AC8 by the recentreAbove unit test plus the mobile wiring. Manual 320px light/dark legibility check recommended before merge (bump DIMMED_OPACITY from 0.4 to 0.45–0.5 if it reads too faint in either theme).

🤖 Generated with Claude Code

Closes #703. ## What Adds a **focus + dim** (lineage highlight) layer bound to the Stammbaum side panel. While a person is selected, their bloodline stays at full strength and everyone else fades back so a reader can trace one line of heritage. Active set = the selected person **+** their full pedigree upward **+** their full descendant tree downward **+** the spouses of every one of those blood people (spouses are active leaves — we never climb into a married-in spouse's own ancestry). Everyone else, and every connector with a dimmed endpoint, renders at ~0.4 opacity. No panel open → full-strength tree. ## How - **Pure traversal** — `frontend/src/lib/person/genealogy/layout/highlightLineage.ts` (beside `buildLayout.ts`): `buildLineageIndex(edges)` builds the adjacency once; `highlightLineage(index, rootId)` returns the active `Set<string>` and an `isConnectorActive(a, b)` predicate. The ancestor/descendant walk is guarded by the accumulating visited set, so cyclic `PARENT_OF` data terminates (REQ-STAMMBAUM-04 / AC10). `SIBLING_OF` and social relations are ignored, so collaterals never enter the active set. - **Presentation-only components** — `StammbaumNode` gains a `dimmed` prop (group-level opacity on its root `<g>`); `StammbaumConnectors` gains an `isConnectorActive` predicate and wraps each logical connector in a `<g opacity>` (the shared sibling bar stays lit to a lineage child while dimming to a collateral sibling on the same row). Both fade via a `lineage-fade` CSS transition gated by `prefers-reduced-motion`. - **Binding** — `StammbaumTree` derives the active set from the raw `selectedId` rune (`$derived`, not `$effect`); the index rebuilds only when `edges` change. `?focus={id}` paints already dimmed on load (selectedId seeded server-side, AC9). - **Mobile centring (AC8)** — new pure `recentreAbove` helper in `panZoom.ts` lifts the tapped anchor above the bottom sheet; the page drives it from a `matchMedia` flag (desktop side panel never overlaps the canvas, so no centring there). Opacity, not a hue swap — theme-correct in light/dark and a non-colour lightness cue (NFR-A11Y-001). View-only: no backend / endpoint / schema / `generate:api` change. ## Tests TDD throughout. New node-project tests (run locally, all green): - `highlightLineage.test.ts` — isolated person, ancestors-only, descendants-only, pedigree fan-out, multiple spouses/remarriage, spouse boundary, `SIBLING_OF` excluded, connector both-endpoints rule, cyclic-data termination with exact membership. - `panZoom.test.ts` — `recentreAbove` bias geometry. - `StammbaumTree.svelte.test.ts` — AC1 (no dimming unselected), AC2 (bloodline + spouses full, collaterals dim), AC6 (re-select recomputes), AC7 (close clears). _Browser project — runs in CI._ `npm run build` and `npm run check` (no new errors in touched files) pass. ## Acceptance criteria AC1–AC7, AC9, AC10 covered by automated tests; AC8 by the `recentreAbove` unit test plus the mobile wiring. Manual 320px light/dark legibility check recommended before merge (bump `DIMMED_OPACITY` from 0.4 to 0.45–0.5 if it reads too faint in either theme). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 7 commits 2026-05-31 16:44:21 +02:00
Pure, DOM-free traversal over the family graph. Given the relationship
edges and a selected root, highlightLineage returns the active id set
(root + full pedigree upward + full descendant tree downward + every
spouse of those blood people, as active leaves) and a connector
predicate active only when both joined people are active.

The walk is guarded by the accumulating visited set, so cyclic PARENT_OF
data terminates (REQ-STAMMBAUM-04 / AC10). SIBLING_OF and social
relation types are ignored, so collaterals never enter the active set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
StammbaumNode gains an optional `dimmed` prop that sets group-level
opacity (DIMMED_OPACITY) on the node's root <g>, so the box, accent bar,
name, and dates fade together as one unit. A lineage-fade CSS transition
eases the change and is neutralised under prefers-reduced-motion. The
selected-node styling (active fill + mint accent bar) is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
StammbaumConnectors gains an isConnectorActive(a, b) predicate prop and
wraps each logical connector in a <g opacity> group. A connector is full
strength only when both joined people are active; otherwise it dims to
DIMMED_OPACITY. The shared parent-pair drop+bar keys on both parents,
while each child vertical keys on both parents AND that child — so the
bar stays lit to a lineage child yet dims to a collateral sibling on the
same row. Defaults to always-active, so no highlight means no dimming.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
StammbaumTree derives the active set from the raw selectedId rune: the
adjacency index is built once per edge set ($derived on edges) and the
walk re-runs on selection change ($derived.by on selectedId). It passes
`dimmed` to each node and the isConnectorActive predicate to the
connectors. A null highlight (no selection) leaves everything full
strength, so an unselected tree never dims (AC1) and a ?focus deep link
paints already dimmed on load (AC9, selectedId seeded server-side).

Adds StammbaumTree.svelte.test.ts cases for AC1 (no dimming when
unselected), AC2 (bloodline + spouses full, collaterals dim), AC6
(re-select recomputes and clears the previous highlight), and AC7
(close returns the whole tree to full strength).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
recentreAbove recentres on a node and lifts it above the viewBox centre
by a fraction of the zoomed viewBox height, measured against the
auto-zoomed height. On a phone this lands the tapped anchor in the band
above the bottom sheet instead of behind it (AC8). A zero bias is exactly
a legible recentre.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On a touch viewport (below the md breakpoint, where the bottom sheet
overlays the lower part of the canvas), tapping a person now auto-centres
them via recentreAbove with a 0.3 height bias, so the highlighted anchor
lands in the band above the sheet instead of behind it (AC8). On desktop
the side panel is a flex sibling that never covers the tree, so the bias
is 0 and selection does not pan. StammbaumTree's recentre effect takes a
centreBiasFraction prop and the page drives it from a matchMedia flag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
docs(glossary): define "lineage highlight" (#703)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m17s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m26s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m2s
e5784caa9d
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: Approved with minor suggestions

TDD discipline is visible and honest: the pure highlightLineage.test.ts and the recentreAbove cases land before/with their implementations, and the traversal is the right shape — $derived over $effect, raw selectedId not a .find() lookup, adjacency built once and keyed on edges, the walk guarded by the accumulating visited set. Components stay presentation-only and receive plain dimmed / predicate props. Names reveal intent. Good.

Suggestions (non-blocking)

  1. Index-alignment smell in StammbaumConnectors.svelte (the shared child loop, ~L134-140). childActive reads group.childIds[i] while iterating the filtered childCenters. If any child lacks a position, childCenters is shorter than childIds and the index drifts — the same latent assumption the existing (group.childIds[i]) key already makes, but now it also drives the active lookup. Pre-existing, low-risk (every node gets a position today), but worth a follow-up: carry the id on the mapped object ({ id, x, y }) so the center and id never desync.
  2. Duplicated lineage-fade style block in StammbaumNode.svelte and StammbaumConnectors.svelte (identical transition: opacity 200ms + reduced-motion). It's 6 lines × 2 — KISS-acceptable, but a single global utility class in app.css would remove the drift risk if the duration ever changes. Your call.
  3. DIMMED_OPACITY is exercised through the components but the literal '0.4' is hard-coded in the test assertion (StammbaumTree.svelte.test.ts) — if the constant moves to 0.45 the test won't follow. Minor; importing the constant into the test would keep them in lock-step.

Clean work. None of the above blocks merge.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ✅ Approved with minor suggestions** TDD discipline is visible and honest: the pure `highlightLineage.test.ts` and the `recentreAbove` cases land before/with their implementations, and the traversal is the right shape — `$derived` over `$effect`, raw `selectedId` not a `.find()` lookup, adjacency built once and keyed on `edges`, the walk guarded by the accumulating visited set. Components stay presentation-only and receive plain `dimmed` / predicate props. Names reveal intent. Good. ### Suggestions (non-blocking) 1. **Index-alignment smell in `StammbaumConnectors.svelte`** (the shared child loop, ~L134-140). `childActive` reads `group.childIds[i]` while iterating the **filtered** `childCenters`. If any child lacks a position, `childCenters` is shorter than `childIds` and the index drifts — the same latent assumption the existing `(group.childIds[i])` key already makes, but now it also drives the active lookup. Pre-existing, low-risk (every node gets a position today), but worth a follow-up: carry the id on the mapped object (`{ id, x, y }`) so the center and id never desync. 2. **Duplicated `lineage-fade` style block** in `StammbaumNode.svelte` and `StammbaumConnectors.svelte` (identical `transition: opacity 200ms` + reduced-motion). It's 6 lines × 2 — KISS-acceptable, but a single global utility class in `app.css` would remove the drift risk if the duration ever changes. Your call. 3. `DIMMED_OPACITY` is exercised through the components but the literal `'0.4'` is hard-coded in the test assertion (`StammbaumTree.svelte.test.ts`) — if the constant moves to 0.45 the test won't follow. Minor; importing the constant into the test would keep them in lock-step. Clean work. None of the above blocks merge.
Author
Owner

🏛️ Markus Keller — Application Architect

Verdict: Approved

Altitude and boundaries are right. One pure, DOM-free module (highlightLineage.ts) at the same layout/ boundary as buildLayout.ts; the adjacency index is built once and the walk is a cheap O(V+E) pass; the components carry no traversal logic. No backend, endpoint, schema, or generate:api change — the /api/network graph is already delivered client-side, so this is a view-only transform. No new route (Stammbaum exists), no new package, no transport change.

Documentation obligation checked: a new domain term was introduced → docs/GLOSSARY.md updated with the "lineage highlight" entry. No ADR is warranted — opacity-dim reuses the established $derived/recentreOn patterns and introduces no lasting structural decision.

Minor note (non-blocking)

  • DIMMED_OPACITY (a presentation value) lives in the otherwise-pure traversal module. It's cohesive with the feature and harmless, but if highlightLineage.ts is ever framed as "pure graph logic," that constant is the one thing that isn't. Leave it; just noting the seam.

No concerns, no open decisions from my angle.

## 🏛️ Markus Keller — Application Architect **Verdict: ✅ Approved** Altitude and boundaries are right. One pure, DOM-free module (`highlightLineage.ts`) at the same `layout/` boundary as `buildLayout.ts`; the adjacency index is built once and the walk is a cheap O(V+E) pass; the components carry no traversal logic. No backend, endpoint, schema, or `generate:api` change — the `/api/network` graph is already delivered client-side, so this is a view-only transform. No new route (Stammbaum exists), no new package, no transport change. Documentation obligation checked: a new **domain term** was introduced → `docs/GLOSSARY.md` updated with the "lineage highlight" entry. ✅ No ADR is warranted — opacity-dim reuses the established `$derived`/`recentreOn` patterns and introduces no lasting structural decision. ### Minor note (non-blocking) - `DIMMED_OPACITY` (a presentation value) lives in the otherwise-pure traversal module. It's cohesive with the feature and harmless, but if `highlightLineage.ts` is ever framed as "pure graph logic," that constant is the one thing that isn't. Leave it; just noting the seam. No concerns, no open decisions from my angle.
Author
Owner

🛡️ Nora Steiner ("NullX") — Application Security Engineer

Verdict: Approved

No new attack surface. This is a client-side transform over a graph the user is already authorized to see (/api/network) — no new input, no fetch, no DOM sink. The opacity attribute is a numeric constant, never user-controlled; aria-label reuses displayName exactly as the unchanged code already did. The only string-interpolated selector (g[role="button"][aria-label="${displayName}"]) is in test code against fixed fixtures, not a production sink.

My prior finding is codified and holds: CWE-835 (uncontrolled recursion) is closed by the into.has(id) visited guard in collectReachable, with REQ-STAMMBAUM-04 / AC10 backed by two explicit tests (a↔b cycle and x→x self-loop) that assert exact membership, not just "doesn't hang." That's the right way to prove a termination fix.

No injection, no auth/permission change, no secrets. Nothing for me to block.

## 🛡️ Nora Steiner ("NullX") — Application Security Engineer **Verdict: ✅ Approved** No new attack surface. This is a client-side transform over a graph the user is already authorized to see (`/api/network`) — no new input, no fetch, no DOM sink. The `opacity` attribute is a numeric constant, never user-controlled; `aria-label` reuses `displayName` exactly as the unchanged code already did. The only string-interpolated selector (`g[role="button"][aria-label="${displayName}"]`) is in **test** code against fixed fixtures, not a production sink. My prior finding is codified and holds: **CWE-835 (uncontrolled recursion)** is closed by the `into.has(id)` visited guard in `collectReachable`, with REQ-STAMMBAUM-04 / AC10 backed by two explicit tests (`a↔b` cycle and `x→x` self-loop) that assert exact membership, not just "doesn't hang." That's the right way to prove a termination fix. No injection, no auth/permission change, no secrets. Nothing for me to block.
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

Verdict: ⚠️ Approved with concerns

The unit layer is strong and at the right altitude: highlightLineage.test.ts covers isolated / ancestors / descendants / pedigree fan-out / multiple-spouse / spouse-boundary / SIBLING_OF-excluded / connector predicate / cyclic-termination, each with one behaviour per test and readable fixtures mirroring buildLayout.test.ts. My earlier tightening landed — AC10 asserts the exact active set (['a','b']), not just non-hang. recentreAbove geometry is unit-tested. Component tests assert via the opacity attribute, not screenshots. Good pyramid shape.

Concerns (coverage gaps, not blockers)

  1. AC5 connector dimming is unverified at the render layer. highlightLineage.test.ts proves the isConnectorActive predicate, but no component test asserts that a connector <g> between an active and a dimmed person actually renders opacity="0.4". The StammbaumTree.svelte.test.ts cases only inspect node opacity. One assertion on a spouse-connector group (active↔in-law) would close the loop and guard the <g opacity> wiring against regression.
  2. AC8 interaction is untested. Only the pure recentreAbove math is covered. The actual wiring — selectPerson firing centreOnId only when isMobile, and centreBiasFraction flipping with the matchMedia flag — has no test. A small browser test (set a narrow viewport, tap a node, assert the view recentred with the bias) would move AC8 from "geometry proven" to "behaviour proven."
  3. Component tests use rerender, which the codebase documents as a full re-mount rather than an in-place prop update — fine for asserting per-selection output (AC6/AC7), just be aware it isn't exercising reactive update paths.

None block merge, but #1 and #2 are the gaps I'd want filled before this is "done-done." Browser tests run CI-only here, so trust CI for the new component cases.

## 🧪 Sara Holt — QA Engineer & Test Strategist **Verdict: ⚠️ Approved with concerns** The unit layer is strong and at the right altitude: `highlightLineage.test.ts` covers isolated / ancestors / descendants / pedigree fan-out / multiple-spouse / spouse-boundary / `SIBLING_OF`-excluded / connector predicate / cyclic-termination, each with one behaviour per test and readable fixtures mirroring `buildLayout.test.ts`. My earlier tightening landed — AC10 asserts the **exact** active set (`['a','b']`), not just non-hang. `recentreAbove` geometry is unit-tested. Component tests assert via the `opacity` attribute, not screenshots. Good pyramid shape. ### Concerns (coverage gaps, not blockers) 1. **AC5 connector dimming is unverified at the render layer.** `highlightLineage.test.ts` proves the `isConnectorActive` *predicate*, but no component test asserts that a connector `<g>` between an active and a dimmed person actually renders `opacity="0.4"`. The `StammbaumTree.svelte.test.ts` cases only inspect **node** opacity. One assertion on a spouse-connector group (active↔in-law) would close the loop and guard the `<g opacity>` wiring against regression. 2. **AC8 interaction is untested.** Only the pure `recentreAbove` math is covered. The actual wiring — `selectPerson` firing `centreOnId` *only when* `isMobile`, and `centreBiasFraction` flipping with the `matchMedia` flag — has no test. A small browser test (set a narrow viewport, tap a node, assert the view recentred with the bias) would move AC8 from "geometry proven" to "behaviour proven." 3. Component tests use `rerender`, which the codebase documents as a full re-mount rather than an in-place prop update — fine for asserting per-selection output (AC6/AC7), just be aware it isn't exercising reactive *update* paths. None block merge, but #1 and #2 are the gaps I'd want filled before this is "done-done." Browser tests run CI-only here, so trust CI for the new component cases.
Author
Owner

🎨 Leonie Voss — UI/UX & Accessibility Lead

Verdict: ⚠️ Approved with concerns

The mechanism is theme-safe and the right call: opacity (a lightness cue), not a hue swap, so the active/dimmed distinction survives greyscale and holds in both themes (WCAG 1.4.1 / NFR-A11Y-001). The dim transition is gated behind prefers-reduced-motion via pure CSS — correct, no JS, zero cost. The selected-node accent bar is preserved as the anchor cue, and AC8 centres the tapped person above the bottom sheet so the anchor isn't trapped behind the 60dvh sheet on a phone. Good mobile thinking.

Concern — must verify before merge (not a code blocker)

  • 0.4 opacity legibility is asserted but not yet visually confirmed. At ~0.4 the dimmed group must stay perceivable, not vanish — dimmed names need to remain readable against bg-surface in both light and dark mode (dark dims mint, which is already light, so it's the riskier case). Please do the 320px pass in both themes during build; if it reads too faint in either, bump DIMMED_OPACITY (highlightLineage.ts:23) to 0.45–0.5. The requirement is "clearly de-emphasised but still legible," not invisible. This is a single constant, so the fix is trivial if the eye test fails.

Minor

  • Confirm the focus ring on a dimmed-but-keyboard-focused node is still visible — the group opacity multiplies the ring too, so a tabbed-to dimmed node at 0.4 may have a faint outline. Likely fine since focus moves attention, but worth a glance at 320px.

No brand or token violations. Approve once the legibility check is done.

## 🎨 Leonie Voss — UI/UX & Accessibility Lead **Verdict: ⚠️ Approved with concerns** The mechanism is theme-safe and the right call: opacity (a lightness cue), not a hue swap, so the active/dimmed distinction survives greyscale and holds in both themes (WCAG 1.4.1 / NFR-A11Y-001). The dim transition is gated behind `prefers-reduced-motion` via pure CSS — correct, no JS, zero cost. The selected-node accent bar is preserved as the anchor cue, and AC8 centres the tapped person above the bottom sheet so the anchor isn't trapped behind the 60dvh sheet on a phone. Good mobile thinking. ### Concern — must verify before merge (not a code blocker) - **0.4 opacity legibility is asserted but not yet visually confirmed.** At ~0.4 the dimmed group must stay *perceivable*, not vanish — dimmed names need to remain readable against `bg-surface` in **both** light and dark mode (dark dims mint, which is already light, so it's the riskier case). Please do the 320px pass in both themes during build; if it reads too faint in either, bump `DIMMED_OPACITY` (`highlightLineage.ts:23`) to 0.45–0.5. The requirement is "clearly de-emphasised but still legible," not invisible. This is a single constant, so the fix is trivial if the eye test fails. ### Minor - Confirm the focus ring on a dimmed-but-keyboard-focused node is still visible — the group `opacity` multiplies the ring too, so a tabbed-to dimmed node at 0.4 may have a faint outline. Likely fine since focus moves attention, but worth a glance at 320px. No brand or token violations. Approve once the legibility check is done.
Author
Owner

🔧 Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

Nothing in my domain. No Compose service, image tag, volume, port, env var, secret, or CI-workflow change — purely a client-side SvelteKit change shipped inside the existing frontend build. No new infrastructure to size or operate, no new failure mode.

One operational note, not a blocker: the new behavioural coverage is in *.svelte.test.ts (the browser/Playwright client project), which runs CI-only per project convention — so the new StammbaumTree highlight cases will execute for the first time in CI on this PR. Worth a glance at that job's result before merge. Build artifact and deploy path are unchanged.

## 🔧 Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** Nothing in my domain. No Compose service, image tag, volume, port, env var, secret, or CI-workflow change — purely a client-side SvelteKit change shipped inside the existing frontend build. No new infrastructure to size or operate, no new failure mode. One operational note, not a blocker: the new behavioural coverage is in `*.svelte.test.ts` (the browser/Playwright `client` project), which runs CI-only per project convention — so the new `StammbaumTree` highlight cases will execute for the first time in CI on this PR. Worth a glance at that job's result before merge. Build artifact and deploy path are unchanged.
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: Approved (traceability complete; one criterion partially verified)

Requirement → verification trace for #703:

AC / REQ Verified by
AC1 (unselected → full strength) StammbaumTree.svelte.test.ts — all nodes opacity null
AC2 (bloodline + spouses full, rest dim) StammbaumTree.svelte.test.ts + highlightLineage.test.ts
AC3 (in-law active, in-law's parents dim) highlightLineage.test.ts (spouse boundary)
AC4 (multiple spouses all active) highlightLineage.test.ts (remarriage)
AC5 (connector active iff both endpoints) highlightLineage.test.ts (predicate) — ⚠️ render layer not asserted
AC6 (re-select recomputes, prior clears) StammbaumTree.svelte.test.ts
AC7 (close → full strength) StammbaumTree.svelte.test.ts
AC8 (touch tap centres above sheet) recentreAbove geometry unit-tested — ⚠️ interaction not asserted
AC9 (?focus paints already dimmed) falls out of selectedId-seeded $derived (no click)
AC10 (cyclic data terminates, defined state) highlightLineage.test.ts — exact membership
REQ-STAMMBAUM-01..04 covered by the above

Every acceptance criterion has a verification path and no Must-have requirement is left ambiguous. Two criteria (AC5, AC8) are proven at the logic/geometry layer but not yet at the rendered-behaviour layer — this overlaps Sara's coverage concerns and is the only open item. NFR-A11Y-001 is structurally satisfied; NFR-PERF-001 (in-memory O(V+E)) holds. No scope creep beyond the issue's stated boundaries.

## 📋 Elicit — Requirements Engineer **Verdict: ✅ Approved (traceability complete; one criterion partially verified)** Requirement → verification trace for #703: | AC / REQ | Verified by | | --- | --- | | AC1 (unselected → full strength) | `StammbaumTree.svelte.test.ts` — all nodes `opacity` null | | AC2 (bloodline + spouses full, rest dim) | `StammbaumTree.svelte.test.ts` + `highlightLineage.test.ts` | | AC3 (in-law active, in-law's parents dim) | `highlightLineage.test.ts` (spouse boundary) | | AC4 (multiple spouses all active) | `highlightLineage.test.ts` (remarriage) | | AC5 (connector active iff both endpoints) | `highlightLineage.test.ts` (predicate) — ⚠️ render layer not asserted | | AC6 (re-select recomputes, prior clears) | `StammbaumTree.svelte.test.ts` | | AC7 (close → full strength) | `StammbaumTree.svelte.test.ts` | | AC8 (touch tap centres above sheet) | `recentreAbove` geometry unit-tested — ⚠️ interaction not asserted | | AC9 (`?focus` paints already dimmed) | falls out of `selectedId`-seeded `$derived` (no click) | | AC10 (cyclic data terminates, defined state) | `highlightLineage.test.ts` — exact membership | | REQ-STAMMBAUM-01..04 | covered by the above | Every acceptance criterion has a verification path and no Must-have requirement is left ambiguous. Two criteria (AC5, AC8) are proven at the logic/geometry layer but not yet at the rendered-behaviour layer — this overlaps Sara's coverage concerns and is the only open item. NFR-A11Y-001 is structurally satisfied; NFR-PERF-001 (in-memory O(V+E)) holds. No scope creep beyond the issue's stated boundaries.
marcel added 5 commits 2026-05-31 19:22:46 +02:00
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>
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>
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>
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>
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
81224829a2
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>
Author
Owner

👨‍💻 Felix Brandt — review follow-up

Pushed 5 commits addressing the open concerns plus a visual defect Marcel caught during testing.

Defect fixed (Marcel, screenshot)

  • Connectors bled through dimmed nodes. Group opacity on the node <g> made the whole node — including its white card fill — translucent, so the connector lines drawn beneath a dimmed box showed through it. Now the card fill renders at full strength outside the dim group, and the lineage focus+dim sits on an inner content group (outline + labels) only. Same opacity-based lightness cue (theme-safe, survives greyscale — Leonie's a11y rationale still holds), but the box is never see-through. — 9c12f623
    • Bonus: the focus ring also moved out of the dim group, so a dimmed-but-keyboard-focused node keeps a full-strength ring — closes @Leonie's minor focus-ring note.

Reviewer concerns

Concern Source Resolution Commit
AC5 connector dimming unverified at the render layer @Sara #1 / @Elicit Render-layer test: no connector group carries a dim opacity unselected; selecting Vater dims exactly the vertical feeding the collateral child Tante (exercises the shared parent-pair <g opacity> wiring) da306715
AC8 mobile-centre wiring untested @Sara #2 / @Elicit Route-level tests mocking window.matchMedia: a tap recentres the canvas when the mobile breakpoint matches, and leaves the view untouched on desktop 81224829
DIMMED_OPACITY hard-coded '0.4' in assertions @Felix #3 Imported the constant into the test; assertions now track it 10249c33
0.4 legibility risk (esp. dark mode) @Leonie Bumped DIMMED_OPACITY 0.4 → 0.45 10249c33
Index-alignment smell (childIds[i] vs filtered childCenters) @Felix #1 Child id now rides on the mapped {id, x, y} centre, so id and centre can never desync — a positionless child drops out of both together 7cc2ddc6

Deferred (with reason)

  • Duplicated lineage-fade style block (@Felix #2) — left as-is per KISS (6 lines × 2; I'd flagged it as acceptable). Happy to extract to a global utility if preferred.

Verification

  • npm run checkno new errors in touched files (the remaining page.svelte.test.ts errors are pre-existing sampleNodes baseline debt).
  • npm run build — green.
  • Pure node-project suites (highlightLineage, panZoom) — 46 green locally. The new *.svelte.test.ts cases (node opacity, AC5 connectors, AC8 wiring) run CI-only per project convention — watch this PR's CI job.

⚠️ One manual eye-check still recommended before merge (@Leonie): confirm the 0.45 dim reads as clearly de-emphasised but legible at 320px in both themes against bg-surface. The dim now lives on the outline + labels over a solid box, so it should read cleaner than the old translucent box — but it's a single constant if it needs nudging.

🤖 Generated with Claude Code

## 👨‍💻 Felix Brandt — review follow-up Pushed 5 commits addressing the open concerns plus a visual defect Marcel caught during testing. ### Defect fixed (Marcel, screenshot) - **Connectors bled through dimmed nodes.** Group `opacity` on the node `<g>` made the whole node — including its white card fill — translucent, so the connector lines drawn beneath a dimmed box showed through it. Now the **card fill renders at full strength outside the dim group**, and the lineage focus+dim sits on an inner content group (outline + labels) only. Same opacity-based lightness cue (theme-safe, survives greyscale — Leonie's a11y rationale still holds), but the box is never see-through. — `9c12f623` - Bonus: the focus ring also moved out of the dim group, so a **dimmed-but-keyboard-focused node keeps a full-strength ring** — closes @Leonie's minor focus-ring note. ### Reviewer concerns | Concern | Source | Resolution | Commit | | --- | --- | --- | --- | | AC5 connector dimming unverified at the render layer | @Sara #1 / @Elicit | Render-layer test: no connector group carries a dim opacity unselected; selecting Vater dims **exactly** the vertical feeding the collateral child Tante (exercises the shared parent-pair `<g opacity>` wiring) | `da306715` | | AC8 mobile-centre wiring untested | @Sara #2 / @Elicit | Route-level tests mocking `window.matchMedia`: a tap recentres the canvas when the mobile breakpoint matches, and leaves the view untouched on desktop | `81224829` | | `DIMMED_OPACITY` hard-coded `'0.4'` in assertions | @Felix #3 | Imported the constant into the test; assertions now track it | `10249c33` | | 0.4 legibility risk (esp. dark mode) | @Leonie | Bumped `DIMMED_OPACITY` 0.4 → **0.45** | `10249c33` | | Index-alignment smell (`childIds[i]` vs filtered `childCenters`) | @Felix #1 | Child id now rides on the mapped `{id, x, y}` centre, so id and centre can never desync — a positionless child drops out of both together | `7cc2ddc6` | ### Deferred (with reason) - **Duplicated `lineage-fade` style block** (@Felix #2) — left as-is per KISS (6 lines × 2; I'd flagged it as acceptable). Happy to extract to a global utility if preferred. ### Verification - `npm run check` — **no new errors in touched files** (the remaining `page.svelte.test.ts` errors are pre-existing `sampleNodes` baseline debt). - `npm run build` — green. - Pure node-project suites (`highlightLineage`, `panZoom`) — 46 green locally. The new `*.svelte.test.ts` cases (node opacity, AC5 connectors, AC8 wiring) run **CI-only** per project convention — watch this PR's CI job. ⚠️ One manual eye-check still recommended before merge (@Leonie): confirm the 0.45 dim reads as *clearly de-emphasised but legible* at 320px in **both** themes against `bg-surface`. The dim now lives on the outline + labels over a solid box, so it should read cleaner than the old translucent box — but it's a single constant if it needs nudging. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 1 commit 2026-05-31 19:44:38 +02:00
test(stammbaum): assert AC8 recentre via viewBox, not replaceState (#703)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m21s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m34s
CI / fail2ban Regex (pull_request) Successful in 42s
CI / Semgrep Security Scan (pull_request) Successful in 19s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m2s
CI / Unit & Component Tests (push) Successful in 3m23s
CI / OCR Service Tests (push) Successful in 21s
CI / Backend Unit Tests (push) Successful in 3m44s
CI / fail2ban Regex (push) Successful in 45s
CI / Semgrep Security Scan (push) Successful in 20s
CI / Compose Bucket Idempotency (push) Successful in 1m0s
4ebebe1e07
The desktop AC8 test flaked in CI: it asserted replaceState was never
called after a tap, but the mount-time URL mirror fired late with the
unchanged default view (cx=0&cy=0&z=1), tripping the assertion. Assert on
the rendered viewBox instead — a pure function of the view state — so a
recentre shows as a shifted origin and a desktop tap leaves it identical,
with no dependence on the noisy mirror-effect timing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
marcel merged commit 4ebebe1e07 into main 2026-05-31 20:11:07 +02:00
marcel deleted branch feat/issue-703-stammbaum-lineage-highlight 2026-05-31 20:11:07 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#704