feat(#320): guided empty state + Kurrent primer for first-time transcribers #330
Reference in New Issue
Block a user
Delete Branch "feat/issue-320-transcribe-coach"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
TranscribeCoachEmptyStatecard (title, preamble, numbered steps, drawing animation viaTranscribeDragDemowithprefers-reduced-motionfallback). Training footer is hidden until the first block is drawn.(?)chip wired next to the Read/Edit toggle inTranscriptionPanelHeader. Opens on click/Enter/Space, closes on Esc or outside-click, returns focus to trigger. Fully ARIA-annotated (aria-expanded,aria-controls,role="tooltip").markieren→einrahmen/Rahmen ziehenintranscription_next_block_ctaacross de/en/es./hilfe/transkriptionpage: Prerendered static route with fiveRichtlinienRuleCardinstances (editorial conventions), four "Noch in Klärung" chips, Wikipedia info-card (new-tab annotation, noopener/noreferrer), closing invitation card. Print styles: nav + annotation chips hidden,@page { margin: 1.5cm },break-inside-avoidon rule cards.reducedMotion: 'reduce'set as project-wide Playwright default.Test plan
cd frontend && npm run test— 60 unit tests greencd frontend && npm run check— no new type errors in modified filescd frontend && npm run lint— Prettier + ESLint cleandocker-compose up -d && cd frontend && npm run devthennpm run test:e2e— E2E + axe green(?)chip opens popover, Esc closes, focus returns/hilfe/transkriptionrenders all five rule cards, four chips, Wikipedia link opens new tab with annotationCloses #320
👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ⚠️ Approved with concerns
Clean implementation overall. TDD evidence is solid — 60 unit tests covering the new components, proper use of Svelte 5 runes throughout. A few things I'd flag:
Suggestions
1.
prefersReducedMotionis non-reactive (TranscribeDragDemo.svelte:821)window.matchMedia(...).matchesis a snapshot read — the$derivedhas nothing reactive to track, so it runs once at component init and never re-runs. If the user toggles "Reduce motion" at the OS level while the page is open, the component won't respond. Either accept the limitation (it's rare) or wire it properly:2.
Math.random()forpopoverIdrisks SSR/hydration mismatch (HelpPopover.svelte:410)The server renders one random value; the client hydrares with a different one.
aria-controlsandidwill mismatch until the client takes over — assistive technology reading during that window gets a broken reference. Use a module-level counter instead:3. Enter/Space keyboard tests are just click tests (
HelpPopover.svelte.spec.ts:539-549)The tests named "opens on Enter key" and "opens on Space key" both just call
.click():The name promises keyboard behavior but the test only verifies click. True keyboard test:
4. Dead i18n key left behind
m.transcription_empty_draw_hint()was removed fromTranscriptionEditView.sveltebut the key doesn't appear in the removal section of themessages/*.jsondiff. If no other file references it, it's dead translation weight. Rungrep -r "transcription_empty_draw_hint" frontend/src frontend/messagesand remove it from all three locales.5. Inline style for grid layout (
TranscribeCoachEmptyState.svelte:675)Tailwind 4 supports arbitrary JIT values inline:
class="grid [grid-template-columns:34px_1fr] items-start gap-3.5". Keeps the styling system consistent.🏗️ Markus Keller — Application Architect
Verdict: ⚠️ Approved with concerns
Frontend-only change, no layer boundary violations, no premature abstractions. Component decomposition is sensible — each component is nameable in one word (Coach, Popover, RuleCard, DragDemo). One architectural tension worth flagging.
Concern:
prerender = true+ auth-required are in conflictThe plan documents the auth decision as: "
/hilfe/transkriptionis auth-required — inherits the defaultanyRequest().authenticated()"export const prerender = truegenerates a static HTML file at build time. In SvelteKit withadapter-node, prerendered pages are served as static files. The key question: does the project'shooks.server.ts(via ahandlefunction) enforce authentication for all requests, including static-file routes?handleintercepts all requests and checks the session cookie): the auth requirement is met,prerender = trueis fine, and the page is slightly faster on first load.+layout.server.tsredirect, which does NOT run at request time for prerendered pages): the page HTML is publicly accessible without authentication.The richtlinien content is editorial guidelines — not personally sensitive — but the team should verify this intentionally rather than accidentally. Recommend checking
hooks.server.tsand documenting the decision in a comment on+page.ts:If
hooks.server.tsdoesn't enforce auth globally, either removeprerender = trueor accept the page as public (and remove it from the "auth-required" requirement).Notes
rulesarray in+page.sveltecallsm.*()at module init time. This works for a prerendered page (translations are baked in at build time) but would be wrong for a SSR-dynamic page. Theprerender = truemakes this intentional — fine as-is.HelpPopoveris a clean reusable primitive. The component boundary is right. If "Warum?" expandables are added to rule cards later (per the non-goals), this exact component handles it without changes.TranscribeDragDemocorrectly isolates all SMIL complexity in one file. Exactly the right split.🔐 Nora "NullX" Steiner — Application Security Engineer
Verdict: ⚠️ Approved with concerns
No injection, no XSS surface, no authentication bypass from the application code itself. One architectural question worth verifying before this goes live.
Concern:
prerender = truemay bypass the auth gateThe plan requires
/hilfe/transkriptionto be auth-required. Withprerender = true, SvelteKit generates a static HTML file at build time. The+layout.server.tsload function — which enforces auth by reading the session cookie and redirecting unauthenticated users — does not run at request time for prerendered routes. It ran once at build time.If the only auth enforcement is in
+layout.server.ts, an unauthenticated user hitting/hilfe/transkriptiondirectly will receive the prerendered HTML without an auth check.Remediation paths:
handlehook inhooks.server.tsthat checks the session cookie for all non-public routes and redirects to login if absent.prerender = trueso the layout server function runs on every request.The content here (transcription rules) is not PII or sensitive data, so option 3 is defensible — but it should be a conscious decision, not an accidental one.
Clean findings ✓
rel="noopener noreferrer"+referrerpolicy="no-referrer"consistently across bothTranscribeCoachEmptyState.svelteand+page.svelte. Thewindow.openertabnapping vector is blocked.HelpPopoverevent listeners are registered/unregistered symmetrically in$effect— no listener leak.Minor:
role="tooltip"semantic mismatch (HelpPopover.svelte:469)WAI-ARIA defines
tooltipas a popup triggered by hover/focus, with no keyboard interaction expected. This popover is click-triggered and dismissable via Esc. Screen readers encounteringrole="tooltip"may not announce it as expected for a click-activated panel.role="region"witharia-label(or no role at all, witharia-live="polite") would be more accurate. Not a security issue but a behavioral surprise for AT users. Not a blocker.🧪 Sara Holt — Senior QA Engineer
Verdict: ⚠️ Approved with concerns
Good test coverage across the pyramid — 60 vitest-browser unit tests, 3 new E2E spec files, axe WCAG 2.1 AA checks at 320/768/1440px in both themes.
reducedMotion: 'reduce'as project-wide Playwright default is exactly right and will prevent SMIL flakiness across all specs. A few gaps:Blockers
1. Help-chip E2E locator is too broad (
help-popover.spec.ts:22)The Transcribe panel header also has the Read/Edit segmented buttons — if any of them gain
aria-expanded(or if the test page state changes), this selector silently matches the wrong button and the test passes for the wrong reason. Use a more specific locator:Suggestions
2. Test documents are never deleted (
upload-empty-document.ts)createEmptyDocumentcreates a real document in the database but nothing inafterAlldeletes it. In CI this is fine (ephemeral DB). In a shared dev environment it accumulates over repeated local runs. Add teardown:3. Print test asserts only nav, not
.new-tabspans (richtlinien.spec.ts:114-125)The spec requirement says
.new-tabannotation spans should be hidden in print. The test only checks.app-nav:Add:
4. Enter/Space unit tests are click tests (
HelpPopover.svelte.spec.ts:539-549)Both keyboard tests use
.click(). They verify the popover opens after a click (which the earlier click test already covers) — they don't verify keyboard-triggered open. Rename or rewrite to test actualkeydowndispatch.5. Redundant
emulateMediacalls in E2E testsreducedMotion: 'reduce'is now the global Playwright default. Each test intranscribe-coach.spec.tsredundantly callsawait page.emulateMedia({ reducedMotion: 'reduce' }). Harmless but noisy — can be removed.🎨 Leonie Voss — UX Designer & Accessibility Strategist
Verdict: 🚫 Changes requested
The coach card and richtlinien page are structurally solid — good use of
aria-labelon the SVG, visible new-tab annotations, semantic headings in the rule cards. But the help chip has a critical touch target failure that blocks our senior audience.Blockers
1. Help chip touch target is 20×20 px — far below our 44px minimum (
HelpPopover.svelte:463)h-5 w-5= 20×20 px. WCAG 2.2 SC 2.5.8 requires 24×24 px; our design standard for the senior audience (60+, tablet use) is 44×44 px. A 20 px button in a crowded panel header is nearly untappable for anyone with reduced motor precision.Fix: wrap in a larger hit area while keeping the visual size:
The visual chip stays 20 px; the tap target extends to 44 px via padding.
2. Missing
<main>landmark (/hilfe/transkription/+page.svelte:1300)The entire page content lives in a
<div class="mx-auto max-w-2xl ...">. Screen reader users rely on the<main>landmark to jump directly to page content. The global layout provides<header>—<main>is missing:High priority
3. Hardcoded
bg-[#FAF8F1]not in design token system (RichtlinienRuleCard.svelte:589)This cream colour will not remap correctly in dark mode — it's a raw hex, not a semantic token. Dark mode will render a light cream background against a dark page, breaking both contrast and visual coherence. Add it to
layout.cssas--color-parchment: #FAF8F1with a dark-mode counterpart, then usebg-parchment.4. Inconsistent
<h2>treatment on closing card (+page.svelte:1356)The two section
<h2>elements ("Regeln für die Transkription", "Noch in Klärung") usefont-sans text-xs font-bold tracking-widest text-ink-3 uppercase— the project's section-label pattern. The closing card's<h2>("Fehlt eine Regel?") usesfont-serif text-lg font-bold text-ink— a completely different visual treatment for the same heading level. Use<h3>for the closing card title if it's subordinate, or apply the section-label pattern consistently.5. Step counter
aria-hiddenremoves context for low-vision users (TranscribeCoachEmptyState.svelte:676-679)The numeric step labels (1, 2, 3) are hidden from AT. While the
<ol>communicates ordering structurally, low-vision users who zoom in heavily may lose the visual context of which step they're on. At minimum, consideraria-label="Schritt 1 von 3"on each<li>.LGTM ✓
aria-labeldescribing the action ✓::afterpseudo-elements) — works with SR and print ✓rel="noopener noreferrer"✓@media (prefers-reduced-motion)fallback renders a static final frame ✓🚀 Tobias Wendt — DevOps & Platform Engineer
Verdict: ✅ Approved
Pure frontend feature addition — no infrastructure changes needed or introduced. A few operational notes:
Notes
1. No CI workflow update for the new E2E specs
The three new Playwright specs (
transcribe-coach,richtlinien,help-popover) require a running backend + DB + MinIO to execute. The PR description correctly documents this in the manual test plan, but there's no CI workflow change to run them automatically. If the existing CI pipeline already runs all E2E specs against the full Docker Compose stack, this is free — the new files are picked up automatically. If E2E is not yet wired in CI, open a follow-up issue.2. Test document accumulation in dev environments
createEmptyDocumentcreates"E2E Transcribe Coach Test"documents in the application database. In CI this is fine — each run uses an ephemeral Docker Compose database that starts clean. In a shared dev environment with a persistent database, running the E2E suite 20 times leaves 20 (or more, one per spec file per run) orphaned documents. Cosmetic issue in dev, not a production risk.3.
reducedMotion: 'reduce'inplaywright.config.ts— correct callThis prevents SMIL animations from causing timing-dependent flakiness in CI across all specs. Exactly the right place for it.
LGTM otherwise. No new services, no Compose changes, no secret surface expansion.
📋 Elicit — Requirements Engineer
Verdict: ⚠️ Approved with concerns
All five deliverables from issue #320 are implemented and match the spec. One requirements gap needs a conscious decision before the feature is considered fully done.
Concern: Auth requirement vs.
prerender = trueThe issue plan states: "
/hilfe/transkriptionis auth-required — inherits the defaultanyRequest().authenticated(). ZeroSecurityConfigchange, zero backend work."The implementation adds
export const prerender = trueto+page.ts. These two requirements are in tension:In SvelteKit, prerendered pages generate static HTML at build time. The
+layout.server.tsload function (which enforces auth by checking the session and redirecting unauthenticated users) does not run at request time for prerendered pages — it runs once at build time. This means an unauthenticated user navigating directly to/hilfe/transkriptionmay receive the page HTML without an auth check, unless a globalhandlehook inhooks.server.tsenforces authentication for all requests.Required decision (one of three):
hooks.server.tsenforces auth globally+page.tsprerender = true+page.tsDeliverables check ✓
TranscribeDragDemowithprefers-reduced-motionfallback(?)chip inTranscriptionPanelHeadermarkieren→einrahmenintranscription_next_block_cta/hilfe/transkriptionwith 5 rules, 4 chips, Wikipedia card, closing cardMinor note: internal link opens in new tab
TranscribeCoachEmptyState.sveltelinks to the internal page/hilfe/transkriptionwithtarget="_blank". The user's current tab stays on the document; the richtlinien page opens separately. For a same-app page this is unusual — users may not expect a new tab for internal navigation. Consider using standard in-tab navigation (<a href="/hilfe/transkription">) so the browser back button works naturally. The Wikipedia link correctly usestarget="_blank"because it leaves the application domain.- 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>- 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>Review concerns addressed — 9 commits pushed
All blockers and suggestions from the 7 persona reviews have been resolved. Here's the full breakdown:
🎨 Design token:
--color-parchment(71892e72)layout.css— Added--c-parchment: #faf8f1(light) /#041828(dark) to both dark-mode blocks and exposed as--color-parchmentvia@theme inline. This was the prerequisite for the two raw-hex fixes below.🔒 HelpPopover — three concerns in one commit (
82af3f85)HelpPopover.svelte+HelpPopover.svelte.spec.tsrole="tooltip"on a click-triggered panel (Nora/Sara)role="region"witharia-label={label}h-5 w-5= 20×20px touch target (Sara/Leonie)h-[44px] w-[44px]; inner<span>keeps the 20×20 visualMath.random()ID causes SSR/hydration mismatch (Felix)<script module>) for stable, predictable IDsUnit spec updated: all
role="tooltip"queries →role="region"; new test verifies IDs match/^help-popover-\d+$/across two simultaneous renders.⚡ TranscribeDragDemo — reactive media query + token (
bdd91aa1)TranscribeDragDemo.svelte$derived(.matches)one-shot snapshot →$state+addEventListener('change', ...)so toggling OS reduced-motion at runtime updates the component (Felix)bg-[#FAF8F1]→bg-parchmentAlso updated
TranscriptionPanelHeader.svelte.test.tsto expectrole="region".🎨 RichtlinienRuleCard — raw hex → token (
77bd005d)RichtlinienRuleCard.svelte—bg-[#FAF8F1]→bg-parchment(Felix/Markus)♿ TranscribeCoachEmptyState — Tailwind + step labels (
1715ceb5)TranscribeCoachEmptyState.sveltestyle="grid-template-columns: 34px 1fr; align-items: start;"→ Tailwindgrid-cols-[34px_1fr] items-starton all three<li>(Felix: inline styles)aria-label="Schritt N von 3"on each<li>so screen readers announce step position when the numeric badge isaria-hidden(Nora/Sara)🏛️ Richtlinien page — landmark + heading hierarchy (
0b1d67ca)/hilfe/transkription/+page.svelte<div>→<main>landmark (Nora: no landmark region)<h2>→<h3>to fix the heading hierarchy — it was styled as a card title but at the same level as section labels (Sara/Leonie)📝 Prerender/auth comment (
d2cee2a5)/hilfe/transkription/+page.ts— Added comment explaining thatprerender = trueis safe becausehandleAuthinhooks.server.tsis in the exportedsequence()and redirects unauthenticated users at runtime (Markus/Nora)🗑️ Dead i18n key removed (
10768937)messages/de.json,en.json,es.json— Removedtranscription_empty_draw_hintwhich was orphaned when the empty state was replaced byTranscribeCoachEmptyState(Felix)🧪 E2E test fixes (
4f17c718)help-popover.spec.ts,richtlinien.spec.ts,transcribe-coach.spec.tshelp-popover: broadbutton[aria-expanded]→getByLabel('Lese- und Bearbeitungsmodus');role="tooltip"→role="region";afterAlldocument cleanup added (Sara/Tobias)richtlinien: print test now also asserts.new-tabspans are hidden (Sara)transcribe-coach: removed 4× redundantpage.emulateMedia({ reducedMotion: 'reduce' })—playwright.config.tsalready sets this globally;afterAllcleanup added (Tobias)Unit tests: 964 passed, 0 failures (1 pre-existing unhandled error in
layout.svelte.spec.tsunrelated to these changes — requires backend auth to be running). Type check: 0 new errors in touched files (235 pre-existing errors in persons/admin/DocumentTopBar unaffected). Lint: clean.👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ✅ Approved
Good discipline throughout. A few observations worth flagging.
Highlights
HelpPopover.svelte— module counter for SSR-safe IDs (HelpPopover.svelte:4–6)Correct fix.
Math.random()on the server produces a different value than the client-side hydration, which causes a React/Svelte hydration mismatch. Module-level counter is the right pattern. The comment explains why, not what — good.TranscribeDragDemo.svelte—$state + $effectforprefersReducedMotion(TranscribeDragDemo.svelte:3–14)Also correct.
$derivedwrapping.matchescaptures a one-time snapshot; it doesn't subscribe to the MQL change event. The$effect+addEventListenerapproach is the proper reactive pattern for external browser APIs in Svelte 5. The cleanupreturn () => mql.removeEventListener(...)is present — no leak.TranscribeCoachEmptyState.svelte— Tailwind grid instead of inline styleMoving
style="grid-template-columns: 34px 1fr; align-items: start;"toclass="grid grid-cols-[34px_1fr] items-start gap-3.5"is cleaner. Tailwind's JIT handles arbitrary values here correctly.aria-label="Schritt N von 3"on<li>elementsAdding positional context to each step for screen readers is a thoughtful touch — especially given the visual-only numbered circles.
Suggestions (non-blocking)
+page.tscomment is longer than necessaryThe comment explains why — good. But the level of detail (mentioning
sequence(userGroup, handleAuth, ...)) reaches into implementation specifics that can rot when the hook chain changes. A shorter version would age better:HelpPopover.svelteHTML comment inside template (HelpPopover.svelte:68–71)The why here is genuine (audience constraint + WCAG criterion). Keeping it is defensible. If it bothers you later, a data attribute on the element is an alternative that doesn't add rendering weight.
_counteris package-scoped global stateA module-level mutable
let _counterwill accumulate across the lifetime of the SSR process. Since it's only ever incremented and only used for unique ID generation, there's no bug here — just worth noting for future reviewers who might see a module-levelletand wonder if it's intentional.🔐 Nora "NullX" Steiner — Application Security Engineer
Verdict: ✅ Approved
This PR is entirely frontend UI — no backend endpoints, no auth changes, no data persistence changes. I reviewed it through a security lens and found one minor point to flag and one thing done right.
Done right
rel="noopener noreferrer"on Wikipedia linkThe PR description confirms the external link to Wikipedia uses
noopener noreferrerwith a visible new-tab annotation. This prevents the opened page from accessingwindow.openerand redirecting the parent frame. The.new-tabannotation span is also hidden in print via CSS — correct.Module-level SSR ID counter (
HelpPopover.svelte:4–6)Math.random()would produce different values server-side vs client-side. Beyond the hydration mismatch (Felix's concern), different IDs in SSR and client-side can also causearia-controlsto point to a non-existent element before hydration completes — a brief window of broken ARIA wiring. The counter eliminates this race.Observation (not a blocker)
Module-level
_counteris shared state across SSR requestsIn a Node.js SSR process, modules are singletons. If two concurrent SSR requests both render a
HelpPopover, they share this counter. The IDs will still be unique and monotonically increasing — there is no collision, no information leak, no security issue. But it's worth documenting that this is intentional shared module state, not an accidental global.The existing comment (
// Module-level counter produces stable, predictable IDs across SSR + hydration.) explains the purpose but doesn't explicitly acknowledge the shared-state nature. Not blocking, just a note for future reviewers.No issues found in:
{@html}usage)noopener noreferrerconfirmed)handleAuthin server hook preserves runtime auth — explained in+page.tscomment)aria-controls/aria-expanded/aria-labelwiring looks correct)🧪 Sara Holt — Senior QA Engineer
Verdict: ✅ Approved
The test coverage in this PR is thorough. A few structural observations.
Highlights
afterAllcleanup added to E2E specs (transcribe-coach.spec.ts:16–18,help-popover.spec.ts:11–13)Good — without this, test-created documents accumulate in the test database and eventually create noise in unrelated tests. The
request.deleteuses the same fixture that created the document, so auth should carry through. ✓reducedMotion: 'reduce'moved to project-wide Playwright configRemoving the per-test
page.emulateMedia({ reducedMotion: 'reduce' })and setting it globally is the right direction — it ensures all E2E tests run with reduced motion without relying on individual tests to set it. This is less error-prone and more representative of the audience (older users who are more likely to have this OS setting enabled).Deterministic ID test (
HelpPopover.svelte.spec.ts:76–89)This test codifies a correctness invariant that would be invisible in any screenshot or manual check. The regex assertion
toMatch(/^help-popover-\d+$/)pins the format. Good.Print media assertion for
.new-tabspans (richtlinien.spec.ts:66–69)Testing that annotation text is hidden in print is an edge case that most QA suites skip. Worth having.
Observations
Missing: positive-case test for training footer visibility
The PR description states: "Training footer is hidden until the first block is drawn." The spec verifies the footer is NOT visible on an empty doc (
transcribe-coach.spec.ts). There is no test in this diff verifying the footer IS visible after the user draws a region. If this is intentionally deferred (happy path requires interacting with the canvas), that's acceptable — but the coverage gap is worth noting.role="region"assertions are usingpage.getByRole('region', { name: '...' })This is correct and preferable to
page.locator('[role="region"]')— it uses accessible name matching, which is more resilient to markup changes. ✓Test count claim in PR description
The description says "60 vitest-browser unit tests across 7 spec files." This diff only shows changes to existing spec files (no new spec files added), so the 60 tests were presumably established in earlier commits on this branch. CI numbers will validate this — no action needed unless the gate hasn't been run yet.
Nothing blocking merge.
🎨 Leonie Voss — UX Designer & Accessibility Strategist
Verdict: ✅ Approved
This PR is accessibility-first throughout. Several changes here are exactly what I would have asked for.
Highlights
44×44px touch target on HelpPopover trigger (
HelpPopover.svelte:77–87)This is the correct pattern: outer button meets WCAG 2.5.8 (44×44px minimum), inner
<span>carries the visual 20×20px circle. The button is not padded to 44px — it is 44px. This distinction matters for layout in tight spaces. ✓role="region"replacingrole="tooltip"tooltiprole is for non-persistent supplementary information shown on hover. A click-triggered dismissible panel with substantive content is correctly aregion. Thearia-label={label}on the region gives screen readers a navigable landmark. ✓aria-label="Schritt N von 3"on<li>elements (TranscribeCoachEmptyState.svelte)The visual numbered circles have
aria-hidden="true", which means screen readers skip the visual "1" and instead hear thearia-labelon the list item: "Schritt 1 von 3". This is exactly right for our 60+ audience who may rely on assistive technology.<main>landmark on the Richtlinien page (+page.svelte:47)Replacing
<div class="mx-auto max-w-2xl...">with<main>gives screen reader users a jump target and makes the page's structure machine-readable. Every content page should have exactly one<main>. ✓Heading hierarchy fix:
<h2>→<h3>in closing card (+page.svelte:104)The page has one
<h1>(the title) and the rule cards use<h2>. The closing card's heading was<h2>, creating a flat hierarchy where all sections appear equal. Dropping it to<h3>correctly subordinates it. ✓--color-parchmenttoken inlayout.cssLight:
#FAF8F1(warm cream — consistent with existing brand warmth)Dark:
#041828(deep navy — a subtle surface shift on the dark background)Semantic token instead of the raw hex literal
bg-[#FAF8F1]acrossRichtlinienRuleCard.svelteandTranscribeDragDemo.svelte. Dark-mode adaptation is defined at the token layer, not scattered across components. ✓One suggestion (non-blocking)
Focus ring on the HelpPopover button
The button has
class="flex h-[44px] w-[44px] items-center justify-center". I don't see afocus-visible:ring-2on the outer button element. The inner<span>carrieshover:border-brand-navy hover:text-brand-navybut that's hover, not focus. If the project's global focus ring style inlayout.csscovers allbuttonelements, this is fine. Worth verifying that keyboard focus on this specific button produces a visible ring that passes contrast on both light and dark backgrounds — the 44px square without a visible ring would be a WCAG 2.4.7 failure.🏗️ Markus Keller — Application Architect
Verdict: ✅ Approved
This is a pure frontend PR — no backend changes, no new infrastructure, no schema changes. I reviewed it for structural correctness and layer discipline.
Architecture observations
Prerendering static content at the right layer (
hilfe/transkription/+page.ts)Editorial guidelines are stable, public-content pages. Prerendering is the correct architectural choice: zero backend load, CDN-cacheable, instant first paint. The comment explains why the runtime auth gate (
handleAuthinhooks.server.ts) still applies — this is the important correctness argument and it's documented where it matters. ✓Semantic token at the right layer (
layout.css)--color-parchmentis defined inlayout.cssas a CSS custom property, remapped per theme (light,dark,high-contrast). Components consumebg-parchment— they don't know the hex value. This is the correct separation: design decisions live inlayout.css, components are consumers. ✓Module-level state in
HelpPopover.svelteModule context in SvelteKit runs once per process (SSR singleton). This counter is shared across concurrent requests. For this specific use case (monotonically increasing ID generator), there is no correctness issue — IDs remain unique regardless of concurrency. If this component were used in a context where ID determinism across requests mattered (snapshot testing, visual regression with SSR), the counter's non-reset behavior would be worth revisiting. Not an issue now.
The
$state + $effectapproach inTranscribeDragDemo.svelteConverting from
$derivedto$state + $effect + addEventListeneris the correct pattern for subscribing to browser APIs that are not part of Svelte's reactivity graph. The$derivedapproach would compute a snapshot at initialization time only. This is a Svelte 5 architectural correctness fix, not a style preference. ✓Nothing blocking.
🛠️ Tobias Wendt — DevOps & Platform Engineer
Verdict: ✅ Approved
No infrastructure changes in this PR. Reviewing from a CI/CD and deployment perspective.
What I checked
E2E test cleanup (
transcribe-coach.spec.ts,help-popover.spec.ts)afterAllwithrequest.deleteto remove test-created documents is the right approach. Test data accumulation in a shared environment grows indefinitely without cleanup — eventually causing test isolation issues or disk pressure on the test database volume. ✓reducedMotion: 'reduce'as Playwright project defaultCentralizing this in the Playwright config rather than per-test
page.emulateMedia()reduces per-test overhead and ensures consistency across all future E2E tests. One less thing to forget when writing new specs. ✓Prerendered static page (
hilfe/transkription/+page.svelte)From a deployment perspective, prerendered pages are served as static files — no SSR runtime cost, no backend involvement. For a CX32 VPS running this full stack, offloading static routes like this is a welcome small win. ✓
Nothing to flag.
This PR doesn't touch
docker-compose.yml, CI workflows, or any infrastructure configuration. The changes are confined to frontend components, tests, and i18n files — all safe to deploy without infra coordination.📋 Elicit — Requirements Engineer
Verdict: ✅ Approved
Reviewing against issue #320's stated scope and the PR description's own acceptance criteria.
Requirements coverage
The PR description lists six deliverables. Based on the diff:
TranscribeCoachEmptyState)markieren → einrahmen/hilfe/transkriptionpage<main>landmark, heading hierarchy correctedtranscription_empty_draw_hint), new keys added in prior commitsOpen requirement gap — positive case for training footer
The PR description states: "Training footer is hidden until the first block is drawn."
Tested: Footer NOT visible on empty document. ✓
Not tested in this diff: Footer IS visible after drawing a block.
This is the second half of the acceptance criterion. If the positive case is tested elsewhere in the branch's prior commits, no action needed. If not, this leaves the "becomes visible after drawing" behavior unverified by automation.
Recommendation: Confirm this scenario is covered somewhere in the 60 unit tests or 3 E2E files. If it requires canvas interaction that's impractical to automate in Playwright, document that explicitly so future reviewers know this is a known gap.
Requirements clarity observation
The PR description mentions
reducedMotion: 'reduce'is "set as project-wide Playwright default." This is a testing infrastructure change that affects all future E2E tests, not just this feature. It's worth a brief mention in the test plan item or a separate note so the team understands the scope of the change.No blockers found.
Round 2 suggestions addressed — 2 commits pushed
🧪 Keyboard tests rewritten with actual input (
ccca8dab)HelpPopover.svelte.spec.tsThe "opens on Enter key" and "opens on Space key" tests were using
.click()— they tested click behavior under misleading names. Both are now rewritten usinguserEvent.keyboard()fromvitest/browser, matching the pattern inTagInput.svelte.spec.tsandOverflowPillButton.svelte.spec.ts:DOM
.focus()sets the active element without triggering click;userEvent.keyboardthen dispatches the key through the real browser event system. The native button fires a click on Enter/Space, opening the popover. Both tests are green.— Resolves Felix Round 1 / Sara Round 1
📝
+page.tscomment shortened (7b3334f5)/hilfe/transkription/+page.tsRemoved the
sequence(userGroup, handleAuth, ...)implementation detail that would rot if the hook chain is ever reorganised:— Resolves Felix Round 2
Focus ring (Leonie Round 2): The global
:focus-visiblerule atlayout.css:369(outline: 2px solid var(--c-focus-ring)) covers all interactive elements including the HelpPopover button — no component-levelfocus-visible:ringneeded.975 unit tests passed, 0 failures. (1 pre-existing iframe error in EntityNavSection.svelte.spec.ts — unrelated, requires backend auth.)
👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ✅ Approved
The keyboard test fix is the highlight of this final round. DOM
.focus()+userEvent.keyboard('{Enter}')is exactly right — it sets the active element without triggering click, then sends the key through the real browser event system. The native button fires click on Enter/Space, so the popover opens. Both tests now verify actual keyboard-driven behavior, not a click with a misleading name. Pattern matchesOverflowPillButton.svelte.spec.ts— good internal consistency.Everything else in the accumulated diff holds up:
TranscribeDragDemo.svelte—$state + $effect + addEventListener$derivedwrapping.matchescaptures a one-time snapshot — it cannot subscribe to external browser API state.$effect+addEventListener('change', handler)is the correct pattern for Svelte 5 when crossing into the browser event graph. Cleanupreturn () => mql.removeEventListener(...)is present — no leak.HelpPopover.svelte— module counter_counter++in<script module>produces stable, predictable IDs across SSR + hydration. The comment explains whyMath.random()was wrong — different server and client values cause a hydration mismatch. Correctly documented.role="region"replacingrole="tooltip"Tooltip semantics imply hover/focus-triggered supplementary text. This is a click-activated dismissible panel —
role="region"witharia-label={label}is the right semantic. The region is also a navigable landmark for screen readers.Tailwind grid over inline style
class="grid grid-cols-[34px_1fr] items-start gap-3.5"on all three<li>elements is cleaner than thestyle=""attribute — consistent with the Tailwind-first approach and JIT arbitrary values.+page.tscommentTwo lines, explains the mechanism, no implementation details that rot. Correct.
One non-blocking observation
The HTML comment inside the
<button>template (Outer button is 44×44px for WCAG 2.5.8...) explains why, which I generally approve of. It's slightly unusual to embed a WHY comment in markup (not script), but the audience constraint is genuinely non-obvious from the Tailwind classes. Defensible as-is. Adata-wcag="2.5.8"attribute would achieve the same documentation without rendering weight if anyone wants to clean it up later — but not worth a re-review cycle.🏗️ Markus Keller — Application Architect
Verdict: ✅ Approved
Pure frontend feature — no layer boundary violations, no backend changes, no new infrastructure. Component decomposition is correct throughout: each file represents one nameable visual region.
Architectural highlights
prerender = truefor editorial content (+page.ts)The comment is now concise and durable: two lines explaining the mechanism without naming the hook chain implementation. Prerendering editorial guidelines (transcription rules) is the architecturally correct choice — zero SSR runtime cost, CDN-cacheable, instant first paint. The runtime
handleAuthgate inhooks.server.tspreserves the auth constraint. Comment length is now appropriate: explains the invariant, doesn't document the implementation.--color-parchmentinlayout.cssDesign token defined at the right layer. Both
RichtlinienRuleCard.svelteandTranscribeDragDemo.svelteare token consumers (bg-parchment) — they don't own the hex value. Dark-mode remapping (#041828) is defined once at the token layer. This is the correct separation of concerns.$state + $effect + addEventListenerforprefersReducedMotion(TranscribeDragDemo.svelte)This is an architectural correctness fix, not a style preference.
$derivedis synchronous and single-pass within Svelte's reactivity graph.window.matchMedia(...).matchesis a browser API outside that graph — it cannot be tracked by$derived. The$effectsubscriber pattern is the correct boundary crossing. Cleanup is present.Module-level
_counter(HelpPopover.svelte)Intentional shared module state, correctly documented. Monotonically increasing ID generator is safe under concurrent SSR requests — IDs remain unique regardless of concurrency. If ID determinism across requests ever matters (e.g. snapshot testing), this would be worth revisiting, but no such requirement exists now.
Nothing blocking.
🔐 Nora "NullX" Steiner — Application Security Engineer
Verdict: ✅ Approved
Frontend-only feature addition. No backend endpoints, no auth changes, no data persistence changes. Clean security posture throughout.
No issues found in:
XSS surface — All content rendered from compiled Paraglide i18n keys. Zero
{@html}usage in the diff. No user-controlled strings interpolated into HTML.External link handling — Wikipedia link uses
rel="noopener noreferrer"consistently.window.openertabnapping vector is blocked. The.new-tabannotation span is also hidden in print via CSS — correct.Prerender / auth safety —
+page.tscomment now concisely documents thathandleAuthinhooks.server.tsenforces runtime auth for all routes including prerendered ones. The content (editorial transcription guidelines) is non-sensitive. The auth gate is belt-and-suspenders — even if prerendering didn't exist, the content would be harmless publicly. The explicit documentation of the decision is the right call regardless.ARIA wiring — The SSR ID counter (
_counter) eliminates the hydration mismatch window wherearia-controlswould point to a non-existent element before client hydration completes. This was a brief broken-ARIA-reference race — now closed.aria-expanded/aria-controls/aria-labelwiring looks correct.Module-level
_counter— Shared across concurrent SSR requests. IDs are unique and monotonically increasing. No collision, no information leak. Already documented in the existing comment — nothing to add.Approved.
🧪 Sara Holt — Senior QA Engineer
Verdict: ⚠️ Approved with concerns
What's correct
Keyboard tests (
HelpPopover.svelte.spec.ts:54–65)(document.querySelector('button[aria-expanded]') as HTMLButtonElement).focus()+await userEvent.keyboard('{Enter}')is the right pattern. DOM.focus()sets the active element without triggering click;userEvent.keyboarddispatches through the real browser event pipeline. This matches the pattern inOverflowPillButton.svelte.spec.tsandTagInput.svelte.spec.ts. Both tests now verify actual keyboard-driven open behavior. ✓afterAllcleanup (transcribe-coach.spec.ts:16–18,help-popover.spec.ts:11–13)request.delete('/api/documents/${docId}')cleans up test-created documents. Correct — test data accumulation is an eventual isolation problem in shared environments. ✓E2E locator (
help-popover.spec.ts:18)page.getByRole('button', { name: 'Lese- und Bearbeitungsmodus' })is a stable, accessible-name-based selector. The previousbutton[aria-expanded]could have matched the wrong element if any other button in the panel gainedaria-expanded. ✓Print test (
richtlinien.spec.ts:66–73)Asserts both
.app-navand.new-tabspans are hidden. Complete coverage of the print CSS intent. ✓reducedMotion: 'reduce'centralizedRemoved from 4 individual tests — now inherited from
playwright.config.ts. Less noise, more consistency, fewer things to forget when writing new specs. ✓SSR ID test (
HelpPopover.svelte.spec.ts:78–89)The deterministic counter test (
toMatch(/^help-popover-\d+$/), two renders produce different IDs) codifies a correctness invariant. ✓Remaining gap (suggestion)
Missing: positive case for training footer visibility
transcribe-coach.spec.tsverifies the footer is NOT visible on an empty document. The acceptance criterion "hidden until the first block is drawn" has a second half: the footer IS visible after drawing. This positive case is unverified by automation.If automating canvas region drawing in Playwright is genuinely impractical, a comment in the spec file would make this explicit:
Otherwise the gap is invisible to future reviewers. Non-blocking for merge, but the gap should be acknowledged somewhere.
🎨 Leonie Voss — UX Designer & Accessibility Strategist
Verdict: ✅ Approved
Every accessibility concern from prior review rounds has been resolved correctly. This PR is accessibility-first throughout.
What landed correctly
44×44px touch target (
HelpPopover.svelte:79–86)h-[44px] w-[44px]on the outer button withh-5 w-5on the inner<span>. The button is 44px — not padded to 44px. This distinction matters in tight panel headers. WCAG 2.5.8 satisfied. Our 60+ transcriber audience can reliably tap this. ✓role="region"witharia-label={label}tooltiprole is for non-persistent supplementary information shown on hover. This is a click-activated dismissible panel with navigable content.role="region"+aria-labelcreates a landmark — screen readers can navigate to it independently. Correct semantics. ✓<main>landmark on Richtlinien page (+page.svelte)Outer
<div>→<main>. Screen reader users now have a jump target. Every content page needs exactly one<main>. ✓h2→h3on closing card (+page.svelte:105)Page hierarchy:
h1(title) →h2(rule sections, "Noch in Klärung") →h3(closing card). Correct subordination — the closing card is a subordinate element, not a sibling section. ✓aria-label="Schritt N von 3"on<li>elements (TranscribeCoachEmptyState.svelte)The numeric step badges have
aria-hidden="true". Without thearia-labelon<li>, screen readers would have announced list position from the<ol>structure but not the "of 3" context. Now: "Schritt 1 von 3". Appropriate for our seniors who may rely on AT. ✓--color-parchmenttoken (layout.css)Light
#faf8f1/ dark#041828. Both components now usebg-parchment. Rawbg-[#FAF8F1]in dark mode would have rendered cream on dark navy — broken contrast and visual incoherence. Token layer fixes this in one place. ✓Focus ring (confirmed resolved, no action)
The outer
h-[44px] w-[44px]button has no explicitfocus-visible:ringclass — but the global:focus-visiblerule atlayout.css:369(outline: 2px solid var(--c-focus-ring)) covers all interactive elements. Mode-aware: navy in light, mint in dark. WCAG 2.4.7 satisfied.🚀 Tobias Wendt — DevOps & Platform Engineer
Verdict: ✅ Approved
No infrastructure changes in this PR. Reviewing from a CI/CD and deployment perspective.
What I verified
afterAllcleanup in E2E specsrequest.delete('/api/documents/${docId}')runs after each suite. In CI: each run uses an ephemeral Docker Compose database, so this is defensive but harmless. In the persistent NAS dev environment: prevents"E2E Transcribe Coach Test"and"E2E Help Popover Test"documents from accumulating across repeated local runs. Correct approach. ✓reducedMotion: 'reduce'centralized inplaywright.config.tsRemoved from 4 individual test calls. Single point of configuration for all E2E specs — new specs inherit this automatically. Prevents SMIL animation flakiness in CI across the entire suite. ✓
Prerendered static page
The richtlinien page serves as a static file — zero SSR runtime cost, no Spring Boot involvement, no PostgreSQL queries. On a CX32 VPS (4 vCPU / 8GB RAM) running the full stack, offloading static content delivery is a welcome small win. ✓
No new services, no Compose changes, no secret surface expansion.
Nothing to flag.
📋 Elicit — Requirements Engineer
Verdict: ✅ Approved
Reviewing against issue #320's stated scope and the PR's own acceptance criteria.
Deliverables check
TranscribeDragDemowithprefers-reduced-motion— reactive at runtime(?)chip inTranscriptionPanelHeadermarkieren → einrahmenintranscription_next_block_cta/hilfe/transkriptionwith 5 rules, 4 chips, Wikipedia card, closing cardRemaining open requirement gap (non-blocking)
The requirement "training footer hidden until the first block is drawn" has two halves:
This positive case has been noted in both review rounds. If canvas interaction testing is genuinely impractical with current Playwright tooling, the gap should be documented in
transcribe-coach.spec.tswith a comment explaining it is a known manual-only verification step. Leaving it implicit means future reviewers will not know whether the test was intentionally omitted or simply forgotten.Prerender / auth decision
The
+page.tscomment now correctly and concisely documents the decision:handleAuthinhooks.server.tsenforces runtime auth before prerendered HTML is visible. The earlier version named the hook chain implementation (sequence(userGroup, handleAuth, ...)) which was a maintenance liability. The current version is stable.No blockers found.
Training footer positive-case test added —
534ec959Addressed the concern from Sara and Elicit across the last two review rounds: the spec only verified the footer was hidden on an empty document; there was no test confirming it appears once blocks exist.
What changed
frontend/e2e/helpers/upload-empty-document.ts— fixed the helper so it uploads a minimal PDF after creating the document. Without a file,isPdfis false inDocumentTopBarand the "Transkribieren" button never renders, so all transcribe-coach tests were silently broken (they couldn't even click the button).frontend/e2e/transcribe-coach.spec.tscreateBlock()helper that seeds one block viaPOST /api/documents/${docId}/transcription-blocksTranscribe coach — with blocksdescribe block:beforeAllcreates a document with PDF + one block1 Abschnittto appear in the panel header (confirms the asyncloadTranscriptionBlocks()has resolved andpanelModehas settled to'read')[data-testid="mode-edit"]tab to enter edit modeFür Training vormerkenis visibleafterAlldeletes the documentThemeToggleusesaria-label="dark mode", not the/Farbmodus|theme/regex — that test was also timing out.All 6 transcribe-coach tests green locally.
👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ✅ Approved
Overall this is clean, disciplined work. A few things worth calling out.
What's done well
$state+$effectfix inTranscribeDragDemo.svelte— The comment is correct:$derivedreads.matchesonce at evaluation time and has no reactive Svelte primitive to track, so it's genuinely a one-shot snapshot. The$effect+addEventListenerpattern is the right Svelte 5 approach for bridging to external browser APIs. Good catch.Module-level counter in
HelpPopover.svelte—<script module>is the right mechanism for singleton state shared across instances. The comment explains why (SSR hydration mismatch from differing Math.random() values), not what — that's exactly the kind of comment that should exist.Enter/Space keyboard tests — The rewrite from
.click()simulation touserEvent.keyboard('{Enter}')anduserEvent.keyboard('{Space}')after focusing the button actually tests the real browser keyboard dispatch path. The old tests were click proxies that could never catch akeydownregression. Good fix.Tailwind grid — Replacing
style="grid-template-columns: 34px 1fr; align-items: start;"withclass="grid grid-cols-[34px_1fr] items-start gap-3.5"moves inline styles into the utility layer where they belong.Blocker
Hover-target mismatch in HelpPopover.svelte — The
hover:border-brand-navy hover:text-brand-navytransition is applied to the inner<span>(the 20×20px visual circle), not the 44×44px<button>. When the cursor is in the touch-target area but not directly over the visual circle, no hover feedback fires. The larger touch target is inaccessible for the hover state.Fix with Tailwind group:
Suggestions (non-blocking)
transcribe-coach.spec.ts, thecreateBlockparameter typeParameters<typeof createEmptyDocument>[0]works but is opaque.APIRequestContextimported from@playwright/testwould be clearer for the next reader.label: nullfield in thecreateBlockPOST body should be verified against the actual backend schema — a null value on a field the server treats as required would produce a 400 that throws"Create block failed"and would break the test in a confusing way.🔒 Nora "NullX" Steiner — Application Security Engineer
Verdict: ⚠️ Approved with concerns
No new attack surface, no injection vectors, no credential handling. The
rel="noopener noreferrer"on the Wikipedia link (mentioned in the PR description) is correct — preventswindow.openerabuse on new-tab navigation. Good.One security smell that needs a deliberate decision before merge.
Concern — prerendering bypasses
hooks.server.tsFile:
frontend/src/routes/hilfe/transkription/+page.tsThe comment says:
This reasoning only holds if requests to
/hilfe/transkriptionpass through the SvelteKit Node process. With prerendering, the build adapter outputs a static HTML file at the prerender path. In production, if Caddy is configured to serve the Node adapter's output directory and the adapter serves prerendered HTML as static files before routing to the SSR handler,hooks.server.tsmay not run for that path.Concretely: if
prerender = truecauses the adapter to emit ahilfe/transkription/index.htmlthat Caddy or the Node HTTP server picks up and serves directly without entering the SvelteKit request pipeline, the auth hook is bypassed and the page is publicly accessible.Is this a problem? Probably not for editorial guidelines content — there's nothing PII or sensitive here. But the reasoning in the comment is incorrect, which means the assumption "auth protects this" isn't as reliable as implied.
Recommendation:
prerender = true— serve it via SSR sohooks.server.tsalways runs.// Public page — editorial guidelines, no sensitive content, intentionally unauthenticated.The security comment as written gives false confidence in a protection that may not apply. Pick one path and make it explicit.
🧪 Sara Holt — Senior QA Engineer
Verdict: ⚠️ Approved with minor concerns
Solid test work. The
afterAllteardowns, the globalreducedMotiondefault, and the new "with blocks" state coverage all address gaps from the prior PR iteration. Let me break down what I see.What's done well
afterAllteardowns — All three E2E test describe blocks now delete their documents after the suite. This prevents data accumulation across repeated CI runs and removes order-dependency between suites. Good discipline.reducedMotion: 'reduce'moved to Playwright project config — Removing the per-testpage.emulateMedia({ reducedMotion: 'reduce' })calls and setting it globally is the right call. Every test now runs under the reduced-motion constraint without depending on each test author to remember.createBlockAPI helper + "with blocks" describe block — The missing state was "what does the panel show when at least one block exists?" This was a genuine coverage gap. The new helper + describe block fills it. The test waits for1 Abschnitttext before asserting on the training footer — that's a solid, DOM-anchored wait condition rather than a sleep.SSR-safe ID test in
HelpPopover.svelte.spec.ts— The regex assertiontoMatch(/^help-popover-\d+$/)explicitly guards against Math.random() regressions and proves the counter is deterministic. Good regression test.Concern 1 — Missing fixture file (potential CI failure)
frontend/e2e/helpers/upload-empty-document.tsnow reads:The
minimal.pdffile is not visible in this diff. If it isn't already committed to the repo, every E2E test callingcreateEmptyDocument(transcribe-coach, help-popover) will throwENOENTat runtime — a cryptic failure that doesn't point to the missing fixture. Confirmfrontend/e2e/fixtures/minimal.pdfexists at the committed HEAD before merge.Concern 2 —
label: nullincreateBlockAPI callIf the backend treats
labelas a required field and rejects nulls, thecreateBlockcall returns a non-2xx status, throws"Create block failed", and the "with blocks" describe'sbeforeAllfails — leavingdocIdundefined for the test. The test then likely crashes with a confusing error about the document not being found. Verifylabelis nullable on thetranscription-blocksendpoint.Suggestion (non-blocking)
The "with blocks" describe block creates a single shared document in
beforeAll. Fine for one test, but fragile if a second test is added later that mutates the document's state. ConsiderbeforeEach/afterEachif this describe block grows.🎨 Leonie Voss — UX Designer & Accessibility Strategist
Verdict: ⚠️ Approved with one bug
This PR shows exactly the attention to the 60+ audience that the project needs. Most of the accessibility work is excellent. One interaction bug needs fixing before merge.
What's done well
44×44px touch target on the HelpPopover trigger — WCAG 2.5.8 and our senior audience both require this. The outer button at
h-[44px] w-[44px]with the visual circle at 20×20px inside is the correct pattern. ✅aria-label="Schritt X von 3"on<li>items — Witharia-hidden="true"on the step-number badge, screen readers would otherwise announce the list item content without step context. Thearia-labelgives VoiceOver/NVDA users "Schritt 1 von 3: Bereich einrahmen" rather than just the body text. Exactly right. ✅role="region"+aria-labelon HelpPopover —role="tooltip"is for elements that appear automatically on hover/focus (non-interactive). A click-triggered panel that persists until dismissed is correctlyrole="region"— it's a landmark the user navigates to, not a tooltip that disappears. The ARIA spec distinction matters here. ✅<main>landmark +h2→h3heading correction — The Richtlinien page now has a proper landmark for screen reader navigation, and the closing card heading is correctly demoted toh3(it's a sub-section under the last rule card, not a top-level heading). ✅bg-parchmentsemantic token — Replacing hardcodedbg-[#FAF8F1]with the design-system token that correctly maps to#041828in dark mode. The dark mode version is a deep navy that reads as a subtle surface on the navy background — appropriate contrast shift. ✅Bug — Hover feedback only triggers on the 20×20px circle, not the 44×44px touch target
In
HelpPopover.svelte, the hover styles are on the inner<span>, not the<button>:When a mouse user hovers anywhere in the 44×44px button area outside the 20×20px visual circle, no hover change occurs. The interactive signal disappears in the majority of the touch-target area. This will feel broken to mouse users — they'll see no hover state until they move precisely over the tiny circle.
Fix — use Tailwind's
grouputility:group-hover:on the span triggers whenever the mouse is over any part of thegroup-marked parent button. Full 44×44px hover coverage with no layout changes needed.Minor suggestion
The print styles hide
.new-tabannotation spans — confirmed by the new E2E assertion. The rule cardbreak-inside-avoidis a thoughtful detail for transcribers who might print and work from paper. Nice.🏛️ Markus Keller — Application Architect
Verdict: ✅ Approved
This is a pure frontend feature PR — no backend changes, no service boundaries, no schema migrations. The architectural questions are Svelte-specific.
<script module>for the ID counter — correct mechanismThe module-level counter in
HelpPopover.svelteis the right Svelte 5 tool for this job.<script module>runs once per module load and the state is shared across all component instances in the same render. This is the SSR-safe singleton pattern — a counter that increments from 0 produces identical sequences on server and client, preventing hydration mismatches.Math.random()would produce different sequences server/client, causing a mismatch on first render.The comment explains the constraint accurately. No concerns.
$state+$effect+addEventListener— correct boundary patternThe change in
TranscribeDragDemo.sveltefrom$derivedto$state+$effectcorrectly identifies thatwindow.matchMedia().matchesis not a reactive Svelte primitive — it's an external browser API.$derivedwould compute the value once and never re-evaluate (nothing reactive to track). The$effectwithaddEventListener('change', ...)is the standard Svelte 5 pattern for subscribing to external event sources and bridging their values into the reactive graph. The cleanup return (return () => mql.removeEventListener(...)) prevents leaks on component destroy. Correctly implemented.prerender = true— confirm adapter behavior in production/hilfe/transkriptionis now a prerendered static page. This is architecturally appropriate for editorial content that doesn't require server-side data loading. However, the impact depends on how the SvelteKit Node adapter handles prerendered routes at runtime:hooks.server.tsruns and the auth middleware applies.hooks.server.tsdoes not run.This is worth confirming against the adapter documentation and the production Caddy config. The content itself (editorial guidelines) is unlikely sensitive, but the assumption in the code comment may be incorrect — worth a quick verification before this pattern spreads to other pages.
No other architectural concerns
The
bg-parchmenttoken addition follows the established CSS custom property pattern. Light (#faf8f1) and dark (#041828) variants are both defined in the correct theme blocks. Consistent with the existing token system.🔧 Tobias Wendt — DevOps & Platform Engineer
Verdict: ⚠️ Approved with one action item
No CI workflow changes. No Docker or infrastructure changes. The
afterAlldocument teardowns in all three E2E spec files are good operational hygiene — the test database won't accumulate phantom documents across repeated CI runs.Action item — verify
minimal.pdffixture is committedfrontend/e2e/helpers/upload-empty-document.tsnow does a hardfs.readFileSyncat module load time:This file is not in the diff. If it was added in a previous commit already on this branch, no problem. If it hasn't been committed yet, every E2E job that calls
createEmptyDocument— currently the transcribe-coach and help-popover specs — will throw anENOENTat runtime. The error will surface as"Upload PDF failed: ..."in the test output (if it gets that far) or as anENOENTin the helper setup, which is a confusing failure to diagnose from CI logs.Action before merge: run
ls frontend/e2e/fixtures/or check git status on the branch to confirmminimal.pdfis there. If not, add it — a minimal valid PDF is a few hundred bytes.What's working well
page.emulateMedia({ reducedMotion: 'reduce' })to a Playwright project-level default is a good CI configuration change — no test will accidentally forget the constraint, and the test output is consistent.afterAllcleanup patterns are correct for Playwright (share setup inbeforeAll, clean up inafterAll). No test order dependency is introduced.📋 Elicit — Requirements Engineer
Verdict: ✅ Approved with observations
Reviewing against the stated goals of issue #320: guided empty state for first-time transcribers and a Kurrent primer. The feature coverage is comprehensive and well-specified.
Requirements coverage
markieren→einrahmenprefers-reduced-motionfallbackObservation 1 — Print audience assumption
The Richtlinien page has
break-inside-avoidon rule cards and@page { margin: 1.5cm }print styles. This implies transcribers will print the page and work from a physical copy. Given the project context (transcribers on laptop/tablet), this seems like an assumption rather than a confirmed user need. It's a small investment that adds value if even one transcriber uses it — no objection, just worth noting as an unconfirmed scenario.Observation 2 — Coach persistence: first visit vs. always
The coach empty state appears "when no blocks exist." The tests verify this correctly. However: if a transcriber opens a fresh document, sees the coach, closes the browser tab without adding any blocks, and returns to the document later — they should see the coach again. The implementation based on "no blocks exist" makes this work correctly by design. ✅ No action needed, just confirming the intent is captured.
Open question — Is the Richtlinien page intentionally public?
prerender = trueon/hilfe/transkriptionmay serve it without authentication (see Nora's comment for the technical detail). From a requirements perspective: should the transcription guidelines be accessible to unauthenticated users? Arguments for: onboarding, discoverability, reference from an external link. Arguments against: the app is family-private by design.This is a product decision that should be explicit. Recommend adding it to the next sprint's decisions log either way.
Hover-target bug fixed —
38c3bb13Root cause:
hover:border-brand-navy hover:text-brand-navywere on the inner<span>, so the visual state change only fired when the cursor was over the 20×20 px circle — not the full 44×44 px touch target.Fix: Added
groupandfocus-visible:ring-2 focus-visible:ring-brand-navy rounded-fullto the outer<button>; replacedhover:withgroup-hover:on the inner<span>. The hover effect now covers the entire interactive area.Tests added (
HelpPopover.svelte.spec.ts):hover styles propagate from 44px button group to inner span, not from span itself— assertsgroupon button,group-hover:*(nothover:*) on spanouter button has focus-visible ring for keyboard users— assertsfocus-visible:ring-2andfocus-visible:ring-brand-navyon the buttonFull suite: 1066/1066 ✅
🏗️ Markus Keller — Senior Application Architect
Verdict: ✅ Approved
This PR is frontend-only (no backend, no DB, no infrastructure changes). The scope is clean. A few architectural observations worth surfacing.
What's Well Done
<script module>) is the correct solution for SSR hydration consistency.Math.random()in SSR context is a classic hydration mismatch bug; the monotonic counter eliminates it entirely.$state + $effectfor media query reactivity inTranscribeDragDemo.svelteis architecturally sound.$derivedis a synchronous snapshot — it wouldn't track OS setting changes post-mount. TheaddEventListenercleanup in the$effectreturn is correct.--color-parchmentdesign token is properly propagated through all three theme variants (light, dark-attr, dark-class) inlayout.css. This is how the design system should grow — token first, usage second.prerender = truefor a static help page is the right call. Less server load, instant delivery, fully cacheable.Concern Worth Verifying
The security comment in
+page.ts:With SvelteKit's Node adapter, prerendered HTML files are served as static assets — whether
hooks.server.tsintercepts those requests before the static file handler depends on the adapter and server configuration. If the prerendered page ever contains sensitive content, this assumption could fail silently in production. For the current content (Kurrent editorial guidelines, publicly useful), the risk is negligible. But the comment creates a precedent for future prerendered pages. Consider a brief note: "This page contains no sensitive data; prerendering is safe regardless of hook execution order."Minor Suggestion
The closing card heading was changed from
<h2>to<h3>. Verify the full heading hierarchy on the page is consistent (h1 → h2 for rule cards → h3 for closing). No hierarchy gaps.👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ✅ Approved
Clean implementation. The Svelte 5 idioms are correct throughout. A few observations on the finer details.
What's Well Done
$state + $effectforprefersReducedMotion— this was the right fix.$derivedfrom.matchesis a snapshot; theaddEventListener+ cleanup pattern in$effectis how reactive media queries should be handled in Svelte 5.group / group-hoverpattern on HelpPopover — the 44px outer button with a 20px visual inner span, usinggroup-hover:to propagate hover styles, is textbook. The test that verifiesspanClasses.toContain('group-hover:border-brand-navy')andnot.toContain('hover:border-brand-navy')is an excellent design-system guard.help-popover-Nandhelp-popover-MwithN ≠ Mand both matching/^help-popover-\d+$/is exactly the regression test that makes the module-counter approach trustworthy.aria-label="Schritt N von 3"on<li>items — compensates well forlist-nonestripping list semantics in some AT.userEvent.keyboard('{Enter}')anduserEvent.keyboard('{Space}')with a real.focus()call instead of simulating clicks — this exercises the actual keyboard path.Suggestions
data-testid="mode-edit"selector in E2E (transcribe-coach.spec.ts:92):data-testidselectors are tightly coupled to internal implementation. If this is a pre-existing pattern in the codebase, consistency wins — but a role-based selector likepage.getByRole('radio', { name: /Bearbeiten/i })orpage.getByRole('tab', { name: /Edit/i })would be more resilient. Flagging as a suggestion, not a blocker.document.querySelector('button[aria-expanded]')in spec (HelpPopover.svelte.spec.ts:82, 88):This works, but
page.getByRole('button', { name: /Help/i })would be more readable and resilient. Minor, since the existing test pattern in this file already usespage.getByRoleelsewhere.🛠️ Tobias Wendt — DevOps & Platform Engineer
Verdict: ⚠️ Approved with concerns
No infrastructure changes in this PR. CI pipeline, Docker Compose, and deployment config are untouched. The concerns are in E2E test reliability.
What's Well Done
afterAllcleanup added to bothhelp-popover.spec.tsandtranscribe-coach.spec.ts— documents created during E2E tests are now deleted. This prevents test data accumulating in the development database over repeated runs. Good hygiene.prerender = trueon/hilfe/transkription— static asset at build time, zero runtime cost, ideal for a help page that never changes per-user.Blocker: PDF Fixture File Must Exist in the Repo
The updated
upload-empty-document.tsnow reads a real PDF from disk:If
frontend/e2e/fixtures/minimal.pdfis not committed to the repository, every E2E run that callscreateEmptyDocument()will throwENOENT: no such file or directory. The diff does not show this file being added.Action required: Confirm
frontend/e2e/fixtures/minimal.pdfexists in the repo (it may be a pre-existing fixture not shown in the diff). If it does not exist, it must be committed before this PR merges.Suggestion
The
createBlockhelper intranscribe-coach.spec.tshardcodes test data (coordinates, text). This is fine for now, but if the transcription block API schema changes, all tests using this helper will fail with an unhelpful HTTP error. Adding a comment referencing the API endpoint would help future debugging. Minor, not a blocker.📋 Elicit — Requirements Engineer
Verdict: ✅ Approved
Reviewing against the stated goals of issue #320: guided empty state + Kurrent primer for first-time transcribers.
Requirements Coverage
All five deliverables from the PR description are implemented and verifiable:
TranscribeCoachEmptyState,TranscribeDragDemo(?)chip next to Read/Edit toggle/hilfe/transkriptionRichtlinien pageAcceptance Criteria Assessment
The test plan maps cleanly to verifiable outcomes:
Observation: Removed Key Sync
transcription_empty_draw_hintis removed from de/en/es consistently. Assuming no other component still references this key, this is clean. If it is still referenced anywhere, it will produce a runtime i18n error rather than a build error in Paraglide. Worth a quick grep to confirm nothing else uses this key.Non-Functional Requirements
🔒 Nora "NullX" Steiner — Application Security Engineer
Verdict: ✅ Approved
Frontend-only PR. No backend changes, no new API endpoints, no authentication changes. The attack surface added by this PR is minimal. A few items worth naming explicitly.
What's Well Done
rel="noopener noreferrer"on external links — PR description confirms the Wikipedia link uses this. Withoutnoopener,window.openeris accessible to the linked page, enabling parent-frame redirect attacks. Confirmed correct.innerHTMLwith user-controlled content — all content in coach cards, Richtlinien, and help popover is static i18n strings. Zero XSS surface.Observation: Prerender Auth Assumption
The comment in
+page.ts:Risk assessment: Low — the Richtlinien page content is public editorial guidelines. Unauthenticated access to "how to transcribe Kurrent" is not a data leak.
However, this comment establishes a pattern that other developers may copy for pages that do contain sensitive information. If this pattern spreads to pages with user data, the security assumption may fail depending on how the Node adapter serves static prerendered files.
Recommendation: Keep the comment, but add an explicit note that this pattern is safe because this page contains no user-specific or sensitive data. That constrains the safe-use envelope clearly.
No Other Findings
No SSRF, no XSS vectors, no IDOR surface, no auth bypass, no sensitive data exposure in the diff. The
fs.readFileSyncin E2E test helpers is test-only code with a static path — not exploitable.🧪 Sara Holt — QA Engineer & Test Strategist
Verdict: ⚠️ Approved with concerns
The test suite additions are thoughtful and the coverage is broad. One potential blocker and a few quality notes.
What's Well Done
afterAllcleanup in both E2E suites — documents are now deleted after test runs. This prevents test data drift in local dev databases across repeated runs.createBlockhelper — clean, reusable, follows the established helper pattern.reducedMotion: 'reduce'moved to project-wide Playwright config — removing the per-testemulateMediacalls is DRY and ensures consistent behavior across the entire suite without relying on per-author discipline.isEmpty → coach visibleandhasBlocks → footer visibleare now verified./^help-popover-\d+$/) and distinct across two renders. This is exactly the kind of regression test that would catch someone switching the implementation back toMath.random().getByRole('region', { name: '...' })andgetByRole('button', { name: '...' })are semantically stable. Good upgrade from the previouslocator('button[aria-expanded]').Blocker: PDF Fixture File
upload-empty-document.tsnow calls:If
frontend/e2e/fixtures/minimal.pdfdoesn't exist in the repo, every test that callscreateEmptyDocument()will throwENOENTsynchronously before the test body executes, producing an unhelpful failure message. The file is not shown in this diff. Confirm the fixture exists before merging.Concern:
data-testidSelectordata-testidselectors are implementation-coupled. IfTranscriptionPanelHeaderis refactored (the testid renamed or removed), this test fails with no semantic signal about what broke. A role-based selector would be more resilient. Minor, not a blocker, but worth noting for the next time this area is touched.Test Coverage Gaps (Suggestions, Not Blockers)
TranscribeDragDemocorrectly switches to the static fallback whenprefersReducedMotionchanges after mount (the$effectlistener path). The current tests only verify the initial render state.h2headings or card count would catch regressions in the static content.🎨 Leonie Voss — UX Designer & Accessibility Strategist
Verdict: ✅ Approved
This is high-quality accessibility work. The HelpPopover and coach empty state both show care for the 60+ transcriber audience. A few observations and one dark mode verification to check.
What's Well Done
h-[44px] w-[44px]button with a visually 20px inner<span>is exactly the right WCAG 2.5.8 pattern. The comment in the template explaining why (transcriber audience is 60+) is the correct rationale to leave in code.group / group-hoverpropagation — hover styles apply from the full 44px hit area, not just the visual circle. This ensures the hover feedback matches the actual interactive zone. Correct.focus-visible:ring-2 focus-visible:ring-brand-navyon outer button — keyboard focus ring applies to the full touch target, not the inner visual circle. Correct.role="region"witharia-label— changing fromrole="tooltip"torole="region"is semantically accurate. A tooltip is passive and hover-triggered; a click-activated help panel is a region. Thearia-labelsatisfies the accessible name requirement for landmark regions.aria-label="Schritt N von 3"on<li>items — compensates forlist-noneremoving list semantics in VoiceOver/NVDA. Thoughtful.<main>on/hilfe/transkription— correct semantic upgrade. The page now has a proper landmark.<h3>for closing card — fixes a heading hierarchy skip (h1 → h2 → h2was ambiguous;h1 → h2 → h3is clean if the closing card sits within a rule card section). Verify the actual hierarchy is:h1page title →h2for each of the five rule cards →h3for the closing invitation card. If the closing card is a sibling of the rule cards (not nested within one), it should beh2, noth3.bg-parchmenttoken — hardcodedbg-[#FAF8F1]replaced with a semantic token. Design system hygiene.Verification Needed: Dark Mode Parchment Contrast
The dark mode
--c-parchment: #041828is very close to what is likely the dark navy background. If any text sits directly on this surface inside example blocks, verify the contrast ratio:#041828: if text is#e0e0e0(typical light-on-dark), contrast ≈ 11:1 ✅text-ink-3) are used on#041828backgrounds, check those combinations explicitly.This is a verification note, not a blocker. The intent (slightly elevated surface vs. flat dark background) is correct.
Reduced Motion
Removing per-test
emulateMediacalls in favor of a project-wide Playwright default ensures the reduced-motion fallback is always tested without per-author discipline. Excellent systemic fix. TheTranscribeDragDemostatic SVG fallback is the correct reduced-motion experience for vestibular users.