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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
Completes Phase 2a: geschichten/[id], persons/[id]/edit and admin/tags/[id]
page specs now provide a real createConfirmService() via render context
instead of mocking confirm.svelte. Zero confirm.svelte vi.mocks remain
across the client suite (AC#4). Part of #560.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>