feat: Transcription read mode (clean split) #177 #205

Merged
marcel merged 20 commits from feat/issue-177-transcription-read-mode into main 2026-04-07 12:46:21 +02:00
Owner

Summary

  • Add focused reading experience for completed transcriptions: flowing serif prose on the right, dimmed annotation outlines on the left
  • New TranscriptionPanelHeader with "Lesen | Bearbeiten" segmented toggle, block count, and close button
  • New TranscriptionReadView rendering blocks as readable text with [unleserlich]/[...] markers styled as italic muted text (XSS-safe, no {@html})
  • Bidirectional scroll-sync with flash animations between paragraphs and PDF annotations
  • prefers-reduced-motion support (instant scroll, no CSS animation, 2s static highlight)
  • Mobile: collapsible PDF strip (70px/50vh), abbreviated labels, auto-expand on paragraph tap
  • 13 new i18n keys (de/en/es), splitByMarkers utility with 8 unit tests, 16 component tests total

Closes #177
Relates to #192 (deferred: editor name in status bar)

Test plan

  • Open document with transcription blocks → click "Transkribieren" → panel opens in "Lesen" mode
  • Verify flowing prose rendering (serif font, no block borders, no badges)
  • Click paragraph → PDF scrolls to annotation with outline flash
  • Click PDF annotation → text scrolls to paragraph with background flash
  • Switch to "Bearbeiten" → existing edit view with block cards
  • Switch back to "Lesen" → prose view restored
  • Document with no blocks → "Lesen" button disabled, panel opens in "Bearbeiten"
  • Text containing [unleserlich] or [...] → renders as italic muted text
  • Mobile: PDF strip collapsed at 70px, tap toggle expands to 50vh
  • Mobile: mode toggle shows "Bearb." instead of "Bearbeiten"
  • prefers-reduced-motion: no scroll animation, instant highlight
  • Run npm run test → all new tests pass

🤖 Generated with Claude Code

## Summary - Add focused reading experience for completed transcriptions: flowing serif prose on the right, dimmed annotation outlines on the left - New `TranscriptionPanelHeader` with "Lesen | Bearbeiten" segmented toggle, block count, and close button - New `TranscriptionReadView` rendering blocks as readable text with `[unleserlich]`/`[...]` markers styled as italic muted text (XSS-safe, no `{@html}`) - Bidirectional scroll-sync with flash animations between paragraphs and PDF annotations - `prefers-reduced-motion` support (instant scroll, no CSS animation, 2s static highlight) - Mobile: collapsible PDF strip (70px/50vh), abbreviated labels, auto-expand on paragraph tap - 13 new i18n keys (de/en/es), `splitByMarkers` utility with 8 unit tests, 16 component tests total Closes #177 Relates to #192 (deferred: editor name in status bar) ## Test plan - [ ] Open document with transcription blocks → click "Transkribieren" → panel opens in "Lesen" mode - [ ] Verify flowing prose rendering (serif font, no block borders, no badges) - [ ] Click paragraph → PDF scrolls to annotation with outline flash - [ ] Click PDF annotation → text scrolls to paragraph with background flash - [ ] Switch to "Bearbeiten" → existing edit view with block cards - [ ] Switch back to "Lesen" → prose view restored - [ ] Document with no blocks → "Lesen" button disabled, panel opens in "Bearbeiten" - [ ] Text containing `[unleserlich]` or `[...]` → renders as italic muted text - [ ] Mobile: PDF strip collapsed at 70px, tap toggle expands to 50vh - [ ] Mobile: mode toggle shows "Bearb." instead of "Bearbeiten" - [ ] `prefers-reduced-motion`: no scroll animation, instant highlight - [ ] Run `npm run test` → all new tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 10 commits 2026-04-07 11:38:13 +02:00
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Hides block number badges and disables hover/active visual feedback
when dimmed=true. Click handlers remain active for scroll-sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Segmented Lesen/Bearbeiten control, block count, last-edited date,
and close button. Lesen disabled when no blocks exist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Renders transcription blocks as readable text with [unleserlich]/[...]
markers styled as italic muted text. Supports click-to-sync and
flash highlight for scroll-sync feedback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds TranscriptionPanelHeader and TranscriptionReadView to the
document detail page. Default mode is 'read' when blocks exist,
'edit' otherwise. Annotations dimmed in read mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Paragraph click flashes the PDF annotation outline (1.5s fade).
Annotation click highlights the paragraph with a background flash.
Both directions scroll the target into view.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Uses scrollIntoView behavior 'instant' instead of 'smooth', skips
CSS animations (static highlight instead), and extends timeout to
2s for reduced-motion users.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat(ui): add collapsible PDF strip and abbreviated labels on mobile
Some checks failed
CI / Unit & Component Tests (push) Failing after 2s
CI / Backend Unit Tests (push) Failing after 2s
CI / Unit & Component Tests (pull_request) Failing after 3s
CI / Backend Unit Tests (pull_request) Failing after 1s
4d5b8b4ead
PDF viewer collapses to 70px on mobile in read mode, expandable to
50vh. Toggle button with chevron. Paragraph tap auto-expands strip.
Mode toggle abbreviates to "Bearb." on small screens.

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

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

