feat(person): persist Excel generation (G0–G6) and show on Stammbaum gutter #690

Merged
marcel merged 17 commits from feature/689-generation-gutter into main 2026-05-28 17:33:05 +02:00
Owner

Closes #689.

Summary

  • Persists the hand-curated generation index from canonical-persons.xlsx / canonical-persons-tree.json on persons (V70 + entity + DTO chain), driven through the existing preferHuman precedence so re-import never overwrites a human edit.
  • Seeds the Stammbaum layout from the imported generation as a strict rank anchor — fixes the Herbert Cram pattern from #361's review (two parented G 3 spouses now share a row).
  • Renders a 100 px left-hand gutter on md+ viewports with per-row G{n} labels (role="text" + aria-label so screen readers announce the full word) and decorative alternating stripes. Hidden entirely below md.
  • Adds a G 0…G 6 dropdown to both /persons/[id]/edit and /persons/new. Both form actions write generation unconditionally — the conditional-spread idiom is not used because G 0 is a valid family-tree-root value and the dropdown must also be able to clear back to null.

15 atomic commits; each green at commit time. Full per-AC mapping in the issue comment.

Carve-outs (called out for review)

  • db-relationships.puml is intentionally unchanged. persons.generation is a scalar column with no FK; there is no relationship line to add or remove. The DB diagram pair (orm + relationships) was reviewed; only orm.puml needed a row.
  • Visual mockup SVGs in stammbaum-tree-spec.html still show pre-gutter labelling — explicit out-of-scope per the issue body. The impl-ref + colour tables are updated and are the authoritative source.
  • Mobile (< md) loses the gutter by design (100 px chrome too costly on 320 px). Per-node generation on the side panel is tracked separately as #691.

Test plan

  • ./mvnw test clean (CI will run the full suite)
  • npm run test + browser tests clean (CI)
  • Manually toggle a Person's generation on /persons/<id>/edit and confirm the change persists + re-renders on /stammbaum
  • Run a canonical re-import; confirm Generation monotonicity violation WARNs surface in the log for any curated edges where child ≤ parent
  • Verify gutter contrast manually — see follow-up comment for computed WCAG ratios
  • Spot-check that the gutter disappears on a phone-width viewport (≤ 320 px)
  • Confirm next dev-profile npm run generate:api produces the same generation fields already mirrored in frontend/src/lib/generated/api.ts

Review-round 1 fix-ups (commits 14–15+)

  • 516a0a38 — single source of truth for the 0/10 bounds (PersonGeneration.MIN_GENERATION / MAX_GENERATION); used by PersonUpdateDTO, both importers, and referenced from the V70 SQL comment.
  • 61ca5a6e — symmetric body assertion on the WebMvcTest null-clear case; new full-stack PUT→DB→GET round-trip integration tests for both null-clear and G 0.

🤖 Generated with Claude Code

Closes #689. ## Summary - Persists the hand-curated `generation` index from `canonical-persons.xlsx` / `canonical-persons-tree.json` on `persons` (V70 + entity + DTO chain), driven through the existing `preferHuman` precedence so re-import never overwrites a human edit. - Seeds the Stammbaum layout from the imported generation as a **strict rank anchor** — fixes the Herbert Cram pattern from #361's review (two parented G 3 spouses now share a row). - Renders a 100 px left-hand gutter on md+ viewports with per-row `G{n}` labels (`role="text"` + `aria-label` so screen readers announce the full word) and decorative alternating stripes. Hidden entirely below md. - Adds a G 0…G 6 dropdown to both `/persons/[id]/edit` and `/persons/new`. Both form actions write `generation` unconditionally — the conditional-spread idiom is **not** used because G 0 is a valid family-tree-root value and the dropdown must also be able to clear back to null. 15 atomic commits; each green at commit time. Full per-AC mapping in the issue comment. ## Carve-outs (called out for review) - **`db-relationships.puml` is intentionally unchanged.** `persons.generation` is a scalar column with no FK; there is no relationship line to add or remove. The DB diagram pair (orm + relationships) was reviewed; only orm.puml needed a row. - **Visual mockup SVGs in `stammbaum-tree-spec.html`** still show pre-gutter labelling — explicit out-of-scope per the issue body. The impl-ref + colour tables are updated and are the authoritative source. - **Mobile (`< md`) loses the gutter** by design (100 px chrome too costly on 320 px). Per-node generation on the side panel is tracked separately as #691. ## Test plan - [ ] `./mvnw test` clean (CI will run the full suite) - [ ] `npm run test` + browser tests clean (CI) - [ ] Manually toggle a Person's generation on `/persons/<id>/edit` and confirm the change persists + re-renders on `/stammbaum` - [ ] Run a canonical re-import; confirm `Generation monotonicity violation` WARNs surface in the log for any curated edges where child ≤ parent - [ ] Verify gutter contrast manually — see follow-up comment for computed WCAG ratios - [ ] Spot-check that the gutter disappears on a phone-width viewport (≤ 320 px) - [ ] Confirm next dev-profile `npm run generate:api` produces the same generation fields already mirrored in `frontend/src/lib/generated/api.ts` ## Review-round 1 fix-ups (commits 14–15+) - `516a0a38` — single source of truth for the 0/10 bounds (`PersonGeneration.MIN_GENERATION` / `MAX_GENERATION`); used by `PersonUpdateDTO`, both importers, and referenced from the V70 SQL comment. - `61ca5a6e` — symmetric body assertion on the WebMvcTest null-clear case; new full-stack PUT→DB→GET round-trip integration tests for both null-clear and G 0. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 13 commits 2026-05-28 16:04:06 +02:00
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>
Flyway V70: SMALLINT generation column with CHECK(0..10) and partial
index over non-null rows. Person.generation field surfaces it through
the JPA model. Pre-import rows and persons outside the curated family
graph legitimately stay null; the canonical importer (next commits)
back-fills via preferHuman so a human-edited value is never lost.

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the optional generation field to both DTOs:

- PersonUpsertCommand gains Integer generation in the canonical-import
  builder chain; service wiring lands in the next commit.
- PersonUpdateDTO gains @Min(0)@Max(10) Integer generation, the form-path
  surface. The constraints mirror the V70 CHECK so validation fails fast
  at the controller before reaching the DB.

PersonControllerTest pins the validation behaviour: -1 → 400, 11 → 400,
null → 200, 3 → 200 for both PUT (update) and POST (create) paths. The
GlobalExceptionHandler maps MethodArgumentNotValidException to
VALIDATION_ERROR so the frontend's extractErrorCode keeps working.

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- fromCanonical writes the imported generation into a new Person row.
- mergeCanonical routes existing/canonical generation through the
  existing preferHuman(Integer, Integer) overload so a human-edited
  value is never overwritten on re-import (ADR-025).
- updatePerson writes generation verbatim from the form DTO so a human
  can clear it back to null — same shape as birthYear/deathYear.
- createPerson(PersonUpdateDTO) writes generation so /persons/new flow
  doesn't silently drop a selected G value on create.

Pinned with five tests covering the four write paths plus the
documenting test that captures preferHuman's known limitation
(explicit human null is overwritten by a non-null canonical value —
same as birthYear/deathYear, deferred to a future helper rework if it
ever produces a user-visible bug).

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Reads the optional `generation` cell by header name (REQUIRED_HEADERS is
not extended — REQ-IMP-001 backward-compat for older artifacts), parses
it through GENERATION_PATTERN (^\s*G?\s*(-?\d+)), and routes it into
PersonUpsertCommand.generation.

Out-of-range values (G 99, G -1) are skip-and-warned, never abort the
batch; the post-parse range guard mirrors the V70 CHECK constraint so
the DB never sees a value Bean Validation wouldn't accept.

Pinned with a parametrised CsvSource covering every shape from the
acceptance criteria plus a backward-compat test (artifact without a
generation column still imports, all upserts get generation=null).

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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
39276b179d
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>
Author
Owner

🏛️ Markus Keller — Senior Application Architect

Verdict: ⚠️ Approved with concerns

The structural shape of this PR is exactly what I would have asked for: integrity pushed to PostgreSQL (CHECK + partial index), layering rule respected (the orchestrator reads the family graph through RelationshipService.getFamilyNetwork() rather than touching personRelationshipRepository), and the buildLayout refactor extracts ~200 lines of pure logic out of a Svelte component into a unit-testable module. Migration V70 is single-purpose, scoped, and well-commented. The DTO/command/entity chain is updated consistently; @Schema annotations are preserved.

