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>
The senior-audience body-text floor is 16 px (CLAUDE.md
§Dual-Audience). The search input was the smallest non-metadata
text in the dropdown at text-sm (14 px), even though it is the
primary write surface a 60+ transcriber types into. Bumping to
text-base costs ~2 px of popover header height and closes the
"I can't read what I'm typing" complaint that historically tops
senior-usability tests of search bars. Leonie FINDING-MENTION-006
on PR #629.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
w-72 (288 px) listbox can overflow horizontally on a 320 px viewport
when the caret sits near the right edge — the existing flip logic
only handles vertical overflow. max-w-[calc(100vw-1rem)] adds a
defensive horizontal cap so a senior on a 320 px phone never sees
the dropdown clip off-screen. Leonie FINDING-MENTION-005 on PR #629.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The sr-only label for the search input was reusing the listbox
"Link person" label — but the input filters a candidate list, it does
not link anything. Screen readers heard a verb mismatch between the
listbox announce and the search-input focus event. New
person_mention_search_label key in de/en/es. The listbox aria-label
stays person_mention_btn_label since that labels the listbox itself.
Leonie FINDING-MENTION-004 on PR #629.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The <input maxlength=100> attribute capped direct user edits but did
not cover the Tiptap editor-mirror path. A 5000-char @-suffix in the
contenteditable would mirror unchanged into searchQuery and reach
runSearch. Clipping at the mirror keeps both paths bounded. The
literal in the maxlength attribute is also bound to the new
MAX_QUERY_LENGTH constant so the two stay in sync. Server-side cap
tracked separately. Nora #1 on PR #629.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tiptap intercepts ArrowDown/ArrowUp/Enter at the editor level and
forwards them via the dropdown's exported onKeyDown — the dropdown
itself has no DOM keydown listener. These tests exercise the same
export directly (the full focus-chain E2E is deferred to a separate
Playwright issue). Sara #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>
Test-only helper colocated with production code now has a visible
.test-fixture.svelte boundary so eslint-boundaries and code search
do not confuse it for a production component. The internal alias was
also bumped from *Host to *Fixture for consistency. No behaviour
change. Felix #3 / Nora #3 on PR #629.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The mirror effect on the dropdown's searchQuery looks like it should be
\$derived but it cannot be: bind:value on the <input> writes to the same
state, so it must remain mutable. Felix #2 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>
Markus on PR #629 — the cancel-not-flush contract is what the
PersonMentionEditor onDestroy path relies on. Spell it out so future
callers can rely on the same guarantee.
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>
Once the user edits the dropdown search input, subsequent editorQuery
changes from the host editor must not overwrite it. Felix on PR #629.
Adds a small test host that exposes a setter for editorQuery so the
test can drive reactive prop changes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Collapse the two empty-state branches into a single p[aria-live=polite]
whose text derives from the search query. Screen readers now hear the
transition between "Namen eingeben…" and "Keine Personen gefunden".
Leonie FINDING-MENTION-002 on PR #629.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bump h-4 w-4 to h-5 w-5 and text-ink-3 to text-ink-2 so the icon
carries enough visual weight to identify the input region without a
visible text label. Leonie FINDING-MENTION-001 on PR #629.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Soft-cap on the client side mitigates CWE-400 query amplification
(server-side cap remains a separate backend PR).
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. Drops the redundant MentionDropdown.svelte.spec.ts that
was added earlier in this branch and folds its search-input coverage
into the long-established MentionDropdown.svelte.test.ts. Same
test surface, single file.
While there:
- Updates the empty-state test to match the new behaviour: an empty
search field shows the "Namen eingeben…" prompt; "Keine Personen
gefunden" only appears when a query is entered but nothing matches.
- Fixes pre-existing Person-type drift in makePerson (missing
personType, familyMember).
- Stricten the create-new link rel assertion to cover the new
noreferrer addition.
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 (Nora CWE-116). The "Neue Person anlegen" link opens in
a new tab and was missing `noreferrer` — the new tab could read
window.opener and the referrer leaked the transcription URL. Same-origin
risk is low but the omission was unintentional.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
For issue #380 NFR. The transcriber audience is 60+ on laptops/tablets;
the search input must meet WCAG 2.2 AA touch target dimensions just like
the existing person result rows.
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>
For issue #380. Asserts that typing in the search input invokes the
onSearch prop with the current value — characterising the boundary that
PersonMentionEditor relies on for its debounced fetch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
For issue #380. Adds an explicit Playwright selector attribute on the
mention search input so E2E tests target a stable hook instead of a
fragile CSS class string.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
For issue #380. The dropdown now renders a dedicated search input at the
top, pre-filled with the text typed after @. This decouples the lookup
from the display text — the transcriber can edit the search field to
find a person whose stored name differs from what was typed.
The fetch wiring (onSearch callback) is consumed by PersonMentionEditor
in a follow-up commit; this commit only introduces the input UI and the
prop surface.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
For issue #380 — the new search input inside the @mention dropdown
needs an empty-state prompt distinct from "no results found".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
VITE_SENTRY_DSN is a Vite build-time variable baked into the JS bundle.
Without an ARG/ENV in the Dockerfile build stage and a build.args entry in
docker-compose.prod.yml, the SDK initialised with enabled=false regardless
of the Gitea secret value.
- frontend/Dockerfile: add ARG VITE_SENTRY_DSN + ENV before npm run build
- docker-compose.prod.yml: add build.args.VITE_SENTRY_DSN with empty fallback
- nightly.yml: write VITE_SENTRY_DSN secret into .env.staging
Requires Gitea secret VITE_SENTRY_DSN to be set to the GlitchTip project #1 DSN.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
text-red-500 on bg-red-50 gives ~3.8:1 contrast (passes AA for UI
components at 3:1 but leaves no margin). text-red-600 gives ~5.0:1,
comfortably above the AA threshold with no visual downgrade.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
role="alert" already implies aria-live="assertive". The polite override
caused screen readers to wait for the current announcement to finish
before reading the error — too gentle for a failure state the user just
triggered. Dropping the attribute restores the implicit assertive
behaviour.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
text-xs (12px) is at the lower bound for the 60+ transcriber cohort.
text-sm (14px) matches the visual weight of the progress counter label
above and is more comfortable to read under stress (failed operation).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds an await for the button to become non-disabled between the two
dispatchEvent calls in 'clears error on next successful call'. This
ensures the first async rejection has fully settled and Svelte has
flushed markingAllReviewed before the second click fires.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a test for when the server returns a non-JSON body (e.g. an nginx
502 HTML page). Confirms the res.json().catch(() => ({})) fallback
produces 'INTERNAL_ERROR' as the thrown message and leaves blocks intact.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds toHaveTextContent(m.transcription_mark_all_reviewed_error()) to the
error-present test. The previous check only asserted presence via
role="alert", which would not have caught the dead key bug — the banner
was showing the generic fallback rather than the operation-specific copy.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes the getErrorMessage() indirection and calls
m.transcription_mark_all_reviewed_error() directly in the catch block.
The previous implementation routed through getErrorMessage(code) which
mapped any error code to the generic m.error_internal_error() fallback,
leaving the domain-specific key unreachable.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The function now throws instead of silently returning on failure.
Update the test name and assertion to match the new behaviour, and
verify blocks remain unchanged after the error.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace hardcoded German strings with m.transcription_mark_all_reviewed()
and m.transcription_mark_all_reviewed_disabled().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds markAllError state and catch block to handleMarkAllReviewed.
Error banner renders below the review progress bar with role="alert"
and aria-live="polite" for screen reader announcement. Dismiss button
clears the error; next successful call also clears it automatically.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously the function silently returned on failure, leaving no way
for callers to detect or surface the error to the user.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
RED phase: 4 new Vitest browser tests that fail because the error
banner and catch block don't exist yet.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces hardcoded visible text 'Für Training vormerken' with
m.transcribe_mark_for_training() so the label translates in EN and ES.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces hardcoded 'Menü öffnen'/'Menü schließen' ternary with
m.layout_menu_open()/m.layout_menu_close() so the mobile nav toggle
announces correctly in EN and ES locales.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>