Highlight the selected person's bloodline on the family tree #703

Closed
opened 2026-05-31 15:56:56 +02:00 by marcel · 7 comments
Owner

Context

On the family tree (Stammbaum) page, clicking a person opens a side panel. Today the rest of the tree stays at full visual weight, so a reader cannot easily trace a single line of heritage — the whole tree competes for attention.

This adds a focus + dim (lineage highlighting) layer tied to the existing side panel: while a person is selected, their bloodline stays at full strength and everyone else is dimmed.

This is a read-path enhancement. Readers skew toward phones, so the touch path matters.

User story

As a family-tree reader, I want the bloodline of the person I've selected highlighted while everyone else fades back, so that I can trace one line of heritage without the rest of the tree competing for attention.

Priority: Should · Kano: Delighter

Scope decisions (resolved)

Topic Decision
Highlighted set The clicked person's full pedigree upward (both parents, all grandparents, …) + their full descendant tree downward.
Collaterals Excluded — no siblings, cousins, aunts/uncles unless they are themselves a direct ancestor or descendant. SIBLING_OF edges never pull a person into the active set.
Spouses All spouses of any highlighted blood person are active — including the clicked person's own spouse and any remarriages (multiple spouses all active).
Spouse boundary Stop at the married-in spouse. The in-law is an active leaf; we do not climb into that spouse's own parents/ancestors — they stay dimmed.
Anchor node Keep the existing StammbaumNode selected-node styling (active fill + mint accent bar). That accent bar is the cue that marks which person anchors the highlight within an all-active lineage. No additional emphasis.
Trigger Highlight is bound to the side panel: panel open → tree dims; panel closed → full-strength tree. This includes initial load via ?focus={id} — the tree must render already dimmed, with no click event.
Dimming mechanism Opacity, not a hue swap. Active nodes/connectors render at full --c-primary; dimmed people and connectors render the same tokens at reduced opacity (≈0.4 / opacity-40). This is theme-correct in both light and dark mode (the tokens are theme-aware: --c-primary is navy in light mode, mint in dark mode) and provides a non-colour lightness cue.
Connectors A connector is active iff both people it joins are active; otherwise it is dimmed at the same opacity as dimmed nodes. (This fences off the in-law family — the edge from an active spouse to their dimmed parent is dimmed.)

Acceptance criteria

  1. Given the family tree with no side panel open, when the page loads, then every node and connector renders at full strength (no dimming).
  2. Given the tree, when I click a person and their side panel opens, then that person, all of their direct ancestors, all of their direct descendants, and the spouses of every one of those people render at full strength, and every other person renders dimmed (reduced opacity).
  3. Given a highlighted line, then a married-in spouse is active but that spouse's own parents and ancestors remain dimmed.
  4. Given a blood ancestor or descendant who had more than one spouse, then all of their spouses render active.
  5. Given a highlighted line, then a connector renders active only when both people it joins are active; otherwise it is dimmed.
  6. Given an open side panel, when I click a different person, then the highlight recomputes for the newly selected person and the previous highlight clears.
  7. Given an open side panel, when I close it (by any available means), then the entire tree returns to full strength.
  8. Given a touch device, when I tap a person, then behaviour matches the click case above (highlight follows the panel), and the selected person is centred so the anchor is not hidden behind the bottom sheet.
  9. Given a direct link with ?focus={id} (panel open from the start), when the page loads, then the tree renders already dimmed for that person's lineage — no click required.
  10. Given relationship data that contains a cyclic PARENT_OF chain, when the lineage is computed, then the walk terminates (no hang) and the cycle members resolve to a defined active/dimmed state.

System rules (EARS)

  • REQ-STAMMBAUM-01: While a person's side panel is open, the system shall render that person, their ancestors, their descendants, and all spouses of those people at full strength, and all other people dimmed.
  • REQ-STAMMBAUM-02: While a side panel is open, the system shall render a connector at full strength only if both connected people are in the active set.
  • REQ-STAMMBAUM-03: When no side panel is open, the system shall render all people and connectors at full strength.
  • REQ-STAMMBAUM-04: While computing the lineage, the system shall guard the ancestor/descendant walk with a visited set so that cyclic relationship data cannot cause unbounded recursion.

