feat(transcribe): keyboard shortcuts for the transcribe power path + cheatsheet overlay (#327) #728

Merged
marcel merged 12 commits from feat/issue-327-transcribe-shortcuts into main 2026-06-04 17:54:26 +02:00
Owner

Closes #327.

Adds the full keyboard power-path for the Transcribe panel plus a ? cheatsheet overlay, and makes the annotation Delete affordance discoverable (the deferred #722 addendum). TDD throughout — 46 unit/component tests green locally, an e2e + axe spec for CI; npm run check adds 0 errors over baseline and npm run build passes.

What's in

  • transcribeShortcuts action (lib/shared/actions/) — single-owner window keydown translator: j/k region nav, e mode toggle, n draw-arm (edit only), t training mark, Delete, ? cheatsheet, and the Esc precedence ladder (cheatsheet → editable no-op → close panel). Focus guard exempts only ?; destroy() removes the listener. 20 tests.
  • ShortcutCheatsheet.svelte — native <dialog aria-modal> with showModal() focus trap, close button focused on open, 8 grouped <kbd> rows, autosave footer, reduced-motion fade. 8 tests.
  • Page wiring (documents/[id]/+page.svelte) — action attached, callbacks to existing context setters, the inline Esc handler removed (single owner), cheatsheet + draw-armed hint rendered.
  • Annotation Delete discoverabilityaria-keyshortcuts="Delete" + extended aria-label; AnnotationShape no longer self-handles Delete (action is sole owner → deletes exactly once), onfocus syncs the active region. Removed the now-redundant onDeleteRequest plumbing through AnnotationLayer/PdfViewer/DocumentViewer.
  • Coach card? tip added (#320). i18n complete de/en/es. e2e transcribe-shortcuts.spec.ts + axe on the open cheatsheet.

Two decisions where the codebase differed from the spec (please sanity-check)

  1. t semantics — no per-region training flag exists on main (that's #321); "mark for training" is two fixed document-level script-type chips. So t toggles the document's KURRENT_RECOGNITION enrollment, no-op unless a region is active. Follow-up once #321 lands.
  2. n draw mode — drawing is always-on pointer-drag with no keyboard draw path, so n (edit-only) arms a visible draw cue (drawArmed → hint banner), cleared on next draw / leaving edit mode.

Minor deviations from the issue's file list

  • Action spec is transcribeShortcuts.svelte.spec.ts (not .spec.ts) so it runs in the client/browser vitest project where DOM APIs exist — matches clickOutside.svelte.spec.ts.

🤖 Generated with Claude Code

Closes #327. Adds the full keyboard power-path for the Transcribe panel plus a `?` cheatsheet overlay, and makes the annotation `Delete` affordance discoverable (the deferred #722 addendum). TDD throughout — 46 unit/component tests green locally, an e2e + axe spec for CI; `npm run check` adds 0 errors over baseline and `npm run build` passes. ## What's in - **`transcribeShortcuts` action** (`lib/shared/actions/`) — single-owner `window` keydown translator: `j`/`k` region nav, `e` mode toggle, `n` draw-arm (edit only), `t` training mark, `Delete`, `?` cheatsheet, and the `Esc` precedence ladder (cheatsheet → editable no-op → close panel). Focus guard exempts only `?`; `destroy()` removes the listener. 20 tests. - **`ShortcutCheatsheet.svelte`** — native `<dialog aria-modal>` with `showModal()` focus trap, close button focused on open, 8 grouped `<kbd>` rows, autosave footer, reduced-motion fade. 8 tests. - **Page wiring** (`documents/[id]/+page.svelte`) — action attached, callbacks to existing context setters, the inline `Esc` handler removed (single owner), cheatsheet + draw-armed hint rendered. - **Annotation `Delete` discoverability** — `aria-keyshortcuts="Delete"` + extended `aria-label`; AnnotationShape no longer self-handles `Delete` (action is sole owner → deletes exactly once), `onfocus` syncs the active region. Removed the now-redundant `onDeleteRequest` plumbing through AnnotationLayer/PdfViewer/DocumentViewer. - **Coach card** — `?` tip added (#320). **i18n** complete de/en/es. **e2e** `transcribe-shortcuts.spec.ts` + axe on the open cheatsheet. ## Two decisions where the codebase differed from the spec (please sanity-check) 1. **`t` semantics** — no per-region training flag exists on `main` (that's #321); "mark for training" is two fixed *document-level* script-type chips. So `t` toggles the document's `KURRENT_RECOGNITION` enrollment, no-op unless a region is active. Follow-up once #321 lands. 2. **`n` draw mode** — drawing is always-on pointer-drag with no keyboard draw path, so `n` (edit-only) arms a visible draw cue (`drawArmed` → hint banner), cleared on next draw / leaving edit mode. ## Minor deviations from the issue's file list - Action spec is `transcribeShortcuts.svelte.spec.ts` (not `.spec.ts`) so it runs in the client/browser vitest project where DOM APIs exist — matches `clickOutside.svelte.spec.ts`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 6 commits 2026-06-04 17:05:36 +02:00
Adds de/en/es Paraglide keys for the keyboard-shortcut cheatsheet,
coach hint, draw-armed hint, and the discoverable annotation Delete
aria-label.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Single-owner window keydown action for the Transcribe panel: j/k region
nav, e mode toggle, n draw (edit only), t training mark, Delete, ? cheat-
sheet, and the Esc precedence ladder (cheatsheet → editable no-op → close
panel). Pure input-to-callback translator with a focus guard that exempts
only "?"; removes its listener on destroy. 20 unit tests cover every key,
the panel/focus guards, the Esc matrix, and teardown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Native <dialog aria-modal> cheatsheet: showModal()/close() bridge, close
button focused on open, eight grouped <kbd> rows (nav/edit/utility), an
autosave footer line, and a reduced-motion-guarded fade. Closes on Esc,
backdrop click, and the close button; "?" while open is a no-op. Adds the
shortcut_close_panel i18n key. 8 component tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Attaches the transcribeShortcuts action to the document page and wires every
command to existing context setters: j/k walk the sortOrder-sorted regions
and set activeAnnotationId, e toggles read/edit, n arms a draw cue (edit
only), Delete routes to the existing confirm path, ? opens the cheatsheet,
and Esc is now owned solely by the action — the inline onMount Esc listener
is removed (decision B1). Renders ShortcutCheatsheet and a draw-armed hint.

"t" toggles the document-level KURRENT_RECOGNITION training enrollment (the
only training surface that exists; there is no per-region flag yet — see
#321) and no-ops unless a region is active. Also reconciles annotation
Delete: the shape no longer self-handles the key, with onfocus syncing the
active region so the action deletes exactly once.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a secondary keyboard hint to the existing coach footer row pointing
transcribers at the "?" cheatsheet, with a semantic <kbd>. Cross-references
the shortcuts introduced for the empty-state coach (#320).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
test(transcribe): e2e coverage for shortcuts + cheatsheet a11y (#327)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m35s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m32s
CI / fail2ban Regex (pull_request) Successful in 42s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
6cd500ed8f
Seeds a two-block document via API (annotations.spec pattern) and drives the
keyboard: ? opens the cheatsheet, Esc closes it then a second Esc closes the
panel (Esc ladder), e toggles read/edit, and j/k walk the regions forward and
back. Adds an axe-core pass over the open dialog asserting no critical
violations and aria-modal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

🏛️ Markus — Software Architect

Approved

I reviewed this purely through the architecture lens: layer boundaries, coupling, module responsibilities, and domain separation. This is a structurally clean change — it actually reduces coupling rather than adding it, which is exactly what I want to see from a frontend feature PR.

What I checked, and why it's clean

1. The prop-plumbing removal is a real coupling win. Previously onDeleteAnnotationRequest / onDeleteRequest was threaded down four layers (DocumentViewerPdfViewerAnnotationLayerAnnotationShape) for the sole purpose of letting the leaf shape's inline Delete keydown call back up to the page. That is plumbing-as-coupling: a presentational leaf component owning a domain command. The PR collapses it — deletion now lives at the page level (deleteCurrentRegion → the still-present handleAnnotationDeleteRequest), and the downward callback is replaced by a thinner, more general onAnnotationFocus(id) signal. The leaf no longer knows about deletion semantics at all (AnnotationShape.svelte:343-346 documents the single-owner decision). I grepped the tree — zero dangling references to the removed props. Boundary is clean.

2. Single owner for the keydown / Esc ladder is the right call. The old inline document.addEventListener('keydown', …) in +page.svelte's onMount competed with the shape's own Delete handler for ownership of keyboard behavior. The action (transcribeShortcuts.ts) is now the single owner of the panel's global keydown, including Esc (decision B1, documented inline at transcribeShortcuts.ts:944-953 and +page.svelte:288-289). Consolidating two scattered listeners into one well-documented owner is a responsibility-boundary improvement.

3. The action's interface is properly decoupled from the domain. TranscribeShortcutOptions is all primitives + callbacks — no transcription entity types leak in. The action owns no state and has no persistence responsibility; it's a pure input→callback translator. That keeps it independently unit-testable (the 254-line spec mocks every callback with zero Svelte/DOM-store coupling), which is exactly the testability posture I want: logic lives in a replaceable unit, not buried in a component's lifecycle.

4. No accidental complexity, no backend/infra touch. Pure frontend island. No new transport, no schema, no migration, no new domain concept, no new ErrorCode/Permission, no route. Nothing on my doc-currency checklist is triggered — no diagram or ADR update is owed. i18n keys added across all three locales (de/en/es) consistently.

Suggestions (non-blocking)

  • transcribeShortcuts.ts placement. It lives in shared/actions/ next to clickOutside, trapFocus, radioGroupNav — but those three are domain-agnostic primitives, whereas this one is transcribe-specific (its option vocabulary is goToNextRegion, toggleTrainingMark, etc.). The coupling is fine because the interface only takes primitives/callbacks, so I'm not asking for a move. But for "navigate by naming convention alone," a transcribe-specific action arguably belongs under document/transcription/ alongside ShortcutCheatsheet.svelte. shared/ should read as "reusable anywhere." Minor — your call; if it stays, a one-line comment noting it's transcribe-scoped-but-parked-here would help future readers. (transcribeShortcuts.ts:944)

  • The action ignores its node parameter (_node) and binds the listener to window instead. That's a legitimate choice for a panel-global shortcut, and the underscore signals intent — but it means use:transcribeShortcuts on the panel div (+page.svelte:1199) is slightly misleading: the binding element is not the listener scope. A short comment ("listens on window, not node, because shortcuts are panel-global while the panel is a fixed overlay") would close the small gap between what the directive looks like it does and what it does. Non-blocking.

Neither suggestion affects a module boundary or data-integrity rule, so they don't gate the merge. From an architecture standpoint this lands cleanly. Nice work pulling the delete responsibility back up to where it belongs.

## 🏛️ Markus — Software Architect **✅ Approved** I reviewed this purely through the architecture lens: layer boundaries, coupling, module responsibilities, and domain separation. This is a structurally clean change — it actually *reduces* coupling rather than adding it, which is exactly what I want to see from a frontend feature PR. ### What I checked, and why it's clean **1. The prop-plumbing removal is a real coupling win.** Previously `onDeleteAnnotationRequest` / `onDeleteRequest` was threaded down four layers (`DocumentViewer` → `PdfViewer` → `AnnotationLayer` → `AnnotationShape`) for the sole purpose of letting the leaf shape's inline `Delete` keydown call back up to the page. That is plumbing-as-coupling: a presentational leaf component owning a domain command. The PR collapses it — deletion now lives at the page level (`deleteCurrentRegion` → the still-present `handleAnnotationDeleteRequest`), and the downward callback is replaced by a thinner, more general `onAnnotationFocus(id)` signal. The leaf no longer knows about deletion semantics at all (`AnnotationShape.svelte:343-346` documents the single-owner decision). I grepped the tree — zero dangling references to the removed props. Boundary is clean. **2. Single owner for the `keydown` / Esc ladder is the right call.** The old inline `document.addEventListener('keydown', …)` in `+page.svelte`'s `onMount` competed with the shape's own `Delete` handler for ownership of keyboard behavior. The action (`transcribeShortcuts.ts`) is now the single owner of the panel's global keydown, including Esc (decision B1, documented inline at `transcribeShortcuts.ts:944-953` and `+page.svelte:288-289`). Consolidating two scattered listeners into one well-documented owner is a responsibility-boundary improvement. **3. The action's interface is properly decoupled from the domain.** `TranscribeShortcutOptions` is all primitives + callbacks — no transcription entity types leak in. The action owns no state and has no persistence responsibility; it's a pure input→callback translator. That keeps it independently unit-testable (the 254-line spec mocks every callback with zero Svelte/DOM-store coupling), which is exactly the testability posture I want: logic lives in a replaceable unit, not buried in a component's lifecycle. **4. No accidental complexity, no backend/infra touch.** Pure frontend island. No new transport, no schema, no migration, no new domain concept, no new ErrorCode/Permission, no route. Nothing on my doc-currency checklist is triggered — no diagram or ADR update is owed. i18n keys added across all three locales (de/en/es) consistently. ### Suggestions (non-blocking) - **`transcribeShortcuts.ts` placement.** It lives in `shared/actions/` next to `clickOutside`, `trapFocus`, `radioGroupNav` — but those three are domain-*agnostic* primitives, whereas this one is transcribe-specific (its option vocabulary is `goToNextRegion`, `toggleTrainingMark`, etc.). The *coupling* is fine because the interface only takes primitives/callbacks, so I'm not asking for a move. But for "navigate by naming convention alone," a transcribe-specific action arguably belongs under `document/transcription/` alongside `ShortcutCheatsheet.svelte`. `shared/` should read as "reusable anywhere." Minor — your call; if it stays, a one-line comment noting it's transcribe-scoped-but-parked-here would help future readers. (`transcribeShortcuts.ts:944`) - **The action ignores its `node` parameter** (`_node`) and binds the listener to `window` instead. That's a legitimate choice for a panel-global shortcut, and the underscore signals intent — but it means `use:transcribeShortcuts` on the panel div (`+page.svelte:1199`) is slightly misleading: the binding element is not the listener scope. A short comment ("listens on window, not node, because shortcuts are panel-global while the panel is a fixed overlay") would close the small gap between what the directive *looks* like it does and what it does. Non-blocking. Neither suggestion affects a module boundary or data-integrity rule, so they don't gate the merge. From an architecture standpoint this lands cleanly. Nice work pulling the delete responsibility back up to where it belongs.
Author
Owner

🔒 Nora — Security Engineer

Verdict: LGTM. Frontend-only keyboard-shortcut feature with effectively zero new security surface — no network/auth changes, no untrusted data rendered unsafely, and the global listener lifecycle is correctly bounded. A couple of hygiene notes below, none blocking.

Blockers

None.

Suggestions

  • transcribeShortcuts.ts:30 — listener stays attached for the whole document page, not just transcribe mode. The window keydown handler is registered on the page-root <div> (+page.svelte:288), which is unconditionally mounted on /documents/[id]. Svelte calls destroy() on navigation away, so there is no leak — good. But the listener is live even when the panel is closed; every key on the document page runs through handleKeydown. The early isPanelOpen() guards (transcribeShortcuts.ts:25, :34) keep it a clean no-op, so this is fine as-is. Worth a one-line comment noting the guard is the thing that makes the always-on listener safe, for the next auditor. (CWE-401 considered and ruled out — flagging only as documentation hygiene.)

  • transcribeShortcuts.ts:18-39preventDefault scope is appropriately narrow. e.preventDefault() only fires on keys the action actually consumes (j/k/e/n/t/Delete/?/Esc) and only after the editable-focus guard (isEditableTarget, :21) has bailed. This correctly avoids swallowing typing inside <input>/<textarea>/TipTap contenteditable. The ?-before-panel-guard ordering (:30 returns early if !isPanelOpen()) means it won't steal ? on unrelated pages. No key-swallowing concern.

  • i18n strings are all static literals (de/en/es.json) rendered via Paraglide m.*() and plain kbd/text interpolation — no user-controlled data reaches the DOM. ShortcutCheatsheet.svelte and the coach hint render only hardcoded shortcut caps and translated labels. No {@html}, no innerHTML. XSS surface: none.

  • ShortcutCheatsheet.svelte:38 backdrop-click close compares event.target === dialogEl on a native <dialog> with aria-modal="true" and focus moved to the close button on open — correct modal hygiene, no focus-trap escape concern.

  • E2E seeding (transcribe-shortcuts.spec.ts:33-84) creates the doc + blocks through the real authenticated API (same pattern as annotations.spec), not by bypassing auth or writing to the DB directly. Good — it exercises the actual permission path rather than circumventing it. E2E_BASE_URL defaults to localhost; no secrets embedded.

Nice touch removing the duplicate Delete handler from AnnotationShape.svelte and making the action the single owner — that eliminates the double-delete race and is backed by a regression test (AnnotationShape.svelte.spec.ts:55). Clean.

## 🔒 Nora — Security Engineer **Verdict: LGTM.** Frontend-only keyboard-shortcut feature with effectively zero new security surface — no network/auth changes, no untrusted data rendered unsafely, and the global listener lifecycle is correctly bounded. A couple of hygiene notes below, none blocking. ### Blockers None. ### Suggestions - **`transcribeShortcuts.ts:30` — listener stays attached for the whole document page, not just transcribe mode.** The `window` keydown handler is registered on the page-root `<div>` (`+page.svelte:288`), which is unconditionally mounted on `/documents/[id]`. Svelte calls `destroy()` on navigation away, so there is **no leak** — good. But the listener is live even when the panel is closed; every key on the document page runs through `handleKeydown`. The early `isPanelOpen()` guards (`transcribeShortcuts.ts:25`, `:34`) keep it a clean no-op, so this is fine as-is. Worth a one-line comment noting the guard is the thing that makes the always-on listener safe, for the next auditor. (CWE-401 considered and ruled out — flagging only as documentation hygiene.) - **`transcribeShortcuts.ts:18-39` — `preventDefault` scope is appropriately narrow.** `e.preventDefault()` only fires on keys the action actually consumes (`j/k/e/n/t/Delete/?/Esc`) and only after the editable-focus guard (`isEditableTarget`, `:21`) has bailed. This correctly avoids swallowing typing inside `<input>`/`<textarea>`/TipTap `contenteditable`. The `?`-before-panel-guard ordering (`:30` returns early if `!isPanelOpen()`) means it won't steal `?` on unrelated pages. No key-swallowing concern. - **i18n strings are all static literals (`de/en/es.json`) rendered via Paraglide `m.*()` and plain `kbd`/text interpolation — no user-controlled data reaches the DOM.** `ShortcutCheatsheet.svelte` and the coach hint render only hardcoded shortcut caps and translated labels. No `{@html}`, no `innerHTML`. XSS surface: none. - **`ShortcutCheatsheet.svelte:38` backdrop-click close** compares `event.target === dialogEl` on a native `<dialog>` with `aria-modal="true"` and focus moved to the close button on open — correct modal hygiene, no focus-trap escape concern. - **E2E seeding (`transcribe-shortcuts.spec.ts:33-84`)** creates the doc + blocks through the real authenticated API (same pattern as `annotations.spec`), not by bypassing auth or writing to the DB directly. Good — it exercises the actual permission path rather than circumventing it. `E2E_BASE_URL` defaults to localhost; no secrets embedded. Nice touch removing the duplicate `Delete` handler from `AnnotationShape.svelte` and making the action the single owner — that eliminates the double-delete race and is backed by a regression test (`AnnotationShape.svelte.spec.ts:55`). Clean.
Author
Owner

⚙️ DevOps

LGTM — frontend-only change, nothing operational to flag. No new dependencies, no CI/build/config changes, no env or secret touches. The new e2e spec fits the existing Playwright setup cleanly.

Blockers

None.

Suggestions

None blocking. Notes for the record:

  • No dependency or config drift. package.json/package-lock.json/playwright.config.ts are untouched. The spec's only non-Playwright import, @axe-core/playwright, is already a declared devDependency (frontend/package.json:38) and is used identically by ~10 existing specs — so the i18n compile (Paraglide) and the CI image (mcr.microsoft.com/playwright:v1.60.0-noble, .gitea/workflows/ci.yml:16) are unaffected.
  • New spec auto-runs without config. transcribe-shortcuts.spec.ts lands under testDir: './e2e' (frontend/playwright.config.ts:8) and is picked up automatically — no workflow edit needed.
  • Flakiness risk: low. The spec mirrors the proven annotations.spec.ts pattern (API-seed two transcription blocks in beforeAll, then drive the keyboard — no OCR, no manual draw). It reuses the committed frontend/e2e/fixtures/minimal.pdf fixture (present, 310 bytes). It waits on concrete selectors ([data-hydrated], [data-testid="annotation-…]") rather than fixed sleeps, and per-test setTimeout(30_000) is in line with neighbours. With workers: 1 / sequential execution and retries: 2 in CI (playwright.config.ts:22-24), the timing surface is well controlled.
  • CI cost: marginal. Four short tests appended to an already-sequential suite — a small, bounded runtime add, not a concern.

One minor watch-item (non-blocking): the a11y test at frontend/e2e/transcribe-shortcuts.spec.ts:152 runs AxeBuilder scoped to .include('dialog'). Axe scans can be the slowest part of an e2e run; scoping to the dialog (as done here) keeps it cheap, so this is fine as written — just the one spot to revisit if e2e wall-time ever creeps up.

## ⚙️ DevOps **LGTM** — frontend-only change, nothing operational to flag. No new dependencies, no CI/build/config changes, no env or secret touches. The new e2e spec fits the existing Playwright setup cleanly. ### Blockers None. ### Suggestions None blocking. Notes for the record: - **No dependency or config drift.** `package.json`/`package-lock.json`/`playwright.config.ts` are untouched. The spec's only non-Playwright import, `@axe-core/playwright`, is already a declared devDependency (`frontend/package.json:38`) and is used identically by ~10 existing specs — so the i18n compile (Paraglide) and the CI image (`mcr.microsoft.com/playwright:v1.60.0-noble`, `.gitea/workflows/ci.yml:16`) are unaffected. - **New spec auto-runs without config.** `transcribe-shortcuts.spec.ts` lands under `testDir: './e2e'` (`frontend/playwright.config.ts:8`) and is picked up automatically — no workflow edit needed. - **Flakiness risk: low.** The spec mirrors the proven `annotations.spec.ts` pattern (API-seed two transcription blocks in `beforeAll`, then drive the keyboard — no OCR, no manual draw). It reuses the committed `frontend/e2e/fixtures/minimal.pdf` fixture (present, 310 bytes). It waits on concrete selectors (`[data-hydrated]`, `[data-testid="annotation-…]"`) rather than fixed sleeps, and per-test `setTimeout(30_000)` is in line with neighbours. With `workers: 1` / sequential execution and `retries: 2` in CI (`playwright.config.ts:22-24`), the timing surface is well controlled. - **CI cost: marginal.** Four short tests appended to an already-sequential suite — a small, bounded runtime add, not a concern. One minor watch-item (non-blocking): the a11y test at `frontend/e2e/transcribe-shortcuts.spec.ts:152` runs AxeBuilder scoped to `.include('dialog')`. Axe scans can be the slowest part of an e2e run; scoping to the dialog (as done here) keeps it cheap, so this is fine as written — just the one spot to revisit if e2e wall-time ever creeps up.
Author
Owner

🎨 Leonie — UX / Accessibility

Verdict: Solid, accessible keyboard layer — native <dialog>, real focus management, aria-keyshortcuts, reduced-motion all done right. Two real issues hold it back from a clean approve: key-cap glyphs are hardcoded German, and the whole shortcut surface is shown to touch-only users who can't use it.

Blockers

  1. Key-cap glyphs are hardcoded, not i18n'd — ShortcutCheatsheet.svelte:19,22. Every label in the cheatsheet is a Paraglide message, but the caps 'Entf' and 'Esc' are German literals baked into the groups array. An EN/ES user sees German Entf while reading "Delete current region". Entf is also the wrong glyph on a US/UK/ES keyboard — that key reads Del/Supr. The standard cross-keyboard label is Delete/Del. Either route the caps through messages (cheatsheet_cap_deleteEntf/Del/Supr, cheatsheet_cap_escEsc) or use the neutral Esc/Del everywhere. Same literal leaks into AnnotationShape.svelte: annotation_label_with_delete is correctly i18n'd ("Show block, press Delete to remove"), but the fallback 'Block anzeigen' on line 39 is a hardcoded German string — that's the label a non-transcribe reader's screen reader announces, in every locale. Move it to a message too.

  2. Touch-only devices are shown shortcuts they can't trigger — ShortcutCheatsheet.svelte, TranscribeCoachEmptyState.svelte:75-82, +page.svelte ? path. There's no (pointer: coarse) / (hover: none) guard anywhere (confirmed: no matchMedia for pointer modality in any touched file). A 67-year-old transcriber on a tablet gets a ? tip pointing at a key they don't have, and if a Bluetooth-less device ever shows the cheatsheet trigger it's pure noise. Per our dual-audience rule, gate the empty-state tip and any visible cheatsheet entry point behind a fine-pointer check (e.g. window.matchMedia('(any-hover: hover) and (any-pointer: fine)'), reactive to change). The ? keydown handler itself can stay unconditional — it's harmless on touch since the key never fires.

Suggestions

  1. <kbd> chips are 12px (text-xs) — ShortcutCheatsheet.svelte:74, TranscribeCoachEmptyState.svelte:78 (11px). 12px is my hard floor and 11px is below it. The cheatsheet is a deliberate reading surface for the 60+ audience; bump the caps to text-sm (14px) and the empty-state inline ? to at least text-xs. Contrast is fine (text-ink on bg-muted, both AA-documented in layout.css).

  2. Draw hint is announced, but role="status" re-announcement is unreliable — +page.svelte:430-437. Good call making it non-blocking (pointer-events-none, centered, z-50). But a plain role="status" element that's conditionally mounted may not re-announce if armed twice in a row. Render the live region as a persistent node toggling its text content (or add aria-live="polite" explicitly on a stable wrapper) so the cue is announced every time n is pressed, not just first mount. Visually it's clean and doesn't overlap the cheatsheet (separate stacking, hint at bottom-4).

  3. Cheatsheet rows are single-column key-then-label, not the two-column grid the spec implied — ShortcutCheatsheet.svelte:72-78. Not a bug; the justify-between rows read fine and stay full-width on mobile (w-[calc(100%-2rem)] max-w-md). Just flagging that label-right-aligned with the cap hard-left puts a wide gap between key and meaning on a narrow phone — consider justify-start gap-3 with the label immediately after the cap so the eye doesn't have to track across the row.

  4. Close button is a proper 44×44 (h-11 w-11) with aria-label and focus-on-open — all correct. Backdrop-click-to-close and onclose wired to onClose are good. One nit: the close button has hover:bg-muted but no visible focus-visible: ring — add focus-visible:ring-2 focus-visible:ring-brand-navy so keyboard users landing on it (it's auto-focused) see the focus state, not just the hover state.

## 🎨 Leonie — UX / Accessibility **Verdict:** Solid, accessible keyboard layer — native `<dialog>`, real focus management, `aria-keyshortcuts`, reduced-motion all done right. Two real issues hold it back from a clean approve: key-cap glyphs are hardcoded German, and the whole shortcut surface is shown to touch-only users who can't use it. ### Blockers 1. **Key-cap glyphs are hardcoded, not i18n'd — `ShortcutCheatsheet.svelte:19,22`.** Every *label* in the cheatsheet is a Paraglide message, but the caps `'Entf'` and `'Esc'` are German literals baked into the `groups` array. An EN/ES user sees German `Entf` while reading "Delete current region". `Entf` is also the wrong glyph on a US/UK/ES keyboard — that key reads `Del`/`Supr`. The standard cross-keyboard label is `Delete`/`Del`. Either route the caps through messages (`cheatsheet_cap_delete` → `Entf`/`Del`/`Supr`, `cheatsheet_cap_esc` → `Esc`) or use the neutral `Esc`/`Del` everywhere. Same literal leaks into `AnnotationShape.svelte`: `annotation_label_with_delete` is correctly i18n'd ("Show block, press Delete to remove"), but the *fallback* `'Block anzeigen'` on line 39 is a hardcoded German string — that's the label a non-transcribe reader's screen reader announces, in every locale. Move it to a message too. 2. **Touch-only devices are shown shortcuts they can't trigger — `ShortcutCheatsheet.svelte`, `TranscribeCoachEmptyState.svelte:75-82`, `+page.svelte` `?` path.** There's no `(pointer: coarse)` / `(hover: none)` guard anywhere (confirmed: no `matchMedia` for pointer modality in any touched file). A 67-year-old transcriber on a tablet gets a `?` tip pointing at a key they don't have, and if a Bluetooth-less device ever shows the cheatsheet trigger it's pure noise. Per our dual-audience rule, gate the empty-state tip and any visible cheatsheet entry point behind a fine-pointer check (e.g. `window.matchMedia('(any-hover: hover) and (any-pointer: fine)')`, reactive to `change`). The `?` keydown handler itself can stay unconditional — it's harmless on touch since the key never fires. ### Suggestions 3. **`<kbd>` chips are 12px (`text-xs`) — `ShortcutCheatsheet.svelte:74`, `TranscribeCoachEmptyState.svelte:78` (11px).** 12px is my hard floor and 11px is below it. The cheatsheet is a deliberate reading surface for the 60+ audience; bump the caps to `text-sm` (14px) and the empty-state inline `?` to at least `text-xs`. Contrast is fine (`text-ink` on `bg-muted`, both AA-documented in `layout.css`). 4. **Draw hint is announced, but `role="status"` re-announcement is unreliable — `+page.svelte:430-437`.** Good call making it non-blocking (`pointer-events-none`, centered, `z-50`). But a plain `role="status"` element that's conditionally mounted may not re-announce if armed twice in a row. Render the live region as a persistent node toggling its text content (or add `aria-live="polite"` explicitly on a stable wrapper) so the cue is announced every time `n` is pressed, not just first mount. Visually it's clean and doesn't overlap the cheatsheet (separate stacking, hint at `bottom-4`). 5. **Cheatsheet rows are single-column key-then-label, not the two-column grid the spec implied — `ShortcutCheatsheet.svelte:72-78`.** Not a bug; the `justify-between` rows read fine and stay full-width on mobile (`w-[calc(100%-2rem)] max-w-md`). Just flagging that label-right-aligned with the cap hard-left puts a wide gap between key and meaning on a narrow phone — consider `justify-start gap-3` with the label immediately after the cap so the eye doesn't have to track across the row. 6. **Close button is a proper 44×44 (`h-11 w-11`) with `aria-label` and focus-on-open — all correct.** Backdrop-click-to-close and `onclose` wired to `onClose` are good. One nit: the close button has `hover:bg-muted` but no visible `focus-visible:` ring — add `focus-visible:ring-2 focus-visible:ring-brand-navy` so keyboard users landing on it (it's auto-focused) see the focus state, not just the hover state.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

⚠️ Approved with concerns — clean, well-tested, idiomatic Svelte 5. The action design (stateless input→callback translator owning the global keydown) is exactly right, the Esc ladder is documented and tested, and the Delete single-owner refactor closes a real double-delete hole. A handful of clean-code nits, no blockers.

What I liked

  • TDD evidence is solid. Every feat commit ships its spec alongside the impl (52920a5a is spec+impl only). The transcribeShortcuts.svelte.spec.ts covers guards, edge cases (panel closed, modifier held, cheatsheet-already-open), the Esc ladder rung-by-rung, and lifecycle (destroy + update() swapping stale closures). This is the empty/error/edge discipline I want to see.
  • Svelte 5 rules respected. {#each} is keyed everywhere ((i), (shortcut.cap), (annotation.id) downstream). sortedBlocks is a named $derived, not a $state+$effect. The two page $effects are legitimate side-effect synchronisation, not derived-value-in-effect: the cheatsheet one drives the imperative <dialog>.showModal() DOM API, and the drawArmed reset is cross-state reconciliation. Both correct.
  • The stable shortcutOptions getter object is the right call. Methods close over $state via getters, so the listener reads live state and update() never needs to refire. Good reasoning, and the update() test pins it.

Blockers

None.

Suggestions

1. Nested ternary in goToRegion — hard to hold in working memory (+page.svelte ~L113-123)

const next =
    current === -1
        ? delta > 0 ? 0 : sortedBlocks.length - 1
        : (current + delta + sortedBlocks.length) % sortedBlocks.length;

Two guard clauses read in one pass:

function goToRegion(delta: 1 | -1) {
    if (sortedBlocks.length === 0) return;
    const current = sortedBlocks.findIndex((b) => b.annotationId === activeAnnotationId);
    if (current === -1) {
        activeAnnotationId = sortedBlocks[delta > 0 ? 0 : sortedBlocks.length - 1].annotationId;
        return;
    }
    const next = (current + delta + sortedBlocks.length) % sortedBlocks.length;
    activeAnnotationId = sortedBlocks[next].annotationId;
}

2. Redundant condition in the draw-hint template (+page.svelte L430)

{#if drawArmed && panelMode === 'edit'}

The $effect at L138 already forces drawArmed = false whenever panelMode !== 'edit', so drawArmed is true ⟹ we're in edit mode. The && panelMode === 'edit' is dead logic in the markup — {#if drawArmed} is sufficient. (If you keep the belt-and-braces guard, that's a defensible call; just flagging it isn't load-bearing.)

3. Untranslated aria-label fallback (AnnotationShape.svelte ~L40)

const ariaLabel = $derived(showDelete ? m.annotation_label_with_delete() : 'Block anzeigen');

The new branch is i18n'd via paraglide; the fallback is a hardcoded German literal. This pre-dates your PR (the old aria-label="Block anzeigen" was already hardcoded), but since you're touching the line, a m.annotation_label_plain() key would finish the job and keep es/en honest.

4. e swallows the key even when read-only (+page.svelte toggleMode)
The action always preventDefault()s e, but toggleMode() no-ops when !canWrite. A read-only user pressing e gets a silent dead key. Minor, but consider gating the action's e on panelMode/write-capability the way n is gated, so the keypress falls through when it can't act.

Note

n (startDrawMode) only arms the visual hint banner — it doesn't change the viewer's actual draw capability (drawing is already available in edit mode via canDraw). That's the intended "cue" per your comment, so it's correct; just confirming it's cosmetic-by-design and not a missed wiring.

i18n parity verified: all 15 new keys present in de/en/es. Nice work.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **⚠️ Approved with concerns** — clean, well-tested, idiomatic Svelte 5. The action design (stateless input→callback translator owning the global keydown) is exactly right, the Esc ladder is documented and tested, and the Delete single-owner refactor closes a real double-delete hole. A handful of clean-code nits, no blockers. ### What I liked - **TDD evidence is solid.** Every feat commit ships its spec alongside the impl (`52920a5a` is spec+impl only). The `transcribeShortcuts.svelte.spec.ts` covers guards, edge cases (panel closed, modifier held, cheatsheet-already-open), the Esc ladder rung-by-rung, and lifecycle (destroy + `update()` swapping stale closures). This is the empty/error/edge discipline I want to see. - **Svelte 5 rules respected.** `{#each}` is keyed everywhere (`(i)`, `(shortcut.cap)`, `(annotation.id)` downstream). `sortedBlocks` is a named `$derived`, not a `$state`+`$effect`. The two page `$effect`s are legitimate side-effect synchronisation, not derived-value-in-effect: the cheatsheet one drives the imperative `<dialog>.showModal()` DOM API, and the `drawArmed` reset is cross-state reconciliation. Both correct. - **The stable `shortcutOptions` getter object is the right call.** Methods close over `$state` via getters, so the listener reads live state and `update()` never needs to refire. Good reasoning, and the `update()` test pins it. ### Blockers None. ### Suggestions **1. Nested ternary in `goToRegion` — hard to hold in working memory** (`+page.svelte` ~L113-123) ```js const next = current === -1 ? delta > 0 ? 0 : sortedBlocks.length - 1 : (current + delta + sortedBlocks.length) % sortedBlocks.length; ``` Two guard clauses read in one pass: ```js function goToRegion(delta: 1 | -1) { if (sortedBlocks.length === 0) return; const current = sortedBlocks.findIndex((b) => b.annotationId === activeAnnotationId); if (current === -1) { activeAnnotationId = sortedBlocks[delta > 0 ? 0 : sortedBlocks.length - 1].annotationId; return; } const next = (current + delta + sortedBlocks.length) % sortedBlocks.length; activeAnnotationId = sortedBlocks[next].annotationId; } ``` **2. Redundant condition in the draw-hint template** (`+page.svelte` L430) ```svelte {#if drawArmed && panelMode === 'edit'} ``` The `$effect` at L138 already forces `drawArmed = false` whenever `panelMode !== 'edit'`, so `drawArmed` is true ⟹ we're in edit mode. The `&& panelMode === 'edit'` is dead logic in the markup — `{#if drawArmed}` is sufficient. (If you keep the belt-and-braces guard, that's a defensible call; just flagging it isn't load-bearing.) **3. Untranslated aria-label fallback** (`AnnotationShape.svelte` ~L40) ```js const ariaLabel = $derived(showDelete ? m.annotation_label_with_delete() : 'Block anzeigen'); ``` The new branch is i18n'd via paraglide; the fallback is a hardcoded German literal. This pre-dates your PR (the old `aria-label="Block anzeigen"` was already hardcoded), but since you're touching the line, a `m.annotation_label_plain()` key would finish the job and keep es/en honest. **4. `e` swallows the key even when read-only** (`+page.svelte` `toggleMode`) The action always `preventDefault()`s `e`, but `toggleMode()` no-ops when `!canWrite`. A read-only user pressing `e` gets a silent dead key. Minor, but consider gating the action's `e` on `panelMode`/write-capability the way `n` is gated, so the keypress falls through when it can't act. ### Note `n` (`startDrawMode`) only arms the visual hint banner — it doesn't change the viewer's actual draw capability (drawing is already available in edit mode via `canDraw`). That's the intended "cue" per your comment, so it's correct; just confirming it's cosmetic-by-design and not a missed wiring. i18n parity verified: all 15 new keys present in de/en/es. Nice work.
Author
Owner

🧪 Sara — QA Engineer

Verdict: Approve with reservations. The action layer is genuinely well-tested (20 clean, sentence-named cases, real teardown, leak + stale-state checks). My concern is the seam: the page-level wiring that actually contains the interesting branch logic has zero direct coverage, and the headline "delete exactly once" claim is proven by construction in two separate files but never against the integrated whole.

Blockers

None that should hold merge — no broken tests, no flake I can reproduce. The items below are coverage gaps I'd want closed in this PR or a fast follow.

Suggestions

1. goToRegion wrap-around + fresh-entry branches are untested (routes/documents/[id]/+page.svelte:96-107).
The branchy logic — current === -1 ? (delta>0?0:len-1) and the modulo wrap (current+delta+len)%len — lives here, but the action spec mocks goToNextRegion/goToPrevRegion as vi.fn(), so none of it is exercised by unit tests. The e2e (transcribe-shortcuts.spec.ts:130-143) only does j j k across two blocks and stops before any boundary, so neither the end→start wrap (j on last) nor start→end wrap (k on first) nor the -1 fresh-entry-from-k path ever runs. This is the exact "untested boundary condition" pattern. Extract goToRegion (or a pure nextIndex(current, delta, len) helper) into a testable unit and cover: empty list, fresh entry via j and via k, wrap at both ends.

2. Empty-block-list early return is untested (+page.svelte:98). if (sortedBlocks.length === 0) return; has no test. One line, one assertion — j/k with no blocks is a no-op.

3. t with no active block is untested (+page.svelte:135-139). The action spec proves t fires the toggleTrainingMark callback, but the callback's own guard if (!activeAnnotationId) return; — the whole reason the comment says "no-op unless a region is active" — is never asserted. The behavior the doc-comment promises is the one with no test behind it.

4. The "delete exactly once" double-bind fix is proven by construction, not integration. Two separate unit facts (AnnotationShape.svelte.spec.ts:59 "shape does not act on Delete", + action spec:198 "action fires once") together imply exactly-once, but nothing renders a real AnnotationShape and attaches the action and presses Delete once to confirm the count is 1, not 2. The action spec at :198 even decorates the target with data-annotation/tabindex to look like the real scenario, but since the shape no longer binds Delete, that DOM is inert — the assertion passes identically with a plain document target, so it gives false reassurance that the double-bind is covered. A single component/integration test (shape + action, one Delete, toHaveBeenCalledOnce) would actually pin the regression you're fixing.

5. drawArmed hint + disarm-$effect are untested (+page.svelte:130, 137-139, 430-438). New visible UI state (the draw-hint banner) and the "disarm on leaving edit mode" effect have no coverage. n toggling the hint visible, and switching mode hiding it, would be a small component test.

6. e2e flake watch (not blocking, just flagging the load-bearing assumptions):

  • j/k correctness hinges entirely on the resize overlay (RESIZE_AREA_LABEL) rendering for the active region in read mode (:88-90, :135). The header comment asserts this; if that overlay ever becomes edit-only, this test silently inverts from "navigation works" to "navigation broken" with no other signal. Worth a one-line comment in the impl tying the read-mode overlay to this test, so a future refactor can't quietly break it.
  • e-toggle (:113-128) couples to the literal string 'Für Training vormerken' as its only edit-mode proxy. Fine today; a data-testid on the edit view would be more durable than a content string.

What's good

  • Action spec is exemplary: AAA, sentence names, factory (makeOptions/makeEditable), real afterEach teardown, explicit leak test (:236) and stale-update() test (:244). This is the test quality bar I want.
  • Cheatsheet a11y is gated properly — aria-modal, labelled heading, focus-on-open (:60), and axe critical filter in e2e (:156). Accessibility-as-gate, exactly right.
  • aria-keyshortcuts/label coverage in AnnotationShape.svelte.spec.ts (:both branches) is thorough.

Net: ship it if the wrap-around (#1) gets at least a thin unit test — that's the one genuinely branchy, genuinely untested piece of business logic in the diff. The rest are cheap follow-ups.

## 🧪 Sara — QA Engineer **Verdict: Approve with reservations.** The action layer is genuinely well-tested (20 clean, sentence-named cases, real teardown, leak + stale-state checks). My concern is the *seam*: the page-level wiring that actually contains the interesting branch logic has zero direct coverage, and the headline "delete exactly once" claim is proven by construction in two separate files but never against the integrated whole. ### Blockers None that should hold merge — no broken tests, no flake I can reproduce. The items below are coverage gaps I'd want closed in this PR or a fast follow. ### Suggestions **1. `goToRegion` wrap-around + fresh-entry branches are untested** (`routes/documents/[id]/+page.svelte:96-107`). The branchy logic — `current === -1 ? (delta>0?0:len-1)` and the modulo wrap `(current+delta+len)%len` — lives here, but the action spec mocks `goToNextRegion`/`goToPrevRegion` as `vi.fn()`, so none of it is exercised by unit tests. The e2e (`transcribe-shortcuts.spec.ts:130-143`) only does `j j k` across **two** blocks and stops before any boundary, so neither the end→start wrap (`j` on last) nor start→end wrap (`k` on first) nor the `-1` fresh-entry-from-`k` path ever runs. This is the exact "untested boundary condition" pattern. Extract `goToRegion` (or a pure `nextIndex(current, delta, len)` helper) into a testable unit and cover: empty list, fresh entry via `j` and via `k`, wrap at both ends. **2. Empty-block-list early return is untested** (`+page.svelte:98`). `if (sortedBlocks.length === 0) return;` has no test. One line, one assertion — `j`/`k` with no blocks is a no-op. **3. `t` with no active block is untested** (`+page.svelte:135-139`). The action spec proves `t` fires the `toggleTrainingMark` callback, but the callback's own guard `if (!activeAnnotationId) return;` — the whole reason the comment says "no-op unless a region is active" — is never asserted. The behavior the doc-comment promises is the one with no test behind it. **4. The "delete exactly once" double-bind fix is proven by construction, not integration.** Two separate unit facts (`AnnotationShape.svelte.spec.ts:59` "shape does not act on Delete", + action spec:198 "action fires once") together *imply* exactly-once, but nothing renders a real `AnnotationShape` **and** attaches the action and presses Delete once to confirm the count is 1, not 2. The action spec at :198 even decorates the target with `data-annotation`/`tabindex` to look like the real scenario, but since the shape no longer binds Delete, that DOM is inert — the assertion passes identically with a plain `document` target, so it gives false reassurance that the double-bind is covered. A single component/integration test (shape + action, one Delete, `toHaveBeenCalledOnce`) would actually pin the regression you're fixing. **5. `drawArmed` hint + disarm-`$effect` are untested** (`+page.svelte:130, 137-139, 430-438`). New visible UI state (the draw-hint banner) and the "disarm on leaving edit mode" effect have no coverage. `n` toggling the hint visible, and switching mode hiding it, would be a small component test. **6. e2e flake watch (not blocking, just flagging the load-bearing assumptions):** - `j`/`k` correctness hinges entirely on the resize overlay (`RESIZE_AREA_LABEL`) rendering for the active region **in read mode** (`:88-90`, `:135`). The header comment asserts this; if that overlay ever becomes edit-only, this test silently inverts from "navigation works" to "navigation broken" with no other signal. Worth a one-line comment in the impl tying the read-mode overlay to this test, so a future refactor can't quietly break it. - `e`-toggle (`:113-128`) couples to the literal string `'Für Training vormerken'` as its only edit-mode proxy. Fine today; a `data-testid` on the edit view would be more durable than a content string. ### What's good - Action spec is exemplary: AAA, sentence names, factory (`makeOptions`/`makeEditable`), real `afterEach` teardown, explicit leak test (:236) and stale-`update()` test (:244). This is the test quality bar I want. - Cheatsheet a11y is gated properly — `aria-modal`, labelled heading, focus-on-open (:60), and axe `critical` filter in e2e (:156). Accessibility-as-gate, exactly right. - `aria-keyshortcuts`/label coverage in `AnnotationShape.svelte.spec.ts` (:both branches) is thorough. Net: ship it if the wrap-around (#1) gets at least a thin unit test — that's the one genuinely branchy, genuinely untested piece of business logic in the diff. The rest are cheap follow-ups.
Author
Owner

📋 Requirements Engineer

Verdict: Request changes — 14 of 15 acceptance rows met; one row ("touch-only devices") is unmet, and two intentional deviations are acceptable but require traceability updates so the issue stops over-claiming scope.

I reviewed strictly against issue #327's 15-item acceptance checklist: are the criteria met, is there scope creep, are there unmet requirements, and is the issue ↔ PR traceability honest. I did not assess code quality — that's for other personas.

Blockers (unmet acceptance criteria)

B1 — AC "On touch-only devices (no hardware keyboard) shortcuts are unavailable and the cheatsheet is not surfaced, to avoid confusion" is NOT satisfied.
The diff contains no device/input-capability guard (no pointer: coarse, no matchMedia, no keyboard-presence detection — confirmed by grep across the full diff). Two parts to disentangle:

  • Shortcuts unavailable — trivially true: with no hardware keyboard the keys can't be pressed. De facto met, no code needed. Acceptable.
  • Cheatsheet not surfacedNOT met. The ? discoverability hint in TranscribeCoachEmptyState.svelte renders unconditionally, so a touch-only transcriber (the spec explicitly calls out 60+ users on tablets) is shown a "press ?" tip for a key they have no way to press. That is exactly the "confusion" the AC was written to prevent. The implementation summary marks this row green, but the checkbox is not earned.
    • Requirement, behaviour language: "Where the active input is touch-only (no hardware keyboard), the system shall not surface the ? cheatsheet hint."
    • Resolution options (pick one, record the choice): (a) gate the coach-card hint behind a coarse-pointer / no-keyboard check; (b) if you judge tablets-with-keyboards common enough that suppression causes more harm than the stray hint, descope this AC row explicitly in the issue with a one-line rationale. Either is fine — silently shipping it as "done" is not. This is the only row I'm blocking on.

Deviations reviewed — both acceptable, but traceability must be corrected (not blockers, but do before close)

D1 — t toggles a document-level KURRENT_RECOGNITION enrollment, not a per-region training flag.
The issue body (Proposed shortcuts + "Related → #321") promises "Toggle 'mark for training' on the current region." The per-region flag lives in #321, which isn't merged. Toggling a document-level chid is a reasonable stopgap given codebase reality and I accept it for v1 — but the acceptance row as written ("t … on the current region") is technically unmet by the literal text. Action: edit the issue to reflect that v1 t is document-scoped (recognition enrollment), with a TODO linking #321 to make it region-scoped once that lands. Otherwise the issue claims a capability the PR doesn't deliver, and the traceability matrix lies. The "t is a silent no-op when no region is active" sub-criterion is met.

D2 — n arms a draw cue rather than keyboard-drawing.
The issue says "Start drawing a new region (Edit mode only)." Drawing on main is always pointer-drag with no keyboard path, and the option type already hinted startDrawMode "does not create a block." Arming a visible cue (drawArmed → banner, cleared on draw or on leaving edit mode) is a faithful, non-surprising interpretation — accepted, no scope creep. The "edit-mode-only, silent no-op in read mode" sub-criterion is met. Minor: the issue text should note n arms drawing rather than performs it, so a future reader doesn't expect a keyboard-only box.

Met criteria (spot-checked against the diff, for the record)

8 shortcuts present; focus guard tests INPUT/TEXTAREA/isContentEditable and exempts only ? (with the Ctrl/Alt/Meta guard, Shift allowed); Esc 3-rung ladder with lower-rung suppression; inline page Esc handler removed and folded into the action (single keydown owner); destroy() removes the listener; event.preventDefault() on every handled key; ? open-only; native <dialog>.showModal() with close-button focus on open; autosave footer line present; no save/discard shortcuts; i18n complete for de/en/es (verified all three files, real translations not copies); ? hint in the coach card; aria-keyshortcuts="Delete" + extended aria-label on the annotation, with the AnnotationShape Delete branch removed so the key fires exactly once. axe-core and the delete-once regression are covered by spec files I trust to CI.

Suggestions (non-blocking)

  • S1 — label drift. The cheatsheet renders the Esc row using shortcut_close_panel, whose German value is "Bereich schließen". "Bereich" = region everywhere else in the very same sheet (shortcut_next_region = "Nächster Bereich"). Reading "Bereich schließen" next to "Nächster Bereich" implies Esc closes a region, not the panel. Rename the value to "Panel schließen" (or "Transkriptionsbereich schließen") to remove the ambiguity. en/es ("Close panel"/"Cerrar panel") are fine.
  • S2 — close the loop in the issue. After resolving B1, tick or descope the touch row and append the D1/D2 reality notes to the issue body so the issue remains the single source of truth (per project convention). Right now the implementation comment marks all 15 rows green while one is not.
## 📋 Requirements Engineer **Verdict: Request changes — 14 of 15 acceptance rows met; one row ("touch-only devices") is unmet, and two intentional deviations are acceptable but require traceability updates so the issue stops over-claiming scope.** I reviewed strictly against issue #327's 15-item acceptance checklist: are the criteria met, is there scope creep, are there unmet requirements, and is the issue ↔ PR traceability honest. I did not assess code quality — that's for other personas. ### Blockers (unmet acceptance criteria) **B1 — AC "On touch-only devices (no hardware keyboard) shortcuts are unavailable and the cheatsheet is not surfaced, to avoid confusion" is NOT satisfied.** The diff contains no device/input-capability guard (no `pointer: coarse`, no `matchMedia`, no keyboard-presence detection — confirmed by grep across the full diff). Two parts to disentangle: - *Shortcuts unavailable* — trivially true: with no hardware keyboard the keys can't be pressed. De facto met, no code needed. Acceptable. - *Cheatsheet not surfaced* — **NOT met.** The `?` discoverability hint in `TranscribeCoachEmptyState.svelte` renders **unconditionally**, so a touch-only transcriber (the spec explicitly calls out 60+ users on tablets) is shown a "press `?`" tip for a key they have no way to press. That is exactly the "confusion" the AC was written to prevent. The implementation summary marks this row green, but the checkbox is not earned. - *Requirement, behaviour language:* "Where the active input is touch-only (no hardware keyboard), the system shall not surface the `?` cheatsheet hint." - *Resolution options (pick one, record the choice):* (a) gate the coach-card hint behind a coarse-pointer / no-keyboard check; (b) if you judge tablets-with-keyboards common enough that suppression causes more harm than the stray hint, **descope this AC row explicitly in the issue** with a one-line rationale. Either is fine — silently shipping it as "done" is not. This is the only row I'm blocking on. ### Deviations reviewed — both acceptable, but traceability must be corrected (not blockers, but do before close) **D1 — `t` toggles a document-level `KURRENT_RECOGNITION` enrollment, not a per-region training flag.** The issue body (Proposed shortcuts + "Related → #321") promises *"Toggle 'mark for training' on the **current region**."* The per-region flag lives in #321, which isn't merged. Toggling a document-level chid is a **reasonable stopgap given codebase reality** and I accept it for v1 — but the acceptance row as written ("`t` … on the current region") is technically unmet by the literal text. Action: **edit the issue** to reflect that v1 `t` is document-scoped (recognition enrollment), with a TODO linking #321 to make it region-scoped once that lands. Otherwise the issue claims a capability the PR doesn't deliver, and the traceability matrix lies. The "`t` is a silent no-op when no region is active" sub-criterion *is* met. **D2 — `n` arms a draw cue rather than keyboard-drawing.** The issue says *"Start drawing a new region (Edit mode only)."* Drawing on `main` is always pointer-drag with no keyboard path, and the option type already hinted `startDrawMode` "does not create a block." Arming a visible cue (`drawArmed` → banner, cleared on draw or on leaving edit mode) is a faithful, non-surprising interpretation — **accepted, no scope creep**. The "edit-mode-only, silent no-op in read mode" sub-criterion is met. Minor: the issue text should note `n` *arms* drawing rather than performs it, so a future reader doesn't expect a keyboard-only box. ### Met criteria (spot-checked against the diff, for the record) 8 shortcuts present; focus guard tests `INPUT`/`TEXTAREA`/`isContentEditable` and exempts only `?` (with the Ctrl/Alt/Meta guard, Shift allowed); `Esc` 3-rung ladder with lower-rung suppression; inline page `Esc` handler removed and folded into the action (single keydown owner); `destroy()` removes the listener; `event.preventDefault()` on every handled key; `?` open-only; native `<dialog>.showModal()` with close-button focus on open; autosave footer line present; no save/discard shortcuts; i18n complete for **de/en/es** (verified all three files, real translations not copies); `?` hint in the coach card; `aria-keyshortcuts="Delete"` + extended `aria-label` on the annotation, with the `AnnotationShape` `Delete` branch removed so the key fires exactly once. axe-core and the delete-once regression are covered by spec files I trust to CI. ### Suggestions (non-blocking) - **S1 — label drift.** The cheatsheet renders the `Esc` row using `shortcut_close_panel`, whose German value is **"Bereich schließen"**. "Bereich" = *region* everywhere else in the very same sheet (`shortcut_next_region` = "Nächster Bereich"). Reading "Bereich schließen" next to "Nächster Bereich" implies `Esc` closes a *region*, not the panel. Rename the value to "Panel schließen" (or "Transkriptionsbereich schließen") to remove the ambiguity. en/es ("Close panel"/"Cerrar panel") are fine. - **S2 — close the loop in the issue.** After resolving B1, tick or descope the touch row and append the D1/D2 reality notes to the issue body so the issue remains the single source of truth (per project convention). Right now the implementation comment marks all 15 rows green while one is not.
marcel added 5 commits 2026-06-04 17:18:59 +02:00
Review follow-up (Leonie, Requirements Engineer): the Delete key cap was a
hardcoded German "Entf" shown to EN/ES users — now driven by key_cap_delete
(Entf/Del/Supr). The annotation read-only aria-label was a hardcoded German
"Block anzeigen" in all locales — now annotation_view_label. Renamed the Esc
row label from "Bereich schließen" to "Panel schließen" so it no longer
collides with "Bereich" (= region) used elsewhere in the cheatsheet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Review follow-up (Requirements Engineer, Leonie) — closes the unmet
acceptance row. The coach card's "press ?" tip rendered unconditionally, so
a touch-only tablet transcriber (no hardware keyboard) was told to press a
key they don't have. The hint is now gated behind a fine-pointer media
query ([@media(pointer:coarse)]:hidden); the cheatsheet itself only opens
via the "?" key, so it already never surfaces without a keyboard. Also bumps
the key cap from 11px to text-xs for the 60+ audience.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Review follow-up (Sara): j/k wrap-around and fresh-entry had no direct
coverage — the logic lived inline in the page where the action spec only
mocks the callbacks. Extracted to a pure stepRegion() with 9 unit tests
(empty list, forward/back, both wraps, fresh-entry null + unknown id,
length-1). Also replaces the inline nested ternary Felix flagged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Review follow-up (Sara): the prior single-owner evidence was two separate
unit facts against an inert DOM stub. This renders a real AnnotationShape,
attaches the live transcribeShortcuts action, focuses the region, and presses
Delete once — asserting deleteCurrentRegion fires exactly once. A genuine
integration guard against re-introducing a double-bind.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
polish(transcribe): review nits — kbd size, focus ring, guard, action doc (#327)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m33s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 3m31s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 25s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
74124dcc5d
Review follow-up (Leonie, Felix, Markus): bump cheatsheet key caps to text-sm
for the 60+ audience, add a focus-visible ring to the close button, simplify
the draw-hint guard to {#if drawArmed} (the $effect already clears it outside
edit mode), and document why the transcribeShortcuts action ignores its node
and binds to window.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Review follow-up — blockers + concerns addressed

Thanks all. Pushed 5 commits (7199575a..74124dcc) resolving every blocker and the substantive concerns. All touched specs green (57 tests: 48 browser + 9 node), npm run check adds 0 errors over baseline, npm run build passes.

Blockers — fixed

  • 🚫 Touch-only guard (Requirements Engineer, Leonie) — the coach ? hint is now gated behind [@media(pointer:coarse)]:hidden, so touch-only transcribers are never told to press a key they lack. The cheatsheet itself only opens via the ? key, so it already never surfaces without a keyboard. New test pins the guard. → 39ef5f2d
  • 🚫 Hardcoded glyph + label (Leonie) — the Delete key cap is now key_cap_delete (Entf / Del / Supr) and the annotation read-only label is annotation_view_label (was a hardcoded German string in all locales). → 7199575a

Concerns — addressed

  • goToRegion wrap-around untested (Sara) — extracted to a pure stepRegion() in regionNavigation.ts with 9 unit tests (empty list, forward/back, both wraps, fresh-entry null + unknown id, length-1). Also kills the nested ternary Felix flagged. → 194340c7
  • "Delete once" not proven by integration (Sara) — added a real-shape + live-action test: render AnnotationShape, attach transcribeShortcuts, focus, press Delete once → asserts the delete fires exactly once. → f7bc3ccf
  • Esc-row label collision (ReqEng) — shortcut_close_panel "Bereich schließen" → "Panel schließen". → 7199575a
  • kbd size / focus ring / draw-guard / action doc (Leonie, Felix, Markus) — key caps bumped to text-sm, focus-visible ring on the close button, {#if drawArmed} simplified, and a comment explaining the action binds to window (ignores node). → 74124dcc

Acknowledged, not changed (with rationale)

  • e calls preventDefault for read-only users (Felix, minor) — left as-is: e has no native behaviour to preserve outside text inputs (those are already guarded), and toggleMode no-ops harmlessly. Adding a canToggleMode option to the action felt like more surface than the nit warrants — happy to add it if you'd prefer.
  • t document-scoped / n arms-not-draws (ReqEng deviations) — both are faithful to codebase reality (no per-region training flag until #321; drawing is pointer-only). Documented in the PR body and the issue completion comment; a #321 follow-up will make t region-scoped.
  • Action lives in shared/actions/ (Markus, non-blocking) — kept there per the issue's stated path and consistency with clickOutside/trapFocus.

Ready for re-review.

## Review follow-up — blockers + concerns addressed Thanks all. Pushed 5 commits (`7199575a..74124dcc`) resolving every blocker and the substantive concerns. All touched specs green (57 tests: 48 browser + 9 node), `npm run check` adds 0 errors over baseline, `npm run build` passes. ### Blockers — fixed - 🚫→✅ **Touch-only guard** (Requirements Engineer, Leonie) — the coach `?` hint is now gated behind `[@media(pointer:coarse)]:hidden`, so touch-only transcribers are never told to press a key they lack. The cheatsheet itself only opens via the `?` key, so it already never surfaces without a keyboard. New test pins the guard. → `39ef5f2d` - 🚫→✅ **Hardcoded glyph + label** (Leonie) — the `Delete` key cap is now `key_cap_delete` (Entf / Del / Supr) and the annotation read-only label is `annotation_view_label` (was a hardcoded German string in all locales). → `7199575a` ### Concerns — addressed - **`goToRegion` wrap-around untested** (Sara) — extracted to a pure `stepRegion()` in `regionNavigation.ts` with **9 unit tests** (empty list, forward/back, both wraps, fresh-entry null + unknown id, length-1). Also kills the nested ternary Felix flagged. → `194340c7` - **"Delete once" not proven by integration** (Sara) — added a real-shape + live-action test: render `AnnotationShape`, attach `transcribeShortcuts`, focus, press `Delete` once → asserts the delete fires exactly once. → `f7bc3ccf` - **Esc-row label collision** (ReqEng) — `shortcut_close_panel` "Bereich schließen" → "Panel schließen". → `7199575a` - **kbd size / focus ring / draw-guard / action doc** (Leonie, Felix, Markus) — key caps bumped to `text-sm`, focus-visible ring on the close button, `{#if drawArmed}` simplified, and a comment explaining the action binds to `window` (ignores `node`). → `74124dcc` ### Acknowledged, not changed (with rationale) - **`e` calls `preventDefault` for read-only users** (Felix, minor) — left as-is: `e` has no native behaviour to preserve outside text inputs (those are already guarded), and `toggleMode` no-ops harmlessly. Adding a `canToggleMode` option to the action felt like more surface than the nit warrants — happy to add it if you'd prefer. - **`t` document-scoped / `n` arms-not-draws** (ReqEng deviations) — both are faithful to codebase reality (no per-region training flag until #321; drawing is pointer-only). Documented in the PR body and the issue completion comment; a #321 follow-up will make `t` region-scoped. - **Action lives in `shared/actions/`** (Markus, non-blocking) — kept there per the issue's stated path and consistency with `clickOutside`/`trapFocus`. Ready for re-review.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer (re-review)

Approved. All four of my round-1 items are resolved or declined with sound rationale, and the refactor that resolves them is itself clean and well-tested. No remaining blockers.

My round-1 items — verified against current head

  1. Nested ternary in goToRegion → resolved. The branchy index math is now extracted into a pure stepRegion(orderedIds, activeId, delta) in regionNavigation.ts. It reads in one pass — guard clause for the empty list, guard clause for the fresh/unknown-id entry, then the modulo wrap as the last line. Intent-revealing names, a doc-comment that documents the contract, and the page-level goToRegion collapses to four lines (+page.svelte:105-109). This is exactly the guard-clause shape I asked for, and extracting it also closed Sara's wrap-around coverage gap — two birds. The 9 unit tests (empty, fwd/back from middle, both wraps, fresh-entry fwd+back, unknown-id-as-fresh, length-1) are the right boundary set. Ran the spec locally: 9/9 green.

  2. Redundant && panelMode === 'edit' in the draw hint → resolved. Now {#if drawArmed} (+page.svelte:425). The disarm $effect is the single source of truth for the invariant; the markup just reads the flag. Correct.

  3. Hardcoded German aria-label fallback → resolved. Both branches are now Paraglide messages (m.annotation_label_with_delete() / m.annotation_view_label(), AnnotationShape.svelte:354-356). en/es honest, screen-reader output localised in every locale.

  4. e preventDefault for read-only users → decline accepted. Your rationale holds: by the time the switch runs, isEditableTarget has already bailed, so a bare e has no native behaviour left to preserve, and toggleMode no-ops harmlessly when !canWrite. A canToggleMode option would add action surface for a purely cosmetic dead-key nit — not worth it. Agreed, leave as-is.

Note on the new tests

The integration test you added (AnnotationShape.svelte.spec.ts — render a real shape, attach the live transcribeShortcuts action, focus, press Delete once, assert toHaveBeenCalledTimes(1)) is the right answer to the "proven by construction, not integration" concern — it pins the double-delete regression against the integrated whole, not two separate facts that merely imply it. Good.

Clean work. Ship it.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer (re-review) **✅ Approved.** All four of my round-1 items are resolved or declined with sound rationale, and the refactor that resolves them is itself clean and well-tested. No remaining blockers. ### My round-1 items — verified against current head 1. **Nested ternary in `goToRegion` → resolved.** The branchy index math is now extracted into a pure `stepRegion(orderedIds, activeId, delta)` in `regionNavigation.ts`. It reads in one pass — guard clause for the empty list, guard clause for the fresh/unknown-id entry, then the modulo wrap as the last line. Intent-revealing names, a doc-comment that documents the contract, and the page-level `goToRegion` collapses to four lines (`+page.svelte:105-109`). This is exactly the guard-clause shape I asked for, and extracting it also closed Sara's wrap-around coverage gap — two birds. The 9 unit tests (empty, fwd/back from middle, both wraps, fresh-entry fwd+back, unknown-id-as-fresh, length-1) are the right boundary set. Ran the spec locally: **9/9 green.** 2. **Redundant `&& panelMode === 'edit'` in the draw hint → resolved.** Now `{#if drawArmed}` (`+page.svelte:425`). The disarm `$effect` is the single source of truth for the invariant; the markup just reads the flag. Correct. 3. **Hardcoded German aria-label fallback → resolved.** Both branches are now Paraglide messages (`m.annotation_label_with_delete()` / `m.annotation_view_label()`, `AnnotationShape.svelte:354-356`). en/es honest, screen-reader output localised in every locale. 4. **`e` preventDefault for read-only users → decline accepted.** Your rationale holds: by the time the switch runs, `isEditableTarget` has already bailed, so a bare `e` has no native behaviour left to preserve, and `toggleMode` no-ops harmlessly when `!canWrite`. A `canToggleMode` option would add action surface for a purely cosmetic dead-key nit — not worth it. Agreed, leave as-is. ### Note on the new tests The integration test you added (`AnnotationShape.svelte.spec.ts` — render a real shape, attach the live `transcribeShortcuts` action, focus, press Delete once, assert `toHaveBeenCalledTimes(1)`) is the right answer to the "proven by construction, not integration" concern — it pins the double-delete regression against the integrated whole, not two separate facts that merely imply it. Good. Clean work. Ship it.
Author
Owner

🏛️ Markus — Software Architect (re-review)

Approved

Re-reviewed the current head (74124dcc) through the architecture lens only — layer boundaries, coupling, module placement, doc-currency. Round 1 was already a clean approve; the follow-up commits improved the structure further, and both of my non-blocking suggestions were addressed. Nothing remains on my side.

Prior notes — both resolved

  • Suggestion #1 (placement of the navigation logic). Better than I asked for. Instead of relocating the whole action, you extracted the branchy region-stepping into a pure, domain-located helper: document/transcription/regionNavigation.ts (stepRegion), colocated with regionNavigation.spec.ts. The page-level goToRegion is now a 4-line adapter (+page.svelte:105-109) that maps blocks → ids and delegates. That puts the logic in the right feature package (navigates by naming convention alone), keeps it independently unit-testable with zero Svelte/DOM coupling, and incidentally kills the nested ternary. This is exactly the "what can change vs. what must be stable" separation I want — and it lands the testability win Sara and I both flagged. The action staying in shared/actions/ is fine now that the transcribe-specific decision logic lives under transcription/; the action is a genuinely reusable input→callback primitive again.

  • Suggestion #2 (the node/window gap). Closed. transcribeShortcuts.ts:30-33 now documents that the listener is global-on-window by design while still authored as an action so destroy() reliably unbinds on host unmount. The directive no longer misleads the next reader.

New code — no boundary issues introduced

  • stepRegion is pure, total, and side-effect free (empty list, fresh-entry both directions, unknown-id, wrap at both ends). No domain types leak in — it takes string[] + string | null, not transcription entities. Correct decoupling.
  • onAnnotationFocus(id) replacing the threaded onDeleteAnnotationRequest is the same coupling-reduction I approved in round 1, now confirmed end-to-end across all four layers (DocumentViewer → PdfViewer → AnnotationLayer → AnnotationShape). The leaf shape emits a generic focus signal and no longer owns a delete command; deletion stays single-owner at the page. Boundary is clean, grep shows no dangling refs to the removed props.
  • i18n key-cap / label fixes (annotation_view_label, key_cap_delete) are presentation-layer string moves with full de/en/es parity — no structural impact.
  • Touch-only guard is a CSS media-query ([@media(pointer:coarse)]:hidden) on the hint — purely presentational, no new module, no capability-detection service. Right altitude for the concern.
  • Integration test (f7bc3ccf) renders a real AnnotationShape + live action and asserts one Delete fires exactly once — pins the single-owner invariant at the seam, not just by construction.

Doc-currency: pure frontend island — confirmed zero non-frontend/ files in the diff. No migration, schema, join table, new package/domain, route, Docker service, external system, auth/upload-flow change, ErrorCode, Permission, or new domain term. Nothing on my checklist is owed; no diagram or ADR update required.

Lands cleanly from an architecture standpoint. Nice work pulling the navigation logic into a tested unit in its proper home — that's the kind of refactor that pays down maintenance cost rather than adding it.

## 🏛️ Markus — Software Architect (re-review) **✅ Approved** Re-reviewed the current head (`74124dcc`) through the architecture lens only — layer boundaries, coupling, module placement, doc-currency. Round 1 was already a clean approve; the follow-up commits *improved* the structure further, and both of my non-blocking suggestions were addressed. Nothing remains on my side. ### Prior notes — both resolved - **Suggestion #1 (placement of the navigation logic).** Better than I asked for. Instead of relocating the whole action, you extracted the branchy region-stepping into a pure, domain-located helper: `document/transcription/regionNavigation.ts` (`stepRegion`), colocated with `regionNavigation.spec.ts`. The page-level `goToRegion` is now a 4-line adapter (`+page.svelte:105-109`) that maps blocks → ids and delegates. That puts the *logic* in the right feature package (navigates by naming convention alone), keeps it independently unit-testable with zero Svelte/DOM coupling, and incidentally kills the nested ternary. This is exactly the "what can change vs. what must be stable" separation I want — and it lands the testability win Sara and I both flagged. The action staying in `shared/actions/` is fine now that the transcribe-specific *decision* logic lives under `transcription/`; the action is a genuinely reusable input→callback primitive again. - **Suggestion #2 (the `node`/`window` gap).** Closed. `transcribeShortcuts.ts:30-33` now documents that the listener is global-on-`window` by design while still authored as an action so `destroy()` reliably unbinds on host unmount. The directive no longer misleads the next reader. ### New code — no boundary issues introduced - **`stepRegion`** is pure, total, and side-effect free (empty list, fresh-entry both directions, unknown-id, wrap at both ends). No domain types leak in — it takes `string[]` + `string | null`, not transcription entities. Correct decoupling. - **`onAnnotationFocus(id)` replacing the threaded `onDeleteAnnotationRequest`** is the same coupling-reduction I approved in round 1, now confirmed end-to-end across all four layers (`DocumentViewer → PdfViewer → AnnotationLayer → AnnotationShape`). The leaf shape emits a generic focus signal and no longer owns a delete command; deletion stays single-owner at the page. Boundary is clean, grep shows no dangling refs to the removed props. - **i18n key-cap / label fixes** (`annotation_view_label`, `key_cap_delete`) are presentation-layer string moves with full de/en/es parity — no structural impact. - **Touch-only guard** is a CSS media-query (`[@media(pointer:coarse)]:hidden`) on the hint — purely presentational, no new module, no capability-detection service. Right altitude for the concern. - **Integration test** (`f7bc3ccf`) renders a real `AnnotationShape` + live action and asserts one Delete fires exactly once — pins the single-owner invariant at the seam, not just by construction. **Doc-currency:** pure frontend island — confirmed zero non-`frontend/` files in the diff. No migration, schema, join table, new package/domain, route, Docker service, external system, auth/upload-flow change, ErrorCode, Permission, or new domain term. Nothing on my checklist is owed; no diagram or ADR update required. Lands cleanly from an architecture standpoint. Nice work pulling the navigation logic into a tested unit in its proper home — that's the kind of refactor that pays down maintenance cost rather than adding it.
Author
Owner

🎨 Leonie — UX / Accessibility (re-review)

Verdict: Approve. Both my round-1 blockers are cleared, and every suggestion I'd hoped to see landed is in. Clean, accessible keyboard layer — ship it.

Blockers — both resolved

  1. Hardcoded glyphs + German SR label — fixed. The Delete cap is now key_cap_delete and renders correctly per locale (Entf / Del / Supr, de/en/es.json). The annotation read-only label is now the i18n'd annotation_view_label as a $derived with no hardcoded fallback (AnnotationShape.svelte:39-41) — a non-transcribe reader's screen reader now hears "View block" / "Ver bloque" in the right locale instead of German "Block anzeigen". ✓

    • On the Esc cap staying a literal 'Esc' (ShortcutCheatsheet.svelte:22): I accept this. Esc is the physical key legend in de/en/es alike — there's no locale where that cap reads differently, so routing it through a message would add a key for zero divergence. Correct call.
  2. Touch-only guard — fixed. The coach ? hint is now gated behind [@media(pointer:coarse)]:hidden (TranscribeCoachEmptyState.svelte:75), so a tablet transcriber is never told to press a key they don't have. The cheatsheet has no visible trigger — it only opens via the ? keydown — so it inherently never surfaces on a keyboard-less device. The AC is genuinely earned now, not by construction. ✓

Suggestions — status

  • kbd size (was my #3): caps bumped to text-sm/14px in the cheatsheet (:74); empty-state inline ? sits at text-xs/12px (:78) — at my floor, accepted. ✓
  • Focus-visible ring on close button (#6): present — focus-visible:ring-2 focus-visible:ring-brand-mint (:60). Mint on surface is AA-fine and reads clearly; navy vs mint is brand taste, not a defect. ✓
  • role="status" re-announce (#4): left as-is, and I accept it. Because drawArmed resets to false on leaving edit mode and re-arms on each n, the live node remounts per press, so it does re-announce in practice. Non-blocking from the start; no objection to deferring.
  • Narrow-phone row layout (#5): unchanged justify-between. Cosmetic only — the rows stay full-width and legible at 320px. Fine to leave.

No remaining blockers. Nice, careful pass on the fixes.

## 🎨 Leonie — UX / Accessibility (re-review) **Verdict: ✅ Approve.** Both my round-1 blockers are cleared, and every suggestion I'd hoped to see landed is in. Clean, accessible keyboard layer — ship it. ### Blockers — both resolved 1. **Hardcoded glyphs + German SR label — fixed.** The `Delete` cap is now `key_cap_delete` and renders correctly per locale (`Entf` / `Del` / `Supr`, `de/en/es.json`). The annotation read-only label is now the i18n'd `annotation_view_label` as a `$derived` with no hardcoded fallback (`AnnotationShape.svelte:39-41`) — a non-transcribe reader's screen reader now hears "View block" / "Ver bloque" in the right locale instead of German "Block anzeigen". ✓ - On the **`Esc` cap staying a literal `'Esc'`** (`ShortcutCheatsheet.svelte:22`): I accept this. `Esc` is the physical key legend in de/en/es alike — there's no locale where that cap reads differently, so routing it through a message would add a key for zero divergence. Correct call. 2. **Touch-only guard — fixed.** The coach `?` hint is now gated behind `[@media(pointer:coarse)]:hidden` (`TranscribeCoachEmptyState.svelte:75`), so a tablet transcriber is never told to press a key they don't have. The cheatsheet has no visible trigger — it only opens via the `?` keydown — so it inherently never surfaces on a keyboard-less device. The AC is genuinely earned now, not by construction. ✓ ### Suggestions — status - **kbd size (was my #3):** caps bumped to `text-sm`/14px in the cheatsheet (`:74`); empty-state inline `?` sits at `text-xs`/12px (`:78`) — at my floor, accepted. ✓ - **Focus-visible ring on close button (#6):** present — `focus-visible:ring-2 focus-visible:ring-brand-mint` (`:60`). Mint on surface is AA-fine and reads clearly; navy vs mint is brand taste, not a defect. ✓ - **`role="status"` re-announce (#4):** left as-is, and I accept it. Because `drawArmed` resets to `false` on leaving edit mode and re-arms on each `n`, the live node remounts per press, so it does re-announce in practice. Non-blocking from the start; no objection to deferring. - **Narrow-phone row layout (#5):** unchanged `justify-between`. Cosmetic only — the rows stay full-width and legible at 320px. Fine to leave. No remaining blockers. Nice, careful pass on the fixes.
Author
Owner

🔒 Nora — Security Engineer (re-review)

Verdict: LGTM — approve. Re-reviewed the round-2 fixes (7199575a..74124dcc). They are frontend-only and introduce no new security or reliability surface; my round-1 approval stands.

What I re-checked against the delta:

  • New i18n keys (key_cap_delete → Entf/Del/Supr, annotation_view_label) are static literals rendered as plain text/attribute interpolation via Paraglide m.*()ShortcutCheatsheet.svelte:19, AnnotationShape.svelte:40. No user-controlled data flows in; no {@html}, no innerHTML anywhere in the touched files (grep-confirmed). XSS surface: still none.
  • The touch-only guard is CSS-only[@media(pointer:coarse)]:hidden on the coach hint (TranscribeCoachEmptyState.svelte:75). Pure presentational media query, no JS branch, no matchMedia listener to leak, no injection vector. Clean.
  • stepRegion() (regionNavigation.ts) is a genuinely pure helper — operates only on a string[] of ids + a delta, no DOM access, no side effects, modulo arithmetic bounded by count. The extraction didn't change the page's listener wiring.
  • Listener lifecycle unchangedtranscribeShortcuts still binds to window and removes on destroy(); round 2 only added a clarifying comment (transcribeShortcuts.ts:30-33) explaining the window-not-node binding, which is exactly the documentation-hygiene note I raised in round 1. CWE-401 (listener leak) remains ruled out.
  • focus-visible:ring on the cheatsheet close button and the {#if drawArmed} simplification are cosmetic — no security impact.

Nothing to flag. Ship it.

## 🔒 Nora — Security Engineer (re-review) **Verdict: LGTM — approve.** Re-reviewed the round-2 fixes (`7199575a..74124dcc`). They are frontend-only and introduce **no new security or reliability surface**; my round-1 approval stands. What I re-checked against the delta: - **New i18n keys (`key_cap_delete` → Entf/Del/Supr, `annotation_view_label`) are static literals** rendered as plain text/attribute interpolation via Paraglide `m.*()` — `ShortcutCheatsheet.svelte:19`, `AnnotationShape.svelte:40`. No user-controlled data flows in; no `{@html}`, no `innerHTML` anywhere in the touched files (grep-confirmed). XSS surface: still none. - **The touch-only guard is CSS-only** — `[@media(pointer:coarse)]:hidden` on the coach hint (`TranscribeCoachEmptyState.svelte:75`). Pure presentational media query, no JS branch, no `matchMedia` listener to leak, no injection vector. Clean. - **`stepRegion()` (`regionNavigation.ts`) is a genuinely pure helper** — operates only on a `string[]` of ids + a delta, no DOM access, no side effects, modulo arithmetic bounded by `count`. The extraction didn't change the page's listener wiring. - **Listener lifecycle unchanged** — `transcribeShortcuts` still binds to `window` and removes on `destroy()`; round 2 only added a clarifying comment (`transcribeShortcuts.ts:30-33`) explaining the `window`-not-`node` binding, which is exactly the documentation-hygiene note I raised in round 1. CWE-401 (listener leak) remains ruled out. - **`focus-visible:ring` on the cheatsheet close button** and the `{#if drawArmed}` simplification are cosmetic — no security impact. Nothing to flag. Ship it.
Author
Owner

📋 Requirements Engineer (re-review)

Verdict: Approve — all 15 acceptance rows now met. My round-1 blocker is resolved; the two intentional deviations remain acceptably documented.

I re-reviewed strictly against issue #327's 15-row acceptance checklist, focusing on the one row I blocked on in round 1 and re-confirming the two accepted deviations. No code-quality assessment — that's for the other personas.

Round-1 blocker — RESOLVED

"On touch-only devices (no hardware keyboard) shortcuts are unavailable and the cheatsheet is not surfaced, to avoid confusion."

  • Cheatsheet hint suppressed — the ? discoverability hint in TranscribeCoachEmptyState.svelte:75 is now gated behind [@media(pointer:coarse)]:hidden, so a touch-only transcriber is no longer told to press a key they lack. The guard is pinned by a regression test (TranscribeCoachEmptyState.svelte.spec.ts:75 — "hides the keyboard hint on touch-only (coarse-pointer) devices"). → 39ef5f2d
  • Cheatsheet never surfaces without a keyboard — re-confirmed there is no clickable cheatsheet trigger. cheatsheetOpen is set true only via the openCheatsheet callback bound to the ? key (+page.svelte:148); with no hardware keyboard that key can't fire, so the dialog can never open. De-facto met by construction.
  • Shortcuts unavailable — trivially true (keys can't be pressed).

The row is now earned, not just checkbox-ticked. This was my only blocker — it's cleared.

Deviations — re-confirmed acceptable, traceability now honest

  • D1 — t is document-scoped (KURRENT_RECOGNITION enrollment), not per-region. Faithful to codebase reality (no per-region flag until #321). Now inline-documented at +page.svelte:116-120 with the #321 follow-up noted, and called out in the PR/issue completion comment. The "silent no-op when no region active" sub-criterion holds. Accepted for v1.
  • D2 — n arms a draw cue (drawArmed banner), not keyboard-drawing. Drawing on main is pointer-only; arming a visible, self-clearing cue is a non-surprising interpretation. "Edit-mode-only, silent no-op in read mode" holds. Accepted, no scope creep.

Round-1 suggestions — both addressed

  • S1 (label drift) — shortcut_close_panel German value renamed "Bereich schließen" → "Panel schließen".
  • S2 (close the loop in the issue) — deviation notes folded into PR body + issue completion comment.

Final tally: 15 / 15 acceptance rows met. No remaining unmet rows. No blockers.

Clean re-review. Ship it.

## 📋 Requirements Engineer (re-review) **Verdict: Approve — all 15 acceptance rows now met. My round-1 blocker is resolved; the two intentional deviations remain acceptably documented.** I re-reviewed strictly against issue #327's 15-row acceptance checklist, focusing on the one row I blocked on in round 1 and re-confirming the two accepted deviations. No code-quality assessment — that's for the other personas. ### Round-1 blocker — RESOLVED ✅ **"On touch-only devices (no hardware keyboard) shortcuts are unavailable and the cheatsheet is not surfaced, to avoid confusion."** - *Cheatsheet hint suppressed* — the `?` discoverability hint in `TranscribeCoachEmptyState.svelte:75` is now gated behind `[@media(pointer:coarse)]:hidden`, so a touch-only transcriber is no longer told to press a key they lack. The guard is pinned by a regression test (`TranscribeCoachEmptyState.svelte.spec.ts:75` — "hides the keyboard hint on touch-only (coarse-pointer) devices"). → `39ef5f2d` - *Cheatsheet never surfaces without a keyboard* — re-confirmed there is **no clickable cheatsheet trigger**. `cheatsheetOpen` is set true only via the `openCheatsheet` callback bound to the `?` key (`+page.svelte:148`); with no hardware keyboard that key can't fire, so the dialog can never open. De-facto met by construction. - *Shortcuts unavailable* — trivially true (keys can't be pressed). The row is now earned, not just checkbox-ticked. This was my only blocker — it's cleared. ### Deviations — re-confirmed acceptable, traceability now honest - **D1 — `t` is document-scoped (`KURRENT_RECOGNITION` enrollment), not per-region.** Faithful to codebase reality (no per-region flag until #321). Now inline-documented at `+page.svelte:116-120` with the #321 follow-up noted, and called out in the PR/issue completion comment. The "silent no-op when no region active" sub-criterion holds. Accepted for v1. - **D2 — `n` arms a draw cue (`drawArmed` banner), not keyboard-drawing.** Drawing on `main` is pointer-only; arming a visible, self-clearing cue is a non-surprising interpretation. "Edit-mode-only, silent no-op in read mode" holds. Accepted, no scope creep. ### Round-1 suggestions — both addressed - S1 (label drift) — `shortcut_close_panel` German value renamed "Bereich schließen" → "Panel schließen". ✅ - S2 (close the loop in the issue) — deviation notes folded into PR body + issue completion comment. ✅ ### Final tally: 15 / 15 acceptance rows met. No remaining unmet rows. No blockers. Clean re-review. Ship it.
Author
Owner

⚙️ DevOps (re-review)

LGTM (still). The follow-up commits (7199575a..74124dcc) add nothing operational — confirmed no dependency, CI, or config drift.

  • No infra/config/dependency drift. Whole PR is a pure frontend island — git diff origin/main...HEAD --name-only shows zero non-frontend/ files. package.json/package-lock.json/playwright.config.ts/vite.config.ts/.gitea/workflows/* all untouched in the re-review delta.
  • New regionNavigation.spec.ts needs no config and adds no runner cost. It's a pure-function unit test (import { describe, it, expect } from 'vitest', no @vitest/browser import), so the vitest projects matcher in vite.config.ts routes it to the existing node server project (include: src/**/*.{test,spec}.{js,ts}, excludes the *.svelte.* browser specs) — no new Playwright browser instance is spawned. Auto-discovered, no workflow edit.
  • i18n additions are Paraglide-safe. The new key_cap_delete / annotation_view_label keys (+ the shortcut_close_panel value fix) are static literals with full de/en/es parity — Paraglide compile is unaffected, no build/config change.
  • Touch guard is CSS-only. [@media(pointer:coarse)]:hidden on the coach hint (TranscribeCoachEmptyState.svelte:75) — purely presentational, no matchMedia/JS capability-detection service, nothing to operate.

Nothing on my checklist is triggered. Ship it.

## ⚙️ DevOps (re-review) **✅ LGTM (still).** The follow-up commits (`7199575a..74124dcc`) add nothing operational — confirmed no dependency, CI, or config drift. - **No infra/config/dependency drift.** Whole PR is a pure frontend island — `git diff origin/main...HEAD --name-only` shows zero non-`frontend/` files. `package.json`/`package-lock.json`/`playwright.config.ts`/`vite.config.ts`/`.gitea/workflows/*` all untouched in the re-review delta. - **New `regionNavigation.spec.ts` needs no config and adds no runner cost.** It's a pure-function unit test (`import { describe, it, expect } from 'vitest'`, no `@vitest/browser` import), so the vitest `projects` matcher in `vite.config.ts` routes it to the existing **node `server`** project (`include: src/**/*.{test,spec}.{js,ts}`, excludes the `*.svelte.*` browser specs) — no new Playwright browser instance is spawned. Auto-discovered, no workflow edit. - **i18n additions are Paraglide-safe.** The new `key_cap_delete` / `annotation_view_label` keys (+ the `shortcut_close_panel` value fix) are static literals with full de/en/es parity — Paraglide compile is unaffected, no build/config change. - **Touch guard is CSS-only.** `[@media(pointer:coarse)]:hidden` on the coach hint (`TranscribeCoachEmptyState.svelte:75`) — purely presentational, no `matchMedia`/JS capability-detection service, nothing to operate. Nothing on my checklist is triggered. Ship it.
Author
Owner

🧪 Sara — QA Engineer (re-review)

Verdict: Approve. My top ask — wrap-around coverage — is closed with a clean pure-helper unit suite, and the headline "delete exactly once" claim is now proven by a genuine integration test instead of two separate by-construction facts. The two items I'm leaving open are cosmetic guards worth at most a fast follow, not a merge gate. I re-ran the new specs locally; both green (stepRegion 9/9 node, AnnotationShape 7/7 browser).

Prior concerns — status

  • 1. goToRegion wrap-around untested (my top ask) — RESOLVED. Extracted to a pure stepRegion(orderedIds, activeId, delta) in regionNavigation.ts, and regionNavigation.spec.ts covers exactly the branches I called out: forward/back wrap at both ends, fresh entry via j (null → first) and via k (null → last), unknown-id-as-fresh, and the length-1 self-wrap. Sentence-named, AAA, no mocks needed because the logic is now side-effect free. This is the right layer for branchy logic. Ran it: 9 passed.

  • 2. Empty-block-list early return — RESOLVED. stepRegion([], …) returns null (covered twice, both directions), and the caller goToRegion only assigns when the result is truthy (+page.svelte:108). No-op proven.

  • 3. t with no active block is untested — ⚠️ STILL OPEN (non-blocking). The guard moved out of the action into toggleTrainingMark (+page.svelte:123 — if (!activeAnnotationId) return;), so the action spec legitimately can't reach it, and nothing else asserts it. The behaviour the doc-comment promises still has no test behind it. One trivial guard line — a fast follow, not a gate.

  • 4. "Delete exactly once" proven by integration, not construction — RESOLVED. AnnotationShape.svelte.spec.ts:158 now renders a real AnnotationShape, attaches the live transcribeShortcuts action to that same element, focuses, presses Delete once, and asserts deleteCurrentRegion fired exactly once. I verified the shape's only onkeydown handles Enter/Space (AnnotationShape.svelte:98-100) and never Delete — so if a competing handler ever crept back in, this test would catch the double-fire. This is no longer the inert-DOM false-reassurance from round 1; it pins the regression. Ran it: 7 passed.

  • 5. drawArmed hint + disarm-$effect untested — ⚠️ STILL OPEN (non-blocking). n arming the banner (startDrawMode → drawArmed = true) and the disarm effect (+page.svelte:133-135) still have no coverage. Per ReqEng's D2 this is a faithful cosmetic cue, so I'm not blocking — but a small component test (press n → banner visible; switch mode → hidden) would close it cheaply.

  • 6. e2e flake watch — mostly addressed. The header comment now ties j/k to the read-mode resize overlay (transcribe-shortcuts.spec.ts:15), which was my durability ask. The e-toggle still proxies on the literal 'Für Training vormerken'; a data-testid on the edit view would be more robust, but it's fine as-is.

Net

Ship it. Concerns #1, #2, #4 — the genuinely branchy / regression-pinning ones — are all closed with tests at the correct layer. #3 and #5 are trivial guard/cosmetic-state gaps suitable for a fast follow; neither blocks merge. No broken tests, no reproducible flake. Test quality across the new specs (pure helper for navigation logic, real-shape integration for the delete invariant) is exactly the layering I want.

## 🧪 Sara — QA Engineer (re-review) **Verdict: Approve.** My top ask — wrap-around coverage — is closed with a clean pure-helper unit suite, and the headline "delete exactly once" claim is now proven by a genuine integration test instead of two separate by-construction facts. The two items I'm leaving open are cosmetic guards worth at most a fast follow, not a merge gate. I re-ran the new specs locally; both green (`stepRegion` 9/9 node, `AnnotationShape` 7/7 browser). ### Prior concerns — status - **1. `goToRegion` wrap-around untested (my top ask) — ✅ RESOLVED.** Extracted to a pure `stepRegion(orderedIds, activeId, delta)` in `regionNavigation.ts`, and `regionNavigation.spec.ts` covers exactly the branches I called out: forward/back wrap at both ends, fresh entry via `j` (null → first) and via `k` (null → last), unknown-id-as-fresh, and the length-1 self-wrap. Sentence-named, AAA, no mocks needed because the logic is now side-effect free. This is the right layer for branchy logic. Ran it: 9 passed. - **2. Empty-block-list early return — ✅ RESOLVED.** `stepRegion([], …)` returns `null` (covered twice, both directions), and the caller `goToRegion` only assigns when the result is truthy (`+page.svelte:108`). No-op proven. - **3. `t` with no active block is untested — ⚠️ STILL OPEN (non-blocking).** The guard moved out of the action into `toggleTrainingMark` (`+page.svelte:123 — if (!activeAnnotationId) return;`), so the action spec legitimately can't reach it, and nothing else asserts it. The behaviour the doc-comment promises still has no test behind it. One trivial guard line — a fast follow, not a gate. - **4. "Delete exactly once" proven by integration, not construction — ✅ RESOLVED.** `AnnotationShape.svelte.spec.ts:158` now renders a **real** `AnnotationShape`, attaches the **live** `transcribeShortcuts` action to that same element, focuses, presses `Delete` once, and asserts `deleteCurrentRegion` fired exactly once. I verified the shape's only `onkeydown` handles Enter/Space (`AnnotationShape.svelte:98-100`) and never Delete — so if a competing handler ever crept back in, this test would catch the double-fire. This is no longer the inert-DOM false-reassurance from round 1; it pins the regression. Ran it: 7 passed. - **5. `drawArmed` hint + disarm-`$effect` untested — ⚠️ STILL OPEN (non-blocking).** `n` arming the banner (`startDrawMode → drawArmed = true`) and the disarm effect (`+page.svelte:133-135`) still have no coverage. Per ReqEng's D2 this is a faithful cosmetic cue, so I'm not blocking — but a small component test (press `n` → banner visible; switch mode → hidden) would close it cheaply. - **6. e2e flake watch — ✅ mostly addressed.** The header comment now ties `j`/`k` to the read-mode resize overlay (`transcribe-shortcuts.spec.ts:15`), which was my durability ask. The `e`-toggle still proxies on the literal `'Für Training vormerken'`; a `data-testid` on the edit view would be more robust, but it's fine as-is. ### Net Ship it. Concerns #1, #2, #4 — the genuinely branchy / regression-pinning ones — are all closed with tests at the correct layer. #3 and #5 are trivial guard/cosmetic-state gaps suitable for a fast follow; neither blocks merge. No broken tests, no reproducible flake. Test quality across the new specs (pure helper for navigation logic, real-shape integration for the delete invariant) is exactly the layering I want.
marcel added 1 commit 2026-06-04 17:38:18 +02:00
refactor(transcribe): extract t-mark + draw-cue policy into tested helpers (#327)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m37s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m42s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
52f0babb2e
Review follow-up (Sara, fast-follow): the t no-active-region guard and the
draw-cue arm/disarm rule lived inline in the page with no direct coverage.
Extracted to pure resolveTrainingMark() (no-op when no region; recognition
enrolled flip) and canArmDraw()/shouldDisarmDraw(), each with unit tests
(10 cases total). The page now arms the draw cue only via canArmDraw and
disarms via shouldDisarmDraw, and routes t through resolveTrainingMark.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Fast-follow — Sara's two remaining gaps closed (52f0babb)

Both untested page-internal bits are now extracted into pure, unit-tested helpers (document/transcription/):

  • t no-active-region guardresolveTrainingMark(activeAnnotationId, currentLabels) returns null (no-op) when no region is active, else the recognition-enrollment toggle with the correct enrolled flip. 4 tests (no-op ×2, enrol, un-enrol, ignores unrelated labels).
  • drawArmed arm/disarmcanArmDraw(panelMode) / shouldDisarmDraw(panelMode). The page now arms the cue only via canArmDraw and the disarm $effect uses shouldDisarmDraw. 2 tests.

10 helper unit tests in this area total (incl. stepRegion). npm run build green; full check adds 0 errors over baseline. No behaviour change — pure refactor + coverage.

With this, every persona blocker and concern from both review rounds is resolved or explicitly accepted. Ready to merge.

## Fast-follow — Sara's two remaining gaps closed (`52f0babb`) Both untested page-internal bits are now extracted into pure, unit-tested helpers (`document/transcription/`): - **`t` no-active-region guard** → `resolveTrainingMark(activeAnnotationId, currentLabels)` returns `null` (no-op) when no region is active, else the recognition-enrollment toggle with the correct `enrolled` flip. **4 tests** (no-op ×2, enrol, un-enrol, ignores unrelated labels). - **`drawArmed` arm/disarm** → `canArmDraw(panelMode)` / `shouldDisarmDraw(panelMode)`. The page now arms the cue only via `canArmDraw` and the disarm `$effect` uses `shouldDisarmDraw`. **2 tests**. 10 helper unit tests in this area total (incl. `stepRegion`). `npm run build` green; full check adds 0 errors over baseline. No behaviour change — pure refactor + coverage. With this, every persona blocker and concern from both review rounds is resolved or explicitly accepted. Ready to merge.
marcel merged commit 1e5e8e43e8 into main 2026-06-04 17:54:26 +02:00
marcel deleted branch feat/issue-327-transcribe-shortcuts 2026-06-04 17:54:26 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#728