Compare commits

..

60 Commits

Author SHA1 Message Date
Marcel
b690c74ddf fix(richtlinien): improve examples, copy, and Wikipedia link
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m57s
CI / OCR Service Tests (push) Successful in 34s
CI / Backend Unit Tests (push) Failing after 2m59s
- Rule cards now show before→after examples; strikethrough rule input
  renders with CSS line-through so the visual context is honest
- Illegible-words rule shows output only — can't represent unreadable
  text as readable characters
- Intro drops fictional family names in favour of "egal wer tippt"
- Wikipedia card copy is more direct; link uses icon instead of
  parenthetical "(öffnet in neuem Tab)" text

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 13:10:56 +02:00
Marcel
0797406f02 docs(bulk-upload): explain chunkSize=10 and 50-file cap constants
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m47s
CI / OCR Service Tests (push) Successful in 34s
CI / Backend Unit Tests (push) Failing after 2m56s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
c94d2cec03 feat(bulk-upload): guard discard-all with confirm dialog
Uses getConfirmService() (optional — null fallback when context is absent so
unit tests that don't exercise the discard path need no CONFIRM_KEY context)
and the new bulk_discard_confirm i18n key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
4da0bf71a0 fix(bulk-upload): add gradient overflow indicators to chip strip
Adds pointer-events-none left/right gradient fade overlays on the
FileSwitcherStrip track div so mouse-only users can see when more
chips are hidden beyond the visible area. The scrollbar is hidden
(scrollbar-width:none) so gradients are the only overflow signal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
da5d3c60b3 fix(bulk-upload): chip readability and focus management in FileSwitcherStrip
Chip label text increased from 11px to 12px (text-xs) and number badge
from 9px to 11px for the 60+ senior audience on laptops/tablets.

After removing a chip via the × button, focus moves to the previous chip
(falling back to the next chip when the first chip is removed) so keyboard
users are not stranded on <body>. Uses Svelte tick() to wait for DOM update.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
ed0d0bf331 fix(bulk-upload): handle network errors and partial upload success
save() now wraps each chunk fetch in try/catch — a thrown network error
marks all files in that chunk as errored. Also handles HTTP 200 responses
with a non-empty errors array (partial success): only the named filenames
are marked as errored rather than all files in the chunk. Navigation is
suppressed whenever any file fails.

Tests added:
- network error marks all chunk files as errored, no navigation
- HTTP 200 with errors array marks only affected files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
899508f9ca feat(bulk-upload): guard save() against concurrent invocations
Adds a saving $state flag that blocks re-entry while a chunk upload is
in flight. The UploadSaveBar save button is disabled via a new disabled
prop while saving is true. Tested: clicking Save twice fires fetch only
once.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
d32e671e9d fix(bulk-upload): raise discard button touch target to 44px for WCAG compliance
Senior users on tablets need at least 44×44px touch targets (WCAG 2.2).
Added min-h-[44px] flex items-center px-2 to the discard button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
b61cfa081f test(bulk-upload): add positive navigation assertion for successful save
The error-path test (goto not called on failure) had no matching positive
assertion. Added: save() navigates to /documents when all chunks succeed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
d914385afc fix(bulk-upload): correct stale DocumentBatchMetadataDTO type in api.ts
Generated type had tags?: string but Java DTO declares List<String> tagNames.
Corrected to tagNames?: string[] to match the backend contract.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
6cdfc1f6a3 fix(bulk-upload): announce error chip status to screen readers
The ! indicator was aria-hidden with no sr-only fallback, making failed
uploads invisible to assistive technology. Added sr-only span with
bulk_file_error_chip_label before the visual indicator.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
ed6a2fb56f test(bulk-upload): fix ScopeCard spec assertions to match actual component classes
/brand-mint/ never matched (component uses border-accent bg-accent-bg);
companion test also updated to assert the meaningful negative.
getByText('5') fixed to exact:true to avoid strict-mode ambiguity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
58545876cd fix(bulk-upload): accessibility improvements and fetch comment
- BulkDropZone: link description <p> to drop zone region via aria-describedby
- UploadSaveBar: add explicit aria-valuenow/aria-valuemin/aria-valuemax to
  <progress> element for consistent screen reader support across browsers
- FileSwitcherStrip: add non-color error indicator (red !) to error chips so
  error state is not communicated by color alone (WCAG 1.4.1)
- BulkDocumentEditLayout: comment explaining why raw fetch is used instead of
  a SvelteKit form action (chunked FormData with per-chunk progress tracking)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
687ebf495d fix(bulk-upload): match error chips by filename, not by chunk position
save() was marking the first N files in a chunk as errored (where N = the
error count returned by the backend), but the backend errors are keyed by
filename. A failure for file[2] would incorrectly mark file[0] as the error.

Now builds a Set of error filenames and matches chunk entries by file.name.
Test added: save marks only the file whose filename matches the backend error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
bc10f2af06 fix(i18n): remove orphaned merge conflict markers from message files
All three message files had a bare `<<<<<<< HEAD` at line 814 with no
corresponding separator or closing marker, making them invalid JSON and
breaking the Paraglide build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
0bfd342190 test(bulk-upload): add unit tests for storeDocumentWithBatchMetadata
Covers four behaviours of applyBatchMetadata that had no coverage:
title applied by list index, sender resolved via PersonService,
tags applied via updateDocumentTags, and title left unchanged when
the fileIndex exceeds the titles list length.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
1973f88e56 fix(bulk-upload): truncate long chip titles with tooltip in FileSwitcherStrip
Long filenames caused chips to overflow the strip. Added max-w-[8rem]
and truncate on the title span, plus a title attribute for full text
on hover.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
9f044f429c fix(bulk-upload): enlarge scroll button touch targets to 44×44px
Prev/next scroll buttons were 24×20px, below the WCAG 2.2 SC 2.5.5
minimum of 44×44px. Changed to h-[44px] w-[44px].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
7ad5e35fd6 fix(bulk-upload): populate aria-live region with active file title
The sr-only aria-live div was always empty, so screen readers never
announced file switches. Derived activeAnnouncement from the active
entry and bound it to the div's text content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
e7afed5ac3 fix(bulk-upload): add aria-label to progress bar in UploadSaveBar
<progress> had no accessible name, failing WCAG 1.3.1 and 4.1.2.
Labels it with the already-existing bulk_upload_progress i18n key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
f48d1e3cd8 fix(bulk-upload): i18n topbar title; replace hardcoded German strings
'Neues Dokument' / 'Neue Dokumente' in BulkDocumentEditLayout topbar
bypassed Paraglide. Added bulk_title_single and bulk_title_multi keys
to de/en/es message files and switched to m.*() calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
fc118f7032 fix(bulk-upload): skip navigation when any chunk fails to upload
goto('/documents') fired unconditionally, discarding error chips and
leaving the user with no feedback on which files failed. Now only
navigates when hadErrors is false after all chunks complete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
4229e952fb fix(bulk-upload): include tagNames in quick-upload metadata payload
Tags were silently dropped because the metadata object built in save()
never included a tagNames field; they never reached the backend.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
e1259215ef test(bulk-upload): add save-error and discard-all coverage to BulkDocumentEditLayout spec
- save error path: server returns non-ok → fetch is called (error handling wired)
- discard-all: N=2 → click topbar button → N=0 drop-zone restored, switcher gone
- Add data-testid="discard-all-btn" to topbar discard button for reliable selection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
f06d034b36 fix(bulk-upload): i18n hardcoded strings in BulkDropZone and FileSwitcherStrip
- Add bulk_drop_desc, bulk_select_files, bulk_drop_zone_label, bulk_remove_file
  keys to de/en/es message files
- BulkDropZone: use m.bulk_drop_zone_label(), m.bulk_drop_desc(),
  m.bulk_select_files() — removes all hardcoded German
- FileSwitcherStrip: use m.bulk_remove_file() on × button; move aria-live
  from <ul> to a dedicated visually-hidden region above the strip (screen
  readers now announce changes without coupling the live region to the list)
- Spec: import FileEntry from component instead of re-declaring; use
  data-remove-id selector instead of hardcoded German aria-label

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
a6cd10f219 refactor(documents): extract applyBatchMetadata private helper in DocumentService
storeDocumentWithBatchMetadata was a 30-line flat method mixing file storage
with metadata hydration. The private helper makes each concern visible at a
glance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
b8e6fe9ec9 refactor(documents): change DocumentBatchMetadataDTO.tags from String to List<String> tagNames
Replaces comma-delimited String with a proper JSON array field — callers no
longer need to pre-serialise. Service drops the split/trim/filter step and
passes tagNames directly to updateDocumentTags().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
763f1990cd refactor(documents): move batch validation from controller into DocumentService
Validation guards (BATCH_TOO_LARGE, titles > files) are domain rules and
belong in the service where they can be unit-tested without the HTTP layer.
Controller now delegates to documentService.validateBatch().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
ca62f50921 fix(forms): remove autofocus from WhoWhenSection entirely
The autofocus prop was added conditionally but still triggered on the
bulk-upload page. Removing it completely — callers that need focus
management can handle it independently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
61f84a86ac fix(forms): apply py-3 to location input for consistent 44px height
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
0eb5c95c6c fix(forms): raise date and sender field height to match receiver (44px)
PersonMultiSelect naturally renders at 44px due to nested padding (outer p-2 + inner p-1).
Apply py-3 px-2 to the date input and PersonTypeahead default mode so all three fields
align visually.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
d662635392 fix(PersonTypeahead): match height and border-radius of other form inputs
Default mode was text-base (16px) and rounded-md — date field uses text-sm
(14px) and rounded. Aligning these makes Sender/Date/Receiver rows consistent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
b00be2548c fix(PersonMultiSelect): align height and focus ring with other form inputs
min-h-[42px] → min-h-[38px] to match p-2 text-sm input height.
Add shadow-sm (was missing vs date/sender inputs).
focus-within:ring-1 ring-ink → focus-within:ring-2 ring-focus-ring to match
the focus style used consistently across all other form inputs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
01a8654347 fix(bulk-upload): no layout shift, no autofocus on date field
Replace JS navHeight measurement with CSS var(--header-height) so the fixed
panel renders in its final position on first paint — no onMount shift.

Add autofocus prop to WhoWhenSection (default true, preserves document-edit
behaviour) and pass autofocus={false} from BulkDocumentEditLayout so the date
field does not steal focus before the user has even dropped any files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
c1b221412f fix(bulk-upload): PDF-only file acceptance
Drop non-PDF accept types from file input and update format hint strings
in all three languages. JPEG/PNG/TIFF were never officially supported.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
76c14ea604 fix(bulk-upload): form layout polish and drop zone sizing
- Drop zone box doubled: max-w-xl, larger icon (80px), bigger padding and text
- Title field wrapped in its own card (matches WhoWhenSection/DescriptionSection)
- Removed double-wrapping outer card around WhoWhenSection + DescriptionSection
- Added space-y-4 between form sections for consistent breathing room
- ScopeCard per-file label: text-accent → text-primary for legible contrast in light theme

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
539842e849 fix(bulk-upload): spec-compliant split-panel layout with local PDF preview
Rewrites BulkDocumentEditLayout to match the spec exactly:
- Fixed viewport layout (same as DocumentEditLayout) filling viewport below nav
- Split panel visible in all states (N=0/1/≥2) — was fullscreen dark drop zone
- N=0: centered drop-zone-box in left panel; shared form visible but greyed out
- N≥1: real PDF preview via URL.createObjectURL (no server upload required)
- N≥2: FileSwitcherStrip at bottom of left panel; count pill + discard in topbar
- FileEntry gains previewUrl; blob URLs created on add, revoked on remove/destroy
- save() checks response.ok and marks failed files with status: 'error'
- BulkDropZone redesigned: spec-accurate box with circular mint icon, serif title
- FileSwitcherStrip: number badges, arrows, keyboard nav via data-chip-id selector
- ScopeCard, UploadSaveBar: hardcoded German replaced with Paraglide i18n keys
- +page.svelte simplified to bare component render (layout is self-contained)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
ef7a51fe30 chore(api): regenerate types — adds DocumentBatchMetadataDTO
New type from the bulk-upload metadata part added in #317.
Generated from backend running with --spring.profiles.active=dev.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
ec17cb123a feat(bulk-upload): wire /documents/new to BulkDocumentEditLayout
Replaces the single-file form-action flow with BulkDocumentEditLayout,
enabling multi-file drag-and-drop upload with local preview, per-file
title editing, and shared metadata. Server load function unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
801470093d feat(bulk-upload): add BulkDocumentEditLayout component with save handler
State-owner for the bulk upload flow:
- N=0: full-panel BulkDropZone
- N=1: title + shared metadata (no switcher/scope cards)
- N≥2: FileSwitcherStrip + per-file ScopeCard + shared ScopeCard
Save handler chunks files at 10/request, POSTs to /api/documents/quick-upload
with typed metadata JSON part, tracks progress, redirects to /documents.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
af6ba6a9cc feat(bulk-upload): add UploadSaveBar component + fix bulk_save_cta message
Save bar with sticky positioning, a determinate progress bar while
uploading chunks, plural save CTA, and a destructive discard link.
Replaces broken ICU plural in bulk_save_cta with two-key approach
(bulk_save_cta_one / bulk_save_cta) since Paraglide 2.5 does not support
ICU plural syntax.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
9acd5ec617 feat(bulk-upload): add ScopeCard component
Card container with two variants: per-file (mint tint) and shared (neutral
with file-count badge). Used to visually separate per-file vs shared
metadata sections in the bulk upload layout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
29a44b3cd1 feat(bulk-upload): add FileSwitcherStrip component
Horizontal chip strip for switching between files in a bulk upload session.
Supports keyboard navigation (arrow keys cycle within the strip), error state
chips, and onSelect/onRemove callbacks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
5fe289b06b feat(bulk-upload): add BulkDropZone component
Full-panel drop target that supports multi-file selection via drag-and-drop
or file picker. Fires onFilesAdded callback with the full File array.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
f76af8c678 feat(bulk-upload): add bulkTitleFromFilename utility
Converts a raw filename into a human-readable title candidate by
stripping the extension and replacing underscore/hyphen runs with spaces.
Reuses the existing stripExtension() helper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
69c739c6e3 feat(i18n): add BATCH_TOO_LARGE error code + 16 bulk-upload Paraglide keys
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
43cf022f05 feat(documents): extend quick-upload with optional batch metadata part
- Add DocumentBatchMetadataDTO (titles, senderId, receiverIds, documentDate, location, tags, metadataComplete)
- Add BATCH_TOO_LARGE to ErrorCode
- Extend quickUpload to accept optional @RequestPart("metadata"); dispatches to storeDocumentWithBatchMetadata when present
- Cap batch at 50 files/request; reject 400 when titles.size > files.size
- Add DocumentService.storeDocumentWithBatchMetadata applying shared fields + index-based titles to both created and updated docs
- Raise max-request-size to 500MB (10-file chunk at max per-file size)
- Add structured SLF4J logging for every quickUpload call

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
48d034dcb8 fix(transcribe-coach): propagate hover from 44px button group to inner span
Some checks failed
CI / Backend Unit Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has started running
hover: on the <span> only fired on the 20×20px visual circle, not the
full 44×44px touch target. Add `group` + `focus-visible:ring-*` to the
outer button; switch to `group-hover:` on the inner span so the visual
response covers the entire interactive area.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
c335ddd686 test(e2e): add training footer positive-case test and fix broken selectors
- createEmptyDocument now uploads a minimal PDF so the Transkribieren
  button is rendered (requires isPdf = true in DocumentTopBar)
- add 'Transcribe coach — with blocks' describe: seeds a block via API,
  waits for blocks to settle in read mode, switches to edit, confirms
  'Für Training vormerken' is visible
- fix dark-theme axe test: ThemeToggle uses aria-label 'dark mode',
  not the previous /Farbmodus|theme/ regex

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
7830a749a0 docs(richtlinien): shorten prerender comment to essentials
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
5b7c37391c test(HelpPopover): use userEvent.keyboard for Enter/Space tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
ce72b07197 test(e2e): fix locators, add print assertion, cleanup, remove redundant emulateMedia
- help-popover: replace broad button[aria-expanded] with specific
  getByLabel('Lese- und Bearbeitungsmodus'); update role="tooltip" →
  role="region"; add afterAll doc cleanup (Sara/Tobias)
- richtlinien: assert .new-tab spans are hidden in print media — the
  existing test only checked .app-nav (Sara)
- transcribe-coach: remove 4× redundant page.emulateMedia({reducedMotion})
  calls — playwright.config.ts already sets reducedMotion: 'reduce' globally;
  add afterAll doc cleanup (Tobias)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
505804c893 chore(i18n): remove dead transcription_empty_draw_hint key
The key was orphaned when TranscriptionEditView's empty state was replaced
by TranscribeCoachEmptyState. Removed from de/en/es to avoid accumulating
unreferenced strings. (Felix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
67421a4c0c docs(richtlinien): document why prerender=true is auth-safe
handleAuth in hooks.server.ts is in the sequence() chain and redirects
unauthenticated users at runtime regardless of prerender. Adding a comment
so the next reader doesn't mistake this for a security hole. (Markus/Nora)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
0ea0df4f72 fix(richtlinien): <main> landmark + closing card h2 → h3
- Wrap page content in <main> so AT users can jump to main content (Nora)
- Closing card "Fehlt eine Regel?" was <h2> after two existing <h2> siblings
  but styled like a card title, not a section label; downgrade to <h3> to
  fix the heading hierarchy (Sara/Leonie)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
077f5c85df fix(TranscribeCoachEmptyState): Tailwind grid instead of inline styles; step aria-labels
- Replace style="grid-template-columns: 34px 1fr; align-items: start;"
  with Tailwind grid-cols-[34px_1fr] items-start (Felix: inline styles)
- Add aria-label="Schritt N von 3" on each <li> so screen readers announce
  step position when the numeric badge is aria-hidden (Nora/Sara)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
018e272a3b fix(RichtlinienRuleCard): bg-[#FAF8F1] → bg-parchment design token
Raw hex bypassed the token system and wouldn't remap in dark mode.
Now uses --color-parchment which has a proper dark-mode counterpart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
0c4a0ead7b fix(TranscribeDragDemo): reactive prefersReducedMotion + bg-parchment token
- Replace one-shot $derived(.matches) snapshot with $state + addEventListener
  so the static/animated branch reacts when the user toggles OS reduced-motion
  at runtime (Felix: non-reactive media query)
- Replace bg-[#FAF8F1] raw hex with bg-parchment design token so the SVG
  background remaps correctly in dark mode (Felix/Markus)

Also update TranscriptionPanelHeader.svelte.test.ts to expect role="region"
after the HelpPopover ARIA fix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
82b12d4383 fix(HelpPopover): role=region, 44px touch target, counter-based ID
- role="tooltip" → role="region" + aria-label={label}: tooltip semantics
  are wrong for a click-triggered panel (Nora/Sara)
- expand button to 44×44px with inner visual <span>: WCAG 2.5.8 touch
  target for 60+ transcriber audience (Sara/Leonie)
- replace Math.random() with module-level counter: SSR/hydration mismatch
  when server and client generate different IDs (Felix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
01758e8e00 feat(tokens): add --color-parchment design token for warm example-block surfaces
Adds --c-parchment (#faf8f1 light / #041828 dark) to :root and both
dark-mode blocks, exposed as --color-parchment via @theme inline.
Prerequisite for replacing bg-[#FAF8F1] raw-hex in RichtlinienRuleCard
and TranscribeDragDemo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
17 changed files with 243 additions and 87 deletions

View File

@@ -8,21 +8,27 @@ test.describe('Help chip — Read/Edit panel header', () => {
docId = await createEmptyDocument(request); docId = await createEmptyDocument(request);
}); });
test.afterAll(async ({ request }) => {
await request.delete(`/api/documents/${docId}`);
});
test('opens popover on click, closes on Esc, returns focus to chip', async ({ page }) => { test('opens popover on click, closes on Esc, returns focus to chip', async ({ page }) => {
await page.goto(`/documents/${docId}`); await page.goto(`/documents/${docId}`);
await page.getByRole('button', { name: 'Transkribieren' }).click(); await page.getByRole('button', { name: 'Transkribieren' }).click();
// Find and click the (?) help chip // Use the accessible label of the HelpPopover trigger (transcription_mode_help_label)
const helpBtn = page.locator('button[aria-expanded]'); const helpBtn = page.getByRole('button', { name: 'Lese- und Bearbeitungsmodus' });
await expect(helpBtn).toBeVisible({ timeout: 5000 }); await expect(helpBtn).toBeVisible({ timeout: 5000 });
await helpBtn.click(); await helpBtn.click();
// Popover should open // Popover should open (role="region", not tooltip — click-triggered panels are regions)
await expect(page.locator('[role="tooltip"]')).toBeVisible(); await expect(page.getByRole('region', { name: 'Lese- und Bearbeitungsmodus' })).toBeVisible();
// Press Esc // Press Esc
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
await expect(page.locator('[role="tooltip"]')).not.toBeVisible(); await expect(
page.getByRole('region', { name: 'Lese- und Bearbeitungsmodus' })
).not.toBeVisible();
// Focus should have returned to the chip // Focus should have returned to the chip
await expect(helpBtn).toBeFocused(); await expect(helpBtn).toBeFocused();

View File

@@ -1,10 +1,30 @@
import type { APIRequestContext } from '@playwright/test'; import type { APIRequestContext } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PDF_FIXTURE = path.resolve(__dirname, '../fixtures/minimal.pdf');
export async function createEmptyDocument(request: APIRequestContext): Promise<string> { export async function createEmptyDocument(request: APIRequestContext): Promise<string> {
const res = await request.post('/api/documents', { const createRes = await request.post('/api/documents', {
multipart: { title: 'E2E Transcribe Coach Test' } multipart: { title: 'E2E Transcribe Coach Test' }
}); });
if (!res.ok()) throw new Error(`Create document failed: ${res.status()}`); if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
const doc = await res.json(); const doc = await createRes.json();
return doc.id as string; const docId = doc.id as string;
const uploadRes = await request.put(`/api/documents/${docId}`, {
multipart: {
title: doc.title,
file: {
name: 'minimal.pdf',
mimeType: 'application/pdf',
buffer: fs.readFileSync(PDF_FIXTURE)
}
}
});
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
return docId;
} }

View File

@@ -63,6 +63,12 @@ test.describe('Richtlinien page — print media', () => {
await expect(nav).toBeHidden(); await expect(nav).toBeHidden();
} }
// .new-tab annotation spans must be hidden in print so "(öffnet in neuem Tab)"
// text does not clutter the printed output (the print CSS declares display:none)
for (const span of await page.locator('.new-tab').all()) {
await expect(span).toBeHidden();
}
await page.screenshot({ path: 'test-results/e2e/richtlinien-print.png', fullPage: true }); await page.screenshot({ path: 'test-results/e2e/richtlinien-print.png', fullPage: true });
}); });
}); });

View File

@@ -2,6 +2,24 @@ import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright'; import AxeBuilder from '@axe-core/playwright';
import { createEmptyDocument } from './helpers/upload-empty-document.js'; import { createEmptyDocument } from './helpers/upload-empty-document.js';
async function createBlock(
request: Parameters<typeof createEmptyDocument>[0],
docId: string
): Promise<void> {
const res = await request.post(`/api/documents/${docId}/transcription-blocks`, {
data: {
pageNumber: 1,
x: 0.1,
y: 0.1,
width: 0.3,
height: 0.1,
text: 'Liebe Mutter,',
label: null
}
});
if (!res.ok()) throw new Error(`Create block failed: ${res.status()}`);
}
function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) { function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) {
return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']); return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
} }
@@ -13,10 +31,13 @@ test.describe('Transcribe coach — empty state', () => {
docId = await createEmptyDocument(request); docId = await createEmptyDocument(request);
}); });
test.afterAll(async ({ request }) => {
await request.delete(`/api/documents/${docId}`);
});
test('shows coach card (title, preamble, three step bodies, animation region)', async ({ test('shows coach card (title, preamble, three step bodies, animation region)', async ({
page page
}) => { }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto(`/documents/${docId}`); await page.goto(`/documents/${docId}`);
await page.getByRole('button', { name: 'Transkribieren' }).click(); await page.getByRole('button', { name: 'Transkribieren' }).click();
@@ -31,14 +52,12 @@ test.describe('Transcribe coach — empty state', () => {
}); });
test('training footer is NOT visible on empty doc', async ({ page }) => { test('training footer is NOT visible on empty doc', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto(`/documents/${docId}`); await page.goto(`/documents/${docId}`);
await page.getByRole('button', { name: 'Transkribieren' }).click(); await page.getByRole('button', { name: 'Transkribieren' }).click();
await expect(page.getByText('Für Training vormerken')).not.toBeVisible({ timeout: 3000 }); await expect(page.getByText('Für Training vormerken')).not.toBeVisible({ timeout: 3000 });
}); });
test('axe: panel empty state — light theme — no WCAG 2.1 AA violations', async ({ page }) => { test('axe: panel empty state — light theme — no WCAG 2.1 AA violations', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto(`/documents/${docId}`); await page.goto(`/documents/${docId}`);
await page.getByRole('button', { name: 'Transkribieren' }).click(); await page.getByRole('button', { name: 'Transkribieren' }).click();
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({ await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
@@ -50,10 +69,9 @@ test.describe('Transcribe coach — empty state', () => {
}); });
test('axe: panel empty state — dark theme — no WCAG 2.1 AA violations', async ({ page }) => { test('axe: panel empty state — dark theme — no WCAG 2.1 AA violations', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto(`/documents/${docId}`); await page.goto(`/documents/${docId}`);
// Toggle dark theme // Toggle dark theme
await page.getByRole('button', { name: /Farbmodus|theme/i }).click(); await page.getByRole('button', { name: /dark mode/i }).click();
await page.getByRole('button', { name: 'Transkribieren' }).click(); await page.getByRole('button', { name: 'Transkribieren' }).click();
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({ await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
timeout: 5000 timeout: 5000
@@ -63,3 +81,25 @@ test.describe('Transcribe coach — empty state', () => {
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0); expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
}); });
}); });
test.describe('Transcribe coach — with blocks', () => {
let docId: string;
test.beforeAll(async ({ request }) => {
docId = await createEmptyDocument(request);
await createBlock(request, docId);
});
test.afterAll(async ({ request }) => {
await request.delete(`/api/documents/${docId}`);
});
test('training footer IS visible when at least one block exists', async ({ page }) => {
await page.goto(`/documents/${docId}`);
await page.getByRole('button', { name: 'Transkribieren' }).click();
// Wait for blocks to finish loading — block count confirms mode settled to 'read'
await expect(page.getByText(/1 Abschnitt/)).toBeVisible({ timeout: 5000 });
await page.locator('[data-testid="mode-edit"]').click();
await expect(page.getByText('Für Training vormerken')).toBeVisible({ timeout: 5000 });
});
});

View File

@@ -515,7 +515,6 @@
"scan_collapse": "Scan verkleinern", "scan_collapse": "Scan verkleinern",
"transcription_empty_title": "Noch keine Transkription", "transcription_empty_title": "Noch keine Transkription",
"transcription_empty_desc": "Zeichne Bereiche auf dem Scan und tippe den Text ab, um eine Transkription zu erstellen.", "transcription_empty_desc": "Zeichne Bereiche auf dem Scan und tippe den Text ab, um eine Transkription zu erstellen.",
"transcription_empty_draw_hint": "Zeichnen Sie Bereiche auf dem Dokument, um mit der Transkription zu beginnen.",
"transcription_panel_close": "Panel schließen", "transcription_panel_close": "Panel schließen",
"person_alias_heading": "Namensverlauf", "person_alias_heading": "Namensverlauf",
"person_alias_empty": "Noch keine Namensaenderungen erfasst.", "person_alias_empty": "Noch keine Namensaenderungen erfasst.",
@@ -828,9 +827,9 @@
"transcription_mode_help_body": "Lesen zeigt die Transkription als fließenden Text. Bearbeiten öffnet die Textfelder für jede Passage.", "transcription_mode_help_body": "Lesen zeigt die Transkription als fließenden Text. Bearbeiten öffnet die Textfelder für jede Passage.",
"richtlinien_title": "Transkriptions-Richtlinien", "richtlinien_title": "Transkriptions-Richtlinien",
"richtlinien_intro": "Damit alle Briefe einheitlich transkribiert werden — egal ob Tante Hedwig oder Cousin Paul tippt — hier unsere Regeln. Die Seite wächst mit: sobald wir eine neue Konvention beschließen, landet sie hier.", "richtlinien_intro": "Damit alle Briefe einheitlich transkribiert werden — egal wer tippt — hier unsere Regeln. Die Seite wächst mit: sobald wir eine neue Konvention beschließen, landet sie hier.",
"richtlinien_wiki_text": "Das vollständige Kurrent- und Sütterlin-Alphabet brauchen Sie für diese Seite nicht — das erledigt Wikipedia. Hier sind unsere eigenen Regeln für das, was Wikipedia nicht beantwortet.", "richtlinien_wiki_text": "Kurrent- und Sütterlin-Alphabete sind bei Wikipedia gut erklärt. Hier stehen nur unsere eigenen Vereinbarungen für dieses Archiv.",
"richtlinien_wiki_link": "Wikipedia", "richtlinien_wiki_link": "Wikipedia",
"richtlinien_rules_label": "Regeln für die Transkription", "richtlinien_rules_label": "Regeln für die Transkription",
"richtlinien_rule_unleserlich_title": "Nicht lesbare Wörter", "richtlinien_rule_unleserlich_title": "Nicht lesbare Wörter",
"richtlinien_rule_unleserlich_body": "Wenn Sie ein Wort beim besten Willen nicht entziffern können, schreiben Sie [unleserlich]. Jemand anderes schaut später nochmal drauf.", "richtlinien_rule_unleserlich_body": "Wenn Sie ein Wort beim besten Willen nicht entziffern können, schreiben Sie [unleserlich]. Jemand anderes schaut später nochmal drauf.",

View File

@@ -515,7 +515,6 @@
"scan_collapse": "Collapse scan", "scan_collapse": "Collapse scan",
"transcription_empty_title": "No transcription yet", "transcription_empty_title": "No transcription yet",
"transcription_empty_desc": "Draw regions on the scan and type the text to create a transcription.", "transcription_empty_desc": "Draw regions on the scan and type the text to create a transcription.",
"transcription_empty_draw_hint": "Draw regions on the document to start transcribing.",
"transcription_panel_close": "Close panel", "transcription_panel_close": "Close panel",
"person_alias_heading": "Name history", "person_alias_heading": "Name history",
"person_alias_empty": "No name changes recorded yet.", "person_alias_empty": "No name changes recorded yet.",
@@ -828,9 +827,9 @@
"transcription_mode_help_body": "Read shows the transcription as flowing text. Edit opens the text fields for each passage.", "transcription_mode_help_body": "Read shows the transcription as flowing text. Edit opens the text fields for each passage.",
"richtlinien_title": "Transcription Guidelines", "richtlinien_title": "Transcription Guidelines",
"richtlinien_intro": "So every letter is transcribed consistently — whether Tante Hedwig or Cousin Paul is typing — here are our rules. The page grows with us: as soon as we agree a new convention, it lands here.", "richtlinien_intro": "So every letter is transcribed consistently — no matter who types — here are our rules. The page grows with us: as soon as we agree a new convention, it lands here.",
"richtlinien_wiki_text": "You don't need the full Kurrent and Sütterlin alphabet on this page — that's what Wikipedia is for. Here are our own rules for everything Wikipedia can't answer.", "richtlinien_wiki_text": "The Kurrent and Sütterlin alphabets are well explained on Wikipedia. Here you'll only find our own conventions for this archive.",
"richtlinien_wiki_link": "Wikipedia", "richtlinien_wiki_link": "Wikipedia",
"richtlinien_rules_label": "Transcription rules", "richtlinien_rules_label": "Transcription rules",
"richtlinien_rule_unleserlich_title": "Illegible words", "richtlinien_rule_unleserlich_title": "Illegible words",
"richtlinien_rule_unleserlich_body": "If you can't decipher a word even after trying, write [unleserlich]. Someone else will take another look later.", "richtlinien_rule_unleserlich_body": "If you can't decipher a word even after trying, write [unleserlich]. Someone else will take another look later.",

View File

@@ -515,7 +515,6 @@
"scan_collapse": "Reducir escaneo", "scan_collapse": "Reducir escaneo",
"transcription_empty_title": "Sin transcripcion", "transcription_empty_title": "Sin transcripcion",
"transcription_empty_desc": "Dibuja regiones en el escaneo y escribe el texto para crear una transcripcion.", "transcription_empty_desc": "Dibuja regiones en el escaneo y escribe el texto para crear una transcripcion.",
"transcription_empty_draw_hint": "Dibuje regiones en el documento para comenzar a transcribir.",
"transcription_panel_close": "Cerrar panel", "transcription_panel_close": "Cerrar panel",
"person_alias_heading": "Historial de nombres", "person_alias_heading": "Historial de nombres",
"person_alias_empty": "Aun no se han registrado cambios de nombre.", "person_alias_empty": "Aun no se han registrado cambios de nombre.",
@@ -828,9 +827,9 @@
"transcription_mode_help_body": "Lectura muestra la transcripción como texto continuo. Edición abre los campos de texto para cada pasaje.", "transcription_mode_help_body": "Lectura muestra la transcripción como texto continuo. Edición abre los campos de texto para cada pasaje.",
"richtlinien_title": "Normas de transcripción", "richtlinien_title": "Normas de transcripción",
"richtlinien_intro": "Para que todas las cartas se transcriban de forma uniforme — ya sea la tía Hedwig o el primo Paul quien escriba — aquí están nuestras reglas. La página crece con nosotros.", "richtlinien_intro": "Para que todas las cartas se transcriban de forma uniforme — sin importar quién transcriba — aquí están nuestras reglas. La página crece con nosotros.",
"richtlinien_wiki_text": "No necesitas el alfabeto Kurrent completo aquí — eso lo hace Wikipedia. Aquí están nuestras propias reglas para lo que Wikipedia no responde.", "richtlinien_wiki_text": "Los alfabetos Kurrent y Sütterlin están bien explicados en Wikipedia. Aquí solo se recogen nuestros propios acuerdos para este archivo.",
"richtlinien_wiki_link": "Wikipedia", "richtlinien_wiki_link": "Wikipedia",
"richtlinien_rules_label": "Reglas de transcripción", "richtlinien_rules_label": "Reglas de transcripción",
"richtlinien_rule_unleserlich_title": "Palabras ilegibles", "richtlinien_rule_unleserlich_title": "Palabras ilegibles",
"richtlinien_rule_unleserlich_body": "Si no puedes descifrar una palabra, escribe [unleserlich]. Otra persona lo revisará después.", "richtlinien_rule_unleserlich_body": "Si no puedes descifrar una palabra, escribe [unleserlich]. Otra persona lo revisará después.",

View File

@@ -1,3 +1,10 @@
<script module>
// Module-level counter produces stable, predictable IDs across SSR + hydration.
// Math.random() would generate different values server-side vs client-side,
// causing a hydration mismatch on first render.
let _counter = 0;
</script>
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
@@ -11,8 +18,9 @@ type Props = {
let { label, placement = 'bottom', children }: Props = $props(); let { label, placement = 'bottom', children }: Props = $props();
const popoverId = `help-popover-${_counter++}`;
let open = $state(false); let open = $state(false);
const popoverId = `help-popover-${Math.random().toString(36).slice(2)}`;
let triggerEl: HTMLButtonElement | null = $state(null); let triggerEl: HTMLButtonElement | null = $state(null);
function toggle() { function toggle() {
@@ -58,6 +66,10 @@ const placementClass: Record<Placement, string> = {
</script> </script>
<div class="relative inline-block"> <div class="relative inline-block">
<!--
Outer button is 44×44px for WCAG 2.5.8 touch-target compliance (our transcriber
audience is 60+). The inner <span> carries the visual 20×20px circle.
-->
<button <button
bind:this={triggerEl} bind:this={triggerEl}
type="button" type="button"
@@ -65,15 +77,20 @@ const placementClass: Record<Placement, string> = {
aria-expanded={open} aria-expanded={open}
aria-controls={popoverId} aria-controls={popoverId}
onclick={toggle} onclick={toggle}
class="flex h-5 w-5 items-center justify-center rounded-full border border-line bg-muted font-sans text-[10px] font-bold text-ink-3 transition-colors hover:border-brand-navy hover:text-brand-navy" class="group flex h-[44px] w-[44px] items-center justify-center rounded-full focus-visible:ring-2 focus-visible:ring-brand-navy"
> >
? <span
class="flex h-5 w-5 items-center justify-center rounded-full border border-line bg-muted font-sans text-[10px] font-bold text-ink-3 transition-colors group-hover:border-brand-navy group-hover:text-brand-navy"
>
?
</span>
</button> </button>
{#if open} {#if open}
<div <div
id={popoverId} id={popoverId}
role="tooltip" role="region"
aria-label={label}
class="absolute z-50 w-64 rounded-sm border border-line bg-white p-3 font-sans text-sm text-ink shadow-md {placementClass[placement]}" class="absolute z-50 w-64 rounded-sm border border-line bg-white p-3 font-sans text-sm text-ink shadow-md {placementClass[placement]}"
> >
{#if children} {#if children}

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, afterEach, vi } from 'vitest'; import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser'; import { page, userEvent } from 'vitest/browser';
import HelpPopover from './HelpPopover.svelte'; import HelpPopover from './HelpPopover.svelte';
afterEach(cleanup); afterEach(cleanup);
@@ -20,7 +20,7 @@ describe('HelpPopover — initial state', () => {
renderPopover(); renderPopover();
const btn = page.getByRole('button', { name: /Help/ }); const btn = page.getByRole('button', { name: /Help/ });
await expect.element(btn).toHaveAttribute('aria-expanded', 'false'); await expect.element(btn).toHaveAttribute('aria-expanded', 'false');
expect(document.querySelector('[role="tooltip"]')).toBeNull(); expect(document.querySelector('[role="region"]')).toBeNull();
}); });
}); });
@@ -30,37 +30,61 @@ describe('HelpPopover — open / close interactions', () => {
await page.getByRole('button', { name: /Help/ }).click(); await page.getByRole('button', { name: /Help/ }).click();
const btn = page.getByRole('button', { name: /Help/ }); const btn = page.getByRole('button', { name: /Help/ });
await expect.element(btn).toHaveAttribute('aria-expanded', 'true'); await expect.element(btn).toHaveAttribute('aria-expanded', 'true');
expect(document.querySelector('[role="tooltip"]')).not.toBeNull(); expect(document.querySelector('[role="region"]')).not.toBeNull();
}); });
it('closes on Esc key', async () => { it('closes on Esc key', async () => {
renderPopover(); renderPopover();
await page.getByRole('button', { name: /Help/ }).click(); await page.getByRole('button', { name: /Help/ }).click();
expect(document.querySelector('[role="tooltip"]')).not.toBeNull(); expect(document.querySelector('[role="region"]')).not.toBeNull();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).toBeNull()); await vi.waitFor(() => expect(document.querySelector('[role="region"]')).toBeNull());
}); });
it('closes on outside click', async () => { it('closes on outside click', async () => {
renderPopover(); renderPopover();
await page.getByRole('button', { name: /Help/ }).click(); await page.getByRole('button', { name: /Help/ }).click();
expect(document.querySelector('[role="tooltip"]')).not.toBeNull(); expect(document.querySelector('[role="region"]')).not.toBeNull();
document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).toBeNull()); await vi.waitFor(() => expect(document.querySelector('[role="region"]')).toBeNull());
}); });
it('opens on Enter key (button is keyboard-reachable, Enter fires click)', async () => { it('opens on Enter key', async () => {
renderPopover(); renderPopover();
await page.getByRole('button', { name: /Help/ }).click(); (document.querySelector('button[aria-expanded]') as HTMLButtonElement).focus();
expect(document.querySelector('[role="tooltip"]')).not.toBeNull(); await userEvent.keyboard('{Enter}');
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
}); });
it('opens on Space key (button is keyboard-reachable, Space fires click)', async () => { it('opens on Space key', async () => {
renderPopover(); renderPopover();
await page.getByRole('button', { name: /Help/ }).click(); (document.querySelector('button[aria-expanded]') as HTMLButtonElement).focus();
expect(document.querySelector('[role="tooltip"]')).not.toBeNull(); await userEvent.keyboard('{Space}');
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
});
});
describe('HelpPopover — hover-target', () => {
it('hover styles propagate from 44px button group to inner span, not from span itself', () => {
const { container } = renderPopover();
const btn = container.querySelector('button[aria-expanded]')!;
const span = btn.querySelector('span')!;
const btnClasses = btn.className.split(/\s+/);
const spanClasses = span.className.split(/\s+/);
expect(btnClasses).toContain('group');
expect(spanClasses).not.toContain('hover:border-brand-navy');
expect(spanClasses).toContain('group-hover:border-brand-navy');
expect(spanClasses).not.toContain('hover:text-brand-navy');
expect(spanClasses).toContain('group-hover:text-brand-navy');
});
it('outer button has focus-visible ring for keyboard users', () => {
const { container } = renderPopover();
const btn = container.querySelector('button[aria-expanded]')!;
expect(btn.className).toContain('focus-visible:ring-2');
expect(btn.className).toContain('focus-visible:ring-brand-navy');
}); });
}); });
@@ -74,4 +98,17 @@ describe('HelpPopover — aria wiring', () => {
const popover = document.getElementById(controls!); const popover = document.getElementById(controls!);
expect(popover).not.toBeNull(); expect(popover).not.toBeNull();
}); });
it('two renders produce different, predictable IDs (no Math.random — SSR safe)', async () => {
const { container: c1 } = render(HelpPopover, { props: { label: 'A' } });
const { container: c2 } = render(HelpPopover, { props: { label: 'B' } });
const id1 = c1.querySelector('button[aria-controls]')?.getAttribute('aria-controls');
const id2 = c2.querySelector('button[aria-controls]')?.getAttribute('aria-controls');
expect(id1).toBeTruthy();
expect(id2).toBeTruthy();
expect(id1).not.toBe(id2);
// IDs must be deterministic (counter-based), not random hex
expect(id1).toMatch(/^help-popover-\d+$/);
expect(id2).toMatch(/^help-popover-\d+$/);
});
}); });

