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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Introduces `csrfFetch` (= `makeCsrfFetch(fetch)`) in cookies.ts as a
drop-in fetch replacement that auto-injects X-XSRF-TOKEN on POST/PUT/PATCH/DELETE.
Previously 8 call sites sent mutating requests without the CSRF header —
annotation resize, comment POST/PATCH/DELETE, Geschichte CRUD, Stammbaum
relationship creation, bulk-edit PATCH, and file upload — all would fail
with CSRF_TOKEN_MISSING if the backend's cookie-based protection triggered.
All 14 client-side mutating fetches now use csrfFetch; withCsrf/makeCsrfFetch
remain in the API for injectable-fetch use cases (e.g. useTranscriptionBlocks).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extract the three SVG connector layers (+ the parent-link graph computation)
into StammbaumConnectors.svelte and the node <g> into StammbaumNode.svelte (which
now owns its own focus-ring state). StammbaumTree drops 546→308 lines and is now
an orchestrator: layout, gutter/reduced-motion state, viewBox, gestures, rail,
anchor. Rendered SVG is byte-identical, so the existing browser tests are
unchanged. Verified live: 62 nodes + 58 connector lines render, node-tap selects.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- page.svelte.test.ts mocked $app/navigation with only replaceState, dropping
invalidateAll (imported by StammbaumSidePanel) → the module errored and failed
all 7 tests in the file. Mock now exports invalidateAll + goto too.
- StammbaumTree viewBox 'offsets origin' test hard-coded a wrong unpanned-x; assert
the robust relationship instead (viewBox centre − content centroid == pan).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- 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>
The frame-corner anchor + xMidYMid letterboxing left ~290px of empty space
above the first row on desktop. Anchor to the content corner (first row /
leftmost node, small margin) via cornerView, and switch the canvas to
xMinYMin meet so a wide/short tree pins to the top-left instead of centring
vertically. Verified live: gap above row 1 is now ~20px.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
At z=3 a pan of {0,0} centres on the tree midpoint; a fresh visit (no shared
?z) now anchors the viewBox to the tree's top-left corner via topLeftView
(the negative clamp limit), emitted on mount. Shared links still win.
Verified live: lands at cx<0, cy<0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a deterministic stubbed-rAF test for animateView's animated path (was only
covering the reduced-motion branch) and assert the server load redirects on 401
and throws on a network 500 (QA review).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Enlarge the centre-on-person, panel-close, and affordance-dismiss icon buttons
to 44x44 hit areas (WCAG 2.5.8, UX review) while keeping the small glyphs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Zoom is normalised to the whole tree, so z=3 still renders a wide tree too
small on a phone. Raise the ceiling to 10 (revises OQ-001); SVG stays crisp at
any zoom so a generous max is harmless.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Capturing the pointer on pointerdown made the browser dispatch the trailing
click at the SVG instead of the node under the finger, so node taps silently
stopped opening the person panel. Capture only once a drag crosses the
threshold; a tap now reaches the node's onclick. Verified live.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Read the pan emission from the pointermove (deterministic) instead of the
post-pointerup last call, which inertia could perturb when reduced-motion is
not forced in vitest-browser (QA blocker).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the pinch-zoom (pinchZoom) and inertia-step (stepInertia) geometry out of
the panZoomGestures DOM glue into pure, unit-tested helpers in panZoom.ts, with
named FRAME_MS/INERTIA_* constants. Addresses the QA blocker that the gesture
module's core math was untested. No behaviour change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pan rounded to 2 decimals, zoom to 3, so ?cx/?cy/?z no longer carry float
noise like cx=457.8300882631206.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Generation labels are no longer drawn in-SVG (where they panned/zoomed off
screen and were desktop-only). A new StammbaumGenerationRail overlays the canvas
left edge, mapping each generation row's centre through the SVG's live
getScreenCTM so chips stay pinned horizontally and track their row vertically at
any pan/zoom — on phones too. The desktop stripe underlay stays (gated on the
gutter breakpoint); the #689 label tests are rewritten against the rail.
Verified live: labels stay at left=4px while the canvas pans.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A fresh visit (no URL state) now opens at INITIAL_VIEW (z=3) so node tiles and
generation labels are legible on arrival; the fit-to-screen control still zooms
out to the whole tree (DEFAULT_VIEW, z=1). Shared links with ?z still win.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The #361 layout ADR already owns 026; renumber the custom-viewBox pan/zoom ADR
to 027 and update the glossary + panZoom.ts references (Elicit review).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add StammbaumAffordance: a touch-only "drag to explore · pinch to zoom" hint
that auto-dismisses on the first canvas pointer interaction (wired via the
gesture action's onGestureStart) or the explicit close, and stays dismissed for
30 days via a localStorage timestamp (boolean gate only, never rendered).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an onCentre control to StammbaumSidePanel (title row, both desktop aside
and mobile sheet). The page drives a one-shot centreOnId so StammbaumTree
recentres the canvas on the focal node (US-PAN-005). Also tighten the panel
spec's deathYear fixture to a valid type.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wrap the mobile person panel in StammbaumBottomSheet: drag-handle grip with
swipe-down-to-dismiss (≥80px), full-screen backdrop button for tap-outside
dismiss, role=dialog + aria-label, focus trap, and Escape (NFR-A11Y-004).
Pan/zoom state is untouched by open/close (US-PANEL-001/002).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A view-keyed effect mirrors pan/zoom into the URL via replaceState (URL read
untracked to avoid a feedback loop). State survives panel open/close
(US-PANEL-002 AC1) and a shared link reproduces the view (AC2).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fit-to-screen tweens to the default view over 300ms via animateView (eased,
lerpView-driven) and snaps instantly when prefers-reduced-motion is set
(US-PAN-004 AC2, NFR-A11Y-003).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move zoom controls out of the page header into a docked bottom-right cluster
inside the canvas (one-handed phone reach, Leonie) and add a fit-to-screen
button (data-testid=fit-to-screen). Add the 5 new i18n keys to de/en/es.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Permanent 4-edge mask-image gradient cues off-screen content when the tree is
zoomed in; nothing fades at fit. Replaces the dropped US-PAN-006 AC3 idle cue.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a panZoomGestures action: one-finger/left-button drag pans, two-finger
pinch and Ctrl+wheel zoom around the centroid, plain wheel pans. Pan is
edge-clamped via clampPan (no infinite scroll), a real drag suppresses the
trailing node click, and inertia decays after release unless prefers-reduced-
motion. Canvas container switches from native scroll to overflow-hidden.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
+/- zoom by the fixed step and arrow keys pan by a tenth of the visible
extent, emitted via onPanZoom. Provides the keyboard-only alternative path
required by NFR-A11Y-002. Nodes keep their own Enter/Space selection.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the scalar zoom prop with a {x,y,z} PanZoomState. The viewBox centre
is offset by the pan and width/height scaled by zoom; the default {0,0,1}
frames the whole tree (fit-to-screen). Page header buttons now step view.z
through clampZoom over the resolved 0.25–3.0 range.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Degrade Infinity/NaN/overflow per axis and clamp zoom into bounds so a crafted
?cx/?cy/?z shared link cannot blank the SVG (Nora's review).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduces a local `type TestNode = { id: string; generation: number | null }`
so the three AC3 test fixtures can write `generation: null` directly,
without the awkward `as number | null` cast next to the literal `generation:
2`. Sara cycle-3 cosmetic; same predicate, cleaner reading.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Extracts the AC3 revisit-trigger predicate into a plain .mjs module both
the Node-run capture script and the TypeScript validator import directly.
Removes the line-for-line duplicate (and its "keep both in sync" comment)
that Felix + Markus flagged in cycle-3 review.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cycle-2 follow-up from Elicit. ADR-026 defers AC3 (unseeded loose
spouse with parents-in-graph) with the revisit trigger being "first
canonical fixture containing such a person". The trigger previously
relied on a human spotting the new shape during recapture, with no
automated nudge.
`findAc3Candidates(network)` is the testable predicate (5 unit tests
including the precondition that the *committed* canonical fixture has
zero candidates today — anchors the ADR-026 "0 rows" annotation
against the fixture). The capture script calls it after writing the
fixture and emits a loud non-blocking stderr warning if the count goes
non-zero. The warning is the revisit trigger Elicit asked for.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cycle-2 follow-up from Sara. The radius assertion proves the geometry
side of the WCAG 1.4.11 contract; the fill-token assertion proves the
colour side. Together they catch an accidental "neutralise the dot"
diff (e.g. swap to var(--c-ink-3) or a literal light token) before the
permanent axe-core gate ships in #692.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cycle-2 follow-up from Sara. The existing assertion
`Math.abs(posA2.x - posB2.x) === NODE_W + COL_GAP` proves adjacency in
the current integer-slot packer but would silently pass if a future
refactor moved to fractional offsets with a third node squatting at a
non-slot x between the spouses. The added loop closes that contract.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@Felix + @Markus on PR #693: viewBox computation is self-contained
(reads only positions + the MIN/PAD constants). Lift it out so buildLayout
ends with a readable two-line orchestration.
Pure refactor under green tests — no behaviour change, no test diff.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@Felix + @Markus on PR #693: buildLayout was a 367-line orchestrator
doing five sequential phases. assignRanks() is one of the two
self-contained phases that reads top-down on its own.
Pure refactor under green tests — no behaviour change, no test diff.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>