Non-functional requirements

  • NFR-A11Y-001: Active vs. dimmed people shall be distinguishable without relying on hue — satisfied by the opacity (lightness) difference, which holds in greyscale and in both themes (WCAG 1.4.1). Dimmed text must remain present (not erased); ≈0.4 opacity keeps names legible while clearly de-emphasised.
  • NFR-PERF-001: The lineage walk is an in-memory O(V+E) traversal over the already-loaded graph; no explicit latency assertion is required. A single sanity test over a large synthetic fixture is sufficient if a guard is wanted.

Implementation notes (from review)

  • Pure module: put the traversal in frontend/src/lib/person/genealogy/highlightLineage.ts beside buildLayout.ts, depending only on RelationshipDTO[] + a root id. It returns the active id Set<string> and the connector predicate. StammbaumNode / StammbaumConnectors stay presentation-only and receive plain active/dimmed props — no traversal logic in components.
  • TDD: write the failing highlightLineage.test.ts first, fixtures modelled on buildLayout.test.ts. Cover: isolated person, ancestors-only, descendants-only, pedigree fan-out (both parents + all four grandparents), multiple spouses/remarriage, spouse boundary (in-law active, in-law's parents dimmed), SIBLING_OF excluded, connector both-endpoints rule (incl. spouse→in-law-parent dimmed), cyclic-data termination, and initial-load-with-?focus.
  • Reactivity: derive the active set with $derived.by(...) from selectedId + data.edges; never $effect. Build the adjacency index (parent→children, child→parents, spouse pairs) once in a $derived keyed on data.edges.
  • Motion: if the dim transitions in, gate it behind prefers-reduced-motion (reuse the existing animateView pattern).
  • Component tests (vitest-browser-svelte): assert active vs dimmed rendering via class/attribute, not screenshots; verify re-select recomputes (AC6) and close clears (AC7). E2E not required (browser tests are CI-only).
  • Docs: add a one-line docs/GLOSSARY.md entry for "lineage highlight". No ADR, no PUML, no generate:api (view-only, no backend change).

Out of scope

  • Collateral-relative highlighting (siblings/cousins).
  • Climbing into a married-in spouse's separate bloodline.
  • Any change to the side panel's own content or open/close mechanics.
  • Any backend / endpoint / schema change — /api/network already delivers the full family graph client-side.
## Context On the family tree (Stammbaum) page, clicking a person opens a side panel. Today the rest of the tree stays at full visual weight, so a reader cannot easily trace a single line of heritage — the whole tree competes for attention. This adds a **focus + dim** (lineage highlighting) layer tied to the existing side panel: while a person is selected, their bloodline stays at full strength and everyone else is dimmed. This is a read-path enhancement. Readers skew toward phones, so the touch path matters. ## User story > **As a** family-tree reader, **I want** the bloodline of the person I've selected highlighted while everyone else fades back, **so that** I can trace one line of heritage without the rest of the tree competing for attention. Priority: **Should** · Kano: **Delighter** ## Scope decisions (resolved) | Topic | Decision | | --- | --- | | Highlighted set | The clicked person's **full pedigree upward** (both parents, all grandparents, …) **+** their **full descendant tree downward**. | | Collaterals | **Excluded** — no siblings, cousins, aunts/uncles unless they are themselves a direct ancestor or descendant. `SIBLING_OF` edges never pull a person into the active set. | | Spouses | **All** spouses of any highlighted blood person are active — including the clicked person's own spouse and any remarriages (multiple spouses all active). | | Spouse boundary | **Stop at the married-in spouse.** The in-law is an active leaf; we do **not** climb into that spouse's own parents/ancestors — they stay dimmed. | | Anchor node | **Keep** the existing `StammbaumNode` selected-node styling (active fill + mint accent bar). That accent bar is the cue that marks *which* person anchors the highlight within an all-active lineage. No additional emphasis. | | Trigger | Highlight is bound to the side panel: panel open → tree dims; panel closed → full-strength tree. This includes initial load via `?focus={id}` — the tree must render already dimmed, with no click event. | | Dimming mechanism | **Opacity, not a hue swap.** Active nodes/connectors render at full `--c-primary`; dimmed people and connectors render the *same* tokens at reduced opacity (≈0.4 / `opacity-40`). This is theme-correct in both light and dark mode (the tokens are theme-aware: `--c-primary` is navy in light mode, mint in dark mode) and provides a non-colour lightness cue. | | Connectors | A connector is active **iff both** people it joins are active; otherwise it is dimmed at the same opacity as dimmed nodes. (This fences off the in-law family — the edge from an active spouse to their dimmed parent is dimmed.) | ## Acceptance criteria 1. **Given** the family tree with no side panel open, **when** the page loads, **then** every node and connector renders at full strength (no dimming). 2. **Given** the tree, **when** I click a person and their side panel opens, **then** that person, all of their direct ancestors, all of their direct descendants, and the spouses of every one of those people render at full strength, and every other person renders dimmed (reduced opacity). 3. **Given** a highlighted line, **then** a married-in spouse is active **but** that spouse's own parents and ancestors remain **dimmed**. 4. **Given** a blood ancestor or descendant who had **more than one spouse**, **then** **all** of their spouses render active. 5. **Given** a highlighted line, **then** a connector renders active **only when both** people it joins are active; otherwise it is dimmed. 6. **Given** an open side panel, **when** I click a **different** person, **then** the highlight recomputes for the newly selected person and the previous highlight clears. 7. **Given** an open side panel, **when** I close it (by any available means), **then** the entire tree returns to full strength. 8. **Given** a touch device, **when** I tap a person, **then** behaviour matches the click case above (highlight follows the panel), and the selected person is centred so the anchor is not hidden behind the bottom sheet. 9. **Given** a direct link with `?focus={id}` (panel open from the start), **when** the page loads, **then** the tree renders **already dimmed** for that person's lineage — no click required. 10. **Given** relationship data that contains a cyclic `PARENT_OF` chain, **when** the lineage is computed, **then** the walk terminates (no hang) and the cycle members resolve to a defined active/dimmed state. ## System rules (EARS) - **REQ-STAMMBAUM-01:** While a person's side panel is open, the system shall render that person, their ancestors, their descendants, and all spouses of those people at full strength, and all other people dimmed. - **REQ-STAMMBAUM-02:** While a side panel is open, the system shall render a connector at full strength only if both connected people are in the active set. - **REQ-STAMMBAUM-03:** When no side panel is open, the system shall render all people and connectors at full strength. - **REQ-STAMMBAUM-04:** While computing the lineage, the system shall guard the ancestor/descendant walk with a visited set so that cyclic relationship data cannot cause unbounded recursion. ## Non-functional requirements - **NFR-A11Y-001:** Active vs. dimmed people shall be distinguishable without relying on hue — satisfied by the opacity (lightness) difference, which holds in greyscale and in both themes (WCAG 1.4.1). Dimmed text must remain present (not erased); ≈0.4 opacity keeps names legible while clearly de-emphasised. - **NFR-PERF-001:** The lineage walk is an in-memory O(V+E) traversal over the already-loaded graph; no explicit latency assertion is required. A single sanity test over a large synthetic fixture is sufficient if a guard is wanted. ## Implementation notes (from review) - **Pure module:** put the traversal in `frontend/src/lib/person/genealogy/highlightLineage.ts` beside `buildLayout.ts`, depending only on `RelationshipDTO[]` + a root id. It returns the active id `Set<string>` and the connector predicate. `StammbaumNode` / `StammbaumConnectors` stay presentation-only and receive plain `active`/`dimmed` props — no traversal logic in components. - **TDD:** write the failing `highlightLineage.test.ts` first, fixtures modelled on `buildLayout.test.ts`. Cover: isolated person, ancestors-only, descendants-only, pedigree fan-out (both parents + all four grandparents), multiple spouses/remarriage, spouse boundary (in-law active, in-law's parents dimmed), `SIBLING_OF` excluded, connector both-endpoints rule (incl. spouse→in-law-parent dimmed), cyclic-data termination, and initial-load-with-`?focus`. - **Reactivity:** derive the active set with `$derived.by(...)` from `selectedId` + `data.edges`; never `$effect`. Build the adjacency index (parent→children, child→parents, spouse pairs) once in a `$derived` keyed on `data.edges`. - **Motion:** if the dim transitions in, gate it behind `prefers-reduced-motion` (reuse the existing `animateView` pattern). - **Component tests** (`vitest-browser-svelte`): assert active vs dimmed rendering via class/attribute, not screenshots; verify re-select recomputes (AC6) and close clears (AC7). E2E not required (browser tests are CI-only). - **Docs:** add a one-line `docs/GLOSSARY.md` entry for "lineage highlight". No ADR, no PUML, no `generate:api` (view-only, no backend change). ## Out of scope - Collateral-relative highlighting (siblings/cousins). - Climbing into a married-in spouse's separate bloodline. - Any change to the side panel's own content or open/close mechanics. - Any backend / endpoint / schema change — `/api/network` already delivers the full family graph client-side.
marcel added the P2-mediumfeatureui labels 2026-05-31 15:57:03 +02:00
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer (re-review)

The implementation notes now capture everything I'd have asked for — pure highlightLineage.ts beside buildLayout.ts, $derived over $effect, visited-set guard, presentation-only components, TDD matrix. Clear to build.

One concrete note

  • Apply the dim at the SVG group level, not per-element: wrap dimmed nodes/connectors in (or set on) a <g opacity="0.4"> so the rect, stroke, and text fade together as one unit. Per-attribute opacity on each <rect>/<text> is more code and risks the label and box drifting out of sync. The active set then just decides which group an element lands in.
  • Drive the lineage from selectedId (the raw rune), not the selectedNode derived — selectedNode is a .find() lookup and the walk only needs the id.

Open Decisions (none)

## 👨‍💻 Felix Brandt — Senior Fullstack Developer _(re-review)_ The implementation notes now capture everything I'd have asked for — pure `highlightLineage.ts` beside `buildLayout.ts`, `$derived` over `$effect`, visited-set guard, presentation-only components, TDD matrix. Clear to build. ### One concrete note - Apply the dim at the **SVG group level**, not per-element: wrap dimmed nodes/connectors in (or set on) a `<g opacity="0.4">` so the rect, stroke, *and* text fade together as one unit. Per-attribute opacity on each `<rect>`/`<text>` is more code and risks the label and box drifting out of sync. The active set then just decides which group an element lands in. - Drive the lineage from `selectedId` (the raw rune), not the `selectedNode` derived — `selectedNode` is a `.find()` lookup and the walk only needs the id. ### Open Decisions _(none)_
Author
Owner

🏛️ Markus Keller — Application Architect (re-review)

Addressed. The body now states it plainly: no backend/endpoint/schema change, one pure module at the same boundary as buildLayout, adjacency index built once, and the only doc touch is a GLOSSARY line. Altitude and boundaries are correct.

No concerns and no open decisions from my angle.

## 🏛️ Markus Keller — Application Architect _(re-review)_ Addressed. The body now states it plainly: no backend/endpoint/schema change, one pure module at the same boundary as `buildLayout`, adjacency index built once, and the only doc touch is a GLOSSARY line. Altitude and boundaries are correct. No concerns and no open decisions from my angle.
Author
Owner

🔧 Tobias Wendt — DevOps & Platform Engineer (re-review)

No concerns. Still a client-side-only change — no infrastructure, config, secret, or CI surface. Nothing for me to operate.

## 🔧 Tobias Wendt — DevOps & Platform Engineer _(re-review)_ No concerns. Still a client-side-only change — no infrastructure, config, secret, or CI surface. Nothing for me to operate.
Author
Owner

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

My one finding is now codified: REQ-STAMMBAUM-04 + AC10 mandate the visited-set guard against cyclic PARENT_OF data (CWE-835), with a test for it. No new attack surface — client-side transform over already-authorized data, no input or DOM sink.

No concerns and no open decisions.

## 🛡️ Nora Steiner ("NullX") — Application Security Engineer _(re-review)_ My one finding is now codified: REQ-STAMMBAUM-04 + AC10 mandate the visited-set guard against cyclic `PARENT_OF` data (CWE-835), with a test for it. No new attack surface — client-side transform over already-authorized data, no input or DOM sink. No concerns and no open decisions.
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist (re-review)

The test matrix, the ?focus initial-load case (AC9), and the cyclic-termination case (AC10) are all in the body now, at the right layer. Good.

One tightening

  • AC10 currently says cycle members "resolve to a defined active/dimmed state" — pin that to an exact expected membership in the test, not just "doesn't hang". A termination-only assertion passes even if the result is wrong. Decide the deterministic outcome (e.g. a person reachable as their own ancestor is still active because they're in the lineage set; the walk just stops re-visiting) and assert the precise set.

Open Decisions (none)

## 🧪 Sara Holt — QA Engineer & Test Strategist _(re-review)_ The test matrix, the `?focus` initial-load case (AC9), and the cyclic-termination case (AC10) are all in the body now, at the right layer. Good. ### One tightening - AC10 currently says cycle members "resolve to a defined active/dimmed state" — pin that to an *exact* expected membership in the test, not just "doesn't hang". A termination-only assertion passes even if the result is wrong. Decide the deterministic outcome (e.g. a person reachable as their own ancestor is still active because they're in the lineage set; the walk just stops re-visiting) and assert the precise set. ### Open Decisions _(none)_
Author
Owner

🎨 Leonie Voss — UI/UX & Accessibility Lead (re-review)

The opacity-dim decision, kept accent bar, reduced-motion gating, and the mobile-centring behaviour (AC8) are all captured correctly, and NFR-A11Y-001 now reflects the lightness-cue rationale. This is theme-safe and accessible.

One implementation guard

  • At ~0.4 opacity the dimmed group should stay perceivable, not vanish — verify dimmed names remain readable against bg-surface in both light and dark mode (dark mode dims mint, which is already light). If 0.4 reads too faint in either theme, 0.45–0.5 is fine; the requirement is "clearly de-emphasised but still legible," not invisible. Confirm visually at 320px during build.

Open Decisions (none)

## 🎨 Leonie Voss — UI/UX & Accessibility Lead _(re-review)_ The opacity-dim decision, kept accent bar, reduced-motion gating, and the mobile-centring behaviour (AC8) are all captured correctly, and NFR-A11Y-001 now reflects the lightness-cue rationale. This is theme-safe and accessible. ### One implementation guard - At ~0.4 opacity the dimmed group should stay *perceivable*, not vanish — verify dimmed names remain readable against `bg-surface` in **both** light and dark mode (dark mode dims mint, which is already light). If 0.4 reads too faint in either theme, 0.45–0.5 is fine; the requirement is "clearly de-emphasised but still legible," not invisible. Confirm visually at 320px during build. ### Open Decisions _(none)_
Author
Owner

👨‍💻 Implemented — PR #704

Built on feat/issue-703-stammbaum-lineage-highlight (off latest main), TDD throughout. PR: #704.

Commits

  • 7a655ce6 feat(stammbaum): add lineage highlight traversal module — pure highlightLineage.ts beside buildLayout.ts
  • f6da9501 feat(stammbaum): dim a node when outside the highlighted lineage — StammbaumNode dimmed prop, group-level opacity
  • 9f5d7b85 feat(stammbaum): dim connectors outside the highlighted lineage — isConnectorActive predicate, per-connector <g opacity>
  • a3858b6c feat(stammbaum): bind the lineage highlight to the selected person — StammbaumTree $derived wiring + AC1/2/6/7 tests
  • 0a7b4fa2 feat(stammbaum): add recentreAbove pan helper for the mobile anchor
  • 4583ee2c feat(stammbaum): centre the tapped person above the bottom sheet — mobile auto-centre (AC8)
  • e5784caa docs(glossary): define "lineage highlight"

Decisions taken (clarified with the maintainer)

  • Module location: placed at frontend/src/lib/person/genealogy/layout/highlightLineage.ts — truly beside buildLayout.ts (the issue's literal path predated the layout/ reorg).
  • AC8: implemented auto-centre on mobile tap with a sheet-aware offset (anchor lifted into the ~40dvh band above the bottom sheet via the new recentreAbove helper), rather than a plain geometric centre.

Acceptance criteria

  • AC1–AC7, AC9, AC10 — covered by automated tests (highlightLineage.test.ts for the traversal incl. cyclic-termination exact-membership; StammbaumTree.svelte.test.ts for AC1/2/6/7; AC9 falls out of the selectedId-seeded $derived).
  • AC8recentreAbove geometry unit-tested + mobile wiring; the touch interaction itself verifies in CI / manually.

Notes for review

  • Browser component tests (*.svelte.test.ts) run in CI per project convention.
  • ⚠️ Recommend a quick 320px light + dark legibility pass before merge (Leonie's guard): if DIMMED_OPACITY = 0.4 reads too faint in either theme, bump to 0.45–0.5 — it's a single constant in highlightLineage.ts.

npm run build · node-project tests (57 passed) · npm run check — no new errors in touched files.

Next: /review-pr on #704.

## 👨‍💻 Implemented — PR #704 Built on `feat/issue-703-stammbaum-lineage-highlight` (off latest `main`), TDD throughout. PR: #704. ### Commits - `7a655ce6` feat(stammbaum): add lineage highlight traversal module — pure `highlightLineage.ts` beside `buildLayout.ts` - `f6da9501` feat(stammbaum): dim a node when outside the highlighted lineage — `StammbaumNode` `dimmed` prop, group-level opacity - `9f5d7b85` feat(stammbaum): dim connectors outside the highlighted lineage — `isConnectorActive` predicate, per-connector `<g opacity>` - `a3858b6c` feat(stammbaum): bind the lineage highlight to the selected person — `StammbaumTree` `$derived` wiring + AC1/2/6/7 tests - `0a7b4fa2` feat(stammbaum): add recentreAbove pan helper for the mobile anchor - `4583ee2c` feat(stammbaum): centre the tapped person above the bottom sheet — mobile auto-centre (AC8) - `e5784caa` docs(glossary): define "lineage highlight" ### Decisions taken (clarified with the maintainer) - **Module location:** placed at `frontend/src/lib/person/genealogy/layout/highlightLineage.ts` — truly beside `buildLayout.ts` (the issue's literal path predated the `layout/` reorg). - **AC8:** implemented auto-centre on mobile tap with a sheet-aware offset (anchor lifted into the ~40dvh band above the bottom sheet via the new `recentreAbove` helper), rather than a plain geometric centre. ### Acceptance criteria - **AC1–AC7, AC9, AC10** — covered by automated tests (`highlightLineage.test.ts` for the traversal incl. cyclic-termination exact-membership; `StammbaumTree.svelte.test.ts` for AC1/2/6/7; AC9 falls out of the `selectedId`-seeded `$derived`). - **AC8** — `recentreAbove` geometry unit-tested + mobile wiring; the touch interaction itself verifies in CI / manually. ### Notes for review - Browser component tests (`*.svelte.test.ts`) run in CI per project convention. - ⚠️ Recommend a quick **320px light + dark** legibility pass before merge (Leonie's guard): if `DIMMED_OPACITY = 0.4` reads too faint in either theme, bump to 0.45–0.5 — it's a single constant in `highlightLineage.ts`. `npm run build` ✅ · node-project tests ✅ (57 passed) · `npm run check` — no new errors in touched files. Next: `/review-pr` on #704.
Sign in to join this conversation.
No Label P2-medium feature ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#703