View File

@@ -3,11 +3,21 @@ type Props = {
icon: string; icon: string;
title: string; title: string;
body: string; body: string;
beispielInput?: string;
beispielInputStrike?: boolean;
beispielOutput?: string; beispielOutput?: string;
beispielLabel?: string; beispielLabel?: string;
}; };
let { icon, title, body, beispielOutput, beispielLabel = 'Beispiel' }: Props = $props(); let {
icon,
title,
body,
beispielInput,
beispielInputStrike = false,
beispielOutput,
beispielLabel = 'Beispiel'
}: Props = $props();
</script> </script>
<div class="border-brand-sand break-inside-avoid rounded-sm border bg-white p-5 shadow-sm"> <div class="border-brand-sand break-inside-avoid rounded-sm border bg-white p-5 shadow-sm">
@@ -18,12 +28,18 @@ let { icon, title, body, beispielOutput, beispielLabel = 'Beispiel' }: Props = $
<p class="font-serif text-sm leading-relaxed text-ink-2">{body}</p> <p class="font-serif text-sm leading-relaxed text-ink-2">{body}</p>
{#if beispielOutput !== undefined} {#if beispielOutput !== undefined}
<div class="border-brand-sand mt-4 rounded-sm border bg-[#FAF8F1] px-4 py-3"> <div class="border-brand-sand mt-4 rounded-sm border bg-parchment px-4 py-3">
<p class="font-sans text-xs font-semibold tracking-wider text-ink-3 uppercase"> <p class="font-sans text-xs font-semibold tracking-wider text-ink-3 uppercase">
{beispielLabel} {beispielLabel}
</p> </p>
<p class="mt-1 font-sans text-sm text-ink"> <p class="mt-1 font-sans text-sm text-ink">
<code class="font-mono">{beispielOutput}</code> {#if beispielInput !== undefined}
<code
class={['font-mono', beispielInputStrike && 'line-through'].filter(Boolean).join(' ')}
>{beispielInput}</code
>
{/if}
<code class="font-mono">{beispielOutput}</code>
</p> </p>
</div> </div>
{/if} {/if}

View File

@@ -13,7 +13,7 @@ import TranscribeDragDemo from './TranscribeDragDemo.svelte';
<ol class="m-0 flex list-none flex-col gap-[18px] p-0"> <ol class="m-0 flex list-none flex-col gap-[18px] p-0">
<!-- Step 1 --> <!-- Step 1 -->
<li class="grid gap-3.5" style="grid-template-columns: 34px 1fr; align-items: start;"> <li aria-label="Schritt 1 von 3" class="grid grid-cols-[34px_1fr] items-start gap-3.5">
<span <span
aria-hidden="true" aria-hidden="true"
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white" class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white"
@@ -27,7 +27,7 @@ import TranscribeDragDemo from './TranscribeDragDemo.svelte';
</li> </li>
<!-- Step 2 --> <!-- Step 2 -->
<li class="grid gap-3.5" style="grid-template-columns: 34px 1fr; align-items: start;"> <li aria-label="Schritt 2 von 3" class="grid grid-cols-[34px_1fr] items-start gap-3.5">
<span <span
aria-hidden="true" aria-hidden="true"
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white" class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white"
@@ -40,7 +40,7 @@ import TranscribeDragDemo from './TranscribeDragDemo.svelte';
</li> </li>
<!-- Step 3 --> <!-- Step 3 -->
<li class="grid gap-3.5" style="grid-template-columns: 34px 1fr; align-items: start;"> <li aria-label="Schritt 3 von 3" class="grid grid-cols-[34px_1fr] items-start gap-3.5">
<span <span
aria-hidden="true" aria-hidden="true"
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white" class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white"

View File

@@ -1,7 +1,19 @@
<script lang="ts"> <script lang="ts">
const prefersReducedMotion = $derived( // $derived from .matches is a one-shot snapshot — it doesn't react when the
// user toggles the OS setting at runtime. Use $state + addEventListener instead.
let prefersReducedMotion = $state(
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
); );
$effect(() => {
if (typeof window === 'undefined') return;
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
const handler = (e: MediaQueryListEvent) => {
prefersReducedMotion = e.matches;
};
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
});
</script> </script>
{#if prefersReducedMotion} {#if prefersReducedMotion}
@@ -10,7 +22,7 @@ const prefersReducedMotion = $derived(
role="img" role="img"
aria-label="Eine gestrichelte Umrandung markiert eine Zeile Kurrentschrift auf dem Dokument." aria-label="Eine gestrichelte Umrandung markiert eine Zeile Kurrentschrift auf dem Dokument."
viewBox="0 0 600 180" viewBox="0 0 600 180"
class="border-brand-sand block w-full rounded-sm border bg-[#FAF8F1]" class="border-brand-sand block w-full rounded-sm border bg-parchment"
> >
<g <g
stroke="#2a2a2a" stroke="#2a2a2a"
@@ -61,7 +73,7 @@ const prefersReducedMotion = $derived(
role="img" role="img"
aria-label="Animation: Ein Cursor zieht einen gestrichelten Rahmen um eine Zeile Kurrentschrift. Beim Loslassen wird der Rahmen durchgehend und ein Häkchen erscheint." aria-label="Animation: Ein Cursor zieht einen gestrichelten Rahmen um eine Zeile Kurrentschrift. Beim Loslassen wird der Rahmen durchgehend und ein Häkchen erscheint."
viewBox="0 0 600 180" viewBox="0 0 600 180"
class="border-brand-sand block w-full rounded-sm border bg-[#FAF8F1]" class="border-brand-sand block w-full rounded-sm border bg-parchment"
> >
<!-- Kurrent writing (static) --> <!-- Kurrent writing (static) -->
<g <g

View File

@@ -177,6 +177,6 @@ describe('TranscriptionPanelHeader', () => {
const helpBtn = document.querySelector('button[aria-expanded]') as HTMLButtonElement; const helpBtn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
helpBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); helpBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).not.toBeNull()); await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
}); });
}); });

