feat: Transcription read mode (clean split) #177
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
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 mockupsKey screens
Core concepts
Flowing prose rendering
<p>inside an<article>— no block borders, no numbered badges[unleserlich]markers render as italic muted textDimmed annotations
Bidirectional scroll sync
scrollIntoView({ behavior: 'smooth', block: 'center' })Mode switcher
let mode: 'read' | 'transcribe' | 'annotate' = $state(...)Mobile layout
Component architecture
TranscriptionReadView.svelteModeSwitcher.svelteDocumentTopBar.svelte[id]/+page.sveltePdfAnnotationLayer.sveltedimmedprop for read modei18n keys
mode_readmode_editmode_edit_shorttranscription_status_sectionstranscription_status_last_editedtranscription_empty_titletranscription_empty_descscan_expandscan_collapseAcceptance criteria
[unleserlich]markers render as italic muted textprefers-reduced-motion👨💻 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. TheModeSwitchercomponent 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.svelteis 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 withhasBlocks=false→ assert "Lesen" is disabled. Render withhasBlocks=true, click "Lesen" → assert mode changed. Clean TDD target.Scroll sync implementation — the spec describes bidirectional scroll sync with CSS animations. The
data-block-idattribute on paragraphs anddata-annotation-idon 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
ModeSwitcherbetween 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.🏗️ 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
modestate ('read' | 'transcribe' | 'annotate') lives in[id]/+page.svelte. TheModeSwitcherreceives it as a bindable prop. TheDocumentTopBaralso needs it (for the Annotieren button behavior). This meansmodeis 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.sveltemodification — adding adimmedprop 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.svelteshould not know about transcription blocks, annotations, or PDFs. It's a pure UI control: receivesmode,items,disabledstate. 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).🧪 Sara Holt — QA Engineer & Test Strategist
Questions & Observations
Good AC coverage — 15 criteria, mostly concrete and testable. A few observations:
Missing edge cases:
transcription_status_sectionsneeds singular/plural handling.[unleserlich]: what does a paragraph that's entirely[unleserlich]look like? Just a single italic muted word?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
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.updated_by.🔒 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
{@html}for[unleserlich]rendering — use a text-splitting approach instead:<script>tags. Belt and suspenders.🎨 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-facedeclarations. 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-motionbehavior — 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 — usebehavior: '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 printblock. Not essential for MVP, but low-cost and high-value for the family archive use case (grandparents printing letters).🔧 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.svelteandModeSwitcher.svelteare 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-faceinlayout.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.
👨💻 Felix Brandt — Senior Fullstack Developer
Discussion outcomes from pre-implementation review with Marcel.
Resolved
TranscriptionPanelHeader.sveltecomponent 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.[unleserlich]/[...]rendering — Only these two bracket markers, rendered as italic muted text. Text-splitting approach (no{@html}) per NullX's recommendation.transcription_status_section("1 Abschnitt", no param) andtranscription_status_sections("{n} Abschnitte", parameterized). Selection vian === 1in 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
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.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 highlightTranscriptionPanelHeader.svelte- segmented "Lesen | Bearbeiten" toggle, block count status, last-edited date, close buttonModified components:
AnnotationLayer.svelte-dimmedprop (30% opacity, no badges) +flashAnnotationIdprop for scroll-sync flashPdfViewer.svelte/DocumentViewer.svelte- prop threading for dimmed + flash[id]/+page.svelte- panelMode state, conditional read/edit rendering, bidirectional scroll-sync, collapsible PDF strip on mobileNew utility:
splitByMarkers()- splits text on[unleserlich]and[...]without{@html}(XSS-safe)i18n: 13 new keys in de/en/es
Accessibility:
prefers-reduced-motionsupport - instant scroll, no CSS animation, static highlight with 2s timeoutMobile: Collapsible PDF strip (70px collapsed / 50vh expanded), toggle button, abbreviated "Bearb." label, auto-expand on paragraph tap
Commits
a94df4bfeat(i18n): add read mode translation keys for de/en/esf38c384feat(types): add updatedAt to TranscriptionBlockData3279342feat(util): add splitByMarkers for [unleserlich] and [...] text splittingd070ae2feat(annotation): add dimmed prop to AnnotationLayer7d98081feat(ui): add TranscriptionPanelHeader with mode toggle and status306eef2feat(ui): add TranscriptionReadView for flowing prose displaye089192feat(ui): wire panelMode state with read/edit view switching81b14e5feat(ui): add bidirectional scroll-sync with flash animations10cecb0feat(a11y): respect prefers-reduced-motion for scroll-sync4d5b8b4feat(ui): add collapsible PDF strip and abbreviated labels on mobileDeferred