fix(stammbaum): address re-review nits — opaque rail, stale docs, rail clarity (#692)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m35s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m38s
CI / fail2ban Regex (pull_request) Successful in 41s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s

- Rail chip background opaque (was /85) so G{n} labels stay AA-legible over
  tree content (Leonie).
- Rail effect: replace the reactKey hack with an inputsFinite guard that both
  tracks deps and guards NaN; name the fallback-stack magics; correct the stale
  'xMidYMid' comment (the CTM mapping is preserveAspectRatio-agnostic) (Felix/Markus).
- GLOSSARY zoom range 0.25–3.0 → 0.25–10; ADR-027 preserveAspectRatio note
  xMidYMid → xMinYMin (Elicit traceability).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-29 20:21:13 +02:00
parent 11dc25ef31
commit 95d35c20b2
3 changed files with 28 additions and 14 deletions

View File

@@ -58,6 +58,7 @@ _See also [Annotation](#annotation-documentannotation)._
**DocumentStatus lifecycle** — the ordered states a `Document` moves through:
`PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
- `PLACEHOLDER`: created during mass import; no file attached yet.
- `UPLOADED`: a file has been stored in MinIO/S3.
- `TRANSCRIBED`: all transcription blocks have been marked done.
@@ -119,13 +120,13 @@ _Not to be confused with [parented](#parented-layout)_ — loose is the absence
**anchor index** — within a sibling block, the average position of `parented` member indices. The block is shifted horizontally so this index, multiplied by `NODE_W + COL_GAP`, lines up under the midpoint of the block's parents — keeping every parent-child connector orthogonal (90°).
**intra-family marriage** — a `SPOUSE_OF` edge where both endpoints are parented members of *different* sibling blocks at the same rank (i.e. both have parents in the graph, but the parent sets differ). Layout merges the two blocks so the spouses sit adjacent at the join boundary; latent in current data (0 cases in the May-2026 canonical snapshot) but covered by a synthetic regression test in `buildLayout.test.ts`.
**intra-family marriage** — a `SPOUSE_OF` edge where both endpoints are parented members of _different_ sibling blocks at the same rank (i.e. both have parents in the graph, but the parent sets differ). Layout merges the two blocks so the spouses sit adjacent at the join boundary; latent in current data (0 cases in the May-2026 canonical snapshot) but covered by a synthetic regression test in `buildLayout.test.ts`.
**marriage dot** — the SVG circle drawn at the midpoint of a `SPOUSE_OF` connector in the Stammbaum tree (`StammbaumTree.svelte`). Radius is `r=6` (12 px diameter) so the marker meets WCAG 1.4.11 (3:1 non-text contrast) when it stacks to disambiguate multiple marriages on the same focal person.
**canonical fixture** (Stammbaum) — `frontend/src/lib/person/genealogy/__fixtures__/stammbaum.json`, a pinned `/api/network` snapshot used by `buildLayout.test.ts` for structural-property assertions against real data. Captured locally via `frontend/scripts/capture-network-fixture.mjs` with explicit credentials and a localhost backend; never invoked from CI. Sanity-gated by `validateFixture.ts` (≥ 50 nodes / ≥ 5 generations / ≥ 1 SPOUSE_OF edge / ≥ 1 multi-spouse person).
**pan/zoom view state** `[#692]` — the `{ x, y, z }` triple (`PanZoomState` in `frontend/src/lib/person/genealogy/panZoom.ts`) describing the Stammbaum canvas position: `x`/`y` are pan offsets applied to the SVG viewBox centre, `z` is the zoom factor (clamped 0.253.0). Mirrored into the shareable URL as `?cx&cy&z` and seeded server-side from those params. See [ADR-027](adr/027-stammbaum-custom-viewbox-pan-zoom.md).
**pan/zoom view state** `[#692]` — the `{ x, y, z }` triple (`PanZoomState` in `frontend/src/lib/person/genealogy/panZoom.ts`) describing the Stammbaum canvas position: `x`/`y` are pan offsets applied to the SVG viewBox centre, `z` is the zoom factor (clamped 0.2510). Mirrored into the shareable URL as `?cx&cy&z` and seeded server-side from those params. See [ADR-027](adr/027-stammbaum-custom-viewbox-pan-zoom.md).
**fit-to-screen** `[user-facing, #692]` — the Stammbaum control (`⤢`) and initial state that frames the whole tree in the viewport. Because the base viewBox already encloses the layout at `z=1`, fit-to-screen is simply the default view `{x:0, y:0, z:1}`.

View File

@@ -16,7 +16,8 @@ library** (timmywil v4.x) on the team's recommendation, pinned per NFR-MAINT-001
That recommendation predated a load-bearing implementation detail: `StammbaumTree.svelte`
already renders zoom by **deriving the SVG `viewBox`** (`w = baseW / z`, centred on the
layout bounding box, `preserveAspectRatio="xMidYMid meet"`) — not by applying a CSS
layout bounding box, `preserveAspectRatio="xMinYMin meet"` so a fresh visit anchors to the
tree's top-left corner) — not by applying a CSS
`transform`. The `panzoom` library operates by writing `transform` to a DOM node. Adopting
it would mean:

View File

@@ -22,21 +22,33 @@ type Chip = { rank: number; label: number; top: number; visible: boolean };
let chips = $state<Chip[]>([]);
let height = $state(0);
// Map each generation-row centre from SVG user space to a screen-y via the
// live CTM (exact under preserveAspectRatio="xMidYMid meet" — no manual
// letterbox math). Runs in an effect so it reads the CTM after the viewBox DOM
// update is flushed. `panZoom` and `height` are the recompute triggers.
// Fallback stacked positions when no screen transform is available (e.g. an
// unsized test container): list the chips top-down rather than hiding them.
const FALLBACK_TOP = 24;
const FALLBACK_GAP = 28;
// Off-screen cull margin (px) so a chip just past an edge isn't popped abruptly.
const CULL_MARGIN = 16;
// Map each generation-row centre from SVG user space to a screen-y via the live
// CTM. This reads whatever transform the browser actually computed, so it is
// independent of `preserveAspectRatio` (no manual letterbox math). Runs in an
// effect — not a $derived — so the CTM is read AFTER the viewBox DOM update is
// flushed. NB: this reads layout (getScreenCTM + getBoundingClientRect) once per
// pan/zoom/inertia frame; fine for a handful of chips, revisit if rows grow large.
$effect(() => {
// reactKey makes the effect re-run on pan, zoom and resize; the CTM reflects
// the parent's viewBox, which Svelte cannot track on its own.
const reactKey = panZoom.x + panZoom.y + panZoom.z + height;
const ctm = svg && Number.isFinite(reactKey) ? svg.getScreenCTM() : null;
// Reading pan/zoom + height registers them as effect dependencies (so it
// re-runs on pan, zoom and resize — the CTM reflects the parent's viewBox,
// which Svelte cannot track on its own) and doubles as a NaN guard.
const inputsFinite = Number.isFinite(panZoom.x + panZoom.y + panZoom.z + height);
const ctm = svg && inputsFinite ? svg.getScreenCTM() : null;
const top0 = svg ? svg.getBoundingClientRect().top : 0;
// Always emit one chip per labelled row so the labels exist regardless of
// transform availability; the CTM only positions them (fallback: stacked).
chips = rows.map((row, i) => {
const top = ctm ? new DOMPoint(0, row.centerY).matrixTransform(ctm).y - top0 : 24 + i * 28;
const visible = !ctm || height <= 0 || (top >= -16 && top <= height + 16);
const top = ctm
? new DOMPoint(0, row.centerY).matrixTransform(ctm).y - top0
: FALLBACK_TOP + i * FALLBACK_GAP;
const visible = !ctm || height <= 0 || (top >= -CULL_MARGIN && top <= height + CULL_MARGIN);
return { rank: row.rank, label: row.label, top, visible };
});
});
@@ -50,7 +62,7 @@ $effect(() => {
<div
role="text"
aria-label={`Generation ${chip.label}`}
class="absolute left-1 -translate-y-1/2 rounded-sm border border-line bg-surface/85 px-1.5 py-0.5 font-serif text-xs text-ink-3 shadow-sm"
class="absolute left-1 -translate-y-1/2 rounded-sm border border-line bg-surface px-1.5 py-0.5 font-serif text-xs text-ink-3 shadow-sm"
style="top: {chip.top}px"
>
G{chip.label}