Commit Graph

3373 Commits

Author SHA1 Message Date
Marcel
6753d115f9 fix(db): leave V56 untouched to avoid Flyway checksum drift (#684)
Editing an already-applied migration changes its Flyway checksum and would
fail validateOnMigrate against prod (where V56 is applied). Revert the V56
comment edit; V71 now records that it reverses V56's no-FK choice and points
to ADR-032 as the authoritative record, so the V56 -> V71 trail stays
discoverable without touching the applied migration. (DevOps review, PR #736.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:34:46 +02:00
Marcel
73dd6c80fa docs(adr): record DB-level person-delete integrity decision (ADR-032) (#684)
Capture the reversal of V56's no-FK decision, the DB-layer-integrity
principle, and the cascade-boundary invariant (the cascade never reaches
documents rows). Numbered 032 — 028-031 are already taken on main; the
issue's '028 is next' was written before main moved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:34:46 +02:00
Marcel
9ade36dd3b docs(db): annotate person-delete ON DELETE behaviour in DB diagrams (#684)
Annotate SET NULL on documents.sender_id and CASCADE on
document_receivers.person_id, and add the new
transcription_block_mentioned_persons -> persons person_id FK (CASCADE)
to both db-relationships.puml and db-orm.puml.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:34:46 +02:00
Marcel
378da60ae8 test(mention): lock deleted-person graceful-degradation contract (#684)
Strengthen one renderTranscriptionBody case into the AC-6 contract: a
@DisplayName with an empty mentionedPersons array (the deleted-person case
V71 produces) must render as plain readable text with no <a>, person-mention
class, data-person-id, or href. Guards against a future renderer refactor
silently reintroducing the dead-link-on-deleted-person degradation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:34:46 +02:00
Marcel
6d267f2269 test(person): describe DB-cascade mechanism in delete service-path test (#684)
The deletePerson service-path guard (AC-4) is unchanged behaviourally, but its
comments described the removed reassignSenderToNull/deleteReceiverReferences
chain. Update them to the V71 ON DELETE cascade mechanism.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:34:46 +02:00
Marcel
ff76a3784f refactor(person): simplify mergePersons to lean on V71 cascade (#684)
Drop the explicit deleteReceiverReferences call from mergePersons — the
source's leftover receiver join rows now cascade-drop via V71's ON DELETE
CASCADE on deleteById. Remove the now-unused deleteReceiverReferences
repository method (and its repo test), and add clearAutomatically +
flushAutomatically to the remaining merge native queries so the L1 cache
cannot desync from the bulk updates. Rewrite the merge unit test with
verifyNoMoreInteractions and add an end-to-end merge regression test (AC-7).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:34:46 +02:00
Marcel
534665459f refactor(person): thin deletePerson to lean on V71 DB cascade (#684)
Drop the application-layer sender/receiver detach from deletePerson — the
V71 ON DELETE constraints now enforce it. Remove the now-unused
reassignSenderToNull repository method and rewrite the unit test to assert
only the existence check plus deleteById (verifyNoMoreInteractions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:34:46 +02:00
Marcel
fd792f6d78 feat(person): enforce person-delete integrity at the DB layer (V71) (#684)
Add ON DELETE behaviour to the two V1 FKs into persons (documents.sender_id
-> SET NULL, document_receivers.person_id -> CASCADE) and a real FK with
ON DELETE CASCADE on the transcription_block_mentioned_persons soft reference,
cleaning up pre-existing orphan mention rows first. The cascade stays strictly
at the join/reference layer and never reaches documents rows.

Proven by new Postgres-backed PersonRepositoryTest cascade tests (AC-1/2/3/8
plus the cascade-boundary document-survival guard). Rewrites the now-stale
V56 'no FK' comment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:34:46 +02:00
Marcel
bafbf609eb docs(adr): ADR-032 tag-name resolution tolerates case-collisions (#730)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m16s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m34s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
CI / Unit & Component Tests (push) Successful in 3m17s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 3m36s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m6s
Records the lasting decision behind the #730 fix: exact-case-first
resolution, deterministic lowest-id case-insensitive fallback, and the
explicit refusal of a unique(lower(name)) constraint (collisions are
valid canonical nodes). Previously the rationale lived only in code
comments and the issue body. Raised as a blocker in the PR #733 review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 11:09:10 +02:00
Marcel
2710f2e233 test(tag): close review-flagged gaps in case-collision coverage (#730)
Two adversarial gaps from PR #733 review:

- Unit: exact-case must win even when its id is NOT the lowest, proving
  exact-case short-circuits before the lowest-id tie-break (a naive
  "lowest id across all CI matches" would pick the wrong row).
- Integration: assert findAllByNameIgnoreCase folds the UPPERCASE
  "GLÜCKWÜNSCHE" — the exact string findOrCreate passes — so the umlaut
  proof matches the resolution path under test, not a lowercase probe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 11:07:39 +02:00
Marcel
80f6468d52 refactor(tag): use orElseThrow over Optional.get in findOrCreate (#730)
The lowest-id tie-break stream is guarded non-empty, so .get() never
throws — but the project bans Optional.get(). Switch to .orElseThrow()
for the project idiom. No behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 11:05:45 +02:00
Marcel
a58378e8f0 test(tag): pin case-colliding tag resolution on real Postgres (#730)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m16s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m35s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
Mocked TagServiceTest can't prove the two things that actually broke:
that findAllByNameIgnoreCase folds umlauts the way Postgres LOWER() does,
and that saving a document tagged with a case-colliding tag no longer
throws NonUniqueResultException. Testcontainers postgres:16-alpine:

- updateDocument on a doc tagged with the child "weihnachten" succeeds
  and keeps exactly the child tag (not the parent).
- findOrCreate("GLÜCKWÜNSCHE") resolves the Glückwünsche/glückwünsche
  umlaut pair deterministically (lowest id) without throwing — the
  regression catcher a plain-ASCII pair would miss.
- bulk-edit funnels through resolveTags → findOrCreate, guarding a
  future refactor that bypasses it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 10:53:04 +02:00
Marcel
d000170f52 fix(tag): resolve case-colliding tag names without throwing (#730)
findOrCreate used tagRepository.findByNameIgnoreCase, which returns
Optional<Tag> and threw NonUniqueResultException whenever two tags
collided case-insensitively (a canonical parent and its same-named
lowercase child). Every document carrying such a tag became un-editable:
any save re-resolves the whole tag set by name and blew up with a 500.

Replace the throwing lookup with exact-case-first resolution: findByName
(exact) → findAllByNameIgnoreCase (lowest-id, deterministic, never
throws) → create. Delete findByNameIgnoreCase so the throwing call can't
be reintroduced. Case collisions are valid tree nodes — no migration, no
unique(lower(name)) constraint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 10:49:02 +02:00
Marcel
d1ed9c022f test(stammbaum): fix #718 tab-order test for tidy-tree layout (#724)
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 3m17s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m39s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
CI / Unit & Component Tests (push) Successful in 3m19s
CI / OCR Service Tests (push) Successful in 23s
CI / fail2ban Regex (push) Has been cancelled
CI / Semgrep Security Scan (push) Has been cancelled
CI / Compose Bucket Idempotency (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
nightly / deploy-staging (push) Successful in 1m55s
The #718 keyboard-tab-order test hardcoded the visual order
['Eugenie','Walter','Clara','Hans'] on the assumption that buildLayout
sorts each generation alphabetically. #724 replaced that with the
tidy-tree layout, which orders a couple's run by structural ownership
(earliest birth year, then a deterministic id tie-break) — so Walter
(id …a1) now owns the run and Eugenie renders to his right.

Both PRs were green independently; the stale assertion only surfaced
once #718 and #724 landed together on main. Correct the expected reading
order to ['Walter','Eugenie','Clara','Hans'] and refresh the now-wrong
'alphabetical' comment. The companion self-validating test (DOM order ==
sorted by y,x) already guarded the real property, so only the hardcoded
assertion needed updating.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:00:59 +02:00
Marcel
1e5e8e43e8 refactor(transcribe): extract t-mark + draw-cue policy into tested helpers (#327)
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m33s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 3m42s
CI / fail2ban Regex (push) Successful in 43s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m7s
Review follow-up (Sara, fast-follow): the t no-active-region guard and the
draw-cue arm/disarm rule lived inline in the page with no direct coverage.
Extracted to pure resolveTrainingMark() (no-op when no region; recognition
enrolled flip) and canArmDraw()/shouldDisarmDraw(), each with unit tests
(10 cases total). The page now arms the draw cue only via canArmDraw and
disarms via shouldDisarmDraw, and routes t through resolveTrainingMark.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 17:54:24 +02:00
Marcel
8c198f22be polish(transcribe): review nits — kbd size, focus ring, guard, action doc (#327)
Review follow-up (Leonie, Felix, Markus): bump cheatsheet key caps to text-sm
for the 60+ audience, add a focus-visible ring to the close button, simplify
the draw-hint guard to {#if drawArmed} (the $effect already clears it outside
edit mode), and document why the transcribeShortcuts action ignores its node
and binds to window.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 17:54:24 +02:00
Marcel
6fd05e08d8 test(transcribe): prove Delete fires once via real shape + action (#327)
Review follow-up (Sara): the prior single-owner evidence was two separate
unit facts against an inert DOM stub. This renders a real AnnotationShape,
attaches the live transcribeShortcuts action, focuses the region, and presses
Delete once — asserting deleteCurrentRegion fires exactly once. A genuine
integration guard against re-introducing a double-bind.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 17:54:24 +02:00
Marcel
ab469b744c refactor(transcribe): extract region navigation into a tested pure helper (#327)
Review follow-up (Sara): j/k wrap-around and fresh-entry had no direct
coverage — the logic lived inline in the page where the action spec only
mocks the callbacks. Extracted to a pure stepRegion() with 9 unit tests
(empty list, forward/back, both wraps, fresh-entry null + unknown id,
length-1). Also replaces the inline nested ternary Felix flagged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 17:54:24 +02:00
Marcel
f07527158c fix(transcribe): hide the "?" hint on touch-only devices (#327)
Review follow-up (Requirements Engineer, Leonie) — closes the unmet
acceptance row. The coach card's "press ?" tip rendered unconditionally, so
a touch-only tablet transcriber (no hardware keyboard) was told to press a
key they don't have. The hint is now gated behind a fine-pointer media
query ([@media(pointer:coarse)]:hidden); the cheatsheet itself only opens
via the "?" key, so it already never surfaces without a keyboard. Also bumps
the key cap from 11px to text-xs for the 60+ audience.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 17:54:24 +02:00
Marcel
9f75de0350 fix(transcribe): localise Delete key cap + annotation label, clarify Esc row (#327)
Review follow-up (Leonie, Requirements Engineer): the Delete key cap was a
hardcoded German "Entf" shown to EN/ES users — now driven by key_cap_delete
(Entf/Del/Supr). The annotation read-only aria-label was a hardcoded German
"Block anzeigen" in all locales — now annotation_view_label. Renamed the Esc
row label from "Bereich schließen" to "Panel schließen" so it no longer
collides with "Bereich" (= region) used elsewhere in the cheatsheet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 17:54:24 +02:00
Marcel
8a9fbc6aef test(transcribe): e2e coverage for shortcuts + cheatsheet a11y (#327)
Seeds a two-block document via API (annotations.spec pattern) and drives the
keyboard: ? opens the cheatsheet, Esc closes it then a second Esc closes the
panel (Esc ladder), e toggles read/edit, and j/k walk the regions forward and
back. Adds an axe-core pass over the open dialog asserting no critical
violations and aria-modal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 17:54:24 +02:00
Marcel
0336d07980 feat(transcribe): surface the "?" shortcut tip in the coach card (#327)
Adds a secondary keyboard hint to the existing coach footer row pointing
transcribers at the "?" cheatsheet, with a semantic <kbd>. Cross-references
the shortcuts introduced for the empty-state coach (#320).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 17:54:24 +02:00
Marcel
61256942e1 feat(transcribe): wire keyboard shortcuts into the document panel (#327)
Attaches the transcribeShortcuts action to the document page and wires every
command to existing context setters: j/k walk the sortOrder-sorted regions
and set activeAnnotationId, e toggles read/edit, n arms a draw cue (edit
only), Delete routes to the existing confirm path, ? opens the cheatsheet,
and Esc is now owned solely by the action — the inline onMount Esc listener
is removed (decision B1). Renders ShortcutCheatsheet and a draw-armed hint.

"t" toggles the document-level KURRENT_RECOGNITION training enrollment (the
only training surface that exists; there is no per-region flag yet — see
#321) and no-ops unless a region is active. Also reconciles annotation
Delete: the shape no longer self-handles the key, with onfocus syncing the
active region so the action deletes exactly once.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 17:54:24 +02:00
Marcel
6aaf8ddb9e feat(transcribe): add ShortcutCheatsheet dialog overlay (#327)
Native <dialog aria-modal> cheatsheet: showModal()/close() bridge, close
button focused on open, eight grouped <kbd> rows (nav/edit/utility), an
autosave footer line, and a reduced-motion-guarded fade. Closes on Esc,
backdrop click, and the close button; "?" while open is a no-op. Adds the
shortcut_close_panel i18n key. 8 component tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 17:54:24 +02:00
Marcel
1b9707c6cd feat(transcribe): add transcribeShortcuts keyboard action (#327)
Single-owner window keydown action for the Transcribe panel: j/k region
nav, e mode toggle, n draw (edit only), t training mark, Delete, ? cheat-
sheet, and the Esc precedence ladder (cheatsheet → editable no-op → close
panel). Pure input-to-callback translator with a focus guard that exempts
only "?"; removes its listener on destroy. 20 unit tests cover every key,
the panel/focus guards, the Esc matrix, and teardown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 17:54:24 +02:00
Marcel
8353e71eed feat(transcribe): add i18n keys for shortcut cheatsheet (#327)
Adds de/en/es Paraglide keys for the keyboard-shortcut cheatsheet,
coach hint, draw-armed hint, and the discoverable annotation Delete
aria-label.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 17:54:24 +02:00
Marcel
0693cfddd1 fix(document): enlarge auto-title helper to 14px and assert its localized text (#726)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m35s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m33s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
CI / Unit & Component Tests (push) Failing after 2m31s
CI / OCR Service Tests (push) Successful in 21s
CI / Backend Unit Tests (push) Successful in 3m38s
CI / fail2ban Regex (push) Successful in 44s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m6s
Bumps the title helper from text-xs (12px) to text-sm (14px) for the 60+ audience (FR-005
prefers a larger size than the field hints) and tightens the component test to assert the
actual localized string and the 14px class — addresses Leonie's and Sara's review notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 17:15:46 +02:00
Marcel
f656f7c1ff test(document): close review-flagged coverage gaps for auto-title sync (#726)
- save-time: precision+raw carry-over when the DTO omits them (exercises the shared skip-null
  resolvers), and a RANGE label round-trip (Sara/Elicit)
- factory: a bare Document with a null index builds "" rather than NPE-ing (Felix)
- backfill matcher: negative near-misses — ASCII hyphen vs en dash, missing separator before
  trailing text, year-with-trailing-letters, index followed by text without a separator (Sara)
- backfill integration: tighten the count assertion to exactly 1 on the clean test DB (Sara)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 17:10:50 +02:00
Marcel
7316c51d4a refactor(document): share skip-null date-field resolution between save and projection (#726)
Extract effectivePrecision/effectiveMetaDateEnd/effectiveMetaDateRaw, used by both
applyDatePrecision (the real setters) and projectedState (the title projection), so the two
can no longer drift — addresses review feedback (Markus/Felix/Sara). Writing a stored value
back when the DTO omits a field is a harmless no-op, so behaviour is unchanged (185 existing
DocumentServiceTest cases stay green). Also documents the file-replace "treat as manual" path
inline at the reassignment site.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 17:08:51 +02:00
Marcel
cf457cb96f docs(document): ADR-031 + glossary/c4/api_tests for auto-title sync (#726)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m32s
CI / OCR Service Tests (pull_request) Successful in 26s
CI / Backend Unit Tests (pull_request) Successful in 3m35s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
ADR-031 records the shared document-package title factory, the exact-match save-time
regeneration, and the grammar-heuristic one-time backfill (with the ReDoS / no-version-spam
/ file-replace-is-manual decisions). Adds an "auto-generated title" glossary entry, extends
the document-management c4 diagram with DocumentTitleFactory / DocumentTitleBackfillMatcher
and the backfill flows, and documents POST /api/admin/backfill-titles in Admin-Auth.http as
a one-shot ADMIN call hitting port 8080 directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:44:56 +02:00
Marcel
83e0afb466 feat(document): explain auto-generated title under the edit title field (#726)
Adds the FR-TITLE-005 helper line under the title input in DescriptionSection, shown only
on the single-document edit form via a new showTitleHelp prop (off for the new-document and
bulk-edit forms). It is wired to the input with aria-describedby and uses text-ink-3 (WCAG AA
on bg-surface). New Paraglide key form_helper_title_autogenerated in de/en/es. Adds a
component test for the helper + aria wiring and an end-to-end pass: create an auto-titled doc,
edit its date, and see the title follow on the detail page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:41:52 +02:00
Marcel
12db7b3596 test(document): integration-test title backfill against real Postgres (#726)
Pins backfill behaviour on postgres:16-alpine (H2 unusable — title is NOT NULL): a stale
auto-title is rewritten, the sweep is idempotent (second run touches nothing), prose is
left alone, and the mechanical rename adds no document_versions rows. Permission (401/403)
stays in the faster @WebMvcTest slice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:32:07 +02:00
Marcel
26b45f1c78 feat(document): one-time backfill endpoint for stale auto-titles (#726)
Adds POST /api/admin/backfill-titles (ADMIN-only, synchronous) which rebuilds every
machine-generated title from the row's current state. A grammar heuristic
(DocumentTitleBackfillMatcher) decides overwritability: index matched literally via
startsWith (originalFilename is user-controlled — no regex injection / ReDoS, CWE-1333),
date-label forms derived from the same Locale.GERMAN formatters as the factory so they
cannot drift, prose left untouched, fail-closed on any surprise. Saves via the repository
directly (no recordVersion — follows backfillFileHashes), so the mechanical rename never
version-spams document_versions. Idempotent: a second run rewrites nothing. Emits one
SLF4J-parameterized scanned/updated/skipped line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:29:57 +02:00
Marcel
e6ce00035e feat(document): regenerate auto-title on save when date/location change (#726)
updateDocument now captures the machine title from the persisted state before any
setter runs, and rebuilds it from the new state only when the submitted title still
equals that machine value — an exact comparison that relies on the edit form
round-tripping an untouched title verbatim. A hand-written or freshly-typed title is
kept; a blank submission falls back to the rebuilt auto-title (title is always present);
a file-replaced document no longer matches its import-time title and is treated as
manual. projectedState mirrors the setter asymmetry exactly (date/location overwrite
incl. null-clear; precision/end/raw skip-null from the entity).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:20:46 +02:00
Marcel
b1f77bcfb6 refactor(document): extract title composition into shared DocumentTitleFactory (#726)
Move DocumentTitleFormatter from importing into the document package and
introduce DocumentTitleFactory there as the single source of truth for the
{index} – {dateLabel} – {location} formula. DocumentImporter now consumes the
factory instead of owning the composition; the document package owns the rule,
importing depends on it (not the reverse). No behavioral change — importer
title assertions and the #666 fixture parity test stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:15:00 +02:00
Marcel
4d1a5862d0 docs(stammbaum): ADR-030 tidy-tree layout, supersede ADR-026 packer, refresh glossary (#724)
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m33s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 3m34s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
Review follow-up (Markus/Architect): ADR-026 pre-committed a successor ADR if the
in-house layout stopped converging; its UX stop-trigger (Albert smeared across the
canvas) fired. ADR-030 records the bottom-up tidy-tree, the module split, and the two
maintainer-confirmed decisions (hybrid intra-family, per-bloodline width metric),
superseding ADR-026's block-packer in part (no-dagre + seeded-rank retained). GLOSSARY
replaces the deleted sibling-block / parented / anchor-index vocabulary with the new
family-forest model (unit, tidy tree, structural owner, bloodline, cross-link).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:55:10 +02:00
Marcel
4e8a430dc3 fix(stammbaum): raise cross-link opacity to 0.7 + add dash-render test (#724)
Review follow-ups:
- Leonie/UX: 0.55 navy on the sand canvas was ~2.6:1, under the WCAG 1.4.11 3:1
  non-text floor for senior readers; 0.7 clears it.
- Sara/QA: add a browser test that actually renders a cross-level link and
  asserts the distinct 2 6 dash, and that a non-cross-link parent edge stays
  solid — the cadence was previously only validated via the structural
  crossLinks array, never where it renders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:55:10 +02:00
Marcel
e1d404609e test(stammbaum): cover empty-graph and single-node layouts (#724)
Review follow-up (Sara/QA): the empty graph (fresh /stammbaum before data loads)
exercised the positions.size===0 viewBox fallback and the roots.length===0 early
return, both previously untested. Assert no NaN in the viewBox and MIN dimensions,
plus a single isolated node placed once at rank 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:55:10 +02:00
Marcel
b36addde22 test(stammbaum): cyclic input fails closed — finite layout, one position per node (#724)
An A<->B parent cycle and a founder reaching a re-entrant 3-cycle both return a
finite layout (no frozen $derived) with every node placed exactly once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:55:10 +02:00
Marcel
456e019c3d test(stammbaum): layout is deterministic under input reordering (#724)
Seeded Fisher-Yates permutation of nodes and edges yields byte-identical
positions — confirms every comparator ends in a stable id and nothing relies on
Map iteration order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:55:10 +02:00
Marcel
d3bb08e7ff test(stammbaum): per-bloodline span regression replaces total-width (#724)
Total canvas width is the wrong metric: centring every ancestor makes a 24-root
forest wider overall (an accepted trade-off, pan/zoom handles navigation). The
actual fix is per-bloodline compactness. Assert every contiguous bloodline's
span stays far under the old full-canvas smear (4860px) — today the widest,
Albert de Gruyter's, is ~960px, down from being smeared across the whole canvas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:55:10 +02:00
Marcel
6703347468 fix(stammbaum): index tidy-tree contour by generation level, not tree depth (#724)
The canonical graph is a forest of 24 roots spread across generations 0-4.
Packing every root at tree-depth 0 stacked all of them horizontally even when
they sit at different generations (different y), blowing the canvas out to
~9660px. Indexing the contour by absolute level (the rank buildLayout already
passes as level) lets unrelated roots at different generations share x-columns,
and keeps the no-overlap guarantee per-row. level falls back to tree depth when
omitted, so the abstract tidyTree tests are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:55:10 +02:00
Marcel
1d55901388 test(stammbaum): a bloodline occupies one contiguous band (#724)
No node outside a root's structural subtree may intrude into that bloodline's
[minX, maxX] horizontal span — the contiguity guarantee that fixes the smeared
bloodline symptom.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:55:10 +02:00
Marcel
0cd4882ef4 test(stammbaum): no two nodes overlap on the same row (#724)
O(n^2) sweep over canonical + synthetic: any two nodes sharing a y are at least
NODE_W + COL_GAP apart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:55:10 +02:00
Marcel
a85b22efcf test(stammbaum): every unit centre sits within its child-units span (#724)
Fixture-wide loop over the canonical forest and a synthetic tree: each unit's
run centre is within [min, max] of its child-unit centres — the ancestor
centring invariant, asserted on real data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:55:10 +02:00
Marcel
7627589844 test(stammbaum): named-bug guard — deep-bloodline apex is centred, not stranded left (#724)
A 5-generation single bloodline fanning out wide at the bottom: the apex
great-great-grandparent (and every ancestor in the chain) sits at the centre of
the descendant span, the exact symptom the old per-generation packer produced
in reverse (apex pinned to the left edge).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:55:10 +02:00
Marcel
96a1afe09a feat(stammbaum): render cross-level links with a distinct dash (#724)
StammbaumConnectors takes the layout's crossLinks and draws those parent->child
connectors with a 2 6 dash at reduced opacity — deliberately distinct from the
ended-marriage spouse dash (4 4) and from a solid parent drop. Geometry still
lands on the child top, so the meaning is carried redundantly (WCAG 1.4.1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:55:10 +02:00
Marcel
c1b125bdb2 test(stammbaum): cross-level marriage records a distinct cross-link (#724)
When the two spouses' parents sit at different structural levels, the
structural owner keeps its hierarchy edge and the other parent->spouse edge is
recorded in layout.crossLinks (rendered with a distinct dash). The couple still
sits exactly adjacent in the owner's run and B keeps a real position.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:55:10 +02:00
Marcel
e4a9999f2f test(stammbaum): same-level intra-family bond renders solid, not a cross-link (#724)
Extends the existing adjacency contract: the couple is exactly adjacent in the
run AND, because both parents are roots (same structural level), the displaced
parent edge stays solid — layout.crossLinks is empty for this case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:55:10 +02:00
Marcel
e48c794c12 feat(stammbaum): replace per-generation packer with tidy-tree orchestration (#724)
buildLayout now builds the family forest, packs it bottom-up via tidyTree, and
maps each unit's run x back to per-person positions (x from structure, y from
rank). assignRanks, the generations map, and computeViewBox are reused
unchanged. The unknown-id guard now covers PARENT_OF as well as SPOUSE_OF, and
displaced cross-level edges are exposed as crossLinks for distinct rendering.
The ~210-line block packer (and its block/merge helpers) is gone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:55:10 +02:00