Commit Graph

87 Commits

Author SHA1 Message Date
Marcel
4f2880a61a a11y(transcription): bump @mention search input to text-base (16 px floor)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m30s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m23s
CI / fail2ban Regex (pull_request) Successful in 41s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 58s
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>
2026-05-19 23:28:00 +02:00
Marcel
e37351f5c2 a11y(transcription): cap @mention listbox width at viewport-1rem (WCAG 1.4.10)
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>
2026-05-19 23:26:11 +02:00
Marcel
332d81975f a11y(transcription): give @mention search input its own sr-only label
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>
2026-05-19 23:24:17 +02:00
Marcel
b5455066c9 fix(transcription): clip @mention editor-mirror to 100 chars (CWE-400 layered)
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>
2026-05-19 23:21:11 +02:00
Marcel
2df46b71f3 test(transcription): unit-test @mention dropdown onKeyDown export
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>
2026-05-19 23:18:53 +02:00
Marcel
34b6a8a220 test(transcription): characterize @mention silent failure on 500 / network error
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>
2026-05-19 23:15:20 +02:00
Marcel
b6b9235dd8 test(transcription): de-flake one-fetch @mention test via searchbox fill
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>
2026-05-19 23:13:47 +02:00
Marcel
7603c8d936 refactor(transcription): rename @mention test-host to test-fixture
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>
2026-05-19 23:12:07 +02:00
Marcel
a822479535 docs(transcription): explain why @mention mirror uses \$state+\$effect
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>
2026-05-19 23:10:30 +02:00
Marcel
58358e845d fix(transcription): cancel pending @mention debounce in onExit
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>
2026-05-19 23:09:22 +02:00
Marcel
fcd4a41ba1 docs(debounce): clarify that cancel() drops, never flushes, the trailing call
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m20s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m22s
CI / fail2ban Regex (pull_request) Successful in 41s
CI / Semgrep Security Scan (pull_request) Successful in 19s
CI / Compose Bucket Idempotency (pull_request) Successful in 58s
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>
2026-05-19 22:33:20 +02:00
Marcel
b6bf24db60 refactor(test): drop double-cast on Person fixtures
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>
2026-05-19 22:32:29 +02:00
Marcel
44209048a2 refactor(test): name the debounce slack and harden against CI jitter
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>
2026-05-19 22:30:16 +02:00
Marcel
f67f5330ce fix(transcription): defensively cap @mention fetch with limit=5
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>
2026-05-19 22:27:32 +02:00
Marcel
fb658e7647 test(transcription): pin sticky search-input takeover behaviour
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>
2026-05-19 22:23:34 +02:00
Marcel
7618558895 a11y(transcription): announce @mention empty state via aria-live
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>
2026-05-19 22:20:16 +02:00
Marcel
94f63c4550 a11y(transcription): enlarge @mention magnifier and darken contrast
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>
2026-05-19 22:18:42 +02:00
Marcel
8052131576 fix(transcription): cap @mention search input at maxlength=100
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>
2026-05-19 22:16:28 +02:00
Marcel
2556e7f5c8 fix(transcription): guard @mention fetch against stale responses
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>
2026-05-19 22:14:51 +02:00
Marcel
ecc4d1aa67 fix(transcription): neutralize legacy items() to dedupe @mention fetch
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>
2026-05-19 22:11:17 +02:00
Marcel
896d34cfcd refactor(transcription): consolidate MentionDropdown test files
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m27s
CI / OCR Service Tests (pull_request) Successful in 19s
CI / Backend Unit Tests (pull_request) Successful in 3m22s
CI / fail2ban Regex (pull_request) Successful in 42s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 59s
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>
2026-05-19 21:29:13 +02:00
Marcel
a4e184d939 feat(transcription): drive @mention fetch through the dropdown search input
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>
2026-05-19 21:20:06 +02:00
Marcel
e1b5c1b15c fix(transcription): add noreferrer to mention dropdown create-new link
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>
2026-05-19 21:05:03 +02:00
Marcel
5099dfa424 test(transcription): cover 44px touch target on mention search input
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>
2026-05-19 21:03:59 +02:00
Marcel
d9be001f1f feat(transcription): wire dropdown search input to editor @-text
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>
2026-05-19 21:03:13 +02:00
Marcel
671d05acac test(transcription): cover MentionDropdown onSearch callback wiring
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>
2026-05-19 20:57:15 +02:00
Marcel
25afed0d65 feat(transcription): add data-test-search-input hook for E2E selectors
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>
2026-05-19 20:56:15 +02:00
Marcel
a026d8bb05 feat(transcription): add search input with initialQuery prefill to MentionDropdown
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>
2026-05-19 20:55:00 +02:00
Marcel
78fd9e026e feat(frontend): add CSRF injection, rate-limit i18n, and 429 login handling
- handleFetch injects X-XSRF-TOKEN + XSRF-TOKEN cookie on all mutating
  backend API requests (double-submit cookie pattern); generates a fresh
  UUID when no XSRF-TOKEN cookie exists yet
