On a touch viewport (below the md breakpoint, where the bottom sheet
overlays the lower part of the canvas), tapping a person now auto-centres
them via recentreAbove with a 0.3 height bias, so the highlighted anchor
lands in the band above the sheet instead of behind it (AC8). On desktop
the side panel is a flex sibling that never covers the tree, so the bias
is 0 and selection does not pan. StammbaumTree's recentre effect takes a
centreBiasFraction prop and the page drives it from a matchMedia flag.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
recentreAbove recentres on a node and lifts it above the viewBox centre
by a fraction of the zoomed viewBox height, measured against the
auto-zoomed height. On a phone this lands the tapped anchor in the band
above the bottom sheet instead of behind it (AC8). A zero bias is exactly
a legible recentre.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
StammbaumTree derives the active set from the raw selectedId rune: the
adjacency index is built once per edge set ($derived on edges) and the
walk re-runs on selection change ($derived.by on selectedId). It passes
`dimmed` to each node and the isConnectorActive predicate to the
connectors. A null highlight (no selection) leaves everything full
strength, so an unselected tree never dims (AC1) and a ?focus deep link
paints already dimmed on load (AC9, selectedId seeded server-side).
Adds StammbaumTree.svelte.test.ts cases for AC1 (no dimming when
unselected), AC2 (bloodline + spouses full, collaterals dim), AC6
(re-select recomputes and clears the previous highlight), and AC7
(close returns the whole tree to full strength).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
StammbaumConnectors gains an isConnectorActive(a, b) predicate prop and
wraps each logical connector in a <g opacity> group. A connector is full
strength only when both joined people are active; otherwise it dims to
DIMMED_OPACITY. The shared parent-pair drop+bar keys on both parents,
while each child vertical keys on both parents AND that child — so the
bar stays lit to a lineage child yet dims to a collateral sibling on the
same row. Defaults to always-active, so no highlight means no dimming.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
StammbaumNode gains an optional `dimmed` prop that sets group-level
opacity (DIMMED_OPACITY) on the node's root <g>, so the box, accent bar,
name, and dates fade together as one unit. A lineage-fade CSS transition
eases the change and is neutralised under prefers-reduced-motion. The
selected-node styling (active fill + mint accent bar) is untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pure, DOM-free traversal over the family graph. Given the relationship
edges and a selected root, highlightLineage returns the active id set
(root + full pedigree upward + full descendant tree downward + every
spouse of those blood people, as active leaves) and a connector
predicate active only when both joined people are active.
The walk is guarded by the accumulating visited set, so cyclic PARENT_OF
data terminates (REQ-STAMMBAUM-04 / AC10). SIBLING_OF and social
relation types are ignored, so collaterals never enter the active set.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The /search path already pins the Boolean-undated->primitive coercion via
search_withoutUndatedParam_forwardsFalseToService; add the symmetric pin for
getDocumentIds so an absent param provably resolves to undated=false on the
record (never NPE). Raised in the #702 review.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Clarify at loadFilteredDates why the density path constructs a SearchFilters:
the two filter records are kept separate (density has no date/undated fields),
so it adapts here to reuse buildSearchSpec. Raised in the #702 review.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the ~29 repeated `new SearchFilters(null, null, null, null, null,
null, null, null, null, false)` literals across the search test suites with
a shared SearchFiltersFixtures.noFilters() factory (and noFilters()
.withUndated(true) for the undated-only case). Tests that pin a specific
field keep their explicit `new SearchFilters(...)` so intent stays visible.
Pure test-ergonomics cleanup raised in the #702 review; no behaviour change.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the long positional filter lists on the document search chain
with the SearchFilters record. searchDocuments now takes
(SearchFilters, DocumentSort, String dir, Pageable) and findIdsForFilter
takes a single SearchFilters; the four private helpers (buildSearchSpec,
runSearch, countUndatedForFilter, isPureTextRelevance) no longer carry a
positional 10-field filter list. The controller builds the record after
its existing tagOp/undated coercions; the density path adapts its
DensityFilters into a SearchFilters at the shared buildSearchSpec call.
The forced-undated count path is preserved via filters.withUndated(true),
so countUndatedForFilter still ignores the user's toggle (#668) while
runSearch honours it. No behaviour change.
Controller binding tests swap their positional any()/eq() matchers for
ArgumentCaptor<SearchFilters>, asserting captured.undated()/.status()/
.sender() — strictly stronger than the previous any()-soup.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Filter-only value object bundling the ten search predicates so the long
positional argument lists on the document search chain can be replaced
with one named record — killing the sender/receiver and from/to swap-bug
class. Mirrors the existing DensityFilters; carries a withUndated copy
accessor for the forced-undated count path. Unused as of this commit.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The OCR status check is now gated behind canWrite (readers do no write-path
work), so the two OCR-status page tests must render as a writer — OCR is a
writer action. Without canWrite the status check never fires and the "OCR
läuft" spinner never mounts. Fixes the CI regression introduced by confining
read-only users to the read view.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Round out the "read-only users can't write anything" boundary: a READ_ALL
principal is forbidden from posting a block comment, replying, and editing a
comment (the prior tests only used a no-authority principal for create).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the hasTranscription existence query out of the shared getDocumentById
into a dedicated getDocumentDetail used solely by GET /api/documents/{id}.
The flag is only consumed by the detail page, so the extra EXISTS query no
longer runs for the many internal getDocumentById callers (e.g. the
Geschichte resolve loop and the dashboard resume path). Behaviour of the
detail endpoint is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI happy path: seed a PDF document with a transcription block as admin, then
as the READ_ALL "reader" open it — assert the "Transkription lesen" control,
the read text, a plain "Transkription" header, and the absence of the
Lesen/Bearbeiten tabs (panel cannot switch to edit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On the document detail page, pass canEdit={canWrite} to the panel header,
guard onModeChange so a reader can never flip to edit, and default panelMode
to 'read' for readers. Thread canAnnotate={canWrite} through DocumentViewer
to PdfViewer so the annotation layer's canDraw (which also gates delete and
resize) is off for readers — they can open and read, but not draw, edit, or
delete. The writer-only OCR status check is also skipped for readers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TranscriptionPanelHeader gains a canEdit prop (default true). Editors keep
the Lesen/Bearbeiten segmented toggle; read-only users get a plain
"Transkription" heading instead of a lone single-option pill, while the
"N Abschnitte" status line stays visible.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
transcription_read_label ("Transkription lesen") for the read-only entry
control and transcription_panel_title ("Transkription") for the plain
header readers see instead of the Lesen/Bearbeiten toggle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirrors the new server-computed boolean on the document detail payload so
the frontend can gate the transcription entry control at first paint.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Read-only users will soon be able to open the transcription read view, so
the write endpoints become the real authorization boundary. Explicitly
assert a READ_ALL-only principal is forbidden from create/update/reorder/
review block writes and annotation create/patch (the prior tests only used
a no-authority principal).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
getDocumentById now populates a transient hasTranscription boolean so the
document detail page can gate the transcription entry control at first
paint (no client store, no full block fetch, no layout shift).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Domain-service wrapper over existsByDocumentId so other domains can ask
"does this document have any transcription blocks?" without reaching into
the repository.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cheap EXISTS query backing a server-side "has a transcription" signal so
read-only users can be offered the read view at first paint.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address review nit: the older getTagTree tests relied on Mockito's default
empty-list return for findSubtreeDocumentCountsPerTag. Stub it explicitly so
the two-query contract is self-documenting.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Characterization tests for AC#8: the merge preview and the delete-impact
warning describe direct-document operations, so they must report the tag's
direct documentCount, never a subtree rollup. Both tests pass a stray
subtreeDocumentCount and assert it does not leak into the preview, so a future
change can't silently desync a destructive-action preview.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TagTreeNodeDTO now requires subtreeDocumentCount, so the admin sidebar test
fixtures (TagTreeNode, TagsListPanel) need the field to type-check. The admin
sidebar still renders the direct documentCount — these fixtures only gain the
new field, no behaviour change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The /themen page (box header, child rows, aria-labels) and the dashboard
ThemenWidget now display subtreeDocumentCount instead of the direct
documentCount, so a topic's number reflects its whole sub-topic tree and
matches what /documents?tag=X actually returns. A parent with 0 direct
documents but documents under its children now shows a non-zero total.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Regenerate the TagTreeNodeDTO type with subtreeDocumentCount and switch
hasAnyDocuments to read it directly — the backend rollup already includes all
descendants, so the recursive children walk is no longer needed. Reader
surfaces now hide a topic only when its whole subtree is empty.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Record that getTagTree returns both documentCount (direct, read by admin
surfaces) and subtreeDocumentCount (rollup, read by the reader surfaces),
matching the corrected getTagTree JavaDoc.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cover AC#1-4 (leaf=direct, distinct overlap counted once, full descendant
depth), REQ-THEMEN-05 (empty subtree absent), REQ-THEMEN-06 (cycle terminates
via the 50-level guard) and AC#7 (rollup equals distinct documents found by the
real tag-search expansion — count↔destination parity). Testcontainers
postgres:16-alpine since the recursive CTE + COUNT(DISTINCT) needs real PG.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add subtreeDocumentCount to TagTreeNodeDTO, populated by a new recursive-CTE
aggregate query that builds a tag closure and counts distinct documents per
ancestor subtree. The direct documentCount is unchanged; getTagTree now maps
both counts onto each node from two aggregate queries (no N+1).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the inline {#if data?.user && data.canWrite} condition into a named
$derived, matching the existing isAdmin / isAuthPage derivations in the
same file. No behaviour change — the 11 layout specs stay green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Hiding the header upload button is UI polish; the real control is endpoint
authz. Add explicit READ_ALL-only 403 boundary tests for POST /api/documents
and POST /api/documents/quick-upload, matching the reader-only convention
already used elsewhere in this suite.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Documents that the gate keys on lack of WRITE_ALL, not on being READ_ALL:
an ANNOTATE_ALL-only user (canWrite=false) must still not see the upload
link. The writer-sees-it contract is already covered by the existing
upload-link tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The header "Hochladen" link was gated only on {#if data?.user}, so a
reader without WRITE_ALL saw it, clicked it, and got bounced by the
server-side redirect in documents/new — confusing friction on the main
read journey. Gate it on data.canWrite (already on the layout data).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Covers getCsrfToken (cookie parsing, URL-decoding, server-side null),
withCsrf (header injection, immutability, no-op when absent),
makeCsrfFetch (method filtering, case-insensitivity, inner-vs-global),
and csrfFetch (regression guard: vi.stubGlobal is honoured at call time,
not bypassed by a module-level captured reference).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous `export const csrfFetch = makeCsrfFetch(fetch)` captured the
global fetch at module evaluation time. Tests that mock fetch via
`vi.stubGlobal('fetch', mockFetch)` set up their stub *after* module import,
so all calls through csrfFetch bypassed the mock — 21 browser tests saw 0
fetch calls.
Changing csrfFetch to a plain function means `fetch` is resolved from the
global scope at each call site, picking up whatever stub is in place at
call time. Production behaviour is identical; test isolation is restored.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces `csrfFetch` (= `makeCsrfFetch(fetch)`) in cookies.ts as a
drop-in fetch replacement that auto-injects X-XSRF-TOKEN on POST/PUT/PATCH/DELETE.
Previously 8 call sites sent mutating requests without the CSRF header —
annotation resize, comment POST/PATCH/DELETE, Geschichte CRUD, Stammbaum
relationship creation, bulk-edit PATCH, and file upload — all would fail
with CSRF_TOKEN_MISSING if the backend's cookie-based protection triggered.
All 14 client-side mutating fetches now use csrfFetch; withCsrf/makeCsrfFetch
remain in the API for injectable-fetch use cases (e.g. useTranscriptionBlocks).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extract the three SVG connector layers (+ the parent-link graph computation)
into StammbaumConnectors.svelte and the node <g> into StammbaumNode.svelte (which
now owns its own focus-ring state). StammbaumTree drops 546→308 lines and is now
an orchestrator: layout, gutter/reduced-motion state, viewBox, gestures, rail,
anchor. Rendered SVG is byte-identical, so the existing browser tests are
unchanged. Verified live: 62 nodes + 58 connector lines render, node-tap selects.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- page.svelte.test.ts mocked $app/navigation with only replaceState, dropping
invalidateAll (imported by StammbaumSidePanel) → the module errored and failed
all 7 tests in the file. Mock now exports invalidateAll + goto too.
- StammbaumTree viewBox 'offsets origin' test hard-coded a wrong unpanned-x; assert
the robust relationship instead (viewBox centre − content centroid == pan).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Rail chip background opaque (was /85) so G{n} labels stay AA-legible over
tree content (Leonie).
- Rail effect: replace the reactKey hack with an inputsFinite guard that both
tracks deps and guards NaN; name the fallback-stack magics; correct the stale
'xMidYMid' comment (the CTM mapping is preserveAspectRatio-agnostic) (Felix/Markus).
- GLOSSARY zoom range 0.25–3.0 → 0.25–10; ADR-027 preserveAspectRatio note
xMidYMid → xMinYMin (Elicit traceability).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The frame-corner anchor + xMidYMid letterboxing left ~290px of empty space
above the first row on desktop. Anchor to the content corner (first row /
leftmost node, small margin) via cornerView, and switch the canvas to
xMinYMin meet so a wide/short tree pins to the top-left instead of centring
vertically. Verified live: gap above row 1 is now ~20px.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
At z=3 a pan of {0,0} centres on the tree midpoint; a fresh visit (no shared
?z) now anchors the viewBox to the tree's top-left corner via topLeftView
(the negative clamp limit), emitted on mount. Shared links still win.
Verified live: lands at cx<0, cy<0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Strengthen the zoom-clamp test to assert z floors at 0.25 in the URL (was a
'does not throw' smoke test) and move the affordance localStorage reset to a
beforeEach so the e2e tests are order-independent (QA review).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a deterministic stubbed-rAF test for animateView's animated path (was only
covering the reduced-motion branch) and assert the server load redirects on 401
and throws on a network 500 (QA review).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Enlarge the centre-on-person, panel-close, and affordance-dismiss icon buttons
to 44x44 hit areas (WCAG 2.5.8, UX review) while keeping the small glyphs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Zoom is normalised to the whole tree, so z=3 still renders a wide tree too
small on a phone. Raise the ceiling to 10 (revises OQ-001); SVG stays crisp at
any zoom so a generous max is harmless.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Capturing the pointer on pointerdown made the browser dispatch the trailing
click at the SVG instead of the node under the finger, so node taps silently
stopped opening the person panel. Capture only once a drag crosses the
threshold; a tap now reaches the node's onclick. Verified live.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the bottom sheet closes, focus returns to the element that was focused
before it opened instead of being dropped to document.body (WCAG 2.4.3,
Architect + UX review).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>