feat: Transcription read mode (clean split) #177

Closed
opened 2026-04-05 09:30:00 +02:00 by marcel · 8 comments
Owner

Summary

Implement a focused reading experience for completed transcriptions using the clean split layout: PDF scan on the left with dimmed annotation outlines, flowing serif prose on the right. All editing chrome is stripped — no block borders, no comment threads, no toolbars. The text reads like a letter, not like an editing interface.

Read mode is the default view when a document has transcription blocks. Users switch between reading and editing via a "Lesen | Bearbeiten" segmented control in the topbar.

Motivation

Most visits to a completed transcription are for reading — comparing handwriting with typed text, sharing with family, or revisiting a letter. The editing UI (block cards, comment threads, toolbars) adds visual clutter that distracts from the reading experience. The clean split preserves the familiar side-by-side layout while removing all editing noise.

Spec

📄 docs/specs/transcription-read-mode-final-spec.html — open locally in browser for full mockups

Key screens

Screen Description
S1 Desktop read mode — flowing prose, dimmed annotations
S2 Scroll sync highlight — click paragraph → PDF annotation flashes
S3 Empty state — no transcription blocks yet, "Lesen" tab disabled
S4 Mobile read mode — collapsible PDF strip + flowing text
S5 Mode switcher states — all 4 states (Lesen, Bearbeiten, Annotieren, disabled)

Core concepts

Flowing prose rendering

  • Each transcription block renders as a <p> inside an <article> — no block borders, no numbered badges
  • Typography: Tinos serif, 16px, line-height 1.85 (generous reading font)
  • [unleserlich] markers render as italic muted text
  • Text is not contenteditable — no cursor, no editing

Dimmed annotations

  • Turquoise annotation rects on PDF rendered at 30% opacity, no numbered badges
  • Still clickable for scroll-sync
  • Yellow annotations not shown in read mode

Bidirectional scroll sync

  • Click paragraph → matching PDF annotation flashes turquoise (1.5s CSS animation fade)
  • Click PDF annotation → matching paragraph gets subtle background highlight (1.5s fade)
  • Both directions use scrollIntoView({ behavior: 'smooth', block: 'center' })

Mode switcher

  • Segmented control in topbar: "Lesen | Bearbeiten" (replaces "Transkribieren" button)
  • "Annotieren" remains a separate button
  • Default: "Lesen" if blocks exist, "Bearbeiten" if no blocks
  • "Lesen" disabled (opacity 0.35, aria-disabled) when no transcription blocks exist
  • When Annotieren is active, both segmented items appear dimmed
  • State: let mode: 'read' | 'transcribe' | 'annotate' = $state(...)

Mobile layout

  • Split becomes vertical: collapsible PDF strip (70px collapsed, ~50vh expanded) at top, text below
  • Tap strip to expand/collapse (CSS transition 300ms)
  • Mode switcher abbreviates: "Lesen | Bearb."
  • Paragraph tap: auto-expand PDF if collapsed, then scroll-sync

Component architecture

Component Purpose
TranscriptionReadView.svelte New — right panel in read mode, renders prose + scroll-sync handlers
ModeSwitcher.svelte New — segmented control (Lesen/Bearbeiten), props: mode (bindable), hasBlocks
DocumentTopBar.svelte Modified — replace Transkribieren button with ModeSwitcher
[id]/+page.svelte Modified — add mode state, conditionally render ReadView vs EditView
PdfAnnotationLayer.svelte Modified — accept dimmed prop for read mode

i18n keys

Key de en
mode_read Lesen Read
mode_edit Bearbeiten Edit
mode_edit_short Bearb. Edit
transcription_status_sections {n} Abschnitte {n} sections
transcription_status_last_edited Zuletzt bearbeitet: {name}, {time} Last edited: {name}, {time}
transcription_empty_title Noch keine Transkription No transcription yet
transcription_empty_desc Zeichne Bereiche auf dem Scan... Draw regions on the scan...
scan_expand Scan vergrößern Expand scan
scan_collapse Scan verkleinern Collapse scan

Acceptance criteria

  • Read mode is the default when transcription blocks exist
  • Transcription text renders as flowing serif prose (Tinos, 16px, line-height 1.85)
  • No block borders, no numbered badges, no contenteditable, no comment threads
  • [unleserlich] markers render as italic muted text
  • PDF annotations dimmed (opacity 0.3) but clickable for scroll-sync
  • Click paragraph → PDF scrolls to annotation with 1.5s turquoise flash
  • Click PDF annotation → text scrolls to paragraph with 1.5s highlight fade
  • Mode switcher "Lesen | Bearbeiten" in topbar
  • "Lesen" tab disabled when no blocks exist
  • Annotieren button deselects both segmented items when active
  • Status bar shows block count + last edited info
  • Mobile: vertical stack with collapsible PDF strip (70px collapsed)
  • Mobile: mode switcher abbreviates to "Lesen | Bearb."
  • Scroll sync respects prefers-reduced-motion
  • All i18n keys added for de/en/es
