Exports the standard nav functions as vi.fn() and a beforeNavigate that
captures the registered callback. The exported simulateNavigate(href)
helper fires that callback and returns the cancel spy — the whole
capture-and-fire pattern lives in the shared module, not the raw callback.
An embedded beforeEach clears the captured callback and the mock call
histories before every test. Part of #560.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
Single home for the non-trivial form-interceptor enhance() shared by the
four complex consumers: it intercepts submit, invokes the SubmitFunction,
and fires the returned callback with a configurable result. setFormResult()
drives the success/failure branch; an embedded beforeEach resets it before
every test so isolation is structural. Consumed via vi.mock('$app/forms',
() => ({ ...formsMock })) through the $mocks alias. Part of #560.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The vite resolve.alias (added for the client + coverage runs) does not
reach svelte-check, which resolves paths through the generated tsconfig.
Declaring $mocks in kit.alias feeds both the generated tsconfig paths and
the sveltekit() vite plugin, so editor/type-check resolve it too. Part of #560.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A vi.mock('$app/navigation') with no factory does not auto-resolve to a
__mocks__ file for SvelteKit virtual modules — it substitutes some
exports and leaves others (replaceState) bound to the live router, which
is exactly the PR #657 failure. This Node-mode source scan, mirroring
no-async-mock-factories and no-duplicate-mock-ids, fails at every vitest
invocation if any *.svelte.{spec,test}.ts reintroduces the pattern, and
forecloses ADR-012's rejected Option C. Part of #560.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Declares $mocks -> src/__mocks__ in both vite.config.ts and
vitest.client-coverage.config.ts so shared mock modules resolve in the
client test run and the coverage job alike. Enables the sync-factory
dedup pattern from ADR-012 (vi.mock('$app/forms', () => ({ ...formsMock }))).
Part of #560.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drop the caret so the version cannot float off the patched release.
patches/@vitest+browser-playwright+4.1.6.patch backports vitest PR #10267
(the duplicate-mock-id birpc race, ADR-012) and only applies to 4.1.6; a
caret range could resolve to a version the patch rejects. A top-level
"//" key records the removal condition since package.json forbids
comments. Part of #560.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The legacy $app/stores subscription API is replaced with the modern
$app/state reactive proxy (page.url.pathname), per ADR-012's
architectural follow-on. The two spec mocks of $app/stores are replaced
with sync-factory $app/state mocks, matching the existing convention in
aktivitaeten/documents specs. Part of #560.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
toHaveAttribute compares by equality, so passing a regex asserted against
the literal RegExp object and failed. Assert the full title against
m.person_correspondents_search_title(...) instead — it names both persons
and avoids retyping the copy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The e2e README still listed the deleted korrespondenz.spec.ts. Replace it
with the new briefwechsel-removed.spec.ts guard entry — closing the last
dangling reference flagged in review.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drop the Briefwechsel route and the conversation derived-domain /
conversation-thread prose from the route tables (CLAUDE.md,
frontend/CLAUDE.md), ARCHITECTURE.md, the C4 frontend/backend diagrams,
and GLOSSARY.md (term + derived-domain list). Delete the two superseded
Briefwechsel design specs. Historical ADRs and dated analyses are left
untouched as point-in-time context.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
Drop the 22 message keys that only the deleted Briefwechsel view used
(conv_* except the still-used conv_sort_newest/oldest, plus
nav_conversations, doc_conversation_title and person_correspondents_hint,
all now superseded by the retargeted card's new search keys).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an active e2e spec asserting /briefwechsel 404s on the styled app
error page. The old assertion lived in stammbaum.spec.ts inside a
test.skip() block (never executed) and asserted the opposite — remove it.
Drop /briefwechsel from the auth protected-route loop; /documents (the
redirect target) sits behind the same authenticated() rule, so coverage
is preserved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Delete the /briefwechsel route in full (page, server load, eight
components and all co-located unit tests) and its end-to-end coverage
(briefwechsel-rows.visual, briefwechsel-a11y, the bilateral-correspondence
fixture, and the stale korrespondenz spec which targeted the route's
former /korrespondenz path). The card link now deep-links into document
search, so this view has no remaining inbound references.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Häufige Korrespondenten" card linked into the standalone Briefwechsel
view. Retarget each chip to the existing document search pre-filtered by
sender and receiver (/documents?senderId=A&receiverId=B), naming both
persons in a search-action title, swapping the chat-bubble icon for a
magnifier, and clarifying that the ×N badge counts shared letters in both
directions (not the unidirectional search result count).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
CI's node coverage run (vite.config.ts, 'measure utility + server-side logic
only') counts every .ts under the include globs via all-files, but the Tiptap
NodeView builds live ProseMirror DOM and only runs in the browser editor — it is
exercised by the client project's browser tests, not the node run. Left in, it
showed 0% and dragged global functions (78.68%) and branches (78.48%) below the
80% gate.
Exclude it alongside the .svelte / browser-only UI files this config already
measures around. Restores the gate: statements 88.82%, branches 82.3%,
functions 87.27%, lines 89.77% (server project, verified locally).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
- 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>
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>
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>
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>
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>
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>
Enable svelte/no-target-blank so reverse-tabnabbing is caught at lint
time instead of relying on review (the very gap that left the viewer
download link exposed). Repo is already clean — all existing
target="_blank" anchors carry rel="noopener noreferrer".
Addresses re-review: Nora (optional detection-for-free).
Refs #708
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
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>