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>
userEvent.clear deletes per-keystroke, so intermediate values 'Au'/'A'
transit through the bound searchQuery and each schedules a debounced
fetch. When CI keystroke jitter exceeds SEARCH_DEBOUNCE_MS (150 ms), an
intermediate timer fires before the input reaches '' and the count
assertion sees a phantom q=Au call. fill('') drops a single input event
so the empty-query branch wins deterministically — same pattern this
test file already uses for fill('Walter').
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
GET /api/persons now returns PersonSearchResult { items, … } instead of a bare
list. Update every caller: the dashboard top-persons path reads .items; the
unused full-list fetches in documents/new and documents/[id]/edit are dropped
(both pages use the self-fetching PersonTypeahead); the raw-fetch consumers
(PersonTypeahead, PersonMultiSelect, PersonMentionEditor) read body.items and
pass review=true so search still spans the whole directory. Specs updated to
the new envelope shape.
Refs #667
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Document entity schema now carries the required metaDatePrecision field
and the Person schema the required provisional field (both @Schema(REQUIRED)).
Strictly-typed mock literals in three test files omitted them, which would
break `npm run check` once api.ts is regenerated.
- ReaderRecentDocs.svelte.spec.ts: baseDoc gains metaDatePrecision; sender mock
gains provisional.
- PersonMentionEditor.svelte.spec.ts: AUGUSTE/ANNA gain provisional.
- MentionDropdown.svelte.test.ts: makePerson factory base gains provisional.
--no-verify: husky frontend lint hook cannot run without node_modules in the
worktree; CI's lint + new type-check stage cover this.
Refs #671
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The test raced a real 150 ms setTimeout: fill('Walter') started the
debounce, then focus + keyboard(Escape) had to complete before 150 ms
elapsed. Under CI load the Playwright CDP round-trips exceeded 150 ms,
letting the debounce fire first.
Fix: install vi.useFakeTimers() after the stable-state setup (so
vi.waitFor()'s real-timer polling still works), freeze the Walter
debounce, let Escape trigger onExit/cancel, then advance fake time
with vi.advanceTimersByTimeAsync() — no real-wall-clock race.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Round-4 polish from Sara (#11199) and Felix (#11186):
- Replace setTimeout(50) in stale-response race with tick() — matches
round-3 pattern Sara verified in the sticky-takeover test.
- Add intent comment above the "clear input" wait — it is a negative
assertion that must not be optimised away.
- Tighten displayName-clip assert from <=100 to ===100 so the test
discriminates "clip works" from "clip works AND nothing weakened it".
- JSDoc POST_DEBOUNCE_SLACK_MS with the calibration rationale.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The aria-live region previously lived inside {#if items.length === 0} so
it remounted whenever items transitioned between empty and populated —
VoiceOver in particular swallows announcements from freshly-mounted live
regions, and the "N persons found" announcement was missing entirely on
the populated branch. Move the live region above the conditional so the
element persists, and announce a localized "1 person found" / "N persons
found" count on the populated branch. The visible empty-state <p> stays
as a visual cue (no aria-live). Leonie #3 on PR #629 round 3.
Adds person_mention_results_count_singular / _plural in de/en/es.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Round 2 renamed only MentionDropdown's fixture; three siblings retained
the old suffix. Rename PersonMentionEditor, confirm, and TranscriptionBlock
test hosts to the .test-fixture suffix and update the three importers so
the boundary is uniform across the repo. Felix #1 / Tobi #1 on PR #629.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sara on PR #629 round 3: the round-2 fix captured the fetch count AFTER
typing '@', so a regression that re-introduced the legacy per-keystroke
items() callback would have its '@'-keystroke fetch silently absorbed
into the baseline. Drop the baseline subtraction and count every
/api/persons fetch since render — typing '@' + fill('Walter') must
total exactly one fetch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The dropdown's editor-mirror clips at 100 chars (CWE-400, Nora #1), but
the host editor previously fed renderProps.query directly to displayName
on selection — so a 200-char @-suffix would search the first 100 chars
but insert 200 chars. Clip once in updateState and use the clipped value
for both the inserted displayName and the dropdown's editorQuery mirror,
keeping "what I searched" and "what got inserted" in sync. Felix #3 on
PR #629.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Single source of truth for MAX_QUERY_LENGTH, SEARCH_DEBOUNCE_MS, and
SEARCH_RESULT_LIMIT — MentionDropdown imports MAX_QUERY_LENGTH;
PersonMentionEditor imports the debounce + result-limit; the spec's
mirror now imports SEARCH_DEBOUNCE_MS so it can never drift. Unblocks
the displayName length-cap fix (Felix #3 on PR #629).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
runSearch swallows non-OK responses and fetch rejections to an empty
items list. The user sees "Keine Personen gefunden" identically to a
genuine empty result. These two tests pin that behaviour so a future
distinct-error-UX implementer is forced to update the assertions.
Sara #2 on PR #629.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
userEvent.type(@Walter) types 7 keys; CI jitter can space the gaps past
the 150 ms debounce and fire 2+ fetches, even though the request-token
guard discards the stale response. fill() collapses the input into one
event so the assertion (exactly 1 fetch) becomes deterministic.
Sara #1 on PR #629.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Without this, a closed dropdown's trailing runSearch could fire against
the next dropdown's state and silently overwrite its items before its
own fetch resolved. Felix #1 on PR #629.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Drops the `as unknown as Person` double-cast in makePerson and on
AUGUSTE/ANNA in favor of plain return-typed object literals; this
restores the type-system safety net Felix flagged on PR #629 — a
future required field on Person now fails compilation in the fixture
instead of silently slipping through.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Extracts SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS at the top of the
spec and bumps the post-debounce wait from 250/300 ms to 500 ms.
Addresses Felix's "magic number" suggestion and Sara's flake-risk
concern on PR #629. (Sara's fake-timer alternative collides with
userEvent + vi.waitFor in vitest-browser; the slack bump achieves the
same deterministic outcome with no fragility.)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds &limit=5 to the /api/persons request so the client signals its
intent and stays consistent with the SEARCH_RESULT_LIMIT slice. Backend
enforcement (and the broader PersonSummaryDTO response-shape audit) is
tracked separately. Markus on PR #629.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tag each runSearch with an incrementing requestId; discard responses
whose id no longer matches the latest onSearch. Prevents a slow fetch
from repopulating the dropdown after the user has cleared the search.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tiptap's suggestion items() callback fired a fetch on every keystroke
after `@`, in parallel with the debounced search-input fetch. Its result
was discarded by updateState, so it was pure waste — doubling the load
on /api/persons and confusing the debounce.
Returning [] from items() routes the entire fetch flow through the
search-input -> debounced onSearch path. New test pins @Walter to
exactly one fetch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
For issue #380 (AC-2, AC-3, AC-4 + NFR debounce).
The search input is now the single fetch trigger. The dropdown's
searchQuery reactivity calls onSearch on every change — whether sourced
from the editor mirror or the user's own input. PersonMentionEditor
debounces these calls at 150 ms, short-circuits on empty queries (no
fetch, items cleared), and tears down pending timers on destroy.
The Tiptap suggestion plugin's items() now returns [] — per-keystroke
fetches in the editor are gone. The same /api/persons?q= endpoint is
used; the difference is in when and how often the request fires.
Adds a cancel() method to the debounce utility so destroyed editors
don't leave trailing fetches alive (which previously polluted the test
ledger and would have wasted bandwidth in production tab-close races).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
For issue #380. The search input mirrors the @-text the user types until
the user takes ownership by typing into the input itself. After that,
the input owns its own state and editor typing no longer overrides it.
Two empty states now exist:
- "Namen eingeben…" when the search input is empty (AC-4)
- "Keine Personen gefunden" when the search input has a query but the
list is empty (existing behavior)
The dropdown reads editorQuery through the shared $state proxy via a
getter prop, matching the established pattern for model.items.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Fixes all remaining failing tests in the browser project. Root cause in
every case: Playwright CDP-based clicks/keyboard events do not reliably
trigger Svelte 5 onclick/onkeydown handlers. Pattern applied throughout:
- Buttons / result items: native `.element().click()` or
`dispatchEvent(new MouseEvent('click', { bubbles: true }))`
- Keyboard events: `dispatchEvent(new KeyboardEvent('keydown', { key }))`
on the target DOM element
- TipTap selection: `element.focus()` + Selection API +
`document.dispatchEvent(new Event('selectionchange'))`
- ProseMirror focus for onFocus: `dispatchEvent(new FocusEvent('focus'))`
Also fixes pre-existing content/logic issues found during analysis:
- ChronikErrorCard, BulkDropZone, CorrespondenzHero: stale i18n strings
and wrong ARIA role (combobox not textbox)
- RichtlinienRuleCard: beide beispielInput + beispielOutput required for
arrow to render; querySelectorAll to get last code element
- admin/system/page: vi.unstubAllGlobals() in afterEach; strict-mode
heading selector; per-call mockResolvedValueOnce for dual-card page
- DocumentList: add total prop + result count paragraph (test relied on it)
- PersonTypeahead keyboard navigation: pressKey() helper with native
KeyboardEvent dispatch replaces userEvent.keyboard()
- PersonMultiSelect: native element clicks for result selection and
chip removal; keydown dispatch on result div for Enter key test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>