## Summary Implement a **focused reading experience** for completed transcriptions using the clean split layout: PDF scan on the left with dimmed annotation outlines, flowing serif prose on the right. All editing chrome is stripped — no block borders, no comment threads, no toolbars. The text reads like a letter, not like an editing interface. Read mode is the **default view** when a document has transcription blocks. Users switch between reading and editing via a "Lesen | Bearbeiten" segmented control in the topbar. ## Motivation Most visits to a completed transcription are for reading — comparing handwriting with typed text, sharing with family, or revisiting a letter. The editing UI (block cards, comment threads, toolbars) adds visual clutter that distracts from the reading experience. The clean split preserves the familiar side-by-side layout while removing all editing noise. ## Spec 📄 **[`docs/specs/transcription-read-mode-final-spec.html`](../blob/main/docs/specs/transcription-read-mode-final-spec.html)** — open locally in browser for full mockups ### Key screens | Screen | Description | |--------|-------------| | S1 | Desktop read mode — flowing prose, dimmed annotations | | S2 | Scroll sync highlight — click paragraph → PDF annotation flashes | | S3 | Empty state — no transcription blocks yet, "Lesen" tab disabled | | S4 | Mobile read mode — collapsible PDF strip + flowing text | | S5 | Mode switcher states — all 4 states (Lesen, Bearbeiten, Annotieren, disabled) | ### Core concepts #### Flowing prose rendering - Each transcription block renders as a `<p>` inside an `<article>` — no block borders, no numbered badges - Typography: Tinos serif, 16px, line-height 1.85 (generous reading font) - `[unleserlich]` markers render as italic muted text - Text is **not** contenteditable — no cursor, no editing #### Dimmed annotations - Turquoise annotation rects on PDF rendered at 30% opacity, no numbered badges - Still clickable for scroll-sync - Yellow annotations not shown in read mode #### Bidirectional scroll sync - Click paragraph → matching PDF annotation flashes turquoise (1.5s CSS animation fade) - Click PDF annotation → matching paragraph gets subtle background highlight (1.5s fade) - Both directions use `scrollIntoView({ behavior: 'smooth', block: 'center' })` #### Mode switcher - Segmented control in topbar: "Lesen | Bearbeiten" (replaces "Transkribieren" button) - "Annotieren" remains a separate button - Default: "Lesen" if blocks exist, "Bearbeiten" if no blocks - "Lesen" disabled (opacity 0.35, aria-disabled) when no transcription blocks exist - When Annotieren is active, both segmented items appear dimmed - State: `let mode: 'read' | 'transcribe' | 'annotate' = $state(...)` #### Mobile layout - Split becomes vertical: collapsible PDF strip (70px collapsed, ~50vh expanded) at top, text below - Tap strip to expand/collapse (CSS transition 300ms) - Mode switcher abbreviates: "Lesen | Bearb." - Paragraph tap: auto-expand PDF if collapsed, then scroll-sync ### Component architecture | Component | Purpose | |-----------|---------| | `TranscriptionReadView.svelte` | **New** — right panel in read mode, renders prose + scroll-sync handlers | | `ModeSwitcher.svelte` | **New** — segmented control (Lesen/Bearbeiten), props: mode (bindable), hasBlocks | | `DocumentTopBar.svelte` | Modified — replace Transkribieren button with ModeSwitcher | | `[id]/+page.svelte` | Modified — add mode state, conditionally render ReadView vs EditView | | `PdfAnnotationLayer.svelte` | Modified — accept `dimmed` prop for read mode | ### i18n keys | Key | de | en | |-----|----|----| | `mode_read` | Lesen | Read | | `mode_edit` | Bearbeiten | Edit | | `mode_edit_short` | Bearb. | Edit | | `transcription_status_sections` | {n} Abschnitte | {n} sections | | `transcription_status_last_edited` | Zuletzt bearbeitet: {name}, {time} | Last edited: {name}, {time} | | `transcription_empty_title` | Noch keine Transkription | No transcription yet | | `transcription_empty_desc` | Zeichne Bereiche auf dem Scan... | Draw regions on the scan... | | `scan_expand` | Scan vergrößern | Expand scan | | `scan_collapse` | Scan verkleinern | Collapse scan | ## Acceptance criteria - [ ] Read mode is the default when transcription blocks exist - [ ] Transcription text renders as flowing serif prose (Tinos, 16px, line-height 1.85) - [ ] No block borders, no numbered badges, no contenteditable, no comment threads - [ ] `[unleserlich]` markers render as italic muted text - [ ] PDF annotations dimmed (opacity 0.3) but clickable for scroll-sync - [ ] Click paragraph → PDF scrolls to annotation with 1.5s turquoise flash - [ ] Click PDF annotation → text scrolls to paragraph with 1.5s highlight fade - [ ] Mode switcher "Lesen | Bearbeiten" in topbar - [ ] "Lesen" tab disabled when no blocks exist - [ ] Annotieren button deselects both segmented items when active - [ ] Status bar shows block count + last edited info - [ ] Mobile: vertical stack with collapsible PDF strip (70px collapsed) - [ ] Mobile: mode switcher abbreviates to "Lesen | Bearb." - [ ] Scroll sync respects `prefers-reduced-motion` - [ ] All i18n keys added for de/en/es
marcel added the featureui labels 2026-04-05 09:30:10 +02:00
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Questions & Observations

  • Dependency on #176 — this issue can't be implemented until the transcription block backend (issue #176) exists. The read view fetches from GET /api/documents/{id}/transcription-blocks — that endpoint doesn't exist yet. The ModeSwitcher component also needs the mode state that's introduced as part of the transcription system. Should this issue be explicitly marked as blocked by #176?

  • TranscriptionReadView.svelte is clean — renders <article> with <p> per block, no contenteditable, no interactive editing. This is essentially a pure presentation component. Props: blocks: TranscriptionBlock[], onHighlight: (annotationId: string) => void. Very testable.

  • [unleserlich] detection via regex — the spec says detect [unleserlich] and wrap in <em>. What about other bracket markers? [Seitenumbruch], [Lücke], [Name unleserlich]? Should the regex be generic (/\[.*?\]/g) or specific to [unleserlich]? The spec only mentions [unleserlich], but the implementation choice affects future extensibility.

  • ModeSwitcher.svelte — small, focused component. Props: mode (bindable), hasBlocks (boolean). This is a good candidate for a spec-first Vitest test: render with hasBlocks=false → assert "Lesen" is disabled. Render with hasBlocks=true, click "Lesen" → assert mode changed. Clean TDD target.

  • Scroll sync implementation — the spec describes bidirectional scroll sync with CSS animations. The data-block-id attribute on paragraphs and data-annotation-id on PDF rects create the binding. But how is the "click on PDF annotation" event communicated from the PDF viewer (which is likely a canvas or iframe) to the Svelte component? This depends on how the PDF annotation layer is implemented — is it an SVG overlay, a canvas, or HTML divs positioned over the PDF?

