Commit Graph

569 Commits

Author SHA1 Message Date
Marcel
5890bb3abd refactor(person-mention): split fetchHoverData into pure load + cache wrapper
Felix #1: fetchHoverData was doing four things — cache lookup, fetch, JSON
parsing, 404 normalisation. Split into:

  loadHoverData(personId)       — pure fetch + 404→null + non-OK→throw
  getOrFetchHoverData(personId) — five-line cache wrapper around the above

Also document the cache-lifetime trade-off (Markus #4, Elicit OQ-372-02):
the cache is per-mount, so closing and reopening the transcription panel
rebuilds it. That's intentional given the read-only nature of the view —
revisit if stale-card user reports surface.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 08:54:35 +02:00
Marcel
060db69108 refactor(person-mention): extract computeHoverCardPosition into testable util
Three reviewer concerns land here:
- Felix #2: magic numbers 0.7 and 300 belong in named constants
- Sara #6: the position function had 4 branches and 2 thresholds with zero tests
- Leonie FINDING-05: at 320px viewport the flip-left could push the card
  past the right edge — needed a viewport clamp

Move the function to src/lib/utils/hoverCardPosition.ts as a pure
(rect, viewport) → {top, left} mapping, with named exports CARD_WIDTH_PX,
CARD_HEIGHT_PX, CARD_GAP_PX, BOTTOM_BAND_RATIO, RIGHT_FLIP_THRESHOLD_PX.
Add a viewport clamp so left + CARD_WIDTH never exceeds the right edge.

Ten unit tests cover default placement, flip-up (both triggers), flip-left,
flip-right-edge clamp, and scroll offset. TranscriptionReadView passes the
current window viewport in on each call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 08:53:29 +02:00
Marcel
1842e23c81 refactor(person-mention): centralise PERSON_MENTION_SELECTOR constant
Markus flagged that 'a.person-mention' is a magic string repeated four times
in TranscriptionReadView, plus the CSS rule, plus tests. Extract into a single
exported constant so the renderer template, the delegated event handlers,
and the consumer-side selectors all import the same value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 08:51:25 +02:00
Marcel
488d4384a1 refactor(person-mention): brand renderer return types as SafeHtml
Markus, Felix, and Nora independently flagged the {@html …} boundary as a
distributed-knowledge security risk: today renderBody and renderTranscriptionBody
return string, so the next refactor that does {@html block.text} (instead of
{@html renderBlockHtml(block)}) is one typo away from a stored-XSS regression.

Introduce a SafeHtml brand type (string with a phantom __brand) returned by
both renderers and by renderBlockHtml in TranscriptionReadView. Compile-time
enforcement of the escape invariant — costs zero runtime, makes the contract
auditable in one file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 08:48:26 +02:00
Marcel
6a6967d841 refactor(person-mention): hoist LoadState + HoverData into shared types module
Markus flagged the LoadState export from PersonHoverCard.svelte as a
view-vs-orchestrator boundary smell — both files own the same shape, and a
third caller (admin previews, briefwechsel cards) would create a circular
import. Move the types into src/lib/types/personHoverCard.ts so the contract
is module-stable.

Also harden .prettierignore + eslint.config.js so a stray .svelte-kit.old/
backup directory (rotated by SvelteKit during dev) doesn't break the lint
hook — matches the existing .svelte-kit-backup/ convention.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 08:46:42 +02:00
Marcel
1fd38830fe feat(person-mention): TranscriptionReadView wires hover card and click nav
Composes splitByMarkers + renderTranscriptionBody so [unleserlich]
markers render as <em data-marker> siblings of the mention anchor —
neither nested inside the other (B19b).

Hover card lifecycle on each .person-mention anchor:
  mouseenter → set aria-describedby, place card via getBoundingClientRect
               (default below-right; flip up if <200px from bottom or
                mention is in bottom 30% of viewport; flip left if
                <300px from right), fire fetch, mount card with
                skeleton state
  resolved   → swap card to loaded state with person + family
                relationships (PARENT_OF / SPOUSE_OF / SIBLING_OF only)
  404        → degrade: mark anchor with data-person-deleted="true",
                unmount card, suppress future hovers/clicks
  network    → swap card to error state — link still navigates
  mouseleave → drop aria-describedby, unmount card

Per-page SvelteMap<personId, Promise> cache (B15.5) so a sweep across
N mentions of the same person fires the backend once. Click handler
calls goto() so SvelteKit handles routing without a full reload.

Event listeners are attached once per article via a Svelte action
because the anchor HTML is injected via {@html ...} and would not
receive declarative bindings. The eslint-disable comment mirrors
the rationale on CommentMessage.svelte:88-89.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 08:21:35 +02:00
Marcel
c9c395eb59 feat(person-mention): PersonHoverCard with skeleton/error/loaded states
The card has three render states:
  - loading  → 320×180 skeleton with three pulse-animated bars; respects
               prefers-reduced-motion (animation disabled, opacity dimmed)
  - error    → generic load-error message in the body; the footer link
               still navigates (click works regardless of fetch outcome)
  - loaded   → navy header with name, life-date range, and "geb. <alias>";
               family-only relationship chips (PARENT_OF / SPOUSE_OF /
               SIBLING_OF) — non-family types are filtered out;
               notes excerpt capped at 120 chars with ellipsis;
               footer with "Zur Person →" + hover hint

aria-live="polite" on the card root so screen readers announce loaded
content when the fetch resolves; the host's id is the cardId so the
parent anchor can use aria-describedby. The card is hidden via
@media (hover: none) on touch devices — tap navigates directly per
spec.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 08:16:51 +02:00
Marcel
7fc56022ae test(person-mention): assert popup degrades to empty state on fetch reject
Tester #5506 §4: there was a test for fetch returning ok:false but no
test for the broad catch covering thrown rejections (DNS failure,
TypeError: Failed to fetch). Pin that path so a future refactor can't
accidentally bubble the error and crash the editor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:17:45 +02:00
Marcel
e8ba840560 test(person-mention): drive editor specs via fake timers
Tester #5506 §1: 14 tests × 250ms real-timer waits = 3.5s wall-clock,
also racing the 200ms internal debounce by only 50ms — a flake on a
busy CI runner. Switch to vi.useFakeTimers + advanceTimersByTimeAsync;
test execution now 236ms (was 3.08s), determinism guaranteed because
the debounce runs against the fake clock.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:16:55 +02:00
Marcel
09f71a2dce feat(person-mention): empty-state link to create the missing person
Leonie #5507 §5 + ReqEng #5510 §3: when the typeahead returned zero
results, the user was told their search failed and given no path to
recovery. Mirror PersonTypeahead's behaviour: offer a "Neue Person
anlegen →" link that opens /persons/new?name={query} in a new tab so
the transcriber doesn't lose their in-progress block.

Adds person_mention_create_new in de/en/es.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:15:41 +02:00
Marcel
86ad5ca9b3 fix(person-mention): show loading state during debounce + fetch
Leonie #5507 concern 7: on slow networks the popup sat empty for up to
1.5s while the user wondered if anything was happening. Add a loading
flag that flips on as soon as scheduleSearch is asked to query and
back off in the fetch's finally branch. Reuses the existing
comp_typeahead_loading message ("Suche…") so no new i18n keys.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:14:37 +02:00
Marcel
780c682136 fix(person-mention): distinguish keyboard-highlighted row from hover
Leonie #5507 concern 3: hover and aria-selected both used bg-canvas, so
a tablet user sweeping the trackpad couldn't tell where the keyboard
cursor was. Use bg-brand-mint/20 + a 2px ring-inset for the highlighted
row — keeps hover affordance, adds a distinct keyboard-cursor token
that meets WCAG 1.4.11 Non-Text Contrast.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:13:27 +02:00
Marcel
a8a3b7f574 fix(person-mention): textarea focus ring + 44px tap target
Leonie #5507 concerns 4 + 6:
- The textarea had outline-none and no focus indicator — broken for
  keyboard-only navigation now that the typeahead is fully keyboard-driven.
- A rows=1 textarea is ~24px tall (Merriweather + 1.625 line-height),
  below the WCAG 2.2 AA Target Size (44×44) requirement for the focused
  actionable element.

Add focus-visible ring/border in brand-mint and a min-h of 44px with
py-2.5 padding so the empty-state textarea hits the target.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:12:37 +02:00
Marcel
f0bb1c3163 fix(person-mention): close popup on textarea blur
Leonie #5507 concern 1: tabbing away from the editor left the popup
hanging over the next field. Add a 150ms-deferred close on blur — the
delay lets onmousedown on a result fire before the popup unmounts (the
race that the existing onmousedown+e.preventDefault() pattern depends on).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:11:33 +02:00
Marcel
cacbd57752 docs(person-mention): document implicit auth assumption on typeahead fetch
Sina #5505 concern 2: the typeahead silently relies on the Vite-proxy
cookie injection + same-origin policy for auth. Spell that out in the
fetch site so the next reader doesn't have to derive it from the proxy
config.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:10:30 +02:00
Marcel
49db82e1bd refactor(person-mention): move autoresize into PersonMentionEditor
Felix #5: TranscriptionBlock had a `\$effect(() => { void localText; ... })`
hack to re-trigger autoresize on text change, plus a captureTextarea
callback that the parent only used to size a node it didn't own.

The editor owns the textarea — it should also size it. Move the
autoresize \$effect into PersonMentionEditor so the parent only
captures the node when it genuinely needs to read selection bounds
(quote selection still works).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:08:12 +02:00
Marcel
bbde9e8497 refactor(person-mention): rename shadowed m param in TranscriptionBlock bind setter
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m20s
CI / OCR Service Tests (push) Successful in 34s
CI / Backend Unit Tests (push) Failing after 2m55s
CI / Unit & Component Tests (pull_request) Failing after 3m7s
CI / OCR Service Tests (pull_request) Successful in 35s
CI / Backend Unit Tests (pull_request) Failing after 2m58s
Same fix as 79349644 — the bind:mentionedPersons setter parameter `m`
shadowed the imported Paraglide m helper used two lines later in
placeholder={m.transcription_block_placeholder()}. Functionally fine
because the inner scope ends before the outer reference, but a clarity
trap. Renamed to next.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:02:13 +02:00
Marcel
793496440c refactor(person-mention): rename shadowed Paraglide m variable in dedup check
Felix #1: inside selectPerson the .some((m) => ...) parameter shadowed the
imported Paraglide m helper. Functionally fine, but a footgun. Rename to
existing for clarity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 00:50:35 +02:00
Marcel
e3175f493c test(transcription): backfill mentionedPersons on missed read-view fixture
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m18s
CI / OCR Service Tests (push) Successful in 34s
CI / Backend Unit Tests (push) Failing after 3m5s
CI / Unit & Component Tests (pull_request) Failing after 3m10s
CI / OCR Service Tests (pull_request) Successful in 31s
CI / Backend Unit Tests (pull_request) Failing after 3m7s
The b2 fixture in the second describe block had been missed when the
TranscriptionBlockData type added the mentionedPersons field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 00:39:45 +02:00
Marcel
02d3e2ab61 feat(transcription): swap plain textarea for PersonMentionEditor and thread mentionedPersons through autosave
- TranscriptionBlockData now carries mentionedPersons (matches backend
  schema added in PR-A).
- useBlockAutoSave.saveFn signature widens to (blockId, text, mentions);
  pendingMentions is tracked alongside pendingTexts and is preserved on
  failure so a retry resends the in-flight payload (B12).
- TranscriptionBlock.svelte renders <PersonMentionEditor>, exposing the
  textarea node back through a captureTextarea callback so the existing
  quote-selection feature still works.
- saveBlock in routes/documents/[id]/+page.svelte forwards mentions on
  PUT.
- flushOnUnload sends mentions in the keepalive payload too.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 00:32:09 +02:00
Marcel
c4ee2c666b feat(transcription): add PersonMentionEditor with typeahead + keyboard nav
Mirrors MentionEditor for users but searches /api/persons?q=, allows
multi-word queries (delegated to detectPersonMention), displays life
dates next to each result, and uses min-h-[44px] rows for WCAG 2.2 AA
touch targets. Selection writes both the @DisplayName text and a
{personId, displayName} sidecar entry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 00:22:30 +02:00
Marcel
cb93f55396 refactor(stammbaum): StammbaumSidePanel composes AddRelationshipForm — removes inline form duplication
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m6s
CI / OCR Service Tests (pull_request) Successful in 32s
CI / Backend Unit Tests (pull_request) Failing after 2m54s
CI / Unit & Component Tests (push) Failing after 3m2s
CI / OCR Service Tests (push) Successful in 30s
CI / Backend Unit Tests (push) Has been cancelled
Replaces the 86-line duplicated inline add-relationship form with
<AddRelationshipForm onSubmit={handleAddRelationship}>. The {#key node.id}
wrapper resets the form's open state when the selected tree node changes.
Year inputs now have <label> elements (WCAG 1.3.1) via the shared component.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
3cfaae06da feat(stammbaum): AddRelationshipForm accepts onSubmit callback prop for fetch-based submission
When onSubmit is provided the form has no server action and calls the
callback with typed RelFormData instead. Uses a shared {#snippet} for
the form body so the two submission paths share one template.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
10b1bab57b fix(stammbaum): state-aware aria-label on family-member toggle — WCAG accessible name
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
5817a79151 test(stammbaum): year-range validation test for AddRelationshipForm — toYear before fromYear shows alert
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
3b430828b7 test(stammbaum): component tests for StammbaumCard — heading, empty state, toggle, error display
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
f8aa8c6574 test(stammbaum): component tests for StammbaumSidePanel — displayName, empty state, loading indicator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
ce005622f2 fix(stammbaum): i18n inline add-form in StammbaumSidePanel — replace 5 hardcoded German strings with m.relation_form_*() keys
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
0e9fa157e5 fix(stammbaum): add × dismiss button with aria-label to StammbaumSidePanel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
fa1dfbc99d fix(stammbaum): guard inferred-relationship badge to single-receiver documents only
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
43fb51305e fix(stammbaum): i18n AddRelationshipForm — wire Paraglide for type/year labels and optgroup captions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
93f4a00032 fix(stammbaum): SVG node font 14→16px and reliable keyboard focus ring
CSS box-shadow rings (focus-visible:ring-*) are invisible inside SVG.
Replace with a conditional <rect> drawn at -3px offset that renders in
all browsers. Name font-size bumped from 14 to 16px for the 60+
transcriber audience (WCAG readability, Leonie medium concerns).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
499d0a3ca8 fix(stammbaum): derived relationship names link to person page in StammbaumCard
The <span> in the derived-relationships list is replaced with <a href>
so keyboard and pointer users can navigate directly from the edit card,
consistent with PersonRelationshipsCard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
bd3feda182 fix(stammbaum): WCAG 2.2 SC 2.5.8 — delete button 32px → 44px in RelationshipChip
h-8 w-8 (32px) replaced with h-11 w-11 (44px) to meet the minimum
touch target for the 60+ transcriber audience. Test added to prevent
regression.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
f2127e2814 fix(stammbaum): i18n the StammbaumCard heading (de/en/es)
Hardcoded 'Stammbaum & Beziehungen' heading replaced with
m.stammbaum_relationships_heading(); new key added to all
three message files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
fcfae8fb78 refactor(stammbaum): use shared chipLabel/otherName from relationshipLabels in both components
Addresses @felix blocker: removes the verbatim duplicate switch+2-line helper
from StammbaumCard.svelte and StammbaumSidePanel.svelte; both now import from
the shared $lib/relationshipLabels helper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
48649e67f9 refactor(stammbaum): extract RelationshipChip and AddRelationshipForm
Split StammbaumCard from 366 to 196 lines by extracting:
- RelationshipChip.svelte — single relationship list item with optional delete
- AddRelationshipForm.svelte — self-contained add-relationship form with open/close state

Both components have browser-mode spec tests covering rendering and interaction.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
1d14c32c23 fix(stammbaum): WCAG min font-size and 44px touch targets
Raise chip labels from 10px to 12px (text-xs) in StammbaumCard,
StammbaumSidePanel and StammbaumTree SVG text. Widen zoom buttons
from 32px to 44px for senior-audience touch targets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
d27fed3c35 fix(stammbaum): i18n for year-range labels in StammbaumCard
Replaces hardcoded German strings "ab {from}" / "bis {to}" in yearRange()
with parameterized Paraglide keys relation_year_from / relation_year_to,
added to all three message files (de/en/es).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
cd26057ea5 fix(stammbaum): iterative generation + spouse-adjacent block layout
Two distinct bugs surfaced once a 3-generation tree was loaded
(Walter+Eugenie → Hans+Clara, Hans married to Hilde with child Lili):

1. Generation BFS was non-iterative. Hilde was visited as a "root"
   first, assigning Lili = gen 1, then Hilde was pulled to gen 1 to
   match her spouse Hans — but Lili's depth was never recomputed,
   leaving her on the same row as her parents. Replaced the BFS with
   an iterative longest-path assignment that re-runs (max parent gen
   + 1) and the spouse-shared-row rule together until stable.

2. No spouse adjacency. Hilde (no parents in the graph) ended up in
   her own block on the far left, with Hans + Clara to her right and
   the spouse line drawn straight across Clara's box. Replaced the
   per-parent-set grouping with a block model:
     - sibling-blocks group children of the same parent set
     - loose spouses attach on the outer edge of their partner's block
     - dual-loose spouse pairs merge into one 2-person block
     - each block is centred so its parented members' average sits
       exactly under the parent midpoint, keeping all connectors at 90°

Adds a regression test for the full Walter/Eugenie/Hans/Clara/Hilde/
Lili scenario (Lili in a deeper row, Hans+Hilde adjacent, no slanted
segments) and rewrites the viewBox tests to be position-agnostic via
a rect-centroid helper that reads the per-node `<g transform>`.

Tracked the eventual move to dagre (multi-marriage / cross-cousin /
~50+ nodes) in #361.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
ccbcbca0e8 feat(stammbaum): inline add-relationship form in side panel
Implements the inline-edit affordance from
docs/specs/stammbaum-tree-spec.html (section 3): a low-opacity
"+ Beziehung hinzufügen" button below the direct relationships list
expands into a compact form (type select, person typeahead,
optional Von/Bis Jahr inputs, Abbrechen + Speichern). On save the
form POSTs to /api/persons/{id}/relationships, reloads the panel's
own data, and calls invalidateAll() so the tree picks up the new
edge without a hard refresh.

The panel takes a new canWrite prop, plumbed through from the
+layout.server.ts data already exposed on page.data.

Also pins the /stammbaum canvas to the viewport (-my-6 cancels
<main>'s py-6, h-[calc(100dvh-4.25rem)] subtracts the navbar) so
the page no longer overflows below the fold.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
c40cc05f68 feat(stammbaum): tree visual polish + parent-midpoint layout
Aligns the SVG tree with docs/specs/stammbaum-tree-spec.html:

- Node outline: var(--c-primary) at stroke-width=1.5 (was the much
  paler --c-line at 1) and selected text uses var(--c-primary-fg)
  so it remains readable on the dark/light primary fill
- Spouse line and parent-child line now share the same stroke style;
  spouse keeps the midpoint dot (radius bumped to 4.5 per spec)
- When two parents are connected by SPOUSE_OF, draw a single shared
  parent-pair → child line from the spouse midpoint instead of two
  diverging lines
- ViewBox: enforces a 1200×800 minimum and centers the content so a
  single node no longer scales up to fill the whole canvas in the
  top-left
- Children are positioned at the average of their parents' x and
  packed left-to-right per row, keeping connectors close to vertical

Adds component tests for the centring, the shared parent-pair link
(verified vertical), and the fallback to two lines when parents are
not spouses.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
a021355072 feat(documents): inline relationship pills next to person names
Replaces the standalone "Beziehung" badge at the bottom of the
metadata drawer's Personen column with small inline pills attached
to each personCard — sender gets labelFromA, the single receiver
gets labelFromB. Matches docs/specs/stammbaum-doc-badge-spec.html.

Drops the now-unused RelationshipBadge component.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
242e10179d feat(stammbaum): /stammbaum page — SVG tree + side panel + empty state
- /stammbaum/+page.server.ts loads GET /api/network (already filtered
  to family members on the backend) and returns nodes + edges.
- +page.svelte holds the page shell, manages selectedId (with
  ?focus={id} deep-link support) and zoom state, renders the empty
  state when nodes.length === 0 (icon + heading + body + link to
  /persons), or the tree + side panel otherwise.
- StammbaumTree.svelte: BFS-based generation assignment from roots,
  spouses promoted to the deeper generation so couples sit on the same
  row, alphabetical sort within row, simple grid layout. SVG nodes are
  role="button" + aria-label="{name}, {birth}–{death}" +
  aria-expanded={selected}, with click + Enter/Space activation. Solid
  parent→child connectors; mint spouse line with midpoint circle, dashed
  if SPOUSE_OF.toYear is set (former spouse). Zoom maps to viewBox.
- StammbaumSidePanel.svelte: lazily loads
  /api/persons/{id}/relationships and /inferred-relationships when the
  selection changes; shows direct chips (mint), top-5 derived chips
  (grey), and a "Zur Personenseite →" link. Escape closes the panel.

Refs #358.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
aaf885cafd feat(stammbaum): person edit Stammbaum & Beziehungen card
New StammbaumCard rendered below the Namensverlauf card on
/persons/{id}/edit:
- Header with "Als Familienmitglied" toggle (form action
  toggleFamilyMember → PATCH /api/persons/{id}/family-member).
- "Erscheint im Stammbaum" banner with deep-link to
  /stammbaum?focus={id} when familyMember is true.
- Direct relationships list grouped by type, then year. Chip text is
  direction-aware: storage subject reads "Elternteil von", storage
  object reads "Kind von" (new relation_child_of i18n key in all 3
  locales). Symmetric and non-family types use their own keys.
- + Beziehung hinzufügen reveals an inline form with type select
  (grouped Familie / Sozial), a PersonTypeahead with the new
  excludePersonId prop (self-rel prevention, Elicit blocker 1), and
  Von / Bis year fields.
- Year validation lives client-side via $derived: empty/empty is OK,
  Bis < Von shows a red text-red-700 error wired with aria-describedby
  and disables submit (Sara blocker 3).
- Self-rel inline error mirrors the typeahead exclusion in case the
  user submits the personId regardless.
- Abgeleitete Beziehungen section (top 5) collapsed by default.

+page.server.ts loads relationships + inferred relationships in the
existing parallel fetch and adds three actions: toggleFamilyMember,
addRelationship (with year-range guard), deleteRelationship.

Refs #358.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
b658a13247 feat(stammbaum): show inferred relationship in the document drawer
- New presentational RelationshipBadge component (labelFromA → arrow →
  labelFromB) wired into DocumentMetadataDrawer's Personen column,
  rendered after the receivers block when both endpoints are family
  members.
- DocumentTopBar gains an optional inferredRelationship prop and
  passes it through.
- documents/[id]/+page.server.ts loads the badge: only when sender is
  a family member, exactly one receiver, and that receiver is also a
  family member; 404 (no path) → null.
- relationshipLabels.ts maps the backend label keys (parent/child/...)
  to localised strings, so the server load returns badge-ready strings.

Refs #358.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
fc46704144 chore(stammbaum): regenerate TS API types for relationship endpoints
openapi-typescript pulled the Stammbaum schemas: Person now has
familyMember (required), plus PersonNodeDTO, NetworkDTO, RelationshipDTO,
InferredRelationshipDTO, InferredRelationshipWithPersonDTO,
CreateRelationshipRequest, FamilyMemberPatchDTO. Routes:
/api/network, /api/persons/{id}/relationships,
/api/persons/{id}/inferred-relationships,
/api/persons/{aId}/relationship-to/{bId}, and the family-member PATCH.

Test fixtures in PersonMultiSelect, briefwechsel page, and DocumentList
specs gained familyMember: false where they otherwise typed Person
end-to-end. Pre-existing "missing lastName/personType" fixture errors
in DocumentRow.spec are out of scope.

Refs #358.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
f6cf2e0e42 feat(transcription): add "Alle als fertig markieren" bulk action (#345) (#352)
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m15s
CI / OCR Service Tests (push) Successful in 51s
CI / Backend Unit Tests (push) Failing after 3m13s
## Summary

Implements the bulk "Alle als fertig markieren" action for the transcription panel requested in #345.

### Backend

- Added `PUT /api/documents/{documentId}/transcription-blocks/review-all` endpoint to `TranscriptionBlockController`, guarded with `@RequirePermission(Permission.WRITE_ALL)`
- Added `markAllBlocksReviewed(UUID documentId, UUID userId)` to `TranscriptionService` — `@Transactional`, single DB round-trip via `blockRepository.saveAll()`, emits one `BLOCK_REVIEWED` audit event per previously-unreviewed block
- Returns full updated block list (same shape as `listBlocks`) for a clean frontend update pass
- 5 new `TranscriptionServiceTest` unit tests (idempotency, audit events, empty document)
- 5 new `TranscriptionBlockControllerTest` `@WebMvcTest` tests (401, 403, 200 happy path, 200 empty, 401 user not found)
- All 68 backend tests pass

### Frontend

- Added `onMarkAllReviewed?: () => Promise<void>` prop to `TranscriptionEditView` (optional, consistent with `onTriggerOcr` pattern)
- Button placed in sticky progress header, right-aligned next to `reviewedCount / totalCount geprüft`
- Button is **disabled** (not hidden) when all blocks are already reviewed — `title="Alle Blöcke sind bereits als fertig markiert"` (Decision 1)
- Loading spinner replaces checkmark icon during operation — always shown (Decision 4, no threshold)
- Handler `markAllReviewed()` added to `documents/[id]/+page.svelte`, wired as `onMarkAllReviewed`
- 5 new `TranscriptionEditView.svelte.spec.ts` Vitest Browser component tests; all 25 tests pass

### Decisions applied

| # | Question | Choice |
|---|---|---|
| 1 | Button when all reviewed | **Disabled** with `title` tooltip |
| 2 | Audit log | **N individual BLOCK_REVIEWED events** (one per unreviewed block) |
| 3 | Atomicity | **All-or-nothing** via `@Transactional` |
| 4 | Loading indicator | **Always show** during operation |

Closes #345

Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: http://heim-nas:3005/marcel/familienarchiv/pulls/352
2026-04-28 08:34:26 +02:00
Marcel
a9080e9dab test(persons): add ArrowDown forward-wrap unit test for keyboard navigation
Adds the missing 'ArrowDown from last wraps to first option' test to
close the asymmetric coverage gap noted by Sara (QA) in the review of
PR #350. The ArrowUp backward-wrap test already existed; this test
verifies the % modulo wrap works in the forward direction too.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 09:01:44 +02:00
Marcel
e8a1cc82ff fix(persons): fix PersonTypeahead dropdown clipping with fixed positioning
The dropdown was clipped by parent containers using overflow, transform,
or stacking context via shadow-sm + z-index combinations. Adopts the same
fixed-position strategy as PersonMultiSelect: binds to the input element,
computes position via getBoundingClientRect(), and registers svelte:window
scroll/resize listeners to keep it current.

Also adds full ARIA combobox pattern (role=combobox, aria-expanded,
aria-haspopup, aria-controls, aria-activedescendant) and keyboard
navigation (ArrowDown/Up, Enter, Escape) matching TagInput's reference
implementation.

Removes the now-dead z-30/z-10 z-index workarounds from ConversationFilterBar.

Closes #343

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 09:01:44 +02:00