Commit Graph

3062 Commits

Author SHA1 Message Date
Marcel
1dffb430ac feat(stammbaum): centre-on-person control in the panel title row (#692)
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>
2026-05-29 17:10:49 +02:00
Marcel
1e5a45a027 feat(stammbaum): dismissible accessible mobile bottom sheet (#692)
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>
2026-05-29 17:06:55 +02:00
Marcel
ccc37fe1bb feat(shared): add trapFocus action for modal overlays (#692)
Focuses the first focusable on mount and wraps Tab/Shift+Tab within the node.
Used by the Stammbaum mobile bottom sheet (NFR-A11Y-004).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 17:04:12 +02:00
Marcel
289c3bbfb5 feat(stammbaum): sync view to shareable ?cx&cy&z URL (#692)
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>
2026-05-29 17:02:47 +02:00
Marcel
8d29bb10e2 feat(stammbaum): server-clamped initial view from ?cx&cy&z (#692)
The server load parses and sanitises the shareable pan/zoom params (degrading
Infinity/NaN, clamping zoom) into initialView, which seeds the page view. A
crafted link can no longer blank the SVG (Nora). US-PANEL-002 AC2 groundwork.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 16:58:36 +02:00
Marcel
396c87f8ab feat(stammbaum): animate fit-to-screen, snap under reduced motion (#692)
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>
2026-05-29 16:54:34 +02:00
Marcel
7a6c2e877f feat(stammbaum): bottom-right zoom + fit-to-screen control cluster (#692)
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>
2026-05-29 16:52:32 +02:00
Marcel
ffc14dd2ff feat(stammbaum): edge-fade mask when zoomed past fit (#692)
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>
2026-05-29 16:48:56 +02:00
Marcel
3827a9d059 feat(stammbaum): recentre on a node via centreOnId prop (#692)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 16:47:10 +02:00
Marcel
c8931071ba feat(stammbaum): touch/mouse/wheel pan & pinch zoom gestures (#692)
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>
2026-05-29 16:45:18 +02:00
Marcel
da1984b916 feat(stammbaum): keyboard pan/zoom on the canvas (#692)
+/- 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>
2026-05-29 16:39:55 +02:00
Marcel
0422af8980 feat(stammbaum): drive viewBox from PanZoomState (pan + zoom) (#692)
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>
2026-05-29 16:35:49 +02:00
Marcel
197b668f20 feat(stammbaum): recentre-on-node with legible auto-zoom (#692)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 16:29:55 +02:00
Marcel
5d752fcc0f feat(stammbaum): centroid-anchored zoom (zoomAtPoint) (#692)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 16:28:41 +02:00
Marcel
0170f79690 feat(stammbaum): convert pointer pixel delta to SVG units (#692)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 16:27:14 +02:00
Marcel
369a0213e5 feat(stammbaum): serialise pan/zoom state to URL params (#692)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 16:26:07 +02:00
Marcel
a7d0e96613 feat(stammbaum): parse + sanitise URL pan/zoom params (#692)
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>
2026-05-29 16:25:11 +02:00
Marcel
5458ca9bae feat(stammbaum): add clampZoom with resolved 0.25–3.0 zoom bounds (#692)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 16:23:47 +02:00
Marcel
23d93d492d refactor(stammbaum): TestNode type alias drops generation cast (#361)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m25s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 4m14s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
CI / Unit & Component Tests (push) Successful in 3m49s
CI / OCR Service Tests (push) Successful in 22s
CI / Backend Unit Tests (push) Successful in 3m37s
CI / fail2ban Regex (push) Successful in 44s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m5s
nightly / deploy-staging (push) Successful in 5m3s
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>
2026-05-28 21:16:49 +02:00
Marcel
2097dddf3a docs(adr): ADR-026 cross-references findAc3Candidates() predicate (#361)
Names the JavaScript function next to the AC3 SQL probe so a future reader
of ADR-026 has a concrete code anchor for the testable predicate (Markus
cycle-3 cosmetic). The SQL remains the source-of-truth probe against live
data; the function is the capture-time + fixture-time signal.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:15:40 +02:00
Marcel
585f28cd23 refactor(stammbaum): single source of truth for findAc3Candidates (#361)
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>
2026-05-28 21:15:02 +02:00
Marcel
2c18cb8b0d docs(adr): ADR-026 names assessor + revisit cadence for dagre deferral (#361)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m18s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 3m41s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m3s
Cycle-2 follow-up from Elicit. The "UX-signal-only stop trigger" wording
was honest about being qualitative but left no named owner and no
cadence — if #361 changes hands in 18 months, "Albert de Gruyter's read
test failing" had no one accountable for running it. Names Felix Brandt
as owner, sets a hard 2027-05-01 fallback so the question can't drift
indefinitely.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:59:25 +02:00
Marcel
655f0c3531 test+feat(stammbaum): capture script soft-warns on AC3 revisit trigger (#361)
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>
2026-05-28 20:58:50 +02:00
Marcel
e7931335ce test(stammbaum): assert r=6 marriage dot fill is var(--c-primary) (#361)
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>
2026-05-28 20:56:15 +02:00
Marcel
89bb0b5d65 test(stammbaum): assert no node sits between AC2 spouses on same y (#361)
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>
2026-05-28 20:55:23 +02:00
Marcel
b8ad64dd13 docs(stammbaum): layout glossary + AC3 deferral SQL (#361)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m41s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m51s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
@Elicit on PR #693: two doc gaps that block traceability on this PR.

1. docs/GLOSSARY.md: add a Stammbaum section with the layout vocabulary
   introduced by #689 and #361 — Stammbaum, seeded rank, sibling block,
   loose spouse, parented, anchor index, intra-family marriage, marriage
   dot, canonical fixture. Removes the Pending placeholder.

2. docs/adr/026: commit the AC3 reachability probe (the SQL that returned
   "0 of 942 unseeded persons match the predicate" in May 2026) directly
   into the ADR. A future architect re-evaluating the deferral can rerun
   it verbatim — reproducibility of the decision is itself a requirement.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:44:49 +02:00
Marcel
9bdd9fb3a5 refactor(stammbaum): extract computeViewBox() helper from buildLayout (#361)
@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>
2026-05-28 20:43:25 +02:00
Marcel
52e48a6b8c refactor(stammbaum): extract assignRanks() helper from buildLayout (#361)
@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>
2026-05-28 20:42:14 +02:00
Marcel
fd624f6ec8 test(stammbaum): assert no canonical SPOUSE_OF carries fromYear (#361)
@Sara on PR #693: canonical_fixture_multi_spouse_falls_through_to_displayName
_when_no_fromYear asserts the *fallback* branch of the multi-spouse sort
(NULLS LAST, then displayName). It only exercises the name branch while
every SPOUSE_OF row in the fixture has fromYear=undefined. The day a year
gets backfilled in canonical import, the test would silently start
asserting year-order with no notice.

Add a precondition at the head of the test that fails fast with a clear
maintainer message ("update or split into year-branch / name-branch")
when any canonical SPOUSE_OF row gains a fromYear.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:41:17 +02:00
Marcel
6d8655bad1 test+fix(stammbaum): capture script floors >= 1 multi-spouse person (#361)
@Markus + @Tobias + @Sara on PR #693: the multi-spouse property is
load-bearing for buildLayout.test.ts (canonical_fixture_assigns_a_position
_to_every_node_with_multiple_spouses + canonical_fixture_multi_spouse
_falls_through_to_displayName_when_no_fromYear). A recapture against a
dataset that lost every multi-spouse person would silently degrade those
tests to vacuous truth.

Add MIN_MULTI_SPOUSE_PERSONS=1 to the capture-script sanity gates. Extract
the validator into a unit-testable TS module next to the fixture; the .mjs
script keeps its inline copy (one-file local utility) but the contract is
now covered by validateFixture.test.ts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:39:55 +02:00
Marcel
5167a2ae18 test+fix(stammbaum): capture script refuses default creds and non-localhost (#361)
@Nora + @Tobias on PR #693: defaulting CAPTURE_EMAIL/PASSWORD to
documented admin creds and BACKEND_URL to localhost:8080 means an env-var
slip silently auth's against staging/prod. Make both explicit: refuse to
run unless CAPTURE_EMAIL and CAPTURE_PASSWORD are set, and unless
BACKEND_URL hostname is localhost / 127.0.0.1 / ::1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:36:58 +02:00
Marcel
4f07527b0f docs(adr): ADR-026 in-house Stammbaum layout, dagre deferred (#361)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m32s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m56s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 25s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
Records the decision to keep Stammbaum layout in-house, with the in-house
fixes from commits 1-6 of #361 as the implementation, and a UX-signal-only
stop trigger as the dagre re-evaluation criterion. Captures the deferred
acceptance criteria (AC3, AC6, AC7) with explicit revisit triggers so
future maintainers do not silently inherit unbounded scope.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:22:18 +02:00
Marcel
0c5f56e9d1 test+fix(stammbaum): enlarge marriage-line midpoint dot to r=6 (#361)
Once the dot starts stacking to disambiguate multiple marriages on
multi-spouse rows it carries meaning, so it's no longer decorative —
WCAG 1.4.11 (3:1) applies. r=6 (12 px diameter) covers the contrast
gap; the existing brand-navy fill against the gutter and surface
backgrounds satisfies the ratio without a hue change.

Impl-ref table in stammbaum-tree-spec.html updated to match (r=6 /
12 px dia / Informational), with the WCAG reference noted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:20:51 +02:00
Marcel
652100a9c2 test+feat(stammbaum): merge sibling blocks across same-rank spouse edge (#361)
AC2 — intra-family marriage. When two parented persons at the same
imported generation are spouses but live in separate sibling blocks
(each under their own parent), the block-packer used to leave them
split, drawing a long spouse line that crossed through any intervening
siblings. The new step 3.5 detects that case, moves the focal members
to the join boundary (A's spouse rightmost in A's block, B's spouse
leftmost in B's), and concatenates B's members onto A's; the combined
block centres on the average of the two parents' midpoints.

Latent against today's data (no intra-family marriage in the canonical
fixture); covered by a synthetic two-family scenario in
buildLayout.test.ts. Packer growth stays comfortably under Markus's
80-LoC extraction threshold, so packBlocks.ts is not yet warranted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:18:23 +02:00
Marcel
557f37be54 test+feat(stammbaum): order multi-spouses by fromYear then displayName (#361)
Replaces the alternating-side insertOnRight rule with a sort-and-splice
that places every loose spouse to the right of the parented focal in
(fromYear ASC NULLS LAST, displayName ASC) order. Mirrored in step 3 for
the all-loose chained merge so Albert de Gruyter's four marriages land
in deterministic alphabetical order today (no fromYear populated in the
canonical dataset) and switch automatically to year-order as the
transcription pipeline backfills marriage years.

PersonNodeDTO carries only displayName, not parsed first/last names, so
the tiebreaker uses displayName rather than the (lastName, firstName)
key in the original UX brief. The canonical alphabetical order matches
in both schemes — the rule activates the moment a multi-spouse case has
mixed display-name patterns.

Retires the temporary commit-3 scaffold
`attaches_loose_multi_spouse_to_parented_partner_when_edge_order_clobbers`
which became position-arithmetic-equivalent under the new right-of-focal
rule; the two new sort tests are stronger discriminators for the same
behaviour.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:14:23 +02:00
Marcel
2a462d0a7c test+feat(stammbaum): preserve all SPOUSE_OF edges in layout (#361)
Switches spousePairs from Map<string, string> to Map<string, Set<string>>
so multi-spouse persons (canonical case: Albert de Gruyter, 4 marriages)
keep every partner instead of losing the earlier .set() values.

The behavioural discriminator (now exercised by
attaches_loose_multi_spouse_to_parented_partner_when_edge_order_clobbers)
is a loose person with both a parented and a loose spouse: the old map
clobbered to whichever edge landed last, so the loose-placement step could
miss the parented partner and merge the focal node into the wrong block.

Also closes the robustness gap NullX flagged: SPOUSE_OF edges referencing
IDs outside allNodes are dropped at ingestion instead of leaking into the
spouse-pulldown loop.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:03:52 +02:00
Marcel
36bd7e0414 chore(stammbaum): add /api/network capture script + canonical fixture (#361)
Local-only developer utility that authenticates against the running backend,
captures the current /api/network snapshot, and writes it to
src/lib/person/genealogy/__fixtures__/stammbaum.json. Sanity gates exit
non-zero on a vacuous capture (< 50 nodes, < 5 generations, 0 SPOUSE_OF
edges). Fixture and script land together so the fixture is reproducible from
the script that generated it.

Captured snapshot: 62 nodes, 43 edges, 28 SPOUSE_OF (0 with fromYear),
generations G0-G4. Albert de Gruyter is the canonical multi-spouse case with
4 marriages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 19:55:30 +02:00
Marcel
6970cc95fb docs(stammbaum): reconcile spec geometry to 160x56 and document seeded-rank invariant (#361)
Updates the impl-ref constants table to match buildLayout.ts (NODE_W=160,
NODE_H=56) and adds an explicit Layout rules section asserting the seeded-
rank invariant honoured since #689. Mockup <rect> dimensions stay at 144x50
with an explanatory annotation; re-pixel-pushing the illustrative SVG has
disproportionate blast radius for a spec doc.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 19:51:13 +02:00
Marcel
a5e3205520 fix(stammbaum): make gutter visibility prop-overridable for tests (#689)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m45s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m54s
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
CI / Unit & Component Tests (push) Successful in 3m49s
CI / OCR Service Tests (push) Successful in 23s
CI / Backend Unit Tests (push) Successful in 4m14s
CI / fail2ban Regex (push) Successful in 47s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m3s
CI kept failing on the two gutter-render tests because the vitest-browser
iframe viewport is narrower than 768 px → window.matchMedia(min-width:
768px) returns false → gutter is hidden → g[role="text"] selector
returns []. The previous synchronous-seed fix was insufficient because
matchMedia itself was the false branch.

Add an optional `showGutter?: boolean` prop. When set, it bypasses the
matchMedia detection — tests pass `showGutter: true` to assert the
rendered gutter, and `showGutter: false` to assert the absent path.
Production callers leave it undefined so the existing media-query
detection still governs visibility.

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:53:27 +02:00
Marcel
f124529ee8 fix(stammbaum): seed gutter media-query state synchronously (#689)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m32s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m39s
CI / fail2ban Regex (pull_request) Failing after 45s
CI / Semgrep Security Scan (pull_request) Successful in 24s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
CI flagged two browser tests:

- "renders a G{n} label per occupied generation row …"
- "wraps the visible G3 text inside an aria-labelled group …"

Both queried g[role="text"] and got an empty array. Root cause:
isMdOrUp was initialised to false and only flipped to true inside a
$effect — but $effect runs after the first render, so the test's
post-render DOM scan saw the pre-effect (gutter-absent) state.

Seed the rune synchronously from window.matchMedia(...).matches when
window is available; SSR still picks the false branch and hydrates
without a layout flash. The effect now only attaches the change
listener for subsequent resizes.

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:22:09 +02:00
Marcel
61ca5a6e40 test(person): tighten generation null-clear coverage (#689)
Sara's QA concerns:

1. PersonControllerTest.updatePerson_returns200_whenGenerationNull was
   asymmetric — only checked status 200, no body assertion. Now also
   asserts `$.generation` is null in the JSON response, mirroring the
   in-range test's body check.

2. New full-stack PUT→DB→GET round-trip in PersonServiceIntegrationTest
   (updatePerson_clearGenerationToNull_readsBackNullFromDb) seeds a
   person with generation=3, calls updatePerson with generation=null,
   flushes the persistence context, and asserts the column reads back
   null from the DB. Without this we only had the mocked WebMvcTest
   boundary; nothing proved JPA actually wrote SQL NULL.

3. Sibling test (updatePerson_setGenerationToZero_readsBackZeroFromDb)
   pins the G 0 end-to-end so a primitive zero can't silently coerce
   to null anywhere along controller → service → JPA.

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:19:13 +02:00
Marcel
516a0a3814 refactor(person): single source of truth for generation bounds (#689)
Markus flagged the 0/10 range was duplicated across five sites (DB
CHECK, both importers, DTO @Min/@Max, dropdown range). New
PersonGeneration.MIN_GENERATION / MAX_GENERATION constants are now
the canonical Java source; the DTO annotations and both importer
guards reference them. The V70 SQL CHECK comment now points at the
Java constants so future widening updates one Java class plus one
SQL literal (Flyway forbids rewriting the migration in place).

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:16:26 +02:00
Marcel
39276b179d docs(stammbaum): document gutter + persons.generation column (#689)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m52s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 4m7s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m3s
db-orm.puml: persons gains a generation : SMALLINT attribute mirroring
the V70 column. No FK change, so db-relationships.puml is unaffected.

stammbaum-tree-spec.html:
- impl-ref table: replace "Gen label" with "Gutter label" + new
  "Gutter stripe underlay" rows describing the role="text" wrapper,
  un-shifted source-truth value, and below-md hidden state.
- light + dark colour-table rows updated to "Gutter label" /
  "Gutter stripe" with the new var(--c-ink-2) / var(--c-gutter-stripe)
  swatches.
- "Generationen ▾" filter chip mocks removed from desktop and tablet
  layout sections (the filter UI was de-scoped from this PR).

Inline visual mockup SVGs that still show pre-gutter labelling are
out of scope per the issue body — the impl-ref table is the
authoritative source for this PR.

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:57:54 +02:00
Marcel
577dd3fcb1 feat(person): generation dropdown on Person edit/new forms (#689)
PersonEditForm.svelte gains a G 0…G 6 select inside the {#if isPerson}
block. min-h-[44px] meets WCAG 2.5.8 / dual-audience touch target.
generationStr is initialised via $state(untrack(...)) so prop reruns
never reset an in-progress edit (same pattern as selectedType).

Both /persons/[id]/edit and /persons/new form actions read the field
without the conditional-spread idiom — generation always lands in the
PUT/POST body. G 0 is a valid family-tree-root value the spread would
silently drop, and an empty option sends null so a human can clear the
field back to "unset".

i18n adds person_label_generation / person_option_generation_unset /
person_hint_generation in de/en/es. Drops the dead stammbaum_generations
key (zero callsites after the filter-chip removal in the spec).

Tests: dropdown render + hydration in the component, generation=0/3/null
arriving in the API body in the server actions.

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:55:25 +02:00
Marcel
c0b500b692 feat(stammbaum): render generation gutter on the family tree (#689)
The gutter sits 100 px to the left of the tree canvas on md+ viewports
(hidden entirely below md to preserve scrollable area on phones — see
spec's deliberate dual-audience trade-off). Per occupied generation
row it draws:

- A full-width decorative stripe rect alternating transparent and
  var(--c-gutter-stripe). aria-hidden because it carries no meaning.
- The label `G{n}` at the left edge, sourced from the un-shifted
  node.generation value (never the post-normalise rank), wrapped in
  `<g role="text" aria-label="Generation N">` so screen readers
  announce the full word instead of "G three".

CSS adds --c-gutter-stripe in both the light root and the dark mode
blocks (8% / 14% mint over canvas — decorative contrast carve-out).

Browser tests cover label rendering, the ARIA wrapper, and the
viewport-below-md absent-gutter path via a matchMedia stub. Existing
StammbaumTree structural-invariant tests still pass since none of
them assert anything inside the gutter region.

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:49:23 +02:00
Marcel
cb8c85a742 feat(stammbaum): seed layout rank from imported generation (#689)
buildLayout switches to a two-stage assignment:

1. Seed — every node with node.generation != null is locked at that
   rank. The fallback heuristic never moves a locked rank, and the
   spouse-pulldown never pulls a locked rank.
2. Fallback — for unseeded nodes, rank = max(parent rank) + 1 reading
   parents from the same unified rank map, so an unseeded child of a
   seeded G 2 parent correctly inherits rank 3. Spouse-pulldown ties
   unseeded spouses to their deeper partner exactly as before.
3. Normalise — if any rank is negative (future G −1 ancestor), shift
   the whole map so min(rank) == 0. No-op for today's data.

Fixes the Herbert Cram pattern from #361's review: two parented
spouses with imported G 3 now render on the same y row. Existing
StammbaumTree tests still pass byte-for-byte because every test node
has node.generation undefined, so the heuristic runs unchanged.

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:43:58 +02:00
Marcel
c93d3b03ed chore(api): mirror generation field in api types + PersonFormData (#689)
Manually mirrors the Spring Boot @Schema additions on PersonNodeDTO,
Person, and PersonUpdateDTO into the generated api.ts so the form +
gutter components compile against a finished type surface. The next
backend dev-profile run + `npm run generate:api` will regenerate the
same shape from the live OpenAPI spec.

PersonFormData gains `generation?: number | null` so PersonEditForm's
$state initialiser typechecks.

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:41:18 +02:00
Marcel
8f163f9b77 feat(import): warn on generation monotonicity violations (#689)
Inject RelationshipService into CanonicalImportOrchestrator and walk
PARENT_OF edges in the family network after both person loaders finish
(before documents). For every edge where child.generation is set and
not strictly deeper than parent.generation, log a WARN — soft check,
never fails the batch.

Reads through getFamilyNetwork() per the layering rule (orchestrator
never touches PersonRelationshipRepository directly). Curators see the
warning in the import log; the rest of the pipeline is unaffected so
data with curatorial gaps still loads cleanly.

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:39:29 +02:00
Marcel
40511535eb feat(relationship): add generation to PersonNodeDTO + update all sites (#689)
PersonNodeDTO is a positional record. The optional Integer generation
field is inserted between deathYear and familyMember so all four
construction sites stay readable without a builder.

- RelationshipService.getFamilyNetwork → populates with
  person.getGeneration() (the Stammbaum's strict-rank source on the
  frontend).
- RelationshipInferenceService.findAllFor → populates the same way;
  inference UI does not consume it but the field travels along for
  consistency.
- RelationshipControllerTest fixtures pass null.

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:35:40 +02:00
Marcel
a68a822c13 feat(import): pass generation from JSON in PersonTreeImporter (#689)
Reads the optional `generation` integer from the canonical tree JSON and
routes it into PersonUpsertCommand. Out-of-range values are skip-and-
warned with the same policy as the register importer.

Tree imports run after register (per CanonicalImportOrchestrator); a
tree-confirmed integer overwrites a register-parsed value — both sides
are "canonical" in preferHuman terms (neither is a human edit).

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:32:27 +02:00