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>
@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>
@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>
@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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Move the layout function out of StammbaumTree.svelte (lines 47-275) into a
new pure TypeScript module at frontend/src/lib/person/genealogy/layout/
buildLayout.ts so it can be exercised by direct unit tests. Drops the
eslint-disable svelte/prefer-svelte-reactivity blanket; switches the
remaining scope-local Maps/Sets in parentLinks to SvelteMap/SvelteSet to
satisfy the rule per-call-site. No behaviour change — existing
StammbaumTree tests must pass byte-for-byte.
Refs #689
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Confirming a provisional person was a one-click write — easy to fat-finger
on a touchscreen and irreversible (the person disappears from the review
list, with no obvious undo path). Mirror the destructive-delete pattern
with a non-destructive confirm dialog (destructive: false) so the action
requires a second deliberate click.
New i18n keys (persons_review_confirm_confirm_title/text/button) added
to all three locales (de, en, es).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pure formatting (line wrap) so the file passes prettier --check; no behaviour
change.
Refs #667
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The role="switch" toggle set a fixed aria-label of "Zu prüfen (N)" while its
visible text flips to "Alle anzeigen" when active — a visible-text /
accessible-name mismatch (WCAG 2.5.3 Label in Name). Drop the aria-label so
the visible text is the accessible name; aria-checked carries the state.
Refs #667
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The triage rename form reused persons_filter_type_person ("Person") and
persons_section_details ("Angaben zur Person") as the first/last-name field
labels, so a screen reader announced the wrong name for each input. Add
dedicated persons_field_first_name / persons_field_last_name keys (de/en/es)
and use them.
Refs #667
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Align PersonCard's "unbestätigt" badge with the authoritative provisional
flag so the badge, the "Zu prüfen (N)" count and the /persons/review triage
list can never disagree. Empty/"?" name handling is now a separate
crash-safety concern: it still routes to the neutral placeholder glyph
(never a "?" initial) but no longer implies a badge on its own.
Refs #667
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
GET /api/persons now returns PersonSearchResult { items, … } instead of a bare
list. Update every caller: the dashboard top-persons path reads .items; the
unused full-list fetches in documents/new and documents/[id]/edit are dropped
(both pages use the self-fetching PersonTypeahead); the raw-fetch consumers
(PersonTypeahead, PersonMultiSelect, PersonMentionEditor) read body.items and
pass review=true so search still spans the whole directory. Specs updated to
the new envelope shape.
Refs #667
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New WRITE-gated triage route lists provisional persons (one PersonReviewRow
each) with Merge (reuses POST /merge), Umbenennen (PUT), Bestätigen
(PATCH /confirm) and Löschen (DELETE behind the focus-trapped, Escape-dismissible
ConfirmDialog service). Actions run as form actions via use:enhance so they work
without JS and stay server-side permission-guarded; the loader is READ_ALL.
Refs #667
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Rewrite /persons: server-side filter chips (type, family-only, has-documents)
that AND within the clean reader default (familyMember OR documentCount > 0),
a writer-only show-all/Zu-prüfen toggle, and reused Pagination. Extract
PersonCard (fixes the null-lastName render crash and never shows a "?" initial —
provisional/UNKNOWN/"?" entries get a neutral placeholder avatar + a text+icon
"unbestätigt" badge, WCAG 1.4.1) and PersonFilterBar (44px aria-pressed chips,
role=switch toggle with the count in its accessible name). The loader applies
the reader restriction unless review=1 and surfaces a cheap needsReviewCount.
i18n keys added for de/en/es.
Refs #667
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces 3 setTimeout sleeps with vi.waitFor on listbox / aria-expanded
state and converts 2 .not.toThrow smoke tests + 1 vacuous expect(true)
into assertions about the input remaining usable after fetch errors
and Escape on a closed dropdown being a no-op.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
use:enhance vs callback form variant rendering, self-relation
error, submit disabled on missing related person, submit disabled
on yearError.
5 new tests covering ~10 branches.
Refs #496.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds selected-node primary fill, birth/death year combinations,
node click and Enter/Space/other-key handling, dashed/solid spouse
line, single-parent connector, focus ring on focus + blur, aria
labels and aria-expanded reflection, accent stripe on selected node.
13 new tests covering ~30 branches in the node-render path.
Refs #496.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds direct-relationship sorting, yearRange formatting (both years,
only fromYear), inferred-relationships disclosure rendering, 5-item
cap on derived relationships.
5 new tests targeting ~15 branches.
Refs #496.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Toggle button, form open on click, all relationship type options,
year-error alert when toYear < fromYear, no-error path when equal,
cancel button closes form, onSubmit prop wiring.
7 tests covering ~20 branches.
Refs #496.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>