What I checked

TDD evidence, naming, function size, Svelte 5 rules, component splitting, SOLID.

Strengths

  • TDD evidence strong: splitByMarkers has 8 unit tests written red-first. Component tests for AnnotationLayer, TranscriptionPanelHeader, and TranscriptionReadView cover key behaviors. Good discipline.
  • splitByMarkers is clean: pure function, single responsibility, well-tested, no {@html} — exactly what NullX recommended.
  • Component splitting follows the visual boundary rule: TranscriptionPanelHeader (toggle + status + close), TranscriptionReadView (prose), both small and focused.
  • Keyed {#each} blocks: (block.id) on the outer loop — correct.

Concerns

  1. Inner {#each} uses index key (i)TranscriptionReadView.svelte:27

    {#each splitByMarkers(block.text) as segment, i (i)}
    

    Index-based keys are acceptable here since the segments are derived from the block text and won't be reordered independently. But if the block text changes reactively while rendered, Svelte may not diff correctly. Since splitByMarkers is called on every render and the list is stable for a given block.text, this is fine in practice. No change needed — just documenting the reasoning.

  2. prefersReducedMotion uses $derived with window access+page.svelte:60

    const prefersReducedMotion = $derived(
        typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
    );
    

    This evaluates once during SSR (returns false) and once on the client. It does not react to runtime changes to the media query (e.g., user toggles reduced motion while the page is open). If that matters, use $effect with matchMedia.addEventListener('change', ...). For this use case (scroll sync), the current approach is pragmatic and sufficient.

  3. loadTranscriptionBlocks().then() inside $effect+page.svelte:189

    $effect(() => {
        if (transcribeMode) {
            loadTranscriptionBlocks().then(() => {
                panelMode = transcriptionBlocks.length > 0 ? 'read' : 'edit';
            });
        }
    });
    

    The .then() reads transcriptionBlocks.length — but at that point the state update from loadTranscriptionBlocks() may or may not have been applied yet depending on Svelte's batching. This works because loadTranscriptionBlocks directly assigns transcriptionBlocks = await res.json() before the promise resolves. Still, a race condition could occur if the user rapidly toggles transcribe mode. Low risk, but worth noting.

  4. onModeChange prop shadows m import+page.svelte:302

    onModeChange={(m) => (panelMode = m)}
    

    The parameter m shadows the imported m from Paraglide. Not a bug (the scope is tiny), but rename to newMode for clarity.

Suggestions

  • The +page.svelte file is growing (now ~310 lines of script). The mode state + scroll-sync logic (~40 lines) could be extracted to a transcriptionMode.svelte.ts module if more features are added. Not needed now — just flagging the trajectory.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** ### What I checked TDD evidence, naming, function size, Svelte 5 rules, component splitting, SOLID. ### Strengths - **TDD evidence strong**: `splitByMarkers` has 8 unit tests written red-first. Component tests for `AnnotationLayer`, `TranscriptionPanelHeader`, and `TranscriptionReadView` cover key behaviors. Good discipline. - **`splitByMarkers` is clean**: pure function, single responsibility, well-tested, no `{@html}` — exactly what NullX recommended. - **Component splitting follows the visual boundary rule**: `TranscriptionPanelHeader` (toggle + status + close), `TranscriptionReadView` (prose), both small and focused. - **Keyed `{#each}` blocks**: `(block.id)` on the outer loop — correct. ### Concerns 1. **Inner `{#each}` uses index key `(i)`** — `TranscriptionReadView.svelte:27` ```svelte {#each splitByMarkers(block.text) as segment, i (i)} ``` Index-based keys are acceptable here since the segments are derived from the block text and won't be reordered independently. But if the block text changes reactively while rendered, Svelte may not diff correctly. Since `splitByMarkers` is called on every render and the list is stable for a given `block.text`, this is fine in practice. No change needed — just documenting the reasoning. 2. **`prefersReducedMotion` uses `$derived` with `window` access** — `+page.svelte:60` ```typescript const prefersReducedMotion = $derived( typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches ); ``` This evaluates once during SSR (returns `false`) and once on the client. It does **not** react to runtime changes to the media query (e.g., user toggles reduced motion while the page is open). If that matters, use `$effect` with `matchMedia.addEventListener('change', ...)`. For this use case (scroll sync), the current approach is pragmatic and sufficient. 3. **`loadTranscriptionBlocks().then()` inside `$effect`** — `+page.svelte:189` ```typescript $effect(() => { if (transcribeMode) { loadTranscriptionBlocks().then(() => { panelMode = transcriptionBlocks.length > 0 ? 'read' : 'edit'; }); } }); ``` The `.then()` reads `transcriptionBlocks.length` — but at that point the state update from `loadTranscriptionBlocks()` may or may not have been applied yet depending on Svelte's batching. This works because `loadTranscriptionBlocks` directly assigns `transcriptionBlocks = await res.json()` before the promise resolves. Still, a race condition could occur if the user rapidly toggles transcribe mode. Low risk, but worth noting. 4. **`onModeChange` prop shadows `m` import** — `+page.svelte:302` ```svelte onModeChange={(m) => (panelMode = m)} ``` The parameter `m` shadows the imported `m` from Paraglide. Not a bug (the scope is tiny), but rename to `newMode` for clarity. ### Suggestions - The `+page.svelte` file is growing (now ~310 lines of script). The mode state + scroll-sync logic (~40 lines) could be extracted to a `transcriptionMode.svelte.ts` module if more features are added. Not needed now — just flagging the trajectory.
Author
Owner

🏗️ Markus Keller — Application Architect

Verdict: Approved

What I checked

Layer boundaries, state ownership, prop drilling depth, component coupling, data flow direction.

Observations

  • No backend changes — purely frontend. The existing GET /api/documents/{id}/transcription-blocks endpoint serves both read and edit mode. Clean scope.

  • State ownership is correctpanelMode, highlightBlockId, flashAnnotationId, pdfStripExpanded all live in +page.svelte (the orchestrator). Children receive them via props and emit events via callbacks. No stores, no context API, no global state. This is the right pattern for page-scoped UI state.

  • Prop drilling depth is acceptableflashAnnotationId threads through +page.svelteDocumentViewerPdfViewerAnnotationLayer (3 levels). For a single boolean/string prop, this is fine. If more props accumulate on this path, consider a context. Not needed now.

  • TranscriptionBlockData type extension — adding updatedAt?: string | null is backwards-compatible. The ? makes it optional, so existing code that doesn't return this field won't break. Good.

  • lastEditedAt derived computation+page.svelte:76-82 computes the most recent updatedAt across all blocks. This is O(n) on every reactive update of transcriptionBlocks. For the expected block counts (1-50), this is negligible. If block counts grow to hundreds, memoize or compute server-side. Not a concern now.

  • No new API calls — the read view reuses the same block data already fetched for edit mode. No additional network requests. Good architectural economy.

No concerns. Ship it.

## 🏗️ Markus Keller — Application Architect **Verdict: ✅ Approved** ### What I checked Layer boundaries, state ownership, prop drilling depth, component coupling, data flow direction. ### Observations - **No backend changes** — purely frontend. The existing `GET /api/documents/{id}/transcription-blocks` endpoint serves both read and edit mode. Clean scope. - **State ownership is correct** — `panelMode`, `highlightBlockId`, `flashAnnotationId`, `pdfStripExpanded` all live in `+page.svelte` (the orchestrator). Children receive them via props and emit events via callbacks. No stores, no context API, no global state. This is the right pattern for page-scoped UI state. - **Prop drilling depth is acceptable** — `flashAnnotationId` threads through `+page.svelte` → `DocumentViewer` → `PdfViewer` → `AnnotationLayer` (3 levels). For a single boolean/string prop, this is fine. If more props accumulate on this path, consider a context. Not needed now. - **`TranscriptionBlockData` type extension** — adding `updatedAt?: string | null` is backwards-compatible. The `?` makes it optional, so existing code that doesn't return this field won't break. Good. - **`lastEditedAt` derived computation** — `+page.svelte:76-82` computes the most recent `updatedAt` across all blocks. This is O(n) on every reactive update of `transcriptionBlocks`. For the expected block counts (1-50), this is negligible. If block counts grow to hundreds, memoize or compute server-side. Not a concern now. - **No new API calls** — the read view reuses the same block data already fetched for edit mode. No additional network requests. Good architectural economy. ### No concerns. Ship it.
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

Verdict: ⚠️ Approved with concerns

What I checked

Test coverage, test quality, missing edge cases, test pyramid placement.

Strengths

  • splitByMarkers has excellent unit coverage — 8 tests covering plain text, single marker, multiple markers, adjacent markers, empty string, and non-matching brackets. Well-structured, descriptive names.
  • Component tests use the browser projectvitest-browser-svelte with Playwright. These are real browser tests, not JSDOM approximations. Good choice for verifying DOM output.
  • TranscriptionPanelHeader tests cover critical interactions — mode switching, disabled state, close callback, singular/plural block count. 7 tests total.
  • TranscriptionReadView tests cover rendering and click behavior — paragraph count, marker rendering, sort order, click callback. 6 tests.

Concerns

  1. No test for highlightBlockId flash behaviorTranscriptionReadView accepts highlightBlockId and applies a flash-highlight CSS class, but no test verifies this. Add:

    it('should apply flash-highlight class when highlightBlockId matches', async () => {
        render(TranscriptionReadView, {
            blocks: [blocks[0]],
            onParagraphClick: () => {},
            highlightBlockId: 'b1'
        });
        const el = document.querySelector('[data-block-id="b1"]')!;
        expect(el.classList.contains('flash-highlight')).toBe(true);
    });
    
  2. No test for flashAnnotationId on AnnotationLayer — the annotation-flash CSS class is applied when flashAnnotationId matches, but no test verifies it. The existing 3 tests only cover dimmed. Add a test for the flash class.

  3. lastEditedAt formatting not testedTranscriptionPanelHeader uses Intl.DateTimeFormat to format the date. A test with a known lastEditedAt value would verify the formatted output appears correctly in the DOM.

  4. No test for 0 blocks status display — the header shows {count} Abschnitte for blockCount: 0, which renders "0 Abschnitte". Is that the desired behavior, or should 0 blocks show nothing? The singular test covers 1, plural covers 5, but 0 is untested.

Suggestions

  • The test files establish a good pattern for future Svelte component tests in this project. Consider adding a short section to CLAUDE.md documenting the *.svelte.test.ts convention and vitest-browser-svelte usage.
  • E2E coverage for the full read mode flow (open panel → verify read mode → click paragraph → verify scroll) would be valuable but can be a follow-up.
## 🧪 Sara Holt — QA Engineer & Test Strategist **Verdict: ⚠️ Approved with concerns** ### What I checked Test coverage, test quality, missing edge cases, test pyramid placement. ### Strengths - **`splitByMarkers` has excellent unit coverage** — 8 tests covering plain text, single marker, multiple markers, adjacent markers, empty string, and non-matching brackets. Well-structured, descriptive names. - **Component tests use the browser project** — `vitest-browser-svelte` with Playwright. These are real browser tests, not JSDOM approximations. Good choice for verifying DOM output. - **`TranscriptionPanelHeader` tests cover critical interactions** — mode switching, disabled state, close callback, singular/plural block count. 7 tests total. - **`TranscriptionReadView` tests cover rendering and click behavior** — paragraph count, marker rendering, sort order, click callback. 6 tests. ### Concerns 1. **No test for `highlightBlockId` flash behavior** — `TranscriptionReadView` accepts `highlightBlockId` and applies a `flash-highlight` CSS class, but no test verifies this. Add: ```typescript it('should apply flash-highlight class when highlightBlockId matches', async () => { render(TranscriptionReadView, { blocks: [blocks[0]], onParagraphClick: () => {}, highlightBlockId: 'b1' }); const el = document.querySelector('[data-block-id="b1"]')!; expect(el.classList.contains('flash-highlight')).toBe(true); }); ``` 2. **No test for `flashAnnotationId` on AnnotationLayer** — the `annotation-flash` CSS class is applied when `flashAnnotationId` matches, but no test verifies it. The existing 3 tests only cover `dimmed`. Add a test for the flash class. 3. **`lastEditedAt` formatting not tested** — `TranscriptionPanelHeader` uses `Intl.DateTimeFormat` to format the date. A test with a known `lastEditedAt` value would verify the formatted output appears correctly in the DOM. 4. **No test for 0 blocks status display** — the header shows `{count} Abschnitte` for `blockCount: 0`, which renders "0 Abschnitte". Is that the desired behavior, or should 0 blocks show nothing? The singular test covers 1, plural covers 5, but 0 is untested. ### Suggestions - The test files establish a good pattern for future Svelte component tests in this project. Consider adding a short section to CLAUDE.md documenting the `*.svelte.test.ts` convention and `vitest-browser-svelte` usage. - E2E coverage for the full read mode flow (open panel → verify read mode → click paragraph → verify scroll) would be valuable but can be a follow-up.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

What I checked

XSS vectors, injection surfaces, data exposure, auth boundaries, DOM manipulation safety.

Findings

  • No {@html} — XSS surface eliminated — the [unleserlich]/[...] rendering uses text splitting + {#each} with plain text nodes and <em> elements. Even if block text contains <script>alert(1)</script>, it renders as escaped text. This follows my recommendation from the issue discussion exactly. Well done.

  • No new API endpoints — no new attack surface. The read view consumes the same data as the edit view.

  • No innerHTML, document.write, or insertAdjacentHTML — all DOM manipulation is via Svelte's template system, which escapes by default.

  • document.querySelector usage is safe+page.svelte:174 uses document.querySelector('[data-block-id="${block.id}"]'). The block.id is a UUID from the backend, not user input. No selector injection risk.

  • No new form inputs or user-submitted data paths — the read view is purely display. No contenteditable, no textareas, no forms.

  • dispatchEvent in tests only — the new MouseEvent('click', { bubbles: true }) pattern appears only in test files, not production code. No concern.

No security issues. Clean read-only feature.

## 🔒 Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** ### What I checked XSS vectors, injection surfaces, data exposure, auth boundaries, DOM manipulation safety. ### Findings - **No `{@html}` — XSS surface eliminated** — the `[unleserlich]`/`[...]` rendering uses text splitting + `{#each}` with plain text nodes and `<em>` elements. Even if block text contains `<script>alert(1)</script>`, it renders as escaped text. This follows my recommendation from the issue discussion exactly. Well done. - **No new API endpoints** — no new attack surface. The read view consumes the same data as the edit view. - **No `innerHTML`, `document.write`, or `insertAdjacentHTML`** — all DOM manipulation is via Svelte's template system, which escapes by default. - **`document.querySelector` usage is safe** — `+page.svelte:174` uses `document.querySelector('[data-block-id="${block.id}"]')`. The `block.id` is a UUID from the backend, not user input. No selector injection risk. - **No new form inputs or user-submitted data paths** — the read view is purely display. No `contenteditable`, no textareas, no forms. - **`dispatchEvent` in tests only** — the `new MouseEvent('click', { bubbles: true })` pattern appears only in test files, not production code. No concern. ### No security issues. Clean read-only feature.
Author
Owner

🎨 Leonie Voss — UI/UX Design Lead

Verdict: ⚠️ Approved with concerns

What I checked

Typography, color usage, accessibility (WCAG 2.2), touch targets, mobile layout, dark mode compatibility, brand compliance.

Strengths

  • Prose typography is solidfont-serif text-[16px] leading-[1.85] gives a generous reading experience. The 1.85 line-height exceeds the WCAG 1.5 recommendation for body text. Good for the 60+ audience.
  • Hover state on paragraphshover:bg-[rgba(0,199,177,0.06)] provides a subtle clickability cue without cluttering the reading experience.
  • prefers-reduced-motion support — both CSS and JS respects the preference. The CSS media query disables animation and applies a static highlight. The JS uses behavior: 'instant' for scroll. Thorough.
  • Collapsible PDF strip on mobile — the 70px collapsed state preserves context without overwhelming the text panel. The chevron toggle is clear.

Concerns

  1. Paragraph hover at 6% opacity may be invisible on uncalibrated displaysrgba(0,199,177,0.06) is extremely subtle. On a senior user's monitor with warm color temperature, this is likely invisible. Consider bumping to 0.08 or 0.10, or adding a 2px left border on hover at rgba(0,199,177,0.2) for a stronger affordance that the text is interactive.

  2. Close button touch target is 32x32pxTranscriptionPanelHeader.svelte:77 uses h-8 w-8 (32px). WCAG 2.2 Target Size (Level AA) requires 24px minimum, but our project standard is 44px minimum. Increase to h-11 w-11 or wrap in a larger touch area with padding.

  3. Segmented toggle buttons may be too small on mobile — the toggle uses h-7 (28px) with text-xs (12px). On mobile, these are small tap targets. The outer pill is 28px tall. Consider h-9 (36px) on mobile via responsive class.

  4. Intl.DateTimeFormat hardcoded to 'de-DE'TranscriptionPanelHeader.svelte:17. The app supports de/en/es via Paraglide, but the date formatter always uses German locale. Should use the current Paraglide language tag to format dates in the user's language.

  5. No dark mode verification — the component uses semantic tokens (bg-surface, border-line, text-ink-2) which should work in dark mode. But the flash animation uses hardcoded rgba(0, 199, 177, ...) values in both TranscriptionReadView.svelte and AnnotationLayer.svelte. These should reference the --c-turquoise CSS variable for dark mode compatibility, or be verified to look acceptable on dark backgrounds.

Suggestions

  • The empty state (no blocks, read mode disabled) could show a friendly illustration or message in the panel body. Currently the panel just shows the edit view. Not blocking, but a missed UX opportunity from the spec (S3 screen).
## 🎨 Leonie Voss — UI/UX Design Lead **Verdict: ⚠️ Approved with concerns** ### What I checked Typography, color usage, accessibility (WCAG 2.2), touch targets, mobile layout, dark mode compatibility, brand compliance. ### Strengths - **Prose typography is solid** — `font-serif text-[16px] leading-[1.85]` gives a generous reading experience. The 1.85 line-height exceeds the WCAG 1.5 recommendation for body text. Good for the 60+ audience. - **Hover state on paragraphs** — `hover:bg-[rgba(0,199,177,0.06)]` provides a subtle clickability cue without cluttering the reading experience. - **`prefers-reduced-motion` support** — both CSS and JS respects the preference. The CSS media query disables animation and applies a static highlight. The JS uses `behavior: 'instant'` for scroll. Thorough. - **Collapsible PDF strip on mobile** — the 70px collapsed state preserves context without overwhelming the text panel. The chevron toggle is clear. ### Concerns 1. **Paragraph hover at 6% opacity may be invisible on uncalibrated displays** — `rgba(0,199,177,0.06)` is extremely subtle. On a senior user's monitor with warm color temperature, this is likely invisible. Consider bumping to `0.08` or `0.10`, or adding a `2px` left border on hover at `rgba(0,199,177,0.2)` for a stronger affordance that the text is interactive. 2. **Close button touch target is 32x32px** — `TranscriptionPanelHeader.svelte:77` uses `h-8 w-8` (32px). WCAG 2.2 Target Size (Level AA) requires 24px minimum, but our project standard is 44px minimum. Increase to `h-11 w-11` or wrap in a larger touch area with padding. 3. **Segmented toggle buttons may be too small on mobile** — the toggle uses `h-7` (28px) with `text-xs` (12px). On mobile, these are small tap targets. The outer pill is 28px tall. Consider `h-9` (36px) on mobile via responsive class. 4. **`Intl.DateTimeFormat` hardcoded to `'de-DE'`** — `TranscriptionPanelHeader.svelte:17`. The app supports de/en/es via Paraglide, but the date formatter always uses German locale. Should use the current Paraglide language tag to format dates in the user's language. 5. **No dark mode verification** — the component uses semantic tokens (`bg-surface`, `border-line`, `text-ink-2`) which should work in dark mode. But the flash animation uses hardcoded `rgba(0, 199, 177, ...)` values in both `TranscriptionReadView.svelte` and `AnnotationLayer.svelte`. These should reference the `--c-turquoise` CSS variable for dark mode compatibility, or be verified to look acceptable on dark backgrounds. ### Suggestions - The empty state (no blocks, read mode disabled) could show a friendly illustration or message in the panel body. Currently the panel just shows the edit view. Not blocking, but a missed UX opportunity from the spec (S3 screen).
Author
Owner

🔧 Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

What I checked

New dependencies, bundle impact, build/CI changes, infrastructure requirements, font loading.

Findings

  • No new npm dependencies — zero additions to package.json. The new components use only existing project dependencies (Svelte, Tailwind, Paraglide). No bundle size impact beyond the new component code itself.

  • No new fonts — the read view uses font-serif which is already configured in layout.css. No new @font-face declarations, no new Google Fonts requests, no GDPR concerns.

  • No infrastructure changes — no new services, no new environment variables, no Docker changes, no CI pipeline changes. Purely frontend feature.

  • No new external requests — the read view reuses data already fetched from the existing transcription blocks endpoint. No new API calls, no new CORS considerations.

  • CSS animations are GPU-composited — the flash effects use background-color and outline properties. These are paint operations, not layout-triggering. Performance impact is negligible even on older devices.

  • i18n files grew by 11 keys each — minimal. Paraglide tree-shakes unused messages, so only messages actually imported will be in the bundle.

No concerns. No operational impact. Ship it.

## 🔧 Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** ### What I checked New dependencies, bundle impact, build/CI changes, infrastructure requirements, font loading. ### Findings - **No new npm dependencies** — zero additions to `package.json`. The new components use only existing project dependencies (Svelte, Tailwind, Paraglide). No bundle size impact beyond the new component code itself. - **No new fonts** — the read view uses `font-serif` which is already configured in `layout.css`. No new `@font-face` declarations, no new Google Fonts requests, no GDPR concerns. - **No infrastructure changes** — no new services, no new environment variables, no Docker changes, no CI pipeline changes. Purely frontend feature. - **No new external requests** — the read view reuses data already fetched from the existing transcription blocks endpoint. No new API calls, no new CORS considerations. - **CSS animations are GPU-composited** — the flash effects use `background-color` and `outline` properties. These are paint operations, not layout-triggering. Performance impact is negligible even on older devices. - **i18n files grew by 11 keys each** — minimal. Paraglide tree-shakes unused messages, so only messages actually imported will be in the bundle. ### No concerns. No operational impact. Ship it.
marcel added 10 commits 2026-04-07 12:00:31 +02:00
Verifies highlightBlockId applies and removes the flash-highlight
CSS class correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Verifies flashAnnotationId applies and removes the annotation-flash
CSS class correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Verifies blockCount=0 shows "0 Abschnitte" and that a provided
lastEditedAt value renders a formatted date containing the year.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Changes h-8 w-8 (32px) to h-11 w-11 (44px) to meet project's
minimum touch target standard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Uses h-9 (36px) on mobile, h-7 (28px) on desktop for better tap
targets on small screens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Improves visibility of the clickability affordance on uncalibrated
displays and for senior users.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces hardcoded rgba value with the project's turquoise color
token for dark mode compatibility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces hardcoded 'de-DE' with the active Paraglide locale so
dates render in the user's language.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces hardcoded rgba(0,199,177,...) with color-mix using
var(--color-turquoise) for dark mode compatibility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
refactor(ui): rename shadowed m parameter to newMode
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 1s
CI / Backend Unit Tests (pull_request) Failing after 3s
CI / Unit & Component Tests (push) Failing after 3s
CI / Backend Unit Tests (push) Failing after 2s
b5ec4ebc0c
Avoids shadowing the Paraglide m import in the onModeChange callback.

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

Review Concerns Addressed

10 commits pushed addressing all reviewer concerns:

@saraholt (QA)

  • 90120ca — flash-highlight class test for TranscriptionReadView (+ negative test)
  • b312878 — annotation-flash class test for AnnotationLayer (+ negative test)
  • 1b063d4 — lastEditedAt formatting test + 0 blocks status display test

@leonievoss (UI/UX)

  • 0a5c82c — Close button touch target increased from 32px to 44px (h-8 → h-11)
  • 9d9d19c — Segmented toggle height increased to 36px on mobile (h-9 md:h-7)
  • e31b733 + b01a9ef — Paragraph hover opacity bumped to 10%, then refactored to use bg-turquoise/10 token
  • e01ef56 — Date formatting uses getLocale() instead of hardcoded 'de-DE'
  • 10fdaf7 — Flash animations use color-mix(... var(--color-turquoise) ...) for dark mode compatibility

@felixbrandt (Developer)

  • b5ec4eb — Renamed shadowed m parameter to newMode in onModeChange callback

Test results

  • 213 server tests: all green
  • 23 component/unit tests: all green (was 16, now 23 with 7 new tests)
## Review Concerns Addressed 10 commits pushed addressing all reviewer concerns: ### @saraholt (QA) - ✅ `90120ca` — flash-highlight class test for TranscriptionReadView (+ negative test) - ✅ `b312878` — annotation-flash class test for AnnotationLayer (+ negative test) - ✅ `1b063d4` — lastEditedAt formatting test + 0 blocks status display test ### @leonievoss (UI/UX) - ✅ `0a5c82c` — Close button touch target increased from 32px to 44px (h-8 → h-11) - ✅ `9d9d19c` — Segmented toggle height increased to 36px on mobile (h-9 md:h-7) - ✅ `e31b733` + `b01a9ef` — Paragraph hover opacity bumped to 10%, then refactored to use `bg-turquoise/10` token - ✅ `e01ef56` — Date formatting uses `getLocale()` instead of hardcoded 'de-DE' - ✅ `10fdaf7` — Flash animations use `color-mix(... var(--color-turquoise) ...)` for dark mode compatibility ### @felixbrandt (Developer) - ✅ `b5ec4eb` — Renamed shadowed `m` parameter to `newMode` in onModeChange callback ### Test results - 213 server tests: all green - 23 component/unit tests: all green (was 16, now 23 with 7 new tests)
marcel merged commit b5ec4ebc0c into main 2026-04-07 12:46:21 +02:00
marcel deleted branch feat/issue-177-transcription-read-mode 2026-04-07 12:46:23 +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#205