diff --git a/docs/specs/annotation-transcription-final-spec.html b/docs/specs/annotation-transcription-final-spec.html new file mode 100644 index 00000000..dded5517 --- /dev/null +++ b/docs/specs/annotation-transcription-final-spec.html @@ -0,0 +1,963 @@ + + +
+ + +Final spec for the collaborative inline transcription system. Draw turquoise rectangles on the scanned letter → numbered transcript blocks appear in a side panel → type what you read. Block-level comment threads with quoted selections for discussion. History in the transcript toolbar. No bottom panel.
+Today, annotations are rectangles on the PDF that open a comment thread in the side panel. By adding a type field to DocumentAnnotation, the same draw-a-rectangle gesture can create a transcription annotation (turquoise). A transcription annotation links a PDF region to an editable text block in the right panel.
Comments live inside transcript blocks as block-level threads. Users can select a word or phrase before commenting — the selection is auto-quoted into the comment message (e.g. > “Breslau”) rather than structurally anchored to character offsets. This avoids fragile offset tracking that breaks when text is edited. The quote is a display hint, not a structural anchor. Yellow comment annotations are disabled in transcribe mode — only turquoise transcription rectangles appear on the PDF.
Draw a rectangle around a passage on the scan. A transcript block appears in the editor, linked to that region. Type what you read. Rinse and repeat down the page. Others can join and work on different blocks simultaneously.
AnnotationLayer — draw rects on PDFPdfViewer — render, zoom, page navCommentThread — threaded replies, mentionsDocumentAnnotation model — add type fieldDocumentComment model — unchangedAnnotationSidePanel slot → becomes the transcript editor panelannotateMode state → split into annotateMode + transcribeModeAnnotateHintStrip → new copy for transcribe modetranscription_blocks tabletype column on document_annotations/* Comment flow for block-level threads with quoted selections: + * + * 1. TRIGGER: user clicks "Kommentieren" in block footer. + * Alternatively: Ctrl+Shift+K when block is focused. + * + * 2. AUTO-QUOTE: if text is selected in the block body (via mouse or keyboard), + * the selection is captured and pre-filled as a blockquote in the comment input: + * > "[unleserlich]" + * The user can edit or remove the quote before sending (× button). + * If no text was selected → input opens empty (general block comment). + * + * 3. STORAGE: the quote is stored as part of the comment `content` field. + * Markdown blockquote syntax: "> \"Breslau\"\nI think this is Breslau." + * The block_id FK on DocumentComment links the comment to its block. + * NO char_offset_start/end columns. The quote is just text. + * + * 4. DISPLAY: the quote renders as an indented italic line with a left border, + * above the comment text. It's visually distinct but structurally just content. + * + * 5. RESILIENCE: if the transcription text changes after quoting, nothing breaks. + * The quote is a frozen snapshot. The discussion context is preserved. + * Compare to char-offset anchoring where an edit would shift all offsets + * and potentially point to the wrong text. + * + * 6. THREAD: replies to a quoted comment don't need their own quotes — + * the parent comment provides context. Standard CommentThread reply flow. + * + * 7. MOBILE: "Kommentieren" button always visible in footer. + * Selecting text → auto-quote works the same via touch selection. + * Thread collapsed to "N Kommentare" row, tap to expand. */+
| Element | Value | Notes |
|---|---|---|
| Comment input | ||
| Container | border:orange, bg:white, radius:4px, mx:8px | Appears below block body, above footer |
| Quote display | left-border:2px line, italic, 7px muted | Editable — user can modify or remove |
| Remove quote | "× Zitat entfernen" link, 5px, top-right of quote | Converts to general block comment |
| Input field | flex:1, 7px, border:line, bg:page, radius:3px | Auto-focuses when opened |
| Send button | "Senden", 6px/600, navy bg, white text | Enter to send, Shift+Enter for newline |
| Posted comment with quote | ||
| Quote in thread | left-border:2px line, italic, 7px muted | Read-only — frozen snapshot of selected text |
| Message below | 8px normal text, below the quote | Standard CommentThread message styling |
| Data model | ||
| block_id | UUID FK → transcription_blocks (nullable) | Links comment to its block |
| content | TEXT with markdown blockquote | > "quoted text"\nComment message |
| No char offsets | — | Intentional. See spec rationale. |
PanelHistory component but embedded in the transcript panel instead of the bottom panel./* Core flow: enter transcribe mode → crosshair cursor on PDF → draw rect → creates:
+ * 1. DocumentAnnotation(type:"transcription", turquoise) in the DB
+ * 2. TranscriptionBlock(annotation_id, text:"", sort_order:N) in the DB
+ * 3. Editable block in the right panel, linked to the annotation
+ * Clicking an annotation rect on PDF scrolls to + highlights the matching block.
+ * Clicking a block header highlights the matching rect on PDF.
+ *
+ * COMMENTS: block-level threads with quoted selections.
+ * - Each block has a "Kommentieren" button in its footer.
+ * - If text is selected when clicking "Kommentieren", the selection is auto-quoted
+ * into the comment (> "Breslau"). The quote is plain text in the message body,
+ * NOT a structural char-offset anchor. It doesn't break when text changes.
+ * - Threads are anchored to block_id only (no char offsets).
+ * - Yellow comment annotations are DISABLED in transcribe mode.
+ * Only turquoise transcription rects on the PDF. One annotation type per mode.
+ *
+ * History: "Verlauf" button in transcript toolbar toggles a collapsible panel
+ * showing recent changes with word-level diffs per block.
+ * Auto-save: debounced PATCH to /api/transcription-blocks/{blockId} (500ms).
+ * Bottom panel: removed entirely (all modes). Metadata → topbar drawer. */
+ | Element | Value | Notes |
|---|---|---|
| Annotation reuse | ||
| Draw gesture | Existing AnnotationLayer.onDraw(rect) | Same pointer events. crosshair cursor. |
| Annotation color | turquoise (#00C7B1) for transcription | Yellow annotations disabled in transcribe mode |
| Annotation type | New column: type VARCHAR "transcription"|"comment" | Default "comment" for backward compat |
| Number badge | 16px navy circle, top-left of rect | Sort order number, matches block number |
| Transcript blocks (right panel) | ||
| Block card | border:1px line, radius:5px, active: turquoise glow | Header: number + label + presence. Body: contenteditable. |
| Block label | Editable text, defaults: Anrede, Hauptteil, Schluss | Double-click to rename |
| Empty state | Dashed border, "noch leer" italic text | Focus to start typing |
| Add block CTA | Dashed card: "Markiere eine Passage im Scan..." | Not clickable — directs user to draw on PDF |
| Block-level comment threads | ||
| Trigger | "Kommentieren" button in block footer | Always visible — no hover-reveal |
| Quoted selection | If text selected → auto-quoted into comment body | Plain text quote (> "Breslau"), NOT char-offset anchor |
| Quote display | Left border + italic, above the comment text | Decorative only — doesn't link to text range |
| Thread UI | orange left-border, orange-tint bg, below block body | Block-level anchor (block_id). Reuses CommentThread. |
| Footer hint | "Text markieren für Zitat" in 5px muted text | Only shown when block is active/focused |
| Resolve | "✓ Lösen" button collapses thread | Resolved threads hidden by default, toggle to show |
| Mobile | Threads collapsed to "2 Kommentare" row, tap to expand | Saves vertical space on small screens |
| Yellow annotations in transcribe mode | ||
| Status | Disabled — draw gesture only creates turquoise rects | Existing yellow annotations still visible (read-only) |
| Annotate mode | Still available via topbar "Annotieren" button | Exits transcribe mode, enters annotate mode (yellow) |
| History (transcript toolbar) | ||
| Toggle | "🕗 Verlauf" button in transcript toolbar | Active state: navy bg, white text |
| Panel | Collapsible, between toolbar and block list | bg:color-page, border:line, radius:5px |
| Entries | Time + user + block ref + word-level diff | Reuses diffWords from 'diff' library |
| "Alle anzeigen" | Link to full history view (reuses PanelHistory) | Opens in a modal or replaces block list temporarily |
| Interaction | ||
| Click rect → block | scrollIntoView + active state on block | Turquoise glow on both rect and block |
| Click block → rect | PDF scrolls/zooms to show the annotation | If multi-page: switches page |
| Delete block | Deletes annotation + block + threads | Confirm dialog if threads exist |
| Reorder blocks | Drag handle in block header | Updates sort_order via PATCH |
| Presence (collaborative) | ||
| Dots in topbar | Colored dot + user name, flex row | Max 3 shown, "+N" overflow |
| Block-level presence | Colored dot + name in block header | Left border color matches user |
| Implementation | WebSocket presence via Y.js (future) | MVP: polling-based, 5s interval |
| Auto-save | ||
| Debounce | 500ms after last keystroke | PATCH /api/transcription-blocks/{blockId} |
| Status | "✓ Gespeichert" in toolbar, fades after 3s | "Speichern..." while request in-flight |
| Conflict | Last-write-wins for MVP | Y.js CRDT for future collaborative editing |
document_annotationstype VARCHAR(20) NOT NULL DEFAULT 'comment'.'comment' (existing behavior) or 'transcription'.'comment'.transcription_blocks| Column | Type | Notes |
|---|---|---|
id | UUID PK | Generated |
annotation_id | UUID FK → document_annotations | Links block to its PDF rectangle |
document_id | UUID FK → documents | Denormalized for efficient queries |
text | TEXT | The transcription content |
label | VARCHAR(100) | "Anrede", "Hauptteil", etc. |
sort_order | INT | Display order in the editor |
created_by | UUID FK → app_users | |
updated_by | UUID FK → app_users | |
created_at | TIMESTAMP | @CreationTimestamp |
updated_at | TIMESTAMP | @UpdateTimestamp |
document_commentsblock_id UUID FK → transcription_blocks (nullable).content field using blockquote markdown syntax (> “Breslau”). This is intentional — char offsets break when text is edited and require OT/CRDT to maintain. Quotes are a display hint, not a structural anchor.block_id is nullable, existing comments unaffected.Document.transcriptionThe existing transcription TEXT field becomes a computed read-only view: SELECT string_agg(text, E'\n\n' ORDER BY sort_order) FROM transcription_blocks WHERE document_id = ?. Write operations go through the block API. This keeps search indexing, export, and the read-only PanelTranscription working without changes.
| Type | Color | Hex | On click | When active |
|---|---|---|---|---|
| Comment | Yellow | #FFC800 | Opens AnnotationSidePanel (existing) | Annotate mode only |
| Transcription | Turquoise | #00C7B1 | Highlights matching block in transcript editor | Transcribe mode only |
Mode exclusivity: In transcribe mode, only turquoise rects can be drawn. Existing yellow comment annotations from annotate mode are still visible on the PDF (read-only, dimmed) but cannot be created or interacted with. The “Annotieren” button exits transcribe mode and enters annotate mode (and vice versa). This prevents overlapping annotation types and avoids user confusion about which comment system to use.
+ +| Component | Change |
|---|---|
AnnotationLayer.svelte | Pass type to onDraw callback. Render turquoise vs yellow based on annotation type. Add number badges for transcription annotations. |
PdfViewer.svelte | Split handleAnnotationDraw into two paths (annotate vs transcribe). Route handleAnnotationClick to either side panel or transcript editor. |
AnnotationSidePanel.svelte | No change — still handles comment-type annotations in annotate mode. Hidden in transcribe mode. |
TranscriptEditor.svelte (new) | Right panel. Renders transcript toolbar + block list. Manages block CRUD, auto-save, block-level comment threads. |
TranscriptBlock.svelte (new) | Single block card. contenteditable body, header with number/label/presence, footer with “Kommentieren” button, thread slot below body. |
BlockCommentThread.svelte (new) | Comment thread anchored to a block. Shows quoted selections as blockquotes. Reuses CommentThread internally for replies/mentions. |
TranscriptToolbar.svelte (new) | Block count, sort button, history toggle, save status. |
TranscriptHistory.svelte (new) | Collapsible panel. Reuses diffWords from the diff library. Shows recent changes per block. |
DocumentBottomPanel.svelte | Removed entirely. Metadata lives in the topbar drawer (see companion spec). Discussion, transcription, and history are all inline. |
documents/[id]/+page.svelte | Add transcribeMode state. Conditionally render TranscriptEditor vs bottom panel. |
| Method | Path | Notes |
|---|---|---|
| POST | /api/documents/{id}/annotations | Existing, but now accepts type field. If type="transcription", also creates a TranscriptionBlock. |
| GET | /api/documents/{id}/transcription-blocks | Returns all blocks ordered by sort_order. |
| PATCH | /api/transcription-blocks/{blockId} | Update text, label, or sort_order. Auto-save target. |
| DELETE | /api/transcription-blocks/{blockId} | Deletes block + its annotation + any anchored comments. |
| PATCH | /api/transcription-blocks/reorder | Bulk update sort_order for drag-and-drop reordering. |
AnnotationLayer.onDraw(rect) fires. PdfViewer calls POST /api/documents/{id}/annotations with type: "transcription".DocumentAnnotation + TranscriptionBlock (empty text, next sort_order).PATCH /api/transcription-blocks/{blockId}.Comments are anchored to blocks, not character offsets. This is a deliberate simplification:
+ +> “Breslau”. The user can edit or remove the quote before sending.DocumentComment with block_id set. The quoted text is part of the content field (markdown blockquote syntax).CommentThread — no changes needed.Nothing breaks. The quote is a frozen snapshot of what the user selected. If “Bresla” was later corrected to “Breslau”, the original quote still reads > “Bresla” with Oma Inge’s comment “I think this is Breslau.” The context is preserved. No orphaned anchors, no broken highlights.
When a block is focused/active, the footer shows a subtle hint: “Text markieren für Zitat” (select text for a quote). This teaches the quoted-selection pattern without requiring documentation.
+ +TranscriptHistory.svelte.diffWords from the diff library (same as existing PanelHistory).PanelHistory component in a modal.role="region" with aria-label="Transkriptions-Block N: [label]"contenteditable with aria-multiline="true"aria-label="Transkriptions-Bereich N"aria-label="Block N kommentieren"aria-expanded, aria-controls="transcript-history"The expandable metadata header (labeled “Details ▼” toggle) is specified separately in expandable-metadata-header-spec.html. Together, these two specs fully eliminate the bottom panel in all modes: metadata → header drawer, transcription → inline split view, discussion → inline threads, history → transcript toolbar. One consistent pattern — no mode-dependent UI structure.
The document topbar gains a labeled toggle button (“Details ▼”) that opens a full-width metadata drawer below the main row. This replaces the bottom panel’s Metadata tab in transcribe mode, keeping all interactive elements (person links, conversation links, tag filters) accessible without consuming permanent viewport space.
+User interviews include family members aged 60+. A bare 12–16px chevron icon is easy to miss or misinterpret as decorative. A labeled button — “Details ▼” — is self-explanatory, provides a larger click target (min 44×28px), and follows the progressive disclosure pattern: key facts (title, date, person chips) are always visible in the topbar; the toggle reveals the full metadata only when needed.
+/* The topbar gains a labeled "Details ▼" toggle button that opens a full-width metadata
+ * drawer below the main topbar row.
+ *
+ * Collapsed (default): topbar looks like today + a "Details ▼" button between
+ * the person chips and the action buttons.
+ * Expanded: a new row slides down with a 3-column grid (desktop):
+ * Col 1: date (long format), location, archive location — icon + value + label
+ * Col 2: sender card + receiver cards — clickable, links to /persons/{id}
+ * conversation icon links to /korrespondenz?senderId=X&receiverId=Y
+ * Col 3: tag chips — clickable, link to /?tag=X
+ *
+ * The drawer PUSHES content down (document flow, not overlay).
+ * Background: color-page (sand) to visually separate from white topbar.
+ * Animation: Svelte slide transition or max-height + overflow:hidden, 200ms ease.
+ *
+ * KEY DECISION: "Details ▼" labeled toggle instead of bare chevron icon.
+ * Reason: 60+ year old users in user interviews — bare icons are easy to miss.
+ * The label makes the interaction self-explanatory and provides a 44×28px min tap target.
+ *
+ * Mobile: single-column stack, person cards full-width with 44px min-height,
+ * conversation links always visible (no hover-reveal on touch). */
+ | Element | Value | Notes |
|---|---|---|
| Toggle button | ||
| Label | "Details" + ▼ chevron | i18n key: topbar_details_toggle |
| Size | min 44×28px tap target, text-xs font-semibold | WCAG 2.5.5 compliant target size |
| Inactive style | border border-line, text-ink-2, bg-transparent | Subtle, doesn't compete with action buttons |
| Active style | bg-primary, text-primary-fg, border-primary | Clear open state — matches annotate button pattern |
| Chevron | ▼ (U+25BC), rotates 180deg when open | CSS transition transform 200ms |
| Aria | aria-expanded, aria-controls="metadata-drawer" | Button role implicit |
| Keyboard | Ctrl+M toggles, Escape closes | Ctrl+M matches "M for metadata" |
| Drawer (expanded) | ||
| Layout | grid 3-col desktop (1fr 1fr 1fr), 1-col mobile | bg:color-page, border-top:line, p:12px 16px |
| Animation | Svelte slide transition, 200ms | Or CSS max-height 0↔auto with overflow:hidden |
| Push behavior | In document flow, pushes split view down | Not absolute/overlay — no clipping |
| ID | id="metadata-drawer" | role="region", aria-label="Dokumentmetadaten" |
| Drawer content — Details column | ||
| Date | Long format (14. Mai 1943), icon 📅 | Uses existing formatDate utility |
| Location | Text, icon 📍 | Only shown if doc.creationLocation exists |
| Archive | Text, icon 📁 | Only shown if doc.archiveLocation exists |
| Drawer content — Persons column | ||
| Person card | border:line, radius:5px, bg:page, hover:accent-bg | Entire card is a link to /persons/{id} |
| Card content | 18px avatar + full name + alias | Alias from person.alias field |
| Conversation icon | 💬 appears on hover (desktop), always visible (mobile) | Links to /korrespondenz?senderId=X&receiverId=Y |
| Mobile card height | min-height 44px | WCAG touch target compliance |
| Drawer content — Tags column | ||
| Chip | text-[10px]/600, sand bg, uppercase, radius:3px | Click → navigate to /?tag=X |
| Hover | bg-primary, text-primary-fg | Visual feedback that chips are interactive |
| Non-transcribe mode | ||
| Toggle shown? | Yes — always present in topbar | Consistent UX across all modes |
| Bottom panel | Removed entirely — all modes | Drawer is the single metadata pattern everywhere |
Add a labeled “Details” toggle button and a collapsible metadata drawer to DocumentTopBar.svelte. This spec covers only the header expansion — the transcription split view, inline comments, and history toolbar are covered in the companion spec (annotation-transcription-final-spec.html).
let metadataOpen = $state(false) in DocumentTopBar.svelte.| Component | Change |
|---|---|
DocumentTopBar.svelte | Add metadataOpen state, toggle button, and conditional drawer div. New props needed: doc.creationLocation, doc.archiveLocation, doc.tags, full sender/receiver objects with aliases. |
MetadataDrawer.svelte (new) | Extracted child component. Receives the doc object. Renders the 3-column grid (desktop) or 1-column stack (mobile). Contains person cards, tag chips, and metadata fields. |
PersonChipRow.svelte | No change. Still renders the abbreviated chips in the main topbar row. |
DocumentBottomPanel.svelte | Remove entirely. The metadata drawer replaces the Metadata tab. Transcription, Discussion, and History move to inline UI (see companion transcription spec). No bottom panel in any mode. |
In the topbar’s flex row, the button goes after the person chips divider and before the action buttons divider:
+← | Title | chips → | Details ▼ | Transkribieren | Annotieren | Edit | Download
On mobile (<375px), person chips are hidden. The toggle sits after the title, before the transcribe pill.
+ +slide transition: {#if metadataOpen}<div transition:slide={{ duration: 200 }}>grid grid-cols-3 gap-4 p-3 sm:p-4 bg-canvas border-t border-linegrid grid-cols-1 gap-3 p-3 bg-canvas border-t border-linemd:grid-cols-3 (768px+).personAvatarColor), full name (font-serif), alias (text-xs text-ink-2).<a href="/persons/{id}">.<a> inside the card, absolute-positioned or flex-end. Links to /korrespondenz?senderId={sender.id}&receiverId={receiver.id}.min-h-[44px] for touch targets. Conversation icon always visible (opacity-100 instead of opacity-0 group-hover:opacity-100).<a href="/?tag={tag.name}"> with text-[10px] font-semibold uppercase bg-muted rounded px-2 py-0.5 hover:bg-primary hover:text-primary-fg transition-colors.aria-label="Dokumente mit Schlagwort {tag.name} filtern".aria-expanded={metadataOpen}, aria-controls="metadata-drawer".id="metadata-drawer", role="region", aria-label="Dokumentmetadaten".aria-label="Korrespondenz zwischen {sender} und {receiver} anzeigen".| Key | de | en |
|---|---|---|
topbar_details_toggle | Details | Details |
topbar_details_date | Datum | Date |
topbar_details_location | Entstehungsort | Location |
topbar_details_archive | Archivstandort | Archive location |
topbar_details_sender | Absender | Sender |
topbar_details_receivers | Empfänger | Receivers |
topbar_details_tags | Schlagwörter | Tags |
topbar_details_conversation | Korrespondenz anzeigen | View correspondence |
A focused reading experience for completed transcriptions. Uses the clean split layout: PDF scan on the left, flowing 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.
+Transcribe mode is for editing. But most visits to a completed transcription are for reading — comparing the handwriting with the typed text, sharing with family, or just revisiting a letter. Read mode strips away all editing chrome and presents the transcription as flowing prose alongside the original scan.
+The clean split was chosen over the full-page reader (PDF hidden) and the interleaved view (cropped PDF per block) because it preserves the familiar side-by-side layout from transcribe mode while dramatically reducing visual noise. Users can switch between reading and editing without re-learning the spatial layout.
+ +/* Same side-by-side layout as transcribe mode, but the right panel renders + * the transcription as continuous flowing prose instead of block cards. + * + * Key differences from transcribe mode: + * - No block borders, headers, footers, or numbered badges + * - No contenteditable — text is plain rendered HTML + * - No comment threads, no "Kommentieren" buttons + * - No presence dots, no hint strip, no auto-save indicator + * - Annotation rects on PDF are dimmed (opacity ~0.3, no badges) + * - Still clickable for scroll-sync + * - Status bar shows: "4 Abschnitte · Zuletzt bearbeitet: Oma Inge, 14:23" + * + * Scroll sync: + * - Click paragraph → matching PDF annotation flashes turquoise (1.5s fade) + * - Click PDF annotation → matching paragraph gets subtle bg highlight (1.5s fade) + * - PDF auto-scrolls to center the annotation in the viewport */+
| Element | Value | Notes |
|---|---|---|
| Text panel | ||
| Font | Tinos (serif), 16px, line-height 1.85 | Generous reading typography |
| Padding | 24px 32px | Comfortable margins like a book page |
| Paragraphs | One <p> per transcription block | mb-4 between paragraphs |
| [unleserlich] | italic, text-ink-2, font-size: 0.9em | Subtle but readable |
| Hover | Subtle turquoise bg at 6% opacity | Hint that paragraphs are clickable |
| PDF panel | ||
| Annotations | Dimmed: border-opacity 0.3, bg-opacity 0.04 | Still clickable for scroll-sync |
| Badges | Hidden | No numbered circles in read mode |
| Scroll sync | Click para → PDF scrolls, flash 1.5s | Turquoise tint at 18% → fade to 4% |
| Status bar | ||
| Content | "N Abschnitte · Zuletzt bearbeitet: Name, HH:mm" | Uses most recent updated_at across blocks |
| Height | 28px, sand background | Same as transcribe mode status bar |
/* Bidirectional scroll sync with visual feedback. + * + * Text → PDF: + * 1. User clicks a paragraph + * 2. Paragraph gets .highlighted class (turquoise bg at 10%) + * 3. Matching annotation rect gets .highlight-flash class + * 4. PDF viewport scrolls to center the annotation + * 5. Both highlights fade over 1.5s via CSS animation + * + * PDF → Text: + * 1. User clicks a dimmed annotation rect + * 2. Annotation flashes (same .highlight-flash) + * 3. Matching paragraph gets .highlighted + * 4. Text panel scrolls to center the paragraph + * 5. Both fade over 1.5s + * + * Implementation: each paragraph has data-block-id matching the + * transcription block's annotation_id. The annotation rects already + * have annotation IDs from transcribe mode. */+
| Element | Value | Notes |
|---|---|---|
| Highlight animation | ||
| Paragraph bg | rgba(0,199,177,.10) | Turquoise at 10%, fades to 0 |
| Annotation flash | rgba(0,199,177,.18) → .04 | Border returns to .3 opacity |
| Duration | 1.5s ease-out | CSS animation, no JS timers needed |
| Scroll behavior | smooth, block: center | scrollIntoView({ behavior: 'smooth', block: 'center' }) |
| Data binding | ||
| Paragraph attr | data-block-id="{annotation_id}" | Links text to PDF annotation |
| Annotation attr | data-annotation-id="{id}" | Already exists from transcribe mode |
/* When transcription_blocks count is 0: + * - Mode switcher defaults to "Bearbeiten" + * - "Lesen" tab is disabled: opacity 0.35, cursor: not-allowed, not clickable + * - Right panel shows empty state with pencil icon, title, and description + * - No status bar (nothing to show) + * + * As soon as the first block is saved, "Lesen" becomes enabled. + * The mode does NOT auto-switch — user stays in Bearbeiten. */+
| Element | Value | Notes |
|---|---|---|
| Empty state | ||
| Icon | Pencil in 48px sand circle | Centered vertically in panel |
| Title | "Noch keine Transkription" | i18n key: transcription_empty_title |
| Description | "Zeichne Bereiche auf dem Scan…" | i18n key: transcription_empty_desc |
| Mode switcher | ||
| Lesen tab | Disabled: opacity .35, not-allowed | Enabled when block count > 0 |
| Default | "Bearbeiten" active | Enters transcribe mode directly |
Liebe Martha,
+ich schreibe Dir heute aus dem Lazarett in Breslau. Mach Dir keine Sorgen, es geht mir den Umständen entsprechend gut. Der Arzt sagt [unleserlich] Wochen noch dauern wird.
+Die Kinder sollen wissen, dass ich an sie denke. Sag dem kleinen Fritz, er soll auf seine Mutter aufpassen. Und Lotte soll weiter so fleißig in der Schule sein.
+In ewiger Liebe,
Dein Heinrich
Liebe Martha,
+ich schreibe Dir heute aus dem Lazarett in Breslau…
+/* On viewports < 768px, the side-by-side split becomes vertical: + * - PDF scan strip at top (collapsed: 70px, expanded: ~50vh) + * - Flowing text below, full-width + * - Tap PDF strip to toggle expand/collapse + * - Expand hint text: "▲ Scan vergrößern" / "▼ Scan verkleinern" + * + * Mode switcher abbreviates: "Lesen | Bearb." + * Scroll-sync: tapping a paragraph briefly highlights the matching + * region in the expanded PDF. If PDF is collapsed, it auto-expands + * first, then scrolls to the annotation. + * + * Same status bar at the bottom, same flowing prose styling. */+
| Element | Value | Notes |
|---|---|---|
| PDF strip | ||
| Collapsed height | 70px | Shows miniature scan preview |
| Expanded height | ~50vh or max 300px | Smooth CSS transition (300ms ease) |
| Toggle | Tap anywhere on strip | Hint text in bottom-right corner |
| Text area | ||
| Font | Tinos, 15px, line-height 1.9 | Slightly larger than desktop for touch |
| Padding | 16px | Full-width, no wasted space |
| Mode switcher | ||
| Labels | "Lesen | Bearb." | Abbreviated to fit mobile topbar |
| Font size | 10px | Compact but readable |
| Scroll sync on mobile | ||
| Tap paragraph | Expand PDF if collapsed, then highlight | Auto-expand + scroll + flash |
| Tap annotation | Collapse PDF, scroll text to paragraph | Smart collapse after showing match |
/* Three mutually exclusive modes: + * + * 1. Lesen (read) — this spec. Flowing prose, dimmed annotations, no editing. + * 2. Bearbeiten (edit) — annotation-transcription-final-spec. Block cards, contenteditable. + * 3. Annotieren — yellow comment annotations on PDF. Separate button, not in segmented control. + * + * The segmented control only contains Lesen + Bearbeiten. + * Annotieren is a separate button that, when active, deselects both Lesen and Bearbeiten + * (both appear deselected/dimmed in the segmented control). + * + * When the user clicks Annotieren while in read/transcribe mode: + * → Enter annotate mode, both segmented items dim + * When the user clicks a segmented item while in annotate mode: + * → Exit annotate mode, enter the selected mode + * + * State: let mode: 'read' | 'transcribe' | 'annotate' = $state(...) + * Default: 'read' if blocks.length > 0, else 'transcribe' + * The "Annotieren" button is hidden if !canAnnotate || !isPdf */+
| Element | Value | Notes |
|---|---|---|
| Segmented control | ||
| Items | "Lesen" | "Bearbeiten" | Mobile: "Lesen" | "Bearb." |
| Active style | bg:navy, color:#fff | Rounded within the pill |
| Inactive style | bg:transparent, color:muted | Hover: bg:sand |
| Dimmed style | Both items at opacity .5 | Only when annotate mode is active |
| Disabled (Lesen) | opacity .35, cursor not-allowed | When no transcription blocks exist |
| Annotieren button | ||
| Default | Ghost style (border:muted) | Same as current topbar button |
| Active | bg:navy, color:#fff | Filled state when annotate mode on |
| Visibility | canAnnotate && isPdf | Hidden for non-PDF documents |
| Accessibility | ||
| Segmented | role="tablist", children role="tab" | aria-selected on active tab |
| Annotieren | aria-pressed={annotateMode} | Toggle button semantics |
| Disabled tab | aria-disabled="true", tabindex="-1" | Not focusable when no blocks |
Read mode is the default view for documents that have transcription blocks. It reuses the same side-by-side split layout as transcribe mode but replaces the editable block cards with flowing serif prose. The goal is a distraction-free reading experience that still lets users compare handwriting with typed text.
+ +The document detail page manages a single mode state that governs the entire view:
let mode: 'read' | 'transcribe' | 'annotate' = $state( + blocks.length > 0 ? 'read' : 'transcribe' +);+
mode === 'read' → this spec (flowing prose, dimmed annotations, no editing)mode === 'transcribe' → annotation-transcription-final-spec (block cards, contenteditable)mode === 'annotate' → yellow comment annotations on PDF'read' and 'transcribe'.'annotate' on/off. When entering annotate mode, the previous mode (read or transcribe) is stored so the user returns to it when exiting.| Component | Purpose |
|---|---|
TranscriptionReadView.svelte | Right panel content in read mode. Renders transcription blocks as flowing prose (<article> with <p> per block). Handles scroll-sync click handlers. |
ModeSwitcher.svelte | Segmented control (Lesen | Bearbeiten). Props: mode (bindable), hasBlocks (disables Lesen when false). Emits mode changes. |
| Component | Change |
|---|---|
DocumentTopBar.svelte | Replace the Transkribieren button with ModeSwitcher. Keep the Annotieren button separate. Add mode bindable prop. |
[id]/+page.svelte | Add mode state. Conditionally render TranscriptionReadView vs TranscriptionEditView in the right panel based on mode. |
PdfAnnotationLayer.svelte | Accept dimmed prop. When true: annotation rects get opacity 0.3, no numbered badges, but remain clickable for scroll-sync. |
GET /api/documents/{id}/transcription-blocks (same endpoint as transcribe mode).<p data-block-id="{block.annotation_id}"> inside an <article> element.font-family: Tinos, Georgia, serif; font-size: 16px; line-height: 1.85.24px 32px for comfortable reading margins.[unleserlich] markers: detect via regex /\[unleserlich\]/g and wrap in <em class="text-ink-2 italic text-[0.9em]">.highlight-annotation event with the annotation_id.flash-fade, 1.5s ease-out).highlight-paragraph with the annotation_id. The text panel scrolls the matching paragraph into view and applies a background highlight that fades.scrollIntoView({ behavior: 'smooth', block: 'center' }) for both directions.background rgba(0,199,177,.10) → transparent over 1.5s..ann-num elements are hidden via display: none)."{n} Abschnitte · Zuletzt bearbeitet: {userName}, {HH:mm}"updated_at across all transcription blocks for this document.updated_by field of that most recently updated block.transcription_status_sections, transcription_status_last_edited.70px. Shows a miniature scan preview.mode_edit_short).15px (slightly larger than desktop) with line-height: 1.9.transcription_blocks count is 0, “Lesen” tab is disabled (opacity: 0.35, cursor: not-allowed, aria-disabled="true").'transcribe'.| 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 und tippe den Text ab, um eine Transkription zu erstellen. | Draw regions on the scan and type the text to create a transcription. |
scan_expand | Scan vergrößern | Expand scan |
scan_collapse | Scan verkleinern | Collapse scan |
role="tablist" with role="tab" children, aria-selected on active tab.aria-disabled="true", tabindex="-1".<article> wrapping <p> elements. No contenteditable.role="button", tabindex="0", aria-label="Abschnitt N — klicken um Scan-Position anzuzeigen".role="button", aria-expanded="{expanded}", aria-label="Scan {expanded ? 'verkleinern' : 'vergrößern'}".prefers-reduced-motion: skip the 1.5s fade, apply instant highlight that disappears after 200ms.