Suggestions

  • TDD order: (1) ModeSwitcher — simplest, pure UI state. (2) TranscriptionReadView — render blocks as prose, test [unleserlich] rendering, test click handler fires. (3) Scroll sync integration — test that clicking a paragraph dispatches the correct event with the annotation ID. (4) Status bar — test block count and "last edited" display.

  • Shared ModeSwitcher between this issue and #176 — the mode switcher is used in both read mode and transcribe mode. Implement it once in this issue, then #176 reuses it. Or: implement it in #176 first (since that's the dependency), and this issue just consumes it.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### Questions & Observations - **Dependency on #176** — this issue can't be implemented until the transcription block backend (issue #176) exists. The read view fetches from `GET /api/documents/{id}/transcription-blocks` — that endpoint doesn't exist yet. The `ModeSwitcher` component also needs the mode state that's introduced as part of the transcription system. Should this issue be explicitly marked as blocked by #176? - **`TranscriptionReadView.svelte` is clean** — renders `<article>` with `<p>` per block, no contenteditable, no interactive editing. This is essentially a pure presentation component. Props: `blocks: TranscriptionBlock[]`, `onHighlight: (annotationId: string) => void`. Very testable. - **`[unleserlich]` detection via regex** — the spec says detect `[unleserlich]` and wrap in `<em>`. What about other bracket markers? `[Seitenumbruch]`, `[Lücke]`, `[Name unleserlich]`? Should the regex be generic (`/\[.*?\]/g`) or specific to `[unleserlich]`? The spec only mentions `[unleserlich]`, but the implementation choice affects future extensibility. - **`ModeSwitcher.svelte`** — small, focused component. Props: `mode` (bindable), `hasBlocks` (boolean). This is a good candidate for a spec-first Vitest test: render with `hasBlocks=false` → assert "Lesen" is disabled. Render with `hasBlocks=true`, click "Lesen" → assert mode changed. Clean TDD target. - **Scroll sync implementation** — the spec describes bidirectional scroll sync with CSS animations. The `data-block-id` attribute on paragraphs and `data-annotation-id` on PDF rects create the binding. But how is the "click on PDF annotation" event communicated from the PDF viewer (which is likely a canvas or iframe) to the Svelte component? This depends on how the PDF annotation layer is implemented — is it an SVG overlay, a canvas, or HTML divs positioned over the PDF? ### Suggestions - **TDD order**: (1) `ModeSwitcher` — simplest, pure UI state. (2) `TranscriptionReadView` — render blocks as prose, test `[unleserlich]` rendering, test click handler fires. (3) Scroll sync integration — test that clicking a paragraph dispatches the correct event with the annotation ID. (4) Status bar — test block count and "last edited" display. - **Shared `ModeSwitcher` between this issue and #176** — the mode switcher is used in both read mode and transcribe mode. Implement it once in this issue, then #176 reuses it. Or: implement it in #176 first (since that's the dependency), and this issue just consumes it.
Author
Owner

🏗️ Markus Keller — Application Architect

Questions & Observations

  • No backend changes — this is purely frontend. The data comes from the same endpoint as transcribe mode (GET /api/documents/{id}/transcription-blocks). No new API, no new schema. Clean scope.

  • Dependency chain: #175#176#177 — the expandable metadata header (#175) introduces the topbar drawer. The transcription system (#176) introduces the data model and backend. This issue (#177) adds the read view and the mode switcher that replaces the current topbar buttons. All three modify DocumentTopBar.svelte. Implementation order matters — #175 first (it's standalone), then #176 (backend + edit mode), then #177 (read mode on top of edit mode infrastructure).

  • Mode state ownership — the mode state ('read' | 'transcribe' | 'annotate') lives in [id]/+page.svelte. The ModeSwitcher receives it as a bindable prop. The DocumentTopBar also needs it (for the Annotieren button behavior). This means mode is a page-level state passed down to multiple children. That's the correct SvelteKit pattern — no stores needed, just prop drilling from the page component. Don't overcomplicate this.

  • PdfAnnotationLayer.svelte modification — adding a dimmed prop to control annotation opacity. This is a minor change to an existing component. But ensure the dimmed state doesn't interfere with the click handler — annotations must remain clickable even when visually faded. The click target size shouldn't change.