- ErrorCode union gains CSRF_TOKEN_MISSING and TOO_MANY_LOGIN_ATTEMPTS;
  getErrorMessage maps both to i18n keys
- de/en/es messages add error_csrf_token_missing and
  error_too_many_login_attempts translations
- Login action maps HTTP 429 to fail(429, { ..., rateLimited: true });
  page shows a muted clock icon with aria-invalid on rate-limit errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 09:23:01 +02:00
Marcel
b607677f30 refactor(auth): extract extractFaSessionId to \$lib/shared/cookies
Move the Set-Cookie parser out of login/+page.server.ts into a shared module
with its own Vitest coverage (single-header, multi-header getSetCookie path,
missing-header, attribute-stripping, prefix-match-rejection). An Undici or
Node upgrade that changes header shape now trips its own test instead of
silently breaking login. Addresses PR #612 / Felix F2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 22:43:09 +02:00
Marcel
cfff594732 feat(auth): frontend ErrorCode + i18n for INVALID_CREDENTIALS and SESSION_EXPIRED
Mirrors the backend ErrorCode additions from commit 393a3c25.
Adds error_session_expired_explainer for the login-page banner that
will surface when ?reason=expired.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:51:06 +02:00
Marcel
0a5d4fb950 feat(errors): add GROUP_NOT_FOUND error code + i18n keys
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:26:53 +02:00
Marcel
75453bed51 feat(frontend): add GROUP_HAS_ACTIVE_INVITES error code + i18n keys
Adds the error code to the ErrorCode union and getErrorMessage() switch.
Adds admin_new_invite_groups, admin_invite_groups_load_error, and
error_group_has_active_invites to all three locale files (de/en/es).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:26:53 +02:00
Marcel
6c7d696d56 test(discussion): rewrite MentionEditor test with vi.waitFor
Replaces 16 setTimeout(350ms / 30ms / 50ms) sleeps with vi.waitFor on
the actual signal — popup listbox appearance/disappearance, option
aria-selected state — so the test no longer races the 200ms internal
debounce against the real clock under CI load.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 21:50:28 +02:00
Marcel
f3915c4878 test(discussion): rewrite CommentThread test with behavioral assertions
Replaces 8 setTimeout sleeps with vi.waitFor on the actual signal
(textarea value, fetch URL recorded, onCountChange call) and converts
3 .not.toThrow smoke tests into behavioural assertions:

- "no onCountChange wired" → asserts initial comment text still renders
- "network error during reload" → asserts empty-hint state is shown
- "non-OK reload" → asserts empty-hint state is shown

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 21:50:28 +02:00
Marcel
f989fa00d4 test(discussion): expand CommentThread coverage further
Whitespace-only quotedText not seeded, no onCountChange not provided,
fetch network error during reload, non-OK reload response, own
comment with edit/delete affordances.

5 new tests covering ~10 branches.

Refs #496.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:50:28 +02:00
Marcel
6050773da5 test(discussion): expand MentionEditor coverage further
Adds mousedown-to-select flow, Enter-to-select with highlighted
result, fetch network throw handling.

3 new tests covering ~6 branches.

Refs #496.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:50:28 +02:00
Marcel
c1f515ddc4 test(primitives): cover Pagination bridge-page and totalPages=0 branches
Bridge-page-replaces-ellipsis paths (left and right), totalPages=0
hide-the-nav.

3 new tests covering ~5 branches.