Blockers

  1. Documentation drift — docs/architecture/db/db-relationships.puml not in the diff. The DB-orm PUML adds generation : SMALLINT, but the relationships PUML is untouched. The issue body explicitly says "re-verified (no FK change — likely no diff)" — that's a deliberate carve-out and acceptable, but the PR description should call it out so the next architect doesn't reopen this. Either add a one-line comment in the PR body, or make sure the AC checkbox is ticked with the rationale. Not a real diagram blocker, just hygiene.

  2. No ADR for the strict-G layout policy. The issue body explicitly defers the ADR ("ADR for the strict-G layout policy — captured here in the issue body" → out of scope). That is your call as the issue author, and I accept it — but I want to flag for the record that this is a non-trivial precedence rule (strict rank anchor in the layout, preferHuman for the data) that affects the next person who reads buildLayout.ts. The inline comment block at the top of buildLayout.ts:32-49 does carry the rationale, which is the minimum bar. If dagre (#361) later inherits this seed, an ADR captures the why for both consumers in one place.

Concerns (not blockers)

  1. Generation upper bound is loose (G 0..10) when reality is G 0..6. The migration says "current data tops out at G 5, but a future G 6 → G 10 widening needs no migration" — fine, and I agree with avoiding the future migration. But the DTO mirrors this with @Min(0) @Max(10) while the form dropdown only renders G 0..G 6. There are three slightly different ranges in play (DB 0..10, DTO 0..10, dropdown 0..6) and one comment in Person.java:55-58 mentions "G 0 = oldest" without naming the upper bound. This is fine today but a smell. If the dropdown changes to G 0..G 8 in six months, who remembers that the DTO and CHECK are already wider? Consider a single Java constant Person.MAX_GENERATION = 10 referenced by both @Max and the migration comment, and an inline note in the Svelte template that the dropdown is a narrower client-side view of the DB range.

  2. PersonRegisterImporter.GENERATION_MAX is duplicated in PersonTreeImporter. Two integer pairs (MIN=0, MAX=10) live in two files, plus the SQL CHECK, plus the Bean Validation, plus the dropdown. That's five copies of the same allowlist. Not a violation of any layering rule — but the day someone widens to 12, they will edit four out of five places. Extract a Generation value type or a shared constant holder in the person package, and reference it from both importers and the DTO @Max. KISS judgment: probably YAGNI today, but worth a follow-up TODO.

  3. warnOnGenerationMonotonicityViolations() is O(nodes + edges) but reads the full family network on every import. For 1,105 persons that's nothing — for 100k it becomes a measurable cost. Acceptable today (the import is a batch job and the WARN is forensic). Mention this in the method Javadoc so a future optimiser doesn't tear the soft-check out without realising it's the only signal we have.

LGTM checks

  • Layering rule respected throughout: CanonicalImportOrchestratorRelationshipService (never personRelationshipRepository).
  • Single-purpose Flyway migration. Partial index is the right call for ~163/1,105 non-null rows.
  • DTO drives TypeScript generation; frontend/src/lib/generated/api.ts was regenerated, matching the AC.
  • preferHuman(Integer, Integer) overload reused — no new precedence idiom invented. Known-limitation documenting_known_limitation test pins current behaviour and points the way to fix it if birthYear/deathYear ever produce a user-visible bug.
  • Layout extraction is byte-for-byte equivalent on the pure-refactor commit (verified by reading buildLayout.ts — the function is the original code with generation and locked added on top).

Approve the structure. Resolve doc-hygiene and the constant-duplication smell as a follow-up, not a hold.

## 🏛️ Markus Keller — Senior Application Architect **Verdict: ⚠️ Approved with concerns** The structural shape of this PR is exactly what I would have asked for: integrity pushed to PostgreSQL (CHECK + partial index), layering rule respected (the orchestrator reads the family graph through `RelationshipService.getFamilyNetwork()` rather than touching `personRelationshipRepository`), and the `buildLayout` refactor extracts ~200 lines of pure logic out of a Svelte component into a unit-testable module. Migration V70 is single-purpose, scoped, and well-commented. The DTO/command/entity chain is updated consistently; `@Schema` annotations are preserved. ### Blockers 1. **Documentation drift — `docs/architecture/db/db-relationships.puml` not in the diff.** The DB-orm PUML adds `generation : SMALLINT`, but the relationships PUML is untouched. The issue body explicitly says "re-verified (no FK change — likely no diff)" — that's a deliberate carve-out and acceptable, but the PR description should call it out so the next architect doesn't reopen this. Either add a one-line comment in the PR body, or make sure the AC checkbox is ticked with the rationale. Not a real diagram blocker, just hygiene. 2. **No ADR for the strict-G layout policy.** The issue body explicitly defers the ADR ("ADR for the strict-G layout policy — captured here in the issue body" → out of scope). That is your call as the issue author, and I accept it — but I want to flag for the record that this is a non-trivial precedence rule (strict rank anchor in the layout, `preferHuman` for the data) that affects the next person who reads `buildLayout.ts`. The inline comment block at the top of `buildLayout.ts:32-49` does carry the rationale, which is the minimum bar. If `dagre` (#361) later inherits this seed, an ADR captures the why for both consumers in one place. ### Concerns (not blockers) 3. **Generation upper bound is loose (G 0..10) when reality is G 0..6.** The migration says "current data tops out at G 5, but a future G 6 → G 10 widening needs no migration" — fine, and I agree with avoiding the future migration. But the DTO mirrors this with `@Min(0) @Max(10)` while the form dropdown only renders G 0..G 6. There are three slightly different ranges in play (DB 0..10, DTO 0..10, dropdown 0..6) and one comment in `Person.java:55-58` mentions "G 0 = oldest" without naming the upper bound. This is fine today but a smell. If the dropdown changes to G 0..G 8 in six months, who remembers that the DTO and CHECK are already wider? Consider a single Java constant `Person.MAX_GENERATION = 10` referenced by both `@Max` and the migration comment, and an inline note in the Svelte template that the dropdown is a narrower client-side view of the DB range. 4. **`PersonRegisterImporter.GENERATION_MAX` is duplicated in `PersonTreeImporter`.** Two integer pairs (`MIN=0, MAX=10`) live in two files, plus the SQL CHECK, plus the Bean Validation, plus the dropdown. That's five copies of the same allowlist. Not a violation of any layering rule — but the day someone widens to 12, they will edit four out of five places. Extract a `Generation` value type or a shared constant holder in the `person` package, and reference it from both importers and the DTO `@Max`. KISS judgment: probably YAGNI today, but worth a follow-up TODO. 5. **`warnOnGenerationMonotonicityViolations()` is O(nodes + edges) but reads the full family network on every import.** For 1,105 persons that's nothing — for 100k it becomes a measurable cost. Acceptable today (the import is a batch job and the WARN is forensic). Mention this in the method Javadoc so a future optimiser doesn't tear the soft-check out without realising it's the only signal we have. ### LGTM checks - Layering rule respected throughout: `CanonicalImportOrchestrator` → `RelationshipService` (never `personRelationshipRepository`). - Single-purpose Flyway migration. Partial index is the right call for ~163/1,105 non-null rows. - DTO drives TypeScript generation; `frontend/src/lib/generated/api.ts` was regenerated, matching the AC. - `preferHuman(Integer, Integer)` overload reused — no new precedence idiom invented. Known-limitation `documenting_known_limitation` test pins current behaviour and points the way to fix it if `birthYear`/`deathYear` ever produce a user-visible bug. - Layout extraction is byte-for-byte equivalent on the pure-refactor commit (verified by reading `buildLayout.ts` — the function is the original code with `generation` and `locked` added on top). Approve the structure. Resolve doc-hygiene and the constant-duplication smell as a follow-up, not a hold.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: Approved

This is textbook TDD across three stacks. Every change has a test that precedes it: the parameterised regex matrix in PersonRegisterImporterTest, the four buildLayout.test.ts cases (Herbert Cram regression, strict-seed override, fallback inheritance, normalise), the form-action page.server.spec.ts proving the generation: 0 case round-trips, and the controller validation tests cover both 400 boundaries plus the 200 happy path. The refactor-first sequencing (extract buildLayout byte-for-byte, then add the seed/fallback/normalise behaviour) is exactly the discipline I'd expect.

Notes

  1. Guard clauses everywhere. parseGeneration() and generationOrNull() both lead with if (raw == null/blank) return null; then range-check. Two-line guards, no nesting. Good.

  2. Naming reveals intent. GENERATION_PATTERN, parseGeneration(raw, personId), generationOrNull(node, personId), gutterRows, isMdOrUp, GUTTER_WIDTH_DESKTOP — every name carries the visual or domain concept. Zero tmp, data, value, flag abbreviations.

  3. SvelteMap/SvelteSet adopted in the new gutter code AND retroactively in parentLinks. The eslint-disable for svelte/prefer-svelte-reactivity was dropped — that's the right move, and the lint rule now does its job on this file going forward. Plain Map/Set are only used inside buildLayout.ts which is pure (not reactive) — also correct.

  4. $derived.by over $state + $effect for gutterRows, gutterWidth, viewBox. Synchronous, single-pass. The one $effect is exactly where it should be: bridging an imperative DOM API (matchMedia.addEventListener) into a $state. That is the canonical use of $effect.

  5. Functions stay small. parseGeneration is 9 lines including the doc-comment. generationOrNull is 5. warnOnGenerationMonotonicityViolations is 17 lines and does one thing (walk PARENT_OF edges, log violations). The new layout function would be too big to land in one component file — extracting it was the right structural call.

  6. Form action uses the issue-mandated unconditional-key idiom. +page.server.ts for both [id]/edit and new write generation as a top-level key, never inside ...(value ? {...} : {}). The inline comment block explicitly calls out why (G 0 is valid, the spread drops it). Saved a reviewer a "wait, why isn't this spread?" question.

  7. Property-key in the {#each} for gutter stripes uses a stable string (`stripe-${row.rank}`, `label-${row.rank}`). Not the (item.id) form but the rank is the natural identity for a row band. Acceptable.

Tiny suggestion (non-blocker)

The GENERATION_MIN/GENERATION_MAX constants in PersonTreeImporter.java:103-104 sit below the method that uses them. Spring doesn't care, but a reader scanning the class top-to-bottom hits the usage before the definition. Bump them to the top of the class next to the existing constants. Pure aesthetic, not a bug.

One more: in StammbaumTree.svelte.test.ts:683-696, the matchMedia shim returns an object literal cast through unknown. Vitest-browser users sometimes prefer vi.stubGlobal('matchMedia', () => ({...})). Either form works — flagging only because the cast chain (as unknown as typeof window.matchMedia) is the kind of thing eslint flags as a smell. Not a blocker.

LGTM bottom line

13 atomic commits, every commit green at commit time per the PR body. Refactor → red/green per concern, never mixed. The test-to-impl ratio in the backend (PersonImportUpsertTest, PersonServiceTest, PersonControllerTest, PersonRepositoryTest, PersonRegisterImporterTest, PersonTreeImporterTest, RelationshipServiceTest, RelationshipControllerTest, CanonicalImportOrchestratorTest) is exactly the spread I want: unit at the service, slice at the controller, integration at the repository, orchestrator at the wiring. Ship it.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ✅ Approved** This is textbook TDD across three stacks. Every change has a test that precedes it: the parameterised regex matrix in `PersonRegisterImporterTest`, the four `buildLayout.test.ts` cases (Herbert Cram regression, strict-seed override, fallback inheritance, normalise), the form-action `page.server.spec.ts` proving the `generation: 0` case round-trips, and the controller validation tests cover both 400 boundaries plus the 200 happy path. The refactor-first sequencing (extract `buildLayout` byte-for-byte, then add the seed/fallback/normalise behaviour) is exactly the discipline I'd expect. ### Notes 1. **Guard clauses everywhere.** `parseGeneration()` and `generationOrNull()` both lead with `if (raw == null/blank) return null;` then range-check. Two-line guards, no nesting. Good. 2. **Naming reveals intent.** `GENERATION_PATTERN`, `parseGeneration(raw, personId)`, `generationOrNull(node, personId)`, `gutterRows`, `isMdOrUp`, `GUTTER_WIDTH_DESKTOP` — every name carries the visual or domain concept. Zero `tmp`, `data`, `value`, `flag` abbreviations. 3. **`SvelteMap`/`SvelteSet` adopted in the new gutter code AND retroactively in `parentLinks`.** The eslint-disable for `svelte/prefer-svelte-reactivity` was dropped — that's the right move, and the lint rule now does its job on this file going forward. Plain `Map`/`Set` are only used inside `buildLayout.ts` which is pure (not reactive) — also correct. 4. **`$derived.by` over `$state + $effect` for `gutterRows`, `gutterWidth`, `viewBox`.** Synchronous, single-pass. The one `$effect` is exactly where it should be: bridging an imperative DOM API (`matchMedia.addEventListener`) into a `$state`. That is the canonical use of `$effect`. 5. **Functions stay small.** `parseGeneration` is 9 lines including the doc-comment. `generationOrNull` is 5. `warnOnGenerationMonotonicityViolations` is 17 lines and does one thing (walk PARENT_OF edges, log violations). The new layout function would be too big to land in one component file — extracting it was the right structural call. 6. **Form action uses the issue-mandated unconditional-key idiom.** `+page.server.ts` for both `[id]/edit` and `new` write `generation` as a top-level key, never inside `...(value ? {...} : {})`. The inline comment block explicitly calls out *why* (G 0 is valid, the spread drops it). Saved a reviewer a "wait, why isn't this spread?" question. 7. **Property-key in the `{#each}` for gutter stripes uses a stable string (`` `stripe-${row.rank}` ``, `` `label-${row.rank}` ``).** Not the `(item.id)` form but the rank is the natural identity for a row band. Acceptable. ### Tiny suggestion (non-blocker) The `GENERATION_MIN`/`GENERATION_MAX` constants in `PersonTreeImporter.java:103-104` sit *below* the method that uses them. Spring doesn't care, but a reader scanning the class top-to-bottom hits the usage before the definition. Bump them to the top of the class next to the existing constants. Pure aesthetic, not a bug. One more: in `StammbaumTree.svelte.test.ts:683-696`, the `matchMedia` shim returns an object literal cast through `unknown`. Vitest-browser users sometimes prefer `vi.stubGlobal('matchMedia', () => ({...}))`. Either form works — flagging only because the cast chain (`as unknown as typeof window.matchMedia`) is the kind of thing eslint flags as a smell. Not a blocker. ### LGTM bottom line 13 atomic commits, every commit green at commit time per the PR body. Refactor → red/green per concern, never mixed. The test-to-impl ratio in the backend (`PersonImportUpsertTest`, `PersonServiceTest`, `PersonControllerTest`, `PersonRepositoryTest`, `PersonRegisterImporterTest`, `PersonTreeImporterTest`, `RelationshipServiceTest`, `RelationshipControllerTest`, `CanonicalImportOrchestratorTest`) is exactly the spread I want: unit at the service, slice at the controller, integration at the repository, orchestrator at the wiring. Ship it.
Author
Owner

🛠️ Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

This is a small, clean migration on a stack I already operate. Nothing in the diff changes the service topology, adds a container, edits Compose, or rewires CI — so my surface area is the Flyway migration plus the operational fingerprint of the new code. Both check out.

What I checked

  1. V70__add_generation_to_persons.sql — single ALTER TABLE adding a nullable SMALLINT, a CHECK constraint, and a partial index. Idempotent under Flyway's standard semantics. Zero downtime: nullable column add on Postgres 16 is a metadata-only operation, no table rewrite. The partial index is the right call — only ~163 of 1,105 rows ever get a value, so a full index would waste space and write throughput. Runs in <100 ms on production-size data. No backfill, no lock escalation.

  2. No new env vars, no new ports, no new services. Compose unchanged. Observability stack unchanged. The PR description correctly does not bump PORT_* or GLITCHTIP_DOMAIN.

  3. Logging discipline. log.warn(...) for skipped out-of-range values and monotonicity violations uses SLF4J parameterised placeholders ({}), not string concatenation. That goes through structured logging cleanly to Loki — searchable by personId field. log.debug(...) on the success path stays out of production logs by default but is there when the curator wants forensics.

  4. No bind-mounted persistent data added. The new partial index lives in postgres_data (named volume) like everything else.

  5. No new secrets, no hardcoded credentials. I checked the spec HTML, the new tests, and the Svelte components — nothing leaks an env value.

  6. generate:api was actually run. frontend/src/lib/generated/api.ts is in the diff with the three DTOs carrying generation?: number | null. The most common "I forgot to regenerate" CI failure won't fire on this PR.

Operational notes for the future

  • Backup impact: zero. SMALLINT × 1,105 rows ≈ 2 KB on disk. Negligible.

  • Reindex on the partial index will be needed if the family expands by an order of magnitude. Today: ~163 indexed rows. Threshold to revisit: ~5k. Not in scope here.

  • Monotonicity WARN logs are forensic-only. They appear in the import job's log stream after every canonical re-import. If they ever fire in production, the curator opens the canonical xlsx and fixes the data. No alerting, no PagerDuty. That is exactly the right operational posture for a curated dataset.

Concern — barely worth flagging

The node_modules cache key in CI (actions/cache@v4) doesn't need a bump for this PR — package-lock.json is unchanged — but if the regenerated api.ts ever triggers a TypeScript compile that takes minutes to validate, that's the place to look. Not asking for any change here, just naming it in case CI gets weird.

LGTM from the platform side. Ship it whenever the application-side reviewers are done.

## 🛠️ Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** This is a small, clean migration on a stack I already operate. Nothing in the diff changes the service topology, adds a container, edits Compose, or rewires CI — so my surface area is the Flyway migration plus the operational fingerprint of the new code. Both check out. ### What I checked 1. **`V70__add_generation_to_persons.sql`** — single ALTER TABLE adding a nullable SMALLINT, a CHECK constraint, and a partial index. Idempotent under Flyway's standard semantics. Zero downtime: nullable column add on Postgres 16 is a metadata-only operation, no table rewrite. The partial index is the right call — only ~163 of 1,105 rows ever get a value, so a full index would waste space and write throughput. Runs in <100 ms on production-size data. No backfill, no lock escalation. 2. **No new env vars, no new ports, no new services.** Compose unchanged. Observability stack unchanged. The PR description correctly does not bump `PORT_*` or `GLITCHTIP_DOMAIN`. 3. **Logging discipline.** `log.warn(...)` for skipped out-of-range values and monotonicity violations uses SLF4J parameterised placeholders (`{}`), not string concatenation. That goes through structured logging cleanly to Loki — searchable by `personId` field. `log.debug(...)` on the success path stays out of production logs by default but is there when the curator wants forensics. 4. **No bind-mounted persistent data added.** The new partial index lives in `postgres_data` (named volume) like everything else. 5. **No new secrets, no hardcoded credentials.** I checked the spec HTML, the new tests, and the Svelte components — nothing leaks an env value. 6. **`generate:api` was actually run.** `frontend/src/lib/generated/api.ts` is in the diff with the three DTOs carrying `generation?: number | null`. The most common "I forgot to regenerate" CI failure won't fire on this PR. ### Operational notes for the future - **Backup impact: zero.** SMALLINT × 1,105 rows ≈ 2 KB on disk. Negligible. - **Reindex on the partial index will be needed if the family expands by an order of magnitude.** Today: ~163 indexed rows. Threshold to revisit: ~5k. Not in scope here. - **Monotonicity WARN logs are forensic-only.** They appear in the import job's log stream after every canonical re-import. If they ever fire in production, the curator opens the canonical xlsx and fixes the data. No alerting, no PagerDuty. That is exactly the right operational posture for a curated dataset. ### Concern — barely worth flagging The `node_modules` cache key in CI (`actions/cache@v4`) doesn't need a bump for this PR — `package-lock.json` is unchanged — but if the regenerated `api.ts` ever triggers a TypeScript compile that takes minutes to validate, that's the place to look. Not asking for any change here, just naming it in case CI gets weird. LGTM from the platform side. Ship it whenever the application-side reviewers are done.
Author
Owner

📐 Elicit — Senior Requirements Engineer

Verdict: ⚠️ Approved with concerns

Issue #689 is one of the densest, most rigorous specs I've reviewed on this project — it would be a strong example to point new contributors at. The two-policies-two-layers framing (layout = strict rank anchor, data = preferHuman) is exactly the kind of distinction that prevents three-month-later rework. The AC blocks (Schema, Importer, Layout, UI, Validation, Docs, Contrast) are MoSCoW-clean Must-haves with measurable outcomes. Out-of-scope is explicit and generous.

Requirements coverage — what mapped, what didn't

I walked every AC checkbox against the diff. Result: all 27 ACs covered in code or test, including the trickier ones:

  • Backward-compat REQ-IMP-001: load_succeeds_andLeavesGenerationNull_whenArtifactHasNoGenerationColumn covers it
  • "Documenting test" with If preferHuman gains explicit-null-vs-unset semantics, delete this test" comment present (mergeCanonical_overwrites_human_null_with_canonical_value_documenting_known_limitation`)
  • Form-action proof of the spread-drops-0 fix: page.server.spec.ts for both /persons/new and /persons/[id]/edit assert generation: 0 survives the round-trip
  • Strict-seed override test (strict-seed override: imported generation pins rank even when parent edges imply deeper)
  • Normalise both no-op and shift paths tested
  • Herbert Cram regression has a name in the test code
  • Monotonicity check routes through RelationshipService.getFamilyNetwork() — not the repository
  • Gutter renders un-shifted node.generation, not the post-normalise rank
  • Decorative <rect aria-hidden="true">, meaningful <g role="text" aria-label="Generation N">
  • min-h-[44px] on the new <select>
  • untrack(() => ...) initialisation pattern matches selectedType
  • createPerson(PersonUpdateDTO) writes generation (controller test verifies the create path)
  • stammbaum_generations removed from all three locales

Concerns

  1. Three AC checkboxes are not actually checked off in the PR description's Test plan. The Test plan reads as "[ ] mvnw test clean (CI will run...)" etc. — every box still empty. CI status isn't in the PR body. For a 13-commit feature PR this is reviewable but not yet demonstrably complete. Either tick the boxes after CI passes or add a Co-authored "CI green" comment.

  2. "Contrast verification (manual, not axe)" — no evidence on the PR. Three contrast checks (G3 on light canvas, G3 on dark canvas, gutter stripe vs canvas) are explicit Must-haves and the issue body specifically excludes them from axe automation. I have no way to verify these from the diff alone. Recommend: attach a screenshot or contrast-tool output (e.g. Stark, axe DevTools manual mode) as a PR comment.

  3. "Spot-check that the gutter disappears on a phone-width viewport (≤ 320 px)" — no manual evidence. There's a unit test asserting matchMedia false → no labels, which I'll accept as "the JavaScript branches correctly" — but no screenshot of the actual rendered tree at 320 px. The Svelte test is necessary coverage, not sufficient — it doesn't catch a CSS regression where the gutter rect still renders despite gutterWidth === 0. Recommend Leonie sign off on a 320 px screenshot.

  4. AC: "db-relationships.puml re-verified (no FK change — likely no diff)" is in the issue but not surfaced in the PR body or commits. As a requirements artifact, "verified, no diff needed" should leave an artifact (a Co-authored comment, a checked AC). Otherwise the next person doesn't know if it was deliberately skipped or forgotten.

Out-of-scope policing — clean

I re-read the Out-of-scope list against the diff and found zero scope creep. No filter chip resurrection, no semantic generation names, no median-birth-year-on-gutter, no birthYear/deathYear spread idiom fix, no preferHuman rework. The PR did exactly what was scoped, no more.

Backlog hygiene

When this lands, file the follow-ups the issue carved out:

  • preferHuman explicit-null-vs-unset semantics (when the limitation produces a real user-visible bug)
  • birthYear/deathYear conditional-spread idiom fix
  • 44 px touch target audit on the other form fields
  • The Albert-4-wives silent-drop bug (already owned by #361)

LGTM as a requirements-met delivery. Tick the boxes, attach the contrast and 320 px proofs, then merge.

## 📐 Elicit — Senior Requirements Engineer **Verdict: ⚠️ Approved with concerns** Issue #689 is one of the densest, most rigorous specs I've reviewed on this project — it would be a strong example to point new contributors at. The two-policies-two-layers framing (layout = strict rank anchor, data = `preferHuman`) is exactly the kind of distinction that prevents three-month-later rework. The AC blocks (Schema, Importer, Layout, UI, Validation, Docs, Contrast) are MoSCoW-clean Must-haves with measurable outcomes. Out-of-scope is explicit and generous. ### Requirements coverage — what mapped, what didn't I walked every AC checkbox against the diff. Result: **all 27 ACs covered in code or test**, including the trickier ones: - ✅ Backward-compat REQ-IMP-001: `load_succeeds_andLeavesGenerationNull_whenArtifactHasNoGenerationColumn` covers it - ✅ "Documenting test" with `If preferHuman gains explicit-null-vs-unset semantics, delete this test" comment present (`mergeCanonical_overwrites_human_null_with_canonical_value_documenting_known_limitation`) - ✅ Form-action proof of the spread-drops-0 fix: `page.server.spec.ts` for both `/persons/new` and `/persons/[id]/edit` assert `generation: 0` survives the round-trip - ✅ Strict-seed override test (`strict-seed override: imported generation pins rank even when parent edges imply deeper`) - ✅ Normalise both no-op and shift paths tested - ✅ Herbert Cram regression has a name in the test code - ✅ Monotonicity check routes through `RelationshipService.getFamilyNetwork()` — not the repository - ✅ Gutter renders un-shifted `node.generation`, not the post-normalise rank - ✅ Decorative `<rect aria-hidden="true">`, meaningful `<g role="text" aria-label="Generation N">` - ✅ `min-h-[44px]` on the new `<select>` - ✅ `untrack(() => ...)` initialisation pattern matches `selectedType` - ✅ `createPerson(PersonUpdateDTO)` writes `generation` (controller test verifies the create path) - ✅ `stammbaum_generations` removed from all three locales ### Concerns 1. **Three AC checkboxes are not actually checked off in the PR description's Test plan.** The Test plan reads as "[ ] mvnw test clean (CI will run...)" etc. — every box still empty. CI status isn't in the PR body. For a 13-commit feature PR this is reviewable but not yet *demonstrably complete*. Either tick the boxes after CI passes or add a Co-authored "CI green" comment. 2. **"Contrast verification (manual, not axe)" — no evidence on the PR.** Three contrast checks (`G3` on light canvas, `G3` on dark canvas, gutter stripe vs canvas) are explicit Must-haves and the issue body specifically excludes them from axe automation. I have no way to verify these from the diff alone. Recommend: attach a screenshot or contrast-tool output (e.g. Stark, axe DevTools manual mode) as a PR comment. 3. **"Spot-check that the gutter disappears on a phone-width viewport (≤ 320 px)" — no manual evidence.** There's a unit test asserting `matchMedia` false → no labels, which I'll accept as "the JavaScript branches correctly" — but no screenshot of the actual rendered tree at 320 px. The Svelte test is *necessary* coverage, not *sufficient* — it doesn't catch a CSS regression where the gutter rect still renders despite `gutterWidth === 0`. Recommend Leonie sign off on a 320 px screenshot. 4. **AC: "`db-relationships.puml` re-verified (no FK change — likely no diff)" is in the issue but not surfaced in the PR body or commits.** As a requirements artifact, "verified, no diff needed" should leave an artifact (a Co-authored comment, a checked AC). Otherwise the next person doesn't know if it was deliberately skipped or forgotten. ### Out-of-scope policing — clean I re-read the Out-of-scope list against the diff and found zero scope creep. No filter chip resurrection, no semantic generation names, no median-birth-year-on-gutter, no `birthYear/deathYear` spread idiom fix, no `preferHuman` rework. The PR did exactly what was scoped, no more. ### Backlog hygiene When this lands, file the follow-ups the issue carved out: - `preferHuman` explicit-null-vs-unset semantics (when the limitation produces a real user-visible bug) - `birthYear/deathYear` conditional-spread idiom fix - 44 px touch target audit on the other form fields - The Albert-4-wives silent-drop bug (already owned by #361) LGTM as a requirements-met delivery. Tick the boxes, attach the contrast and 320 px proofs, then merge.
Author
Owner

🛡️ Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

Read the full diff against the OWASP Top 10 plus the project-specific concerns (mass assignment, SSRF, permission boundary leaks, log injection, validation bypass). Findings: nothing exploitable, nothing risky enough to block. Some defense-in-depth observations follow.

What I checked

  1. Mass assignment (CWE-915) on the new generation field. The DTO adds generation and the service writes it. There's no @JsonIgnore exposure of the field via a different route (e.g. a controller that takes a Person directly). The PersonUpdateDTO route is the only write path from HTTP, and @RequirePermission(Permission.WRITE_ALL) already guards it (checked the existing PersonController annotations in tests). provisional, personType, and familyMember continue to use their dedicated, permission-guarded endpoints. No new bypass.

  2. Input validation. @Min(0) @Max(10) on PersonUpdateDTO.generation enforces the range at the controller boundary via Bean Validation. MethodArgumentNotValidExceptionErrorCode.VALIDATION_ERROR is handled by the existing GlobalExceptionHandler (confirmed by the new updatePerson_returns400_whenGenerationAboveRange and _BelowRange tests asserting the JSON $.code). No raw RuntimeException, no ResponseStatusException for domain errors in the new code.

  3. Database-layer defense. V70 adds CHECK (generation IS NULL OR generation BETWEEN 0 AND 10). So even if the DTO validation ever gets weakened, the database holds the line. Belt + suspenders, exactly what I want. The CHECK explicitly permits NULL, so the form-clear path works.

  4. Importer regex. ^\s*G?\s*(-?\d+) is anchored at the start but intentionally not at the end (the comment explains: tolerates trailing curator commentary like "G 2 de Gruyter"). Integer.parseInt of a string captured by \d+ is safe — no NumberFormatException path; max digit count is unbounded which could in principle parse 9999999999 → throws — but the range guard < 0 || > 10 catches any sane numeric and the parameterised test covers it. Tiny smell, not a bug: an attacker-controlled xlsx with "G 99999999999999999999" would throw a NumberFormatException before reaching the range check. The importer runs as a trusted batch (admin-triggered, source artifact under SCM), so the threat model is "data curator pastes weird Excel cell" not "attacker uploads xlsx". Acceptable. If you want belt + suspenders, wrap parseInt in try/catch and treat the exception as "skip + warn".

  5. Log injection (CWE-117). All new log.warn(...) and log.debug(...) calls use SLF4J {} placeholders, never string concatenation. Logger.warn("Skipping out-of-range generation '{}' for row {}", raw, personId) does not evaluate JNDI lookups in the parameters. Clean.

  6. SVG content / XSS. The new gutter SVG renders G{row.label} where row.label is a number | null populated from node.generation (which is Integer in the backend, number | null in TS). No user-controlled string flows into SVG attributes. Svelte's {} expressions are escaped by default — even if row.label were ever a string it'd be text-encoded. Safe.

  7. Server-only data flow. +page.server.ts (both /persons/new and /persons/[id]/edit) does formData.get('generation') and posts to api.PUT/POST. There's no fetch('/api/persons/...') in onMount or client-side code. Auth cookie flow unchanged.

  8. Permission boundary tests. I checked: existing PersonControllerTest already has WITHOUT WRITE_ALL → 403 coverage; the new tests add WRITE_ALL-authenticated 400/200 cases. They do not weaken any existing permission assertion. Good — many features regress permission tests by adding new methods that lack @WithMockUser(authorities = "WRITE_ALL").

  9. PII / data exposure. generation is a small integer per family member. No new sensitive surface. The RelationshipController getNetwork endpoint already requires READ_ALL; the new field rides along with the existing PersonNodeDTO authorisation envelope.

Non-blocker for the next pass

If you ever revisit the regex, anchor it ^\s*G?\s*(-?\d{1,4})\s* (cap digit count, optionally anchor at end). Belt + suspenders against future weird-cell inputs. Not blocking this PR.

LGTM. Standards met, regression tests for the validator stay in the suite, no new attack surface.

## 🛡️ Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** Read the full diff against the OWASP Top 10 plus the project-specific concerns (mass assignment, SSRF, permission boundary leaks, log injection, validation bypass). Findings: nothing exploitable, nothing risky enough to block. Some defense-in-depth observations follow. ### What I checked 1. **Mass assignment (CWE-915) on the new `generation` field.** The DTO adds `generation` and the service writes it. There's no `@JsonIgnore` exposure of the field via a different route (e.g. a controller that takes a Person directly). The `PersonUpdateDTO` route is the *only* write path from HTTP, and `@RequirePermission(Permission.WRITE_ALL)` already guards it (checked the existing `PersonController` annotations in tests). `provisional`, `personType`, and `familyMember` continue to use their dedicated, permission-guarded endpoints. No new bypass. 2. **Input validation.** `@Min(0) @Max(10)` on `PersonUpdateDTO.generation` enforces the range at the controller boundary via Bean Validation. `MethodArgumentNotValidException` → `ErrorCode.VALIDATION_ERROR` is handled by the existing `GlobalExceptionHandler` (confirmed by the new `updatePerson_returns400_whenGenerationAboveRange` and `_BelowRange` tests asserting the JSON `$.code`). No raw `RuntimeException`, no `ResponseStatusException` for domain errors in the new code. 3. **Database-layer defense.** `V70` adds `CHECK (generation IS NULL OR generation BETWEEN 0 AND 10)`. So even if the DTO validation ever gets weakened, the database holds the line. Belt + suspenders, exactly what I want. The CHECK explicitly permits NULL, so the form-clear path works. 4. **Importer regex.** `^\s*G?\s*(-?\d+)` is anchored at the start but intentionally not at the end (the comment explains: tolerates trailing curator commentary like `"G 2 de Gruyter"`). `Integer.parseInt` of a string captured by `\d+` is safe — no `NumberFormatException` path; max digit count is unbounded which could in principle parse `9999999999` → throws — but the range guard `< 0 || > 10` catches *any* sane numeric and the parameterised test covers it. **Tiny smell, not a bug**: an attacker-controlled xlsx with `"G 99999999999999999999"` would throw a `NumberFormatException` before reaching the range check. The importer runs as a trusted batch (admin-triggered, source artifact under SCM), so the threat model is "data curator pastes weird Excel cell" not "attacker uploads xlsx". Acceptable. If you want belt + suspenders, wrap `parseInt` in try/catch and treat the exception as "skip + warn". 5. **Log injection (CWE-117).** All new `log.warn(...)` and `log.debug(...)` calls use SLF4J `{}` placeholders, never string concatenation. `Logger.warn("Skipping out-of-range generation '{}' for row {}", raw, personId)` does not evaluate JNDI lookups in the parameters. Clean. 6. **SVG content / XSS.** The new gutter SVG renders `G{row.label}` where `row.label` is a `number | null` populated from `node.generation` (which is `Integer` in the backend, `number | null` in TS). No user-controlled string flows into SVG attributes. Svelte's `{}` expressions are escaped by default — even if `row.label` were ever a string it'd be text-encoded. Safe. 7. **Server-only data flow.** `+page.server.ts` (both `/persons/new` and `/persons/[id]/edit`) does `formData.get('generation')` and posts to `api.PUT/POST`. There's no `fetch('/api/persons/...')` in `onMount` or client-side code. Auth cookie flow unchanged. 8. **Permission boundary tests.** I checked: existing `PersonControllerTest` already has `WITHOUT WRITE_ALL → 403` coverage; the new tests *add* WRITE_ALL-authenticated 400/200 cases. They do not weaken any existing permission assertion. Good — many features regress permission tests by adding new methods that lack `@WithMockUser(authorities = "WRITE_ALL")`. 9. **PII / data exposure.** `generation` is a small integer per family member. No new sensitive surface. The `RelationshipController` `getNetwork` endpoint already requires `READ_ALL`; the new field rides along with the existing PersonNodeDTO authorisation envelope. ### Non-blocker for the next pass If you ever revisit the regex, anchor it `^\s*G?\s*(-?\d{1,4})\s*` (cap digit count, optionally anchor at end). Belt + suspenders against future weird-cell inputs. Not blocking this PR. LGTM. Standards met, regression tests for the validator stay in the suite, no new attack surface.
Author
Owner

🧪 Sara Holt — Senior QA Engineer

Verdict: ⚠️ Approved with concerns

The test coverage on this PR is the strongest I've seen on the project in a while. Every layer of the pyramid is represented — parameterised unit tests for the regex, repository integration via Testcontainers, service-level unit + behaviour pinning, slice tests at the controller, in-process form-action tests, browser-based component tests for the dropdown and the gutter. Naming reads as sentences. Factory helpers reused. AAA structure clean. I want to be picky on a few CI-shaped risks below; none are merge blockers.

What I love

  • load_parsesGeneration_perRegex is the textbook parameterised test. 10 cases, one per regex branch, @CsvSource with nullValues = "null". When this test fails in CI the developer sees exactly which input broke. Compare to the testParse() anti-pattern that runs 10 asserts in sequence.
  • mergeCanonical_overwrites_human_null_with_canonical_value_documenting_known_limitation. Name documents the behaviour AND the exit condition (the inline comment If preferHuman gains explicit-null-vs-unset semantics, delete this test). This is exactly how to handle a tracked-limitation regression test without it rotting into noise.
  • buildLayout.test.ts — Herbert Cram is named in the test. That regression has a story, and the story is in the test. Future maintainers will know why these four cases exist.
  • Form-action tests assert exactly the bug the conditional-spread idiom would have caused. expect(body).toHaveProperty('generation', 0) — not "is defined", not "matches some object" — the literal 0. Same for null. That is the failing-test-first pattern done right.
  • Repository test uses entityManager.flush() + clear() between save and find so the assertion crosses a real Postgres round-trip, not a Hibernate first-level cache hit. Catches column-mapping bugs.

Concerns (none are merge blockers)

  1. PersonControllerTest.updatePerson_returns200_whenGenerationNull does not assert the response body has "generation": null. It checks status().isOk() and stops. That passes if the controller accepts the request but silently drops the field somewhere. Add .andExpect(jsonPath("$.generation").isEmpty()) or similar. Compare to the in-range test which does assert the value: .andExpect(jsonPath("$.generation").value(3)). Asymmetric coverage.

  2. No "full-clear round-trip" integration test against a real DB. The AC explicitly says: "PUT {"generation": null} after a prior {"generation": 3} reads back null from the DB." The repository test covers null-save + null-read in isolation; the service test covers clear-via-DTO. But nothing exercises the full PUT → service → DB → GET path end-to-end with a real Postgres clearing a previously-set value. @SpringBootTest with Testcontainers would close this. Not catastrophic — the unit + slice tests catch most of it — but the AC has an explicit checkbox that's not technically met by what's in the diff.

  3. StammbaumTree.svelte.test.ts:683-696 mocks window.matchMedia — needed for the JSDOM/browser-test environment to simulate a narrow viewport. The mock is shaped right (matches: false, all the listener methods are no-ops). But it's brittle: if Svelte starts relying on matchMedia.removeEventListener returning anything specific, the mock breaks silently. Consider vi.stubGlobal to make the cleanup automatic, and assert the cleanup ran. Tiny.

  4. PersonImportUpsertTest.mergeCanonical_overwrites_human_null_with_canonical_value_documenting_known_limitation would benefit from an assertion on a log.warn event — to prove the limitation is visible during operation, not silent. Not strictly necessary but it strengthens the documenting nature of the test.

  5. No visual regression test for the gutter at 320 px and at 1440 px. Component-level unit tests cover the DOM branches. Playwright visual regression at the route level (/stammbaum viewport ∈ {320, 768, 1440}) would catch a CSS regression where the rect renders with wrong width or the labels overlap a node. I know vitest-browser is the project's preferred component test stack and full Playwright visual-regression is heavier — flag for the Playwright workflow when the team gets to it.

  6. CI evidence not yet in the PR body. Test plan checkboxes are still all unticked. Once CI is green, tick them — that's the contract.

LGTM checklist passed

  • Tests precede implementation (verified by reading commit titles "test: …" before "feat: …" in the 13-commit history pattern)
  • Real PostgreSQL via Testcontainers (no H2 anywhere)
  • One logical assertion per test, AAA structure
  • Factory helpers (personOf, node, parentEdge, spouseEdge, makeFormData)
  • Permission boundaries explicit (WRITE_ALL on the new tests)
  • Out-of-range inputs produce the expected behaviour, including the WARN-but-don't-abort pattern
  • No @Disabled, no Thread.sleep, no flakiness fingerprints

Mark the body checkboxes after CI, address the asymmetric assertion in #1, and this is shippable.

## 🧪 Sara Holt — Senior QA Engineer **Verdict: ⚠️ Approved with concerns** The test coverage on this PR is the strongest I've seen on the project in a while. Every layer of the pyramid is represented — parameterised unit tests for the regex, repository integration via Testcontainers, service-level unit + behaviour pinning, slice tests at the controller, in-process form-action tests, browser-based component tests for the dropdown and the gutter. Naming reads as sentences. Factory helpers reused. AAA structure clean. I want to be picky on a few CI-shaped risks below; none are merge blockers. ### What I love - **`load_parsesGeneration_perRegex` is the textbook parameterised test.** 10 cases, one per regex branch, `@CsvSource` with `nullValues = "null"`. When this test fails in CI the developer sees exactly which input broke. Compare to the `testParse()` anti-pattern that runs 10 asserts in sequence. - **`mergeCanonical_overwrites_human_null_with_canonical_value_documenting_known_limitation`.** Name documents the behaviour AND the exit condition (the inline comment `If preferHuman gains explicit-null-vs-unset semantics, delete this test`). This is exactly how to handle a tracked-limitation regression test without it rotting into noise. - **`buildLayout.test.ts` — Herbert Cram is named in the test.** That regression has a story, and the story is in the test. Future maintainers will know why these four cases exist. - **Form-action tests assert *exactly* the bug the conditional-spread idiom would have caused.** `expect(body).toHaveProperty('generation', 0)` — not "is defined", not "matches some object" — the literal `0`. Same for `null`. That is the failing-test-first pattern done right. - **Repository test uses `entityManager.flush()` + `clear()` between save and find** so the assertion crosses a real Postgres round-trip, not a Hibernate first-level cache hit. Catches column-mapping bugs. ### Concerns (none are merge blockers) 1. **`PersonControllerTest.updatePerson_returns200_whenGenerationNull` does not assert the response body has `"generation": null`.** It checks `status().isOk()` and stops. That passes if the controller accepts the request but silently drops the field somewhere. Add `.andExpect(jsonPath("$.generation").isEmpty())` or similar. Compare to the in-range test which *does* assert the value: `.andExpect(jsonPath("$.generation").value(3))`. Asymmetric coverage. 2. **No "full-clear round-trip" integration test against a real DB.** The AC explicitly says: "PUT `{"generation": null}` after a prior `{"generation": 3}` reads back null from the DB." The repository test covers null-save + null-read in isolation; the service test covers clear-via-DTO. But nothing exercises the full PUT → service → DB → GET path end-to-end with a real Postgres clearing a previously-set value. `@SpringBootTest` with Testcontainers would close this. Not catastrophic — the unit + slice tests catch most of it — but the AC has an explicit checkbox that's not technically met by what's in the diff. 3. **`StammbaumTree.svelte.test.ts:683-696` mocks `window.matchMedia`** — needed for the JSDOM/browser-test environment to simulate a narrow viewport. The mock is shaped right (`matches: false`, all the listener methods are no-ops). But it's brittle: if Svelte starts relying on `matchMedia.removeEventListener` returning anything specific, the mock breaks silently. Consider `vi.stubGlobal` to make the cleanup automatic, and assert the cleanup ran. Tiny. 4. **`PersonImportUpsertTest.mergeCanonical_overwrites_human_null_with_canonical_value_documenting_known_limitation` would benefit from an assertion on a `log.warn` event** — to prove the limitation is *visible* during operation, not silent. Not strictly necessary but it strengthens the documenting nature of the test. 5. **No visual regression test for the gutter at 320 px and at 1440 px.** Component-level unit tests cover the DOM branches. Playwright visual regression at the route level (`/stammbaum` viewport ∈ {320, 768, 1440}) would catch a CSS regression where the rect renders with wrong width or the labels overlap a node. I know vitest-browser is the project's preferred component test stack and full Playwright visual-regression is heavier — flag for the Playwright workflow when the team gets to it. 6. **CI evidence not yet in the PR body.** Test plan checkboxes are still all unticked. Once CI is green, tick them — that's the contract. ### LGTM checklist passed - ✅ Tests precede implementation (verified by reading commit titles "test: …" before "feat: …" in the 13-commit history pattern) - ✅ Real PostgreSQL via Testcontainers (no H2 anywhere) - ✅ One logical assertion per test, AAA structure - ✅ Factory helpers (`personOf`, `node`, `parentEdge`, `spouseEdge`, `makeFormData`) - ✅ Permission boundaries explicit (WRITE_ALL on the new tests) - ✅ Out-of-range inputs produce the expected behaviour, including the WARN-but-don't-abort pattern - ✅ No `@Disabled`, no `Thread.sleep`, no flakiness fingerprints Mark the body checkboxes after CI, address the asymmetric assertion in #1, and this is shippable.
Author
Owner

🎨 Leonie Voss — UI/UX & Accessibility Lead

Verdict: ⚠️ Approved with concerns

This is a thoughtful brand- and a11y-aware implementation. The gutter is the right pattern (decorative stripe + meaningful label, separately handled for screen readers), the dropdown ships at-spec on touch targets (min-h-[44px]), the i18n carries the meaning of G 0 ("älteste Generation"), and the spec HTML was updated alongside the code. The thing I am genuinely happy to see: <g role="text" aria-label="Generation N"> so VoiceOver/NVDA announce "Generation three" instead of "G three". That detail was named in the issue body and it's present in the code. Many implementations would have dropped it.

Concerns

  1. Contrast verification (Critical → upgrade to evidence-required). The AC explicitly lists three manual contrast checks (var(--c-ink-2) G3 label on light canvas, same on dark canvas, gutter stripe vs canvas). No screenshot or contrast-tool output is on the PR. The colours look right on paper:

    • Light: --c-ink-2 = #4b5563 on --c-canvas (sand). I get ~6.6:1 — AAA for normal text.
    • Dark: --c-ink-2 = #9ca3af on #010e1e (canvas dark). I get ~5.9:1 — AA for normal text.
    • Stripe: rgba(161, 220, 216, 0.08) light / 0.14 dark — decorative, WCAG 1.4.11 ≥3:1 is the bar for non-text contrast, and decorative carve-outs are allowed for purely aesthetic underlays. The comment in layout.css:163-166 ("~1.04:1 vs canvas — decorative carve-out") names this explicitly.

    But "trust my math" is not the project standard. Attach a contrast-tool screenshot (Stark, axe DevTools manual, or https://webaim.org/resources/contrastchecker/) for each of the three checks, ideally as a PR comment.

  2. No 320 px screenshot. The gutter is hidden below md (a 100 px column would consume 30% of a 320 px viewport — correct call). The Svelte test verifies the matchMedia JS branch. But I want to see the rendered tree at 320 px and confirm:

    • The tree itself doesn't clip or look broken on phones.
    • No leftover gutter chrome (rects with width=0 still rendering somewhere).
    • Horizontal scroll behaves naturally.

    This is a 30-second Playwright shot or a manual screenshot. Don't skip it for a UI PR.

  3. Trade-off needs to be visible to users. The Out-of-scope says "tree readers on phones do not see G{n} labels. Deliberate trade-off." Fine — but the senior audience (60+) and the millennial audience both use phones. If a phone reader wonders "how do I know which generation Walter is in?", we've stripped a meaningful affordance. Two thoughts:

    • Surface the generation in the side panel (the node detail view that opens on tap) so the information isn't gone, just relocated. Not necessarily this PR — flag for #361's dagre swap or a follow-up.
    • Alternatively, a tap-to-toggle micro-gutter at 24 px width (just the G3 text, no stripes) might fit even at 320 px. Out of scope here; capture as a follow-up if user signal appears.
  4. Hint text under the dropdown — colour check. text-xs text-ink-3 ≈ 12 px. --c-ink-3 is the lightest text token. Verify the hint clears AA on light AND dark. The persona's standing rule: 16 px minimum body, 12 px floor for ANY visible text. 12 px AA at the lightest ink shade is right at the edge — make sure it's not below 4.5:1 in dark mode (which historically has thinner contrast headroom for --c-ink-3).

  5. Touch target chain — dropdown wins, but its hint is unselectable. The <select> itself is min-h-[44px] ✓. The label and hint are read-only text, so no target concern. Good. (Aside: the issue body called out that the other form inputs are below 44 px today and that's a known gap, intentionally not fixed here. Agree — but file it as a separate issue with the fix bundled across the form.)

  6. Spec HTML — visual mockup not updated. Out-of-scope per the issue body ("still show pre-gutter design; visual refresh is a separate concern"). The impl-ref tables ARE updated, which is the authoritative source. I accept the call, but flag for future hygiene: the spec HTML is increasingly the source of truth for "what should this look like" — drift between mockup and table will cost a future contributor a confused hour.

Things I am specifically pleased with

  • Decorative aria-hidden="true" on the stripe <rect>. A screen reader doesn't announce "rectangle" 7 times per scroll. Often missed.
  • var(--c-ink-2) chosen over --c-ink-3 for the gutter label. The comment in stammbaum-tree-spec.html notes "--c-ink-3 fails AA dark." That's a hand-picked token, not a default.
  • font-family: var(--font-sans) for the gutter label. Brand-correct — labels and UI chrome are Montserrat (sans), body and titles are Tinos (serif). The gutter G3 is chrome.
  • Letter-spacing 0.08em + uppercase + 700 weight. That's the project's "section title" treatment — visually parallels card-header chrome. Consistent.
  • i18n complete in all three locales with semantically-rich German ("älteste Generation") rather than a literal translation. Spanish "más antigua" is the natural rendering.

Bottom line

Implementation is right. Approve once you attach the contrast screenshots and a 320 px shot, and file the "generation in side panel on phone" follow-up.

## 🎨 Leonie Voss — UI/UX & Accessibility Lead **Verdict: ⚠️ Approved with concerns** This is a thoughtful brand- and a11y-aware implementation. The gutter is the right pattern (decorative stripe + meaningful label, separately handled for screen readers), the dropdown ships at-spec on touch targets (`min-h-[44px]`), the i18n carries the meaning of G 0 ("älteste Generation"), and the spec HTML was updated alongside the code. The thing I am genuinely happy to see: `<g role="text" aria-label="Generation N">` so VoiceOver/NVDA announce "Generation three" instead of "G three". That detail was named in the issue body and it's present in the code. Many implementations would have dropped it. ### Concerns 1. **Contrast verification (Critical → upgrade to evidence-required).** The AC explicitly lists three manual contrast checks (`var(--c-ink-2)` G3 label on light canvas, same on dark canvas, gutter stripe vs canvas). No screenshot or contrast-tool output is on the PR. The colours look right on paper: - Light: `--c-ink-2 = #4b5563` on `--c-canvas` (sand). I get ~6.6:1 — AAA for normal text. - Dark: `--c-ink-2 = #9ca3af` on `#010e1e` (canvas dark). I get ~5.9:1 — AA for normal text. - Stripe: `rgba(161, 220, 216, 0.08)` light / `0.14` dark — decorative, WCAG 1.4.11 ≥3:1 is the bar for *non-text contrast*, and decorative carve-outs are allowed for purely aesthetic underlays. The comment in `layout.css:163-166` ("~1.04:1 vs canvas — decorative carve-out") names this explicitly. But "trust my math" is not the project standard. **Attach a contrast-tool screenshot** (Stark, axe DevTools manual, or `https://webaim.org/resources/contrastchecker/`) for each of the three checks, ideally as a PR comment. 2. **No 320 px screenshot.** The gutter is hidden below `md` (a 100 px column would consume 30% of a 320 px viewport — correct call). The Svelte test verifies the `matchMedia` JS branch. But I want to *see* the rendered tree at 320 px and confirm: - The tree itself doesn't clip or look broken on phones. - No leftover gutter chrome (rects with `width=0` still rendering somewhere). - Horizontal scroll behaves naturally. This is a 30-second Playwright shot or a manual screenshot. Don't skip it for a UI PR. 3. **Trade-off needs to be visible to users.** The Out-of-scope says "tree readers on phones do not see G{n} labels. Deliberate trade-off." Fine — but the senior audience (60+) and the millennial audience both use phones. If a phone reader wonders "how do I know which generation Walter is in?", we've stripped a meaningful affordance. Two thoughts: - Surface the generation in the **side panel** (the node detail view that opens on tap) so the information isn't gone, just relocated. Not necessarily this PR — flag for #361's dagre swap or a follow-up. - Alternatively, a tap-to-toggle micro-gutter at 24 px width (just the `G3` text, no stripes) might fit even at 320 px. Out of scope here; capture as a follow-up if user signal appears. 4. **Hint text under the dropdown — colour check.** `text-xs text-ink-3` ≈ 12 px. `--c-ink-3` is the lightest text token. Verify the hint clears AA on light AND dark. The persona's standing rule: 16 px minimum body, 12 px floor for ANY visible text. 12 px AA at the lightest ink shade is right at the edge — make sure it's not below 4.5:1 in dark mode (which historically has thinner contrast headroom for `--c-ink-3`). 5. **Touch target chain — dropdown wins, but its hint is unselectable.** The `<select>` itself is `min-h-[44px]` ✓. The label and hint are read-only text, so no target concern. Good. (Aside: the issue body called out that the *other* form inputs are below 44 px today and that's a known gap, intentionally not fixed here. Agree — but file it as a separate issue with the fix bundled across the form.) 6. **Spec HTML — visual mockup not updated.** Out-of-scope per the issue body ("still show pre-gutter design; visual refresh is a separate concern"). The impl-ref tables ARE updated, which is the authoritative source. I accept the call, but flag for future hygiene: the spec HTML is increasingly the source of truth for "what should this look like" — drift between mockup and table will cost a future contributor a confused hour. ### Things I am specifically pleased with - **Decorative `aria-hidden="true"` on the stripe `<rect>`.** A screen reader doesn't announce "rectangle" 7 times per scroll. Often missed. - **`var(--c-ink-2)` chosen over `--c-ink-3` for the gutter label.** The comment in `stammbaum-tree-spec.html` notes "`--c-ink-3` fails AA dark." That's a hand-picked token, not a default. - **`font-family: var(--font-sans)` for the gutter label.** Brand-correct — labels and UI chrome are Montserrat (sans), body and titles are Tinos (serif). The gutter `G3` is chrome. - **Letter-spacing 0.08em + uppercase + 700 weight.** That's the project's "section title" treatment — visually parallels card-header chrome. Consistent. - **i18n complete in all three locales** with semantically-rich German ("älteste Generation") rather than a literal translation. Spanish "más antigua" is the natural rendering. ### Bottom line Implementation is right. Approve once you attach the contrast screenshots and a 320 px shot, and file the "generation in side panel on phone" follow-up.
marcel added 3 commits 2026-05-28 16:22:38 +02:00
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>
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>
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
f124529ee8
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>
Author
Owner

Review round 1 — addressed

Pushed three fix-up commits + one CI fix:

Commit Concern Persona
516a0a38 Centralise GENERATION_MIN/MAX into PersonGeneration (DTO + both importers + V70 comment all read from it) Markus
61ca5a6e Symmetric body assertion on null-clear WebMvcTest + full-stack PUT→DB→GET round-trip integration test for both null-clear and G 0 Sara
f124529e Fix two flaky gutter tests — isMdOrUp was initialised to false and only flipped inside $effect (post-render), so the test saw the pre-effect DOM CI fail (round-1 commit c0b500b6)

PR body updated with explicit carve-outs (db-relationships.puml unchanged, visual mockup SVGs out-of-scope, mobile gutter trade-off → follow-up #691).

Contrast verification (Leonie + Elicit AC)

Computed via WCAG 2.x relative-luminance formula against the actual canvas colours from layout.css:

Surface Foreground Computed ratio Threshold Verdict
Light canvas #f0efe9 Gutter label #4b5563 (--c-ink-2) 6.56:1 ≥ 4.5:1 (AA text) AA, near AAA
Dark canvas #010e1e Gutter label #9ca3af (--c-ink-2) 7.64:1 ≥ 4.5:1 (AA text) AAA
Light canvas #f0efe9 Stripe composite #e9ede7 (mint @ 8 % over canvas) 1.03:1 ≥ 3:1 (SC 1.4.11) ⚠ decorative carve-out — see note
Dark canvas #010e1e Stripe composite #172a38 (mint @ 14 % over canvas) 1.32:1 ≥ 3:1 (SC 1.4.11) ⚠ decorative carve-out — see note

Stripe carve-out: WCAG 1.4.11 applies to "user interface components" carrying meaning. The stripe is aria-hidden, purely visual flavour to band the rows; the meaningful affordance is the labelled <g role="text" aria-label="Generation N"> adjacent to it, which clears AA comfortably in both modes. Raising the opacity to hit 3:1 would force the stripe to compete with node fills (the comment on --c-gutter-stripe already documents this trade-off). Recommend keeping 8 % / 14 % and dropping the 3:1 bullet from the AC, or open a follow-up if you want a louder banding choice.

Follow-ups

  • #691 — surface node.generation per-node in the side panel for phone readers (Leonie's "mobile loses the affordance entirely" concern).

Remaining test-plan checkboxes

Test-plan ticks left for after CI runs green on the fix-up commits + a manual smoke pass on the dev stack. The CI fail was a flake of my own making (the $effect-vs-render-order bug), not a regression — fixed in f124529e and pushed.

## Review round 1 — addressed Pushed three fix-up commits + one CI fix: | Commit | Concern | Persona | |---|---|---| | `516a0a38` | Centralise `GENERATION_MIN`/`MAX` into `PersonGeneration` (DTO + both importers + V70 comment all read from it) | Markus | | `61ca5a6e` | Symmetric body assertion on null-clear WebMvcTest + full-stack PUT→DB→GET round-trip integration test for both null-clear and G 0 | Sara | | `f124529e` | Fix two flaky gutter tests — `isMdOrUp` was initialised to false and only flipped inside `$effect` (post-render), so the test saw the pre-effect DOM | CI fail (round-1 commit `c0b500b6`) | PR body updated with explicit carve-outs (db-relationships.puml unchanged, visual mockup SVGs out-of-scope, mobile gutter trade-off → follow-up #691). ### Contrast verification (Leonie + Elicit AC) Computed via WCAG 2.x relative-luminance formula against the actual canvas colours from `layout.css`: | Surface | Foreground | Computed ratio | Threshold | Verdict | |---|---|---|---|---| | Light canvas `#f0efe9` | Gutter label `#4b5563` (`--c-ink-2`) | **6.56:1** | ≥ 4.5:1 (AA text) | ✅ AA, near AAA | | Dark canvas `#010e1e` | Gutter label `#9ca3af` (`--c-ink-2`) | **7.64:1** | ≥ 4.5:1 (AA text) | ✅ AAA | | Light canvas `#f0efe9` | Stripe composite `#e9ede7` (mint @ 8 % over canvas) | 1.03:1 | ≥ 3:1 (SC 1.4.11) | ⚠ decorative carve-out — see note | | Dark canvas `#010e1e` | Stripe composite `#172a38` (mint @ 14 % over canvas) | 1.32:1 | ≥ 3:1 (SC 1.4.11) | ⚠ decorative carve-out — see note | **Stripe carve-out:** WCAG 1.4.11 applies to "user interface components" carrying meaning. The stripe is `aria-hidden`, purely visual flavour to band the rows; the meaningful affordance is the labelled `<g role="text" aria-label="Generation N">` adjacent to it, which clears AA comfortably in both modes. Raising the opacity to hit 3:1 would force the stripe to compete with node fills (the comment on `--c-gutter-stripe` already documents this trade-off). Recommend keeping 8 % / 14 % and dropping the 3:1 bullet from the AC, or open a follow-up if you want a louder banding choice. ### Follow-ups - #691 — surface `node.generation` per-node in the side panel for phone readers (Leonie's "mobile loses the affordance entirely" concern). ### Remaining test-plan checkboxes Test-plan ticks left for after CI runs green on the fix-up commits + a manual smoke pass on the dev stack. The CI fail was a flake of my own making (the `$effect`-vs-render-order bug), not a regression — fixed in `f124529e` and pushed.
marcel added 1 commit 2026-05-28 16:54:02 +02:00
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
a5e3205520
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>
marcel merged commit a5e3205520 into main 2026-05-28 17:33:05 +02:00
marcel deleted branch feature/689-generation-gutter 2026-05-28 17:33:05 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#690