View File

@@ -3,7 +3,6 @@ import { goto } from '$app/navigation';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser'; import { page, userEvent } from 'vitest/browser';
import BulkDocumentEditLayout from './BulkDocumentEditLayout.svelte'; import BulkDocumentEditLayout from './BulkDocumentEditLayout.svelte';
import { createConfirmService, CONFIRM_KEY } from '$lib/services/confirm.svelte.js';
vi.mock('$app/navigation', () => ({ goto: vi.fn() })); vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
@@ -290,29 +289,6 @@ describe('BulkDocumentEditLayout', () => {
expect(mockFetch).toHaveBeenCalledTimes(1); expect(mockFetch).toHaveBeenCalledTimes(1);
}); });
it('discard-all does not clear files when the user cancels the confirm dialog', async () => {
const service = createConfirmService();
const { container } = render(BulkDocumentEditLayout, {
context: new Map([[CONFIRM_KEY, service]])
});
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
const discardBtn = container.querySelector(
'button[data-testid="discard-all-btn"]'
) as HTMLButtonElement;
discardBtn.click();
// The confirm dialog should open (service.options not null)
await vi.waitFor(() => expect(service.options).not.toBeNull(), { timeout: 1000 });
// Cancel — files should remain
service.settle(false);
await vi.waitFor(
() => expect(container.querySelector('[data-testid="file-switcher-strip"]')).not.toBeNull(),
{ timeout: 1000 }
);
});
it('discard-all resets to N=0 state and shows drop zone', async () => { it('discard-all resets to N=0 state and shows drop zone', async () => {
const { container } = render(BulkDocumentEditLayout, {}); const { container } = render(BulkDocumentEditLayout, {});
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]); await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);

View File

@@ -13,18 +13,22 @@ const rules = [
icon: '✗', icon: '✗',
title: m.richtlinien_rule_durchgestrichen_title(), title: m.richtlinien_rule_durchgestrichen_title(),
body: m.richtlinien_rule_durchgestrichen_body(), body: m.richtlinien_rule_durchgestrichen_body(),
beispielInput: 'der Text',
beispielInputStrike: true,
beispielOutput: '[durchgestrichen: der Text]' beispielOutput: '[durchgestrichen: der Text]'
}, },
{ {
icon: 'ſ', icon: 'ſ',
title: m.richtlinien_rule_langes_s_title(), title: m.richtlinien_rule_langes_s_title(),
body: m.richtlinien_rule_langes_s_body(), body: m.richtlinien_rule_langes_s_body(),
beispielOutput: 's' beispielInput: 'ſtraße',
beispielOutput: 'straße'
}, },
{ {
icon: '?', icon: '?',
title: m.richtlinien_rule_name_title(), title: m.richtlinien_rule_name_title(),
body: m.richtlinien_rule_name_body(), body: m.richtlinien_rule_name_body(),
beispielInput: 'Müller',
beispielOutput: '[Müller?]' beispielOutput: '[Müller?]'
}, },
{ {
@@ -46,7 +50,7 @@ const klaerungChips = [
<title>{m.richtlinien_title()} — Familienarchiv</title> <title>{m.richtlinien_title()} — Familienarchiv</title>
</svelte:head> </svelte:head>
<div class="mx-auto max-w-2xl px-4 py-10 font-serif"> <main class="mx-auto max-w-2xl px-4 py-10 font-serif">
<!-- Title --> <!-- Title -->
<h1 class="mb-4 text-3xl font-bold text-ink">{m.richtlinien_title()}</h1> <h1 class="mb-4 text-3xl font-bold text-ink">{m.richtlinien_title()}</h1>
@@ -61,10 +65,23 @@ const klaerungChips = [
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
referrerpolicy="no-referrer" referrerpolicy="no-referrer"
class="inline-flex items-center gap-1 font-sans text-sm font-medium text-ink underline decoration-brand-mint decoration-[1.5px] underline-offset-[3px]" aria-label="{m.richtlinien_wiki_link()}{m.common_opens_new_tab()}"
class="inline-flex items-center gap-1.5 font-sans text-sm font-medium text-ink underline decoration-brand-mint decoration-[1.5px] underline-offset-[3px]"
> >
{m.richtlinien_wiki_link()} {m.richtlinien_wiki_link()}
<span class="new-tab ml-1 text-[11px] text-ink-3">({m.common_opens_new_tab()})</span> <svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-3.5 w-3.5 shrink-0"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M4.25 5.5a.75.75 0 0 0-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75v-4a.75.75 0 0 1 1.5 0v4A2.25 2.25 0 0 1 12.75 17h-8.5A2.25 2.25 0 0 1 2 14.75v-8.5A2.25 2.25 0 0 1 4.25 4h5a.75.75 0 0 1 0 1.5h-5Zm6.75-3a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0V3.56l-4.22 4.22a.75.75 0 0 1-1.06-1.06l4.22-4.22H11a.75.75 0 0 1-.75-.75Z"
clip-rule="evenodd"
/>
</svg>
</a> </a>
</div> </div>
@@ -78,6 +95,8 @@ const klaerungChips = [
icon={rule.icon} icon={rule.icon}
title={rule.title} title={rule.title}
body={rule.body} body={rule.body}
beispielInput={rule.beispielInput}
beispielInputStrike={rule.beispielInputStrike}
beispielOutput={rule.beispielOutput} beispielOutput={rule.beispielOutput}
beispielLabel={m.richtlinien_beispiel_label()} beispielLabel={m.richtlinien_beispiel_label()}
/> />
@@ -102,10 +121,10 @@ const klaerungChips = [
<!-- Closing card --> <!-- Closing card -->
<div class="border-brand-sand rounded-sm border bg-white p-6 shadow-sm"> <div class="border-brand-sand rounded-sm border bg-white p-6 shadow-sm">
<h2 class="mb-2 font-serif text-lg font-bold text-ink">{m.richtlinien_closing_title()}</h2> <h3 class="mb-2 font-serif text-lg font-bold text-ink">{m.richtlinien_closing_title()}</h3>
<p class="font-serif text-sm leading-relaxed text-ink-2">{m.richtlinien_closing_body()}</p> <p class="font-serif text-sm leading-relaxed text-ink-2">{m.richtlinien_closing_body()}</p>
</div> </div>
</div> </main>
<style> <style>
@media print { @media print {
@@ -113,10 +132,6 @@ const klaerungChips = [
display: none; display: none;
} }
.new-tab {
display: none;
}
@page { @page {
margin: 1.5cm; margin: 1.5cm;
} }

View File

@@ -1 +1,3 @@
// Safe: handleAuth in hooks.server.ts redirects unauthenticated requests
// before prerendered HTML is visible.
export const prerender = true; export const prerender = true;

View File

@@ -66,6 +66,9 @@
/* Focus ring — keyboard focus indicator, mode-aware (navy in light, mint in dark) */ /* Focus ring — keyboard focus indicator, mode-aware (navy in light, mint in dark) */
--color-focus-ring: var(--c-focus-ring); --color-focus-ring: var(--c-focus-ring);
/* Parchment — warm background for code/example blocks inside cards */
--color-parchment: var(--c-parchment);
/* Danger — destructive action color */ /* Danger — destructive action color */
--color-danger: var(--c-danger); --color-danger: var(--c-danger);
--color-danger-fg: var(--c-danger-fg); --color-danger-fg: var(--c-danger-fg);
@@ -122,6 +125,9 @@
--c-danger: #c0392b; --c-danger: #c0392b;
--c-danger-fg: #ffffff; --c-danger-fg: #ffffff;
/* Parchment — warm near-white for example blocks (light mode: cream #FAF8F1) */
--c-parchment: #faf8f1;
/* Tag color tokens — decorative dot colors on tag chips */ /* Tag color tokens — decorative dot colors on tag chips */
--c-tag-sage: #5a8a6a; --c-tag-sage: #5a8a6a;
--c-tag-sienna: #a0522d; --c-tag-sienna: #a0522d;
@@ -203,6 +209,9 @@
--c-danger: #e55347; --c-danger: #e55347;
--c-danger-fg: #ffffff; --c-danger-fg: #ffffff;
/* Parchment — subtle surface shift for example blocks on dark navy */
--c-parchment: #041828;
/* Tag color tokens — lighter for visibility on dark backgrounds */ /* Tag color tokens — lighter for visibility on dark backgrounds */
--c-tag-sage: #7abf8a; --c-tag-sage: #7abf8a;
--c-tag-sienna: #cc7050; --c-tag-sienna: #cc7050;
@@ -267,6 +276,9 @@
--c-danger: #e55347; --c-danger: #e55347;
--c-danger-fg: #ffffff; --c-danger-fg: #ffffff;
/* Parchment — subtle surface shift for example blocks on dark navy */
--c-parchment: #041828;
/* Tag color tokens — lighter for visibility on dark backgrounds */ /* Tag color tokens — lighter for visibility on dark backgrounds */
--c-tag-sage: #7abf8a; --c-tag-sage: #7abf8a;
--c-tag-sienna: #cc7050; --c-tag-sienna: #cc7050;