Suggestions

  • Keep the mode switcher genericModeSwitcher.svelte should not know about transcription blocks, annotations, or PDFs. It's a pure UI control: receives mode, items, disabled state. The parent decides what each mode means. This keeps it reusable and testable in isolation.

  • URL-driven mode — consider making the mode part of the URL search params: /documents/123?mode=read. This allows bookmarking and sharing a direct link to read mode. Not essential for MVP, but low-cost to implement now (read from $page.url.searchParams, default to 'read' if blocks exist).

## 🏗️ Markus Keller — Application Architect ### Questions & Observations - **No backend changes** — this is purely frontend. The data comes from the same endpoint as transcribe mode (`GET /api/documents/{id}/transcription-blocks`). No new API, no new schema. Clean scope. - **Dependency chain: #175 → #176 → #177** — the expandable metadata header (#175) introduces the topbar drawer. The transcription system (#176) introduces the data model and backend. This issue (#177) adds the read view and the mode switcher that replaces the current topbar buttons. All three modify `DocumentTopBar.svelte`. Implementation order matters — #175 first (it's standalone), then #176 (backend + edit mode), then #177 (read mode on top of edit mode infrastructure). - **Mode state ownership** — the `mode` state (`'read' | 'transcribe' | 'annotate'`) lives in `[id]/+page.svelte`. The `ModeSwitcher` receives it as a bindable prop. The `DocumentTopBar` also needs it (for the Annotieren button behavior). This means `mode` is a page-level state passed down to multiple children. That's the correct SvelteKit pattern — no stores needed, just prop drilling from the page component. Don't overcomplicate this. - **`PdfAnnotationLayer.svelte` modification** — adding a `dimmed` prop to control annotation opacity. This is a minor change to an existing component. But ensure the dimmed state doesn't interfere with the click handler — annotations must remain clickable even when visually faded. The click target size shouldn't change. ### Suggestions - **Keep the mode switcher generic** — `ModeSwitcher.svelte` should not know about transcription blocks, annotations, or PDFs. It's a pure UI control: receives `mode`, `items`, `disabled` state. The parent decides what each mode means. This keeps it reusable and testable in isolation. - **URL-driven mode** — consider making the mode part of the URL search params: `/documents/123?mode=read`. This allows bookmarking and sharing a direct link to read mode. Not essential for MVP, but low-cost to implement now (read from `$page.url.searchParams`, default to 'read' if blocks exist).
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

Questions & Observations

  • Good AC coverage — 15 criteria, mostly concrete and testable. A few observations:

  • Missing edge cases:

    • Single block document: does the status bar still say "1 Abschnitte" (grammatically wrong in German) or "1 Abschnitt"? The i18n key transcription_status_sections needs singular/plural handling.
    • Very long transcription: a 20-block letter. Does the text panel scroll independently of the PDF? Does scroll sync still work correctly when both panels are scrollable?
    • Block with only [unleserlich]: what does a paragraph that's entirely [unleserlich] look like? Just a single italic muted word?
    • Document with blocks but no updated_by: what does the status bar show for "Zuletzt bearbeitet" if the user info is null?
  • "Scroll sync respects prefers-reduced-motion" — great AC. But what does "respect" mean concretely? Skip the 1.5s animation entirely? Use an instant highlight? The behavior needs to be specified to test it.

  • Mobile collapsible PDF strip — the AC says "70px collapsed" but doesn't specify the expanded height. The spec says "~50vh or max 300px." This needs to be testable: assert the strip height is 70px when collapsed, assert it's between 200px and 300px when expanded (at 320px viewport).

  • "Annotieren button deselects both segmented items when active" — this is a mode switcher behavior. But it has implications: if the user is in read mode, clicks Annotieren, then clicks Annotieren again to exit — do they return to read mode or transcribe mode? The spec says "the previous mode is stored" — test this round-trip.

Suggestions

  • Test strategy:
    • Unit (Vitest): TranscriptionReadView — renders N paragraphs for N blocks. [unleserlich] renders as <em>. Click handler fires with correct annotation ID. ModeSwitcher — disabled state, active state, mode change callback.
    • Unit (Vitest): Status bar — singular "Abschnitt" vs plural "Abschnitte". Handles null updated_by.
    • Integration: Mode switching round-trip: read → annotate → exit annotate → back to read.
    • E2E (Playwright): Navigate to document with transcription → verify read mode is default → click paragraph → verify PDF scrolls → switch to Bearbeiten → verify block cards appear → switch back to Lesen → verify prose. Mobile: verify collapsed strip, expand, collapse.
    • Visual regression: Desktop read mode, mobile read mode (collapsed + expanded), empty state.
