Commit Graph

1147 Commits

Author SHA1 Message Date
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
Marcel
add619d81d feat(stammbaum): order siblings/branches by birthYear NULLS LAST, displayName, id (#724)
Net-new ordering coverage: roots and every unit's children sort by birthYear
ASC (undated last), then displayName, then stable id — so horizontal x never
depends on Map iteration order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:55:10 +02:00
Marcel
a46c3b416b feat(stammbaum): buildFamilyForest with loose-spouse absorption + multi-spouse runs (#724)
Assigns every person to one unit: a primary, or a spouse absorbed into the
primary's run (marriage-year order, #361 preserved). Wires the parent/child
hierarchy from each primary's structural-owner parent and records displaced
parent edges as cross-links (classified same-level vs cross-level for later
distinct rendering). Unknown-id guard covers PARENT_OF and SPOUSE_OF.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:55:10 +02:00
Marcel
7e8b90c8ee feat(stammbaum): add familyForest.pickStructuralOwner (#724)
Structural-owner rule for couples: earlier birth year wins, missing year sorts
last, ties break on stable id. The single definition reused by the cross-link,
cycle and intra-family paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:55:10 +02:00
Marcel
fc5c837d2c test(stammbaum): tidyTree centres a wide couple run and clears siblings (#724)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:55:10 +02:00
Marcel
4f874bf4e9 test(stammbaum): tidyTree packs multiple roots left-to-right (#724)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:55:10 +02:00
Marcel
28997fc391 test(stammbaum): tidyTree nests deep and shallow siblings without overlap (#724)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:55:10 +02:00
Marcel
003bc9b8cb test(stammbaum): tidyTree centres a parent over its two children (#724)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:55:10 +02:00
Marcel
485e13cfea feat(stammbaum): add tidyTree contour packer with leaf base case (#724)
New domain-agnostic bottom-up tidy-tree module (Reingold-Tilford contour pack)
operating on abstract { id, width, children } nodes — zero generated-API
imports. First rung of the TDD ladder: a single leaf lays out at x=0. The full
contour/centring machinery is in place; subsequent commits add tests that
exercise it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:55:10 +02:00
Marcel
439a386a37 test(stammbaum): add makeNode factory for birth-year ordering tests (#724)
The existing node() factory never sets birthYear, but the new sibling/branch
comparator (birthYear ASC NULLS LAST) needs it. Add makeNode(id, name,
{birthYear, generation}) alongside it; unblocks every ordering test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:55:10 +02:00
Marcel
23006a6562 test(transcription): assert 44px target classes, not rendered px (#722)
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m14s
CI / OCR Service Tests (push) Successful in 21s
CI / Backend Unit Tests (push) Successful in 3m39s
CI / fail2ban Regex (push) Successful in 43s
CI / Semgrep Security Scan (push) Successful in 21s
CI / Compose Bucket Idempotency (push) Successful in 1m4s
The component-test browser env (src/test-setup.ts) loads no Tailwind
stylesheet, so the footer buttons' min-h/min-w-[44px] classes have no
layout effect there and the elements collapse to their 16px icon —
making the getBoundingClientRect size assertions fail in CI.

Assert the sizing utility classes instead; they are the exact mechanism
that produces the WCAG 2.2 §2.5.8 target size in the real app. The
compiled pixel size remains covered by the full-app e2e.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:28:17 +02:00
Marcel
c35f51d209 test(transcription): harden annotation-delete specs and e2e (#722)
- Fix a stale test title that still claimed a delete button is visible.
- Strengthen the two "never renders a delete button" contract tests
  (AnnotationShape + AnnotationLayer specs) to assert the annotation
  element has zero descendant <button> elements, not just the absence of
  the removed testid (a near-tautology now that the testid is gone).
- Harden the e2e delete test: guard countBefore > 0 so a missing seed
  fails clearly instead of asserting toHaveCount(-1), and capture the
  deleted annotation's testid to assert that specific element is gone
  (identity check) alongside the count drop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:28:17 +02:00
Marcel
5297c70453 fix(transcription): enlarge panel block action buttons to 44px touch target (#722)
The panel footer's delete and review-toggle controls were icon-only ~16px
hit areas. After #722 removed the on-canvas delete button, the panel delete
button became the only touch-reachable delete path, so it must meet the WCAG
2.2 §2.5.8 minimum target size (44×44px). Give both icon-only footer actions
a >=44px inline-flex hit area with negative margins so the row layout and the
visible icon size are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:28:17 +02:00
Marcel
ad820955fd fix(transcription): remove annotation canvas delete button that obscured text (#722)
The per-annotation delete button (a 44px circular control pinned to the
box's top-right) overlapped the box below and obscured the underlying
document text. It was redundant: every user-drawn annotation has a
transcription block, and the right-hand panel already offers a
non-overlapping delete per block that cascades to the annotation.

Remove the visible button and its `deleteVisible` derived. Keep the
keyboard Delete shortcut (and its `showDelete`/`onDeleteRequest`/
`deleteAnnotation` wiring) — it obscures nothing and remains a
power-user path and the only cleanup route for orphan annotations.

Tests: replace the button-render/click specs with contract tests
asserting no delete button ever renders; repoint the e2e delete flow
to the keyboard shortcut + confirm dialog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:28:17 +02:00
Marcel
27b6d58632 test(notification): make setNotifications authoritative in bell a11y tests
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m13s
CI / OCR Service Tests (push) Successful in 23s
CI / Backend Unit Tests (push) Successful in 3m37s
CI / fail2ban Regex (push) Successful in 45s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m7s
nightly / deploy-staging (push) Successful in 2m13s
CI showed the single/many a11y tests failing with count 0: init()'s async
fetchUnreadCount resolved to {count:0} AFTER setNotifications() ran,
clobbering the seeded count (the flake Sara predicted in review). Stub
fetch to never settle so the announced count is driven solely by
setNotifications — deterministic, no race. Also rewrites the 'error' test
to seed a count then fail the load and assert the count SURVIVES, so it is
a meaningful state distinct from 'empty' (was byte-identical, flagged by
Felix/Sara/Leonie). Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:38:22 +02:00
Marcel
4db2e97490 revert(test): abandon shared-mock dedup — infeasible in vitest browser mode
CI proved cross-file sharing of a virtual-module mock body cannot work in
@vitest/browser-playwright 4.1.6: the static-import spread fails the hoist
("no top level variables"), and the await-vi.hoisted-import form fails to
parse ("Unexpected identifier 'vi'"). vi.hoisted has the same hoist
constraint as vi.mock, so there is no way to thread an external module's
body into the factory here.

Reverts Phase 1: restores the 4 $app/forms/$app/navigation specs to their
inline factories, inlines NotificationBell.spec's forms stub, deletes the
src/__mocks__/$app/* modules and the $mocks alias (vite, vitest-coverage,
kit). The no-factory-ban meta-test stays (no-factory vi.mock is still
banned). ADR-012 amended to record the infeasibility. Everything else
($app/state migration, confirm context-inject, notification refactor, the
pin, the meta-test) is unaffected. Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:38:22 +02:00
Marcel
25b23843c9 fix(test): load shared mocks via vi.hoisted, not a static import
CI caught that vi.mock('$app/forms', () => ({ ...formsMock })) with a
static `import * as formsMock` fails: vitest hoists vi.mock above the
import, so the factory references an uninitialised binding
("no top level variables inside"). Load the shared mock module via
`const formsMock = await vi.hoisted(() => import('$mocks/...'))` instead —
the factory may reference a vi.hoisted binding, and the dynamic import runs
at collection time (not in the lazily-invoked factory), so it stays clear
of ADR-012's birpc race and the no-async-mock-factories guard. Applies to
all 5 shared-mock consumers ($app/forms x4, $app/navigation x1). Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:38:22 +02:00
Marcel
ad067d2e0e refactor(notification): provide notification store via context + fixture
Converts the module-singleton notificationStore into a context-provided
store so its specs can drive it without mocking the module. notifications.svelte
now exports createNotificationStore() (the former singleton body), plus
provideNotificationStore()/getNotificationStore()/NOTIFICATION_KEY mirroring
the confirm service. Root +layout provides it; NotificationBell and the
Chronik page read it via getNotificationStore().

Tests:
- notifications.svelte.spec drives a fresh createNotificationStore() per test
  (replacing __resetForTest/__setNavigateForTest with setNavigate()).
- notification.test-fixture.svelte wraps the bell, provides the store, and
  exposes setNotifications(items) via onReady (option b).
- NotificationBell.svelte.spec asserts the announced unread count across the
  empty / single / many / error a11y states (AC#5), stubbing EventSource+fetch.
- aktivitaeten page spec injects a real store via render context.

Per the recorded Phase-2b decision (full context refactor). Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:38:22 +02:00
Marcel
b1b8505b93 test: inject real ConfirmService via context (batch 1/2)
Replaces the vi.mock('$lib/shared/services/confirm.svelte') stub with a
real createConfirmService() provided through render's context map, mirroring
the existing admin/tags/[id]/page.svelte.spec.ts pattern. The generic
confirm.test-fixture.svelte renders only ConfirmDialog and cannot wrap an
arbitrary page; none of these specs trigger confirm(), so the children's
getConfirmService() simply reads the provided context instead of a module
mock. No vi.mock of confirm.svelte remains in these 5 specs. Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:38:22 +02:00
Marcel
abe860bec7 test(hooks): migrate useUnsavedWarning spec to shared $app/navigation mock
Replaces the local beforeNavigate-capture plumbing and simulateNavigate
helper with the shared $mocks/$app/navigation module via a sync factory.
The per-test reset now comes from the shared module's embedded beforeEach.
Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:38:22 +02:00
Marcel
e562b3bbea test: migrate remaining 3 $app/forms consumers to shared mock
Completes Phase 1a after the load-bearing ChronikFuerDichBox spec proved
the pattern. ChronikFuerDichBox.test and NotificationDropdown.test (rich
result-firing interceptors) keep their submit-fired assertions
(optimisticMarkRead/MarkAllRead) and use formsMock.setFormResult for the
failure branch. NotificationBell.spec used the simpler intercept-only
factory and renders no form of its own, so it adopts the shared superset
purely as a render-time stub. Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:38:22 +02:00
Marcel
e725910402 test(activity): migrate ChronikFuerDichBox spec to shared $app/forms mock
Load-bearing first migration (ADR-012): this is the hardest case — its
enhance submit callback actually fires and reads the form result. Replaces
the duplicated 23-line interceptor factory with vi.mock('$app/forms',
() => ({ ...formsMock })) via $mocks, and the per-test mockFormResult
mutation with formsMock.setFormResult({ type: 'failure' }). The reset now
comes from the shared module's embedded beforeEach. The existing
optimisticMarkRead/optimisticMarkAllRead-on-submit assertions remain as the
positive proof the callback fired. Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:38:22 +02:00
Marcel
98d081397e chore(api): regenerate TS client without the conversation endpoint
Drop the /api/documents/conversation path and its getConversation
operation from the generated client to match the removed backend
endpoint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:26:54 +02:00
Marcel
e2ec45f819 refactor(document): move ConversationThumbnail into lib/document
With the Briefwechsel view gone, lib/conversation/ held a single shared
component whose only consumer is lib/document/ThumbnailRow. Move it (and
its spec) into lib/document/, update the import, delete the now-empty
lib/conversation/ folder, and fix the stale frontend/CLAUDE.md lib map.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:26:54 +02:00
Marcel
e259908d6a fix(stammbaum): order keyboard tab stops by visual layout, not DB order (#718)
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m21s
CI / OCR Service Tests (push) Successful in 23s
CI / Backend Unit Tests (push) Successful in 3m40s
CI / fail2ban Regex (push) Successful in 43s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m5s
Person nodes rendered in `nodes` array order (backend/DB row order), so
Tab focus hopped between nodes unrelated to their on-screen position,
failing WCAG 2.4.3 Focus Order (Level A).

Render the node loop in reading order instead: sort by layout y (top
generation first) then x (left-to-right within a row), via a
`nodesInReadingOrder` derived. Nodes without a layout position sort last
(mirroring the `{#if pos}` guard); node.id is the final tie-break for a
total, deterministic comparator. Shift+Tab and reload-stability fall out
for free (reversed render order; x/y independent of backend order).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 07:55:47 +02:00
Marcel
9c1eb7608b fix(transcription): harden re-edit pencil hit-testing + disable sync (#628 review)
Addresses the clean-agent review of PR #717:

- C1: the hidden pencil was opacity-0 only, which still hit-tests; its 44px box
  overhangs adjacent text, so a click in the gap between two mentions could land
  on the invisible button and spuriously open the dropdown (AC-8 hole). Add
  pointer-events-none while hidden, re-enabled with the opacity reveal on
  hover/focus.
- C2/N1: editor.setEditable() emits "update", not a ProseMirror transaction, so
  the NodeView's 'transaction' listener missed a mid-session disable flip (stale
  aria-disabled/tabindex; the comment was wrong). Listen on 'update' instead —
  which also skips selection-only changes, so it fires far less often.
- N2: track the node across update() so the pencil opens with the live
  displayName (hardening; relink only swaps personId today).

Tests: structural guard that the hidden pencil is pointer-events-none + reveals,
and a mid-session disable-flip test (fixture gains an onReady setDisabled hook).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:55:28 +02:00
Marcel
9bba5e4a7a feat(transcription): announce re-edit context via the existing live region (#628)
Passes editingDisplayName into MentionDropdown; the persistent aria-live region
announces person_mention_editing_announce({displayName}) on re-edit open and
falls back to the prompt/empty/count copy once the user edits or results arrive.
Routed through the SAME sr-only region as the result count — no second live
region (avoids the double-announce bug Leonie S-2 fixed). Fresh-@ passes an
empty editingDisplayName, so its announcements are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:55:28 +02:00
Marcel
751a48b22c test(transcription): AC-7 disabled, AC-8 no-mention, security clip/provenance (#628)
- AC-7: disabled editor → pencil is disabled + aria-disabled + tabindex -1, and
  neither keyboard nor pointer activation mounts a dropdown (WCAG 2.1.1, not just
  pointer-events-none).
- AC-8: plain text shows no pencil/dropdown; two adjacent mentions each keep one
  pencil with no spurious gap pencil and no auto-open; a doc-start mention still
  renders its pencil.
- Security: an oversized stored displayName clips the search query to 100 chars
  while the preserved node text stays full-length; re-link sources personId
  solely from the picked Person (p-anna), never the reflected/clipped text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:55:28 +02:00
Marcel
58a30a6e2e test(transcription): AC-6 single-dropdown invariant + stale-fetch guard (#628)
Locks in the single-owner controller guarantees: pencil→pencil, fresh-@→pencil
and pencil→fresh-@ all leave exactly one dropdown open; the request-token bump
on open discards a superseded open's in-flight fetch (open A → open B → A
resolves, deterministic, no sleeps). Plus a #380 AC-1 regression guard that the
fresh-@ path still inserts the typed text as displayName after the controller
refactor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:55:28 +02:00
Marcel
2430092e43 feat(transcription): dismiss + keyboard-operate the re-edit dropdown (#628 AC-4/AC-9)
Adds a visible × dismiss control to MentionDropdown (shared by the fresh-@ and
re-edit paths) and, for the re-edit path which has no Tiptap suggestion plugin
to forward keys, focuses the search input on open and handles its own keyboard:
Escape dismisses (AC-4), Arrow/Enter reuse the exported selection logic so the
dropdown is navigable on its own (AC-9 parity with the fresh-@ dropdown).

Both close paths (Escape + ×) leave the mention node attrs + text byte-identical
(AC-4) — close() never touches the document. Controller wires ondismiss=close
(+refocus editor) and focusOnMount only for the re-edit open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:55:28 +02:00
Marcel
4a93543645 feat(transcription): re-edit @mention via a pencil affordance (#628)
Hosts each mention as a Tiptap NodeView (mentionNodeView.ts) that renders the
@displayName token (textContent — never innerHTML) plus a contenteditable=false
pencil button in a fixed-width slot, revealed on whole-token hover and keyboard
focus (instant opacity swap, no reflow). Activating the pencil (click or Enter/
Space) opens the single mention dropdown via the controller, anchored at the
token and pre-filled with the stored displayName.

commitRelink swaps ONLY personId in place via setNodeMarkup, sourcing the id
solely from the selected Person — the stored displayName is preserved by
construction (AC-3), even after the search input is edited (AC-5, the #380 AC-1
invariant). renderHTML/renderText stay for serialization + clipboard.

AC-1/AC-2/AC-3/AC-5 + serializer round-trip covered by browser tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:55:28 +02:00
Marcel
b453c13bae refactor(transcription): lift @mention dropdown lifecycle into a single controller
Pulls mountedDropdown / requestId / debouncedSearch / dropdownState ownership
out of Tiptap's suggestion.render() closure into one createMentionController().
render() becomes a thin adapter: onStart→open, onUpdate→update, onExit→close.

This is the single-owner structure #628 needs for the AC-6 single-dropdown
invariant — the upcoming pencil re-edit affordance opens via the same
controller.open() instead of racing the suggestion plugin over module state.
open() now also bumps the request token so an open-A→open-B sequence discards
A's in-flight fetch (preserved increment-on-open semantics). No behaviour
change for the fresh-@ path — existing browser suite is the regression guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:55:28 +02:00
Marcel
cb61e63b02 fix(document): polish PDF error state — warning icon, 44px target, warmer copy
Address the remaining UI/UX polish: add a warning-triangle icon so the
failure is signalled by shape, not colour alone (WCAG 1.4.1); give the
recovery download link a full 44px tap/focus target (inline-flex
min-h-[44px]); and soften the message copy in de/en/es.

Addresses re-review: Leonie (colour-only, undersized link, copy warmth).

Refs #708

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 21:17:41 +02:00
Marcel
e16b7402bd fix(document): make the PDF error state accessible (alert + larger link)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m20s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m42s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
The error block was a colour-only, visually-small dead end. Add
role="alert" so screen readers announce the failure, bump the message to
text-base and the recovery download link to text-sm with a py-2 tap
target — the only escape hatch, sized for the archive's older readers.

Addresses re-review: Leonie (a11y of the error state).

Refs #708

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 21:12:23 +02:00
Marcel
229c1b0539 test(document): exercise the real render-failure path in PdfViewer test
The "render failure" test rejected getDocument().promise — the load
path, not the render path — and only asserted a template constant. Now
the fake loads the document successfully and rejects the page render
(the actual #708 wasm-decode failure class), plus a negative companion
asserting the message is absent on a successful render. Also reset
renderTask to null on the render-error path.

Addresses re-review: Felix, Sara (mislabeled test / asserted a constant).

Refs #708

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 21:12:23 +02:00
Marcel
f24c415b04 fix(document): localize loadDocument error too — no raw pdf.js text
The render path was localized but loadDocument still stored the raw
pdf.js message (and an untranslated English fallback), contradicting the
"never leak raw error text" principle. Both load and render failures now
set the localized doc_render_failed message.

Addresses re-review: Felix, Nora (raw error leak on the load path).

Refs #708

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 21:12:23 +02:00
Marcel
4c57a2262f test(frontend): guard wasm shipping at build time, drop CI-fragile pixel test
The in-browser pixel-render fixture test was green locally but flaky in
CI: the real pdf.js worker could not fetch /pdfjs-wasm/ in the CI
Chromium container, so the CCITT canvas stayed blank (0 sampled pixels)
and failed the suite — green locally, red in CI, root cause not locally
reproducible. A flaky gate is worse than none.

This bug is a build/serve parity failure, so guard it deterministically
where it actually breaks: a postbuild assertion that jbig2.wasm and
openjpeg.wasm shipped into build/client/pdfjs-wasm/ (non-empty). It runs
after `npm run build` — including the Docker build stage — and fails the
build loudly if a future pdfjs bump makes the static-copy glob match
nothing. Combined with the getDocument(wasmUrl) unit guard and the
negative-path render test, the regression is covered without CI flake.

Addresses re-review: Tobias (no automated parity check), Sara (pixel
test not pinned). Render-decode correctness verified manually via
`node build` serving /pdfjs-wasm/jbig2.wasm as application/wasm.

Refs #708

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 21:12:23 +02:00
Marcel
e8e57d2712 test(document): behavioral CCITT/DCT render fixtures prove the wasm path
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>
2026-06-01 21:12:23 +02:00
Marcel
817835fd6a fix(document): add rel=noopener noreferrer to viewer download link (CWE-1022)
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>
2026-06-01 21:12:23 +02:00
Marcel
c361b3cd45 fix(document): localize PdfViewer render-error message and download link
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>
2026-06-01 21:12:23 +02:00
Marcel
5c8034d298 fix(document): surface PDF render failures instead of a silent blank canvas
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>
2026-06-01 21:12:23 +02:00
Marcel
4ca1c967d2 fix(document): pass wasmUrl to pdf.js getDocument so wasm decoders load
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>
2026-06-01 21:12:23 +02:00
Marcel
8a1cc2d1f0 chore(i18n): drop the unused date_original_label key and stale comments
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m18s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m39s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
CI / Unit & Component Tests (push) Successful in 3m19s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 3m37s
CI / fail2ban Regex (push) Successful in 44s
CI / Semgrep Security Scan (push) Successful in 21s
CI / Compose Bucket Idempotency (push) Successful in 1m6s
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>
2026-06-01 20:10:20 +02:00
Marcel
d5bf401085 feat(document): stop surfacing the raw cell in the detail drawer
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>
2026-06-01 20:10:20 +02:00
Marcel
4944918692 feat(document): remove the visible Originaltext line from DocumentDate
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>
2026-06-01 20:10:20 +02:00
Marcel
bf90427bfa feat(document): drop the read-only Originaltext field from the edit form
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>
2026-06-01 20:10:20 +02:00
Marcel
654ac1478c feat(document): surface end-before-start inline on the date form (#678)
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>
2026-06-01 09:27:57 +02:00