- frontend/e2e/stammbaum.spec.ts covers four journeys:
1) /briefwechsel still resolves with a 2xx after the nav swap.
2) /stammbaum shows the page heading.
3) /stammbaum renders either the empty state (with the Personenliste
link) or at least one node[role=button] in the SVG.
4) The person edit card surfaces the year-range error when Bis < Von.
- persons/[id]/page.server.spec.ts gains two extra mockResolvedValueOnce
entries per scenario to match the new relationships +
inferred-relationships GETs that the page load now performs.
Refs #358.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- persons/[id]/+page.server.ts loads relationships and
inferred-relationships in the existing parallel fetch.
- New PersonRelationshipsCard renders direct chips (mint) and the
top-5 derived chips (grey) on /persons/{id}, both linked to the
other person's page. Empty state shows
"Noch keine Beziehungen bekannt." in muted serif.
- Card sits in the right column above the document lists.
Refs #358.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- /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>
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>
- 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>
Both desktop and mobile nav rows now point at /stammbaum and read
m.nav_stammbaum(). The /briefwechsel route stays intact — only the
nav anchor changes.
Refs #358.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
Addresses three blockers raised in PR #350 review (Felix, Sara, Tobias):
1. Replace all waitForTimeout(400) calls with waitForListbox() which uses
waitForSelector('[role="listbox"]', { state: 'visible' }) — auto-waits
for the debounce to resolve, faster on fast machines and reliable under CI.
2. Remove all conditional if (hasResults) / if (hasDropdown) wrappers.
Tests now use unconditional expect(dropdown).toBeVisible() assertions so
a missing-data condition causes an explicit failure instead of a silent
green run.
3. Replace waitForSelector('[data-hydrated]') with waitForLoadState('networkidle')
in getDocumentEditUrl — the data-hydrated attribute does not exist in the
app markup and would cause a 30s timeout on every test.
4. Extract page: Page type import from @playwright/test and introduce
waitForListbox(page: Page) helper to avoid repeating the selector pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
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>
Repositioning from top:-8px/right:-8px to top:4px/right:4px ensures the
44px touch target stays fully within the annotation shape. Annotations drawn
near the top or right edge of the PDF page no longer risk the button being
obscured or inaccessible.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Documents the stopPropagation guarantee: clicking the trash button must
not trigger the annotation's onclick (which opens the block detail panel)
while the delete confirm is in progress.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without the guard, a failed DELETE (4xx/5xx) was silently swallowed and
annotationReloadKey was incremented anyway, leaving the annotation visible
and the user with no feedback. Now matches the deleteBlock() pattern
immediately above.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a trash icon button (44×44 px touch target) directly on each annotation shape in transcription mode so users can delete a block without navigating through the sidebar. Includes keyboard support (Delete key), confirm dialog via ConfirmService, prop-chain wiring through DocumentViewer → PdfViewer → AnnotationLayer → AnnotationShape, and orphaned-annotation fallback (calls DELETE /annotations/{id} when no block is linked). Backend security regression test added for deleteBlock 403 on READ_ALL.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes#342. The PersonDangerZone collapsible wrapper is removed; PersonMergePanel
is now rendered directly in the edit page with its own red border (border-red-200),
preserving the {#key person.id} state-reset behaviour and the two-step merge flow.
Fix PersonTypeahead mock to use Svelte 5 functional stub (not Svelte 3/4 $$ internals).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the mobile label is aria-hidden and the desktop button container is
display:none (below sm:), mobile screen reader users had no aria-current
indicator. Added a sr-only span with aria-current="page" that stays in
the AT tree at all breakpoints regardless of CSS display state.
On desktop the active page button also carries aria-current — both
announce the same page information, which is acceptable.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The mobile 'Seite X von Y' span had aria-current='page', which created two
elements announcing the current page on wide screens: the hidden mobile label
and the active desktop button. On sm:+ screens the mobile span is display:none
(removed from AT tree), but on small screens both the span and the desktop
button were redundant.
Replace aria-current with aria-hidden='true' on the mobile label so AT always
relies on the desktop button's aria-current. Updates spec test accordingly and
adds a second assertion in a broader test context (Decision Queue #1).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces position-based key `i` with `entry === null ? 'ellipsis-' + i : entry`
so DOM reconciliation is stable when the window shifts (Decision Queue #2).
The index-based key was masking a duplicate-push bug in pageWindow: when
windowStart === first+1 or windowEnd === last-1, the loop already included that
number, causing Svelte to throw `each_key_duplicate` once stable keys are used.
Fixed the bridge-page conditions to use first+2 / last-2 thresholds so the loop
and the bridge branches never push the same page number.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Renames 'page button buttons' → 'page buttons container' (Decision Queue #3).
Adds 'renders both pages without ellipsis when totalPages is 2' to cover the
boundary between the 1-page (hidden) and full-ellipsis-window cases (Decision Queue #5).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds an ellipsis-style numbered page button row (1 … 4 5 6 … 12) to
Pagination.svelte. Buttons are hidden on mobile (sm: breakpoint) and fall
back to the existing prev/next layout. Active page uses brand-navy
background. Client-side clamping via makeHref(entry - 1) satisfies AC3.
i18n key pagination_page_button added for de/en/es.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ProgressRing used text-accent (#a1dcd8) on a percentage text label —
same WCAG 2.1 AA failure as #341. Switched to text-primary.
Also adds ESLint no-restricted-syntax rule (scoped to *.svelte files) that
blocks future text-accent usage in JavaScript string literals inside Svelte
class expressions. The rule caught both violations at once; both are now fixed.
The rule is scoped to .svelte files so test assertions against 'text-accent'
strings in .spec.ts files are unaffected.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes WCAG 2.1 AA contrast failure (#341): text-accent (#a1dcd8) on light
PDF control bar was 1.52:1 — well below the 4.5:1 AA minimum. text-primary
resolves to #012851 in light mode (14.5:1) and #a1dcd8 in dark mode (9:1) —
both states pass AA in both themes.
Adds PdfControls.svelte.spec.ts with 5 tests covering toggle visibility,
label strings, and the contrast-safe class assertion.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes#344
## What was implemented
### Commit 1 — `feat(nav): add cursor-pointer and tooltip to notification bell`
- Extracted `bellLabel` as `$derived` in `NotificationBell.svelte` — eliminates the duplicated inline ternary and keeps tooltip/label in sync reactively
- Added `title={bellLabel}` to the bell `<button>` — native tooltip mirrors `aria-label` in both zero and non-zero unread states
- Added `cursor-pointer` to the bell button's class list
- Added global `button { cursor: pointer; }` rule in `@layer base` of `layout.css` — prevents future regressions (global scope per Decision Queue)
- Added 3 component tests in `NotificationBell.svelte.spec.ts`: cursor-pointer class present, title equals aria-label when unread=0, title equals aria-label when unread=3
### Commit 2 — `fix(nav): replace hardcoded ThemeToggle title with Paraglide i18n keys`
- Added `theme_toggle_to_light` / `theme_toggle_to_dark` keys to `de/en/es` messages
- Extracted `themeLabel` as `$derived` in `ThemeToggle.svelte` and bound both `aria-label` and `title` to it
- Fixes the pre-existing hardcoded English strings (`'light mode'` / `'dark mode'`) per Decision Queue resolution
Touch target size was descoped per the Decision Queue.
## Decision Queue resolutions (from issue #344)
- **cursor-pointer scope**: global via `@layer base` ✅
- **ThemeToggle scope**: fixed in this issue ✅
- **Touch target**: descoped ✅
## Test results
All 5 `NotificationBell` tests pass.
Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: http://heim-nas:3005/marcel/familienarchiv/pulls/351
validatePersonFields now returns a PersonValidationKey instead of a
hardcoded German string. resolveValidationMessage() translates the key
through Paraglide so English and Spanish locale users no longer see
German error text. Adds validation_last_name_required and
validation_first_name_required to all three message files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes four independent PersonType type declarations and the duplicated
TYPES/PERSON_TYPES arrays. normalizePersonType moves from the edit route
module into the shared lib so page.server.test.ts no longer imports from a
route. Both server actions now use normalizePersonType for personType
extraction instead of an inline type cast.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
radioGroupNav now accepts an onChange callback; PersonTypeSelector passes
select() as the callback so ArrowLeft/Right navigation updates the hidden
input value. aria-live region starts empty and announces only on user
interaction (fixes initial page-load announcement).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New src/lib/person-validation.ts exports validatePersonFields (pure function)
- 8 unit tests covering: valid PERSON, lastName missing/undefined,
firstName missing/undefined for PERSON, non-PERSON types without firstName
- Both edit and new-person server actions now call the shared helper instead
of inline if-chains, making the logic testable and non-duplicated
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tests now import from production code instead of a local copy, giving real
regression protection if the inline logic is changed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces hardcoded brand-navy/brand-sand/white classes with semantic
tokens (bg-primary/text-primary-fg, bg-surface/text-ink, border-line,
ring-focus-ring) so the segmented control adapts correctly in dark mode.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move result count, bulk-edit button, and new-document link into a shared
flex row so they appear on the same line. Adds an edit icon to the
bulk-edit button to visually match the existing plus icon on the add link.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>