## 🧪 Sara Holt — QA Engineer & Test Strategist ### Questions & Observations - **Good AC coverage** — 15 criteria, mostly concrete and testable. A few observations: - **Missing edge cases**: - **Single block document**: does the status bar still say "1 Abschnitte" (grammatically wrong in German) or "1 Abschnitt"? The i18n key `transcription_status_sections` needs singular/plural handling. - **Very long transcription**: a 20-block letter. Does the text panel scroll independently of the PDF? Does scroll sync still work correctly when both panels are scrollable? - **Block with only `[unleserlich]`**: what does a paragraph that's entirely `[unleserlich]` look like? Just a single italic muted word? - **Document with blocks but no `updated_by`**: what does the status bar show for "Zuletzt bearbeitet" if the user info is null? - **"Scroll sync respects `prefers-reduced-motion`"** — great AC. But what does "respect" mean concretely? Skip the 1.5s animation entirely? Use an instant highlight? The behavior needs to be specified to test it. - **Mobile collapsible PDF strip** — the AC says "70px collapsed" but doesn't specify the expanded height. The spec says "~50vh or max 300px." This needs to be testable: assert the strip height is 70px when collapsed, assert it's between 200px and 300px when expanded (at 320px viewport). - **"Annotieren button deselects both segmented items when active"** — this is a mode switcher behavior. But it has implications: if the user is in read mode, clicks Annotieren, then clicks Annotieren again to exit — do they return to read mode or transcribe mode? The spec says "the previous mode is stored" — test this round-trip. ### Suggestions - **Test strategy**: - **Unit (Vitest)**: `TranscriptionReadView` — renders N paragraphs for N blocks. `[unleserlich]` renders as `<em>`. Click handler fires with correct annotation ID. `ModeSwitcher` — disabled state, active state, mode change callback. - **Unit (Vitest)**: Status bar — singular "Abschnitt" vs plural "Abschnitte". Handles null `updated_by`. - **Integration**: Mode switching round-trip: read → annotate → exit annotate → back to read. - **E2E (Playwright)**: Navigate to document with transcription → verify read mode is default → click paragraph → verify PDF scrolls → switch to Bearbeiten → verify block cards appear → switch back to Lesen → verify prose. Mobile: verify collapsed strip, expand, collapse. - **Visual regression**: Desktop read mode, mobile read mode (collapsed + expanded), empty state.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Questions & Observations

  • Read-only rendering — minimal attack surface — this is a display-only feature. The text comes from the backend (transcription_blocks.text), is rendered as text content in <p> elements (not {@html}), and the user cannot edit anything. No forms, no inputs, no user-submitted data on this page.

  • [unleserlich] regex replacement — if the regex replacement wraps matched text in HTML tags and uses {@html} to render, there's a potential XSS vector if the block text itself contains malicious HTML. Even though the text should be plain text (see security note on #176), defense in depth says: if you must use {@html} for the <em> wrapping, sanitize the text first. Or better: use Svelte's {#each} to split the text into segments and render <em> components for the [unleserlich] parts without {@html}.

  • No new API endpoints — this feature reads from an existing endpoint. No new auth surface, no new data exposure. The mode switcher is entirely client-side state.

  • PDF annotation click events — the dimmed annotations are still clickable. The click event triggers scroll sync (scroll to a paragraph). No data is sent to the server, no state is mutated. Pure UI interaction. No security concern.

Suggestions

  • Avoid {@html} for [unleserlich] rendering — use a text-splitting approach instead:
    {#each splitByIllegible(block.text) as segment}
      {#if segment.type === 'illegible'}
        <em class="text-ink-2 italic">{segment.text}</em>
      {:else}
        {segment.text}
      {/if}
    {/each}
    
    This avoids any HTML injection surface entirely, even if block text contains <script> tags. Belt and suspenders.
## 🔒 Nora "NullX" Steiner — Application Security Engineer ### Questions & Observations - **Read-only rendering — minimal attack surface** — this is a display-only feature. The text comes from the backend (`transcription_blocks.text`), is rendered as text content in `<p>` elements (not `{@html}`), and the user cannot edit anything. No forms, no inputs, no user-submitted data on this page. - **`[unleserlich]` regex replacement** — if the regex replacement wraps matched text in HTML tags and uses `{@html}` to render, there's a potential XSS vector if the block text itself contains malicious HTML. Even though the text should be plain text (see security note on #176), defense in depth says: if you must use `{@html}` for the `<em>` wrapping, sanitize the text first. Or better: use Svelte's `{#each}` to split the text into segments and render `<em>` components for the `[unleserlich]` parts without `{@html}`. - **No new API endpoints** — this feature reads from an existing endpoint. No new auth surface, no new data exposure. The mode switcher is entirely client-side state. - **PDF annotation click events** — the dimmed annotations are still clickable. The click event triggers scroll sync (scroll to a paragraph). No data is sent to the server, no state is mutated. Pure UI interaction. No security concern. ### Suggestions - **Avoid `{@html}` for `[unleserlich]` rendering** — use a text-splitting approach instead: ```svelte {#each splitByIllegible(block.text) as segment} {#if segment.type === 'illegible'} <em class="text-ink-2 italic">{segment.text}</em> {:else} {segment.text} {/if} {/each} ``` This avoids any HTML injection surface entirely, even if block text contains `<script>` tags. Belt and suspenders.
Author
Owner

🎨 Leonie Voss — UI/UX Design Lead

Questions & Observations

  • Typography choice: Tinos at 16px — Tinos is a good serif reading font, metrically compatible with Times New Roman. At 16px with 1.85 line-height, this creates a very comfortable reading experience. But: is Tinos already loaded in the project? If not, it's a new Google Fonts dependency. Check the existing @font-face declarations. If the project uses Merriweather as the serif font (per CLAUDE.md: "font-serif (Merriweather)"), should read mode use Merriweather instead of introducing a second serif font? Consistency vs. optimal reading typography — worth a deliberate decision.

  • Dimmed annotations at 30% opacity — this is subtle enough to not compete with the reading experience, but still visible enough to serve as spatial anchors. Good balance. However: at 30% opacity, the turquoise border becomes very faint on the gray PDF background. Test with actual scanned documents (low contrast, yellowed paper) to ensure the annotations are still visible when the user needs them for scroll-sync.

  • Collapsible PDF strip on mobile (70px) — at 70px, you can only see a tiny portion of the scan. This is enough to provide context ("yes, there's a scan") but not enough to read any handwriting. That's the right trade-off for read mode — the text is primary, the scan is secondary. But the "▲ Scan vergrößern" hint text needs to be large enough to be discoverable. At the 6px shown in the spec mockup (which scales to ~11px real), it may be too subtle. Make it at least 12px with sufficient contrast.

  • Paragraph hover state — the spec shows rgba(0,199,177,.06) on hover. This is extremely subtle — 6% opacity turquoise on white is barely visible. On a 60+ user's possibly uncalibrated monitor, this could be invisible. Consider bumping to 10% or adding a very subtle left border on hover (2px solid turquoise at 30% opacity) to provide a stronger hint that paragraphs are clickable.

  • Empty state (S3) — the pencil icon in a sand circle with "Noch keine Transkription" is clear and friendly. But the description text ("Zeichne Bereiche auf dem Scan und tippe den Text ab...") is instructional — it tells the user what to do in edit mode. Since the user is looking at the empty state in a mode where "Lesen" is disabled, the message should guide them toward the action: consider adding a button or link that switches to Bearbeiten mode, rather than just descriptive text.

Suggestions

  • prefers-reduced-motion behavior — the AC mentions respecting this preference for scroll sync. For reduced-motion users: skip the 1.5s animation entirely, apply an instant background color that persists for 2 seconds then disappears. No scrollIntoView animation either — use behavior: 'instant' instead of 'smooth'. This is a concrete implementation spec that should be in the issue, not just "respects prefers-reduced-motion."

  • Print stylesheet — read mode is the natural candidate for printing. When a user prints a document in read mode, the PDF panel should be hidden, and the text should render full-width with print-friendly typography (serif, 12pt, black on white, no turquoise accents). Consider adding a @media print block. Not essential for MVP, but low-cost and high-value for the family archive use case (grandparents printing letters).

## 🎨 Leonie Voss — UI/UX Design Lead ### Questions & Observations - **Typography choice: Tinos at 16px** — Tinos is a good serif reading font, metrically compatible with Times New Roman. At 16px with 1.85 line-height, this creates a very comfortable reading experience. But: is Tinos already loaded in the project? If not, it's a new Google Fonts dependency. Check the existing `@font-face` declarations. If the project uses Merriweather as the serif font (per CLAUDE.md: "font-serif (Merriweather)"), should read mode use Merriweather instead of introducing a second serif font? **Consistency vs. optimal reading typography** — worth a deliberate decision. - **Dimmed annotations at 30% opacity** — this is subtle enough to not compete with the reading experience, but still visible enough to serve as spatial anchors. Good balance. However: at 30% opacity, the turquoise border becomes very faint on the gray PDF background. Test with actual scanned documents (low contrast, yellowed paper) to ensure the annotations are still visible when the user needs them for scroll-sync. - **Collapsible PDF strip on mobile (70px)** — at 70px, you can only see a tiny portion of the scan. This is enough to provide context ("yes, there's a scan") but not enough to read any handwriting. That's the right trade-off for read mode — the text is primary, the scan is secondary. But the "▲ Scan vergrößern" hint text needs to be large enough to be discoverable. At the 6px shown in the spec mockup (which scales to ~11px real), it may be too subtle. Make it at least 12px with sufficient contrast. - **Paragraph hover state** — the spec shows `rgba(0,199,177,.06)` on hover. This is extremely subtle — 6% opacity turquoise on white is barely visible. On a 60+ user's possibly uncalibrated monitor, this could be invisible. Consider bumping to 10% or adding a very subtle left border on hover (2px solid turquoise at 30% opacity) to provide a stronger hint that paragraphs are clickable. - **Empty state (S3)** — the pencil icon in a sand circle with "Noch keine Transkription" is clear and friendly. But the description text ("Zeichne Bereiche auf dem Scan und tippe den Text ab...") is instructional — it tells the user what to do in edit mode. Since the user is looking at the empty state in a mode where "Lesen" is disabled, the message should guide them toward the action: consider adding a button or link that switches to Bearbeiten mode, rather than just descriptive text. ### Suggestions - **`prefers-reduced-motion` behavior** — the AC mentions respecting this preference for scroll sync. For reduced-motion users: skip the 1.5s animation entirely, apply an instant background color that persists for 2 seconds then disappears. No scrollIntoView animation either — use `behavior: 'instant'` instead of `'smooth'`. This is a concrete implementation spec that should be in the issue, not just "respects prefers-reduced-motion." - **Print stylesheet** — read mode is the natural candidate for printing. When a user prints a document in read mode, the PDF panel should be hidden, and the text should render full-width with print-friendly typography (serif, 12pt, black on white, no turquoise accents). Consider adding a `@media print` block. Not essential for MVP, but low-cost and high-value for the family archive use case (grandparents printing letters).
Author
Owner

🔧 Tobias Wendt — DevOps & Platform Engineer

Questions & Observations

  • No infrastructure impact — purely frontend. No new services, no new environment variables, no new Docker configuration. The data is served by the existing backend.

  • New font dependency? — the spec calls for Tinos (Google Fonts). If this is a new font not currently in the project, it means either a new <link> to Google Fonts (external dependency, GDPR consideration for EU users — Google Fonts serves from Google CDN which logs IP addresses) or a self-hosted font file. The project already self-hosts Merriweather and Montserrat. If Tinos is added, self-host it too — download the woff2 files and serve from /static/fonts/. No external requests.

  • CSS animation performance — the scroll sync uses CSS animations (1.5s fade). These are GPU-composited properties (background-color, opacity) and have no performance impact. Even on older devices our 60+ users might have, this is fine.

  • Bundle sizeTranscriptionReadView.svelte and ModeSwitcher.svelte are small components. No new npm dependencies. Negligible bundle impact.

Suggestions

  • Self-host Tinos if it's a new font. Add the woff2 files to frontend/static/fonts/ and declare @font-face in layout.css. This avoids GDPR issues with Google Fonts CDN and ensures the font loads even when the user is offline (relevant for a family archive that might run on a local network).

  • Otherwise, no concerns. Ship it.

## 🔧 Tobias Wendt — DevOps & Platform Engineer ### Questions & Observations - **No infrastructure impact** — purely frontend. No new services, no new environment variables, no new Docker configuration. The data is served by the existing backend. - **New font dependency?** — the spec calls for Tinos (Google Fonts). If this is a new font not currently in the project, it means either a new `<link>` to Google Fonts (external dependency, GDPR consideration for EU users — Google Fonts serves from Google CDN which logs IP addresses) or a self-hosted font file. The project already self-hosts Merriweather and Montserrat. If Tinos is added, self-host it too — download the woff2 files and serve from `/static/fonts/`. No external requests. - **CSS animation performance** — the scroll sync uses CSS animations (1.5s fade). These are GPU-composited properties (`background-color`, `opacity`) and have no performance impact. Even on older devices our 60+ users might have, this is fine. - **Bundle size** — `TranscriptionReadView.svelte` and `ModeSwitcher.svelte` are small components. No new npm dependencies. Negligible bundle impact. ### Suggestions - **Self-host Tinos** if it's a new font. Add the woff2 files to `frontend/static/fonts/` and declare `@font-face` in `layout.css`. This avoids GDPR issues with Google Fonts CDN and ensures the font loads even when the user is offline (relevant for a family archive that might run on a local network). - Otherwise, no concerns. Ship it.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Discussion outcomes from pre-implementation review with Marcel.

Resolved

  • Button layout / panel architecture — The spec's topbar segmented control is replaced. The "Transkribieren" button stays in the topbar and opens the side panel as today. A new TranscriptionPanelHeader.svelte component inside the panel holds the "Lesen | Bearbeiten" toggle, block count status, and close button. No auto-open on page load — user always clicks to open.
  • Font — Merriweather (already in the project), not Tinos. 16px, line-height 1.85.
  • [unleserlich] / [...] rendering — Only these two bracket markers, rendered as italic muted text. Text-splitting approach (no {@html}) per NullX's recommendation.
  • Annotieren mode — No longer a separate mode. The old annotation system was removed; annotation is now part of the transcription editing flow. The spec's three-state mode switcher and "restore previous mode" logic are not needed.
  • Singular/plural i18n — Two keys: transcription_status_section ("1 Abschnitt", no param) and transcription_status_sections ("{n} Abschnitte", parameterized). Selection via n === 1 in code.
  • prefers-reduced-motionscrollIntoView({ behavior: 'instant' }), no CSS animation on flash/highlight, instant background color applied via class, removed after 2s via JS timeout (no transition).

Not applicable

  • Dependency on #176 — Already implemented. Endpoint and data model exist.
  • Annotieren exit behavior — Moot, no separate annotate mode.

Overall: the scope is cleaner than the spec suggests. No new font, no three-state mode switcher, no auto-open. The main new work is TranscriptionReadView, TranscriptionPanelHeader, and the scroll-sync wiring.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer Discussion outcomes from pre-implementation review with Marcel. ### Resolved - **Button layout / panel architecture** — The spec's topbar segmented control is replaced. The "Transkribieren" button stays in the topbar and opens the side panel as today. A new `TranscriptionPanelHeader.svelte` component inside the panel holds the "Lesen | Bearbeiten" toggle, block count status, and close button. No auto-open on page load — user always clicks to open. - **Font** — Merriweather (already in the project), not Tinos. 16px, line-height 1.85. - **`[unleserlich]` / `[...]` rendering** — Only these two bracket markers, rendered as italic muted text. Text-splitting approach (no `{@html}`) per NullX's recommendation. - **Annotieren mode** — No longer a separate mode. The old annotation system was removed; annotation is now part of the transcription editing flow. The spec's three-state mode switcher and "restore previous mode" logic are not needed. - **Singular/plural i18n** — Two keys: `transcription_status_section` ("1 Abschnitt", no param) and `transcription_status_sections` ("{n} Abschnitte", parameterized). Selection via `n === 1` in code. - **`prefers-reduced-motion`** — `scrollIntoView({ behavior: 'instant' })`, no CSS animation on flash/highlight, instant background color applied via class, removed after 2s via JS timeout (no transition). ### Not applicable - **Dependency on #176** — Already implemented. Endpoint and data model exist. - **Annotieren exit behavior** — Moot, no separate annotate mode. Overall: the scope is cleaner than the spec suggests. No new font, no three-state mode switcher, no auto-open. The main new work is `TranscriptionReadView`, `TranscriptionPanelHeader`, and the scroll-sync wiring.
Author
Owner

Implementation Complete

All acceptance criteria addressed on branch feat/issue-177-transcription-read-mode (10 commits).

What was implemented

New components:

  • TranscriptionReadView.svelte - flowing serif prose, [unleserlich]/[...] markers as italic muted text, click-to-sync, flash highlight
  • TranscriptionPanelHeader.svelte - segmented "Lesen | Bearbeiten" toggle, block count status, last-edited date, close button

Modified components:

  • AnnotationLayer.svelte - dimmed prop (30% opacity, no badges) + flashAnnotationId prop for scroll-sync flash
  • PdfViewer.svelte / DocumentViewer.svelte - prop threading for dimmed + flash
  • [id]/+page.svelte - panelMode state, conditional read/edit rendering, bidirectional scroll-sync, collapsible PDF strip on mobile

New utility:

  • splitByMarkers() - splits text on [unleserlich] and [...] without {@html} (XSS-safe)

i18n: 13 new keys in de/en/es

Accessibility: prefers-reduced-motion support - instant scroll, no CSS animation, static highlight with 2s timeout

Mobile: Collapsible PDF strip (70px collapsed / 50vh expanded), toggle button, abbreviated "Bearb." label, auto-expand on paragraph tap

Commits

  • a94df4b feat(i18n): add read mode translation keys for de/en/es
  • f38c384 feat(types): add updatedAt to TranscriptionBlockData
  • 3279342 feat(util): add splitByMarkers for [unleserlich] and [...] text splitting
  • d070ae2 feat(annotation): add dimmed prop to AnnotationLayer
  • 7d98081 feat(ui): add TranscriptionPanelHeader with mode toggle and status
  • 306eef2 feat(ui): add TranscriptionReadView for flowing prose display
  • e089192 feat(ui): wire panelMode state with read/edit view switching
  • 81b14e5 feat(ui): add bidirectional scroll-sync with flash animations
  • 10cecb0 feat(a11y): respect prefers-reduced-motion for scroll-sync
  • 4d5b8b4 feat(ui): add collapsible PDF strip and abbreviated labels on mobile

Deferred

  • #192: Show editor name in status bar (requires user name resolution from UUID)
## Implementation Complete All acceptance criteria addressed on branch `feat/issue-177-transcription-read-mode` (10 commits). ### What was implemented **New components:** - `TranscriptionReadView.svelte` - flowing serif prose, `[unleserlich]`/`[...]` markers as italic muted text, click-to-sync, flash highlight - `TranscriptionPanelHeader.svelte` - segmented "Lesen | Bearbeiten" toggle, block count status, last-edited date, close button **Modified components:** - `AnnotationLayer.svelte` - `dimmed` prop (30% opacity, no badges) + `flashAnnotationId` prop for scroll-sync flash - `PdfViewer.svelte` / `DocumentViewer.svelte` - prop threading for dimmed + flash - `[id]/+page.svelte` - panelMode state, conditional read/edit rendering, bidirectional scroll-sync, collapsible PDF strip on mobile **New utility:** - `splitByMarkers()` - splits text on `[unleserlich]` and `[...]` without `{@html}` (XSS-safe) **i18n:** 13 new keys in de/en/es **Accessibility:** `prefers-reduced-motion` support - instant scroll, no CSS animation, static highlight with 2s timeout **Mobile:** Collapsible PDF strip (70px collapsed / 50vh expanded), toggle button, abbreviated "Bearb." label, auto-expand on paragraph tap ### Commits - `a94df4b` feat(i18n): add read mode translation keys for de/en/es - `f38c384` feat(types): add updatedAt to TranscriptionBlockData - `3279342` feat(util): add splitByMarkers for [unleserlich] and [...] text splitting - `d070ae2` feat(annotation): add dimmed prop to AnnotationLayer - `7d98081` feat(ui): add TranscriptionPanelHeader with mode toggle and status - `306eef2` feat(ui): add TranscriptionReadView for flowing prose display - `e089192` feat(ui): wire panelMode state with read/edit view switching - `81b14e5` feat(ui): add bidirectional scroll-sync with flash animations - `10cecb0` feat(a11y): respect prefers-reduced-motion for scroll-sync - `4d5b8b4` feat(ui): add collapsible PDF strip and abbreviated labels on mobile ### Deferred - #192: Show editor name in status bar (requires user name resolution from UUID)
Sign in to join this conversation.
No Label feature ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#177