Refs #496.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:50:28 +02:00
Marcel
b7f2841375 test(primitives): cover DistributionBar zero-total branch
Adds 0/0 zero-total NaN-guard branch and 5/0 outOnly branch.

2 tests covering ~4 branches.

Refs #496.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:50:28 +02:00
Marcel
a93034a8d7 test(discussion): expand CommentThread coverage
Adds onCountChange-after-reload (loadOnMount=true), null currentUserId
isOwn behavior, replies flattened in display.

3 new tests covering ~10 branches.

Refs #496.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:50:28 +02:00
Marcel
d1aa0dc9f0 test(discussion): cover CommentThread branches
Empty hint, populated comment list, singular vs plural label,
canComment toggle, showCompose flag with empty/non-empty messages,
quotedText pre-fills textarea, onCountChange on mount, URL routing
for annotation/block/document comment endpoints.

12 tests covering ~25 branches in CommentThread.

Refs #496.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:50:28 +02:00
Marcel
6b4a5ba0da test(discussion): cover MentionEditor branches
Textarea props (placeholder, rows, disabled), popup not shown
initially, popup opens on @ + query, empty results from API,
HTTP error → empty popup, Enter submits when popup closed,
Shift+Enter does not submit, Escape closes popup, Arrow{Up,Down}
navigation, Enter with no results.

12 tests covering ~30 branches in MentionEditor.

Refs #496.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:50:28 +02:00
Marcel
cd26296969 test(dashboard): cover DashboardResumeStrip branches
Empty card vs populated strip, title rendering, thumbnail image vs
fallback icon, progress bar aria-valuenow, document detail link,
collaborators stack rendering, safeColor fallback to default when hex
invalid.

9 tests covering ~25 of DashboardResumeStrip's branches.

Refs #496.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:50:28 +02:00
Marcel
c42585d5d8 test(dashboard): cover ReaderDraftsModule branches
Heading, empty placeholder, per-draft row rendering, draft edit link
href, meta line with relative time.

5 tests, ~10 branches.

Refs #496.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:50:28 +02:00
Marcel
84c9cdab2f test(dashboard): cover ReaderRecentDocs branches
Heading + all-docs link, New badge gated on createdAt == updatedAt,
sender displayName vs lastName fallback vs em-dash, document detail
link.

8 tests covering ~16 of the recent-docs section's branches.

Refs #496.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:50:28 +02:00
Marcel
2f700f80f7 test(dashboard): cover ReaderHeaderBar and ReaderRecentStories
ReaderHeaderBar: time-of-day greeting matrix (Morgen/Mittag/Abend),
welcome with name, stats counts, em-dash for null counts, three
section links.

ReaderRecentStories: empty early return, populated story rows, all-
stories link, story-detail link, body excerpt visibility.

12 tests, ~30 branches.

Refs #496.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:50:28 +02:00
Marcel
a08d537fd6 test(dashboard): cover ReaderPersonChips branches
Section landmark with aria-label, empty placeholder, per-person chip
with link to detail, document-count chip visibility branch, displayName
vs lastName fallback, all-persons footer link.

7 tests, ~16 branches.

Refs #496.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:50:28 +02:00
Marcel
63f1155966 test(dashboard): cover DashboardRecentDocuments branches
Empty list early return, heading + per-doc row rendering, title link
href, date visibility tied to updatedAt, stats footnote presence
toggled by stats.totalDocuments.

7 tests covering ~16 of the dashboard section's branches.

Refs #496.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:50:28 +02:00
Marcel
a47fe9fbce test(discussion): cover CommentMessage branches
Author + initials, comment body, edited label gated on updatedAt vs
createdAt, edit-mode textarea, delete button gated on isOwn, onDelete
callback wiring.

8 tests covering ~16 of CommentMessage's branches.

Refs #496.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:50:28 +02:00
Marcel
36c08fed61 test(shared/primitives): cover Pagination branches
Hidden when totalPages <= 1, prev/next disabled state matrix at
boundaries, link form when in range, aria-current for active page,
mobile page label, left ellipsis / right ellipsis branches based on
window position, custom ariaLabel.

11 tests covering ~30 of Pagination's branches.

Refs #496.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:50:28 +02:00