Render committed synthetic fixtures through PdfViewer with the REAL
pdf.js loader and assert the canvas is non-blank (sampled dark-pixel
count). The CCITT (G4 fax) fixture exercises the shared jbig2.wasm
decode path — the same module pdf.js uses for JBIG2 — so it transitively
covers the JBIG2 acceptance criterion (the archive sample found zero
true JBIG2 docs and jbig2enc is unavailable to synthesize one). The
JPEG/DCTDecode fixture guards against regressing the natively-decoded
path. Verified the CCITT case goes red when wasmUrl is removed.
Fixtures are hermetic, committed assets (~2-5 KB each), generated with
ImageMagick — never fetched from staging at test time. CI browser mode.
Refs #708
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The error-state download link opened with target="_blank" but no rel,
exposing the opener to reverse tabnavbabbing. Add rel="noopener
noreferrer". Same-origin so low severity, but a one-token fix in a file
this issue already touches.
Refs #708
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The error state showed a hardcoded German string ("Fehler beim Laden
der PDF" / "Direkt öffnen") to all users regardless of locale. Use the
localized doc_render_failed and doc_download_link messages so the
recovery path (message + working download link) is honest in de/en/es.
Refs #708
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
renderCurrentPage swallowed every render rejection with a bare return,
so a decode failure left a blank white viewer with no feedback. Now a
non-cancellation rejection sets a localized doc_render_failed message,
which routes into the existing error UI (message + download link).
Cancellation (page-nav / zoom) still returns silently — no error.
Refs #708
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Localized message shown when a PDF page cannot be rendered, so users
never see a blank canvas or a raw English pdf.js string. de/en/es.
Refs #708
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
getDocument was called with a bare src string, so pdf.js 5.x had no
`wasmUrl` and could not initialise the JBIG2/CCITTFax wasm decoder —
CCITT (G4 fax) scans painted a blank canvas. Pass
{ url, wasmUrl: '/pdfjs-wasm/' }; the directory URL (trailing slash
required) is the single source of truth next to the worker config.
Refs #708
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pdf.js 5.x moved the JBIG2/CCITTFax/JPEG2000 image decoders into
WebAssembly. The wasm lives in node_modules and was never web-served, so
those decoders failed to initialise and CCITT (G4 fax) scans painted
blank in production while rendering fine in dev.
Add vite-plugin-static-copy (devDependency) to copy
node_modules/pdfjs-dist/wasm/* into build/client/pdfjs-wasm/, so the
assets are emitted into the SvelteKit client build and survive the
production Docker image — not just `npm run dev`. Verified that
`node build` serves /pdfjs-wasm/jbig2.wasm with 200 + application/wasm.
Refs #708
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
With the visible "Originaltext" line gone from every view, the
date_original_label message has no remaining references — remove it from
de/en/es. Also drop the now-inaccurate comments in documentDate.ts that
described the raw cell as "preserved separately as the visible secondary
line"; the raw cell now only feeds the SEASON word and is never shown.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The detail drawer's date cell rendered DocumentDate whenever a date OR a
raw cell was present (`{#if documentDate || metaDateRaw}`). For an
undated, raw-only document that meant the verbatim import text leaked
into the view. Tighten the guard to `{#if documentDate}` so such a
document shows "—". The raw prop is still passed through for the SEASON
word on dated documents. Covered by a new test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DocumentDate rendered an "Originaltext: <raw>" secondary line for
UNKNOWN/SEASON/APPROX dates, gated by a showRaw prop. Drop the visible
line, the showRaw prop, the showRawLine derived, and the now-unused
date_original_label message import. The raw prop stays — it still feeds
the SEASON word in formatDocumentDate, which only ever maps a fixed
German season token (never emits raw text), so no XSS surface remains.
Update both DocumentRow call sites to drop the now-gone showRaw={false}
and the comment that justified it. Remove the two DocumentDate tests
that asserted on the deleted DOM sink (the UNKNOWN secondary line and
its XSS-escaping); the DAY/MONTH coverage stays.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Originaltext:" line in WhoWhenSection rendered the verbatim import
cell (metaDateRaw) as static text plus a hidden input that re-submitted
it on every save. Editors mistook it for an editable field. Remove the
visible line, the hidden round-trip input, and the now-unused rawDate
prop (here and at the DocumentEditLayout call site). The backend's
partial update preserves the stored value, so no data is lost.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an endBeforeStart $derived to WhoWhenSection (lexicographic ISO compare,
no Date object) that renders an inline error on the end-date field —
border-red-400, aria-invalid, aria-describedby, and a #end-date-error <p>
inside the existing aria-live region — with a ⚠ glyph so the cue is not
colour-alone (WCAG 1.4.1). Save is not disabled; the server stays the gate.
Wire ErrorCode INVALID_DATE_RANGE through errors.ts getErrorMessage and add
the single key error_invalid_date_range to de/en/es, so the same translated
string is used inline (client) and via getErrorMessage (server fallback).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirror the useTranscriptionBlocks pattern: makeCsrfFetch(options.fetchImpl
?? fetch) wraps both the default and any injected fetch, so CSRF protection
holds regardless of how the hook is constructed — defense-in-depth against a
future caller injecting a bare fetch. Simplifies the CSRF test to assert on
the injected path instead of stubbing global fetch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The OCR trigger POST went through bare `fetch`, so it carried no
X-XSRF-TOKEN header. Spring Security rejected it and the UI showed
"Sitzungsfehler. Bitte laden Sie die Seite neu." (CSRF_TOKEN_MISSING).
Default the job controller's fetchImpl to csrfFetch — matching the
autosave hook — so mutating requests are CSRF-protected while GET
polling passes through unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The desktop AC8 test flaked in CI: it asserted replaceState was never
called after a tap, but the mount-time URL mirror fired late with the
unchanged default view (cx=0&cy=0&z=1), tripping the assertion. Assert on
the rendered viewBox instead — a pure function of the view state — so a
recentre shows as a shifted origin and a desktop tap leaves it identical,
with no dependence on the noisy mirror-effect timing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sara/Elicit noted AC8 was proven only as recentreAbove geometry, never as
wired behaviour. Add route-level tests that mock window.matchMedia: a tap
recentres the canvas (mirror effect re-fires) when the mobile breakpoint
matches, and leaves the view untouched on desktop where the side panel is a
flex sibling that never overlaps the canvas.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The shared parent-pair child loop read group.childIds[i] while iterating
the filtered childCenters, so a child without a position would desync the
id from the centre — and that index now also drives the active-connector
lookup. Ride the id on the mapped {id,x,y} centre so the two never drift;
a positionless child drops out of both together.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sara/Elicit flagged that AC5 was proven only at the isConnectorActive
predicate level. Add render-layer assertions: no connector group carries a
dim opacity when nothing is selected, and selecting Vater dims exactly the
vertical feeding the collateral child Tante. Exercises the shared
parent-pair per-child <g opacity> wiring.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bump DIMMED_OPACITY 0.4 -> 0.45 so dimmed outlines/labels stay legible
against bg-surface in both themes (dark mode dims already-light mint, the
riskier case). Import the constant into StammbaumTree.svelte.test.ts so the
node-opacity assertions track it instead of a hard-coded '0.4'.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Group opacity on the node <g> made the whole node translucent — including
its card fill — so the connector lines drawn beneath a dimmed node showed
through it. Render the card fill at full strength outside the dim group and
move the lineage focus+dim onto an inner content group (outline + labels)
only. The focus ring also leaves the dim group, so a dimmed-but-focused
node keeps a full-strength ring.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 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>
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>
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>
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>
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>