Compare commits

...

115 Commits

Author SHA1 Message Date
Marcel
676d3cb6a7 fix(pdf): prevent scroll-sync effect from hijacking page navigation
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
The scroll-sync $effect was re-triggering on every dependency change
(including currentPage), forcing the PDF back to the annotation's page
when the user clicked next/prev. Fix: track prevActiveAnnotationId
and only scroll when the active annotation actually changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:27:52 +02:00
Marcel
d389dc2023 feat(annotations): dim non-active annotations when a block is focused
When activeAnnotationId is set, the active annotation stays at full
opacity with a highlight box-shadow, while all other annotations fade
to 30% opacity (300ms ease transition). When no block is focused,
all annotations show at full opacity.

Prop chain: activeAnnotationId flows from PdfViewer → AnnotationLayer.

2 new tests (RED/GREEN):
- dims non-active annotations when activeAnnotationId is set
- shows all at full opacity when no activeAnnotationId

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:26:02 +02:00
Marcel
b4212f5e86 feat(transcription): mobile stacked layout + cross-page scroll-sync
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
Mobile layout (< 768px):
- Split view stacks vertically: PDF top (min 40vh), blocks below
- Blocks panel gets border-top instead of border-left
- PDF remains interactive for drawing in stacked mode

Scroll-sync (block → PDF):
- Clicking a block sets activeAnnotationId
- PdfViewer effect watches activeAnnotationId, navigates to the
  annotation's page if different from current, then scrolls the
  annotation element into view (double-rAF for async render timing)
- Works across pages: block on page 3 navigates PDF to page 3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:13:27 +02:00
Marcel
c22f2e41b1 fix(transcription): replace broken HTML5 drag with pointer-based drag
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
HTML5 drag-and-drop didn't work — the grip handle couldn't initiate
drag properly. Replace with pointer event-based drag:

- Grip handle pointerdown starts drag, captures pointer
- Pointermove tracks offset, shows floaty style (shadow, scale, ring)
- Turquoise drop indicator line appears between blocks at cursor position
- Pointerup finalizes: reorders array and calls PUT /reorder endpoint

Visual feedback:
- Dragged block: shadow-xl, ring-2 ring-turquoise/40, scale 1.02, opacity 0.9
- Drop indicator: turquoise h-1 rounded bar between blocks

6 new TranscriptionEditView tests:
- renders blocks in sort order
- shows next-block CTA
- shows empty state
- move-up disabled on first block
- move-down disabled on last block
- drag handle present on each block

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:07:42 +02:00
Marcel
7d2d615e0c feat(transcription): add drag-and-drop + arrow button reordering
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
TranscriptionBlock:
- Desktop: grip handle (⠿) on left side, serves as drag handle
- Mobile (<768px): ▲/▼ arrow buttons (44px tap targets) replace grip
- isFirst/isLast disable boundary arrows
- onMoveUp/onMoveDown callbacks for arrow button clicks

TranscriptionEditView:
- HTML5 drag-and-drop on block wrappers (only initiates from grip handle)
- Dragged block shows 40% opacity
- On drop: reorder array and call PUT /reorder endpoint
- Arrow handlers: swap adjacent blocks and call reorder endpoint

5 new tests:
- drag handle element present
- move-up disabled when isFirst
- move-down disabled when isLast
- onMoveUp fires on click
- onMoveDown fires on click

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:00:52 +02:00
Marcel
4a88b3ba82 feat(transcription): add dashed next-block CTA below block list
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
Shows a muted dashed-outline box after the last block:
"Markiere eine weitere Passage im Scan, um Block N anzulegen"
Guides new users on how to create additional blocks.

Matches the spec's empty block CTA design (S1, bottom of block list).
i18n key transcription_next_block_cta added for de/en/es.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 22:52:15 +02:00
Marcel
6dc81ef2e3 fix(ui): match delete icon size + add cursor-pointer to interactive elements
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
- Comment delete icon: h-3 w-3 → h-4 w-4 (matches block delete icon)
- Add cursor-pointer to: comment delete button, Kommentieren button,
  block delete button, own-comment click-to-edit text
- Add title tooltip on comment delete button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 22:46:41 +02:00
Marcel
cef1810700 fix(comments): stop Escape propagation in edit mode
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
Pressing Escape while editing a comment now only cancels the edit,
without propagating to the parent (which closes the transcribe panel).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 22:43:37 +02:00
Marcel
351f31b183 feat(comments): inline edit on click + trash icon for own comments
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
Own comments:
- Click the text to open inline edit (textarea replaces text)
- Enter saves, Escape cancels
- Small trash icon always visible in bottom-right corner
- Hover on text shows cursor-text + subtle bg highlight

Other people's comments: read-only, no edit/delete affordances.

Re-add currentUserId prop chain for ownership check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 22:42:24 +02:00
Marcel
e6432846a1 fix(topbar): use brand navy for transcribe button, not turquoise
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
Transcribe button now uses border-primary/bg-primary/text-primary-fg
matching the other action buttons (Bearbeiten). Turquoise is reserved
for annotation overlays and block focus borders on the PDF.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 22:35:57 +02:00
Marcel
a66bec1971 fix(comments): increase text size for readability
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
Bump comment body and quote from text-xs (12px) to text-sm (14px).
Bump author name from text-xs to text-sm, timestamp from 10px to text-xs.
Improves readability especially for 60+ target users.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 22:33:42 +02:00
Marcel
82d5a34f76 fix(comments): use semantic tokens for comment box dark mode
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
Replace hardcoded Tailwind orange colors with semantic tokens:
border-accent, bg-muted, text-ink-2 — adapts to light/dark mode
via CSS custom properties instead of Tailwind dark: prefix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 22:32:01 +02:00
Marcel
3d086bd1fb fix(transcription): auto-capture quote on text selection, smart comment button
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
- Quote captured automatically on mouseup in textarea (no button needed)
  Selection is held in state and pre-fills the comment input
- "Kommentieren" button only shown when zero comments exist
  When comments are present, the input is already visible — button is noise
- Chat bubble icon added to Kommentieren button for visual consistency

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 22:30:13 +02:00
Marcel
e384c87eef refactor(comments): streamline input — Enter to send, no buttons
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
- MentionEditor: Enter sends (Shift+Enter for newline), remove @ button
- CommentThread: remove send button, full-width input, always show
  input when comments exist (no need to click Kommentieren first)
- TranscriptionBlock: remove border-t above comment section (orange
  background provides enough visual separation)
- Update placeholder in all languages to hint @mention and Enter to send

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 22:25:46 +02:00
Marcel
f09b605752 refactor(comments): flat compact comment thread matching spec design
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
Rework CommentThread.svelte to match the annotation-transcription spec:
- Flat message list (no nested reply threading)
- Compact inline style: orange left border, tinted background
- Chat bubble icon (💬) with comment count header
- Avatar circles with author initials
- Quoted text extracted and rendered as italic left-bordered snippet
- Simple MentionEditor input at bottom (keeps @mention support)
- Removed: reply-to-specific threading, edit/delete buttons, nesting

Remove dead components no longer used after annotate mode removal:
- AnnotationCommentPanel, AnnotationSidePanel, AnnotateHintStrip
- PanelDiscussion, PanelHistory, PanelMetadata, PanelTranscription
- Associated spec files

Simplify prop chain: remove currentUserId, canAdmin, targetCommentId
from CommentThread, TranscriptionBlock, TranscriptionEditView.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 22:18:24 +02:00
Marcel
193bd73af1 fix(i18n): translate comment timestamps and edited label
Some checks failed
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
Replace hardcoded German strings in CommentThread.timeAgo() with
Paraglide i18n keys: comment_time_just_now, comment_time_minutes,
comment_time_hours, comment_time_days.

Update comment_edited_label from "· bearbeitet" to "(Bearbeitet)"
for the new single-timestamp design. All three languages: de/en/es.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 22:09:26 +02:00
Marcel
cab017a2ce fix(comments): show either created or edited timestamp, not both
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
Unedited comments show "vor X Minuten". Edited comments show
"vor X Minuten (Bearbeitet)" using the updatedAt timestamp.
Reduces visual noise in comment threads.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 22:04:51 +02:00
Marcel
be4f1ed73b fix(transcription): always show comment list, compose box on demand
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
Comments were only visible after clicking "Kommentieren". Now:
- Comment list always renders (CommentThread with loadOnMount=true)
- Compose box hidden by default (showCompose prop on CommentThread)
- Clicking "Kommentieren" sets commentOpen=true → shows compose box
- Closing hides compose box but comments remain visible

This separates "viewing comments" (always) from "writing a comment"
(on demand via Kommentieren button).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 22:02:15 +02:00
Marcel
6475ebcc60 fix(transcription): auto-expand comment thread when block has comments
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 1m20s
CI / Backend Unit Tests (pull_request) Failing after 2m38s
CI / E2E Tests (pull_request) Has been cancelled
Comments were only shown after clicking "Kommentieren". Now:
- Load comment counts per block when blocks are loaded
- Pass commentCount prop to TranscriptionBlock
- If commentCount > 0, the comment thread is expanded by default
- If commentCount is 0, thread stays collapsed behind the button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 21:50:37 +02:00
Marcel
d8830b5a8e fix(transcription): use local state for textarea to prevent flicker on save
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 1m28s
CI / Backend Unit Tests (pull_request) Failing after 2m32s
CI / E2E Tests (pull_request) Failing after 1h31m23s
The textarea value was bound directly to the text prop from the parent.
When auto-save completed and updated the blocks array, Svelte re-rendered
the textarea with the prop value, causing the text to disappear briefly.

Fix: use localText state initialized from prop, synced only when blockId
changes (not on save responses). Typing updates localText immediately,
parent re-renders from save don't overwrite the local value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 21:47:00 +02:00
Marcel
569a13e1b1 feat(transcription): show block numbers on PDF annotation overlays
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 1m26s
CI / Backend Unit Tests (pull_request) Failing after 2m29s
CI / E2E Tests (pull_request) Failing after 1h28m33s
Add blockNumbers prop through AnnotationLayer → PdfViewer → DocumentViewer.
Each turquoise annotation rectangle now shows a numbered badge (top-left,
matching the block card number in the right panel).

Block numbers are derived from sorted transcriptionBlocks, mapped by
annotationId, creating a visual link between PDF regions and block cards.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 21:39:11 +02:00
Marcel
7ad852dd52 fix(comments): remove empty state hint from CommentThread
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 1m21s
CI / Backend Unit Tests (pull_request) Failing after 2m39s
CI / E2E Tests (pull_request) Failing after 1h29m6s
The "Noch keine Kommentare" hint with icon is unnecessary — users
already clicked "Kommentieren" to open the thread, so showing them
an empty state just adds noise. Jump straight to the compose box.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 21:34:22 +02:00
Marcel
03d76863cb fix: clicking annotation enters transcribe mode and scrolls to block
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 1m27s
CI / Backend Unit Tests (pull_request) Failing after 2m37s
CI / E2E Tests (pull_request) Failing after 1h28m16s
When clicking a turquoise annotation on the PDF:
- If not in transcribe mode, enters it and loads blocks
- Waits for DOM render, then scrolls to the corresponding block card

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 21:18:43 +02:00
Marcel
f3c29ffe58 refactor: remove legacy annotate mode — transcription replaces it
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Backend Unit Tests (pull_request) Failing after 4m26s
CI / Unit & Component Tests (pull_request) Failing after 14m40s
CI / E2E Tests (pull_request) Failing after 1h26m51s
The yellow annotation+comment system is now redundant. Transcription
blocks handle the same use case (mark region → discuss) but better,
because they also produce a transcription.

Removed:
- annotateMode state and all wiring through page/topbar/viewer/pdfviewer
- Annotate/Stop annotate buttons from DocumentTopBar
- AnnotateHintStrip import and rendering
- AnnotationSidePanel from document detail page
- canAnnotate prop from DocumentTopBar
- Color picker from PdfViewer
- Comment count badges and loadCommentCounts from PdfViewer
- Delete button from AnnotationLayer (blocks own annotation lifecycle)
- dimColor prop from AnnotationLayer

Simplified:
- AnnotationLayer: only canDraw + color + onDraw + onAnnotationClick
- PdfViewer: only draws in transcribeMode with turquoise
- Clicking annotation in transcribe mode scrolls to corresponding block
- canComment derived from canWrite (no longer needs canAnnotate)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 21:17:27 +02:00
Marcel
8c26876345 feat(transcription): add block-level comment threads with quote support
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 1m33s
CI / Backend Unit Tests (pull_request) Failing after 2m47s
CI / E2E Tests (pull_request) Failing after 19m44s
TranscriptionBlock.svelte:
- "Kommentieren" button opens expandable comment thread per block
- Text selection in textarea captured as quoted text (> "...") prefix
- Quote hint "Text markieren für Zitat" shown when block is active/focused
- Comment thread uses existing CommentThread with blockId prop

CommentThread.svelte:
- Add blockId prop for block-level comments URL routing
- Add quotedText prop — pre-fills comment input with markdown blockquote
- commentsBase now supports 3 URL patterns: document, annotation, block

TranscriptionEditView.svelte:
- Pass canComment + currentUserId through to block components

3 new frontend tests:
- Kommentieren button present
- Quote hint shown when active
- Quote hint hidden when inactive

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 21:05:39 +02:00
Marcel
da43cadb0a feat(comments): add block-level comment endpoints with TDD
RED/GREEN for CommentService:
- getCommentsForBlock(blockId): returns root comments filtered by blockId
- postBlockComment(documentId, blockId, content, mentions, author): creates
  comment with block_id set

RED/GREEN for CommentController:
- GET /api/documents/{docId}/transcription-blocks/{blockId}/comments
- POST /api/documents/{docId}/transcription-blocks/{blockId}/comments
- POST .../comments/{commentId}/replies (reuses existing replyToComment)

4 new tests: 2 service unit tests + 2 controller integration tests
All 25 CommentServiceTest + 24 CommentControllerTest green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 21:01:02 +02:00
Marcel
3b2d905041 fix(transcription): reload annotations after drawing block on PDF
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 1m28s
CI / Backend Unit Tests (pull_request) Failing after 2m34s
CI / E2E Tests (pull_request) Failing after 1h21m56s
After onTranscriptionDraw callback completes, reload the annotation
list from the backend so the turquoise rectangle overlay appears
immediately on the PDF page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 20:49:01 +02:00
Marcel
7036f18b25 test(annotations): add tests for dimColor and crosshair cursor
- dims annotations matching dimColor (opacity 0.3, pointer-events none)
- does not dim annotations that don't match dimColor
- has crosshair cursor when canAnnotate is true

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 20:47:21 +02:00
Marcel
99e2e6e5c1 feat(transcription): enable drawing turquoise rectangles on PDF to create blocks
Some checks failed
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 1m29s
CI / Backend Unit Tests (pull_request) Failing after 2m40s
CI / E2E Tests (pull_request) Failing after 1h22m53s
- AnnotationLayer: add dimColor prop — annotations matching dim color
  render at 30% opacity with pointer-events disabled (300ms transition)
- PdfViewer: add transcribeMode prop, derived drawingEnabled/drawColor;
  in transcribe mode draws with turquoise (#00C7B1), routes draw events
  to onTranscriptionDraw callback instead of annotation endpoint
- DocumentViewer: pass through transcribeMode + onTranscriptionDraw
- Document detail page: createBlockFromDraw() POSTs to transcription
  blocks API on draw completion, adds created block to list
- Mode-based dimming: yellow annotations dim in transcribe mode,
  turquoise annotations dim in annotate mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 20:44:45 +02:00
Marcel
aaffee2804 test(frontend): add Vitest specs for DocumentMetadataDrawer and TranscriptionBlock
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 53s
CI / Backend Unit Tests (pull_request) Failing after 58s
CI / E2E Tests (pull_request) Failing after 26s
DocumentMetadataDrawer (10 tests):
  - Renders formatted date, dash for null date
  - Renders location, dash for null location
  - Renders translated status label
  - Person cards as links to /persons/{id}
  - Receiver links, empty state for no persons
  - Tag chips as links, empty state for no tags

TranscriptionBlock (12 tests):
  - Renders block number, text, optional label
  - Save states: idle (nothing), saving (pulse), saved (checkmark), error (retry)
  - Active turquoise border, error red border
  - onTextChange fires on typing, onFocus fires on click

Fixes @Felix/@Sara: "Frontend component tests still missing"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 20:38:53 +02:00
Marcel
18c6bca2dd refactor(transcription): split reorderBlocks for command-query separation
Some checks failed
CI / Unit & Component Tests (push) Failing after 1m46s
CI / Backend Unit Tests (push) Failing after 2m26s
CI / E2E Tests (push) Has started running
CI / Unit & Component Tests (pull_request) Failing after 1m11s
CI / Backend Unit Tests (pull_request) Failing after 2m24s
CI / E2E Tests (pull_request) Failing after 1h25m31s
TranscriptionService.reorderBlocks() now returns void (command).
Controller calls listBlocks() separately after reorder (query).
Updated test to match new void signature.

Fixes @Felix: "reorderBlocks violates command-query separation"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 20:32:44 +02:00
Marcel
d13f6f69d5 fix(migration): add CHECK constraint on text length (defense in depth)
V18: text column now has CHECK (length(text) <= 10000) to enforce
the 10,000 character limit at the database level, complementing
the application-level enforcement in TranscriptionService.sanitizeText().

Fixes @Nora: "DB constraint catches anything the application misses"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 20:29:41 +02:00
Marcel
052f70e871 fix(transcription): use navigator.sendBeacon for beforeunload save
Replace async executeSave in beforeunload handler with
navigator.sendBeacon — synchronous and reliable for page unload.
Sends pending text as JSON blob to the block update endpoint.

Fixes @Sara: "beforeunload handlers cannot reliably await async"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 20:28:37 +02:00
Marcel
a3fbcf346b fix(ui): semantic turquoise tokens, badge styling, saved fade animation
- Add turquoise/turquoise-fg semantic color tokens to layout.css
  (light + dark mode), replacing all hardcoded #00C7B1 in components
- Bump Details toggle from text-xs to text-sm for visual hierarchy
- Block badge: navy → turquoise, overlapping top-left card border
  with absolute positioning to visually link PDF annotation badges
- Saved indicator: smooth 300ms opacity fade before removal
  (new 'fading' state in SaveState type)
- Transcribe buttons: use border-turquoise/bg-turquoise/text-turquoise-fg

Fixes @Leonie concerns: toggle visual weight, semantic tokens,
badge styling, saved fade animation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 20:26:41 +02:00
Marcel
b21778b3d1 refactor(types): extract TranscriptionBlockData to shared types
Move duplicated type definition from TranscriptionEditView.svelte
and +page.svelte into $lib/types.ts for single source of truth.

Fixes @Felix: "Consider extracting the TranscriptionBlockData type"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 20:22:35 +02:00
Marcel
51c799e20e test(transcription): add TranscriptionServiceTest with 13 unit tests
Some checks failed
CI / Unit & Component Tests (push) Failing after 1m20s
CI / Backend Unit Tests (push) Failing after 2m32s
CI / E2E Tests (push) Failing after 1h22m5s
CI / Unit & Component Tests (pull_request) Failing after 1m35s
CI / Backend Unit Tests (pull_request) Failing after 2m46s
CI / E2E Tests (pull_request) Failing after 1h24m55s
Tests cover: getBlock (found, not found), createBlock (creates annotation +
block + version), updateBlock (text + label), deleteBlock (deletes block +
annotation, not found), reorderBlocks, getBlockHistory, sanitizeText (null,
max length, plain text preservation), listBlocks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 11:46:16 +02:00
Marcel
6463a32dfc fix: address PR review feedback — security, architecture, dead code
Fixes from PR #178 review:

Migration fixes:
- V18/V19: fix FK references from app_users to users (correct table name)
- V18: change annotation_id FK from ON DELETE CASCADE to ON DELETE RESTRICT
  (block is aggregate root, cascade flows from block, not annotation)

Backend fixes:
- TranscriptionService.deleteBlock(): remove userId param, delete block first
  then annotation directly via repository (no ownership check — block owns annotation)
- TranscriptionService.sanitizeText(): remove flawed regex HTML stripping,
  textarea content is plain text by design — just enforce max length
- TranscriptionBlockController.requireUserId(): throw DomainException.unauthorized()
  instead of silently returning null on auth failure
- CreateTranscriptionBlockDTO: add @Min/@Positive validation on coordinates
- Add @Slf4j logging to TranscriptionService for create/delete operations

Frontend fixes:
- Delete DocumentBottomPanel.svelte entirely (issue #175 requirement)
- Remove redundant mode exclusivity $effect (handled at toggle call sites)
- Remove dead handleCommentClick + onCommentClick prop (comments are future work)
- Remove quote hint UI (depends on comment feature)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 11:43:35 +02:00
Marcel
1efd3d8e23 feat(transcription): add frontend transcription editing UI (#176)
Some checks failed
CI / Unit & Component Tests (push) Failing after 1m27s
CI / Backend Unit Tests (push) Failing after 2m40s
CI / E2E Tests (push) Failing after 4m44s
CI / Unit & Component Tests (pull_request) Failing after 1m21s
CI / Backend Unit Tests (pull_request) Failing after 2m27s
CI / E2E Tests (pull_request) Failing after 4m47s
TranscriptionBlock.svelte: editable block card with auto-resize textarea,
  per-block save indicator, turquoise focus border, delete with confirmation
TranscriptionEditView.svelte: right panel with sorted block list,
  debounced auto-save (1.5s), beforeunload flush, empty state CTA
DocumentTopBar: add Transcribe/Done toggle with turquoise styling,
  mode exclusivity (transcribe and annotate mutually exclusive)
Document detail page: split view in transcribe mode (PDF left, blocks right),
  load/save/delete blocks via fetch, block focus syncs to annotation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 11:34:01 +02:00
Marcel
5211e0b9f7 feat(topbar): add expandable metadata drawer with Details toggle (#175)
- DocumentMetadataDrawer: 3-column grid (≥1024px), single-column mobile
  Shows document date, location, status, person cards, tag chips
  Person names link to /persons/{id}, tags link to filtered search
  Empty states for missing persons/tags, receiver cap with expand button
- DocumentTopBar: "Details" toggle button with animated SVG chevron
  44×44px tap target, aria-expanded, Svelte slide transition
  Semantic color tokens for dark mode compatibility
- Remove DocumentBottomPanel from document detail page
  Bottom panel replaced by topbar drawer for metadata access
  Simplify +page.server.ts (remove comments loading)
  Update page.server.spec.ts for new load signature

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 11:22:38 +02:00
Marcel
234f83c40b feat(i18n): add translation keys for metadata drawer and transcription
Keys for #175: doc_details_toggle, section headings, field labels, empty states
Keys for #176: transcription mode, block editing, save states, comments, drawing hints
Error codes: TRANSCRIPTION_BLOCK_NOT_FOUND, TRANSCRIPTION_BLOCK_CONFLICT
All three languages: de, en, es

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 11:16:22 +02:00
Marcel
a46b1a2e84 feat(transcription): add backend entities, service, and controller
TranscriptionBlock entity with @Version optimistic locking
TranscriptionBlockVersion for edit history
TranscriptionService facade: CRUD, reorder, version history
TranscriptionBlockController: REST endpoints under /api/documents/{docId}/transcription-blocks
DTOs: Create, Update, Reorder
ErrorCode: TRANSCRIPTION_BLOCK_NOT_FOUND, TRANSCRIPTION_BLOCK_CONFLICT
DocumentComment: add block_id field for block-level comment threads

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 11:13:13 +02:00
Marcel
5231476c27 feat(transcription): add Flyway migrations for transcription blocks
V18: transcription_blocks table with optimistic locking version column
V19: transcription_block_versions for edit history capture
V20: add block_id FK to document_comments for block-level threads

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 11:12:08 +02:00
Marcel
46d64f50a5 docs(specs): add final specs for transcription feature
Some checks failed
CI / Unit & Component Tests (push) Failing after 1m52s
CI / Backend Unit Tests (push) Failing after 2m54s
CI / E2E Tests (push) Failing after 1h13m6s
Three final UI/UX specs for the collaborative transcription system:
- expandable-metadata-header-spec: labeled "Details" toggle with drawer
- annotation-transcription-final-spec: annotation-backed transcription with block-level comment threads
- transcription-read-mode-final-spec: clean split read mode with flowing prose and scroll sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 09:27:22 +02:00
Marcel
1a57ec2036 feat(topbar): add divider between sender/receiver block and action buttons
Some checks failed
CI / E2E Tests (pull_request) Failing after 1h16m31s
CI / Unit & Component Tests (push) Failing after 1m30s
CI / Backend Unit Tests (push) Failing after 2m29s
CI / Unit & Component Tests (pull_request) Failing after 1m30s
CI / Backend Unit Tests (pull_request) Failing after 2m29s
CI / E2E Tests (push) Failing after 1h12m24s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 11:52:38 +02:00
Marcel
e362bc4977 feat(topbar): remove DocumentStatusChip — status dot has no value for users
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 11:41:03 +02:00
Marcel
01ba0d4121 feat(topbar): make PersonChip a link to the person detail page
Consistent with the overflow pill popup which already linked to persons.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 11:40:18 +02:00
Marcel
2e6366faf7 feat(topbar): add topbar_overflow_suffix i18n key and use it in overflow pill button
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 11:39:34 +02:00
Marcel
9dd35999e0 fix(topbar): fix overflow pill popup clipped and hidden behind pdf viewer
Remove overflow-hidden from the main flex row — the inner min-w-0 flex-1
overflow-hidden title container already handles truncation. Add relative z-10
to the topbar wrapper so it stacks above the pdf viewer. Pill is now hidden
below md (matching the chip row) and shows +N at md, +N weitere at lg+.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 11:36:41 +02:00
Marcel
e94f43264c fix(topbar): add overflow-hidden to flex row so long titles truncate instead of pushing kebab off-screen
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 10:23:32 +02:00
Marcel
da7f94de84 feat(topbar): hide sender→receiver chip row below md to make room for buttons
Some checks failed
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 10:22:05 +02:00
Marcel
3f0b686963 feat(topbar): always show annotate-stop button — primary action, not hidden in kebab
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 10:16:38 +02:00
Marcel
1e9ef63191 refactor(topbar): extract annotate/download actions as Svelte snippets, render in desktop + kebab
Some checks failed
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 10:15:31 +02:00
Marcel
51348ad26a feat(topbar): add mobile kebab menu for annotate/download actions hidden below md
Some checks failed
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 10:11:50 +02:00
Marcel
dba1e2a8eb fix(topbar): use Long-Arrow-Right icon for sender→receiver separator
Some checks failed
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 10:05:03 +02:00
Marcel
654b1283c1 fix(topbar): replace → text char with degruyter arrow icon for reliable centering
Some checks failed
CI / E2E Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 09:59:43 +02:00
Marcel
c5b98af69b fix(topbar): center arrow glyph vertically with inline-flex items-center
Some checks failed
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 09:46:37 +02:00
Marcel
03e2382c8a feat(topbar): increase arrow to 30px and fix vertical alignment with leading-none
Some checks failed
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 09:37:28 +02:00
Marcel
528e1e05ea feat(topbar): increase sender→receiver arrow size for visibility
Some checks failed
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 09:35:30 +02:00
Marcel
c64abccf63 feat(i18n): add doc_panel_annotate_hint message key in de/en/es, use in AnnotateHintStrip
Some checks failed
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 09:30:21 +02:00
Marcel
47960b5028 feat(topbar): scale action button text and icons to match surrounding text size
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 09:23:31 +02:00
Marcel
7f2940f0f2 feat(topbar): increase all font sizes and bar height by another 25%
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 1m23s
CI / Backend Unit Tests (pull_request) Failing after 2m38s
CI / E2E Tests (pull_request) Failing after 1h14m58s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 09:12:27 +02:00
Marcel
37d728b006 feat(topbar): increase all font sizes and bar height by 25% for legibility
Some checks failed
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Backend Unit Tests (pull_request) Failing after 2m38s
CI / Unit & Component Tests (push) Has been cancelled
CI / E2E Tests (pull_request) Failing after 1h15m22s
CI / Unit & Component Tests (pull_request) Failing after 1m40s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 09:09:59 +02:00
Marcel
965087b787 Revert "feat(topbar): double all font sizes and increase bar height for legibility"
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 1m22s
CI / Backend Unit Tests (pull_request) Failing after 2m35s
CI / E2E Tests (pull_request) Failing after 1h16m54s
This reverts commit 1d2e6d7b86.
2026-04-01 09:04:24 +02:00
Marcel
1d2e6d7b86 feat(topbar): double all font sizes and increase bar height for legibility
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 1m54s
CI / Backend Unit Tests (pull_request) Failing after 2m55s
CI / E2E Tests (pull_request) Failing after 1h12m45s
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 08:52:23 +02:00
Marcel
0c40e10743 fix(topbar): add role=group to OverflowPillButton outer div — a11y warning
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m32s
CI / Backend Unit Tests (pull_request) Failing after 3m5s
CI / E2E Tests (pull_request) Failing after 1h11m43s
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 23:17:11 +02:00
Marcel
358131ca34 feat(ui): replace DocumentTopBar with responsive orchestrator (issue #173)
- Accent bar, h-12/h-14 responsive height, 44×44px back link touch target
- PersonChipRow with sender→receivers chips, overflow pill button at ≥768px
- DocumentStatusChip dot-only at ≥768px
- Edit/annotate/download actions with annotateMode wiring
- AnnotateHintStrip below main row when annotateMode active
- status field added to Doc type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 23:11:11 +02:00
Marcel
c7af33b998 feat(ui): add OverflowPillButton — tooltip, Escape focus return, use:clickOutside
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 23:08:53 +02:00
Marcel
eafb566170 feat(ui): add PersonChipRow — sender→receivers chips, 2nd receiver hidden md:contents
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 23:00:32 +02:00
Marcel
624eb9e5d6 feat(ui): add OverflowPillDisplay — non-interactive aria-hidden +N span
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 22:58:47 +02:00
Marcel
7bd995a045 feat(ui): add AnnotateHintStrip — 18px hint strip, hidden md:flex, annotateMode gated
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 22:46:32 +02:00
Marcel
20dbe04d45 feat(ui): add DocumentStatusChip — dot-only status indicator, hidden md:block
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 22:43:15 +02:00
Marcel
c9211b3061 feat(ui): add PersonChip component — avatar initials, abbreviated prop
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 22:42:01 +02:00
Marcel
27254fb0ac feat(utils): add personFormat utility module with 6 pure functions (TDD)
abbreviateName, formatXsMeta, personAvatarColor (djb2), formatDate,
statusDotClass, statusLabel — 27 tests all green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 22:39:44 +02:00
Marcel
b5a68e69e2 refactor(actions): extract clickOutside to shared module, replace 5 inline copies
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 22:34:54 +02:00
Marcel
b1e959412f feat(frontend): add xs breakpoint (375px) to Tailwind @theme
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 22:02:46 +02:00
Marcel
19035fbeab fix(dashboard): move right column first in DOM for mobile-first upload zone
Some checks failed
CI / Backend Unit Tests (pull_request) Failing after 2m37s
CI / E2E Tests (pull_request) Failing after 1h12m25s
CI / Unit & Component Tests (push) Failing after 1m21s
CI / Backend Unit Tests (push) Failing after 2m30s
CI / E2E Tests (push) Failing after 6m59s
CI / Unit & Component Tests (pull_request) Failing after 1m41s
On small screens the upload zone now appears above recent docs.
lg:order-last keeps it visually on the right at desktop width.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 20:42:37 +02:00
Marcel
79faee554a fix(dashboard): reduce incomplete docs widget from 5 to 3 items to prevent scroll
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 20:40:02 +02:00
Marcel
5adef7bec5 refactor(dashboard): delete DashboardMentions component — notifications page exists
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 20:29:03 +02:00
Marcel
595c2eb987 test(e2e): Classic Split — right column absent for read-only user, present for admin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 20:27:39 +02:00
Marcel
518019f099 chore(e2e): gitignore Playwright auth state — regenerate in CI via auth.setup.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 20:26:01 +02:00
Marcel
38b8804b17 style(dashboard): bump stats footnote from text-xs to text-sm for legibility
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 20:24:47 +02:00
Marcel
81ed1ce3ed test(admin): replace setTimeout timing hack with vi.waitFor in layout specs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 20:23:05 +02:00
Marcel
92e7aa127c feat(dashboard): Classic Split — 2-col layout, remove DashboardMentions widget
Some checks failed
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Restructures the dashboard to a lg:grid-cols-[1fr_300px] split:
- Left column: DashboardRecentDocuments (with stats footnote)
- Right column: DropZone (canWrite) + DashboardNeedsMetadata (flex-1)

Adds showRightColumn guard (canWrite || incompleteDocs.length > 0) so
read-only users with a complete archive never see an empty 300px ghost
column. DashboardMentions is removed from the page; the file is kept.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 19:36:36 +02:00
Marcel
f618364632 feat(dashboard): add stats footnote and min-h touch target to DashboardRecentDocuments
Adds stats?: StatsDTO | null prop; renders a quiet footnote showing total
document and person counts. Guards on stats?.totalDocuments != null so
zero is shown but the footnote is absent when stats fails. Adds
min-h-[44px] to doc rows for WCAG 2.5.5 touch target compliance.
Adds dashboard_stats_documents/persons i18n keys in de/en/es.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 19:29:00 +02:00
Marcel
20923d04b6 feat(dashboard): replace notifications fetch with stats in server load
Removes /api/notifications from the dashboard widget fetches and replaces
it with /api/stats so the page no longer needs to own notification data.
Returns stats: StatsDTO | null (null on failure) instead of mentions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 19:23:31 +02:00
Marcel
6d61297182 fix(tests): fix 27 failing frontend unit tests
Six categories of breakage:

1. date.ts — add formatGermanDateInput(raw: string): string as a pure
   function covering both digit-stream auto-dot and manual-dot-with-padding
   modes. Refactor handleGermanDateInput to delegate to it. Fixes 16 failures
   in date.spec.ts where the function was imported but didn't exist.

2. Admin layout specs (groups/tags/users) — $effect fires on initial mount
   with manualCollapse=false, so the spy captured 'false' before the click's
   effect ran. Fix: move spy setup after render(), add await setTimeout(0) to
   flush Svelte effects before asserting.

3. DashboardMentions — component now renders a persistent
   "Benachrichtigungsverlauf ansehen" link, making getByRole('link') strict-
   mode violations. Fix: scope link queries to the actor name, and check
   absence of the actor link (not all links) in the no-documentId test.

4. Conversations page — empty-state copy changed from "Wählen Sie zwei
   Personen aus" to "Korrespondenz durchsuchen". Update the test.

5. Login page — AuthHeader adds a second aria-label="Familienarchiv" link.
   Use .first() to avoid strict-mode violation.

6. Persons page — alias is rendered with German quotation marks „…" not
   straight quotes "…". Update the test string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 17:28:35 +02:00
Marcel
fb636e4152 fix(e2e): replace fragile .last() selector with data-testid on password form submit
The password-reset E2E test was using button[type="submit"].last() to target
the password change button on the profile page. The profile page has two submit
buttons with identical text, so .last() is layout-order-dependent and breaks
if the form order ever changes.

Add data-testid="submit-password" to PasswordChangeForm and use getByTestId()
in the test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 17:13:09 +02:00
Marcel
527d174e9c fix(focus-rings): remove broken [&_input]:focus selectors and fix error state focus-visible
Some checks failed
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- Strip malformed [[&_input]:focus:*] class fragments from PersonTypeahead
  wrapper divs in both ConversationFilterBar components — PersonTypeahead
  manages its own focus ring; parent selectors were redundant and broken
- Fix WhoWhenSection error state: focus:ring-red-500 → focus-visible:ring-red-500
  so invalid date field ring no longer fires on mouse click

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 16:42:11 +02:00
Marcel
f1bf32ee05 feat(focus-rings): CommentThread selection highlight → dotted outline
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 1m19s
CI / Backend Unit Tests (pull_request) Failing after 2m29s
CI / E2E Tests (pull_request) Failing after 1h47m37s
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
ring-2 ring-accent (box-shadow) replaced with outline-2 outline-dotted
outline-accent — visually distinct from the focus ring (solid, navy/mint),
making selection state and keyboard focus clearly different

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 15:27:48 +02:00
Marcel
a5cc8fd16e feat(focus-rings): update interactive widgets to ring-focus-ring
PersonTypeahead, MentionEditor, PanelHistory, UserGroupsSection,
notifications filter buttons, CorrespondentSuggestionsDropdown:
replace ring-accent/ring-primary with ring-focus-ring

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 15:25:02 +02:00
Marcel
1541afd470 feat(focus-rings): update all form inputs and document components to ring-focus-ring
Replaces focus:border-ink, focus:ring-ink, focus:ring-primary, focus:ring-accent
patterns with focus-visible:ring-2 focus-visible:ring-focus-ring focus:outline-none
across: PersonEditForm, profile forms, admin forms, document sections,
conversation filter bars, persons/documents new forms

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 15:22:11 +02:00
Marcel
d0deb26065 feat(focus-rings): update auth and search inputs to ring-focus-ring
login, forgot-password, reset-password, persons search,
CorrespondenzFilterControls: replace focus:border-ink/ring-ink
with focus-visible:ring-2 focus-visible:ring-focus-ring

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 15:18:42 +02:00
Marcel
f04e4ffa8b feat(focus-rings): update header/nav components to ring-focus-ring
ThemeToggle, NotificationBell, LanguageSwitcher, UserMenu, AppNav:
replace focus-visible:ring-accent → focus-visible:ring-focus-ring

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 15:15:06 +02:00
Marcel
17889df220 feat(focus-rings): add --c-focus-ring token to CSS design system
Light: #012851 (brand-navy, 14:1 on white)
Dark:  #a1dcd8 (brand-mint, 9.2:1 on canvas)
- @theme inline mapping → Tailwind ring-focus-ring utility
- Global :focus-visible fallback in @layer base
- forced-colors fallback for Windows High Contrast mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 15:12:00 +02:00
Marcel
fe1121de65 test(focus-rings): add failing Playwright tests for --c-focus-ring token and element ring colors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 15:08:36 +02:00
Marcel
2004a80055 fix(a11y): UserMenu avatar bg-white/text-brand-navy — WCAG AA contrast
Some checks failed
CI / Unit & Component Tests (push) Failing after 1m21s
CI / Backend Unit Tests (push) Failing after 2m27s
CI / E2E Tests (push) Failing after 1h50m38s
CI / Unit & Component Tests (pull_request) Failing after 1m23s
CI / Backend Unit Tests (pull_request) Failing after 2m30s
CI / E2E Tests (pull_request) Failing after 1h53m14s
bg-brand-mint (#A6DAD8) on text-brand-navy (#012851) = 3.5:1, fails AA
for text-xs (12px). bg-white (#fff) on text-brand-navy = 14:1 AAA.
White also reads as a distinct shape against the navy header background.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 14:07:55 +02:00
Marcel
f70b5ae6bd fix(dark-mode): address PR #168 review blockers
Some checks failed
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
- AuthHeader: bg-brand-navy → bg-header (semantic token, responds to dark mode)
- header.spec.ts: add forgot-password AuthHeader tests (bg + axe)
- header.spec.ts: fix BRAND_NAVY comment — references --c-header, not --c-primary

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 13:30:00 +02:00
Marcel
12b8324245 chore: merge main into feat/issue-166 — resolve blue header conflicts
Some checks failed
CI / E2E Tests (pull_request) Failing after 1h51m28s
CI / Unit & Component Tests (pull_request) Failing after 1m30s
CI / Backend Unit Tests (pull_request) Failing after 2m23s
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- +layout.svelte: adopt main's blue header structure (accent stripe, no
  border-b, bg-header instead of bg-brand-navy)
- layout.css light mode: drop --c-nav-active (removed by main); set
  --c-header: #012851 (confirmed correct now that header is brand-navy)
- layout.css dark mode: drop --c-nav-active; keep navy PDF tokens and
  --c-header: #012851 from our branch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 12:24:20 +02:00
Marcel
a9b648454e fix(dark-mode): use bg-header on layout header; set --c-header to brand-navy
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 1m36s
CI / Backend Unit Tests (pull_request) Failing after 2m53s
CI / E2E Tests (pull_request) Failing after 1h51m31s
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
- Change header --c-header dark value from #01335e to #012851 (brand navy):
  #01335e gave 4.3:1 with ink-3 (WCAG AA fail); #012851 gives 4.99:1 (pass)
- Switch header element from bg-surface to bg-header so dark mode uses the
  independent --c-header token instead of inheriting the surface background
- Fix both dark blocks (media query and manual override) to stay in sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 11:53:14 +02:00
Marcel
938a4b07bf test(dark-mode): add failing test for --c-header token on header element
Header should use bg-header (rgb(1,51,94) = #01335e) in dark mode instead
of bg-surface. Currently fails because header still uses bg-surface.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 11:39:37 +02:00
Marcel
7e43bd43a4 feat(dark-mode): replace neutral tokens with navy-tinted palette + fix WCAG AA
- Replace neutral dark tokens (#0d0d0d, #1a1a1a, etc.) with navy-tinted
  values derived from brand-navy: canvas #010e1e, surface #011526,
  overlay #011e38, muted #011a30
- Fix --c-ink-3 WCAG AA failure in [data-theme='dark'] block:
  #6b7280 (3.2:1, fail) → #8b97a5 (7.1:1, AAA ✓)
- Add color-scheme: dark to both dark blocks for native OS scrollbar theming
- Update PDF viewer tokens to navy palette (bg #010e1e, ctrl #011526, text #f0efe9)
- Add --c-header token (#ffffff light / #01335e dark) for independent
  header surface control; mapped to --color-header in @theme inline
- Fix EntityNav contrast: text-white/30 → /50 (heading) and text-white/20
  → /50 (inactive count badges) to pass WCAG AA 4.5:1 on bg-brand-navy

Closes #166

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 11:37:30 +02:00
Marcel
56926efd03 test(a11y): add dark mode axe + color-scheme tests for issue #166
Two failing test suites that encode the regressions this issue fixes:
- accessibility.spec.ts: axe wcag2aa in both prefers-color-scheme:dark
  and data-theme='dark' — fails because --c-ink-3:#6b7280 on #1a1a1a = 3.2:1
- theme.spec.ts: color-scheme computed property is 'dark' in dark mode
  — fails because neither dark CSS block sets color-scheme: dark

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 11:22:35 +02:00
Marcel
a6ee444f3b docs(specs): add focus rings design spec for issue #167
Spec covers the --c-focus-ring token definition, full audit of all 19
affected files, WCAG 2.4.11 analysis, element-by-element mockups (light
and dark), and exact CSS/Tailwind diffs ready for implementation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 10:33:50 +02:00
Marcel
2dd73cf594 test(LanguageSwitcher): add Vitest unit tests for inverted prop
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m27s
CI / Backend Unit Tests (pull_request) Failing after 2m38s
CI / E2E Tests (pull_request) Failing after 1h49m47s
CI / Unit & Component Tests (push) Failing after 1m30s
CI / Backend Unit Tests (push) Failing after 2m28s
CI / E2E Tests (push) Failing after 1h52m7s
Covers active/inactive class tokens for both inverted=true and inverted=false,
and verifies setLocale is called with the correct locale on button click.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 09:43:29 +02:00
Marcel
53038dea68 fix(header): address PR review blockers
- AuthHeader: remove duplicated locale logic, use <LanguageSwitcher inverted />
- Fix text-white/55 → text-white/70 in AppNav and LanguageSwitcher (WCAG AA)
- E2E: add axe accessibility checks, replace [data-hydrated] with role selectors,
  add 768px hamburger test and BRAND_NAVY comment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 09:37:24 +02:00
Marcel
281934529e fix(header): consistent icon styling, focus rings, and responsive breakpoints
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
- Normalize all header icon buttons to white/65 + white/10 hover bg
- Fix guest person icon (img tag needs brightness-0 invert, not text color)
- Add missing focus-visible rings to ThemeToggle and LanguageSwitcher
- Use focus-visible:rounded on nav links so active underline stays sharp
- Bump burger/nav breakpoint from sm→lg to prevent overflow on tablets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:54:04 +02:00
Marcel
c905f136d2 test(header): add Playwright tests for brand-navy header
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
- Asserts header background is rgb(1,40,81) in light mode
- Asserts header stays navy after switching to dark mode
- Asserts logo text visible at 375px viewport
- Asserts login page has AuthHeader with navy background and lang switcher

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:03:38 +02:00
Marcel
36bf591afe feat(forgot-password): add AuthHeader for consistent auth page branding
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:02:29 +02:00
Marcel
550a9704ad feat(login): replace floating lang switcher with AuthHeader
Removes the absolutely-positioned language switcher div and replaces it
with the shared AuthHeader component (logo + lang switcher on navy bar).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:01:40 +02:00
Marcel
55e681c209 feat(AuthHeader): slim brand-navy header for auth pages
Provides logo + language switcher on brand-navy background with
4px accent strip. Used on login and forgot-password pages in place
of the floating language switcher.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:01:02 +02:00
Marcel
e65ddc655e feat(UserMenu): brand-mint avatar, white guest icon, focus rings
- Avatar: bg-brand-mint text-brand-navy (mint circle, navy initials)
- Guest icon button: text-white/60, hover text-white
- Both buttons: focus-visible:ring-2 ring-accent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:00:22 +02:00
Marcel
14b1cc7539 feat(AppNav): brand-navy header styles for logo and nav links
- Logo: always visible (remove hidden md:flex), text-white
- Outer wrapper: items-stretch so active border reaches header bottom
- Desktop nav: items-stretch, active = border-b-2 border-accent text-white
- Inactive links: text-white/55, hover text-white/85
- Hamburger: text-white/70, hover text-white
- Mobile drawer active: bg-accent-bg replacing removed bg-nav-active
- Focus rings: focus-visible:ring-2 ring-accent on all interactive elements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 21:59:38 +02:00
Marcel
adc1f343b2 feat(layout): apply brand-navy header with accent strip
- Replace bg-surface border-b with bg-brand-navy (always #012851)
- Add 4px bg-accent strip above the nav bar
- Remove border-r separator from language switcher wrapper
- Pass inverted prop to LanguageSwitcher for white text on dark bg

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 21:57:39 +02:00
Marcel
3dfaf69fb1 feat(LanguageSwitcher): add inverted prop for dark-header context
When inverted=true, buttons render white text instead of ink tokens,
suitable for placement on brand-navy background.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 21:57:01 +02:00
Marcel
fd2a7a8e96 refactor(layout): remove --c-nav-active CSS token
The nav active state moves from a background pill to a bottom-border
underline, so the rgba purple tint variable is no longer needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 21:55:48 +02:00
126 changed files with 7889 additions and 2439 deletions

View File

@@ -85,6 +85,37 @@ public class CommentController {
return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author);
}
// ─── Block (transcription) comments ────────────────────────────────────────
@GetMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments")
public List<DocumentComment> getBlockComments(@PathVariable UUID blockId) {
return commentService.getCommentsForBlock(blockId);
}
@PostMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments")
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
public DocumentComment postBlockComment(
@PathVariable UUID documentId,
@PathVariable UUID blockId,
@RequestBody CreateCommentDTO dto,
Authentication authentication) {
AppUser author = resolveUser(authentication);
return commentService.postBlockComment(documentId, blockId, dto.getContent(), dto.getMentionedUserIds(), author);
}
@PostMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments/{commentId}/replies")
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
public DocumentComment replyToBlockComment(
@PathVariable UUID documentId,
@PathVariable UUID commentId,
@RequestBody CreateCommentDTO dto,
Authentication authentication) {
AppUser author = resolveUser(authentication);
return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author);
}
// ─── Edit and delete (shared) ─────────────────────────────────────────────
@PatchMapping("/api/documents/{documentId}/comments/{commentId}")

View File

@@ -0,0 +1,102 @@
package org.raddatz.familienarchiv.controller;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO;
import org.raddatz.familienarchiv.dto.ReorderTranscriptionBlocksDTO;
import org.raddatz.familienarchiv.dto.UpdateTranscriptionBlockDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.TranscriptionService;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/documents/{documentId}/transcription-blocks")
@RequiredArgsConstructor
@Slf4j
public class TranscriptionBlockController {
private final TranscriptionService transcriptionService;
private final UserService userService;
@GetMapping
@RequirePermission(Permission.READ_ALL)
public List<TranscriptionBlock> listBlocks(@PathVariable UUID documentId) {
return transcriptionService.listBlocks(documentId);
}
@GetMapping("/{blockId}")
@RequirePermission(Permission.READ_ALL)
public TranscriptionBlock getBlock(@PathVariable UUID documentId, @PathVariable UUID blockId) {
return transcriptionService.getBlock(documentId, blockId);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission(Permission.WRITE_ALL)
public TranscriptionBlock createBlock(
@PathVariable UUID documentId,
@RequestBody CreateTranscriptionBlockDTO dto,
Authentication authentication) {
UUID userId = requireUserId(authentication);
return transcriptionService.createBlock(documentId, dto, userId);
}
@PutMapping("/{blockId}")
@RequirePermission(Permission.WRITE_ALL)
public TranscriptionBlock updateBlock(
@PathVariable UUID documentId,
@PathVariable UUID blockId,
@RequestBody UpdateTranscriptionBlockDTO dto,
Authentication authentication) {
UUID userId = requireUserId(authentication);
return transcriptionService.updateBlock(documentId, blockId, dto, userId);
}
@DeleteMapping("/{blockId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@RequirePermission(Permission.WRITE_ALL)
public void deleteBlock(
@PathVariable UUID documentId,
@PathVariable UUID blockId) {
transcriptionService.deleteBlock(documentId, blockId);
}
@PutMapping("/reorder")
@RequirePermission(Permission.WRITE_ALL)
public List<TranscriptionBlock> reorderBlocks(
@PathVariable UUID documentId,
@RequestBody ReorderTranscriptionBlocksDTO dto) {
transcriptionService.reorderBlocks(documentId, dto);
return transcriptionService.listBlocks(documentId);
}
@GetMapping("/{blockId}/history")
@RequirePermission(Permission.READ_ALL)
public List<TranscriptionBlockVersion> getBlockHistory(
@PathVariable UUID documentId,
@PathVariable UUID blockId) {
return transcriptionService.getBlockHistory(documentId, blockId);
}
private UUID requireUserId(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
throw DomainException.unauthorized("Authentication required");
}
AppUser user = userService.findByUsername(authentication.getName());
if (user == null) {
throw DomainException.unauthorized("User not found");
}
return user.getId();
}
}

View File

@@ -0,0 +1,25 @@
package org.raddatz.familienarchiv.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CreateTranscriptionBlockDTO {
@Min(0)
private int pageNumber;
@Min(0)
private double x;
@Min(0)
private double y;
@Positive
private double width;
@Positive
private double height;
private String text;
private String label;
}

View File

@@ -0,0 +1,15 @@
package org.raddatz.familienarchiv.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.UUID;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ReorderTranscriptionBlocksDTO {
private List<UUID> blockIds;
}

View File

@@ -0,0 +1,13 @@
package org.raddatz.familienarchiv.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UpdateTranscriptionBlockDTO {
private String text;
private String label;
}

View File

@@ -50,6 +50,12 @@ public enum ErrorCode {
/** The new annotation overlaps an existing one on the same page. 409 */
ANNOTATION_OVERLAP,
// --- Transcription Blocks ---
/** The transcription block with the given ID does not exist. 404 */
TRANSCRIPTION_BLOCK_NOT_FOUND,
/** Optimistic locking conflict — block was modified by another user. 409 */
TRANSCRIPTION_BLOCK_CONFLICT,
// --- Comments ---
/** The comment with the given ID does not exist. 404 */
COMMENT_NOT_FOUND,

View File

@@ -33,6 +33,9 @@ public class DocumentComment {
@Column(name = "annotation_id")
private UUID annotationId;
@Column(name = "block_id")
private UUID blockId;
@Column(name = "parent_id")
private UUID parentId;

View File

@@ -0,0 +1,64 @@
package org.raddatz.familienarchiv.model;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "transcription_blocks")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TranscriptionBlock {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID id;
@Column(name = "annotation_id", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID annotationId;
@Column(name = "document_id", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID documentId;
@Column(nullable = false, columnDefinition = "TEXT")
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String text;
@Column(length = 200)
private String label;
@Column(name = "sort_order", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private int sortOrder;
@Version
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private int version;
@Column(name = "created_by")
private UUID createdBy;
@Column(name = "updated_by")
private UUID updatedBy;
@Column(name = "created_at", nullable = false, updatable = false)
@CreationTimestamp
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
@UpdateTimestamp
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,39 @@
package org.raddatz.familienarchiv.model;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "transcription_block_versions")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TranscriptionBlockVersion {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID id;
@Column(name = "block_id", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID blockId;
@Column(nullable = false, columnDefinition = "TEXT")
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String text;
@Column(name = "changed_by")
private UUID changedBy;
@Column(name = "changed_at", nullable = false, updatable = false)
@CreationTimestamp
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime changedAt;
}

View File

@@ -13,4 +13,6 @@ public interface CommentRepository extends JpaRepository<DocumentComment, UUID>
List<DocumentComment> findByAnnotationIdAndParentIdIsNull(UUID annotationId);
List<DocumentComment> findByParentId(UUID parentId);
List<DocumentComment> findByBlockIdAndParentIdIsNull(UUID blockId);
}

View File

@@ -0,0 +1,17 @@
package org.raddatz.familienarchiv.repository;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface TranscriptionBlockRepository extends JpaRepository<TranscriptionBlock, UUID> {
List<TranscriptionBlock> findByDocumentIdOrderBySortOrderAsc(UUID documentId);
Optional<TranscriptionBlock> findByIdAndDocumentId(UUID id, UUID documentId);
int countByDocumentId(UUID documentId);
}

View File

@@ -0,0 +1,12 @@
package org.raddatz.familienarchiv.repository;
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface TranscriptionBlockVersionRepository extends JpaRepository<TranscriptionBlockVersion, UUID> {
List<TranscriptionBlockVersion> findByBlockIdOrderByChangedAtDesc(UUID blockId);
}

View File

@@ -34,6 +34,28 @@ public class CommentService {
return withRepliesAndMentions(roots);
}
public List<DocumentComment> getCommentsForBlock(UUID blockId) {
List<DocumentComment> roots = commentRepository.findByBlockIdAndParentIdIsNull(blockId);
return withRepliesAndMentions(roots);
}
@Transactional
public DocumentComment postBlockComment(UUID documentId, UUID blockId, String content,
List<UUID> mentionedUserIds, AppUser author) {
DocumentComment comment = DocumentComment.builder()
.documentId(documentId)
.blockId(blockId)
.content(content)
.authorId(author.getId())
.authorName(resolveAuthorName(author))
.build();
saveMentions(comment, mentionedUserIds);
DocumentComment saved = commentRepository.save(comment);
withMentionDTOs(saved);
notificationService.notifyMentions(mentionedUserIds, saved);
return saved;
}
@Transactional
public DocumentComment postComment(UUID documentId, UUID annotationId, String content,
List<UUID> mentionedUserIds, AppUser author) {

View File

@@ -0,0 +1,140 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO;
import org.raddatz.familienarchiv.dto.ReorderTranscriptionBlocksDTO;
import org.raddatz.familienarchiv.dto.UpdateTranscriptionBlockDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
import org.raddatz.familienarchiv.repository.AnnotationRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockVersionRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.UUID;
@Service
@RequiredArgsConstructor
@Slf4j
public class TranscriptionService {
private static final String TRANSCRIPTION_COLOR = "#00C7B1";
private static final int MAX_TEXT_LENGTH = 10_000;
private final TranscriptionBlockRepository blockRepository;
private final TranscriptionBlockVersionRepository versionRepository;
private final AnnotationRepository annotationRepository;
private final AnnotationService annotationService;
private final DocumentService documentService;
public List<TranscriptionBlock> listBlocks(UUID documentId) {
return blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId);
}
public TranscriptionBlock getBlock(UUID documentId, UUID blockId) {
return blockRepository.findByIdAndDocumentId(blockId, documentId)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND,
"Transcription block not found: " + blockId));
}
@Transactional
public TranscriptionBlock createBlock(UUID documentId, CreateTranscriptionBlockDTO dto, UUID userId) {
Document doc = documentService.getDocumentById(documentId);
CreateAnnotationDTO annotationDTO = new CreateAnnotationDTO(
dto.getPageNumber(), dto.getX(), dto.getY(),
dto.getWidth(), dto.getHeight(), TRANSCRIPTION_COLOR);
DocumentAnnotation annotation = annotationService.createAnnotation(
documentId, annotationDTO, userId, doc.getFileHash());
int nextOrder = blockRepository.countByDocumentId(documentId);
String text = sanitizeText(dto.getText());
TranscriptionBlock block = TranscriptionBlock.builder()
.annotationId(annotation.getId())
.documentId(documentId)
.text(text)
.label(dto.getLabel())
.sortOrder(nextOrder)
.createdBy(userId)
.updatedBy(userId)
.build();
TranscriptionBlock saved = blockRepository.save(block);
saveVersion(saved, userId);
log.info("Created transcription block {} for document {}", saved.getId(), documentId);
return saved;
}
@Transactional
public TranscriptionBlock updateBlock(UUID documentId, UUID blockId,
UpdateTranscriptionBlockDTO dto, UUID userId) {
TranscriptionBlock block = getBlock(documentId, blockId);
String text = sanitizeText(dto.getText());
block.setText(text);
if (dto.getLabel() != null) {
block.setLabel(dto.getLabel());
}
block.setUpdatedBy(userId);
TranscriptionBlock saved = blockRepository.save(block);
saveVersion(saved, userId);
return saved;
}
@Transactional
public void deleteBlock(UUID documentId, UUID blockId) {
TranscriptionBlock block = getBlock(documentId, blockId);
UUID annotationId = block.getAnnotationId();
// Block is the aggregate root — delete block first (cascades to versions + comments),
// then delete the dependent annotation directly (no ownership check needed)
blockRepository.delete(block);
blockRepository.flush();
annotationRepository.deleteById(annotationId);
log.info("Deleted transcription block {} and annotation {} for document {}",
blockId, annotationId, documentId);
}
@Transactional
public void reorderBlocks(UUID documentId, ReorderTranscriptionBlocksDTO dto) {
List<UUID> blockIds = dto.getBlockIds();
for (int i = 0; i < blockIds.size(); i++) {
TranscriptionBlock block = getBlock(documentId, blockIds.get(i));
block.setSortOrder(i);
blockRepository.save(block);
}
}
public List<TranscriptionBlockVersion> getBlockHistory(UUID documentId, UUID blockId) {
getBlock(documentId, blockId);
return versionRepository.findByBlockIdOrderByChangedAtDesc(blockId);
}
private void saveVersion(TranscriptionBlock block, UUID userId) {
TranscriptionBlockVersion version = TranscriptionBlockVersion.builder()
.blockId(block.getId())
.text(block.getText())
.changedBy(userId)
.build();
versionRepository.save(version);
}
String sanitizeText(String text) {
if (text == null) return "";
if (text.length() > MAX_TEXT_LENGTH) {
text = text.substring(0, MAX_TEXT_LENGTH);
}
return text;
}
}

View File

@@ -0,0 +1,16 @@
CREATE TABLE transcription_blocks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
annotation_id UUID NOT NULL REFERENCES document_annotations(id) ON DELETE RESTRICT,
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
text TEXT NOT NULL DEFAULT '' CHECK (length(text) <= 10000),
label VARCHAR(200),
sort_order INTEGER NOT NULL DEFAULT 0,
version INTEGER NOT NULL DEFAULT 0,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
updated_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX idx_tb_document_sort ON transcription_blocks(document_id, sort_order);
CREATE INDEX idx_tb_annotation ON transcription_blocks(annotation_id);

View File

@@ -0,0 +1,9 @@
CREATE TABLE transcription_block_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
block_id UUID NOT NULL REFERENCES transcription_blocks(id) ON DELETE CASCADE,
text TEXT NOT NULL,
changed_by UUID REFERENCES users(id) ON DELETE SET NULL,
changed_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX idx_tbv_block ON transcription_block_versions(block_id, changed_at DESC);

View File

@@ -0,0 +1,4 @@
ALTER TABLE document_comments
ADD COLUMN block_id UUID REFERENCES transcription_blocks(id) ON DELETE CASCADE;
CREATE INDEX idx_dc_block ON document_comments(block_id);

View File

@@ -279,4 +279,30 @@ class CommentControllerTest {
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
// ─── Block comment endpoints ─────────────────────────────────────────────
@Test
@WithMockUser
void getBlockComments_returns200() throws Exception {
UUID blockId = UUID.randomUUID();
when(commentService.getCommentsForBlock(blockId)).thenReturn(List.of());
mockMvc.perform(get("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void postBlockComment_returns201() throws Exception {
UUID blockId = UUID.randomUUID();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build();
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.blockId").value(blockId.toString()));
}
}

View File

@@ -488,4 +488,40 @@ class CommentServiceTest {
.build()))
.build();
}
// ─── Block-level comments ────────────────────────────────────────────────
@Test
void getCommentsForBlock_returnsRootCommentsFilteredByBlockId() {
UUID blockId = UUID.randomUUID();
DocumentComment root = DocumentComment.builder()
.id(UUID.randomUUID()).blockId(blockId).content("Nice work").authorName("Felix")
.createdAt(LocalDateTime.now()).updatedAt(LocalDateTime.now()).build();
when(commentRepository.findByBlockIdAndParentIdIsNull(blockId)).thenReturn(List.of(root));
when(commentRepository.findByParentId(root.getId())).thenReturn(List.of());
List<DocumentComment> result = commentService.getCommentsForBlock(blockId);
assertThat(result).hasSize(1);
assertThat(result.getFirst().getContent()).isEqualTo("Nice work");
}
@Test
void postBlockComment_setsBlockIdOnComment() {
UUID documentId = UUID.randomUUID();
UUID blockId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("felix").firstName("Felix").lastName("Brandt").build();
when(commentRepository.save(any())).thenAnswer(inv -> {
DocumentComment c = inv.getArgument(0);
c.setId(UUID.randomUUID());
return c;
});
DocumentComment result = commentService.postBlockComment(
documentId, blockId, "Looks like Breslau", List.of(), author);
assertThat(result.getBlockId()).isEqualTo(blockId);
assertThat(result.getDocumentId()).isEqualTo(documentId);
assertThat(result.getContent()).isEqualTo("Looks like Breslau");
}
}

View File

@@ -0,0 +1,246 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO;
import org.raddatz.familienarchiv.dto.ReorderTranscriptionBlocksDTO;
import org.raddatz.familienarchiv.dto.UpdateTranscriptionBlockDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
import org.raddatz.familienarchiv.repository.AnnotationRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockVersionRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@ExtendWith(MockitoExtension.class)
class TranscriptionServiceTest {
@Mock TranscriptionBlockRepository blockRepository;
@Mock TranscriptionBlockVersionRepository versionRepository;
@Mock AnnotationRepository annotationRepository;
@Mock AnnotationService annotationService;
@Mock DocumentService documentService;
@InjectMocks TranscriptionService transcriptionService;
// ─── getBlock ────────────────────────────────────────────────────────────────
@Test
void getBlock_throwsNotFound_whenBlockDoesNotExist() {
UUID docId = UUID.randomUUID();
UUID blockId = UUID.randomUUID();
when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> transcriptionService.getBlock(docId, blockId))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND));
}
@Test
void getBlock_returnsBlock_whenExists() {
UUID docId = UUID.randomUUID();
UUID blockId = UUID.randomUUID();
TranscriptionBlock block = TranscriptionBlock.builder()
.id(blockId).documentId(docId).text("hello").build();
when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block));
TranscriptionBlock result = transcriptionService.getBlock(docId, blockId);
assertThat(result).isEqualTo(block);
}
// ─── createBlock ─────────────────────────────────────────────────────────────
@Test
void createBlock_createsAnnotationAndBlockAndVersion() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
UUID annotId = UUID.randomUUID();
Document doc = Document.builder().id(docId).fileHash("hash123").build();
when(documentService.getDocumentById(docId)).thenReturn(doc);
DocumentAnnotation annotation = DocumentAnnotation.builder().id(annotId).build();
when(annotationService.createAnnotation(eq(docId), any(CreateAnnotationDTO.class), eq(userId), eq("hash123")))
.thenReturn(annotation);
when(blockRepository.countByDocumentId(docId)).thenReturn(0);
when(blockRepository.save(any())).thenAnswer(inv -> {
TranscriptionBlock b = inv.getArgument(0);
b.setId(UUID.randomUUID());
return b;
});
CreateTranscriptionBlockDTO dto = new CreateTranscriptionBlockDTO(1, 0.1, 0.2, 0.3, 0.4, "hello", null);
TranscriptionBlock result = transcriptionService.createBlock(docId, dto, userId);
assertThat(result.getAnnotationId()).isEqualTo(annotId);
assertThat(result.getText()).isEqualTo("hello");
assertThat(result.getSortOrder()).isZero();
assertThat(result.getCreatedBy()).isEqualTo(userId);
verify(versionRepository).save(any(TranscriptionBlockVersion.class));
}
// ─── updateBlock ─────────────────────────────────────────────────────────────
@Test
void updateBlock_updatesTextAndSavesVersion() {
UUID docId = UUID.randomUUID();
UUID blockId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
TranscriptionBlock block = TranscriptionBlock.builder()
.id(blockId).documentId(docId).text("old").build();
when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block));
when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
UpdateTranscriptionBlockDTO dto = new UpdateTranscriptionBlockDTO("new text", null);
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, userId);
assertThat(result.getText()).isEqualTo("new text");
assertThat(result.getUpdatedBy()).isEqualTo(userId);
verify(versionRepository).save(any(TranscriptionBlockVersion.class));
}
@Test
void updateBlock_updatesLabel_whenProvided() {
UUID docId = UUID.randomUUID();
UUID blockId = UUID.randomUUID();
TranscriptionBlock block = TranscriptionBlock.builder()
.id(blockId).documentId(docId).text("text").label("old label").build();
when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block));
when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
UpdateTranscriptionBlockDTO dto = new UpdateTranscriptionBlockDTO("text", "Anrede");
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, UUID.randomUUID());
assertThat(result.getLabel()).isEqualTo("Anrede");
}
// ─── deleteBlock ─────────────────────────────────────────────────────────────
@Test
void deleteBlock_deletesBlockAndAnnotation() {
UUID docId = UUID.randomUUID();
UUID blockId = UUID.randomUUID();
UUID annotId = UUID.randomUUID();
TranscriptionBlock block = TranscriptionBlock.builder()
.id(blockId).documentId(docId).annotationId(annotId).build();
when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block));
transcriptionService.deleteBlock(docId, blockId);
verify(blockRepository).delete(block);
verify(blockRepository).flush();
verify(annotationRepository).deleteById(annotId);
}
@Test
void deleteBlock_throwsNotFound_whenBlockMissing() {
UUID docId = UUID.randomUUID();
UUID blockId = UUID.randomUUID();
when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> transcriptionService.deleteBlock(docId, blockId))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND));
}
// ─── reorderBlocks ───────────────────────────────────────────────────────────
@Test
void reorderBlocks_updatesSortOrder() {
UUID docId = UUID.randomUUID();
UUID id1 = UUID.randomUUID();
UUID id2 = UUID.randomUUID();
TranscriptionBlock block1 = TranscriptionBlock.builder()
.id(id1).documentId(docId).sortOrder(0).build();
TranscriptionBlock block2 = TranscriptionBlock.builder()
.id(id2).documentId(docId).sortOrder(1).build();
when(blockRepository.findByIdAndDocumentId(id2, docId)).thenReturn(Optional.of(block2));
when(blockRepository.findByIdAndDocumentId(id1, docId)).thenReturn(Optional.of(block1));
when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
ReorderTranscriptionBlocksDTO dto = new ReorderTranscriptionBlocksDTO(List.of(id2, id1));
transcriptionService.reorderBlocks(docId, dto);
assertThat(block2.getSortOrder()).isZero();
assertThat(block1.getSortOrder()).isEqualTo(1);
}
// ─── getBlockHistory ─────────────────────────────────────────────────────────
@Test
void getBlockHistory_returnsVersionsForBlock() {
UUID docId = UUID.randomUUID();
UUID blockId = UUID.randomUUID();
TranscriptionBlock block = TranscriptionBlock.builder()
.id(blockId).documentId(docId).build();
when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block));
TranscriptionBlockVersion v = TranscriptionBlockVersion.builder()
.id(UUID.randomUUID()).blockId(blockId).text("ver1").build();
when(versionRepository.findByBlockIdOrderByChangedAtDesc(blockId)).thenReturn(List.of(v));
List<TranscriptionBlockVersion> result = transcriptionService.getBlockHistory(docId, blockId);
assertThat(result).containsExactly(v);
}
// ─── sanitizeText ────────────────────────────────────────────────────────────
@Test
void sanitizeText_returnsEmptyString_forNull() {
assertThat(transcriptionService.sanitizeText(null)).isEmpty();
}
@Test
void sanitizeText_truncatesAtMaxLength() {
String longText = "a".repeat(15_000);
String result = transcriptionService.sanitizeText(longText);
assertThat(result).hasSize(10_000);
}
@Test
void sanitizeText_preservesPlainText() {
assertThat(transcriptionService.sanitizeText("Liebe Mutter,")).isEqualTo("Liebe Mutter,");
}
// ─── listBlocks ──────────────────────────────────────────────────────────────
@Test
void listBlocks_returnsBlocksOrderedBySortOrder() {
UUID docId = UUID.randomUUID();
TranscriptionBlock b = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).sortOrder(0).build();
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId)).thenReturn(List.of(b));
assertThat(transcriptionService.listBlocks(docId)).containsExactly(b);
}
}

View File

@@ -0,0 +1,963 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Annotation-Backed Transcription — Final Spec</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<style>
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--navy:#012851;--mint:#A1DCD8;--sand:#F0EFE9;--turquoise:#00C7B1;--accent-bg:rgba(161,220,216,.12);--blue-tint:#E6F1FB;--blue:#2D7DD2;--blue-dark:#185FA5;--purple-tint:#EEEDFE;--purple:#534AB7;--purple-dark:#3C3489;--green-tint:#E8F5EA;--green:#3D8C4A;--green-dark:#2E6E39;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--yellow-tint:#FDF6D8;--yellow-text:#8A6800;--color-error:#DC4C3E;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.doc-header p{font-size:13px;color:var(--color-text-muted);max-width:680px;}
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
.pill-g{background:var(--green-tint);color:var(--green-dark);}
.section{margin-bottom:64px;}
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
.jh{padding:20px 24px;border-radius:var(--radius-xl);margin-bottom:40px;display:flex;align-items:center;gap:16px;}
.jh .jn{font-family:var(--font-display);font-size:48px;font-weight:300;line-height:1;opacity:.5;}
.jh h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.jh p{font-size:13px;line-height:1.5;}.jh .fl{font-family:var(--font-mono);font-size:11px;margin-top:6px;opacity:.7;}
.jh-b{background:var(--blue-tint);border:1px solid #A4CFF4;}.jh-b .jn{color:var(--blue);}.jh-b p,.jh-b .fl{color:var(--blue-dark);}
.jh-g{background:var(--green-tint);border:1px solid #A0D8A8;}.jh-g .jn{color:var(--green);}.jh-g p,.jh-g .fl{color:var(--green-dark);}
.jh-o{background:var(--orange-tint);border:1px solid #F0C89A;}.jh-o .jn{color:var(--orange);}.jh-o p,.jh-o .fl{color:var(--orange-dark);}
.scr{margin-bottom:56px;}
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;flex-direction:column;min-height:520px;}
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
/* ── FA chrome ── */
.fa-nav{height:32px;background:var(--navy);display:flex;align-items:center;padding:0 12px;gap:8px;flex-shrink:0;}
.fa-logo{font-size:7px;font-weight:900;color:#fff;letter-spacing:.8px;border-bottom:2px solid var(--mint);padding-bottom:1px;}
.fa-link{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:700;text-transform:uppercase;}
.fa-nav-r{margin-left:auto;display:flex;gap:5px;align-items:center;}
.fa-av{width:16px;height:16px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5);}
.fa-topbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:0 12px;gap:6px;height:42px;flex-shrink:0;}
.fa-topbar .back{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);}
.fa-topbar .title{font-family:Georgia,serif;font-size:11px;color:var(--navy);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.fa-chip{display:inline-flex;align-items:center;gap:2px;padding:1px 5px 1px 2px;background:var(--sand);border:1px solid #e4e2d7;border-radius:8px;white-space:nowrap;font-size:7px;color:var(--color-text);}
.fa-chip .av{width:12px;height:12px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;flex-shrink:0;}
.fa-chip .av.navy{background:var(--navy);color:var(--mint);}
.fa-chip .av.purple{background:#5A3080;color:#fff;}
.fa-topbar-btn{font-size:7px;font-weight:600;padding:3px 8px;border-radius:4px;border:1px solid var(--navy);color:var(--navy);background:transparent;display:flex;align-items:center;gap:3px;}
.fa-topbar-btn.active{background:var(--navy);color:#fff;border-color:var(--navy);}
.fa-topbar-btn.ghost{border-color:var(--color-border);color:var(--color-text-muted);font-weight:500;}
.fa-topbar-btn.transcribe{background:var(--turquoise);color:var(--navy);border-color:var(--turquoise);font-weight:700;}
.details-toggle{display:inline-flex;align-items:center;gap:3px;padding:2px 8px 2px 6px;border-radius:4px;font-size:7px;font-weight:600;color:var(--color-text-muted);cursor:pointer;border:1px solid var(--color-border);background:transparent;white-space:nowrap;}
/* ── PDF + paper ── */
.pdf-area{background:#D4D0C8;flex:1;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;}
.paper{background:#FFFEF8;box-shadow:0 2px 8px rgba(0,0,0,.14);border-radius:1px;padding:9px 11px;display:flex;flex-direction:column;gap:2px;position:relative;}
.pl{height:3px;background:#C4BDB0;border-radius:1px;opacity:.5;margin-bottom:2px;}
.ps{height:2px;background:#C4BDB0;border-radius:1px;opacity:.28;margin-bottom:1.5px;}
/* ── Annotation rectangles on PDF ── */
.ann-rect{position:absolute;border-radius:2px;pointer-events:auto;cursor:pointer;transition:all .15s ease;}
.ann-rect.comment{border:1.5px solid rgba(255,200,0,.6);background:rgba(255,200,0,.15);}
.ann-rect.comment:hover{background:rgba(255,200,0,.3);}
.ann-rect.trans{border:1.5px solid var(--turquoise);background:rgba(0,199,177,.1);}
.ann-rect.trans:hover{background:rgba(0,199,177,.2);}
.ann-rect.trans.active{background:rgba(0,199,177,.25);box-shadow:0 0 0 2px var(--turquoise);}
.ann-rect .ann-num{position:absolute;top:-8px;left:-8px;width:16px;height:16px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:7px;font-weight:700;color:#fff;box-shadow:0 1px 3px rgba(0,0,0,.3);}
.ann-rect.trans .ann-num{background:var(--navy);}
.ann-rect.comment .ann-num{background:var(--orange);}
.ann-rect .ann-badge{position:absolute;bottom:-8px;right:-8px;background:var(--navy);color:#fff;font-size:6px;font-weight:700;padding:1px 4px;border-radius:8px;min-width:14px;text-align:center;box-shadow:0 1px 2px rgba(0,0,0,.3);}
/* ── Split + panels ── */
.split{display:flex;flex:1;overflow:hidden;}
.split-left{flex:1;display:flex;flex-direction:column;overflow:hidden;position:relative;}
.split-right{display:flex;flex-direction:column;overflow:hidden;border-left:1px solid #e4e2d7;}
.split-handle{width:4px;background:var(--color-border);cursor:col-resize;flex-shrink:0;display:flex;align-items:center;justify-content:center;}
.split-handle::after{content:'';width:2px;height:20px;background:var(--color-text-muted);border-radius:1px;opacity:.3;}
/* ── Transcript blocks ── */
.tblock{margin-bottom:6px;border:1px solid var(--color-border);border-radius:5px;overflow:hidden;transition:all .15s ease;}
.tblock.active{border-color:var(--turquoise);box-shadow:0 0 0 1px var(--turquoise);}
.tblock.empty{border-style:dashed;opacity:.7;}
.tblock-head{display:flex;align-items:center;gap:4px;padding:3px 8px;font-size:6px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--color-text-muted);}
.tblock-head.active-bg{background:rgba(0,199,177,.08);}
.tblock-head .num{width:14px;height:14px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:700;flex-shrink:0;}
.tblock-body{padding:5px 8px;font-family:Georgia,serif;font-size:9px;line-height:1.65;color:var(--color-text);min-height:18px;}
.tblock-body.editing{background:var(--color-page);cursor:text;}
.tblock-body .illegible{color:var(--color-text-muted);font-style:italic;}
.tblock-footer{display:flex;align-items:center;gap:4px;padding:2px 8px;border-top:1px solid var(--color-subtle);font-size:6px;color:var(--color-text-muted);}
.trans-cursor{display:inline-block;width:1px;height:10px;background:var(--blue);animation:blink 1s infinite;margin-left:1px;}
@keyframes blink{0%,50%{opacity:1}51%,100%{opacity:0}}
/* ── Presence ── */
.presence{display:flex;align-items:center;gap:3px;font-size:7px;color:var(--color-text-muted);}
.presence-dot{width:5px;height:5px;border-radius:50%;}
.hl-blue{border-left:2px solid var(--blue);padding-left:6px;background:rgba(45,125,210,.04);}
.hl-purple{border-left:2px solid var(--purple);padding-left:6px;background:rgba(83,74,183,.04);}
/* ── Inline comment thread ── */
.inline-thread{margin:3px 8px 5px;padding:5px 8px;border-radius:4px;border-left:2px solid var(--orange);background:var(--orange-tint);font-size:8px;color:var(--color-text);}
.inline-thread .thread-head{font-size:6px;font-weight:600;color:var(--orange-dark);margin-bottom:2px;display:flex;align-items:center;gap:3px;}
.inline-thread .thread-msg{display:flex;gap:3px;align-items:flex-start;margin-bottom:2px;}
.inline-thread .thread-av{width:12px;height:12px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:#fff;flex-shrink:0;}
.inline-thread .thread-reply{display:flex;gap:3px;margin-top:3px;}
.inline-thread input{flex:1;font-size:7px;padding:2px 5px;border:1px solid var(--color-border);border-radius:3px;background:#fff;}
.inline-thread .resolve-btn{font-size:6px;font-weight:600;color:var(--green-dark);padding:2px 5px;cursor:pointer;}
/* ── Hint strip ── */
.hint-strip{display:flex;align-items:center;gap:6px;padding:0 12px;height:22px;border-top:1px dashed;flex-shrink:0;font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;}
.hint-strip.trans-hint{background:rgba(0,199,177,.06);border-color:rgba(0,199,177,.3);color:var(--navy);}
.hint-strip .hint-step{display:flex;align-items:center;gap:3px;font-weight:500;color:var(--color-text-muted);text-transform:none;letter-spacing:0;}
/* ── Transcript toolbar ── */
.trans-toolbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:4px 8px;gap:6px;flex-shrink:0;}
.trans-toolbar-btn{font-size:6px;font-weight:600;padding:2px 6px;border-radius:3px;border:1px solid var(--color-border);color:var(--color-text-muted);background:transparent;cursor:pointer;display:flex;align-items:center;gap:2px;}
.trans-toolbar-btn:hover{background:var(--sand);color:var(--color-text);}
.trans-toolbar-btn.active{background:var(--navy);color:#fff;border-color:var(--navy);}
/* ── History panel (in-toolbar) ── */
.history-panel{background:var(--color-page);border:1px solid var(--color-border);border-radius:5px;margin:4px 8px;padding:6px 8px;font-size:7px;}
.history-entry{display:flex;align-items:center;gap:4px;padding:3px 0;border-bottom:1px solid var(--color-subtle);}
.history-entry:last-child{border-bottom:none;}
.history-entry .he-date{font-size:6px;color:var(--color-text-muted);min-width:40px;}
.history-entry .he-user{font-size:6px;font-weight:600;color:var(--color-text);min-width:40px;}
.history-entry .he-diff{font-size:7px;color:var(--color-text);}
.he-add{background:var(--green-tint);color:var(--green-dark);padding:0 2px;border-radius:1px;}
.he-del{background:#FEE2E2;color:#991B1B;padding:0 2px;border-radius:1px;text-decoration:line-through;}
/* ── Status bar ── */
.status-bar{background:var(--sand);border-top:1px solid #e4e2d7;height:18px;display:flex;align-items:center;padding:0 8px;font-size:7px;color:var(--color-text-muted);gap:8px;flex-shrink:0;}
.status-saved{color:var(--green-dark);}
/* ── Agent table ── */
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
.agent pre{font-family:var(--font-mono);font-size:10px;color:#444440;margin-bottom:16px;line-height:1.8;white-space:pre-wrap;}
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
.at thead tr{border-bottom:1px solid #2A2A26;}.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}.at tr:last-child td{border-bottom:none;}.at td:first-child{color:#7A7A72;}.at td:nth-child(2){color:#E8E8E2;font-weight:500;}.at td:nth-child(3){color:#5A5A55;}.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
.llm{background:var(--color-page);border:2px solid var(--navy);border-radius:var(--radius-xl);padding:32px 40px;margin-top:64px;}
.llm h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;color:var(--navy);}
.llm h3{font-size:14px;font-weight:600;margin:20px 0 8px;color:var(--color-text);}
.llm h4{font-size:12px;font-weight:600;margin:14px 0 6px;color:var(--color-text-muted);}
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
.llm li{margin-bottom:4px;}
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
.llm td{color:var(--color-text-muted);}
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
</style>
</head>
<body>
<div class="doc">
<div class="doc-header">
<div>
<h1>Annotation-Backed Transcription</h1>
<p>Final spec for the collaborative inline transcription system. Draw turquoise rectangles on the scanned letter &rarr; numbered transcript blocks appear in a side panel &rarr; type what you read. Block-level comment threads with quoted selections for discussion. History in the transcript toolbar. No bottom panel.</p>
</div>
<div class="doc-meta">
Familienarchiv<br/>
<span class="pill pill-g">Final spec</span><br/>
2026-04-04 &middot; @leonievoss
</div>
</div>
<!-- ═══ CORE CONCEPT ═══ -->
<div class="section">
<div class="section-title">Core concept &mdash; Draw-to-Transcribe</div>
<p class="prose">Today, annotations are rectangles on the PDF that open a comment thread in the side panel. By adding a <code>type</code> field to <code>DocumentAnnotation</code>, the same draw-a-rectangle gesture can create a <strong>transcription annotation</strong> (turquoise). A transcription annotation links a PDF region to an editable text block in the right panel.</p>
<p class="prose">Comments live <strong>inside transcript blocks</strong> as block-level threads. Users can select a word or phrase before commenting &mdash; the selection is <strong>auto-quoted</strong> into the comment message (e.g. <code>&gt; &ldquo;Breslau&rdquo;</code>) 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 <strong>disabled in transcribe mode</strong> &mdash; only turquoise transcription rectangles appear on the PDF.</p>
</div>
<div class="jh jh-b">
<div class="jn">T</div>
<div><h2>Draw-to-transcribe workflow</h2><p>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.</p><div class="fl">Reuses: AnnotationLayer + PdfViewer + CommentThread &middot; New: TranscriptBlock + TranscriptEditor + type:transcription</div></div>
</div>
<!-- ═══ WHAT STAYS / CHANGES / NEW ═══ -->
<div class="section">
<div class="section-title">What stays, what changes, what&rsquo;s new</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;font-size:12px;line-height:1.6;">
<div style="background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:16px;">
<div style="font-weight:600;color:var(--navy);margin-bottom:6px;">Reused as-is</div>
<ul style="padding-left:16px;color:var(--color-text-muted);">
<li><code>AnnotationLayer</code> &mdash; draw rects on PDF</li>
<li><code>PdfViewer</code> &mdash; render, zoom, page nav</li>
<li><code>CommentThread</code> &mdash; threaded replies, mentions</li>
<li><code>DocumentAnnotation</code> model &mdash; add <code>type</code> field</li>
<li><code>DocumentComment</code> model &mdash; unchanged</li>
</ul>
</div>
<div style="background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:16px;">
<div style="font-weight:600;color:var(--orange);margin-bottom:6px;">Repurposed</div>
<ul style="padding-left:16px;color:var(--color-text-muted);">
<li><code>AnnotationSidePanel</code> slot &rarr; becomes the transcript editor panel</li>
<li><code>annotateMode</code> state &rarr; split into <code>annotateMode</code> + <code>transcribeMode</code></li>
<li>Annotation color &rarr; turquoise only in transcribe mode, yellow only in annotate mode (mutually exclusive)</li>
<li><code>AnnotateHintStrip</code> &rarr; new copy for transcribe mode</li>
</ul>
</div>
<div style="background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:16px;">
<div style="font-weight:600;color:var(--green);margin-bottom:6px;">New</div>
<ul style="padding-left:16px;color:var(--color-text-muted);">
<li><code>transcription_blocks</code> table</li>
<li>Transcript editor component (right panel)</li>
<li>Block-level comment threads (quoted selections)</li>
<li><code>type</code> column on <code>document_annotations</code></li>
<li>History in transcript toolbar</li>
<li>Bottom panel removed (all modes)</li>
</ul>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════════
SCREEN 1 — DESKTOP TRANSCRIBE MODE
═══════════════════════════════════════════════════════════════════════════ -->
<div class="scr" id="desktop">
<div class="scr-head"><h3>Desktop &mdash; transcribe mode active</h3><span class="scr-id">S1</span></div>
<div class="scr-desc">Two users are collaborating. <strong>Only turquoise</strong> transcription rectangles appear on the PDF &mdash; no yellow comment annotations in transcribe mode. One user (Oma Inge, purple) is editing Block 2. The current user (blue) is editing Block 3. Block 2 has a comment thread where Oma Inge quoted &ldquo;Breslau&rdquo; to discuss the reading. Each block has a &ldquo;Kommentieren&rdquo; button in its footer. The transcript toolbar shows &ldquo;Verlauf&rdquo; (history). No bottom panel.</div>
<div class="previews">
<div class="prev-col">
<div class="bp-lbl">Desktop &middot; 1040px</div>
<div class="desk">
<div class="fa-nav">
<div class="fa-logo">FAMILIENARCHIV</div>
<div class="fa-link">Dokumente</div>
<div class="fa-link">Personen</div>
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
</div>
<div class="fa-topbar">
<div class="back">&larr;</div>
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
<div style="flex:1"></div>
<div class="presence" style="margin-right:4px;"><div class="presence-dot" style="background:var(--blue);"></div> Du</div>
<div class="presence" style="margin-right:4px;"><div class="presence-dot" style="background:var(--purple);"></div> Oma Inge</div>
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
<div class="details-toggle">Details &#9660;</div>
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
<div class="fa-topbar-btn transcribe">&#9998; Transkribieren</div>
<div class="fa-topbar-btn ghost">Annotieren</div>
</div>
<!-- Hint strip -->
<div class="hint-strip trans-hint">
<span>Transkribieren</span>
<span class="hint-step">&mdash; Markiere eine Textpassage im Scan, um einen Transkriptions-Block anzulegen</span>
</div>
<div class="split" style="height:400px;">
<!-- PDF with annotation rectangles -->
<div class="split-left">
<div class="pdf-area" style="flex:1;">
<div class="paper" style="width:55%;min-height:240px;position:relative;">
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div><div class="ps" style="width:70%;"></div>
<div class="pl" style="width:84%;"></div><div class="ps" style="width:90%;"></div><div class="ps" style="width:60%;"></div>
<div class="pl" style="width:75%;"></div><div class="ps" style="width:82%;"></div>
<div style="font-size:6px;color:#8A8070;margin-top:6px;text-align:right;opacity:.7;">Dein Heinrich</div>
<!-- Transcription annotations (turquoise) -->
<div class="ann-rect trans" style="left:2%;top:0%;width:50%;height:10%;">
<div class="ann-num">1</div>
</div>
<div class="ann-rect trans active" style="left:2%;top:14%;width:96%;height:32%;">
<div class="ann-num">2</div>
</div>
<div class="ann-rect trans" style="left:2%;top:50%;width:96%;height:22%;">
<div class="ann-num">3</div>
</div>
<div class="ann-rect trans" style="left:20%;top:80%;width:60%;height:12%;">
<div class="ann-num">4</div>
</div>
<!-- No yellow comment annotations in transcribe mode —
only turquoise transcription rects on the PDF -->
</div>
</div>
</div>
<div class="split-handle"></div>
<!-- Transcript editor panel -->
<div class="split-right" style="width:380px;">
<!-- Transcript toolbar -->
<div class="trans-toolbar">
<span style="font-size:7px;font-weight:600;color:var(--navy);">4 Bl&ouml;cke</span>
<div style="flex:1;"></div>
<div class="trans-toolbar-btn">&#9776; Sortieren</div>
<div class="trans-toolbar-btn">&#128337; Verlauf</div>
<span style="font-size:7px;color:var(--green-dark);">&#10003; Gespeichert</span>
</div>
<!-- Block list -->
<div style="flex:1;overflow-y:auto;padding:6px 8px;background:#fff;display:flex;flex-direction:column;gap:4px;">
<!-- Block 1 — Greeting (done) -->
<div class="tblock">
<div class="tblock-head"><div class="num">1</div> Anrede <span style="margin-left:auto;color:var(--green-dark);">&#10003;</span></div>
<div class="tblock-body">Liebe Martha,</div>
</div>
<!-- Block 2 — Main body (edited by Oma Inge) -->
<div class="tblock active">
<div class="tblock-head active-bg">
<div class="num">2</div> Hauptteil
<div class="presence" style="margin-left:auto;"><div class="presence-dot" style="background:var(--purple);width:4px;height:4px;"></div> Oma Inge</div>
</div>
<div class="tblock-body editing hl-purple">ich schreibe Dir heute aus dem Lazarett in Breslau. Mach Dir keine Sorgen, es geht mir den Umst&auml;nden entsprechend gut. Der Arzt sagt <span class="illegible">[unleserlich]</span> Wochen noch dauern wird.</div>
<!-- Block-level thread with quoted selection -->
<div class="inline-thread">
<div class="thread-head">&#128172; 2 Kommentare</div>
<div class="thread-msg">
<div class="thread-av" style="background:var(--purple);">OI</div>
<div>
<strong style="font-size:7px;">Oma Inge</strong> &middot; <span style="font-size:7px;color:var(--color-text-muted);">vor 12 Min.</span>
<div style="border-left:2px solid var(--color-border);padding-left:4px;margin:2px 0;font-size:7px;font-style:italic;color:var(--color-text-muted);">&ldquo;Breslau&rdquo;</div>
<div>Ich bin sicher, das ist &ldquo;Breslau&rdquo; &mdash; Heinrich war dort im Lazarett.</div>
</div>
</div>
<div class="thread-msg">
<div class="thread-av" style="background:var(--blue);">DU</div>
<div>
<strong style="font-size:7px;">Du</strong> &middot; <span style="font-size:7px;color:var(--color-text-muted);">vor 8 Min.</span>
<div>Stimmt, danke! Lass ich so.</div>
</div>
</div>
<div class="thread-reply">
<input placeholder="Antworten..."/>
<div class="resolve-btn">&#10003; L&ouml;sen</div>
</div>
</div>
<!-- Block footer with comment button -->
<div class="tblock-footer">
<span style="cursor:pointer;color:var(--orange);display:flex;align-items:center;gap:2px;">&#128172; Kommentieren</span>
<span style="margin-left:auto;font-size:5px;color:var(--color-text-muted);">Text markieren f&uuml;r Zitat</span>
</div>
</div>
<!-- Block 3 — Family (edited by current user) -->
<div class="tblock active" style="border-color:var(--blue);box-shadow:0 0 0 1px var(--blue);">
<div class="tblock-head" style="background:rgba(45,125,210,.06);">
<div class="num">3</div> Familie
<div class="presence" style="margin-left:auto;"><div class="presence-dot" style="background:var(--blue);width:4px;height:4px;"></div> Du</div>
</div>
<div class="tblock-body editing hl-blue">Die Kinder sollen wissen, dass ich an sie denke. Sag dem kleinen Fritz, er soll auf seine Mutter aufpassen.<span class="trans-cursor"></span></div>
<div class="tblock-footer">
<span style="cursor:pointer;color:var(--color-text-muted);display:flex;align-items:center;gap:2px;">&#128172; Kommentieren</span>
</div>
</div>
<!-- Block 4 — Closing (done) -->
<div class="tblock">
<div class="tblock-head"><div class="num">4</div> Schluss <span style="margin-left:auto;color:var(--green-dark);">&#10003;</span></div>
<div class="tblock-body">In ewiger Liebe,<br/>Dein Heinrich</div>
</div>
<!-- Add block CTA -->
<div class="tblock empty" style="text-align:center;padding:8px;font-size:7px;color:var(--color-text-muted);cursor:pointer;">
Markiere eine weitere Passage im Scan, um Block 5 anzulegen
</div>
</div>
<div class="status-bar">
<span>Block 3 aktiv</span>
<span>Oma Inge &middot; Block 2</span>
<span style="margin-left:auto;">1 offene Diskussion</span>
</div>
</div>
</div>
<!-- NO bottom panel in transcribe mode -->
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════════
SCREEN 2 — COMMENT FLOW: SELECT → QUOTE → DISCUSS
═══════════════════════════════════════════════════════════════════════════ -->
<div class="scr" id="comment-flow">
<div class="scr-head"><h3>Comment flow &mdash; select, quote, discuss</h3><span class="scr-id">S2</span></div>
<div class="scr-desc">The user has selected &ldquo;[unleserlich]&rdquo; in Block 2 and clicked &ldquo;Kommentieren&rdquo;. The comment input opens with the selection auto-quoted. After posting, the comment appears in the thread with the quote displayed as an indented blockquote. This shows the full lifecycle: selection &rarr; quoted input &rarr; posted comment.</div>
<div class="scr-var"><strong>Block-level threads + quoted selections</strong> &mdash; no char-offset anchoring, no fragile highlights. The quote is frozen text in the message body.</div>
<div class="previews">
<div class="prev-col">
<div class="bp-lbl">Desktop &middot; comment input open with auto-quote</div>
<div class="desk" style="min-height:380px;">
<div class="fa-nav">
<div class="fa-logo">FAMILIENARCHIV</div>
<div class="fa-link">Dokumente</div>
<div class="fa-link">Personen</div>
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
</div>
<div class="fa-topbar">
<div class="back">&larr;</div>
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
<div style="flex:1"></div>
<div class="details-toggle">Details &#9660;</div>
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
<div class="fa-topbar-btn transcribe">&#9998; Transkribieren</div>
<div class="fa-topbar-btn ghost">Annotieren</div>
</div>
<div class="split" style="height:290px;">
<div class="split-left">
<div class="pdf-area" style="flex:1;">
<div class="paper" style="width:55%;min-height:160px;position:relative;">
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div><div class="ps" style="width:70%;"></div>
<div class="ann-rect trans active" style="left:2%;top:14%;width:96%;height:40%;"><div class="ann-num">2</div></div>
</div>
</div>
</div>
<div class="split-handle"></div>
<div class="split-right" style="width:380px;">
<div class="trans-toolbar">
<span style="font-size:7px;font-weight:600;color:var(--navy);">4 Bl&ouml;cke</span>
<div style="flex:1;"></div>
<span style="font-size:7px;color:var(--green-dark);">&#10003; Gespeichert</span>
</div>
<div style="flex:1;overflow-y:auto;padding:6px 8px;background:#fff;display:flex;flex-direction:column;gap:4px;">
<!-- Block 2 with text selection + open comment input -->
<div class="tblock active">
<div class="tblock-head active-bg"><div class="num">2</div> Hauptteil</div>
<div class="tblock-body editing">ich schreibe Dir heute aus dem Lazarett in Breslau. Mach Dir keine Sorgen. Der Arzt sagt <span style="background:rgba(45,125,210,.25);border-radius:1px;padding:0 1px;">[unleserlich]</span> Wochen noch dauern wird.</div>
<!-- Comment input — open, with auto-quoted selection -->
<div style="margin:0 8px 5px;padding:6px 8px;border-radius:4px;border:1px solid var(--orange);background:#fff;">
<div style="font-size:6px;font-weight:600;color:var(--orange-dark);margin-bottom:3px;">Neuer Kommentar zu Block 2</div>
<!-- Auto-quoted selection shown as editable blockquote -->
<div style="border-left:2px solid var(--color-border);padding-left:4px;margin-bottom:3px;font-size:7px;font-style:italic;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;">
&gt; &ldquo;[unleserlich]&rdquo;
<span style="font-size:5px;color:var(--color-text-muted);font-style:normal;cursor:pointer;margin-left:auto;">&#10005; Zitat entfernen</span>
</div>
<div style="display:flex;gap:3px;">
<input style="flex:1;font-size:7px;padding:3px 5px;border:1px solid var(--color-border);border-radius:3px;background:var(--color-page);" value='Könnte "sechs" oder "acht" sein. Wer hat die Originale?'/>
<button style="font-size:6px;font-weight:600;padding:3px 8px;border-radius:3px;background:var(--navy);color:#fff;border:none;cursor:pointer;">Senden</button>
</div>
</div>
<div class="tblock-footer">
<span style="cursor:pointer;color:var(--orange);display:flex;align-items:center;gap:2px;font-weight:600;">&#128172; Kommentieren</span>
<span style="margin-left:auto;font-size:5px;color:var(--color-text-muted);">Text markieren f&uuml;r Zitat</span>
</div>
</div>
<!-- Block 3 — with an existing posted comment showing the quote -->
<div class="tblock">
<div class="tblock-head"><div class="num">3</div> Familie</div>
<div class="tblock-body">Die Kinder sollen wissen, dass ich an sie denke.</div>
<!-- Posted comment thread with quoted selection -->
<div class="inline-thread">
<div class="thread-head">&#128172; 1 Kommentar</div>
<div class="thread-msg">
<div class="thread-av" style="background:var(--purple);">OI</div>
<div>
<strong style="font-size:7px;">Oma Inge</strong> &middot; <span style="font-size:7px;color:var(--color-text-muted);">vor 5 Min.</span>
<div style="border-left:2px solid var(--color-border);padding-left:4px;margin:2px 0;font-size:7px;font-style:italic;color:var(--color-text-muted);">&ldquo;Die Kinder&rdquo;</div>
<div>Fritz und Lotte. Fritz war damals 4, Lotte 7.</div>
</div>
</div>
<div class="thread-reply">
<input placeholder="Antworten..."/>
</div>
</div>
<div class="tblock-footer">
<span style="cursor:pointer;color:var(--color-text-muted);display:flex;align-items:center;gap:2px;">&#128172; Kommentieren</span>
</div>
</div>
</div>
<div class="status-bar"><span>Block 2 aktiv</span><span style="margin-left:auto;">2 Kommentare</span></div>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>Comment flow &middot; Select → Quote → Discuss</h4>
<pre>/* 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. */</pre>
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
<tr class="grp"><td colspan="3">Comment input</td></tr>
<tr><td>Container</td><td>border:orange, bg:white, radius:4px, mx:8px</td><td>Appears below block body, above footer</td></tr>
<tr><td>Quote display</td><td>left-border:2px line, italic, 7px muted</td><td>Editable — user can modify or remove</td></tr>
<tr><td>Remove quote</td><td>"× Zitat entfernen" link, 5px, top-right of quote</td><td>Converts to general block comment</td></tr>
<tr><td>Input field</td><td>flex:1, 7px, border:line, bg:page, radius:3px</td><td>Auto-focuses when opened</td></tr>
<tr><td>Send button</td><td>"Senden", 6px/600, navy bg, white text</td><td>Enter to send, Shift+Enter for newline</td></tr>
<tr class="grp"><td colspan="3">Posted comment with quote</td></tr>
<tr><td>Quote in thread</td><td>left-border:2px line, italic, 7px muted</td><td>Read-only — frozen snapshot of selected text</td></tr>
<tr><td>Message below</td><td>8px normal text, below the quote</td><td>Standard CommentThread message styling</td></tr>
<tr class="grp"><td colspan="3">Data model</td></tr>
<tr><td>block_id</td><td>UUID FK → transcription_blocks (nullable)</td><td>Links comment to its block</td></tr>
<tr><td>content</td><td>TEXT with markdown blockquote</td><td>&gt; "quoted text"\nComment message</td></tr>
<tr><td>No char offsets</td><td></td><td>Intentional. See spec rationale.</td></tr>
</tbody></table>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════════
SCREEN 3 — HISTORY IN TRANSCRIPT TOOLBAR
═══════════════════════════════════════════════════════════════════════════ -->
<div class="scr" id="history">
<div class="scr-head"><h3>Desktop &mdash; history panel open</h3><span class="scr-id">S3</span></div>
<div class="scr-desc">Clicking &ldquo;Verlauf&rdquo; in the transcript toolbar opens a collapsible history panel between the toolbar and the block list. Shows recent changes with word-level diffs, just like the existing <code>PanelHistory</code> component but embedded in the transcript panel instead of the bottom panel.</div>
<div class="previews">
<div class="prev-col">
<div class="bp-lbl">Desktop &middot; 1040px &middot; history open</div>
<div class="desk" style="min-height:540px;">
<div class="fa-nav">
<div class="fa-logo">FAMILIENARCHIV</div>
<div class="fa-link">Dokumente</div>
<div class="fa-link">Personen</div>
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
</div>
<div class="fa-topbar">
<div class="back">&larr;</div>
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
<div style="flex:1"></div>
<div class="details-toggle">Details &#9660;</div>
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
<div class="fa-topbar-btn transcribe">&#9998; Transkribieren</div>
<div class="fa-topbar-btn ghost">Annotieren</div>
</div>
<div class="hint-strip trans-hint">
<span>Transkribieren</span>
<span class="hint-step">&mdash; Markiere eine Textpassage im Scan</span>
</div>
<div class="split" style="height:420px;">
<div class="split-left">
<div class="pdf-area" style="flex:1;">
<div class="paper" style="width:55%;min-height:220px;position:relative;">
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div><div class="ps" style="width:70%;"></div>
<div class="pl" style="width:84%;"></div><div class="ps" style="width:90%;"></div>
<div class="ann-rect trans" style="left:2%;top:0%;width:50%;height:10%;"><div class="ann-num">1</div></div>
<div class="ann-rect trans" style="left:2%;top:14%;width:96%;height:32%;"><div class="ann-num">2</div></div>
<div class="ann-rect trans" style="left:2%;top:50%;width:96%;height:22%;"><div class="ann-num">3</div></div>
<div class="ann-rect trans" style="left:20%;top:80%;width:60%;height:12%;"><div class="ann-num">4</div></div>
</div>
</div>
</div>
<div class="split-handle"></div>
<div class="split-right" style="width:380px;">
<!-- Toolbar with history active -->
<div class="trans-toolbar">
<span style="font-size:7px;font-weight:600;color:var(--navy);">4 Bl&ouml;cke</span>
<div style="flex:1;"></div>
<div class="trans-toolbar-btn">&#9776; Sortieren</div>
<div class="trans-toolbar-btn active">&#128337; Verlauf</div>
<span style="font-size:7px;color:var(--green-dark);">&#10003; Gespeichert</span>
</div>
<!-- History panel (collapsible) -->
<div class="history-panel">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px;">
<span style="font-size:6px;font-weight:600;color:var(--navy);text-transform:uppercase;letter-spacing:.06em;">Letzte &Auml;nderungen</span>
<span style="font-size:6px;color:var(--color-text-muted);cursor:pointer;">Alle anzeigen &rarr;</span>
</div>
<div class="history-entry">
<span class="he-date">14:23</span>
<span class="he-user">Oma Inge</span>
<span class="he-diff">Block 2: ...Lazarett in <span class="he-add">Breslau</span><span class="he-del">Bresla</span>...</span>
</div>
<div class="history-entry">
<span class="he-date">14:18</span>
<span class="he-user">Du</span>
<span class="he-diff">Block 3: <span class="he-add">Die Kinder sollen wissen, dass ich an sie denke.</span></span>
</div>
<div class="history-entry">
<span class="he-date">14:12</span>
<span class="he-user">Oma Inge</span>
<span class="he-diff">Block 2: <span class="he-add">ich schreibe Dir heute aus dem Lazarett</span></span>
</div>
<div class="history-entry">
<span class="he-date">14:05</span>
<span class="he-user">Du</span>
<span class="he-diff">Block 1: <span class="he-add">Liebe Martha,</span></span>
</div>
</div>
<!-- Blocks below history -->
<div style="flex:1;overflow-y:auto;padding:6px 8px;background:#fff;display:flex;flex-direction:column;gap:4px;">
<div class="tblock">
<div class="tblock-head"><div class="num">1</div> Anrede <span style="margin-left:auto;color:var(--green-dark);">&#10003;</span></div>
<div class="tblock-body">Liebe Martha,</div>
</div>
<div class="tblock">
<div class="tblock-head"><div class="num">2</div> Hauptteil</div>
<div class="tblock-body">ich schreibe Dir heute aus dem Lazarett in Breslau...</div>
</div>
<div class="tblock active" style="border-color:var(--blue);box-shadow:0 0 0 1px var(--blue);">
<div class="tblock-head" style="background:rgba(45,125,210,.06);"><div class="num">3</div> Familie</div>
<div class="tblock-body editing hl-blue">Die Kinder sollen wissen...<span class="trans-cursor"></span></div>
</div>
<div class="tblock">
<div class="tblock-head"><div class="num">4</div> Schluss <span style="margin-left:auto;color:var(--green-dark);">&#10003;</span></div>
<div class="tblock-body">In ewiger Liebe,<br/>Dein Heinrich</div>
</div>
</div>
<div class="status-bar"><span>Block 3 aktiv</span><span style="margin-left:auto;">&#10003; Gespeichert</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════════
SCREEN 3 — MOBILE TRANSCRIBE MODE
═══════════════════════════════════════════════════════════════════════════ -->
<div class="scr" id="mobile">
<div class="scr-head"><h3>Mobile &mdash; transcribe mode</h3><span class="scr-id">S4</span></div>
<div class="scr-desc">On mobile, the PDF collapses to a 90px strip at the top. Annotation rectangles are visible as thin outlines. Transcript blocks stack vertically below. The history button is in the toolbar above the blocks. Inline threads expand in-place.</div>
<div class="previews">
<div class="prev-col">
<div class="bp-lbl">Mobile &middot; 320px</div>
<div class="phone" style="height:600px;">
<div class="pst"><b>14:23</b><span>&bull;&bull;&bull; WiFi &#128267;</span></div>
<div class="pb">
<div style="background:#fff;border-bottom:1px solid #e4e2d7;padding:6px 12px;display:flex;align-items:center;gap:6px;">
<span style="font-size:11px;color:var(--color-text-muted);">&larr;</span>
<span style="font-family:Georgia,serif;font-size:11px;color:var(--navy);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Brief von Heinrich, 14.05.1943</span>
<span style="font-size:7px;font-weight:700;padding:2px 6px;border-radius:3px;background:var(--turquoise);color:var(--navy);">Transkr.</span>
</div>
<!-- PDF strip with annotations -->
<div style="background:#D4D0C8;height:90px;display:flex;align-items:center;justify-content:center;position:relative;border-bottom:2px solid var(--turquoise);">
<div style="background:#FFFEF8;width:45%;padding:6px 8px;box-shadow:0 1px 4px rgba(0,0,0,.12);border-radius:1px;position:relative;">
<div style="font-size:5px;color:#8A8070;font-style:italic;opacity:.7;">Liebe Martha,</div>
<div style="height:2px;background:#C4BDB0;opacity:.4;margin:2px 0;width:80%;"></div>
<div style="height:1.5px;background:#C4BDB0;opacity:.25;margin:1px 0;width:90%;"></div>
<div style="height:1.5px;background:#C4BDB0;opacity:.25;margin:1px 0;width:70%;"></div>
<div style="position:absolute;left:2%;top:0;width:50%;height:18%;border:1px solid var(--turquoise);border-radius:1px;opacity:.5;"></div>
<div style="position:absolute;left:2%;top:22%;width:96%;height:35%;border:1px solid var(--turquoise);border-radius:1px;background:rgba(0,199,177,.1);"></div>
</div>
</div>
<!-- Toolbar -->
<div style="background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:4px 12px;gap:4px;">
<span style="font-size:8px;font-weight:600;color:var(--navy);">4 Bl&ouml;cke</span>
<div style="flex:1;"></div>
<div style="font-size:7px;font-weight:600;padding:3px 6px;border-radius:3px;border:1px solid var(--color-border);color:var(--color-text-muted);">&#128337; Verlauf</div>
<span style="font-size:7px;color:var(--green-dark);">&#10003;</span>
</div>
<!-- Block list -->
<div style="flex:1;overflow-y:auto;padding:8px 12px;background:#fff;">
<div style="border:1px solid var(--color-border);border-radius:5px;overflow:hidden;margin-bottom:6px;">
<div style="padding:3px 8px;font-size:6px;font-weight:600;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;background:var(--sand);"><div style="width:12px;height:12px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;">1</div> Anrede <span style="margin-left:auto;color:var(--green-dark);">&#10003;</span></div>
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;">Liebe Martha,</div>
</div>
<div style="border:1px solid var(--turquoise);border-radius:5px;overflow:hidden;margin-bottom:6px;box-shadow:0 0 0 1px var(--turquoise);">
<div style="padding:3px 8px;font-size:6px;font-weight:600;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;background:rgba(0,199,177,.08);"><div style="width:12px;height:12px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;">2</div> Hauptteil <span style="font-size:5px;color:var(--purple);margin-left:auto;">Oma Inge</span></div>
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;border-left:2px solid var(--purple);">ich schreibe Dir heute aus dem Lazarett in Breslau...</div>
<!-- Inline thread (collapsed on mobile — tap to expand) -->
<div style="padding:3px 8px;border-top:1px solid var(--color-subtle);display:flex;align-items:center;gap:3px;">
<span style="font-size:7px;color:var(--orange);">&#128172;</span>
<span style="font-size:7px;color:var(--color-text-muted);">1 Diskussion &middot; &ldquo;Breslau&rdquo;</span>
<span style="font-size:7px;color:var(--color-text-muted);margin-left:auto;">&#9660;</span>
</div>
</div>
<div style="border:1px solid var(--blue);border-radius:5px;overflow:hidden;margin-bottom:6px;box-shadow:0 0 0 1px var(--blue);">
<div style="padding:3px 8px;font-size:6px;font-weight:600;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;background:rgba(45,125,210,.06);"><div style="width:12px;height:12px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;">3</div> Familie <span style="font-size:5px;color:var(--blue);margin-left:auto;">Du</span></div>
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;border-left:2px solid var(--blue);">Die Kinder sollen wissen...<span class="trans-cursor"></span></div>
</div>
<div style="border:1px solid var(--color-border);border-radius:5px;overflow:hidden;margin-bottom:6px;">
<div style="padding:3px 8px;font-size:6px;font-weight:600;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;background:var(--sand);"><div style="width:12px;height:12px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;">4</div> Schluss <span style="margin-left:auto;color:var(--green-dark);">&#10003;</span></div>
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;">In ewiger Liebe,<br/>Dein Heinrich</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ═══ AGENT TABLES ═══ -->
<div class="agent">
<h4>Annotation-backed transcription &middot; Core implementation spec</h4>
<pre>/* 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. */</pre>
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
<tr class="grp"><td colspan="3">Annotation reuse</td></tr>
<tr><td>Draw gesture</td><td>Existing AnnotationLayer.onDraw(rect)</td><td>Same pointer events. crosshair cursor.</td></tr>
<tr><td>Annotation color</td><td>turquoise (#00C7B1) for transcription</td><td>Yellow annotations disabled in transcribe mode</td></tr>
<tr><td>Annotation type</td><td>New column: type VARCHAR "transcription"|"comment"</td><td>Default "comment" for backward compat</td></tr>
<tr><td>Number badge</td><td>16px navy circle, top-left of rect</td><td>Sort order number, matches block number</td></tr>
<tr class="grp"><td colspan="3">Transcript blocks (right panel)</td></tr>
<tr><td>Block card</td><td>border:1px line, radius:5px, active: turquoise glow</td><td>Header: number + label + presence. Body: contenteditable.</td></tr>
<tr><td>Block label</td><td>Editable text, defaults: Anrede, Hauptteil, Schluss</td><td>Double-click to rename</td></tr>
<tr><td>Empty state</td><td>Dashed border, "noch leer" italic text</td><td>Focus to start typing</td></tr>
<tr><td>Add block CTA</td><td>Dashed card: "Markiere eine Passage im Scan..."</td><td>Not clickable — directs user to draw on PDF</td></tr>
<tr class="grp"><td colspan="3">Block-level comment threads</td></tr>
<tr><td>Trigger</td><td>"Kommentieren" button in block footer</td><td>Always visible — no hover-reveal</td></tr>
<tr><td>Quoted selection</td><td>If text selected → auto-quoted into comment body</td><td>Plain text quote (&gt; "Breslau"), NOT char-offset anchor</td></tr>
<tr><td>Quote display</td><td>Left border + italic, above the comment text</td><td>Decorative only — doesn't link to text range</td></tr>
<tr><td>Thread UI</td><td>orange left-border, orange-tint bg, below block body</td><td>Block-level anchor (block_id). Reuses CommentThread.</td></tr>
<tr><td>Footer hint</td><td>"Text markieren für Zitat" in 5px muted text</td><td>Only shown when block is active/focused</td></tr>
<tr><td>Resolve</td><td>"✓ Lösen" button collapses thread</td><td>Resolved threads hidden by default, toggle to show</td></tr>
<tr><td>Mobile</td><td>Threads collapsed to "2 Kommentare" row, tap to expand</td><td>Saves vertical space on small screens</td></tr>
<tr class="grp"><td colspan="3">Yellow annotations in transcribe mode</td></tr>
<tr><td>Status</td><td>Disabled — draw gesture only creates turquoise rects</td><td>Existing yellow annotations still visible (read-only)</td></tr>
<tr><td>Annotate mode</td><td>Still available via topbar "Annotieren" button</td><td>Exits transcribe mode, enters annotate mode (yellow)</td></tr>
<tr class="grp"><td colspan="3">History (transcript toolbar)</td></tr>
<tr><td>Toggle</td><td>"🕗 Verlauf" button in transcript toolbar</td><td>Active state: navy bg, white text</td></tr>
<tr><td>Panel</td><td>Collapsible, between toolbar and block list</td><td>bg:color-page, border:line, radius:5px</td></tr>
<tr><td>Entries</td><td>Time + user + block ref + word-level diff</td><td>Reuses diffWords from 'diff' library</td></tr>
<tr><td>"Alle anzeigen"</td><td>Link to full history view (reuses PanelHistory)</td><td>Opens in a modal or replaces block list temporarily</td></tr>
<tr class="grp"><td colspan="3">Interaction</td></tr>
<tr><td>Click rect → block</td><td>scrollIntoView + active state on block</td><td>Turquoise glow on both rect and block</td></tr>
<tr><td>Click block → rect</td><td>PDF scrolls/zooms to show the annotation</td><td>If multi-page: switches page</td></tr>
<tr><td>Delete block</td><td>Deletes annotation + block + threads</td><td>Confirm dialog if threads exist</td></tr>
<tr><td>Reorder blocks</td><td>Drag handle in block header</td><td>Updates sort_order via PATCH</td></tr>
<tr class="grp"><td colspan="3">Presence (collaborative)</td></tr>
<tr><td>Dots in topbar</td><td>Colored dot + user name, flex row</td><td>Max 3 shown, "+N" overflow</td></tr>
<tr><td>Block-level presence</td><td>Colored dot + name in block header</td><td>Left border color matches user</td></tr>
<tr><td>Implementation</td><td>WebSocket presence via Y.js (future)</td><td>MVP: polling-based, 5s interval</td></tr>
<tr class="grp"><td colspan="3">Auto-save</td></tr>
<tr><td>Debounce</td><td>500ms after last keystroke</td><td>PATCH /api/transcription-blocks/{blockId}</td></tr>
<tr><td>Status</td><td>"✓ Gespeichert" in toolbar, fades after 3s</td><td>"Speichern..." while request in-flight</td></tr>
<tr><td>Conflict</td><td>Last-write-wins for MVP</td><td>Y.js CRDT for future collaborative editing</td></tr>
</tbody></table>
</div>
<!-- ═══ LLM IMPLEMENTATION GUIDE ═══ -->
<div class="llm">
<h2>Implementation Guide &mdash; Annotation-Backed Transcription</h2>
<h3>1. Data Model Changes</h3>
<h4>Flyway migration: <code>document_annotations</code></h4>
<ul>
<li>Add <code>type VARCHAR(20) NOT NULL DEFAULT 'comment'</code>.</li>
<li>Values: <code>'comment'</code> (existing behavior) or <code>'transcription'</code>.</li>
<li>Backward compatible &mdash; all existing annotations default to <code>'comment'</code>.</li>
</ul>
<h4>New table: <code>transcription_blocks</code></h4>
<table>
<thead><tr><th>Column</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
<tr><td><code>id</code></td><td>UUID PK</td><td>Generated</td></tr>
<tr><td><code>annotation_id</code></td><td>UUID FK → document_annotations</td><td>Links block to its PDF rectangle</td></tr>
<tr><td><code>document_id</code></td><td>UUID FK → documents</td><td>Denormalized for efficient queries</td></tr>
<tr><td><code>text</code></td><td>TEXT</td><td>The transcription content</td></tr>
<tr><td><code>label</code></td><td>VARCHAR(100)</td><td>"Anrede", "Hauptteil", etc.</td></tr>
<tr><td><code>sort_order</code></td><td>INT</td><td>Display order in the editor</td></tr>
<tr><td><code>created_by</code></td><td>UUID FK → app_users</td><td></td></tr>
<tr><td><code>updated_by</code></td><td>UUID FK → app_users</td><td></td></tr>
<tr><td><code>created_at</code></td><td>TIMESTAMP</td><td>@CreationTimestamp</td></tr>
<tr><td><code>updated_at</code></td><td>TIMESTAMP</td><td>@UpdateTimestamp</td></tr>
</tbody>
</table>
<h4>Block-level comments: <code>document_comments</code></h4>
<ul>
<li>Add <code>block_id UUID FK → transcription_blocks</code> (nullable).</li>
<li><strong>No char_offset columns.</strong> Quoted selections are stored as plain text in the comment <code>content</code> field using blockquote markdown syntax (<code>&gt; &ldquo;Breslau&rdquo;</code>). This is intentional &mdash; char offsets break when text is edited and require OT/CRDT to maintain. Quotes are a display hint, not a structural anchor.</li>
<li>Backward compatible &mdash; <code>block_id</code> is nullable, existing comments unaffected.</li>
</ul>
<h4>Backward compatibility: <code>Document.transcription</code></h4>
<p>The existing <code>transcription</code> TEXT field becomes a <strong>computed read-only view</strong>: <code>SELECT string_agg(text, E'\n\n' ORDER BY sort_order) FROM transcription_blocks WHERE document_id = ?</code>. Write operations go through the block API. This keeps search indexing, export, and the read-only <code>PanelTranscription</code> working without changes.</p>
<h3>2. Annotation Color Convention &amp; Mode Exclusivity</h3>
<table>
<thead><tr><th>Type</th><th>Color</th><th>Hex</th><th>On click</th><th>When active</th></tr></thead>
<tbody>
<tr><td>Comment</td><td>Yellow</td><td><code>#FFC800</code></td><td>Opens AnnotationSidePanel (existing)</td><td>Annotate mode only</td></tr>
<tr><td>Transcription</td><td>Turquoise</td><td><code>#00C7B1</code></td><td>Highlights matching block in transcript editor</td><td>Transcribe mode only</td></tr>
</tbody>
</table>
<p><strong>Mode exclusivity:</strong> In transcribe mode, only turquoise rects can be drawn. Existing yellow comment annotations from annotate mode are still <em>visible</em> on the PDF (read-only, dimmed) but cannot be created or interacted with. The &ldquo;Annotieren&rdquo; 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.</p>
<h3>3. Component Architecture</h3>
<table>
<thead><tr><th>Component</th><th>Change</th></tr></thead>
<tbody>
<tr><td><code>AnnotationLayer.svelte</code></td><td>Pass <code>type</code> to <code>onDraw</code> callback. Render turquoise vs yellow based on annotation type. Add number badges for transcription annotations.</td></tr>
<tr><td><code>PdfViewer.svelte</code></td><td>Split <code>handleAnnotationDraw</code> into two paths (annotate vs transcribe). Route <code>handleAnnotationClick</code> to either side panel or transcript editor.</td></tr>
<tr><td><code>AnnotationSidePanel.svelte</code></td><td>No change &mdash; still handles comment-type annotations in annotate mode. Hidden in transcribe mode.</td></tr>
<tr><td><code>TranscriptEditor.svelte</code> (new)</td><td>Right panel. Renders transcript toolbar + block list. Manages block CRUD, auto-save, block-level comment threads.</td></tr>
<tr><td><code>TranscriptBlock.svelte</code> (new)</td><td>Single block card. contenteditable body, header with number/label/presence, footer with &ldquo;Kommentieren&rdquo; button, thread slot below body.</td></tr>
<tr><td><code>BlockCommentThread.svelte</code> (new)</td><td>Comment thread anchored to a block. Shows quoted selections as blockquotes. Reuses <code>CommentThread</code> internally for replies/mentions.</td></tr>
<tr><td><code>TranscriptToolbar.svelte</code> (new)</td><td>Block count, sort button, history toggle, save status.</td></tr>
<tr><td><code>TranscriptHistory.svelte</code> (new)</td><td>Collapsible panel. Reuses <code>diffWords</code> from the <code>diff</code> library. Shows recent changes per block.</td></tr>
<tr><td><code>DocumentBottomPanel.svelte</code></td><td>Removed entirely. Metadata lives in the topbar drawer (see companion spec). Discussion, transcription, and history are all inline.</td></tr>
<tr><td><code>documents/[id]/+page.svelte</code></td><td>Add <code>transcribeMode</code> state. Conditionally render TranscriptEditor vs bottom panel.</td></tr>
</tbody>
</table>
<h3>4. API Endpoints</h3>
<table>
<thead><tr><th>Method</th><th>Path</th><th>Notes</th></tr></thead>
<tbody>
<tr><td>POST</td><td><code>/api/documents/{id}/annotations</code></td><td>Existing, but now accepts <code>type</code> field. If <code>type="transcription"</code>, also creates a TranscriptionBlock.</td></tr>
<tr><td>GET</td><td><code>/api/documents/{id}/transcription-blocks</code></td><td>Returns all blocks ordered by sort_order.</td></tr>
<tr><td>PATCH</td><td><code>/api/transcription-blocks/{blockId}</code></td><td>Update text, label, or sort_order. Auto-save target.</td></tr>
<tr><td>DELETE</td><td><code>/api/transcription-blocks/{blockId}</code></td><td>Deletes block + its annotation + any anchored comments.</td></tr>
<tr><td>PATCH</td><td><code>/api/transcription-blocks/reorder</code></td><td>Bulk update sort_order for drag-and-drop reordering.</td></tr>
</tbody>
</table>
<h3>5. Draw-to-Transcribe Workflow</h3>
<ol>
<li>User enters <strong>Transcribe mode</strong> (topbar button, turquoise). Hint strip appears. Yellow comment annotations become read-only/dimmed. Only turquoise rects can be drawn.</li>
<li>Crosshair cursor on PDF (same as annotate mode). User draws a rectangle around a handwriting passage.</li>
<li><code>AnnotationLayer.onDraw(rect)</code> fires. <code>PdfViewer</code> calls <code>POST /api/documents/{id}/annotations</code> with <code>type: "transcription"</code>.</li>
<li>Backend creates <code>DocumentAnnotation</code> + <code>TranscriptionBlock</code> (empty text, next sort_order).</li>
<li>Frontend receives the created annotation + block. The transcript editor scrolls to the new empty block and focuses it.</li>
<li>User types the transcription. Auto-save debounces to <code>PATCH /api/transcription-blocks/{blockId}</code>.</li>
<li>Repeat: draw next rectangle, type next block.</li>
</ol>
<h3>6. Comment Flow &mdash; Block-Level Threads with Quoted Selections</h3>
<p>Comments are anchored to <strong>blocks</strong>, not character offsets. This is a deliberate simplification:</p>
<h4>Why not char-offset anchoring?</h4>
<ul>
<li>When someone edits the transcription text, all character offsets downstream shift.</li>
<li>Keeping offsets in sync requires operational transforms (OT) or CRDT &mdash; that&rsquo;s the Y.js future work, not MVP.</li>
<li>A stale offset pointing to the wrong word is worse than a quoted snippet that no longer matches but still shows what was discussed.</li>
</ul>
<h4>How it works</h4>
<ol>
<li>User clicks <strong>&ldquo;Kommentieren&rdquo;</strong> in a block footer.</li>
<li>If text is selected in the block body, the selection is <strong>auto-quoted</strong> into the comment input: <code>&gt; &ldquo;Breslau&rdquo;</code>. The user can edit or remove the quote before sending.</li>
<li>If no text is selected, the comment input opens empty &mdash; a general block-level comment.</li>
<li>The comment is saved as a <code>DocumentComment</code> with <code>block_id</code> set. The quoted text is part of the <code>content</code> field (markdown blockquote syntax).</li>
<li>The thread renders below the block body with an orange left-border. Quoted text appears as an indented italic blockquote above the comment message.</li>
<li>Replies work the same as existing <code>CommentThread</code> &mdash; no changes needed.</li>
</ol>
<h4>What happens when text changes after quoting?</h4>
<p>Nothing breaks. The quote is a frozen snapshot of what the user selected. If &ldquo;Bresla&rdquo; was later corrected to &ldquo;Breslau&rdquo;, the original quote still reads <code>&gt; &ldquo;Bresla&rdquo;</code> with Oma Inge&rsquo;s comment &ldquo;I think this is Breslau.&rdquo; The context is preserved. No orphaned anchors, no broken highlights.</p>
<h4>Footer hint</h4>
<p>When a block is focused/active, the footer shows a subtle hint: <em>&ldquo;Text markieren f&uuml;r Zitat&rdquo;</em> (select text for a quote). This teaches the quoted-selection pattern without requiring documentation.</p>
<h3>7. History in Transcript Toolbar</h3>
<ul>
<li>The &ldquo;Verlauf&rdquo; button in the toolbar toggles <code>TranscriptHistory.svelte</code>.</li>
<li>The panel renders between the toolbar and the block list (pushes blocks down).</li>
<li>It shows recent changes per block, using <code>diffWords</code> from the <code>diff</code> library (same as existing <code>PanelHistory</code>).</li>
<li>Each entry: timestamp, user name, block reference (e.g. &ldquo;Block 2&rdquo;), and a word-level diff snippet.</li>
<li>&ldquo;Alle anzeigen&rdquo; opens a full history view &mdash; can reuse the existing <code>PanelHistory</code> component in a modal.</li>
<li>Data source: the existing document version history API, filtered/grouped by block.</li>
</ul>
<h3>8. Accessibility</h3>
<ul>
<li>Transcription blocks: <code>role="region"</code> with <code>aria-label="Transkriptions-Block N: [label]"</code></li>
<li>Block body: <code>contenteditable</code> with <code>aria-multiline="true"</code></li>
<li>Number badges on PDF: <code>aria-label="Transkriptions-Bereich N"</code></li>
<li>Comment button: <code>aria-label="Block N kommentieren"</code></li>
<li>History toggle: <code>aria-expanded</code>, <code>aria-controls="transcript-history"</code></li>
<li>Focus order: topbar &rarr; hint strip &rarr; PDF (for drawing) &rarr; transcript blocks (in sort order) &rarr; comment button &rarr; status bar</li>
<li>Keyboard: Tab between blocks, Enter to edit, Escape to deselect. Ctrl+Shift+N to prompt draw on PDF. Ctrl+Shift+K to open comment on focused block.</li>
</ul>
<h3>9. Companion Spec</h3>
<p>The expandable metadata header (labeled &ldquo;Details &#9660;&rdquo; toggle) is specified separately in <code>expandable-metadata-header-spec.html</code>. Together, these two specs fully eliminate the bottom panel in <strong>all modes</strong>: metadata &rarr; header drawer, transcription &rarr; inline split view, discussion &rarr; inline threads, history &rarr; transcript toolbar. One consistent pattern &mdash; no mode-dependent UI structure.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,700 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Expandable Metadata Header — Final Spec</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<style>
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--navy:#012851;--mint:#A1DCD8;--sand:#F0EFE9;--turquoise:#00C7B1;--accent-bg:rgba(161,220,216,.12);--blue-tint:#E6F1FB;--blue:#2D7DD2;--blue-dark:#185FA5;--green-tint:#E8F5EA;--green:#3D8C4A;--green-dark:#2E6E39;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--color-error:#DC4C3E;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.doc-header p{font-size:13px;color:var(--color-text-muted);max-width:680px;}
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
.pill-g{background:var(--green-tint);color:var(--green-dark);}
.section{margin-bottom:64px;}
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
.scr{margin-bottom:56px;}
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;flex-direction:column;min-height:520px;}
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
/* ── FA chrome ── */
.fa-nav{height:32px;background:var(--navy);display:flex;align-items:center;padding:0 12px;gap:8px;flex-shrink:0;}
.fa-logo{font-size:7px;font-weight:900;color:#fff;letter-spacing:.8px;border-bottom:2px solid var(--mint);padding-bottom:1px;}
.fa-link{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:700;text-transform:uppercase;}
.fa-nav-r{margin-left:auto;display:flex;gap:5px;align-items:center;}
.fa-av{width:16px;height:16px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5);}
/* ── Topbar ── */
.topbar{background:#fff;border-bottom:1px solid #e4e2d7;flex-shrink:0;position:relative;}
.topbar-main{display:flex;align-items:center;padding:0 12px;gap:6px;height:42px;}
.topbar .back{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);}
.topbar .title{font-family:Georgia,serif;font-size:11px;color:var(--navy);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.topbar .date{font-size:8px;color:var(--color-text-muted);}
.fa-chip{display:inline-flex;align-items:center;gap:2px;padding:1px 5px 1px 2px;background:var(--sand);border:1px solid #e4e2d7;border-radius:8px;white-space:nowrap;font-size:7px;color:var(--color-text);}
.fa-chip .av{width:12px;height:12px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;flex-shrink:0;}
.fa-chip .av.navy{background:var(--navy);color:var(--mint);}
.fa-chip .av.purple{background:#5A3080;color:#fff;}
.fa-chip a{color:inherit;text-decoration:none;}
.fa-chip a:hover{text-decoration:underline;}
.fa-topbar-btn{font-size:7px;font-weight:600;padding:3px 8px;border-radius:4px;border:1px solid var(--navy);color:var(--navy);background:transparent;display:flex;align-items:center;gap:3px;cursor:pointer;}
.fa-topbar-btn.active{background:var(--navy);color:#fff;border-color:var(--navy);}
.fa-topbar-btn.transcribe{background:var(--turquoise);color:var(--navy);border-color:var(--turquoise);font-weight:700;}
.fa-topbar-btn.ghost{border-color:var(--color-border);color:var(--color-text-muted);font-weight:500;}
/* ── Details toggle ── */
.details-toggle{display:inline-flex;align-items:center;gap:3px;padding:2px 8px 2px 6px;border-radius:4px;font-size:7px;font-weight:600;color:var(--color-text-muted);cursor:pointer;border:1px solid var(--color-border);background:transparent;transition:all .15s ease;white-space:nowrap;}
.details-toggle:hover{background:var(--sand);color:var(--color-text);}
.details-toggle.open{background:var(--navy);color:#fff;border-color:var(--navy);}
.details-toggle .chevron-icon{display:inline-block;font-size:7px;transition:transform .2s ease;}
.details-toggle.open .chevron-icon{transform:rotate(180deg);}
/* ── PDF area ── */
.pdf-area{background:#D4D0C8;flex:1;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;}
.paper{background:#FFFEF8;box-shadow:0 2px 8px rgba(0,0,0,.14);border-radius:1px;padding:9px 11px;display:flex;flex-direction:column;gap:2px;position:relative;}
.pl{height:3px;background:#C4BDB0;border-radius:1px;opacity:.5;margin-bottom:2px;}
.ps{height:2px;background:#C4BDB0;border-radius:1px;opacity:.28;margin-bottom:1.5px;}
/* ── Annotation rects ── */
.ann-rect{position:absolute;border:1.5px solid var(--turquoise);background:rgba(0,199,177,.1);border-radius:2px;}
.ann-num{position:absolute;top:-8px;left:-8px;width:14px;height:14px;border-radius:50%;background:var(--navy);display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:700;color:#fff;box-shadow:0 1px 2px rgba(0,0,0,.3);}
/* ── Transcript blocks ── */
.tblock{margin-bottom:5px;border:1px solid var(--color-border);border-radius:5px;overflow:hidden;}
.tblock.active{border-color:var(--turquoise);box-shadow:0 0 0 1px var(--turquoise);}
.tblock-head{display:flex;align-items:center;gap:4px;padding:3px 8px;font-size:6px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--color-text-muted);}
.tblock-head .num{width:14px;height:14px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:700;flex-shrink:0;}
.tblock-body{padding:5px 8px;font-family:Georgia,serif;font-size:9px;line-height:1.65;color:var(--color-text);min-height:16px;}
.trans-cursor{display:inline-block;width:1px;height:10px;background:var(--blue);animation:blink 1s infinite;margin-left:1px;}
@keyframes blink{0%,50%{opacity:1}51%,100%{opacity:0}}
.split{display:flex;flex:1;overflow:hidden;}
.split-handle{width:4px;background:var(--color-border);cursor:col-resize;flex-shrink:0;display:flex;align-items:center;justify-content:center;}
.split-handle::after{content:'';width:2px;height:20px;background:var(--color-text-muted);border-radius:1px;opacity:.3;}
.status-bar{background:var(--sand);border-top:1px solid #e4e2d7;height:18px;display:flex;align-items:center;padding:0 8px;font-size:7px;color:var(--color-text-muted);gap:8px;flex-shrink:0;}
/* ── Metadata display elements ── */
.meta-icon{width:14px;height:14px;display:flex;align-items:center;justify-content:center;font-size:9px;opacity:.5;flex-shrink:0;}
.meta-label{font-size:5px;font-weight:600;color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.06em;}
.meta-value{font-family:Georgia,serif;font-size:8px;color:var(--color-text);}
.meta-value a{color:var(--navy);text-decoration:none;}.meta-value a:hover{text-decoration:underline;}
.tag-chip{display:inline-block;font-size:6px;font-weight:600;padding:1px 5px;border-radius:3px;background:var(--sand);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.04em;cursor:pointer;}
.tag-chip:hover{background:var(--navy);color:#fff;}
/* ── Person card (for expanded metadata) ── */
.person-card{display:flex;align-items:center;gap:5px;padding:4px 6px;border:1px solid var(--color-border);border-radius:5px;background:var(--color-page);cursor:pointer;transition:all .1s;}
.person-card:hover{border-color:var(--mint);background:var(--accent-bg);}
.person-card .pc-av{width:18px;height:18px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;flex-shrink:0;}
.person-card .pc-name{font-family:Georgia,serif;font-size:8px;color:var(--color-text);}
.person-card .pc-alias{font-size:6px;color:var(--color-text-muted);}
.person-card .pc-action{font-size:7px;color:var(--color-text-muted);margin-left:auto;opacity:0;transition:opacity .1s;}
.person-card:hover .pc-action{opacity:1;}
/* ── Agent table ── */
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
.agent pre{font-family:var(--font-mono);font-size:10px;color:#444440;margin-bottom:16px;line-height:1.8;white-space:pre-wrap;}
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
.at thead tr{border-bottom:1px solid #2A2A26;}.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}.at tr:last-child td{border-bottom:none;}.at td:first-child{color:#7A7A72;}.at td:nth-child(2){color:#E8E8E2;font-weight:500;}.at td:nth-child(3){color:#5A5A55;}.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
.llm{background:var(--color-page);border:2px solid var(--navy);border-radius:var(--radius-xl);padding:32px 40px;margin-top:64px;}
.llm h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;color:var(--navy);}
.llm h3{font-size:14px;font-weight:600;margin:20px 0 8px;color:var(--color-text);}
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
.llm li{margin-bottom:4px;}
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
.llm td{color:var(--color-text-muted);}
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
</style>
</head>
<body>
<div class="doc">
<div class="doc-header">
<div>
<h1>Expandable Metadata Header</h1>
<p>The document topbar gains a labeled toggle button (<strong>&ldquo;Details &#9660;&rdquo;</strong>) that opens a full-width metadata drawer below the main row. This replaces the bottom panel&rsquo;s Metadata tab in transcribe mode, keeping all interactive elements (person links, conversation links, tag filters) accessible without consuming permanent viewport space.</p>
</div>
<div class="doc-meta">
Familienarchiv<br/>
<span class="pill pill-g">Final spec</span><br/>
2026-04-04 &middot; @leonievoss
</div>
</div>
<!-- ═══ DESIGN DECISION ═══ -->
<div class="section">
<div class="section-title">Why a labeled toggle, not just a chevron</div>
<p class="prose">User interviews include family members aged 60+. A bare 12&ndash;16px chevron icon is easy to miss or misinterpret as decorative. A labeled button &mdash; <strong>&ldquo;Details &#9660;&rdquo;</strong> &mdash; is self-explanatory, provides a larger click target (min 44&times;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.</p>
</div>
<div class="section">
<div class="section-title">What lives where</div>
<div style="display:flex;gap:12px;flex-wrap:wrap;font-size:12px;">
<div style="background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-md);padding:10px 14px;flex:1;min-width:200px;">
<div style="font-weight:600;color:var(--navy);margin-bottom:4px;">Always visible in topbar</div>
<ul style="padding-left:16px;color:var(--color-text-muted);line-height:1.8;">
<li>Document title (truncated)</li>
<li>Date (compact format)</li>
<li>Sender &amp; receiver chips (abbreviated)</li>
<li>Action buttons (Edit, Annotate, Download)</li>
<li><strong>&ldquo;Details&rdquo;</strong> toggle button</li>
</ul>
</div>
<div style="background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-md);padding:10px 14px;flex:1;min-width:200px;">
<div style="font-weight:600;color:var(--orange);margin-bottom:4px;">Revealed in drawer</div>
<ul style="padding-left:16px;color:var(--color-text-muted);line-height:1.8;">
<li>Full date (long format)</li>
<li>Creation location (e.g. &ldquo;Breslau&rdquo;)</li>
<li>Archive location (e.g. &ldquo;Ordner A3, Schublade 2&rdquo;)</li>
<li>Tags (clickable &rarr; filter documents)</li>
<li>Full person cards with avatar, name, alias</li>
<li>Person detail links (/persons/{id})</li>
<li>Conversation links (/korrespondenz?...)</li>
</ul>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════════
DESKTOP — COLLAPSED
═══════════════════════════════════════════════════════════════════════════ -->
<div class="scr" id="collapsed">
<div class="scr-head"><h3>Desktop &mdash; collapsed (default)</h3><span class="scr-id">S1</span></div>
<div class="scr-desc">The topbar looks identical to today except for the &ldquo;Details&rdquo; button between the person chips and the action buttons. The split view gets the full remaining viewport.</div>
<div class="previews">
<div class="prev-col">
<div class="bp-lbl">Desktop &middot; 1040px &middot; collapsed</div>
<div class="desk" style="min-height:480px;">
<div class="fa-nav">
<div class="fa-logo">FAMILIENARCHIV</div>
<div class="fa-link">Dokumente</div>
<div class="fa-link">Personen</div>
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
</div>
<div class="topbar">
<div class="topbar-main">
<div class="back">&larr;</div>
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 2px;"></div>
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
<div style="flex:1"></div>
<div class="fa-chip"><div class="av navy">HR</div> <a href="#">Heinrich R.</a></div>
<span style="font-size:7px;color:var(--color-text-muted);">&rarr;</span>
<div class="fa-chip"><div class="av purple">MR</div> <a href="#">Martha R.</a></div>
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
<!-- Labeled toggle button -->
<div class="details-toggle">Details <span class="chevron-icon">&#9660;</span></div>
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
<div class="fa-topbar-btn transcribe">&#9998; Transkribieren</div>
<div class="fa-topbar-btn ghost">Annotieren</div>
</div>
</div>
<!-- Split view fills remaining space -->
<div class="split" style="flex:1;">
<div style="flex:1;">
<div class="pdf-area" style="height:100%;">
<div class="paper" style="width:50%;min-height:180px;position:relative;">
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div><div class="ps" style="width:70%;"></div>
<div class="pl" style="width:84%;"></div><div class="ps" style="width:90%;"></div>
<div class="ann-rect" style="left:2%;top:0%;width:50%;height:12%;"><div class="ann-num">1</div></div>
<div class="ann-rect" style="left:2%;top:16%;width:96%;height:35%;"><div class="ann-num">2</div></div>
<div class="ann-rect" style="left:2%;top:55%;width:96%;height:25%;"><div class="ann-num">3</div></div>
</div>
</div>
</div>
<div class="split-handle"></div>
<div style="width:360px;display:flex;flex-direction:column;border-left:1px solid #e4e2d7;">
<div style="background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:4px 8px;gap:4px;flex-shrink:0;">
<span style="font-size:7px;font-weight:600;color:var(--navy);">3 Bl&ouml;cke</span>
<div style="flex:1;"></div>
<span style="font-size:7px;color:var(--green-dark);">&#10003; Gespeichert</span>
</div>
<div style="flex:1;overflow-y:auto;padding:6px 8px;background:#fff;display:flex;flex-direction:column;gap:4px;">
<div class="tblock"><div class="tblock-head"><div class="num">1</div> Anrede <span style="margin-left:auto;color:var(--green-dark);">&#10003;</span></div><div class="tblock-body">Liebe Martha,</div></div>
<div class="tblock active"><div class="tblock-head" style="background:rgba(0,199,177,.08);"><div class="num">2</div> Hauptteil</div><div class="tblock-body">ich schreibe Dir heute aus dem Lazarett in Breslau...<span class="trans-cursor"></span></div></div>
<div class="tblock"><div class="tblock-head"><div class="num">3</div> Familie</div><div class="tblock-body" style="color:var(--color-text-muted);font-style:italic;">noch leer</div></div>
</div>
<div class="status-bar"><span>Block 2 aktiv</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════════
DESKTOP — EXPANDED
═══════════════════════════════════════════════════════════════════════════ -->
<div class="scr" id="expanded">
<div class="scr-head"><h3>Desktop &mdash; expanded</h3><span class="scr-id">S2</span></div>
<div class="scr-desc">Clicking &ldquo;Details&rdquo; slides open a full-width drawer below the topbar. Three-column grid: details (date, location, archive), persons (sender &amp; receiver cards with conversation links), and tags. The drawer <strong>pushes content down</strong> &mdash; it is part of the document flow, not an overlay. No clipping, no z-index issues.</div>
<div class="previews">
<div class="prev-col">
<div class="bp-lbl">Desktop &middot; 1040px &middot; expanded</div>
<div class="desk" style="min-height:540px;">
<div class="fa-nav">
<div class="fa-logo">FAMILIENARCHIV</div>
<div class="fa-link">Dokumente</div>
<div class="fa-link">Personen</div>
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
</div>
<div class="topbar" style="border-bottom:none;">
<div class="topbar-main">
<div class="back">&larr;</div>
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 2px;"></div>
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
<div style="flex:1"></div>
<div class="fa-chip"><div class="av navy">HR</div> <a href="#">Heinrich R.</a></div>
<span style="font-size:7px;color:var(--color-text-muted);">&rarr;</span>
<div class="fa-chip"><div class="av purple">MR</div> <a href="#">Martha R.</a></div>
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
<!-- Toggle: active state -->
<div class="details-toggle open">Details <span class="chevron-icon">&#9660;</span></div>
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
<div class="fa-topbar-btn transcribe">&#9998; Transkribieren</div>
<div class="fa-topbar-btn ghost">Annotieren</div>
</div>
<!-- Expanded metadata drawer -->
<div style="border-top:1px solid #e4e2d7;padding:10px 16px;display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;background:var(--color-page);">
<!-- Column 1: Details -->
<div>
<div class="meta-label" style="margin-bottom:5px;">Details</div>
<div style="display:flex;flex-direction:column;gap:6px;">
<div style="display:flex;align-items:center;gap:5px;">
<div class="meta-icon">&#128197;</div>
<div><div class="meta-value">14. Mai 1943</div><div class="meta-label">Datum</div></div>
</div>
<div style="display:flex;align-items:center;gap:5px;">
<div class="meta-icon">&#128205;</div>
<div><div class="meta-value">Breslau</div><div class="meta-label">Entstehungsort</div></div>
</div>
<div style="display:flex;align-items:center;gap:5px;">
<div class="meta-icon">&#128193;</div>
<div><div class="meta-value">Ordner A3, Schublade 2</div><div class="meta-label">Archivstandort</div></div>
</div>
</div>
</div>
<!-- Column 2: Persons -->
<div>
<div class="meta-label" style="margin-bottom:5px;">Personen</div>
<div style="display:flex;flex-direction:column;gap:4px;">
<div class="meta-label" style="margin-top:2px;">Absender</div>
<div class="person-card">
<div class="pc-av" style="background:var(--navy);color:var(--mint);">HR</div>
<div><div class="pc-name">Heinrich Raddatz</div><div class="pc-alias">Opa Heinrich</div></div>
<div class="pc-action">&#128172;</div>
</div>
<div class="meta-label" style="margin-top:4px;">Empf&auml;nger</div>
<div class="person-card">
<div class="pc-av" style="background:#5A3080;color:#fff;">MR</div>
<div><div class="pc-name">Martha Raddatz</div><div class="pc-alias">Oma Martha</div></div>
<div class="pc-action">&#128172;</div>
</div>
</div>
</div>
<!-- Column 3: Tags -->
<div>
<div class="meta-label" style="margin-bottom:5px;">Schlagw&ouml;rter</div>
<div style="display:flex;flex-wrap:wrap;gap:3px;">
<div class="tag-chip">Feldpost</div>
<div class="tag-chip">2. Weltkrieg</div>
<div class="tag-chip">Lazarett</div>
<div class="tag-chip">Breslau</div>
</div>
</div>
</div>
</div>
<div style="border-top:1px solid #e4e2d7;"></div>
<!-- Split view (pushed down) -->
<div class="split" style="flex:1;">
<div style="flex:1;">
<div class="pdf-area" style="height:100%;">
<div class="paper" style="width:50%;min-height:140px;position:relative;">
<div style="font-size:7px;color:#8A8070;font-style:italic;opacity:.7;">Liebe Martha,</div>
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div>
</div>
</div>
</div>
<div class="split-handle"></div>
<div style="width:360px;display:flex;flex-direction:column;border-left:1px solid #e4e2d7;">
<div style="flex:1;overflow-y:auto;padding:6px 8px;background:#fff;display:flex;flex-direction:column;gap:4px;">
<div class="tblock"><div class="tblock-head"><div class="num">1</div> Anrede</div><div class="tblock-body">Liebe Martha,</div></div>
<div class="tblock active"><div class="tblock-head" style="background:rgba(0,199,177,.08);"><div class="num">2</div> Hauptteil</div><div class="tblock-body">ich schreibe Dir heute...<span class="trans-cursor"></span></div></div>
</div>
<div class="status-bar"><span>Block 2 aktiv</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════════
MOBILE — COLLAPSED
═══════════════════════════════════════════════════════════════════════════ -->
<div class="scr" id="mobile-collapsed">
<div class="scr-head"><h3>Mobile &mdash; collapsed</h3><span class="scr-id">S3</span></div>
<div class="scr-desc">On mobile, the topbar shows the title, a compact &ldquo;Details&rdquo; toggle, and the transcribe mode pill. Person chips are hidden (shown in drawer instead). The toggle provides a 44px tap target.</div>
<div class="previews">
<div class="prev-col">
<div class="bp-lbl">Mobile &middot; 320px &middot; collapsed</div>
<div class="phone" style="height:560px;">
<div class="pst"><b>14:23</b><span>&bull;&bull;&bull; WiFi &#128267;</span></div>
<div class="pb">
<div style="background:#fff;border-bottom:1px solid #e4e2d7;padding:6px 12px;">
<div style="display:flex;align-items:center;gap:6px;">
<span style="font-size:11px;color:var(--color-text-muted);">&larr;</span>
<span style="font-family:Georgia,serif;font-size:11px;color:var(--navy);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Brief von Heinrich, 14.05.1943</span>
<div class="details-toggle" style="font-size:8px;padding:4px 10px 4px 8px;min-height:28px;">Details <span class="chevron-icon" style="font-size:8px;">&#9660;</span></div>
<span style="font-size:7px;font-weight:700;padding:2px 6px;border-radius:3px;background:var(--turquoise);color:var(--navy);">Transkr.</span>
</div>
</div>
<!-- PDF strip -->
<div style="background:#D4D0C8;height:110px;display:flex;align-items:center;justify-content:center;border-bottom:2px solid var(--turquoise);">
<div style="background:#FFFEF8;width:40%;padding:6px 8px;box-shadow:0 1px 3px rgba(0,0,0,.1);border-radius:1px;position:relative;">
<div style="font-size:5px;color:#8A8070;font-style:italic;opacity:.7;">Liebe Martha,</div>
<div style="height:2px;background:#C4BDB0;opacity:.4;margin:2px 0;width:80%;"></div>
<div style="height:1.5px;background:#C4BDB0;opacity:.2;margin:1px 0;width:90%;"></div>
<div style="height:1.5px;background:#C4BDB0;opacity:.2;margin:1px 0;width:70%;"></div>
</div>
</div>
<!-- Transcript blocks -->
<div style="flex:1;overflow-y:auto;padding:8px 12px;background:#fff;">
<div style="border:1px solid var(--color-border);border-radius:5px;overflow:hidden;margin-bottom:6px;">
<div style="padding:3px 8px;font-size:6px;font-weight:600;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;background:var(--sand);"><div style="width:12px;height:12px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;">1</div> Anrede <span style="margin-left:auto;color:var(--green-dark);">&#10003;</span></div>
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;">Liebe Martha,</div>
</div>
<div style="border:1px solid var(--turquoise);border-radius:5px;overflow:hidden;margin-bottom:6px;box-shadow:0 0 0 1px var(--turquoise);">
<div style="padding:3px 8px;font-size:6px;font-weight:600;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;background:rgba(0,199,177,.08);"><div style="width:12px;height:12px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;">2</div> Hauptteil</div>
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;">ich schreibe Dir heute aus dem Lazarett in Breslau...<span class="trans-cursor"></span></div>
</div>
<div style="border:1px solid var(--color-border);border-radius:5px;overflow:hidden;margin-bottom:6px;">
<div style="padding:3px 8px;font-size:6px;font-weight:600;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;background:var(--sand);"><div style="width:12px;height:12px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;">3</div> Familie</div>
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;color:var(--color-text-muted);font-style:italic;">noch leer</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════════
MOBILE — EXPANDED
═══════════════════════════════════════════════════════════════════════════ -->
<div class="scr" id="mobile-expanded">
<div class="scr-head"><h3>Mobile &mdash; expanded</h3><span class="scr-id">S4</span></div>
<div class="scr-desc">The drawer opens as a single-column stack below the topbar. Person cards are full-width with 44px minimum touch targets. Conversation links are always visible (no hover-reveal on touch). Tags wrap naturally. The PDF strip and transcript blocks are pushed down.</div>
<div class="previews">
<div class="prev-col">
<div class="bp-lbl">Mobile &middot; 320px &middot; expanded</div>
<div class="phone" style="height:620px;">
<div class="pst"><b>14:23</b><span>&bull;&bull;&bull; WiFi &#128267;</span></div>
<div class="pb">
<div style="background:#fff;border-bottom:none;padding:6px 12px;">
<div style="display:flex;align-items:center;gap:6px;">
<span style="font-size:11px;color:var(--color-text-muted);">&larr;</span>
<span style="font-family:Georgia,serif;font-size:11px;color:var(--navy);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Brief von Heinrich, 14.05.1943</span>
<div class="details-toggle open" style="font-size:8px;padding:4px 10px 4px 8px;min-height:28px;">Details <span class="chevron-icon" style="font-size:8px;">&#9660;</span></div>
<span style="font-size:7px;font-weight:700;padding:2px 6px;border-radius:3px;background:var(--turquoise);color:var(--navy);">Transkr.</span>
</div>
</div>
<!-- Expanded drawer: single-column -->
<div style="background:var(--color-page);border-bottom:1px solid #e4e2d7;padding:10px 12px;">
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px;">
<div style="flex:1;min-width:100px;">
<div class="meta-label">Datum</div>
<div class="meta-value">14. Mai 1943</div>
</div>
<div style="flex:1;min-width:100px;">
<div class="meta-label">Ort</div>
<div class="meta-value">Breslau</div>
</div>
</div>
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px;">
<div style="flex:1;min-width:100px;">
<div class="meta-label">Archivstandort</div>
<div class="meta-value">Ordner A3, Schublade 2</div>
</div>
</div>
<div class="meta-label" style="margin-bottom:3px;">Absender</div>
<div class="person-card" style="margin-bottom:4px;min-height:36px;">
<div class="pc-av" style="background:var(--navy);color:var(--mint);">HR</div>
<div><div class="pc-name">Heinrich Raddatz</div><div class="pc-alias">Opa Heinrich</div></div>
<div class="pc-action" style="opacity:1;">&#128172;</div>
</div>
<div class="meta-label" style="margin-bottom:3px;">Empf&auml;nger</div>
<div class="person-card" style="margin-bottom:6px;min-height:36px;">
<div class="pc-av" style="background:#5A3080;color:#fff;">MR</div>
<div><div class="pc-name">Martha Raddatz</div><div class="pc-alias">Oma Martha</div></div>
<div class="pc-action" style="opacity:1;">&#128172;</div>
</div>
<div class="meta-label" style="margin-bottom:3px;">Schlagw&ouml;rter</div>
<div style="display:flex;flex-wrap:wrap;gap:3px;">
<div class="tag-chip">Feldpost</div>
<div class="tag-chip">2. Weltkrieg</div>
<div class="tag-chip">Lazarett</div>
<div class="tag-chip">Breslau</div>
</div>
</div>
<!-- PDF strip (pushed down) -->
<div style="background:#D4D0C8;height:70px;display:flex;align-items:center;justify-content:center;border-bottom:2px solid var(--turquoise);">
<div style="background:#FFFEF8;width:40%;padding:4px 6px;box-shadow:0 1px 3px rgba(0,0,0,.1);border-radius:1px;">
<div style="height:2px;background:#C4BDB0;opacity:.4;margin:2px 0;width:80%;"></div>
<div style="height:1.5px;background:#C4BDB0;opacity:.2;margin:1px 0;width:90%;"></div>
</div>
</div>
<!-- Blocks -->
<div style="flex:1;overflow-y:auto;padding:8px 12px;background:#fff;">
<div style="border:1px solid var(--color-border);border-radius:5px;overflow:hidden;margin-bottom:6px;">
<div style="padding:3px 8px;font-size:6px;font-weight:600;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;background:var(--sand);"><div style="width:12px;height:12px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;">1</div> Anrede</div>
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;">Liebe Martha,</div>
</div>
<div style="border:1px solid var(--turquoise);border-radius:5px;overflow:hidden;margin-bottom:6px;box-shadow:0 0 0 1px var(--turquoise);">
<div style="padding:3px 8px;font-size:6px;font-weight:600;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;background:rgba(0,199,177,.08);"><div style="width:12px;height:12px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;">2</div> Hauptteil</div>
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;">ich schreibe Dir heute...<span class="trans-cursor"></span></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════════
NON-TRANSCRIBE MODE (standard document view)
═══════════════════════════════════════════════════════════════════════════ -->
<div class="scr" id="non-transcribe">
<div class="scr-head"><h3>Non-transcribe mode &mdash; standard document view</h3><span class="scr-id">S5</span></div>
<div class="scr-desc">Outside of transcribe mode, the document detail page uses the <strong>same &ldquo;Details&rdquo; drawer pattern</strong>. No bottom panel. The PDF gets the full remaining viewport. Discussion and transcription are accessible via dedicated buttons (Transkribieren enters split mode, Annotieren enters annotation mode). One consistent pattern everywhere &mdash; no mode-dependent UI structure.</div>
<div class="previews">
<div class="prev-col">
<div class="bp-lbl">Desktop &middot; non-transcribe &middot; collapsed</div>
<div class="desk" style="min-height:400px;">
<div class="fa-nav">
<div class="fa-logo">FAMILIENARCHIV</div>
<div class="fa-link">Dokumente</div>
<div class="fa-link">Personen</div>
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
</div>
<div class="topbar">
<div class="topbar-main">
<div class="back">&larr;</div>
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 2px;"></div>
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
<div style="flex:1"></div>
<div class="fa-chip"><div class="av navy">HR</div> <a href="#">Heinrich R.</a></div>
<span style="font-size:7px;color:var(--color-text-muted);">&rarr;</span>
<div class="fa-chip"><div class="av purple">MR</div> <a href="#">Martha R.</a></div>
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
<div class="details-toggle">Details <span class="chevron-icon">&#9660;</span></div>
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
<div class="fa-topbar-btn ghost">&#9998; Transkribieren</div>
<div class="fa-topbar-btn ghost">Annotieren</div>
<div class="fa-topbar-btn ghost" style="padding:3px 5px;">
<span style="font-size:8px;">&#9998;</span>
</div>
<div style="width:14px;height:14px;border-radius:3px;background:var(--sand);display:flex;align-items:center;justify-content:center;font-size:6px;">&#8681;</div>
</div>
</div>
<!-- Full PDF — no bottom panel -->
<div class="pdf-area" style="flex:1;">
<div class="paper" style="width:45%;min-height:240px;">
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div><div class="ps" style="width:70%;"></div>
<div class="pl" style="width:84%;"></div><div class="ps" style="width:90%;"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ═══ AGENT TABLE ═══ -->
<div class="agent">
<h4>Expandable metadata header &middot; Implementation spec</h4>
<pre>/* 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). */</pre>
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
<tr class="grp"><td colspan="3">Toggle button</td></tr>
<tr><td>Label</td><td>"Details" + ▼ chevron</td><td>i18n key: topbar_details_toggle</td></tr>
<tr><td>Size</td><td>min 44×28px tap target, text-xs font-semibold</td><td>WCAG 2.5.5 compliant target size</td></tr>
<tr><td>Inactive style</td><td>border border-line, text-ink-2, bg-transparent</td><td>Subtle, doesn't compete with action buttons</td></tr>
<tr><td>Active style</td><td>bg-primary, text-primary-fg, border-primary</td><td>Clear open state — matches annotate button pattern</td></tr>
<tr><td>Chevron</td><td>▼ (U+25BC), rotates 180deg when open</td><td>CSS transition transform 200ms</td></tr>
<tr><td>Aria</td><td>aria-expanded, aria-controls="metadata-drawer"</td><td>Button role implicit</td></tr>
<tr><td>Keyboard</td><td>Ctrl+M toggles, Escape closes</td><td>Ctrl+M matches "M for metadata"</td></tr>
<tr class="grp"><td colspan="3">Drawer (expanded)</td></tr>
<tr><td>Layout</td><td>grid 3-col desktop (1fr 1fr 1fr), 1-col mobile</td><td>bg:color-page, border-top:line, p:12px 16px</td></tr>
<tr><td>Animation</td><td>Svelte slide transition, 200ms</td><td>Or CSS max-height 0↔auto with overflow:hidden</td></tr>
<tr><td>Push behavior</td><td>In document flow, pushes split view down</td><td>Not absolute/overlay — no clipping</td></tr>
<tr><td>ID</td><td>id="metadata-drawer"</td><td>role="region", aria-label="Dokumentmetadaten"</td></tr>
<tr class="grp"><td colspan="3">Drawer content — Details column</td></tr>
<tr><td>Date</td><td>Long format (14. Mai 1943), icon 📅</td><td>Uses existing formatDate utility</td></tr>
<tr><td>Location</td><td>Text, icon 📍</td><td>Only shown if doc.creationLocation exists</td></tr>
<tr><td>Archive</td><td>Text, icon 📁</td><td>Only shown if doc.archiveLocation exists</td></tr>
<tr class="grp"><td colspan="3">Drawer content — Persons column</td></tr>
<tr><td>Person card</td><td>border:line, radius:5px, bg:page, hover:accent-bg</td><td>Entire card is a link to /persons/{id}</td></tr>
<tr><td>Card content</td><td>18px avatar + full name + alias</td><td>Alias from person.alias field</td></tr>
<tr><td>Conversation icon</td><td>💬 appears on hover (desktop), always visible (mobile)</td><td>Links to /korrespondenz?senderId=X&amp;receiverId=Y</td></tr>
<tr><td>Mobile card height</td><td>min-height 44px</td><td>WCAG touch target compliance</td></tr>
<tr class="grp"><td colspan="3">Drawer content — Tags column</td></tr>
<tr><td>Chip</td><td>text-[10px]/600, sand bg, uppercase, radius:3px</td><td>Click → navigate to /?tag=X</td></tr>
<tr><td>Hover</td><td>bg-primary, text-primary-fg</td><td>Visual feedback that chips are interactive</td></tr>
<tr class="grp"><td colspan="3">Non-transcribe mode</td></tr>
<tr><td>Toggle shown?</td><td>Yes — always present in topbar</td><td>Consistent UX across all modes</td></tr>
<tr><td>Bottom panel</td><td>Removed entirely — all modes</td><td>Drawer is the single metadata pattern everywhere</td></tr>
</tbody></table>
</div>
<!-- ═══ LLM IMPLEMENTATION GUIDE ═══ -->
<div class="llm">
<h2>Implementation Guide &mdash; Expandable Metadata Header</h2>
<h3>1. Scope</h3>
<p>Add a labeled &ldquo;Details&rdquo; toggle button and a collapsible metadata drawer to <code>DocumentTopBar.svelte</code>. This spec covers <strong>only the header expansion</strong> &mdash; the transcription split view, inline comments, and history toolbar are covered in the companion spec (<code>annotation-transcription-final-spec.html</code>).</p>
<h3>2. State</h3>
<ul>
<li><code>let metadataOpen = $state(false)</code> in <code>DocumentTopBar.svelte</code>.</li>
<li>Toggle on button click. Close on Escape key. Toggle on Ctrl+M.</li>
<li>State is local &mdash; not persisted. Defaults to closed on every page load.</li>
</ul>
<h3>3. Component Changes</h3>
<table>
<thead><tr><th>Component</th><th>Change</th></tr></thead>
<tbody>
<tr><td><code>DocumentTopBar.svelte</code></td><td>Add <code>metadataOpen</code> state, toggle button, and conditional drawer div. New props needed: <code>doc.creationLocation</code>, <code>doc.archiveLocation</code>, <code>doc.tags</code>, full sender/receiver objects with aliases.</td></tr>
<tr><td><code>MetadataDrawer.svelte</code> (new)</td><td>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.</td></tr>
<tr><td><code>PersonChipRow.svelte</code></td><td>No change. Still renders the abbreviated chips in the main topbar row.</td></tr>
<tr><td><code>DocumentBottomPanel.svelte</code></td><td>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.</td></tr>
</tbody>
</table>
<h3>4. Toggle Button Placement</h3>
<p>In the topbar&rsquo;s flex row, the button goes <strong>after the person chips divider and before the action buttons divider</strong>:</p>
<p><code>← | Title | chips → | <strong>Details ▼</strong> | Transkribieren | Annotieren | Edit | Download</code></p>
<p>On mobile (&lt;375px), person chips are hidden. The toggle sits after the title, before the transcribe pill.</p>
<h3>5. Drawer Markup</h3>
<ul>
<li>Use Svelte <code>slide</code> transition: <code>{#if metadataOpen}&lt;div transition:slide={{ duration: 200 }}&gt;</code></li>
<li>The drawer is a direct child of the topbar wrapper, below the main flex row.</li>
<li>Desktop: <code>grid grid-cols-3 gap-4 p-3 sm:p-4 bg-canvas border-t border-line</code></li>
<li>Mobile: <code>grid grid-cols-1 gap-3 p-3 bg-canvas border-t border-line</code></li>
<li>Breakpoint for 3-col: <code>md:grid-cols-3</code> (768px+).</li>
</ul>
<h3>6. Person Cards in Drawer</h3>
<ul>
<li>Each card: avatar (using <code>personAvatarColor</code>), full name (font-serif), alias (text-xs text-ink-2).</li>
<li>Card wraps an <code>&lt;a href="/persons/{id}"&gt;</code>.</li>
<li>Conversation icon: separate <code>&lt;a&gt;</code> inside the card, absolute-positioned or flex-end. Links to <code>/korrespondenz?senderId={sender.id}&amp;receiverId={receiver.id}</code>.</li>
<li>On mobile: <code>min-h-[44px]</code> for touch targets. Conversation icon always visible (<code>opacity-100</code> instead of <code>opacity-0 group-hover:opacity-100</code>).</li>
</ul>
<h3>7. Tag Chips in Drawer</h3>
<ul>
<li>Each tag: <code>&lt;a href="/?tag={tag.name}"&gt;</code> with <code>text-[10px] font-semibold uppercase bg-muted rounded px-2 py-0.5 hover:bg-primary hover:text-primary-fg transition-colors</code>.</li>
<li><code>aria-label="Dokumente mit Schlagwort {tag.name} filtern"</code>.</li>
</ul>
<h3>8. Accessibility</h3>
<ul>
<li>Toggle button: <code>aria-expanded={metadataOpen}</code>, <code>aria-controls="metadata-drawer"</code>.</li>
<li>Drawer: <code>id="metadata-drawer"</code>, <code>role="region"</code>, <code>aria-label="Dokumentmetadaten"</code>.</li>
<li>Person cards: accessible name includes full name + &ldquo;Zur Personenseite&rdquo;.</li>
<li>Conversation link: <code>aria-label="Korrespondenz zwischen {sender} und {receiver} anzeigen"</code>.</li>
<li>Tab order: toggle button &rarr; drawer contents (when open) &rarr; action buttons.</li>
<li>Escape closes drawer and returns focus to the toggle button.</li>
</ul>
<h3>9. i18n Keys</h3>
<table>
<thead><tr><th>Key</th><th>de</th><th>en</th></tr></thead>
<tbody>
<tr><td><code>topbar_details_toggle</code></td><td>Details</td><td>Details</td></tr>
<tr><td><code>topbar_details_date</code></td><td>Datum</td><td>Date</td></tr>
<tr><td><code>topbar_details_location</code></td><td>Entstehungsort</td><td>Location</td></tr>
<tr><td><code>topbar_details_archive</code></td><td>Archivstandort</td><td>Archive location</td></tr>
<tr><td><code>topbar_details_sender</code></td><td>Absender</td><td>Sender</td></tr>
<tr><td><code>topbar_details_receivers</code></td><td>Empf&auml;nger</td><td>Receivers</td></tr>
<tr><td><code>topbar_details_tags</code></td><td>Schlagw&ouml;rter</td><td>Tags</td></tr>
<tr><td><code>topbar_details_conversation</code></td><td>Korrespondenz anzeigen</td><td>View correspondence</td></tr>
</tbody>
</table>
</div>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,804 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Transcription Read Mode — Final Spec (Clean Split)</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&family=Tinos:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet"/>
<style>
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--navy:#012851;--mint:#A1DCD8;--sand:#F0EFE9;--turquoise:#00C7B1;--accent-bg:rgba(161,220,216,.12);--blue-tint:#E6F1FB;--blue:#2D7DD2;--blue-dark:#185FA5;--green-tint:#E8F5EA;--green:#3D8C4A;--green-dark:#2E6E39;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--color-error:#DC4C3E;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--font-read:'Tinos',Georgia,serif;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.doc-header p{font-size:13px;color:var(--color-text-muted);max-width:680px;}
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
.pill-g{background:var(--green-tint);color:var(--green-dark);}
.section{margin-bottom:64px;}
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
.scr{margin-bottom:56px;}
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;flex-direction:column;min-height:520px;}
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
/* ── FA chrome ── */
.fa-nav{height:32px;background:var(--navy);display:flex;align-items:center;padding:0 12px;gap:8px;flex-shrink:0;}
.fa-logo{font-size:7px;font-weight:900;color:#fff;letter-spacing:.8px;border-bottom:2px solid var(--mint);padding-bottom:1px;}
.fa-link{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:700;text-transform:uppercase;}
.fa-nav-r{margin-left:auto;display:flex;gap:5px;align-items:center;}
.fa-av{width:16px;height:16px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5);}
/* ── Topbar ── */
.fa-topbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:0 12px;gap:6px;height:42px;flex-shrink:0;}
.fa-topbar .back{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);}
.fa-topbar .title{font-family:Georgia,serif;font-size:11px;color:var(--navy);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.fa-chip{display:inline-flex;align-items:center;gap:2px;padding:1px 5px 1px 2px;background:var(--sand);border:1px solid #e4e2d7;border-radius:8px;white-space:nowrap;font-size:7px;color:var(--color-text);}
.fa-chip .av{width:12px;height:12px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;flex-shrink:0;}
.fa-chip .av.navy{background:var(--navy);color:var(--mint);}
.fa-chip .av.purple{background:#5A3080;color:#fff;}
.fa-topbar-btn{font-size:7px;font-weight:600;padding:3px 8px;border-radius:4px;border:1px solid var(--navy);color:var(--navy);background:transparent;display:flex;align-items:center;gap:3px;cursor:pointer;}
.fa-topbar-btn.ghost{border-color:var(--color-border);color:var(--color-text-muted);font-weight:500;}
.details-toggle{display:inline-flex;align-items:center;gap:3px;padding:2px 8px 2px 6px;border-radius:4px;font-size:7px;font-weight:600;color:var(--color-text-muted);cursor:pointer;border:1px solid var(--color-border);background:transparent;white-space:nowrap;}
/* ── Mode switcher ── */
.mode-sw{display:inline-flex;border:1px solid var(--color-border);border-radius:4px;overflow:hidden;font-size:6px;font-weight:600;}
.mode-sw span{padding:3px 8px;cursor:pointer;color:var(--color-text-muted);}
.mode-sw span.active{background:var(--navy);color:#fff;border-color:var(--navy);}
.mode-sw span:not(.active):hover{background:var(--sand);}
/* ── PDF area ── */
.pdf-area{background:#D4D0C8;flex:1;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;}
.paper{background:#FFFEF8;box-shadow:0 2px 8px rgba(0,0,0,.14);border-radius:1px;padding:9px 11px;display:flex;flex-direction:column;gap:2px;position:relative;}
.pl{height:3px;background:#C4BDB0;border-radius:1px;opacity:.5;margin-bottom:2px;}
.ps{height:2px;background:#C4BDB0;border-radius:1px;opacity:.28;margin-bottom:1.5px;}
/* ── Annotation rects (dimmed in read mode) ── */
.ann-rect{position:absolute;border-radius:2px;}
.ann-rect.trans{border:1.5px solid var(--turquoise);background:rgba(0,199,177,.1);}
.ann-rect.trans.dimmed{border-color:rgba(0,199,177,.3);background:rgba(0,199,177,.04);}
/* ── Split ── */
.split{display:flex;flex:1;overflow:hidden;}
.split-handle{width:4px;background:var(--color-border);cursor:col-resize;flex-shrink:0;display:flex;align-items:center;justify-content:center;}
.split-handle::after{content:'';width:2px;height:20px;background:var(--color-text-muted);border-radius:1px;opacity:.3;}
/* ── Read-mode text ── */
.read-text{font-family:var(--font-read);font-size:10px;line-height:1.85;color:var(--color-text);padding:16px 20px;}
.read-text .para{margin-bottom:10px;cursor:pointer;padding:2px 4px;border-radius:3px;transition:background .15s ease;}
.read-text .para:hover{background:rgba(0,199,177,.06);}
.read-text .para.highlighted{background:rgba(0,199,177,.1);transition:background .3s ease;}
.read-text .greeting{font-style:italic;}
.read-text .closing{margin-top:12px;text-align:right;font-style:italic;}
.read-text .illegible{color:var(--color-text-muted);font-style:italic;font-size:9px;}
/* ── Status bar ── */
.status-bar{background:var(--sand);border-top:1px solid #e4e2d7;height:18px;display:flex;align-items:center;padding:0 8px;font-size:7px;color:var(--color-text-muted);gap:8px;flex-shrink:0;}
/* ── Scroll sync highlight on PDF ── */
.ann-rect.highlight-flash{border-color:var(--turquoise) !important;background:rgba(0,199,177,.18) !important;transition:all .3s ease;animation:flash-fade 1.5s ease-out forwards;}
@keyframes flash-fade{0%{background:rgba(0,199,177,.18);border-color:var(--turquoise);}100%{background:rgba(0,199,177,.04);border-color:rgba(0,199,177,.3);}}
/* ── Agent table ── */
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
.agent pre{font-family:var(--font-mono);font-size:10px;color:#444440;margin-bottom:16px;line-height:1.8;white-space:pre-wrap;}
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
.at thead tr{border-bottom:1px solid #2A2A26;}.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}.at tr:last-child td{border-bottom:none;}.at td:first-child{color:#7A7A72;}.at td:nth-child(2){color:#E8E8E2;font-weight:500;}.at td:nth-child(3){color:#5A5A55;}.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
.llm{background:var(--color-page);border:2px solid var(--navy);border-radius:var(--radius-xl);padding:32px 40px;margin-top:64px;}
.llm h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;color:var(--navy);}
.llm h3{font-size:14px;font-weight:600;margin:20px 0 8px;color:var(--color-text);}
.llm h4{font-size:12px;font-weight:600;margin:16px 0 6px;color:var(--color-text);}
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
.llm li{margin-bottom:4px;}
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
.llm pre{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:12px 16px;border-radius:var(--radius-md);overflow-x:auto;margin:8px 0 12px;line-height:1.6;}
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
.llm td{color:var(--color-text-muted);}
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
</style>
</head>
<body>
<div class="doc">
<div class="doc-header">
<div>
<h1>Transcription Read Mode &mdash; Final Spec</h1>
<p>A focused reading experience for completed transcriptions. Uses the <strong>clean split</strong> layout: PDF scan on the left, flowing prose on the right. All editing chrome is stripped &mdash; no block borders, no comment threads, no toolbars. The text reads like a letter, not like an editing interface.</p>
</div>
<div class="doc-meta">
Familienarchiv<br/>
<span class="pill pill-g">Final</span><br/>
2026-04-05 &middot; @leonievoss
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════════
SECTION: DESIGN RATIONALE
═══════════════════════════════════════════════════════════════════════════ -->
<div class="section">
<div class="section-title">Design rationale</div>
<p class="prose">Transcribe mode is for editing. But most visits to a completed transcription are for <strong>reading</strong> &mdash; 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.</p>
<p class="prose">The <strong>clean split</strong> 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.</p>
<div style="display:flex;gap:12px;flex-wrap:wrap;font-size:12px;margin-top:20px;">
<div style="background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-md);padding:10px 14px;flex:1;min-width:180px;">
<div style="font-weight:600;color:var(--green);margin-bottom:4px;">Kept</div>
<ul style="padding-left:16px;color:var(--color-text-muted);line-height:1.8;">
<li>Transcription text (flowing prose)</li>
<li>PDF scan viewer (same position)</li>
<li>Topbar (title, Details toggle, person chips)</li>
<li>Mode switcher (Lesen / Bearbeiten)</li>
<li>Resizable split handle</li>
<li>Scroll sync (click paragraph &harr; PDF)</li>
</ul>
</div>
<div style="background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-md);padding:10px 14px;flex:1;min-width:180px;">
<div style="font-weight:600;color:var(--color-error);margin-bottom:4px;">Removed</div>
<ul style="padding-left:16px;color:var(--color-text-muted);line-height:1.8;">
<li>Block borders &amp; numbered badges</li>
<li>Contenteditable / cursor</li>
<li>Comment threads &amp; &ldquo;Kommentieren&rdquo; buttons</li>
<li>Presence dots &amp; user indicators</li>
<li>Hint strip (&ldquo;Markiere eine Passage&hellip;&rdquo;)</li>
<li>Drag handles, sort controls</li>
<li>Auto-save status indicator</li>
<li>Add-block CTA</li>
<li>History / &ldquo;Verlauf&rdquo; button</li>
</ul>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════════
S1: DESKTOP — READ MODE
═══════════════════════════════════════════════════════════════════════════ -->
<div class="scr" id="s1">
<div class="scr-head"><h3>S1 &mdash; Desktop read mode</h3><span class="scr-id">S1</span></div>
<div class="scr-desc">Side-by-side split: PDF scan on the left with dimmed annotation outlines, flowing serif prose on the right. The mode switcher shows &ldquo;Lesen&rdquo; as active. Clicking a paragraph briefly highlights the matching PDF region (turquoise flash, 1.5s fade).</div>
<div class="scr-var"><strong>Primary reading state</strong> &mdash; the default view when a transcription exists.</div>
<div class="previews">
<div class="prev-col">
<div class="bp-lbl">Desktop &middot; 1040px</div>
<div class="desk">
<div class="fa-nav">
<div class="fa-logo">FAMILIENARCHIV</div>
<div class="fa-link">Dokumente</div>
<div class="fa-link">Personen</div>
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
</div>
<div class="fa-topbar">
<div class="back">&larr;</div>
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
<div style="flex:1"></div>
<div class="fa-chip"><div class="av navy">HR</div> Heinrich R.</div>
<span style="font-size:7px;color:var(--color-text-muted);">&rarr;</span>
<div class="fa-chip"><div class="av purple">MR</div> Martha R.</div>
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
<div class="details-toggle">Details &#9660;</div>
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
<!-- Mode switcher: Lesen active -->
<div class="mode-sw"><span class="active">Lesen</span><span>Bearbeiten</span></div>
</div>
<div class="split" style="height:430px;">
<!-- PDF scan — annotations dimmed -->
<div style="flex:1;display:flex;flex-direction:column;">
<div class="pdf-area" style="flex:1;">
<div class="paper" style="width:55%;min-height:260px;position:relative;">
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div><div class="ps" style="width:70%;"></div>
<div class="pl" style="width:84%;"></div><div class="ps" style="width:90%;"></div><div class="ps" style="width:60%;"></div>
<div class="pl" style="width:75%;"></div><div class="ps" style="width:82%;"></div>
<div style="font-size:6px;color:#8A8070;margin-top:6px;text-align:right;opacity:.7;">Dein Heinrich</div>
<!-- Dimmed annotation outlines — no numbered badges -->
<div class="ann-rect trans dimmed" style="left:2%;top:0%;width:50%;height:10%;"></div>
<div class="ann-rect trans dimmed" style="left:2%;top:14%;width:96%;height:32%;"></div>
<div class="ann-rect trans dimmed" style="left:2%;top:50%;width:96%;height:22%;"></div>
<div class="ann-rect trans dimmed" style="left:20%;top:80%;width:60%;height:12%;"></div>
</div>
</div>
</div>
<div class="split-handle"></div>
<!-- Right panel: flowing prose, no block chrome -->
<div style="width:400px;display:flex;flex-direction:column;border-left:1px solid #e4e2d7;background:#fff;">
<div class="read-text" style="flex:1;overflow-y:auto;">
<div class="para greeting">Liebe Martha,</div>
<div class="para">ich schreibe Dir heute aus dem Lazarett in Breslau. Mach Dir keine Sorgen, es geht mir den Umst&auml;nden entsprechend gut. Der Arzt sagt <span class="illegible">[unleserlich]</span> Wochen noch dauern wird.</div>
<div class="para">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&szlig;ig in der Schule sein.</div>
<div class="para closing">In ewiger Liebe,<br/>Dein Heinrich</div>
</div>
<div class="status-bar">
<span>4 Abschnitte</span>
<span style="margin-left:auto;">Zuletzt bearbeitet: Oma Inge, 14:23</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>S1 &middot; Desktop read mode</h4>
<pre>/* 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 */</pre>
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
<tr class="grp"><td colspan="3">Text panel</td></tr>
<tr><td>Font</td><td>Tinos (serif), 16px, line-height 1.85</td><td>Generous reading typography</td></tr>
<tr><td>Padding</td><td>24px 32px</td><td>Comfortable margins like a book page</td></tr>
<tr><td>Paragraphs</td><td>One &lt;p&gt; per transcription block</td><td>mb-4 between paragraphs</td></tr>
<tr><td>[unleserlich]</td><td>italic, text-ink-2, font-size: 0.9em</td><td>Subtle but readable</td></tr>
<tr><td>Hover</td><td>Subtle turquoise bg at 6% opacity</td><td>Hint that paragraphs are clickable</td></tr>
<tr class="grp"><td colspan="3">PDF panel</td></tr>
<tr><td>Annotations</td><td>Dimmed: border-opacity 0.3, bg-opacity 0.04</td><td>Still clickable for scroll-sync</td></tr>
<tr><td>Badges</td><td>Hidden</td><td>No numbered circles in read mode</td></tr>
<tr><td>Scroll sync</td><td>Click para &rarr; PDF scrolls, flash 1.5s</td><td>Turquoise tint at 18% &rarr; fade to 4%</td></tr>
<tr class="grp"><td colspan="3">Status bar</td></tr>
<tr><td>Content</td><td>"N Abschnitte &middot; Zuletzt bearbeitet: Name, HH:mm"</td><td>Uses most recent updated_at across blocks</td></tr>
<tr><td>Height</td><td>28px, sand background</td><td>Same as transcribe mode status bar</td></tr>
</tbody></table>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════════
S2: DESKTOP — SCROLL SYNC INTERACTION
═══════════════════════════════════════════════════════════════════════════ -->
<div class="scr" id="s2">
<div class="scr-head"><h3>S2 &mdash; Scroll sync highlight</h3><span class="scr-id">S2</span></div>
<div class="scr-desc">The user clicked the second paragraph. The matching PDF annotation flashes with a turquoise highlight that fades over 1.5 seconds. The paragraph itself gets a subtle background tint. This is the <strong>only interactive element</strong> in read mode &mdash; no editing, no comments.</div>
<div class="scr-var"><strong>Click-to-highlight interaction</strong> &mdash; bidirectional scroll sync between text and scan.</div>
<div class="previews">
<div class="prev-col">
<div class="bp-lbl">Desktop &middot; 1040px</div>
<div class="desk">
<div class="fa-nav">
<div class="fa-logo">FAMILIENARCHIV</div>
<div class="fa-link">Dokumente</div>
<div class="fa-link">Personen</div>
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
</div>
<div class="fa-topbar">
<div class="back">&larr;</div>
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
<div style="flex:1"></div>
<div class="fa-chip"><div class="av navy">HR</div> Heinrich R.</div>
<span style="font-size:7px;color:var(--color-text-muted);">&rarr;</span>
<div class="fa-chip"><div class="av purple">MR</div> Martha R.</div>
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
<div class="details-toggle">Details &#9660;</div>
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
<div class="mode-sw"><span class="active">Lesen</span><span>Bearbeiten</span></div>
</div>
<div class="split" style="height:430px;">
<div style="flex:1;display:flex;flex-direction:column;">
<div class="pdf-area" style="flex:1;">
<div class="paper" style="width:55%;min-height:260px;position:relative;">
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div><div class="ps" style="width:70%;"></div>
<div class="pl" style="width:84%;"></div><div class="ps" style="width:90%;"></div><div class="ps" style="width:60%;"></div>
<div class="pl" style="width:75%;"></div><div class="ps" style="width:82%;"></div>
<div style="font-size:6px;color:#8A8070;margin-top:6px;text-align:right;opacity:.7;">Dein Heinrich</div>
<div class="ann-rect trans dimmed" style="left:2%;top:0%;width:50%;height:10%;"></div>
<!-- THIS annotation is highlighted — user clicked paragraph 2 -->
<div class="ann-rect trans highlight-flash" style="left:2%;top:14%;width:96%;height:32%;"></div>
<div class="ann-rect trans dimmed" style="left:2%;top:50%;width:96%;height:22%;"></div>
<div class="ann-rect trans dimmed" style="left:20%;top:80%;width:60%;height:12%;"></div>
</div>
</div>
</div>
<div class="split-handle"></div>
<div style="width:400px;display:flex;flex-direction:column;border-left:1px solid #e4e2d7;background:#fff;">
<div class="read-text" style="flex:1;overflow-y:auto;">
<div class="para greeting">Liebe Martha,</div>
<!-- THIS paragraph is highlighted -->
<div class="para highlighted">ich schreibe Dir heute aus dem Lazarett in Breslau. Mach Dir keine Sorgen, es geht mir den Umst&auml;nden entsprechend gut. Der Arzt sagt <span class="illegible">[unleserlich]</span> Wochen noch dauern wird.</div>
<div class="para">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&szlig;ig in der Schule sein.</div>
<div class="para closing">In ewiger Liebe,<br/>Dein Heinrich</div>
</div>
<div class="status-bar">
<span>4 Abschnitte</span>
<span style="margin-left:auto;">Zuletzt bearbeitet: Oma Inge, 14:23</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>S2 &middot; Scroll sync highlight</h4>
<pre>/* 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. */</pre>
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
<tr class="grp"><td colspan="3">Highlight animation</td></tr>
<tr><td>Paragraph bg</td><td>rgba(0,199,177,.10)</td><td>Turquoise at 10%, fades to 0</td></tr>
<tr><td>Annotation flash</td><td>rgba(0,199,177,.18) &rarr; .04</td><td>Border returns to .3 opacity</td></tr>
<tr><td>Duration</td><td>1.5s ease-out</td><td>CSS animation, no JS timers needed</td></tr>
<tr><td>Scroll behavior</td><td>smooth, block: center</td><td>scrollIntoView({ behavior: 'smooth', block: 'center' })</td></tr>
<tr class="grp"><td colspan="3">Data binding</td></tr>
<tr><td>Paragraph attr</td><td>data-block-id="{annotation_id}"</td><td>Links text to PDF annotation</td></tr>
<tr><td>Annotation attr</td><td>data-annotation-id="{id}"</td><td>Already exists from transcribe mode</td></tr>
</tbody></table>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════════
S3: DESKTOP — NO TRANSCRIPTION YET
═══════════════════════════════════════════════════════════════════════════ -->
<div class="scr" id="s3">
<div class="scr-head"><h3>S3 &mdash; No transcription (empty state)</h3><span class="scr-id">S3</span></div>
<div class="scr-desc">When no transcription blocks exist, the mode switcher defaults to &ldquo;Bearbeiten&rdquo; and the right panel shows an empty state encouraging the user to start transcribing. The &ldquo;Lesen&rdquo; tab is disabled (greyed out).</div>
<div class="scr-var"><strong>Empty state</strong> &mdash; no read mode available until at least one block exists.</div>
<div class="previews">
<div class="prev-col">
<div class="bp-lbl">Desktop &middot; 1040px</div>
<div class="desk" style="min-height:400px;">
<div class="fa-nav">
<div class="fa-logo">FAMILIENARCHIV</div>
<div class="fa-link">Dokumente</div>
<div class="fa-link">Personen</div>
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
</div>
<div class="fa-topbar">
<div class="back">&larr;</div>
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
<div style="flex:1"></div>
<div class="details-toggle">Details &#9660;</div>
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
<!-- Mode switcher: Bearbeiten active, Lesen disabled -->
<div class="mode-sw"><span style="opacity:.35;cursor:not-allowed;">Lesen</span><span class="active">Bearbeiten</span></div>
</div>
<div class="split" style="height:320px;">
<div style="flex:1;display:flex;flex-direction:column;">
<div class="pdf-area" style="flex:1;">
<div class="paper" style="width:55%;min-height:200px;">
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div>
<div style="font-size:6px;color:#8A8070;margin-top:6px;text-align:right;opacity:.7;">Dein Heinrich</div>
</div>
</div>
</div>
<div class="split-handle"></div>
<div style="width:400px;display:flex;flex-direction:column;border-left:1px solid #e4e2d7;background:#fff;align-items:center;justify-content:center;text-align:center;padding:32px;">
<div style="width:48px;height:48px;border-radius:50%;background:var(--sand);display:flex;align-items:center;justify-content:center;margin-bottom:12px;">
<span style="font-size:20px;opacity:.5;">&#9998;</span>
</div>
<div style="font-family:var(--font-sans);font-size:11px;font-weight:600;color:var(--color-text);margin-bottom:4px;">Noch keine Transkription</div>
<div style="font-family:var(--font-sans);font-size:10px;color:var(--color-text-muted);max-width:200px;line-height:1.6;">Zeichne Bereiche auf dem Scan und tippe den Text ab, um eine Transkription zu erstellen.</div>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>S3 &middot; Empty state</h4>
<pre>/* 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. */</pre>
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
<tr class="grp"><td colspan="3">Empty state</td></tr>
<tr><td>Icon</td><td>Pencil in 48px sand circle</td><td>Centered vertically in panel</td></tr>
<tr><td>Title</td><td>"Noch keine Transkription"</td><td>i18n key: transcription_empty_title</td></tr>
<tr><td>Description</td><td>"Zeichne Bereiche auf dem Scan&hellip;"</td><td>i18n key: transcription_empty_desc</td></tr>
<tr class="grp"><td colspan="3">Mode switcher</td></tr>
<tr><td>Lesen tab</td><td>Disabled: opacity .35, not-allowed</td><td>Enabled when block count &gt; 0</td></tr>
<tr><td>Default</td><td>"Bearbeiten" active</td><td>Enters transcribe mode directly</td></tr>
</tbody></table>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════════
S4: MOBILE — READ MODE
═══════════════════════════════════════════════════════════════════════════ -->
<div class="scr" id="s4">
<div class="scr-head"><h3>S4 &mdash; Mobile read mode</h3><span class="scr-id">S4</span></div>
<div class="scr-desc">On mobile, the split view becomes vertical: a collapsible PDF strip (70px) at the top, flowing text below. The mode switcher abbreviates &ldquo;Bearbeiten&rdquo; to &ldquo;Bearb.&rdquo; to fit. Tapping the PDF strip expands it; tapping again collapses. Paragraphs are still tappable for scroll-sync.</div>
<div class="scr-var"><strong>Mobile layout</strong> &mdash; stacked vertical with collapsible scan strip.</div>
<div class="previews">
<div class="prev-col">
<div class="bp-lbl">Mobile &middot; 320px</div>
<div class="phone" style="height:620px;">
<div class="pst"><b>14:23</b><span>&bull;&bull;&bull; WiFi &#128267;</span></div>
<div class="pb">
<div style="background:#fff;border-bottom:1px solid #e4e2d7;padding:6px 12px;display:flex;align-items:center;gap:6px;">
<span style="font-size:11px;color:var(--color-text-muted);">&larr;</span>
<span style="font-family:Georgia,serif;font-size:11px;color:var(--navy);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Brief von Heinrich, 14.05.1943</span>
<div class="mode-sw"><span class="active" style="font-size:7px;">Lesen</span><span style="font-size:7px;">Bearb.</span></div>
</div>
<!-- Collapsible PDF strip -->
<div style="background:#D4D0C8;height:70px;display:flex;align-items:center;justify-content:center;border-bottom:1px solid #C4C0B8;position:relative;">
<div style="background:#FFFEF8;width:42%;padding:5px 7px;box-shadow:0 1px 3px rgba(0,0,0,.1);border-radius:1px;">
<div style="font-size:5px;color:#8A8070;font-style:italic;opacity:.7;">Liebe Martha,</div>
<div style="height:2px;background:#C4BDB0;opacity:.4;margin:2px 0;width:80%;"></div>
<div style="height:1.5px;background:#C4BDB0;opacity:.2;margin:1px 0;width:90%;"></div>
</div>
<!-- Expand hint -->
<div style="position:absolute;bottom:3px;right:8px;font-size:6px;color:var(--color-text-muted);opacity:.6;">&#9650; Scan vergr&ouml;&szlig;ern</div>
</div>
<!-- Flowing text -->
<div style="flex:1;overflow-y:auto;padding:16px 16px;background:#fff;">
<div style="font-family:'Tinos',Georgia,serif;font-size:13px;line-height:1.9;color:var(--color-text);">
<p style="margin-bottom:10px;font-style:italic;">Liebe Martha,</p>
<p style="margin-bottom:10px;">ich schreibe Dir heute aus dem Lazarett in Breslau. Mach Dir keine Sorgen, es geht mir den Umst&auml;nden entsprechend gut. Der Arzt sagt <em style="color:var(--color-text-muted);">[unleserlich]</em> Wochen noch dauern wird.</p>
<p style="margin-bottom:10px;">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&szlig;ig in der Schule sein.</p>
<p style="text-align:right;font-style:italic;">In ewiger Liebe,<br/>Dein Heinrich</p>
</div>
</div>
<!-- Status bar -->
<div style="background:var(--sand);border-top:1px solid #e4e2d7;padding:4px 12px;font-size:8px;color:var(--color-text-muted);display:flex;justify-content:space-between;">
<span>4 Abschnitte</span>
<span>Oma Inge, 14:23</span>
</div>
</div>
</div>
</div>
<!-- Mobile: expanded PDF strip -->
<div class="prev-col">
<div class="bp-lbl">Mobile &middot; 320px &middot; scan expanded</div>
<div class="phone" style="height:620px;">
<div class="pst"><b>14:23</b><span>&bull;&bull;&bull; WiFi &#128267;</span></div>
<div class="pb">
<div style="background:#fff;border-bottom:1px solid #e4e2d7;padding:6px 12px;display:flex;align-items:center;gap:6px;">
<span style="font-size:11px;color:var(--color-text-muted);">&larr;</span>
<span style="font-family:Georgia,serif;font-size:11px;color:var(--navy);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Brief von Heinrich, 14.05.1943</span>
<div class="mode-sw"><span class="active" style="font-size:7px;">Lesen</span><span style="font-size:7px;">Bearb.</span></div>
</div>
<!-- Expanded PDF strip -->
<div style="background:#D4D0C8;height:200px;display:flex;align-items:center;justify-content:center;border-bottom:1px solid #C4C0B8;position:relative;">
<div style="background:#FFFEF8;width:50%;padding:8px 10px;box-shadow:0 2px 6px rgba(0,0,0,.12);border-radius:1px;position:relative;">
<div style="font-size:6px;color:#8A8070;font-style:italic;opacity:.7;margin-bottom:3px;">Liebe Martha,</div>
<div class="pl" style="width:90%;height:2px;"></div><div class="ps" style="width:85%;height:1.5px;"></div><div class="ps" style="width:92%;height:1.5px;"></div>
<div class="pl" style="width:78%;height:2px;"></div><div class="ps" style="width:88%;height:1.5px;"></div>
<div class="pl" style="width:84%;height:2px;"></div><div class="ps" style="width:70%;height:1.5px;"></div>
<div style="font-size:5px;color:#8A8070;margin-top:4px;text-align:right;opacity:.7;">Dein Heinrich</div>
<!-- Dimmed annotations visible when expanded -->
<div class="ann-rect trans dimmed" style="left:3%;top:0%;width:45%;height:12%;"></div>
<div class="ann-rect trans dimmed" style="left:3%;top:16%;width:94%;height:35%;"></div>
</div>
<!-- Collapse hint -->
<div style="position:absolute;bottom:3px;right:8px;font-size:6px;color:var(--color-text-muted);opacity:.6;">&#9660; Scan verkleinern</div>
</div>
<!-- Text below (shorter) -->
<div style="flex:1;overflow-y:auto;padding:14px 16px;background:#fff;">
<div style="font-family:'Tinos',Georgia,serif;font-size:13px;line-height:1.9;color:var(--color-text);">
<p style="margin-bottom:10px;font-style:italic;">Liebe Martha,</p>
<p style="margin-bottom:10px;">ich schreibe Dir heute aus dem Lazarett in Breslau&hellip;</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>S4 &middot; Mobile read mode</h4>
<pre>/* 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. */</pre>
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
<tr class="grp"><td colspan="3">PDF strip</td></tr>
<tr><td>Collapsed height</td><td>70px</td><td>Shows miniature scan preview</td></tr>
<tr><td>Expanded height</td><td>~50vh or max 300px</td><td>Smooth CSS transition (300ms ease)</td></tr>
<tr><td>Toggle</td><td>Tap anywhere on strip</td><td>Hint text in bottom-right corner</td></tr>
<tr class="grp"><td colspan="3">Text area</td></tr>
<tr><td>Font</td><td>Tinos, 15px, line-height 1.9</td><td>Slightly larger than desktop for touch</td></tr>
<tr><td>Padding</td><td>16px</td><td>Full-width, no wasted space</td></tr>
<tr class="grp"><td colspan="3">Mode switcher</td></tr>
<tr><td>Labels</td><td>"Lesen | Bearb."</td><td>Abbreviated to fit mobile topbar</td></tr>
<tr><td>Font size</td><td>10px</td><td>Compact but readable</td></tr>
<tr class="grp"><td colspan="3">Scroll sync on mobile</td></tr>
<tr><td>Tap paragraph</td><td>Expand PDF if collapsed, then highlight</td><td>Auto-expand + scroll + flash</td></tr>
<tr><td>Tap annotation</td><td>Collapse PDF, scroll text to paragraph</td><td>Smart collapse after showing match</td></tr>
</tbody></table>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════════
S5: MODE SWITCHER DETAIL
═══════════════════════════════════════════════════════════════════════════ -->
<div class="scr" id="s5">
<div class="scr-head"><h3>S5 &mdash; Mode switcher states</h3><span class="scr-id">S5</span></div>
<div class="scr-desc">The mode switcher is a segmented control in the topbar that replaces the previous &ldquo;Transkribieren&rdquo; turquoise button. It governs three visual states: <strong>Lesen</strong> (read mode, this spec), <strong>Bearbeiten</strong> (transcribe/edit mode), and the existing <strong>Annotieren</strong> button (yellow comment annotations). The modes are mutually exclusive.</div>
<div class="scr-var"><strong>Segmented control</strong> &mdash; replacing the turquoise &ldquo;Transkribieren&rdquo; button.</div>
<div class="previews" style="gap:16px;">
<!-- State 1: Lesen active -->
<div class="prev-col" style="align-items:center;">
<div class="bp-lbl">Lesen active</div>
<div style="background:#fff;border:1px solid #e4e2d7;border-radius:var(--radius-md);padding:12px 16px;display:flex;align-items:center;gap:8px;">
<div class="mode-sw" style="font-size:8px;"><span class="active" style="padding:4px 12px;">Lesen</span><span style="padding:4px 12px;">Bearbeiten</span></div>
<div style="width:1px;height:20px;background:#e4e2d7;"></div>
<div class="fa-topbar-btn ghost" style="font-size:8px;padding:4px 10px;">&#9998; Annotieren</div>
</div>
</div>
<!-- State 2: Bearbeiten active -->
<div class="prev-col" style="align-items:center;">
<div class="bp-lbl">Bearbeiten active</div>
<div style="background:#fff;border:1px solid #e4e2d7;border-radius:var(--radius-md);padding:12px 16px;display:flex;align-items:center;gap:8px;">
<div class="mode-sw" style="font-size:8px;"><span style="padding:4px 12px;">Lesen</span><span class="active" style="padding:4px 12px;">Bearbeiten</span></div>
<div style="width:1px;height:20px;background:#e4e2d7;"></div>
<div class="fa-topbar-btn ghost" style="font-size:8px;padding:4px 10px;">&#9998; Annotieren</div>
</div>
</div>
<!-- State 3: Annotieren active -->
<div class="prev-col" style="align-items:center;">
<div class="bp-lbl">Annotieren active (separate button)</div>
<div style="background:#fff;border:1px solid #e4e2d7;border-radius:var(--radius-md);padding:12px 16px;display:flex;align-items:center;gap:8px;">
<div class="mode-sw" style="font-size:8px;"><span style="padding:4px 12px;opacity:.5;">Lesen</span><span style="padding:4px 12px;opacity:.5;">Bearbeiten</span></div>
<div style="width:1px;height:20px;background:#e4e2d7;"></div>
<div class="fa-topbar-btn" style="font-size:8px;padding:4px 10px;background:var(--navy);color:#fff;border-color:var(--navy);">&#9998; Annotieren</div>
</div>
</div>
<!-- State 4: Lesen disabled (no blocks) -->
<div class="prev-col" style="align-items:center;">
<div class="bp-lbl">No transcription blocks</div>
<div style="background:#fff;border:1px solid #e4e2d7;border-radius:var(--radius-md);padding:12px 16px;display:flex;align-items:center;gap:8px;">
<div class="mode-sw" style="font-size:8px;"><span style="padding:4px 12px;opacity:.35;cursor:not-allowed;">Lesen</span><span class="active" style="padding:4px 12px;">Bearbeiten</span></div>
<div style="width:1px;height:20px;background:#e4e2d7;"></div>
<div class="fa-topbar-btn ghost" style="font-size:8px;padding:4px 10px;">&#9998; Annotieren</div>
</div>
</div>
</div>
<div class="agent">
<h4>S5 &middot; Mode switcher states</h4>
<pre>/* 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 */</pre>
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
<tr class="grp"><td colspan="3">Segmented control</td></tr>
<tr><td>Items</td><td>"Lesen" | "Bearbeiten"</td><td>Mobile: "Lesen" | "Bearb."</td></tr>
<tr><td>Active style</td><td>bg:navy, color:#fff</td><td>Rounded within the pill</td></tr>
<tr><td>Inactive style</td><td>bg:transparent, color:muted</td><td>Hover: bg:sand</td></tr>
<tr><td>Dimmed style</td><td>Both items at opacity .5</td><td>Only when annotate mode is active</td></tr>
<tr><td>Disabled (Lesen)</td><td>opacity .35, cursor not-allowed</td><td>When no transcription blocks exist</td></tr>
<tr class="grp"><td colspan="3">Annotieren button</td></tr>
<tr><td>Default</td><td>Ghost style (border:muted)</td><td>Same as current topbar button</td></tr>
<tr><td>Active</td><td>bg:navy, color:#fff</td><td>Filled state when annotate mode on</td></tr>
<tr><td>Visibility</td><td>canAnnotate &amp;&amp; isPdf</td><td>Hidden for non-PDF documents</td></tr>
<tr class="grp"><td colspan="3">Accessibility</td></tr>
<tr><td>Segmented</td><td>role="tablist", children role="tab"</td><td>aria-selected on active tab</td></tr>
<tr><td>Annotieren</td><td>aria-pressed={annotateMode}</td><td>Toggle button semantics</td></tr>
<tr><td>Disabled tab</td><td>aria-disabled="true", tabindex="-1"</td><td>Not focusable when no blocks</td></tr>
</tbody></table>
</div>
</div>
<!-- ═══ LLM IMPLEMENTATION GUIDE ═══ -->
<div class="llm">
<h2>Implementation Guide &mdash; Transcription Read Mode (Clean Split)</h2>
<h3>1. Overview</h3>
<p>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.</p>
<h3>2. Mode State Management</h3>
<p>The document detail page manages a single <code>mode</code> state that governs the entire view:</p>
<pre>let mode: 'read' | 'transcribe' | 'annotate' = $state(
blocks.length > 0 ? 'read' : 'transcribe'
);</pre>
<ul>
<li><code>mode === 'read'</code> &rarr; this spec (flowing prose, dimmed annotations, no editing)</li>
<li><code>mode === 'transcribe'</code> &rarr; annotation-transcription-final-spec (block cards, contenteditable)</li>
<li><code>mode === 'annotate'</code> &rarr; yellow comment annotations on PDF</li>
<li>The segmented control in the topbar toggles between <code>'read'</code> and <code>'transcribe'</code>.</li>
<li>The &ldquo;Annotieren&rdquo; button toggles <code>'annotate'</code> on/off. When entering annotate mode, the previous mode (read or transcribe) is stored so the user returns to it when exiting.</li>
</ul>
<h3>3. Component Architecture</h3>
<h4>3a. New components</h4>
<table>
<thead><tr><th>Component</th><th>Purpose</th></tr></thead>
<tbody>
<tr><td><code>TranscriptionReadView.svelte</code></td><td>Right panel content in read mode. Renders transcription blocks as flowing prose (<code>&lt;article&gt;</code> with <code>&lt;p&gt;</code> per block). Handles scroll-sync click handlers.</td></tr>
<tr><td><code>ModeSwitcher.svelte</code></td><td>Segmented control (<code>Lesen | Bearbeiten</code>). Props: <code>mode</code> (bindable), <code>hasBlocks</code> (disables Lesen when false). Emits mode changes.</td></tr>
</tbody>
</table>
<h4>3b. Modified components</h4>
<table>
<thead><tr><th>Component</th><th>Change</th></tr></thead>
<tbody>
<tr><td><code>DocumentTopBar.svelte</code></td><td>Replace the <code>Transkribieren</code> button with <code>ModeSwitcher</code>. Keep the <code>Annotieren</code> button separate. Add <code>mode</code> bindable prop.</td></tr>
<tr><td><code>[id]/+page.svelte</code></td><td>Add <code>mode</code> state. Conditionally render <code>TranscriptionReadView</code> vs <code>TranscriptionEditView</code> in the right panel based on <code>mode</code>.</td></tr>
<tr><td><code>PdfAnnotationLayer.svelte</code></td><td>Accept <code>dimmed</code> prop. When true: annotation rects get opacity 0.3, no numbered badges, but remain clickable for scroll-sync.</td></tr>
</tbody>
</table>
<h3>4. Read View Rendering</h3>
<h4>4a. Text rendering</h4>
<ul>
<li>Fetch transcription blocks from <code>GET /api/documents/{id}/transcription-blocks</code> (same endpoint as transcribe mode).</li>
<li>Render each block as a <code>&lt;p data-block-id="{block.annotation_id}"&gt;</code> inside an <code>&lt;article&gt;</code> element.</li>
<li>Typography: <code>font-family: Tinos, Georgia, serif; font-size: 16px; line-height: 1.85</code>.</li>
<li>Padding: <code>24px 32px</code> for comfortable reading margins.</li>
<li><code>[unleserlich]</code> markers: detect via regex <code>/\[unleserlich\]/g</code> and wrap in <code>&lt;em class="text-ink-2 italic text-[0.9em]"&gt;</code>.</li>
<li>Text is <strong>not</strong> contenteditable. No cursor, no selection highlights, no editing.</li>
</ul>
<h4>4b. Scroll sync</h4>
<ul>
<li>Each paragraph has a click handler that dispatches a <code>highlight-annotation</code> event with the <code>annotation_id</code>.</li>
<li>The PDF viewer listens for this event, scrolls to the annotation, and applies a CSS animation (<code>flash-fade</code>, 1.5s ease-out).</li>
<li>Reverse direction: clicking a dimmed annotation on the PDF dispatches <code>highlight-paragraph</code> with the <code>annotation_id</code>. The text panel scrolls the matching paragraph into view and applies a background highlight that fades.</li>
<li>Use <code>scrollIntoView({ behavior: 'smooth', block: 'center' })</code> for both directions.</li>
<li>The highlight CSS animation: <code>background rgba(0,199,177,.10) &rarr; transparent</code> over 1.5s.</li>
</ul>
<h3>5. PDF Annotations in Read Mode</h3>
<ul>
<li>Turquoise annotation rectangles are rendered but <strong>dimmed</strong>: border opacity 0.3, background opacity 0.04.</li>
<li>No numbered badges (the <code>.ann-num</code> elements are hidden via <code>display: none</code>).</li>
<li>Annotations remain clickable &mdash; they trigger scroll-sync to the matching paragraph.</li>
<li>When an annotation is flash-highlighted (via scroll-sync), it briefly returns to full opacity before fading back to dimmed.</li>
<li>Yellow comment annotations are not shown in read mode (they belong to annotate mode only).</li>
</ul>
<h3>6. Status Bar</h3>
<ul>
<li>Positioned at the bottom of the text panel (not the full viewport).</li>
<li>Content: <code>"{n} Abschnitte &middot; Zuletzt bearbeitet: {userName}, {HH:mm}"</code></li>
<li>The &ldquo;Zuletzt bearbeitet&rdquo; timestamp is the most recent <code>updated_at</code> across all transcription blocks for this document.</li>
<li>The user name comes from the <code>updated_by</code> field of that most recently updated block.</li>
<li>i18n keys: <code>transcription_status_sections</code>, <code>transcription_status_last_edited</code>.</li>
</ul>
<h3>7. Mobile Layout (viewport &lt; 768px)</h3>
<ul>
<li>The side-by-side split becomes vertical: PDF strip at top, text below.</li>
<li>PDF strip collapsed height: <code>70px</code>. Shows a miniature scan preview.</li>
<li>Tap strip to expand (~50vh, max 300px). Tap again to collapse. Smooth CSS transition (300ms ease).</li>
<li>Expand/collapse hint text in bottom-right corner of the strip.</li>
<li>Mode switcher abbreviation: &ldquo;Lesen | Bearb.&rdquo; (i18n key: <code>mode_edit_short</code>).</li>
<li>Scroll-sync on paragraph tap: if PDF is collapsed, auto-expand first, then scroll to annotation.</li>
<li>Text typography: <code>15px</code> (slightly larger than desktop) with <code>line-height: 1.9</code>.</li>
</ul>
<h3>8. Empty State</h3>
<ul>
<li>When <code>transcription_blocks</code> count is 0, &ldquo;Lesen&rdquo; tab is disabled (<code>opacity: 0.35</code>, <code>cursor: not-allowed</code>, <code>aria-disabled="true"</code>).</li>
<li>Mode defaults to <code>'transcribe'</code>.</li>
<li>Right panel shows empty state: pencil icon in 48px sand circle, title (&ldquo;Noch keine Transkription&rdquo;), description (&ldquo;Zeichne Bereiche auf dem Scan&hellip;&rdquo;).</li>
<li>As soon as the first block is saved, &ldquo;Lesen&rdquo; becomes clickable. Mode does not auto-switch.</li>
</ul>
<h3>9. i18n Keys</h3>
<table>
<thead><tr><th>Key</th><th>de</th><th>en</th></tr></thead>
<tbody>
<tr><td><code>mode_read</code></td><td>Lesen</td><td>Read</td></tr>
<tr><td><code>mode_edit</code></td><td>Bearbeiten</td><td>Edit</td></tr>
<tr><td><code>mode_edit_short</code></td><td>Bearb.</td><td>Edit</td></tr>
<tr><td><code>transcription_status_sections</code></td><td>{n} Abschnitte</td><td>{n} sections</td></tr>
<tr><td><code>transcription_status_last_edited</code></td><td>Zuletzt bearbeitet: {name}, {time}</td><td>Last edited: {name}, {time}</td></tr>
<tr><td><code>transcription_empty_title</code></td><td>Noch keine Transkription</td><td>No transcription yet</td></tr>
<tr><td><code>transcription_empty_desc</code></td><td>Zeichne Bereiche auf dem Scan und tippe den Text ab, um eine Transkription zu erstellen.</td><td>Draw regions on the scan and type the text to create a transcription.</td></tr>
<tr><td><code>scan_expand</code></td><td>Scan vergr&ouml;&szlig;ern</td><td>Expand scan</td></tr>
<tr><td><code>scan_collapse</code></td><td>Scan verkleinern</td><td>Collapse scan</td></tr>
</tbody>
</table>
<h3>10. Accessibility</h3>
<ul>
<li>Mode switcher: <code>role="tablist"</code> with <code>role="tab"</code> children, <code>aria-selected</code> on active tab.</li>
<li>Disabled &ldquo;Lesen&rdquo; tab: <code>aria-disabled="true"</code>, <code>tabindex="-1"</code>.</li>
<li>Read view text: semantic HTML &mdash; <code>&lt;article&gt;</code> wrapping <code>&lt;p&gt;</code> elements. No <code>contenteditable</code>.</li>
<li>Paragraphs are clickable: <code>role="button"</code>, <code>tabindex="0"</code>, <code>aria-label="Abschnitt N &mdash; klicken um Scan-Position anzuzeigen"</code>.</li>
<li>PDF strip toggle on mobile: <code>role="button"</code>, <code>aria-expanded="{expanded}"</code>, <code>aria-label="Scan {expanded ? 'verkleinern' : 'vergr&ouml;&szlig;ern'}"</code>.</li>
<li>Scroll-sync animations respect <code>prefers-reduced-motion</code>: skip the 1.5s fade, apply instant highlight that disappears after 200ms.</li>
</ul>
</div>
</div>
</body>
</html>

3
frontend/.gitignore vendored
View File

@@ -31,3 +31,6 @@ src/lib/paraglide
# src/lib/generated/api.ts
src/lib/paraglide_bak*
/coverage
# Playwright auth state — regenerated at the start of each CI run via auth.setup.ts
e2e/.auth/

View File

@@ -17,6 +17,7 @@ bun.lockb
/src/lib/generated/
/src/lib/paraglide/
/src/lib/paraglide_bak*/
/src/paraglide/
# Test artifacts
/test-results/

View File

@@ -37,6 +37,57 @@ test.describe('Accessibility — authenticated pages', () => {
}
});
test.describe('Accessibility — dark mode (system preference)', () => {
for (const { name, path } of AUTHENTICATED_PAGES) {
test(`${name} page has no wcag2a/wcag2aa violations in prefers-color-scheme: dark`, async ({
browser
}) => {
const context = await browser.newContext({
colorScheme: 'dark',
storageState: 'e2e/.auth/user.json'
});
const page = await context.newPage();
await page.goto(path);
await page.waitForSelector('[data-hydrated]');
const results = await buildAxe(page).analyze();
if (results.violations.length > 0) {
const summary = results.violations
.map((v) => `[${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} node(s))`)
.join('\n');
console.log(`\nAccessibility violations on ${name} (dark/media):\n${summary}`);
}
await context.close();
expect(results.violations).toEqual([]);
});
}
});
test.describe('Accessibility — dark mode (manual toggle)', () => {
for (const { name, path } of AUTHENTICATED_PAGES) {
test(`${name} page has no wcag2a/wcag2aa violations with data-theme='dark'`, async ({
page
}) => {
await page.goto(path);
await page.waitForSelector('[data-hydrated]');
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
const results = await buildAxe(page).analyze();
if (results.violations.length > 0) {
const summary = results.violations
.map((v) => `[${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} node(s))`)
.join('\n');
console.log(`\nAccessibility violations on ${name} (dark/manual):\n${summary}`);
}
expect(results.violations).toEqual([]);
});
}
});
test.describe('Accessibility — login page', () => {
test.use({ storageState: { cookies: [], origins: [] } });

View File

@@ -0,0 +1,29 @@
import { test, expect } from '@playwright/test';
import { login } from './helpers/auth';
/**
* Classic Split layout — verifies the right column visibility guard.
*
* The right column (DropZone + NeedsMetadata queue) is only rendered when
* `canWrite === true` or there are incomplete docs. A read-only user with a
* complete archive must never see an empty 300px ghost column.
*/
test.describe('Dashboard Classic Split — write user', () => {
test('right column is visible for admin user', async ({ page }) => {
await page.goto('/');
await expect(page.getByTestId('dashboard-right-column')).toBeVisible();
});
});
test.describe('Dashboard Classic Split — read-only user', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test.beforeEach(async ({ page }) => {
await login(page, 'reader', 'reader123');
});
test('right column is absent for read-only user with no incomplete docs', async ({ page }) => {
await expect(page.getByTestId('dashboard-right-column')).not.toBeVisible();
});
});

View File

@@ -0,0 +1,88 @@
import { test, expect } from '@playwright/test';
// Expected focus ring resolved colors
// Light: --c-focus-ring: #012851 (brand-navy)
const FOCUS_RING_LIGHT = 'rgb(1, 40, 81)';
// Dark: --c-focus-ring: #a1dcd8 (brand-mint)
const FOCUS_RING_DARK = 'rgb(161, 220, 216)';
test.describe('Focus ring token — CSS custom property', () => {
test('--c-focus-ring is defined in light mode', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
const value = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue('--c-focus-ring').trim()
);
expect(value).toBe('#012851');
});
test('--c-focus-ring is defined in dark mode', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
const value = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue('--c-focus-ring').trim()
);
expect(value).toBe('#a1dcd8');
});
});
test.describe('Focus ring — header interactive elements', () => {
test('ThemeToggle has brand-navy ring in light mode', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
await page.getByRole('button', { name: /dark mode|dunkelmodus/i }).focus();
const boxShadow = await page.evaluate(
() => getComputedStyle(document.activeElement as HTMLElement).boxShadow
);
expect(boxShadow).toContain(FOCUS_RING_LIGHT);
});
test('AppNav link has brand-mint ring in dark mode', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
// Focus first desktop nav link
await page.locator('header nav').getByRole('link').first().focus();
const boxShadow = await page.evaluate(
() => getComputedStyle(document.activeElement as HTMLElement).boxShadow
);
expect(boxShadow).toContain(FOCUS_RING_DARK);
});
});
test.describe('Focus ring — form inputs', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('login username input has brand-mint ring in dark mode', async ({ page }) => {
await page.goto('/login');
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
await page.locator('#username').focus();
const boxShadow = await page.evaluate(
() => getComputedStyle(document.activeElement as HTMLElement).boxShadow
);
expect(boxShadow).toContain(FOCUS_RING_DARK);
});
});
test.describe('Focus ring — PersonTypeahead', () => {
test('PersonTypeahead input has brand-navy ring in light mode', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
// Open advanced filter panel to expose the sender PersonTypeahead
await page.getByRole('button', { name: /filter/i }).click();
await page.waitForSelector('#senderId-search');
await page.locator('#senderId-search').focus();
const boxShadow = await page.evaluate(
() => getComputedStyle(document.activeElement as HTMLElement).boxShadow
);
expect(boxShadow).toContain(FOCUS_RING_LIGHT);
});
});

118
frontend/e2e/header.spec.ts Normal file
View File

@@ -0,0 +1,118 @@
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
// #012851 — brand-navy, set as --c-header in layout.css (both light and dark mode)
const BRAND_NAVY = 'rgb(1, 40, 81)';
test.describe('Header — brand-navy background', () => {
test('header background is brand-navy in light mode', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('navigation')).toBeVisible();
const bg = await page.locator('header').evaluate((el) => getComputedStyle(el).backgroundColor);
expect(bg).toBe(BRAND_NAVY);
});
test('header passes accessibility audit in light mode', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('navigation')).toBeVisible();
const results = await new AxeBuilder({ page }).include('header').analyze();
expect(results.violations).toEqual([]);
});
test('header background stays brand-navy after switching to dark mode', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('navigation')).toBeVisible();
await page
.getByRole('banner')
.getByRole('button', { name: /dark mode/i })
.click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
const bg = await page.locator('header').evaluate((el) => getComputedStyle(el).backgroundColor);
expect(bg).toBe(BRAND_NAVY);
});
test('header passes accessibility audit in dark mode', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('navigation')).toBeVisible();
await page
.getByRole('banner')
.getByRole('button', { name: /dark mode/i })
.click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
const results = await new AxeBuilder({ page }).include('header').analyze();
expect(results.violations).toEqual([]);
});
test('logo text is visible at 375px viewport', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto('/');
await expect(page.getByRole('banner').getByText('Familienarchiv')).toBeVisible();
});
test('hamburger menu opens on tablet viewport (768px)', async ({ page }) => {
await page.setViewportSize({ width: 768, height: 1024 });
await page.goto('/');
await expect(page.getByRole('navigation')).toBeVisible();
const hamburger = page.getByRole('button', { name: /menü öffnen/i });
await expect(hamburger).toBeVisible();
await hamburger.click();
await expect(
page.getByRole('navigation', { name: /mobile/i }).or(page.locator('#mobile-nav'))
).toBeVisible();
});
});
test.describe('Login page — AuthHeader', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('login page has brand-navy header with language switcher', async ({ page }) => {
await page.goto('/login');
const header = page.locator('header');
await expect(header).toBeVisible();
const bg = await header.evaluate((el) => getComputedStyle(el).backgroundColor);
expect(bg).toBe(BRAND_NAVY);
await expect(header.getByRole('button', { name: 'DE' })).toBeVisible();
});
test('login page header passes accessibility audit', async ({ page }) => {
await page.goto('/login');
await expect(page.locator('header')).toBeVisible();
const results = await new AxeBuilder({ page }).include('header').analyze();
expect(results.violations).toEqual([]);
});
});
test.describe('Forgot-password page — AuthHeader', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('forgot-password page has brand-navy header', async ({ page }) => {
await page.goto('/forgot-password');
const header = page.locator('header');
await expect(header).toBeVisible();
const bg = await header.evaluate((el) => getComputedStyle(el).backgroundColor);
expect(bg).toBe(BRAND_NAVY);
});
test('forgot-password page header passes accessibility audit', async ({ page }) => {
await page.goto('/forgot-password');
await expect(page.locator('header')).toBeVisible();
const results = await new AxeBuilder({ page }).include('header').analyze();
expect(results.violations).toEqual([]);
});
});

View File

@@ -80,8 +80,7 @@ test.describe('Password reset', () => {
await page.locator('input[name="currentPassword"]').fill(newPassword);
await page.locator('input[name="newPassword"]').fill(originalPassword);
await page.locator('input[name="confirmPassword"]').fill(originalPassword);
// Profile page has two "Speichern" buttons — the password form is the last one
await page.locator('button[type="submit"]').last().click();
await page.getByTestId('submit-password').click();
// After changing password, auth_token is stale → redirect to login
await expect(page).toHaveURL(/\/login/);

View File

@@ -60,6 +60,48 @@ test.describe('Theme toggle', () => {
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
});
test('header uses --c-header token background in dark mode', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
const headerBg = await page.evaluate(() => {
const header = document.querySelector('header');
return header ? getComputedStyle(header).backgroundColor : null;
});
// --c-header in dark mode = #012851 (brand navy) → rgb(1, 40, 81)
expect(headerBg).toBe('rgb(1, 40, 81)');
});
test('color-scheme is dark when data-theme=dark is set', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
const colorScheme = await page.evaluate(
() => getComputedStyle(document.documentElement).colorScheme
);
expect(colorScheme).toBe('dark');
});
test('color-scheme is dark in prefers-color-scheme: dark media', async ({ browser }) => {
const context = await browser.newContext({
colorScheme: 'dark',
storageState: 'e2e/.auth/user.json'
});
const page = await context.newPage();
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
const colorScheme = await page.evaluate(
() => getComputedStyle(document.documentElement).colorScheme
);
await context.close();
expect(colorScheme).toBe('dark');
});
test('saved theme is applied before first paint (no flash)', async ({ page }) => {
// Set dark theme in localStorage before navigating
await page.goto('/');

View File

@@ -223,6 +223,12 @@
"admin_label_initial_password": "Passwort",
"doc_file_error_preview": "Vorschau konnte nicht geladen werden.",
"doc_download_title": "Herunterladen",
"topbar_back_label": "Zurück zur Dokumentenliste",
"topbar_more_actions": "Weitere Aktionen",
"topbar_overflow_more": "+{count} weitere",
"topbar_overflow_suffix": "weitere",
"topbar_overflow_heading": "Weitere Empfänger",
"topbar_overflow_show": "{count} weitere Empfänger anzeigen",
"doc_tag_filter_title": "Nach {name} filtern",
"doc_conversation_title": "Konversation anzeigen",
"doc_preview_iframe_title": "Dokumentvorschau",
@@ -309,10 +315,14 @@
"comp_expandable_show_less": "Weniger anzeigen",
"error_comment_not_found": "Der Kommentar wurde nicht gefunden.",
"comment_section_title": "Diskussion",
"comment_placeholder": "Kommentar schreiben…",
"comment_placeholder": "Kommentar schreiben… (@Name erwähnen · Enter senden)",
"comment_btn_post": "Senden",
"comment_btn_reply": "Antworten",
"comment_edited_label": "· bearbeitet",
"comment_edited_label": "(Bearbeitet)",
"comment_time_just_now": "gerade eben",
"comment_time_minutes": "vor {count} Minute(n)",
"comment_time_hours": "vor {count} Stunde(n)",
"comment_time_days": "vor {count} Tag(en)",
"comment_panel_title": "Kommentare",
"comment_panel_close": "Schließen",
"doc_panel_tab_metadata": "Metadaten",
@@ -321,6 +331,7 @@
"doc_panel_tab_history": "Verlauf",
"doc_panel_annotate": "Annotieren",
"doc_panel_annotate_stop": "Fertig",
"doc_panel_annotate_hint": "Klicken und ziehen Sie, um einen Bereich zu markieren",
"doc_panel_annotation_thread_title": "Annotation",
"doc_panel_discussion_annotation_tab": "Annotation · Seite {page}",
"pdf_annotations_show": "Annotierungen anzeigen",
@@ -375,6 +386,8 @@
"dashboard_needs_metadata_heading": "Metadaten fehlen",
"dashboard_needs_metadata_show_all": "Alle anzeigen",
"dashboard_recent_heading": "Zuletzt aktiv",
"dashboard_stats_documents": "Dokumente",
"dashboard_stats_persons": "Personen",
"dashboard_resume_label": "Zuletzt geöffnet:",
"dashboard_resume_fallback": "Unbekanntes Dokument",
"doc_status_placeholder": "Platzhalter",
@@ -414,5 +427,34 @@
"notification_empty_history_body": "Hier erscheinen Erwähnungen und Antworten auf deine Kommentare.",
"notification_row_aria": "{actor} {type} auf \u201e{title}\u201c \u2014 {time} \u2014 {readState}",
"notification_read_state_read": "gelesen",
"notification_read_state_unread": "ungelesen"
"notification_read_state_unread": "ungelesen",
"error_transcription_block_not_found": "Der Transkriptionsblock wurde nicht gefunden.",
"error_transcription_block_conflict": "Der Block wurde zwischenzeitlich von jemand anderem geändert. Bitte laden Sie die Seite neu.",
"doc_details_toggle": "Details",
"doc_details_section_details": "Details",
"doc_details_section_persons": "Personen",
"doc_details_section_tags": "Schlagwörter",
"doc_details_field_date": "Datum",
"doc_details_field_sender": "Absender",
"doc_details_field_receivers": "Empfänger",
"doc_details_field_status": "Status",
"doc_details_no_persons": "Keine Personen zugeordnet",
"doc_details_no_tags": "Keine Schlagwörter zugeordnet",
"doc_details_more_receivers": "+{count} weitere",
"transcription_mode_label": "Transkribieren",
"transcription_mode_stop": "Fertig",
"transcription_block_placeholder": "Text hier eingeben...",
"transcription_block_save_saving": "Speichere...",
"transcription_block_save_saved": "Gespeichert",
"transcription_block_save_error": "Nicht gespeichert",
"transcription_block_save_retry": "Erneut versuchen",
"transcription_block_comment_btn": "Kommentieren",
"transcription_block_quote_hint": "Text markieren für Zitat",
"transcription_block_delete_confirm": "Block und alle zugehörigen Kommentare wirklich löschen?",
"transcription_block_history_btn": "Verlauf",
"transcription_empty_cta": "Markiere einen Bereich auf dem Scan, um mit der Transkription zu beginnen",
"transcription_next_block_cta": "Markiere eine weitere Passage im Scan, um Block {number} anzulegen",
"transcription_draw_tooltip": "Klicken und ziehen, um einen Textbereich zu markieren",
"transcription_quote_stale": "Zitat aus älterer Version",
"transcription_block_conflict": "Dieser Block wurde von jemand anderem geändert — bitte neu laden"
}

View File

@@ -223,6 +223,12 @@
"admin_label_initial_password": "Password",
"doc_file_error_preview": "Could not load preview.",
"doc_download_title": "Download",
"topbar_back_label": "Back to document list",
"topbar_more_actions": "More actions",
"topbar_overflow_more": "+{count} more",
"topbar_overflow_suffix": "more",
"topbar_overflow_heading": "More receivers",
"topbar_overflow_show": "Show {count} more receivers",
"doc_tag_filter_title": "Filter by {name}",
"doc_conversation_title": "Show conversation",
"doc_preview_iframe_title": "Document Preview",
@@ -309,10 +315,14 @@
"comp_expandable_show_less": "Show less",
"error_comment_not_found": "The comment could not be found.",
"comment_section_title": "Discussion",
"comment_placeholder": "Write a comment…",
"comment_placeholder": "Write a comment… (@name to mention · Enter to send)",
"comment_btn_post": "Send",
"comment_btn_reply": "Reply",
"comment_edited_label": "· edited",
"comment_edited_label": "(Edited)",
"comment_time_just_now": "just now",
"comment_time_minutes": "{count} minute(s) ago",
"comment_time_hours": "{count} hour(s) ago",
"comment_time_days": "{count} day(s) ago",
"comment_panel_title": "Comments",
"comment_panel_close": "Close",
"doc_panel_tab_metadata": "Metadata",
@@ -321,6 +331,7 @@
"doc_panel_tab_history": "History",
"doc_panel_annotate": "Annotate",
"doc_panel_annotate_stop": "Done",
"doc_panel_annotate_hint": "Click and drag to mark an area",
"doc_panel_annotation_thread_title": "Annotation",
"doc_panel_discussion_annotation_tab": "Annotation · Page {page}",
"pdf_annotations_show": "Show annotations",
@@ -375,6 +386,8 @@
"dashboard_needs_metadata_heading": "Missing Metadata",
"dashboard_needs_metadata_show_all": "Show all",
"dashboard_recent_heading": "Recent Activity",
"dashboard_stats_documents": "Documents",
"dashboard_stats_persons": "Persons",
"dashboard_resume_label": "Last opened:",
"dashboard_resume_fallback": "Unknown document",
"doc_status_placeholder": "Placeholder",
@@ -414,5 +427,34 @@
"notification_empty_history_body": "Mentions and replies to your comments will appear here.",
"notification_row_aria": "{actor} {type} on \"{title}\" — {time} — {readState}",
"notification_read_state_read": "read",
"notification_read_state_unread": "unread"
"notification_read_state_unread": "unread",
"error_transcription_block_not_found": "Transcription block not found.",
"error_transcription_block_conflict": "This block was modified by someone else. Please reload the page.",
"doc_details_toggle": "Details",
"doc_details_section_details": "Details",
"doc_details_section_persons": "Persons",
"doc_details_section_tags": "Tags",
"doc_details_field_date": "Date",
"doc_details_field_sender": "Sender",
"doc_details_field_receivers": "Receivers",
"doc_details_field_status": "Status",
"doc_details_no_persons": "No persons assigned",
"doc_details_no_tags": "No tags assigned",
"doc_details_more_receivers": "+{count} more",
"transcription_mode_label": "Transcribe",
"transcription_mode_stop": "Done",
"transcription_block_placeholder": "Type text here...",
"transcription_block_save_saving": "Saving...",
"transcription_block_save_saved": "Saved",
"transcription_block_save_error": "Not saved",
"transcription_block_save_retry": "Retry",
"transcription_block_comment_btn": "Comment",
"transcription_block_quote_hint": "Select text to quote",
"transcription_block_delete_confirm": "Really delete this block and all its comments?",
"transcription_block_history_btn": "History",
"transcription_empty_cta": "Mark a region on the scan to start transcribing",
"transcription_next_block_cta": "Mark another passage on the scan to create block {number}",
"transcription_draw_tooltip": "Click and drag to mark a text region",
"transcription_quote_stale": "Quote from an older version",
"transcription_block_conflict": "This block was changed by someone else — please reload"
}

View File

@@ -223,6 +223,12 @@
"admin_label_initial_password": "Contraseña",
"doc_file_error_preview": "No se pudo cargar la vista previa.",
"doc_download_title": "Descargar",
"topbar_back_label": "Volver a la lista de documentos",
"topbar_more_actions": "Más acciones",
"topbar_overflow_more": "+{count} más",
"topbar_overflow_suffix": "más",
"topbar_overflow_heading": "Más destinatarios",
"topbar_overflow_show": "Mostrar {count} destinatarios más",
"doc_tag_filter_title": "Filtrar por {name}",
"doc_conversation_title": "Ver conversación",
"doc_preview_iframe_title": "Vista previa del documento",
@@ -309,10 +315,14 @@
"comp_expandable_show_less": "Mostrar menos",
"error_comment_not_found": "El comentario no pudo encontrarse.",
"comment_section_title": "Discusión",
"comment_placeholder": "Escribe un comentario…",
"comment_placeholder": "Escribe un comentario… (@nombre para mencionar · Enter para enviar)",
"comment_btn_post": "Enviar",
"comment_btn_reply": "Responder",
"comment_edited_label": "· editado",
"comment_edited_label": "(Editado)",
"comment_time_just_now": "justo ahora",
"comment_time_minutes": "hace {count} minuto(s)",
"comment_time_hours": "hace {count} hora(s)",
"comment_time_days": "hace {count} día(s)",
"comment_panel_title": "Comentarios",
"comment_panel_close": "Cerrar",
"doc_panel_tab_metadata": "Metadatos",
@@ -321,6 +331,7 @@
"doc_panel_tab_history": "Historial",
"doc_panel_annotate": "Anotar",
"doc_panel_annotate_stop": "Listo",
"doc_panel_annotate_hint": "Haga clic y arrastre para marcar un área",
"doc_panel_annotation_thread_title": "Anotación",
"doc_panel_discussion_annotation_tab": "Anotación · Página {page}",
"pdf_annotations_show": "Mostrar anotaciones",
@@ -375,6 +386,8 @@
"dashboard_needs_metadata_heading": "Metadatos incompletos",
"dashboard_needs_metadata_show_all": "Ver todos",
"dashboard_recent_heading": "Actividad reciente",
"dashboard_stats_documents": "Documentos",
"dashboard_stats_persons": "Personas",
"dashboard_resume_label": "Último abierto:",
"dashboard_resume_fallback": "Documento desconocido",
"doc_status_placeholder": "Marcador",
@@ -414,5 +427,34 @@
"notification_empty_history_body": "Aquí aparecerán las menciones y respuestas a tus comentarios.",
"notification_row_aria": "{actor} {type} en \"{title}\" — {time} — {readState}",
"notification_read_state_read": "leído",
"notification_read_state_unread": "no leído"
"notification_read_state_unread": "no leído",
"error_transcription_block_not_found": "Bloque de transcripción no encontrado.",
"error_transcription_block_conflict": "Este bloque fue modificado por otra persona. Por favor, recargue la página.",
"doc_details_toggle": "Detalles",
"doc_details_section_details": "Detalles",
"doc_details_section_persons": "Personas",
"doc_details_section_tags": "Etiquetas",
"doc_details_field_date": "Fecha",
"doc_details_field_sender": "Remitente",
"doc_details_field_receivers": "Destinatarios",
"doc_details_field_status": "Estado",
"doc_details_no_persons": "No hay personas asignadas",
"doc_details_no_tags": "No hay etiquetas asignadas",
"doc_details_more_receivers": "+{count} más",
"transcription_mode_label": "Transcribir",
"transcription_mode_stop": "Listo",
"transcription_block_placeholder": "Escriba el texto aquí...",
"transcription_block_save_saving": "Guardando...",
"transcription_block_save_saved": "Guardado",
"transcription_block_save_error": "No guardado",
"transcription_block_save_retry": "Reintentar",
"transcription_block_comment_btn": "Comentar",
"transcription_block_quote_hint": "Seleccione texto para citar",
"transcription_block_delete_confirm": "¿Realmente eliminar este bloque y todos sus comentarios?",
"transcription_block_history_btn": "Historial",
"transcription_empty_cta": "Marque una región en el escaneo para comenzar a transcribir",
"transcription_next_block_cta": "Marque otro pasaje en el escaneo para crear el bloque {number}",
"transcription_draw_tooltip": "Haga clic y arrastre para marcar una región de texto",
"transcription_quote_stale": "Cita de una versión anterior",
"transcription_block_conflict": "Este bloque fue cambiado por otra persona — por favor recargue"
}

View File

@@ -0,0 +1,64 @@
import { describe, it, expect, afterEach } from 'vitest';
const { clickOutside } = await import('./clickOutside');
describe('clickOutside action', () => {
const nodes: HTMLElement[] = [];
function makeNode(): HTMLElement {
const node = document.createElement('div');
document.body.appendChild(node);
nodes.push(node);
return node;
}
afterEach(() => {
nodes.forEach((n) => n.remove());
nodes.length = 0;
});
it('registers a capture-phase click listener on mount', () => {
const node = makeNode();
const original = document.addEventListener.bind(document);
let registered = false;
document.addEventListener = (type: string, _fn: unknown, opts: unknown) => {
if (type === 'click' && opts === true) registered = true;
original(type as string, _fn as EventListener, opts as boolean);
};
clickOutside(node);
expect(registered).toBe(true);
document.addEventListener = original;
});
it('dispatches clickoutside when clicking outside the node', () => {
const node = makeNode();
const outside = makeNode();
let fired = false;
node.addEventListener('clickoutside', () => (fired = true));
clickOutside(node);
outside.click();
expect(fired).toBe(true);
});
it('does not dispatch clickoutside when clicking inside the node', () => {
const node = makeNode();
const child = document.createElement('span');
node.appendChild(child);
let fired = false;
node.addEventListener('clickoutside', () => (fired = true));
clickOutside(node);
child.click();
expect(fired).toBe(false);
});
it('removes the listener on destroy', () => {
const node = makeNode();
const outside = makeNode();
let count = 0;
node.addEventListener('clickoutside', () => count++);
const { destroy } = clickOutside(node);
destroy();
outside.click();
expect(count).toBe(0);
});
});

View File

@@ -0,0 +1,15 @@
export function clickOutside(node: HTMLElement): { destroy: () => void } {
function handleClick(event: MouseEvent) {
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
node.dispatchEvent(new CustomEvent('clickoutside'));
}
}
document.addEventListener('click', handleClick, true);
return {
destroy() {
document.removeEventListener('click', handleClick, true);
}
};
}

View File

@@ -1,90 +0,0 @@
<script lang="ts">
import CommentThread from './CommentThread.svelte';
import { m } from '$lib/paraglide/messages.js';
type Props = {
documentId: string;
annotationId: string;
canComment: boolean;
currentUserId: string | null;
canAdmin: boolean;
onClose: () => void;
onCountChange?: (count: number) => void;
};
let {
documentId,
annotationId,
canComment,
currentUserId,
canAdmin,
onClose,
onCountChange
}: Props = $props();
</script>
<!-- Desktop / tablet panel (≥ sm): absolute overlay on the right side -->
<div
class="absolute top-0 right-0 z-50 hidden h-full w-80 flex-col border-l border-line bg-surface shadow-2xl sm:flex"
>
<div class="flex shrink-0 items-center justify-between border-b border-line px-4 py-3">
<h3 class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
{m.comment_panel_title()}
</h3>
<button
onclick={onClose}
aria-label={m.comment_panel_close()}
class="rounded p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex-1 overflow-y-auto p-4">
<CommentThread
documentId={documentId}
annotationId={annotationId}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
loadOnMount={true}
onCountChange={onCountChange}
/>
</div>
</div>
<!-- Mobile modal (< sm): fixed full-screen with slide-up sheet -->
<div class="fixed inset-0 z-50 flex flex-col sm:hidden">
<!-- Semi-transparent backdrop -->
<div class="flex-1 bg-black/40" onclick={onClose} role="presentation"></div>
<!-- Slide-up panel -->
<div class="flex max-h-[80vh] flex-col rounded-t-2xl bg-surface shadow-2xl">
<div class="flex shrink-0 items-center justify-between border-b border-line px-4 py-3">
<h3 class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
{m.comment_panel_title()}
</h3>
<button
onclick={onClose}
aria-label={m.comment_panel_close()}
class="rounded p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex-1 overflow-y-auto p-4">
<CommentThread
documentId={documentId}
annotationId={annotationId}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
loadOnMount={true}
onCountChange={onCountChange}
/>
</div>
</div>
</div>

View File

@@ -10,19 +10,19 @@ type DrawRect = {
let {
annotations = [],
canAnnotate,
canDraw,
color,
blockNumbers = {},
activeAnnotationId = null,
onDraw,
onDelete,
commentCounts,
onAnnotationClick
}: {
annotations: Annotation[];
canAnnotate: boolean;
canDraw: boolean;
color: string;
onDraw: (rect: { x: number; y: number; width: number; height: number }) => void;
onDelete: (id: string) => void;
commentCounts?: Record<string, number>;
blockNumbers?: Record<string, number>;
activeAnnotationId?: string | null;
onDraw: (rect: DrawRect) => void;
onAnnotationClick?: (id: string) => void;
} = $props();
@@ -45,7 +45,7 @@ function getNormalizedCoords(event: PointerEvent, element: HTMLElement): { x: nu
}
function handlePointerDown(event: PointerEvent) {
if (!canAnnotate) return;
if (!canDraw) return;
if ((event.target as HTMLElement).closest('[data-annotation]')) return;
@@ -58,7 +58,7 @@ function handlePointerDown(event: PointerEvent) {
}
function handlePointerMove(event: PointerEvent) {
if (!canAnnotate || !drawStart) return;
if (!canDraw || !drawStart) return;
const container = event.currentTarget as HTMLElement;
const coords = getNormalizedCoords(event, container);
@@ -72,7 +72,7 @@ function handlePointerMove(event: PointerEvent) {
}
function handlePointerUp(event: PointerEvent) {
if (!canAnnotate || !drawStart || !drawRect) return;
if (!canDraw || !drawStart || !drawRect) return;
const container = event.currentTarget as HTMLElement;
const coords = getNormalizedCoords(event, container);
@@ -93,7 +93,7 @@ function handlePointerUp(event: PointerEvent) {
let hoveredId = $state<string | null>(null);
const containerStyle = $derived(
`position: absolute; top: 0; left: 0; width: 100%; height: 100%;${canAnnotate ? ' cursor: crosshair; touch-action: none;' : ''}`
`position: absolute; top: 0; left: 0; width: 100%; height: 100%;${canDraw ? ' cursor: crosshair; touch-action: none;' : ''}`
);
</script>
@@ -110,9 +110,11 @@ const containerStyle = $derived(
data-annotation
role="button"
tabindex="0"
aria-label="Kommentare anzeigen"
aria-label="Block anzeigen"
onclick={() => onAnnotationClick?.(annotation.id)}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onAnnotationClick?.(annotation.id); }}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onAnnotationClick?.(annotation.id);
}}
onpointerenter={() => (hoveredId = annotation.id)}
onpointerleave={() => (hoveredId = null)}
style="
@@ -121,71 +123,36 @@ const containerStyle = $derived(
top: {annotation.y * 100}%;
width: {annotation.width * 100}%;
height: {annotation.height * 100}%;
background-color: {hexToRgba(annotation.color, hoveredId === annotation.id ? 0.5 : 0.3)};
box-shadow: {hoveredId === annotation.id ? `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}` : 'none'};
background-color: {hexToRgba(annotation.color, hoveredId === annotation.id || annotation.id === activeAnnotationId ? 0.5 : 0.3)};
box-shadow: {annotation.id === activeAnnotationId ? `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}` : hoveredId === annotation.id ? `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}` : 'none'};
opacity: {activeAnnotationId && annotation.id !== activeAnnotationId ? 0.3 : 1};
pointer-events: auto;
transition: background-color 0.15s ease, box-shadow 0.15s ease;
{onAnnotationClick && !canAnnotate ? 'cursor: pointer;' : ''}
cursor: pointer;
transition: background-color 0.15s ease, box-shadow 0.15s ease, opacity 0.3s ease;
"
>
{#if canAnnotate}
<button
aria-label="Annotation löschen"
onclick={(e) => {
e.stopPropagation();
const count = commentCounts?.[annotation.id] ?? 0;
if (count > 0) {
const msg =
count === 1
? 'Diese Annotation hat 1 Kommentar. Beim Löschen wird er ebenfalls entfernt. Fortfahren?'
: `Diese Annotation hat ${count} Kommentare. Beim Löschen werden sie ebenfalls entfernt. Fortfahren?`;
if (!window.confirm(msg)) return;
}
onDelete(annotation.id);
}}
{#if blockNumbers[annotation.id]}
<div
style="
position: absolute;
top: -8px;
right: -8px;
width: 16px;
height: 16px;
background-color: #ef4444;
color: white;
border: none;
left: -8px;
width: 20px;
height: 20px;
border-radius: 50%;
cursor: pointer;
background-color: {annotation.color};
color: white;
font-size: 11px;
font-family: sans-serif;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
line-height: 1;
padding: 0;
pointer-events: auto;
">×</button
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
"
>
{/if}
{#if (commentCounts?.[annotation.id] ?? 0) > 0}
<div
style="
position: absolute;
bottom: -10px;
right: -10px;
background-color: #002850;
color: white;
font-size: 11px;
font-family: sans-serif;
font-weight: 600;
padding: 2px 6px;
border-radius: 999px;
min-width: 20px;
text-align: center;
white-space: nowrap;
pointer-events: none;
line-height: 18px;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
"
>
{commentCounts?.[annotation.id]}
{blockNumbers[annotation.id]}
</div>
{/if}
</div>

View File

@@ -18,7 +18,7 @@ type Annotation = {
createdAt: string;
};
function makeAnnotation(id = 'ann-1'): Annotation {
function makeAnnotation(id = 'ann-1', color = '#00C7B1'): Annotation {
return {
id,
documentId: 'doc-1',
@@ -27,7 +27,7 @@ function makeAnnotation(id = 'ann-1'): Annotation {
y: 0.1,
width: 0.3,
height: 0.2,
color: '#ff0000',
color,
createdAt: new Date().toISOString()
};
}
@@ -36,39 +36,77 @@ describe('AnnotationLayer', () => {
it('renders a colored element for each annotation', async () => {
render(AnnotationLayer, {
annotations: [makeAnnotation('ann-1'), makeAnnotation('ann-2')],
canAnnotate: false,
color: '#ff0000',
onDraw: () => {},
onDelete: () => {}
canDraw: false,
color: '#00C7B1',
onDraw: () => {}
});
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
await expect.element(page.getByTestId('annotation-ann-2')).toBeInTheDocument();
});
it('shows a delete button for each annotation when canAnnotate is true', async () => {
it('has crosshair cursor when canDraw is true', async () => {
render(AnnotationLayer, {
annotations: [makeAnnotation('ann-1')],
canAnnotate: true,
color: '#ff0000',
onDraw: () => {},
onDelete: () => {}
annotations: [],
canDraw: true,
color: '#00C7B1',
onDraw: () => {}
});
await expect
.element(page.getByRole('button', { name: /annotation löschen/i }))
.toBeInTheDocument();
const container = document.querySelector('[role="presentation"]')!;
expect(container.getAttribute('style')).toContain('cursor: crosshair');
});
it('does not show delete buttons when canAnnotate is false', async () => {
it('does not have crosshair cursor when canDraw is false', async () => {
render(AnnotationLayer, {
annotations: [makeAnnotation('ann-1')],
canAnnotate: false,
color: '#ff0000',
onDraw: () => {},
onDelete: () => {}
annotations: [],
canDraw: false,
color: '#00C7B1',
onDraw: () => {}
});
expect(page.getByRole('button', { name: /annotation löschen/i }).query()).toBeNull();
const container = document.querySelector('[role="presentation"]')!;
expect(container.getAttribute('style')).not.toContain('cursor: crosshair');
});
it('dims non-active annotations when activeAnnotationId is set', async () => {
render(AnnotationLayer, {
annotations: [makeAnnotation('ann-1'), makeAnnotation('ann-2')],
canDraw: false,
color: '#00C7B1',
activeAnnotationId: 'ann-1',
onDraw: () => {}
});
const active = page.getByTestId('annotation-ann-1').element();
const dimmed = page.getByTestId('annotation-ann-2').element();
expect(active.style.opacity).toBe('1');
expect(dimmed.style.opacity).toBe('0.3');
});
it('shows all annotations at full opacity when no activeAnnotationId', async () => {
render(AnnotationLayer, {
annotations: [makeAnnotation('ann-1'), makeAnnotation('ann-2')],
canDraw: false,
color: '#00C7B1',
onDraw: () => {}
});
const el1 = page.getByTestId('annotation-ann-1').element();
const el2 = page.getByTestId('annotation-ann-2').element();
expect(el1.style.opacity).toBe('1');
expect(el2.style.opacity).toBe('1');
});
it('does not show delete buttons (annotations owned by blocks)', async () => {
render(AnnotationLayer, {
annotations: [makeAnnotation('ann-1')],
canDraw: true,
color: '#00C7B1',
onDraw: () => {}
});
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
expect(page.getByRole('button', { name: /löschen/i }).query()).toBeNull();
});
});

View File

@@ -1,68 +0,0 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import CommentThread from './CommentThread.svelte';
type Props = {
documentId: string;
activeAnnotationId: string | null;
activeAnnotationPage: number | null;
canComment: boolean;
currentUserId: string | null;
canAdmin: boolean;
targetCommentId?: string | null;
onClose: () => void;
};
let {
documentId,
activeAnnotationId,
activeAnnotationPage,
canComment,
currentUserId,
canAdmin,
targetCommentId = null,
onClose
}: Props = $props();
const visible = $derived(activeAnnotationId !== null);
</script>
<div
class="absolute inset-y-0 right-0 z-10 flex w-80 flex-col border-l border-line bg-surface shadow-[-4px_0_16px_rgba(0,0,0,0.08)] transition-transform duration-200 {visible
? 'translate-x-0'
: 'pointer-events-none translate-x-full'}"
data-testid="annotation-side-panel"
>
<!-- Header -->
<div class="flex shrink-0 items-center justify-between border-b border-line px-4 py-3">
<span class="font-sans text-xs font-medium text-ink">
{m.doc_panel_discussion_annotation_tab({ page: String(activeAnnotationPage ?? '?') })}
</span>
<button
onclick={onClose}
aria-label={m.comment_panel_close()}
class="rounded p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Comment thread -->
<div class="flex-1 overflow-y-auto p-4">
{#if activeAnnotationId}
{#key activeAnnotationId}
<CommentThread
documentId={documentId}
annotationId={activeAnnotationId}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
targetCommentId={targetCommentId}
loadOnMount={true}
/>
{/key}
{/if}
</div>
</div>

View File

@@ -1,76 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import AnnotationSidePanel from './AnnotationSidePanel.svelte';
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => []
})
);
const baseProps = {
documentId: 'doc-1',
activeAnnotationPage: 1,
canComment: true,
currentUserId: 'user-1',
canAdmin: false,
onClose: vi.fn()
};
describe('AnnotationSidePanel visibility', () => {
it('is hidden (translated off-screen) when activeAnnotationId is null', async () => {
render(AnnotationSidePanel, { ...baseProps, activeAnnotationId: null });
const panel = document.querySelector('[data-testid="annotation-side-panel"]');
expect(panel?.classList.contains('translate-x-full')).toBe(true);
expect(panel?.classList.contains('translate-x-0')).toBe(false);
});
it('is visible when activeAnnotationId is set', async () => {
render(AnnotationSidePanel, { ...baseProps, activeAnnotationId: 'ann-1' });
const panel = document.querySelector('[data-testid="annotation-side-panel"]');
expect(panel?.classList.contains('translate-x-0')).toBe(true);
expect(panel?.classList.contains('translate-x-full')).toBe(false);
});
});
describe('AnnotationSidePanel close button', () => {
it('calls onClose when the close button is clicked', async () => {
const onClose = vi.fn();
render(AnnotationSidePanel, { ...baseProps, activeAnnotationId: 'ann-1', onClose });
await page.getByRole('button', { name: /schließen/i }).click();
expect(onClose).toHaveBeenCalledOnce();
});
});
describe('AnnotationSidePanel targetCommentId forwarding', () => {
it('renders CommentThread when annotation is active', async () => {
render(AnnotationSidePanel, {
...baseProps,
activeAnnotationId: 'ann-1',
targetCommentId: 'comment-42'
});
// CommentThread renders inside the panel when activeAnnotationId is set
const panel = document.querySelector('[data-testid="annotation-side-panel"]');
expect(panel).not.toBeNull();
expect(panel?.classList.contains('translate-x-0')).toBe(true);
});
it('does not render CommentThread when annotation is null', async () => {
render(AnnotationSidePanel, {
...baseProps,
activeAnnotationId: null,
targetCommentId: 'comment-42'
});
// Panel is hidden and no fetch should have been triggered for comments
const panel = document.querySelector('[data-testid="annotation-side-panel"]');
expect(panel?.classList.contains('translate-x-full')).toBe(true);
});
});

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, tick, untrack } from 'svelte';
import { onMount, untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import type { Comment, CommentReply } from '$lib/types';
import type { Comment } from '$lib/types';
import MentionEditor from '$lib/components/MentionEditor.svelte';
import { renderBody, extractContent } from '$lib/utils/mention';
import type { MentionDTO } from '$lib/types';
@@ -9,62 +9,95 @@ import type { MentionDTO } from '$lib/types';
type Props = {
documentId: string;
annotationId?: string | null;
blockId?: string | null;
initialComments?: Comment[];
loadOnMount?: boolean;
canComment: boolean;
currentUserId: string | null;
canAdmin: boolean;
targetCommentId?: string | null;
quotedText?: string | null;
showCompose?: boolean;
onCountChange?: (count: number) => void;
};
let {
documentId,
annotationId = null,
blockId = null,
initialComments = [],
loadOnMount = false,
canComment,
currentUserId,
canAdmin,
targetCommentId = null,
currentUserId = null,
quotedText = null,
showCompose = true,
onCountChange
}: Props = $props();
type FlatMessage = {
id: string;
authorId: string | null;
authorName: string;
content: string;
createdAt: string;
updatedAt: string;
mentionDTOs?: MentionDTO[];
};
let comments: Comment[] = $state(untrack(() => [...initialComments]));
let highlightedCommentId: string | null = $state(untrack(() => targetCommentId ?? null));
let newText: string = $state('');
let replyingTo: string | null = $state(null);
let replyText: string = $state('');
let editingId: string | null = $state(null);
let editText: string = $state('');
let posting: boolean = $state(false);
let newMentionCandidates: MentionDTO[] = $state([]);
let replyMentionCandidates: MentionDTO[] = $state([]);
let editMentionCandidates: MentionDTO[] = $state([]);
let editingId: string | null = $state(null);
let editText: string = $state('');
const commentsBase = $derived(
annotationId
? `/api/documents/${documentId}/annotations/${annotationId}/comments`
: `/api/documents/${documentId}/comments`
blockId
? `/api/documents/${documentId}/transcription-blocks/${blockId}/comments`
: annotationId
? `/api/documents/${documentId}/annotations/${annotationId}/comments`
: `/api/documents/${documentId}/comments`
);
const flatMessages = $derived(
comments.flatMap((thread) => [thread as FlatMessage, ...(thread.replies as FlatMessage[])])
);
$effect(() => {
if (quotedText && quotedText.trim()) {
newText = `> "${quotedText}"\n\n`;
}
});
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'gerade eben';
if (minutes < 60) return `vor ${minutes} Minute${minutes === 1 ? '' : 'n'}`;
if (minutes < 1) return m.comment_time_just_now();
if (minutes < 60) return m.comment_time_minutes({ count: minutes });
const hours = Math.floor(minutes / 60);
if (hours < 24) return `vor ${hours} Stunde${hours === 1 ? '' : 'n'}`;
if (hours < 24) return m.comment_time_hours({ count: hours });
const days = Math.floor(hours / 24);
return `vor ${days} Tag${days === 1 ? '' : 'en'}`;
return m.comment_time_days({ count: days });
}
function wasEdited(c: { createdAt: string; updatedAt: string }): boolean {
return c.updatedAt > c.createdAt;
}
function canModify(c: { authorId: string | null }): boolean {
return (currentUserId != null && c.authorId === currentUserId) || canAdmin;
function isOwn(c: { authorId: string | null }): boolean {
return currentUserId !== null && c.authorId === currentUserId;
}
function getInitials(name: string): string {
return name
.split(/\s+/)
.slice(0, 2)
.map((w) => w.charAt(0).toUpperCase())
.join('');
}
function extractQuote(content: string): { quote: string | null; body: string } {
const match = content.match(/^>\s*"(.+?)"\s*\n\n?([\s\S]*)$/);
if (match) return { quote: match[1], body: match[2] };
return { quote: null, body: content };
}
async function reload() {
@@ -101,26 +134,9 @@ async function postComment() {
}
}
async function postReply(threadId: string) {
const text = replyText.trim();
if (!text || posting) return;
posting = true;
try {
const { content, mentionedUserIds } = extractContent(text, replyMentionCandidates);
const res = await fetch(`${commentsBase}/${threadId}/replies`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, mentionedUserIds })
});
if (res.ok) {
replyText = '';
replyMentionCandidates = [];
replyingTo = null;
await reload();
}
} finally {
posting = false;
}
function startEdit(msg: FlatMessage) {
editingId = msg.id;
editText = msg.content;
}
async function saveEdit(commentId: string) {
@@ -128,15 +144,14 @@ async function saveEdit(commentId: string) {
if (!text || posting) return;
posting = true;
try {
const { content, mentionedUserIds } = extractContent(text, editMentionCandidates);
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, mentionedUserIds })
body: JSON.stringify({ content: text })
});
if (res.ok) {
editingId = null;
editMentionCandidates = [];
editText = '';
await reload();
}
} finally {
@@ -144,6 +159,21 @@ async function saveEdit(commentId: string) {
}
}
function cancelEdit() {
editingId = null;
editText = '';
}
function handleEditKeydown(e: KeyboardEvent, commentId: string) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
saveEdit(commentId);
} else if (e.key === 'Escape') {
e.stopPropagation();
cancelEdit();
}
}
async function deleteComment(commentId: string) {
if (posting) return;
posting = true;
@@ -159,230 +189,121 @@ async function deleteComment(commentId: string) {
}
}
function startEdit(comment: Comment | CommentReply) {
editingId = comment.id;
editText = comment.content;
editMentionCandidates = [];
}
function cancelEdit() {
editingId = null;
editText = '';
}
function startReply(threadId: string) {
replyingTo = threadId;
replyText = '';
}
function cancelReply() {
replyingTo = null;
replyText = '';
}
onMount(async () => {
onMount(() => {
if (loadOnMount) {
reload();
} else {
const total = initialComments.reduce((s, c) => s + 1 + c.replies.length, 0);
onCountChange?.(total);
}
if (targetCommentId) {
await tick();
requestAnimationFrame(() => {
const el = document.querySelector(`[data-comment-id="${targetCommentId}"]`);
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
// Remove highlight on first user interaction
const clearHighlight = () => {
highlightedCommentId = null;
document.removeEventListener('click', clearHighlight, true);
document.removeEventListener('keydown', clearHighlight, true);
document.removeEventListener('scroll', clearHighlight, true);
};
document.addEventListener('click', clearHighlight, true);
document.addEventListener('keydown', clearHighlight, true);
document.addEventListener('scroll', clearHighlight, true);
}
});
</script>
<!--
Renders a single comment or reply entry.
showReplyButton: whether the "Reply" button appears (only on last item in a thread).
-->
{#snippet commentEntry(comment: Comment | CommentReply, threadId: string, showReplyButton: boolean)}
{#if editingId === comment.id}
<div class="flex flex-col gap-2">
<MentionEditor
bind:value={editText}
bind:mentionCandidates={editMentionCandidates}
rows={3}
disabled={posting}
onsubmit={() => saveEdit(comment.id)}
/>
<div class="flex items-center gap-3">
<button
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
disabled={posting}
onclick={() => saveEdit(comment.id)}
>
{m.btn_save()}
</button>
<button
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
onclick={cancelEdit}
>
{m.btn_cancel()}
</button>
</div>
</div>
{:else}
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-sans text-xs font-semibold text-ink">{comment.authorName}</span>
<span class="font-sans text-xs text-ink-3">{timeAgo(comment.createdAt)}</span>
{#if wasEdited(comment)}
<span class="font-sans text-xs text-ink-3">
{m.comment_edited_label()}
{timeAgo(comment.updatedAt)}
</span>
{/if}
</div>
<p class="mt-1 font-serif text-sm leading-relaxed text-ink-2">
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderBody escapes all HTML before injecting mention links -->
{@html renderBody(comment.content, comment.mentionDTOs ?? [])}
</p>
</div>
{#if canModify(comment)}
<div class="flex shrink-0 items-center gap-2">
<button
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
onclick={() => startEdit(comment)}
>
{m.btn_edit()}
</button>
<button
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
onclick={() => deleteComment(comment.id)}
>
{m.btn_delete()}
</button>
</div>
{/if}
</div>
{#if showReplyButton && canComment}
<div class="mt-1">
<button
class="font-sans text-xs font-medium text-primary transition-colors hover:text-ink-2"
onclick={() => startReply(threadId)}
>
{m.comment_btn_reply()}
</button>
</div>
{/if}
{/if}
{/snippet}
<div class="space-y-4">
{#if comments.length === 0}
<div class="flex flex-col items-center gap-3 py-8 text-center">
{#if flatMessages.length > 0}
<div class="rounded border-l-2 border-accent bg-muted p-2">
<div class="mb-2 flex items-center gap-1.5 font-sans text-sm font-semibold text-ink-2">
<svg
class="h-10 w-10 text-ink-3"
class="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 0 1 1.037-.443 48.282 48.282 0 0 0 5.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
/>
</svg>
<p class="font-sans text-sm text-ink-3">{m.comment_empty_hint()}</p>
{flatMessages.length}
{flatMessages.length === 1 ? 'Kommentar' : 'Kommentare'}
</div>
{/if}
{#each comments as thread, ti (thread.id)}
<div class={ti > 0 ? 'border-t border-line pt-4' : ''}>
<!-- Root comment -->
<div
data-comment-id={thread.id}
class={highlightedCommentId === thread.id
? 'rounded ring-2 ring-accent ring-offset-1 transition-shadow'
: ''}
>
{@render commentEntry(thread, thread.id, thread.replies.length === 0)}
</div>
<!-- Replies -->
{#each thread.replies as reply, ri (reply.id)}
<div
data-comment-id={reply.id}
class="mt-3 ml-6 border-l-2 border-line pl-4 {highlightedCommentId === reply.id
? 'rounded ring-2 ring-accent ring-offset-1 transition-shadow'
: ''}"
>
{@render commentEntry(reply, thread.id, ri === thread.replies.length - 1)}
</div>
{/each}
<div class="space-y-2">
{#each flatMessages as msg (msg.id)}
{@const parsed = extractQuote(msg.content)}
<div class="flex gap-2">
<div
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-fg"
>
{getInitials(msg.authorName)}
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="font-sans text-sm font-semibold text-ink">{msg.authorName}</span>
{#if wasEdited(msg)}
<span class="font-sans text-xs text-ink-3"
>{timeAgo(msg.updatedAt)} {m.comment_edited_label()}</span
>
{:else}
<span class="font-sans text-xs text-ink-3">{timeAgo(msg.createdAt)}</span>
{/if}
</div>
{#if parsed.quote}
<div class="my-1 border-l-2 border-line pl-2 font-serif text-sm text-ink-3 italic">
&ldquo;{parsed.quote}&rdquo;
</div>
{/if}
<!-- Reply compose box -->
{#if replyingTo === thread.id}
<div class="mt-3 ml-6 flex flex-col gap-2">
<MentionEditor
bind:value={replyText}
bind:mentionCandidates={replyMentionCandidates}
rows={3}
placeholder={m.comment_placeholder()}
disabled={posting}
onsubmit={() => postReply(thread.id)}
/>
<div class="flex items-center gap-3">
<button
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
disabled={posting}
onclick={() => postReply(thread.id)}
>
{m.comment_btn_post()}
</button>
<button
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
onclick={cancelReply}
>
{m.btn_cancel()}
</button>
{#if editingId === msg.id}
<textarea
class="mt-1 w-full resize-none rounded border border-line bg-surface px-2 py-1 font-serif text-sm leading-relaxed text-ink outline-none focus:border-primary"
rows={2}
bind:value={editText}
onkeydown={(e) => handleEditKeydown(e, msg.id)}
></textarea>
<div class="mt-1 font-sans text-xs text-ink-3">Enter speichern · Esc abbrechen</div>
{:else}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="relative" onclick={() => { if (isOwn(msg)) startEdit(msg); }}>
<p
class="font-serif text-sm leading-relaxed text-ink-2 {isOwn(msg) ? '-mx-1 cursor-text rounded px-1 transition-colors hover:bg-surface' : ''}"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderBody escapes all HTML before injecting mention links -->
{@html renderBody(parsed.body, msg.mentionDTOs ?? [])}
</p>
{#if isOwn(msg)}
<button
type="button"
class="hover:text-error absolute -right-1 -bottom-1 cursor-pointer rounded p-0.5 text-ink-3 transition-colors"
title={m.btn_delete()}
aria-label={m.btn_delete()}
onclick={(e) => { e.stopPropagation(); deleteComment(msg.id); }}
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{/if}
</div>
{/if}
</div>
</div>
{/if}
{/each}
</div>
{/each}
</div>
{/if}
<!-- New top-level comment -->
{#if canComment}
<div class={comments.length > 0 ? 'border-t border-line pt-4' : ''}>
<div class="flex flex-col gap-2">
<MentionEditor
bind:value={newText}
bind:mentionCandidates={newMentionCandidates}
rows={3}
placeholder={m.comment_placeholder()}
disabled={posting}
onsubmit={postComment}
/>
<div>
<button
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
disabled={posting || !newText.trim()}
onclick={postComment}
>
{m.comment_btn_post()}
</button>
</div>
</div>
</div>
{/if}
</div>
{#if canComment && (showCompose || flatMessages.length > 0)}
<div class="mt-2">
<MentionEditor
bind:value={newText}
bind:mentionCandidates={newMentionCandidates}
rows={1}
placeholder={m.comment_placeholder()}
disabled={posting}
onsubmit={postComment}
/>
</div>
{/if}

View File

@@ -1,57 +0,0 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
type NotificationDTO = {
id: string;
type: 'REPLY' | 'MENTION';
documentId?: string;
referenceId?: string;
annotationId?: string;
read: boolean;
createdAt: string;
actorName?: string;
};
interface Props {
mentions: NotificationDTO[];
}
let { mentions }: Props = $props();
</script>
{#if mentions.length > 0}
<div data-testid="dashboard-mentions" class="rounded-sm border border-line bg-surface p-6">
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.dashboard_notifications_heading()}
</h2>
<div>
{#each mentions as mention (mention.id)}
<div class="flex items-center gap-3 border-b border-line py-2 last:border-0">
{#if mention.documentId}
<a
href={mention.annotationId
? `/documents/${mention.documentId}?commentId=${mention.referenceId}&annotationId=${mention.annotationId}`
: `/documents/${mention.documentId}?commentId=${mention.referenceId}`}
class="font-serif text-lg text-ink hover:text-ink-2 hover:underline"
>{mention.actorName ?? ''}</a
>
<span class="font-sans text-xs text-gray-400">
{mention.type === 'MENTION'
? m.dashboard_notification_mentioned()
: m.dashboard_notification_replied()}
</span>
{:else}
<span class="font-serif text-lg text-ink">{mention.actorName ?? ''}</span>
{/if}
</div>
{/each}
</div>
<div class="mt-4 border-t border-line pt-4">
<a
href="/notifications"
class="text-sm font-medium text-ink-2 transition-colors hover:text-ink"
>{m.notification_history_view_link()}</a
>
</div>
</div>
{/if}

View File

@@ -1,85 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import DashboardMentions from './DashboardMentions.svelte';
afterEach(cleanup);
type NotificationDTO = {
id: string;
type: 'REPLY' | 'MENTION';
documentId?: string;
referenceId?: string;
annotationId?: string;
read: boolean;
createdAt: string;
actorName?: string;
};
function makeMention(overrides: Partial<NotificationDTO> = {}): NotificationDTO {
return {
id: 'notif-1',
type: 'MENTION',
documentId: 'doc-abc',
referenceId: 'comment-xyz',
read: false,
createdAt: '2026-01-15T10:00:00Z',
actorName: 'Anna Schmidt',
...overrides
};
}
describe('DashboardMentions', () => {
it('renders nothing when mentions list is empty', async () => {
render(DashboardMentions, { mentions: [] });
const widget = page.getByTestId('dashboard-mentions');
await expect.element(widget).not.toBeInTheDocument();
});
it('shows a heading when mentions are present', async () => {
render(DashboardMentions, { mentions: [makeMention()] });
const widget = page.getByTestId('dashboard-mentions');
await expect.element(widget).toBeInTheDocument();
});
it('builds link with commentId param when no annotationId', async () => {
render(DashboardMentions, {
mentions: [makeMention({ documentId: 'doc-1', referenceId: 'cmt-1' })]
});
const link = page.getByRole('link');
await expect.element(link).toHaveAttribute('href', '/documents/doc-1?commentId=cmt-1');
});
it('builds link with commentId and annotationId when annotationId is present', async () => {
render(DashboardMentions, {
mentions: [makeMention({ documentId: 'doc-2', referenceId: 'cmt-2', annotationId: 'ann-9' })]
});
const link = page.getByRole('link');
await expect
.element(link)
.toHaveAttribute('href', '/documents/doc-2?commentId=cmt-2&annotationId=ann-9');
});
it('shows actor name in each row', async () => {
render(DashboardMentions, { mentions: [makeMention({ actorName: 'Maria Müller' })] });
await expect.element(page.getByText('Maria Müller')).toBeInTheDocument();
});
it('shows "replied" label for REPLY type', async () => {
render(DashboardMentions, { mentions: [makeMention({ type: 'REPLY' })] });
const widget = page.getByTestId('dashboard-mentions');
await expect.element(widget).toBeInTheDocument();
const link = page.getByRole('link');
await expect.element(link).toBeInTheDocument();
});
it('renders a span instead of a link when documentId is absent', async () => {
render(DashboardMentions, {
mentions: [makeMention({ documentId: undefined, actorName: 'Lena Bauer' })]
});
await expect.element(page.getByText('Lena Bauer')).toBeInTheDocument();
const links = page.getByRole('link');
await expect.element(links).not.toBeInTheDocument();
});
});

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { getLocale } from '$lib/paraglide/runtime.js';
import type { components } from '$lib/generated/api';
type Document = {
id: string;
@@ -9,11 +10,14 @@ type Document = {
sender?: { id: string; firstName: string; lastName: string };
};
type StatsDTO = components['schemas']['StatsDTO'];
interface Props {
recentDocs: Document[];
stats?: StatsDTO | null;
}
let { recentDocs }: Props = $props();
let { recentDocs, stats = null }: Props = $props();
function formatDate(dateStr: string): string {
// updatedAt is a full ISO datetime — no T12:00:00 noon-anchor needed here
@@ -31,7 +35,10 @@ function formatDate(dateStr: string): string {
{m.dashboard_recent_heading()}
</h2>
{#each recentDocs as doc (doc.id)}
<div class="flex items-center justify-between border-b border-line py-2 last:border-0">
<div
data-testid="doc-row-{doc.id}"
class="flex min-h-[44px] items-center justify-between border-b border-line py-2 last:border-0"
>
<a
href="/documents/{doc.id}"
class="font-serif text-lg text-ink hover:text-ink-2 hover:underline"
@@ -48,5 +55,12 @@ function formatDate(dateStr: string): string {
{/if}
</div>
{/each}
{#if stats?.totalDocuments != null}
<p data-testid="dashboard-stats-footnote" class="mt-4 font-sans text-sm text-ink-3">
{stats.totalDocuments}
{m.dashboard_stats_documents()} · {stats.totalPersons}
{m.dashboard_stats_persons()}
</p>
{/if}
</div>
{/if}

View File

@@ -55,3 +55,40 @@ describe('DashboardRecentDocuments', () => {
await expect.element(dateEl).toBeInTheDocument();
});
});
describe('DashboardRecentDocuments — stats footnote', () => {
it('renders stats footnote when stats.totalDocuments is provided', async () => {
render(DashboardRecentDocuments, {
recentDocs: [makeDoc('d1', 'Taufschein')],
stats: { totalDocuments: 248, totalPersons: 34 }
});
const footnote = page.getByTestId('dashboard-stats-footnote');
await expect.element(footnote).toBeInTheDocument();
});
it('omits stats footnote when stats is null', async () => {
render(DashboardRecentDocuments, {
recentDocs: [makeDoc('d1', 'Taufschein')],
stats: null
});
const footnote = page.getByTestId('dashboard-stats-footnote');
await expect.element(footnote).not.toBeInTheDocument();
});
it('shows "0 Documents" when totalDocuments is 0', async () => {
render(DashboardRecentDocuments, {
recentDocs: [makeDoc('d1', 'Taufschein')],
stats: { totalDocuments: 0, totalPersons: 0 }
});
const footnote = page.getByTestId('dashboard-stats-footnote');
await expect.element(footnote).toBeInTheDocument();
});
});
describe('DashboardRecentDocuments — touch targets', () => {
it('each doc row has min-h-[44px] class for WCAG touch target', async () => {
render(DashboardRecentDocuments, { recentDocs: [makeDoc('d1', 'Taufschein')] });
const row = page.getByTestId('doc-row-d1');
await expect.element(row).toHaveClass('min-h-[44px]');
});
});

View File

@@ -1,193 +0,0 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import PanelMetadata from './PanelMetadata.svelte';
import PanelTranscription from './PanelTranscription.svelte';
import PanelDiscussion from './PanelDiscussion.svelte';
import PanelHistory from './PanelHistory.svelte';
import type { Comment, DocumentPanelTab } from '$lib/types';
type Doc = {
id: string;
title?: string | null;
documentDate?: string | null;
location?: string | null;
documentLocation?: string | null;
tags?: { id: string; name: string }[] | null;
sender?: { id: string; firstName: string; lastName: string; alias?: string | null } | null;
receivers?: { id: string; firstName: string; lastName: string }[] | null;
summary?: string | null;
transcription?: string | null;
};
type Props = {
doc: Doc;
comments: Comment[];
canComment: boolean;
currentUserId: string | null;
canAdmin: boolean;
open: boolean;
height: number;
activeTab: DocumentPanelTab;
targetCommentId?: string | null;
};
let {
doc,
comments,
canComment,
currentUserId,
canAdmin,
open = $bindable(),
height = $bindable(),
activeTab = $bindable(),
targetCommentId = null
}: Props = $props();
const MIN_HEIGHT = 52; // drag handle (8px) + tab bar (~44px)
let isDragging = $state(false);
let dragStartY = 0;
let dragStartHeight = 0;
function fullHeight() {
const topbar = document.querySelector('[data-topbar]');
return window.innerHeight - (topbar?.getBoundingClientRect().bottom ?? 0);
}
function openTab(tab: DocumentPanelTab) {
activeTab = tab;
if (!open) {
open = true;
if (height <= MIN_HEIGHT) height = fullHeight();
}
}
function closePanel() {
open = false;
}
function onDragStart(e: PointerEvent) {
isDragging = true;
dragStartY = e.clientY;
dragStartHeight = open ? height : MIN_HEIGHT;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
function onDragMove(e: PointerEvent) {
if (!isDragging) return;
const delta = dragStartY - e.clientY; // positive = dragging up = bigger panel
const newHeight = dragStartHeight + delta;
const maxHeight = fullHeight();
if (newHeight <= MIN_HEIGHT + 20) {
// collapsed past threshold → close
open = false;
} else {
open = true;
height = Math.max(80, Math.min(newHeight, maxHeight));
}
}
function onDragEnd() {
isDragging = false;
}
const tabs: { id: DocumentPanelTab; label: () => string }[] = [
{ id: 'metadata', label: m.doc_panel_tab_metadata },
{ id: 'transcription', label: m.doc_panel_tab_transcription },
{ id: 'discussion', label: m.doc_panel_tab_discussion },
{ id: 'history', label: m.doc_panel_tab_history }
];
const panelHeight = $derived(open ? height : MIN_HEIGHT);
let discussionCount = $state((() => comments.reduce((s, c) => s + 1 + c.replies.length, 0))());
function handleCountChange(count: number) {
discussionCount = count;
}
</script>
<div
class="z-30 flex shrink-0 flex-col border-t border-line bg-surface shadow-[0_-4px_16px_rgba(0,0,0,0.08)]"
style="height: {panelHeight}px"
data-testid="bottom-panel"
>
<!-- Drag handle -->
<div
class="flex h-2 shrink-0 cursor-ns-resize items-center justify-center bg-surface"
style="touch-action: none"
role="separator"
aria-orientation="horizontal"
aria-label="Panel resize"
onpointerdown={onDragStart}
onpointermove={onDragMove}
onpointerup={onDragEnd}
onpointercancel={onDragEnd}
>
<div class="h-1 w-12 rounded-full bg-line"></div>
</div>
<!-- Tab bar -->
<div class="flex shrink-0 items-center border-b border-line bg-surface">
<!-- Scrollable tabs area — hides scrollbar visually -->
<div
class="flex flex-1 items-center overflow-x-auto px-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{#each tabs as tab (tab.id)}
<button
onclick={() => openTab(tab.id)}
class="mr-1 shrink-0 px-3 py-2.5 font-sans text-xs font-medium transition-colors {activeTab === tab.id && open
? 'border-b-2 border-primary text-ink'
: 'text-ink-3 hover:text-ink'}"
aria-pressed={activeTab === tab.id && open}
>
{tab.label()}
{#if tab.id === 'discussion'}
<span
data-testid="discussion-count-badge"
class="ml-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 font-sans text-[10px] font-bold text-primary-fg"
>{discussionCount}</span
>
{/if}
</button>
{/each}
</div>
{#if open}
<button
onclick={closePanel}
data-testid="panel-close-btn"
aria-label="Panel schließen"
class="mr-2 shrink-0 rounded p-1.5 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
<!-- Tab content -->
{#if open}
<div class="flex-1 overflow-y-auto" data-testid="bottom-panel-content">
{#if activeTab === 'metadata'}
<PanelMetadata doc={doc} />
{:else if activeTab === 'transcription'}
<PanelTranscription doc={doc} />
{:else if activeTab === 'discussion'}
<PanelDiscussion
documentId={doc.id}
initialComments={comments}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
targetCommentId={targetCommentId}
onCountChange={handleCountChange}
/>
{:else if activeTab === 'history'}
<PanelHistory documentId={doc.id} />
{/if}
</div>
{/if}
</div>

View File

@@ -1,47 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import DocumentBottomPanel from './DocumentBottomPanel.svelte';
import type { Comment } from '$lib/types';
afterEach(cleanup);
function makeComment(id: string): Comment {
return {
id,
authorId: 'user-1',
authorName: 'Alice',
content: 'Hello',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
replies: []
};
}
const doc = { id: 'doc-1', title: 'Test' };
const baseProps = {
doc,
canComment: true,
currentUserId: 'user-1',
canAdmin: false,
height: 300,
activeTab: 'discussion' as const
};
describe('DocumentBottomPanel discussion badge', () => {
it('always shows a badge on the Discussion tab', async () => {
render(DocumentBottomPanel, { ...baseProps, comments: [], open: true });
await expect.element(page.getByTestId('discussion-count-badge')).toBeInTheDocument();
await expect.element(page.getByTestId('discussion-count-badge')).toHaveTextContent('0');
});
it('shows the correct count when comments exist', async () => {
render(DocumentBottomPanel, {
...baseProps,
comments: [makeComment('c-1'), makeComment('c-2')],
open: true
});
await expect.element(page.getByTestId('discussion-count-badge')).toHaveTextContent('2');
});
});

View File

@@ -0,0 +1,146 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
import { formatDocumentStatus } from '$lib/utils/documentStatusLabel';
import { personAvatarColor } from '$lib/utils/personFormat';
type Person = { id: string; firstName: string; lastName: string };
type Tag = { id: string; name: string };
type Props = {
documentDate: string | null;
location: string | null;
status: string;
sender: Person | null;
receivers: Person[];
tags: Tag[];
};
let { documentDate, location, status, sender, receivers, tags }: Props = $props();
const VISIBLE_RECEIVER_LIMIT = 5;
const formattedDate = $derived(documentDate ? formatDate(documentDate) : '—');
const displayLocation = $derived(location ?? '—');
const statusLabel = $derived(formatDocumentStatus(status));
const visibleReceivers = $derived(receivers.slice(0, VISIBLE_RECEIVER_LIMIT));
const hiddenReceiverCount = $derived(Math.max(0, receivers.length - VISIBLE_RECEIVER_LIMIT));
const hasPersons = $derived(sender !== null || receivers.length > 0);
const hasTags = $derived(tags.length > 0);
let showAllReceivers = $state(false);
const displayedReceivers = $derived(showAllReceivers ? receivers : visibleReceivers);
function getInitials(person: Person): string {
return `${person.firstName.charAt(0)}${person.lastName.charAt(0)}`.toUpperCase();
}
function getFullName(person: Person): string {
return `${person.firstName} ${person.lastName}`;
}
</script>
{#snippet personCard(person: Person)}
<a
href="/persons/{person.id}"
class="group flex items-center gap-2.5 rounded px-2 py-1.5 transition-colors hover:bg-muted"
>
<span
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
style="background-color: {personAvatarColor(person.id)}"
aria-hidden="true"
>
{getInitials(person)}
</span>
<span class="font-serif text-sm text-ink">{getFullName(person)}</span>
</a>
{/snippet}
<div class="border-b border-line p-6">
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Column 1: Details -->
<div>
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.doc_details_section_details()}
</h2>
<dl class="space-y-3 font-serif text-sm">
<div>
<dt class="font-sans text-xs font-medium text-ink-3">{m.doc_details_field_date()}</dt>
<dd class="text-ink">{formattedDate}</dd>
</div>
<div>
<dt class="font-sans text-xs font-medium text-ink-3">{m.form_label_location()}</dt>
<dd class="text-ink">{displayLocation}</dd>
</div>
<div>
<dt class="font-sans text-xs font-medium text-ink-3">{m.doc_details_field_status()}</dt>
<dd class="text-ink">{statusLabel}</dd>
</div>
</dl>
</div>
<!-- Column 2: Personen -->
<div>
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.doc_details_section_persons()}
</h2>
{#if hasPersons}
<div class="space-y-3">
{#if sender}
<div>
<p class="mb-1 font-sans text-xs font-medium text-ink-3">
{m.doc_details_field_sender()}
</p>
{@render personCard(sender)}
</div>
{/if}
{#if receivers.length > 0}
<div>
<p class="mb-1 font-sans text-xs font-medium text-ink-3">
{m.doc_details_field_receivers()}
</p>
<div class="space-y-0.5">
{#each displayedReceivers as receiver (receiver.id)}
{@render personCard(receiver)}
{/each}
</div>
{#if hiddenReceiverCount > 0 && !showAllReceivers}
<button
type="button"
onclick={() => (showAllReceivers = true)}
class="mt-1 px-2 font-sans text-xs font-medium text-ink-2 transition-colors hover:text-ink"
>
{m.doc_details_more_receivers({ count: hiddenReceiverCount })}
</button>
{/if}
</div>
{/if}
</div>
{:else}
<p class="font-serif text-sm text-ink-3">{m.doc_details_no_persons()}</p>
{/if}
</div>
<!-- Column 3: Schlagwoerter -->
<div>
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.doc_details_section_tags()}
</h2>
{#if hasTags}
<div class="flex flex-wrap gap-2">
{#each tags as tag (tag.id)}
<a
href="/?tag={encodeURIComponent(tag.name)}"
class="rounded bg-muted px-2 py-0.5 text-xs font-bold tracking-wide text-ink uppercase transition-colors hover:bg-accent"
>
{tag.name}
</a>
{/each}
</div>
{:else}
<p class="font-serif text-sm text-ink-3">{m.doc_details_no_tags()}</p>
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,100 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte';
afterEach(cleanup);
const sender = { id: 's1', firstName: 'Karl', lastName: 'Müller' };
const receivers = [
{ id: 'r1', firstName: 'Anna', lastName: 'Schmidt' },
{ id: 'r2', firstName: 'Hans', lastName: 'Weber' }
];
const tags = [
{ id: 't1', name: 'Familienbrief' },
{ id: 't2', name: 'Kriegszeit' }
];
function renderDrawer(overrides: Record<string, unknown> = {}) {
return render(DocumentMetadataDrawer, {
documentDate: '1942-03-15',
location: 'Berlin',
status: 'UPLOADED',
sender,
receivers,
tags,
...overrides
});
}
// ─── Details column ──────────────────────────────────────────────────────────
describe('DocumentMetadataDrawer — details column', () => {
it('renders formatted date', async () => {
renderDrawer();
await expect.element(page.getByText('15. März 1942')).toBeInTheDocument();
});
it('renders dash when date is null', async () => {
renderDrawer({ documentDate: null });
const dds = page.getByText('—');
await expect.element(dds.first()).toBeInTheDocument();
});
it('renders location', async () => {
renderDrawer();
await expect.element(page.getByText('Berlin')).toBeInTheDocument();
});
it('renders dash when location is null', async () => {
renderDrawer({ location: null });
const dashes = page.getByText('—');
await expect.element(dashes.first()).toBeInTheDocument();
});
it('renders translated status label', async () => {
renderDrawer();
// "Hochgeladen" is the German translation of UPLOADED
await expect.element(page.getByText('Hochgeladen')).toBeInTheDocument();
});
});
// ─── Persons column ──────────────────────────────────────────────────────────
describe('DocumentMetadataDrawer — persons column', () => {
it('renders sender name as link to person detail', async () => {
renderDrawer();
const link = page.getByRole('link', { name: /Karl Müller/ });
await expect.element(link).toBeInTheDocument();
await expect.element(link).toHaveAttribute('href', '/persons/s1');
});
it('renders receiver names as links', async () => {
renderDrawer();
const anna = page.getByRole('link', { name: /Anna Schmidt/ });
await expect.element(anna).toHaveAttribute('href', '/persons/r1');
const hans = page.getByRole('link', { name: /Hans Weber/ });
await expect.element(hans).toHaveAttribute('href', '/persons/r2');
});
it('shows empty state when no sender and no receivers', async () => {
renderDrawer({ sender: null, receivers: [] });
await expect.element(page.getByText('Keine Personen zugeordnet')).toBeInTheDocument();
});
});
// ─── Tags column ─────────────────────────────────────────────────────────────
describe('DocumentMetadataDrawer — tags column', () => {
it('renders tag chips as links', async () => {
renderDrawer();
const fb = page.getByRole('link', { name: 'Familienbrief' });
await expect.element(fb).toBeInTheDocument();
await expect.element(fb).toHaveAttribute('href', '/?tag=Familienbrief');
});
it('shows empty state when no tags', async () => {
renderDrawer({ tags: [] });
await expect.element(page.getByText('Keine Schlagwörter zugeordnet')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { statusDotClass, statusLabel } from '$lib/utils/personFormat';
type DocumentStatus = 'PLACEHOLDER' | 'UPLOADED' | 'TRANSCRIBED' | 'REVIEWED' | 'ARCHIVED';
type Props = {
status: DocumentStatus;
};
let { status }: Props = $props();
const dotClass = $derived(statusDotClass(status));
const label = $derived(statusLabel(status));
</script>
<span
class="hidden shrink-0 md:block {dotClass} h-4 w-4 rounded-full"
title={label}
aria-label={label}
></span>

View File

@@ -1,7 +1,14 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { slide } from 'svelte/transition';
import { formatDate } from '$lib/utils/personFormat';
import { clickOutside } from '$lib/actions/clickOutside';
import PersonChipRow from './PersonChipRow.svelte';
import OverflowPillButton from './OverflowPillButton.svelte';
import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte';
type Person = { id: string; firstName: string; lastName: string };
type Tag = { id: string; name: string };
type Doc = {
id: string;
@@ -12,141 +19,270 @@ type Doc = {
receivers?: Person[] | null;
filePath?: string | null;
contentType?: string | null;
location?: string | null;
status?: string | null;
tags?: Tag[] | null;
};
type Props = {
doc: Doc;
canWrite: boolean;
canAnnotate: boolean;
fileUrl: string;
annotateMode: boolean;
transcribeMode: boolean;
};
let { doc, canWrite, canAnnotate, fileUrl, annotateMode = $bindable() }: Props = $props();
let { doc, canWrite, fileUrl, transcribeMode = $bindable() }: Props = $props();
let detailsOpen = $state(false);
const isPdf = $derived(!!doc.filePath && doc.contentType?.startsWith('application/pdf'));
const receivers = $derived(doc.receivers ?? []);
const extraCount = $derived(Math.max(0, receivers.length - 2));
const overflowPersons = $derived(receivers.slice(2));
const receiverDisplay = $derived.by(() => {
const receivers = doc.receivers ?? [];
if (receivers.length === 0) return null;
const shown = receivers.slice(0, 2);
const extra = receivers.length - shown.length;
const names = shown.map((r) => `${r.firstName} ${r.lastName}`).join(', ');
return extra > 0 ? `${names} +${extra}` : names;
});
const shortDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'short') : null);
const longDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'long') : null);
const compactMeta = $derived.by(() => {
const parts: string[] = [];
if (doc.documentDate) {
parts.push(
new Intl.DateTimeFormat('de-DE', {
day: 'numeric',
month: 'numeric',
year: 'numeric'
}).format(new Date(doc.documentDate + 'T12:00:00'))
);
}
if (doc.sender) {
const senderName = `${doc.sender.firstName} ${doc.sender.lastName}`;
const receiver = receiverDisplay;
parts.push(receiver ? `${senderName}${receiver}` : senderName);
} else if (receiverDisplay) {
parts.push(`→ ${receiverDisplay}`);
}
return parts.join(' · ');
});
let mobileMenuOpen = $state(false);
</script>
<div
class="z-20 flex shrink-0 items-center justify-between border-b border-line bg-surface px-3 py-3 shadow-sm sm:px-6"
data-topbar
>
<!-- Left: back + title -->
<div class="flex min-w-0 items-center gap-4 overflow-hidden">
{#snippet transcribeBtn(mobile: boolean)}
<button
onclick={() => {
transcribeMode = true;
if (mobile) mobileMenuOpen = false;
}}
aria-label={m.transcription_mode_label()}
aria-pressed={false}
class={mobile
? 'flex w-full items-center gap-2 rounded px-3 py-2 text-left text-[16px] text-ink transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary'
: 'hidden items-center gap-1.5 rounded border border-primary px-3 py-1.5 font-sans text-[16px] font-medium text-ink transition hover:bg-primary hover:text-primary-fg focus-visible:ring-2 focus-visible:ring-primary md:flex'}
>
<svg
class="h-5 w-5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
{m.transcription_mode_label()}
</button>
{/snippet}
{#snippet transcribeStopBtn(mobile: boolean)}
<button
onclick={() => {
transcribeMode = false;
if (mobile) mobileMenuOpen = false;
}}
aria-label={m.transcription_mode_stop()}
aria-pressed={true}
class={mobile
? 'flex w-full items-center gap-2 rounded bg-primary px-3 py-2 text-left text-[16px] text-primary-fg transition focus-visible:ring-2 focus-visible:ring-primary'
: 'flex items-center gap-1.5 rounded bg-primary px-3 py-1.5 font-sans text-[16px] font-medium text-primary-fg transition focus-visible:ring-2 focus-visible:ring-primary'}
>
<svg
class="h-5 w-5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
{m.transcription_mode_stop()}
</button>
{/snippet}
{#snippet downloadLink(mobile: boolean)}
<a
href={fileUrl}
download={doc.originalFilename}
onclick={() => {
if (mobile) mobileMenuOpen = false;
}}
class={mobile
? 'flex items-center gap-2 rounded px-3 py-2 text-[16px] text-ink transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary'
: 'hidden rounded border border-transparent bg-muted p-1.5 text-ink transition hover:bg-accent focus-visible:ring-2 focus-visible:ring-primary md:block'}
title={m.doc_download_title()}
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Download-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5 shrink-0"
/>
{#if mobile}{m.doc_download_title()}{/if}
</a>
{/snippet}
<div data-topbar class="relative z-10 border-b border-line bg-surface shadow-sm">
<!-- Main row -->
<div class="flex h-[75px] shrink-0 items-center xs:h-[88px]">
<!-- Accent bar -->
<div class="h-full w-[3px] shrink-0 bg-primary"></div>
<!-- Back link — 44×44px touch target -->
<a
href="/"
class="group flex shrink-0 items-center gap-2 font-sans text-sm font-medium text-ink-2 transition-colors hover:text-ink"
aria-label={m.topbar_back_label()}
class="group -ml-0.5 flex h-11 w-11 shrink-0 items-center justify-center rounded-full transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary"
>
<div
class="flex h-8 w-8 items-center justify-center rounded-full bg-canvas transition-colors group-hover:bg-accent"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4"
/>
</div>
<span class="hidden sm:inline">{m.btn_back()}</span>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</a>
<div class="min-w-0 border-l border-line pl-4">
<!-- Divider -->
<div class="mx-2 h-6 w-px shrink-0 bg-line"></div>
<!-- Title + meta -->
<div class="min-w-0 flex-1 overflow-hidden">
<h1
class="truncate font-serif text-base leading-tight text-ink"
class="truncate font-serif text-[18px] leading-tight text-ink lg:text-[20px]"
title={doc.title ?? doc.originalFilename ?? ''}
>
{doc.title || doc.originalFilename}
</h1>
{#if compactMeta}
<p class="truncate font-sans text-xs text-ink-2" title={compactMeta}>
{compactMeta}
{#if shortDate}
<p class="font-sans text-[16px] text-ink-2">
<span class="lg:hidden">{shortDate}</span>
<span class="hidden lg:inline">{longDate}</span>
</p>
{/if}
</div>
<!-- Chip row — desktop only, hidden on small screens to make room for buttons -->
<div class="mx-3 hidden min-w-0 shrink-0 md:block">
<PersonChipRow sender={doc.sender} receivers={receivers} abbreviated={true} extraCount={0} />
</div>
<!-- Overflow pill button (desktop) + status dot -->
{#if extraCount > 0}
<OverflowPillButton extraCount={extraCount} persons={overflowPersons} />
{/if}
<!-- Details toggle -->
<button
type="button"
onclick={() => (detailsOpen = !detailsOpen)}
aria-expanded={detailsOpen}
aria-label={m.doc_details_toggle()}
class="ml-2 inline-flex min-h-[44px] shrink-0 items-center gap-1.5 rounded border px-3 py-1 font-sans text-sm font-semibold transition-colors {detailsOpen ? 'border-primary bg-primary text-primary-fg' : 'border-line text-ink-2 hover:bg-muted hover:text-ink'}"
>
{m.doc_details_toggle()}
<svg
class="h-3.5 w-3.5 transition-transform duration-200 {detailsOpen ? 'rotate-180' : ''}"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
<!-- Divider between metadata and actions -->
<div class="mx-3 hidden h-6 w-px shrink-0 bg-line md:block"></div>
<!-- Action buttons -->
<div class="flex shrink-0 items-center gap-1.5 pr-3 font-sans">
{#if canWrite && isPdf && !transcribeMode}
{@render transcribeBtn(false)}
{/if}
{#if transcribeMode}
{@render transcribeStopBtn(false)}
{/if}
{#if canWrite && !transcribeMode}
<a
href="/documents/{doc.id}/edit"
aria-label={m.btn_edit()}
class="flex items-center gap-1.5 rounded border border-primary bg-transparent px-3 py-1.5 text-[16px] font-medium text-ink transition hover:bg-primary hover:text-primary-fg focus-visible:ring-2 focus-visible:ring-primary"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
<span class="hidden sm:inline">{m.btn_edit()}</span>
</a>
{/if}
{#if doc.filePath && !transcribeMode}
{@render downloadLink(false)}
{/if}
<!-- Kebab menu — mobile only, contains actions hidden below md -->
{#if (canWrite && isPdf) || doc.filePath}
<div
role="group"
class="relative md:hidden"
use:clickOutside
onclickoutside={() => (mobileMenuOpen = false)}
>
<button
type="button"
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
aria-label={m.topbar_more_actions()}
aria-haspopup="true"
aria-expanded={mobileMenuOpen}
class="flex h-9 w-9 items-center justify-center rounded border border-line bg-muted transition hover:bg-accent focus-visible:ring-2 focus-visible:ring-primary"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/View-More-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</button>
{#if mobileMenuOpen}
<div
role="menu"
class="absolute top-full right-0 z-50 mt-1 min-w-[200px] rounded-md border border-line bg-surface p-2 shadow-lg"
>
{#if canWrite && isPdf && !transcribeMode}
{@render transcribeBtn(true)}
{/if}
{#if doc.filePath}
{@render downloadLink(true)}
{/if}
</div>
{/if}
</div>
{/if}
</div>
</div>
<!-- Right: actions -->
<div class="ml-4 flex shrink-0 items-center gap-2 font-sans">
{#if canAnnotate && isPdf}
<button
onclick={() => (annotateMode = !annotateMode)}
aria-label={annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}
class="flex items-center gap-1.5 rounded px-3 py-1.5 font-sans text-xs font-medium transition {annotateMode
? 'bg-primary text-primary-fg'
: 'border border-primary text-ink hover:bg-primary hover:text-primary-fg'}"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Note/Note-Add-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 {annotateMode ? 'invert' : ''}"
/>
<span class="hidden sm:inline"
>{annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}</span
>
</button>
{/if}
{#if canWrite}
<a
href="/documents/{doc.id}/edit"
aria-label={m.btn_edit()}
class="flex items-center gap-2 rounded border border-primary bg-transparent px-3 py-1.5 text-xs font-medium text-ink transition hover:bg-primary hover:text-primary-fg"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4"
/>
<span class="hidden sm:inline">{m.btn_edit()}</span>
</a>
{/if}
{#if doc.filePath}
<a
href={fileUrl}
download={doc.originalFilename}
class="rounded border border-transparent bg-muted p-1.5 text-ink transition hover:bg-accent"
title={m.doc_download_title()}
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Download-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</a>
{/if}
</div>
<!-- Metadata drawer -->
{#if detailsOpen}
<div transition:slide={{ duration: 200 }}>
<DocumentMetadataDrawer
documentDate={doc.documentDate ?? null}
location={doc.location ?? null}
status={doc.status ?? 'PLACEHOLDER'}
sender={doc.sender ?? null}
receivers={doc.receivers ? [...doc.receivers] : []}
tags={doc.tags ? [...doc.tags] : []}
/>
</div>
{/if}
</div>

View File

@@ -9,15 +9,18 @@ type Doc = {
fileHash?: string | null;
};
type DrawRect = { x: number; y: number; width: number; height: number; pageNumber: number };
type Props = {
doc: Doc;
fileUrl: string;
isLoading: boolean;
error: string;
annotateMode: boolean;
transcribeMode?: boolean;
blockNumbers?: Record<string, number>;
activeAnnotationId: string | null;
activeAnnotationPage: number | null;
onAnnotationClick: (id: string) => void;
onTranscriptionDraw?: (rect: DrawRect) => void;
};
let {
@@ -25,10 +28,11 @@ let {
fileUrl,
isLoading,
error,
annotateMode = $bindable(),
transcribeMode = false,
blockNumbers = {},
activeAnnotationId = $bindable(),
activeAnnotationPage = $bindable(),
onAnnotationClick
onAnnotationClick,
onTranscriptionDraw
}: Props = $props();
</script>
@@ -80,10 +84,11 @@ let {
<PdfViewer
url={fileUrl}
documentId={doc.id}
bind:annotateMode={annotateMode}
transcribeMode={transcribeMode}
blockNumbers={blockNumbers}
bind:activeAnnotationId={activeAnnotationId}
bind:activeAnnotationPage={activeAnnotationPage}
onAnnotationClick={onAnnotationClick}
onTranscriptionDraw={onTranscriptionDraw}
documentFileHash={doc.fileHash ?? null}
/>
{:else if fileUrl}

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import { setLocale, getLocale } from '$lib/paraglide/runtime';
let { inverted = false }: { inverted?: boolean } = $props();
const locales = ['DE', 'EN', 'ES'] as const;
const localeMap = { DE: 'de', EN: 'en', ES: 'es' } as const;
const activeLocale = $derived(getLocale().toUpperCase());
@@ -10,8 +12,14 @@ const activeLocale = $derived(getLocale().toUpperCase());
<button
type="button"
onclick={() => setLocale(localeMap[locale])}
class="font-sans tracking-widest transition-colors
{activeLocale === locale ? 'font-bold text-ink' : 'font-normal text-ink-3 hover:text-ink'}"
class="rounded px-1 font-sans tracking-widest transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring
{activeLocale === locale
? inverted
? 'font-bold text-white'
: 'font-bold text-ink'
: inverted
? 'font-normal text-white/70 hover:text-white'
: 'font-normal text-ink-3 hover:text-ink'}"
>
{locale}
</button>

View File

@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import LanguageSwitcher from './LanguageSwitcher.svelte';
const mockSetLocale = vi.hoisted(() => vi.fn());
vi.mock('$lib/paraglide/runtime', () => ({
getLocale: vi.fn(() => 'de'),
setLocale: mockSetLocale
}));
beforeEach(() => mockSetLocale.mockClear());
afterEach(cleanup);
// ─── inverted=true (dark background) ──────────────────────────────────────
describe('LanguageSwitcher inverted=true', () => {
it('active locale button has text-white and font-bold', async () => {
render(LanguageSwitcher, { inverted: true });
const el = await page.getByRole('button', { name: 'DE' }).element();
expect(el.className).toMatch(/\btext-white\b/);
expect(el.className).toMatch(/\bfont-bold\b/);
});
it('inactive locale buttons have text-white/70', async () => {
render(LanguageSwitcher, { inverted: true });
const el = await page.getByRole('button', { name: 'EN' }).element();
expect(el.className).toMatch(/text-white\/70/);
});
it('inactive locale buttons do not have font-bold', async () => {
render(LanguageSwitcher, { inverted: true });
const el = await page.getByRole('button', { name: 'EN' }).element();
expect(el.className).not.toMatch(/\bfont-bold\b/);
});
});
// ─── inverted=false (light background) ─────────────────────────────────────
describe('LanguageSwitcher inverted=false', () => {
it('active locale button has text-ink and font-bold', async () => {
render(LanguageSwitcher, { inverted: false });
const el = await page.getByRole('button', { name: 'DE' }).element();
expect(el.className).toMatch(/\btext-ink\b/);
expect(el.className).toMatch(/\bfont-bold\b/);
});
it('inactive locale buttons have text-ink-3', async () => {
render(LanguageSwitcher, { inverted: false });
const el = await page.getByRole('button', { name: 'EN' }).element();
expect(el.className).toMatch(/\btext-ink-3\b/);
});
it('inactive locale buttons do not have text-white', async () => {
render(LanguageSwitcher, { inverted: false });
const el = await page.getByRole('button', { name: 'EN' }).element();
expect(el.className).not.toMatch(/\btext-white\b/);
});
});
// ─── locale switching ──────────────────────────────────────────────────────
describe('LanguageSwitcher locale switching', () => {
it('calls setLocale with en when EN button is clicked', async () => {
render(LanguageSwitcher, { inverted: false });
const el = await page.getByRole('button', { name: 'EN' }).element();
el.click();
expect(mockSetLocale).toHaveBeenCalledWith('en');
});
it('calls setLocale with es when ES button is clicked', async () => {
render(LanguageSwitcher, { inverted: false });
const el = await page.getByRole('button', { name: 'ES' }).element();
el.click();
expect(mockSetLocale).toHaveBeenCalledWith('es');
});
});

View File

@@ -115,7 +115,8 @@ function closePopup() {
}
function handleKeydown(e: KeyboardEvent) {
if (e.ctrlKey && e.key === 'Enter') {
// Enter sends, Shift+Enter adds newline
if (e.key === 'Enter' && !e.shiftKey && query === null) {
e.preventDefault();
onsubmit?.();
return;
@@ -152,33 +153,6 @@ function handleKeydown(e: KeyboardEvent) {
}
}
async function handleAtButtonClick() {
if (!textarea) return;
const pos = textarea.selectionStart;
const before = value.slice(0, pos);
const after = value.slice(pos);
// Ensure @ is preceded by whitespace or is at the start
const needsSpace = before.length > 0 && !/\s$/.test(before);
const insertion = needsSpace ? ' @' : '@';
value = before + insertion + after;
await tick();
if (!textarea) return;
const newPos = pos + insertion.length;
textarea.selectionStart = newPos;
textarea.selectionEnd = newPos;
textarea.focus();
// Trigger mention detection after inserting @
const detected = detectMention(value, newPos);
if (detected !== null) {
mentionStart = newPos - 1;
query = detected;
highlightedIndex = 0;
scheduleSearch(detected);
}
}
onDestroy(() => clearTimeout(debounceTimer));
const popupOpen = $derived(query !== null);
@@ -187,7 +161,7 @@ const popupOpen = $derived(query !== null);
<div class="relative">
<textarea
{@attach attachTextarea}
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
rows={rows}
placeholder={placeholder}
disabled={disabled}
@@ -224,14 +198,4 @@ const popupOpen = $derived(query !== null);
{/if}
</div>
{/if}
<button
type="button"
aria-label={m.mention_btn_label()}
disabled={disabled}
class="mt-1 rounded border border-line px-2 py-0.5 font-sans text-xs font-medium text-ink-3 transition-colors hover:border-ink hover:text-ink disabled:opacity-40"
onclick={handleAtButtonClick}
>
@
</button>
</div>

View File

@@ -154,7 +154,7 @@ onDestroy(() => {
: m.notification_bell_label()}
aria-expanded={open}
aria-haspopup="true"
class="relative rounded-sm p-2 text-ink-2 transition-colors hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
class="relative rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<!-- Bell SVG -->
<svg

View File

@@ -0,0 +1,77 @@
<script lang="ts">
import { tick } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/actions/clickOutside';
type Person = { id: string; firstName: string; lastName: string };
type Props = {
extraCount: number;
persons: Person[];
};
let { extraCount, persons }: Props = $props();
let open = $state(false);
let buttonEl: HTMLButtonElement | undefined = $state();
function toggle() {
open = !open;
}
async function close() {
open = false;
await tick();
buttonEl?.focus();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.stopPropagation();
close();
}
}
</script>
<div
role="group"
class="relative hidden md:block"
use:clickOutside
onclickoutside={() => (open = false)}
onkeydown={handleKeydown}
>
<button
bind:this={buttonEl}
type="button"
aria-haspopup="true"
aria-expanded={open}
aria-label={m.topbar_overflow_show({ count: extraCount })}
onclick={toggle}
onkeydown={handleKeydown}
class="inline-flex shrink-0 items-center rounded-full border border-line bg-muted px-2 py-0.5 text-[14px] font-bold text-ink-2 hover:bg-surface focus-visible:ring-2 focus-visible:ring-primary"
>
+{extraCount}<span class="hidden lg:inline">&nbsp;{m.topbar_overflow_suffix()}</span>
</button>
{#if open}
<div
role="list"
class="absolute top-full left-0 z-50 mt-1 min-w-[160px] rounded-md border border-line bg-surface p-3 shadow-lg"
>
<p class="mb-2 text-[14px] font-bold tracking-wide text-ink-2 uppercase">
{m.topbar_overflow_heading()}
</p>
{#each persons as person (person.id)}
<div role="listitem">
<a
href="/persons/{person.id}"
class="block py-0.5 text-[18px] text-ink hover:text-primary focus-visible:ring-2 focus-visible:ring-primary"
>
{person.firstName}
{person.lastName}
</a>
</div>
{/each}
</div>
{/if}
</div>

View File

@@ -0,0 +1,47 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import OverflowPillButton from './OverflowPillButton.svelte';
afterEach(cleanup);
const persons = [
{ id: 'p1', firstName: 'Anna', lastName: 'Müller' },
{ id: 'p2', firstName: 'Hans', lastName: 'Schmidt' }
];
describe('OverflowPillButton', () => {
it('renders button with correct aria-haspopup and collapsed aria-expanded', async () => {
render(OverflowPillButton, { extraCount: 2, persons });
const btn = page.getByRole('button');
await expect.element(btn).toHaveAttribute('aria-haspopup', 'true');
await expect.element(btn).toHaveAttribute('aria-expanded', 'false');
});
it('shows tooltip on click and sets aria-expanded true', async () => {
render(OverflowPillButton, { extraCount: 2, persons });
const btn = page.getByRole('button');
await userEvent.click(btn);
const tooltip = page.getByRole('list');
await expect.element(tooltip).toBeInTheDocument();
await expect.element(btn).toHaveAttribute('aria-expanded', 'true');
});
it('closes tooltip on Escape and returns focus to button', async () => {
render(OverflowPillButton, { extraCount: 2, persons });
const btn = page.getByRole('button');
await userEvent.click(btn);
await expect.element(page.getByRole('list')).toBeInTheDocument();
await userEvent.keyboard('{Escape}');
await expect.element(page.getByRole('list')).not.toBeInTheDocument();
await expect.element(btn).toHaveFocus();
});
it('renders person links inside tooltip', async () => {
render(OverflowPillButton, { extraCount: 2, persons });
await userEvent.click(page.getByRole('button'));
const links = page.getByRole('link');
await expect.element(links.nth(0)).toHaveAttribute('href', '/persons/p1');
await expect.element(links.nth(1)).toHaveAttribute('href', '/persons/p2');
});
});

View File

@@ -0,0 +1,14 @@
<script lang="ts">
type Props = {
extraCount: number;
};
let { extraCount }: Props = $props();
</script>
<span
aria-hidden="true"
class="inline-flex shrink-0 items-center rounded-full border border-line bg-muted px-2 py-0.5 text-[14px] font-bold text-ink-2"
>
+{extraCount}
</span>

View File

@@ -1,36 +0,0 @@
<script lang="ts">
import CommentThread from './CommentThread.svelte';
import type { Comment } from '$lib/types';
type Props = {
documentId: string;
initialComments: Comment[];
canComment: boolean;
currentUserId: string | null;
canAdmin: boolean;
targetCommentId?: string | null;
onCountChange?: (count: number) => void;
};
let {
documentId,
initialComments,
canComment,
currentUserId,
canAdmin,
targetCommentId = null,
onCountChange
}: Props = $props();
</script>
<div class="flex-1 overflow-y-auto p-6">
<CommentThread
documentId={documentId}
initialComments={initialComments}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
targetCommentId={targetCommentId}
onCountChange={onCountChange}
/>
</div>

View File

@@ -1,519 +0,0 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { diffWords } from 'diff';
let { documentId }: { documentId: string } = $props();
type VersionSummary = {
id: string;
savedAt: string;
editorName: string;
changedFields: string[];
};
type SnapshotDoc = {
title?: string;
documentDate?: string;
location?: string;
documentLocation?: string;
transcription?: string;
summary?: string;
sender?: { id: string; firstName: string; lastName: string } | null;
receivers?: { id: string; firstName: string; lastName: string }[];
tags?: { id: string; name: string }[];
};
type DiffEntry =
| {
kind: 'text';
field: string;
label: string;
parts: { value: string; added?: boolean; removed?: boolean }[];
}
| { kind: 'scalar'; field: string; label: string; oldVal: string; newVal: string }
| { kind: 'relation'; field: string; label: string; removed: string[]; added: string[] };
let historyLoaded = $state(false);
let historyLoading = $state(false);
let versions = $state<VersionSummary[]>([]);
let compareMode = $state(false);
let compareA = $state('');
let compareB = $state('');
let selectedVersionId = $state<string | null>(null);
let diffEntries = $state<DiffEntry[]>([]);
let diffLoading = $state(false);
let noDiff = $state(false);
const fieldLabels: Record<string, () => string> = {
title: m.history_field_title,
documentDate: m.history_field_document_date,
location: m.history_field_location,
documentLocation: m.history_field_document_location,
transcription: m.history_field_transcription,
summary: m.history_field_summary,
sender: m.history_field_sender,
receivers: m.history_field_receivers,
tags: m.history_field_tags
};
const TEXT_FIELDS = ['title', 'summary', 'transcription'] as const;
const SCALAR_FIELDS = ['documentDate', 'location', 'documentLocation'] as const;
function parseSnapshot(raw: string): SnapshotDoc {
try {
return JSON.parse(raw) as SnapshotDoc;
} catch {
return {};
}
}
function personLabel(p: { firstName: string; lastName: string }): string {
return `${p.firstName} ${p.lastName}`.trim();
}
const DIFF_CONTEXT_WORDS = 4;
type DiffPart = { value: string; added?: boolean; removed?: boolean };
function trimContextParts(parts: DiffPart[]): DiffPart[] {
return parts.flatMap((part, i) => {
if (part.added || part.removed) return [part];
const tokens = part.value.split(/(\s+)/).filter(Boolean);
const wordCount = tokens.filter((t) => /\S/.test(t)).length;
if (wordCount <= DIFF_CONTEXT_WORDS * 2) return [part];
function keepFirst(n: number): string {
let count = 0;
const out: string[] = [];
for (const t of tokens) {
out.push(t);
if (/\S/.test(t) && ++count >= n) break;
}
return out.join('');
}
function keepLast(n: number): string {
let count = 0;
const out: string[] = [];
for (const t of [...tokens].reverse()) {
out.unshift(t);
if (/\S/.test(t) && ++count >= n) break;
}
return out.join('');
}
const isFirst = i === 0;
const isLast = i === parts.length - 1;
if (isFirst) return [{ value: '… ' + keepLast(DIFF_CONTEXT_WORDS) }];
if (isLast) return [{ value: keepFirst(DIFF_CONTEXT_WORDS) + ' …' }];
return [{ value: keepFirst(DIFF_CONTEXT_WORDS) + ' … ' + keepLast(DIFF_CONTEXT_WORDS) }];
});
}
function buildDiff(older: SnapshotDoc | null, newer: SnapshotDoc): DiffEntry[] {
const entries: DiffEntry[] = [];
for (const field of TEXT_FIELDS) {
const a = older?.[field] ?? '';
const b = newer[field] ?? '';
if (a === b) continue;
const parts = trimContextParts(diffWords(a, b));
entries.push({ kind: 'text', field, label: fieldLabels[field](), parts });
}
for (const field of SCALAR_FIELDS) {
const a = older?.[field] ?? '';
const b = newer[field] ?? '';
if (a === b) continue;
entries.push({ kind: 'scalar', field, label: fieldLabels[field](), oldVal: a, newVal: b });
}
const senderA = older?.sender ? personLabel(older.sender) : '';
const senderB = newer.sender ? personLabel(newer.sender) : '';
if (senderA !== senderB) {
entries.push({
kind: 'relation',
field: 'sender',
label: fieldLabels['sender'](),
removed: senderA ? [senderA] : [],
added: senderB ? [senderB] : []
});
}
const receiversA = new Set((older?.receivers ?? []).map(personLabel));
const receiversB = new Set((newer.receivers ?? []).map(personLabel));
const removedReceivers = [...receiversA].filter((r) => !receiversB.has(r));
const addedReceivers = [...receiversB].filter((r) => !receiversA.has(r));
if (removedReceivers.length > 0 || addedReceivers.length > 0) {
entries.push({
kind: 'relation',
field: 'receivers',
label: fieldLabels['receivers'](),
removed: removedReceivers,
added: addedReceivers
});
}
const tagsA = new Set((older?.tags ?? []).map((t) => t.name));
const tagsB = new Set((newer.tags ?? []).map((t) => t.name));
const removedTags = [...tagsA].filter((t) => !tagsB.has(t));
const addedTags = [...tagsB].filter((t) => !tagsA.has(t));
if (removedTags.length > 0 || addedTags.length > 0) {
entries.push({
kind: 'relation',
field: 'tags',
label: fieldLabels['tags'](),
removed: removedTags,
added: addedTags
});
}
return entries;
}
async function fetchSnapshot(versionId: string): Promise<SnapshotDoc> {
const res = await fetch(`/api/documents/${documentId}/versions/${versionId}`);
if (!res.ok) throw new Error('Failed to fetch version');
const v = await res.json();
return parseSnapshot(v.snapshot);
}
async function loadHistory() {
if (historyLoaded) return;
historyLoading = true;
try {
const res = await fetch(`/api/documents/${documentId}/versions`);
if (res.ok) {
versions = await res.json();
}
historyLoaded = true;
} catch {
// ignore
} finally {
historyLoading = false;
}
}
async function selectVersion(versionId: string) {
if (selectedVersionId === versionId) {
selectedVersionId = null;
diffEntries = [];
noDiff = false;
return;
}
selectedVersionId = versionId;
diffEntries = [];
noDiff = false;
diffLoading = true;
try {
const idx = versions.findIndex((v) => v.id === versionId);
const newerSnap = await fetchSnapshot(versionId);
const olderSnap = idx > 0 ? await fetchSnapshot(versions[idx - 1].id) : null;
const entries = buildDiff(olderSnap, newerSnap);
if (entries.length === 0) {
noDiff = true;
} else {
diffEntries = entries;
}
} catch {
// ignore
} finally {
diffLoading = false;
}
}
async function applyCompare() {
if (!compareA || !compareB || compareA === compareB) return;
selectedVersionId = null;
diffEntries = [];
noDiff = false;
diffLoading = true;
try {
const [snapA, snapB] = await Promise.all([fetchSnapshot(compareA), fetchSnapshot(compareB)]);
const entries = buildDiff(snapA, snapB);
if (entries.length === 0) {
noDiff = true;
} else {
diffEntries = entries;
}
} catch {
// ignore
} finally {
diffLoading = false;
}
}
function formatDateTime(iso: string): string {
try {
return new Intl.DateTimeFormat('de-DE', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(iso));
} catch {
return iso;
}
}
function versionLabel(v: VersionSummary, index: number): string {
return `Version ${index + 1}${v.editorName}${formatDateTime(v.savedAt)}`;
}
// Load history when this panel mounts.
$effect(() => {
loadHistory();
});
</script>
<div class="space-y-4 p-6">
{#if historyLoading}
<p class="font-sans text-xs text-ink-3">{m.history_loading()}</p>
{:else if !historyLoaded}
<!-- initial state before effect runs — show nothing -->
{:else if versions.length === 0}
<p class="font-serif text-sm text-ink-3 italic">{m.history_empty()}</p>
{:else}
<!-- Compare mode toggle -->
<div class="flex justify-end">
<button
onclick={() => {
compareMode = !compareMode;
diffEntries = [];
noDiff = false;
selectedVersionId = null;
}}
class="font-sans text-xs font-medium transition {compareMode
? 'text-ink underline'
: 'text-ink-3 hover:text-ink'}"
>
{m.history_compare_mode()}
</button>
</div>
{#if compareMode}
<div class="space-y-2">
<div>
<label for="compare-a" class="mb-1 block font-sans text-[10px] text-ink-3 uppercase"
>{m.history_compare_select_a()}</label
>
<select
id="compare-a"
bind:value={compareA}
class="w-full rounded border border-line bg-surface px-2 py-1 font-sans text-xs text-ink focus:ring-1 focus:ring-accent focus:outline-none"
>
<option value=""></option>
{#each versions as v, i (v.id)}
<option value={v.id}>{versionLabel(v, i)}</option>
{/each}
</select>
</div>
<div>
<label for="compare-b" class="mb-1 block font-sans text-[10px] text-ink-3 uppercase"
>{m.history_compare_select_b()}</label
>
<select
id="compare-b"
bind:value={compareB}
class="w-full rounded border border-line bg-surface px-2 py-1 font-sans text-xs text-ink focus:ring-1 focus:ring-accent focus:outline-none"
>
<option value=""></option>
{#each versions as v, i (v.id)}
<option value={v.id}>{versionLabel(v, i)}</option>
{/each}
</select>
</div>
<button
onclick={applyCompare}
disabled={!compareA || !compareB || compareA === compareB}
class="w-full rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg transition hover:bg-primary/80 disabled:cursor-not-allowed disabled:opacity-40"
>
{m.history_compare_apply()}
</button>
</div>
<!-- Diff panel for compare mode -->
{#if diffLoading}
<p class="font-sans text-xs text-ink-3">{m.history_loading()}</p>
{:else if noDiff}
<div
data-testid="history-diff"
class="rounded-sm border border-line bg-surface p-4 font-serif text-sm text-ink-3 italic"
>
{m.history_diff_no_changes()}
</div>
{:else if diffEntries.length > 0}
<div
data-testid="history-diff"
class="space-y-4 rounded-sm border border-line bg-surface p-4"
>
{#each diffEntries as entry (entry.field)}
<div>
<span
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-ink-3 uppercase"
>{entry.label}</span
>
{#if entry.kind === 'text'}
<p class="font-serif text-sm leading-relaxed">
{#each entry.parts as part, partIdx (partIdx)}
{#if part.added}
<span class="bg-green-50 text-green-700">{part.value}</span>
{:else if part.removed}
<span class="bg-red-50 text-red-600 line-through">{part.value}</span>
{:else}
<span>{part.value}</span>
{/if}
{/each}
</p>
{:else if entry.kind === 'scalar'}
<div class="flex items-center gap-2 font-serif text-sm">
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
<svg
class="h-3 w-3 flex-shrink-0 text-ink-3"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
<span class="text-green-700">{entry.newVal || '—'}</span>
</div>
{:else if entry.kind === 'relation'}
<div class="flex flex-wrap gap-1.5">
{#each entry.removed as item (item)}
<span
class="rounded bg-red-50 px-1.5 py-0.5 font-sans text-[11px] text-red-600 line-through"
>{item}</span
>
{/each}
{#each entry.added as item (item)}
<span
class="rounded bg-green-50 px-1.5 py-0.5 font-sans text-[11px] text-green-700"
>{item}</span
>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
{:else}
<!-- Version list with inline diff below each selected item -->
<ul class="divide-y divide-line">
{#each versions as v, i (v.id)}
<li>
<button
onclick={() => selectVersion(v.id)}
data-testid="history-version"
class="w-full py-2 text-left transition hover:bg-muted {selectedVersionId ===
v.id
? 'border-l-2 border-accent pl-2'
: 'pl-0'}"
>
<div class="flex items-baseline justify-between gap-2">
<span class="font-sans text-xs font-medium text-ink">
Version {i + 1}
</span>
<span class="font-sans text-[10px] text-ink-3">
{formatDateTime(v.savedAt)}
</span>
</div>
<span class="font-sans text-[11px] text-ink-2">{v.editorName}</span>
{#if v.changedFields && v.changedFields.length > 0}
<div class="mt-1 flex flex-wrap gap-1">
{#each v.changedFields as field (field)}
<span
class="rounded bg-muted px-1.5 py-0.5 font-sans text-[10px] tracking-wide text-ink-2 uppercase"
>
{fieldLabels[field] ? fieldLabels[field]() : field}
</span>
{/each}
</div>
{/if}
</button>
<!-- Diff shown inline below the selected version -->
{#if selectedVersionId === v.id}
{#if diffLoading}
<p class="pb-3 pl-2 font-sans text-xs text-ink-3">{m.history_loading()}</p>
{:else if noDiff}
<div
data-testid="history-diff"
class="mb-2 rounded-sm border border-line bg-surface p-4 font-serif text-sm text-ink-3 italic"
>
{m.history_diff_no_changes()}
</div>
{:else if diffEntries.length > 0}
<div
data-testid="history-diff"
class="mb-2 space-y-4 rounded-sm border border-line bg-surface p-4"
>
{#each diffEntries as entry (entry.field)}
<div>
<span
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-ink-3 uppercase"
>{entry.label}</span
>
{#if entry.kind === 'text'}
<p class="font-serif text-sm leading-relaxed">
{#each entry.parts as part, partIdx (partIdx)}
{#if part.added}
<span class="bg-green-50 text-green-700">{part.value}</span>
{:else if part.removed}
<span class="bg-red-50 text-red-600 line-through">{part.value}</span>
{:else}
<span>{part.value}</span>
{/if}
{/each}
</p>
{:else if entry.kind === 'scalar'}
<div class="flex items-center gap-2 font-serif text-sm">
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
<svg
class="h-3 w-3 flex-shrink-0 text-ink-3"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
<span class="text-green-700">{entry.newVal || '—'}</span>
</div>
{:else if entry.kind === 'relation'}
<div class="flex flex-wrap gap-1.5">
{#each entry.removed as item (item)}
<span
class="rounded bg-red-50 px-1.5 py-0.5 font-sans text-[11px] text-red-600 line-through"
>{item}</span
>
{/each}
{#each entry.added as item (item)}
<span
class="rounded bg-green-50 px-1.5 py-0.5 font-sans text-[11px] text-green-700"
>{item}</span
>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
{/if}
</li>
{/each}
</ul>
{/if}
{/if}
</div>

View File

@@ -1,198 +0,0 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
type Person = { id: string; firstName: string; lastName: string; alias?: string | null };
type Tag = { id: string; name: string };
type Doc = {
documentDate?: string | null;
location?: string | null;
documentLocation?: string | null;
tags?: Tag[] | null;
sender?: Person | null;
receivers?: Person[] | null;
};
let { doc }: { doc: Doc } = $props();
</script>
<div class="space-y-10 p-6">
<!-- DETAILS GROUP -->
<div>
<h3
class="mb-4 border-b border-line pb-2 font-sans text-xs font-bold tracking-widest text-ink uppercase"
>
{m.doc_section_details()}
</h3>
<div class="space-y-5">
<!-- Date -->
<div class="flex items-start">
<span class="mt-0.5 w-8 text-accent">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</span>
<div>
<span class="block font-serif text-lg text-ink">
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
</span>
<span class="font-sans text-xs text-ink-2">{m.doc_label_document_date()}</span>
</div>
</div>
<!-- Creation Location -->
<div class="flex items-start">
<span class="mt-0.5 w-8 text-accent">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</span>
<div>
<span class="block font-serif text-lg text-ink">
{doc.location ? doc.location : '—'}
</span>
<span class="font-sans text-xs text-ink-2">{m.doc_label_creation_location()}</span>
</div>
</div>
<!-- Physical Archive Location -->
{#if doc.documentLocation}
<div class="flex items-start">
<span class="mt-0.5 w-8 text-accent">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Folder-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</span>
<div>
<span class="block font-serif text-lg text-ink">
{doc.documentLocation}
</span>
<span class="font-sans text-xs text-ink-2"
>{m.doc_label_archive_location_original()}</span
>
</div>
</div>
{/if}
<!-- Tags -->
{#if doc.tags && doc.tags.length > 0}
<div class="flex items-start">
<span class="mt-0.5 w-8 text-accent">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Bookmark/Bookmark-Outline-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</span>
<div class="flex-1">
<div class="mb-1 flex flex-wrap gap-2">
{#each doc.tags as tag (tag.id)}
<a
href="/?tag={encodeURIComponent(tag.name)}"
class="inline-flex items-center rounded bg-muted px-2 py-0.5 text-xs font-bold tracking-wide text-ink uppercase transition-colors hover:bg-primary hover:text-primary-fg"
title={m.doc_tag_filter_title({ name: tag.name })}
>
{tag.name}
</a>
{/each}
</div>
<span class="font-sans text-xs text-ink-2">{m.form_label_tags()}</span>
</div>
</div>
{/if}
</div>
</div>
<!-- PERSONEN GROUP -->
<div>
<h3
class="mb-4 border-b border-line pb-2 font-sans text-xs font-bold tracking-widest text-ink uppercase"
>
{m.doc_section_persons()}
</h3>
<div class="mb-6">
<span class="mb-2 block font-sans text-xs text-ink-3 uppercase">{m.form_label_sender()}</span>
{#if doc.sender}
<a
href="/persons/{doc.sender.id}"
class="group block rounded border border-line bg-muted p-3 transition hover:border-accent hover:bg-accent/10"
>
<div class="flex items-center gap-3">
<div
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary font-serif text-sm text-primary-fg"
>
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
</div>
<div>
<p class="font-serif text-ink group-hover:underline">
{doc.sender.firstName}
{doc.sender.lastName}
</p>
{#if doc.sender.alias}
<p class="font-sans text-xs text-ink-2">{doc.sender.alias}</p>
{/if}
</div>
</div>
</a>
{:else}
<span class="font-serif text-sm text-ink-3 italic">{m.doc_sender_not_specified()}</span>
{/if}
</div>
<div>
<span class="mb-2 block font-sans text-xs text-ink-3 uppercase"
>{m.form_label_receivers()}</span
>
{#if doc.receivers && doc.receivers.length > 0}
<div class="space-y-2">
{#each doc.receivers as receiver (receiver.id)}
<div
class="group flex items-center justify-between rounded border border-line bg-surface p-3 transition hover:border-primary"
>
<a href="/persons/{receiver.id}" class="flex min-w-0 flex-1 items-center gap-3">
<div
class="flex h-6 w-6 items-center justify-center rounded-full bg-muted font-serif text-xs text-ink-2"
>
{receiver.firstName[0]}{receiver.lastName[0]}
</div>
<span class="truncate font-serif text-sm text-ink">
{receiver.firstName}
{receiver.lastName}
</span>
</a>
{#if doc.sender}
<a
href="/korrespondenz?senderId={doc.sender.id}&receiverId={receiver.id}"
class="text-ink-3 transition hover:text-accent"
title={m.doc_conversation_title()}
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Chat-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</a>
{/if}
</div>
{/each}
</div>
{:else}
<span class="font-serif text-sm text-ink-3 italic">{m.doc_no_receivers()}</span>
{/if}
</div>
</div>
</div>

View File

@@ -1,38 +0,0 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
type Doc = {
summary?: string | null;
transcription?: string | null;
};
let { doc }: { doc: Doc } = $props();
</script>
<div class="flex justify-center px-6 py-8">
<div class="w-full max-w-prose space-y-8">
{#if !doc.summary && !doc.transcription}
<p class="font-serif text-sm text-ink-3 italic"></p>
{/if}
{#if doc.summary}
<div>
<span class="mb-3 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.doc_label_summary()}
</span>
<p class="font-serif text-base leading-relaxed text-ink">{doc.summary}</p>
</div>
{/if}
{#if doc.transcription}
<div>
<span class="mb-3 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.form_label_transcription()}
</span>
<p class="font-serif text-base leading-relaxed whitespace-pre-wrap text-ink">
{doc.transcription}
</p>
</div>
{/if}
</div>
</div>

View File

@@ -1,26 +1,29 @@
<script lang="ts">
import { onMount } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
import AnnotationLayer from './AnnotationLayer.svelte';
import type { Annotation } from '$lib/types';
import { m } from '$lib/paraglide/messages.js';
type DrawRect = { x: number; y: number; width: number; height: number; pageNumber: number };
let {
url,
documentId = '',
annotateMode = $bindable(false),
transcribeMode = false,
blockNumbers = {},
activeAnnotationId = $bindable<string | null>(null),
activeAnnotationPage = $bindable<number | null>(null),
onAnnotationClick,
onTranscriptionDraw,
documentFileHash
}: {
url: string;
documentId?: string;
annotateMode?: boolean;
transcribeMode?: boolean;
blockNumbers?: Record<string, number>;
activeAnnotationId?: string | null;
activeAnnotationPage?: number | null;
onAnnotationClick?: (id: string) => void;
onTranscriptionDraw?: (rect: DrawRect) => void;
documentFileHash?: string | null;
} = $props();
@@ -45,10 +48,10 @@ let pdfjsLib: typeof import('pdfjs-dist') | null = null;
let pdfjsReady = $state(false);
let annotations = $state<Annotation[]>([]);
let annotateColor = $state('#ffff00');
let commentCounts = new SvelteMap<string, number>();
let showAnnotations = $state(true);
const TRANSCRIPTION_COLOR = '#00C7B1';
const visibleAnnotations = $derived(
annotations.filter((a) => !a.fileHash || !documentFileHash || a.fileHash === documentFileHash)
);
@@ -164,81 +167,26 @@ async function prerender(doc: PDFDocumentProxy, pageNum: number) {
}
}
async function loadCommentCounts(docId: string, anns: Annotation[]) {
await Promise.all(
anns.map(async (a) => {
try {
const res = await fetch(`/api/documents/${docId}/annotations/${a.id}/comments`);
if (res.ok) {
const threads = (await res.json()) as Array<{ replies: unknown[] }>;
const total = threads.reduce((sum, t) => sum + 1 + t.replies.length, 0);
commentCounts.set(a.id, total);
}
} catch {
// ignore
}
})
);
}
async function loadAnnotations(docId: string) {
if (!docId) return;
try {
const res = await fetch(`/api/documents/${docId}/annotations`);
if (res.ok) {
annotations = await res.json();
await loadCommentCounts(docId, annotations);
}
} catch {
// ignore
}
}
async function handleAnnotationDraw(rect: { x: number; y: number; width: number; height: number }) {
if (!documentId) return;
try {
const res = await fetch(`/api/documents/${documentId}/annotations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pageNumber: currentPage,
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
color: annotateColor
})
});
if (res.ok) {
const created: Annotation = await res.json();
annotations = [...annotations, created];
activeAnnotationId = created.id;
activeAnnotationPage = created.pageNumber;
onAnnotationClick?.(created.id);
}
} catch {
// ignore
}
}
async function handleAnnotationDelete(annotationId: string) {
if (!documentId) return;
try {
const res = await fetch(`/api/documents/${documentId}/annotations/${annotationId}`, {
method: 'DELETE'
});
if (res.ok) {
annotations = annotations.filter((a) => a.id !== annotationId);
}
} catch {
// ignore
}
async function handleDraw(rect: { x: number; y: number; width: number; height: number }) {
if (!documentId || !transcribeMode) return;
await onTranscriptionDraw?.({ ...rect, pageNumber: currentPage });
await loadAnnotations(documentId);
}
function handleAnnotationClick(id: string) {
activeAnnotationId = id;
const ann = annotations.find((a) => a.id === id);
activeAnnotationPage = ann?.pageNumber ?? null;
onAnnotationClick?.(id);
}
@@ -266,7 +214,33 @@ $effect(() => {
});
$effect(() => {
if (annotateMode) showAnnotations = true;
if (transcribeMode) showAnnotations = true;
});
// Scroll-sync: when activeAnnotationId changes, navigate to its page
let prevActiveAnnotationId: string | null = null;
$effect(() => {
const id = activeAnnotationId;
if (!id || id === prevActiveAnnotationId || !pdfDoc) {
prevActiveAnnotationId = id;
return;
}
prevActiveAnnotationId = id;
const ann = annotations.find((a) => a.id === id);
if (!ann) return;
if (ann.pageNumber !== currentPage) {
currentPage = ann.pageNumber;
}
// After page renders, scroll the annotation into view (double-rAF for async render)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const el = document.querySelector(`[data-testid="annotation-${id}"]`);
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
});
});
function prevPage() {
@@ -412,16 +386,6 @@ function zoomOut() {
</button>
</div>
<!-- Color picker (shown in annotate mode) -->
{#if annotateMode}
<input
type="color"
bind:value={annotateColor}
aria-label="Farbe wählen"
class="h-6 w-6 cursor-pointer rounded border-0 bg-transparent p-0"
title="Farbe wählen"
/>
{/if}
<!-- Annotation visibility toggle (shown when annotations exist) -->
{#if annotations.length > 0}
<button
@@ -486,11 +450,11 @@ function zoomOut() {
{#if showAnnotations}
<AnnotationLayer
annotations={visibleAnnotations.filter((a) => a.pageNumber === currentPage)}
canAnnotate={annotateMode}
color={annotateColor}
onDraw={handleAnnotationDraw}
onDelete={handleAnnotationDelete}
commentCounts={Object.fromEntries(commentCounts)}
canDraw={transcribeMode}
color={TRANSCRIPTION_COLOR}
blockNumbers={blockNumbers}
activeAnnotationId={activeAnnotationId}
onDraw={handleDraw}
onAnnotationClick={handleAnnotationClick}
/>
{/if}

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import { abbreviateName, personAvatarColor } from '$lib/utils/personFormat';
type Person = { id: string; firstName: string; lastName: string };
type Props = {
person: Person;
abbreviated: boolean;
};
let { person, abbreviated }: Props = $props();
const displayName = $derived(
abbreviated ? abbreviateName(person) : `${person.firstName} ${person.lastName}`
);
const avatarColor = $derived(personAvatarColor(person.id));
const initials = $derived(
`${person.firstName.charAt(0)}${person.lastName.charAt(0)}`.toUpperCase()
);
</script>
<a
href="/persons/{person.id}"
class="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-line bg-muted px-2 py-0.5 hover:border-primary hover:bg-surface focus-visible:ring-2 focus-visible:ring-primary"
>
<span
class="flex h-[25px] w-[25px] shrink-0 items-center justify-center rounded-full text-[13px] font-bold text-white"
style="background-color: {avatarColor}"
aria-hidden="true"
>
{initials}
</span>
<span class="text-[14px] font-semibold text-ink">{displayName}</span>
</a>

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import PersonChip from './PersonChip.svelte';
import OverflowPillDisplay from './OverflowPillDisplay.svelte';
type Person = { id: string; firstName: string; lastName: string };
type Props = {
sender: Person | null | undefined;
receivers: Person[];
abbreviated: boolean;
extraCount: number;
};
let { sender, receivers, abbreviated, extraCount }: Props = $props();
const visibleReceivers = $derived(receivers.slice(0, 2));
</script>
<div class="hidden min-w-0 items-center gap-1.5 overflow-hidden xs:flex">
{#if sender}
<PersonChip person={sender} abbreviated={abbreviated} />
{/if}
{#if sender && receivers.length > 0}
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Right-MD.svg"
alt=""
aria-hidden="true"
class="h-6 w-6 shrink-0 opacity-40"
/>
{/if}
{#each visibleReceivers as receiver, i (receiver.id)}
<span class={i === 1 ? 'hidden md:contents' : ''}>
<PersonChip person={receiver} abbreviated={abbreviated} />
</span>
{/each}
{#if extraCount > 0}
<OverflowPillDisplay extraCount={extraCount} />
{/if}
</div>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/actions/clickOutside';
type Person = components['schemas']['Person'];
interface Props {
@@ -56,20 +57,6 @@ function selectPerson(person: Person) {
function removePerson(id: string | undefined) {
selectedPersons = selectedPersons.filter((p) => p.id !== id);
}
function clickOutside(node: HTMLElement) {
const handleClick = (e: MouseEvent) => {
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
showDropdown = false;
}
};
document.addEventListener('click', handleClick, true);
return {
destroy() {
document.removeEventListener('click', handleClick, true);
}
};
}
</script>
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
@@ -78,7 +65,7 @@ function clickOutside(node: HTMLElement) {
<input type="hidden" name="receiverIds" value={person.id} />
{/each}
<div class="relative" use:clickOutside>
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
<div
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-line bg-surface p-2 focus-within:border-ink focus-within:ring-1 focus-within:ring-ink"
>

View File

@@ -2,6 +2,7 @@
import { untrack } from 'svelte';
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/actions/clickOutside';
type Person = components['schemas']['Person'];
interface Props {
@@ -118,23 +119,9 @@ function selectPerson(person: Person) {
showDropdown = false;
onchange?.(person.id!);
}
function clickOutside(node: HTMLElement) {
const handleClick = (event: MouseEvent) => {
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
showDropdown = false;
}
};
document.addEventListener('click', handleClick, true);
return {
destroy() {
document.removeEventListener('click', handleClick, true);
}
};
}
</script>
<div class="relative" use:clickOutside>
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
<label
for={name}
class={compact
@@ -154,8 +141,8 @@ function clickOutside(node: HTMLElement) {
onfocus={handleFocus}
placeholder={placeholder ?? m.comp_typeahead_placeholder()}
class={compact
? 'mt-1 block h-9 w-full rounded border border-line bg-surface px-2 text-sm text-ink placeholder:text-ink-3 focus:border-primary focus:outline-none'
: 'mt-1 block w-full rounded-md border border-line bg-surface p-2 text-ink shadow-sm placeholder:text-ink-3 focus:border-accent focus:ring-accent'}
? 'mt-1 block h-9 w-full rounded border border-line bg-surface px-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
: 'mt-1 block w-full rounded-md border border-line bg-surface p-2 text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
/>
{#if showDropdown && (results.length > 0 || loading)}

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/actions/clickOutside';
interface Props {
tags?: string[];
@@ -66,23 +67,9 @@ function handleKeydown(e: KeyboardEvent) {
activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length;
}
}
function clickOutside(node: HTMLElement) {
const handleClick = (e: MouseEvent) => {
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
showSuggestions = false;
}
};
document.addEventListener('click', handleClick, true);
return {
destroy() {
document.removeEventListener('click', handleClick, true);
}
};
}
</script>
<div class="w-full" use:clickOutside>
<div class="w-full" use:clickOutside onclickoutside={() => (showSuggestions = false)}>
<!-- Tag Container -->
<div
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-line bg-surface p-2 focus-within:border-ink focus-within:ring-1 focus-within:ring-ink"

View File

@@ -31,12 +31,12 @@ function toggle() {
onclick={toggle}
aria-label={theme === 'dark' ? 'light mode' : 'dark mode'}
title={theme === 'dark' ? 'light mode' : 'dark mode'}
class="rounded p-1.5 text-ink-2 transition-colors hover:bg-muted hover:text-ink"
class="rounded p-1.5 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{#if theme === 'dark'}
<!-- Sun icon — click to go light -->
<svg
class="h-4 w-4"
class="h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -52,7 +52,7 @@ function toggle() {
{:else}
<!-- Moon icon — click to go dark -->
<svg
class="h-4 w-4"
class="h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"

View File

@@ -0,0 +1,271 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import CommentThread from './CommentThread.svelte';
type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
type Props = {
blockId: string;
documentId: string;
blockNumber: number;
text: string;
label: string | null;
active: boolean;
saveState: SaveState;
canComment: boolean;
currentUserId: string | null;
onTextChange: (text: string) => void;
onFocus: () => void;
onDeleteClick: () => void;
onRetry: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
isFirst?: boolean;
isLast?: boolean;
};
let {
blockId,
documentId,
blockNumber,
text,
label = null,
active,
saveState,
canComment,
currentUserId,
onTextChange,
onFocus,
onDeleteClick,
onRetry,
onMoveUp,
onMoveDown,
isFirst = false,
isLast = false
}: Props = $props();
let localText = $state(text);
let commentOpen = $state(false);
let commentCount = $state(0);
let selectedQuote = $state<string | null>(null);
let textareaEl = $state<HTMLTextAreaElement | null>(null);
const hasComments = $derived(commentCount > 0);
// Sync from prop only when switching to a different block (not on save responses)
let prevBlockId = $state(blockId);
$effect(() => {
if (blockId !== prevBlockId) {
localText = text;
prevBlockId = blockId;
}
});
let leftBorderClass = $derived(
saveState === 'error' ? 'border-l-2 border-error' : active ? 'border-l-2 border-turquoise' : ''
);
function autoresize(node: HTMLTextAreaElement) {
textareaEl = node;
function resize() {
node.style.height = 'auto';
node.style.height = `${node.scrollHeight}px`;
}
resize();
return {
update() {
resize();
},
destroy() {
textareaEl = null;
}
};
}
function handleInput(event: Event) {
const target = event.target as HTMLTextAreaElement;
localText = target.value;
onTextChange(target.value);
}
function handleDelete() {
if (confirm(m.transcription_block_delete_confirm())) {
onDeleteClick();
}
}
function handleTextareaMouseUp() {
if (!textareaEl) return;
const start = textareaEl.selectionStart;
const end = textareaEl.selectionEnd;
if (start !== end) {
selectedQuote = localText.substring(start, end);
} else {
selectedQuote = null;
}
}
</script>
<div
class="relative flex overflow-visible rounded border border-line {leftBorderClass}"
data-block-id={blockId}
>
<!-- Turquoise numbered badge — overlaps top-left of card -->
<span
class="absolute -top-2 -left-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-turquoise text-xs font-bold text-turquoise-fg shadow-sm"
>
{blockNumber}
</span>
<!-- Drag handle (desktop) / Arrow buttons (mobile) -->
<div class="flex shrink-0 flex-col items-center justify-center border-r border-line px-1">
<!-- Mobile: arrow buttons -->
<button
type="button"
class="flex h-7 w-7 cursor-pointer items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink disabled:opacity-20 md:hidden"
disabled={isFirst}
aria-label="Nach oben"
onclick={() => onMoveUp?.()}
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" />
</svg>
</button>
<!-- Desktop: grip handle (drag target) -->
<div
class="hidden cursor-grab text-ink-3 transition-colors select-none hover:text-ink active:cursor-grabbing md:block"
data-drag-handle
aria-label="Ziehen zum Sortieren"
>
</div>
<!-- Mobile: arrow down -->
<button
type="button"
class="flex h-7 w-7 cursor-pointer items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink disabled:opacity-20 md:hidden"
disabled={isLast}
aria-label="Nach unten"
onclick={() => onMoveDown?.()}
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
<div class="min-w-0 flex-1 p-4 pl-3">
<!-- Header -->
<div class="mb-2 flex items-center gap-2">
{#if label}
<span class="text-xs font-medium tracking-wide text-ink-2 uppercase">
{label}
</span>
{/if}
</div>
<!-- Textarea -->
<textarea
use:autoresize={localText}
class="w-full resize-none border-none bg-transparent font-serif text-base leading-relaxed text-ink outline-none placeholder:text-ink-3"
placeholder={m.transcription_block_placeholder()}
rows={1}
value={localText}
oninput={handleInput}
onfocus={onFocus}
onmouseup={handleTextareaMouseUp}
></textarea>
<!-- Footer -->
<div class="flex items-center justify-between border-t border-line pt-2">
<div>
{#if !hasComments}
<button
type="button"
class="flex cursor-pointer items-center gap-1 text-xs font-medium text-ink-2 transition-colors hover:text-ink"
onclick={() => (commentOpen = true)}
>
<svg
class="h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
/>
</svg>
{m.transcription_block_comment_btn()}
</button>
{/if}
</div>
<div class="flex items-center gap-2">
<!-- Save state indicator -->
{#if saveState === 'saving'}
<span class="animate-pulse text-xs text-ink-3">
{m.transcription_block_save_saving()}
</span>
{:else if saveState === 'saved' || saveState === 'fading'}
<span
class="text-xs text-green-600 transition-opacity duration-300 {saveState === 'fading' ? 'opacity-0' : 'opacity-100'}"
>
{m.transcription_block_save_saved()} <span class="inline-block">&#10003;</span>
</span>
{:else if saveState === 'error'}
<span class="text-error text-xs">
{m.transcription_block_save_error()}
<span class="mx-1">&mdash;</span>
<button
type="button"
class="underline transition-colors hover:text-ink"
onclick={onRetry}
>
{m.transcription_block_save_retry()}
</button>
</span>
{/if}
<!-- Delete button -->
<button
type="button"
class="hover:text-error cursor-pointer text-ink-3 transition-colors"
aria-label={m.btn_delete()}
onclick={handleDelete}
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
<!-- Comment thread — list always visible, compose toggled by Kommentieren -->
<div class="mt-3">
<CommentThread
documentId={documentId}
blockId={blockId}
loadOnMount={true}
canComment={canComment}
currentUserId={currentUserId}
quotedText={selectedQuote}
showCompose={commentOpen}
onCountChange={(count) => (commentCount = count)}
/>
</div>
</div>
</div>

View File

@@ -0,0 +1,161 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TranscriptionBlock from './TranscriptionBlock.svelte';
afterEach(cleanup);
function renderBlock(overrides: Record<string, unknown> = {}) {
return render(TranscriptionBlock, {
blockId: 'block-1',
documentId: 'doc-1',
blockNumber: 3,
text: 'Liebe Mutter,',
label: null,
active: false,
saveState: 'idle' as const,
canComment: true,
currentUserId: 'user-1',
onTextChange: vi.fn(),
onFocus: vi.fn(),
onDeleteClick: vi.fn(),
onRetry: vi.fn(),
...overrides
});
}
// ─── Rendering ───────────────────────────────────────────────────────────────
describe('TranscriptionBlock — rendering', () => {
it('renders block number in turquoise badge', async () => {
renderBlock();
await expect.element(page.getByText('3')).toBeInTheDocument();
});
it('renders text in textarea', async () => {
renderBlock();
const textarea = page.getByRole('textbox');
await expect.element(textarea).toHaveValue('Liebe Mutter,');
});
it('renders optional label when provided', async () => {
renderBlock({ label: 'Anrede' });
await expect.element(page.getByText('Anrede')).toBeInTheDocument();
});
it('does not render label when null', async () => {
renderBlock({ label: null });
const label = page.getByText('Anrede');
await expect.element(label).not.toBeInTheDocument();
});
});
// ─── Save states ─────────────────────────────────────────────────────────────
describe('TranscriptionBlock — save states', () => {
it('shows nothing in idle state', async () => {
renderBlock({ saveState: 'idle' });
const saving = page.getByText('Speichere...');
await expect.element(saving).not.toBeInTheDocument();
});
it('shows "Speichere..." in saving state', async () => {
renderBlock({ saveState: 'saving' });
await expect.element(page.getByText('Speichere...')).toBeInTheDocument();
});
it('shows "Gespeichert" in saved state', async () => {
renderBlock({ saveState: 'saved' });
await expect.element(page.getByText(/Gespeichert/)).toBeInTheDocument();
});
it('shows error with retry button in error state', async () => {
const onRetry = vi.fn();
renderBlock({ saveState: 'error', onRetry });
await expect.element(page.getByText('Nicht gespeichert')).toBeInTheDocument();
const retryBtn = page.getByText('Erneut versuchen');
await expect.element(retryBtn).toBeInTheDocument();
});
});
// ─── Active state ────────────────────────────────────────────────────────────
describe('TranscriptionBlock — active border', () => {
it('has turquoise left border when active', async () => {
renderBlock({ active: true });
await expect.element(page.getByRole('textbox')).toBeInTheDocument();
const block = document.querySelector('[data-block-id="block-1"]')!;
expect(block.className).toContain('border-turquoise');
});
it('has error left border when save failed', async () => {
renderBlock({ saveState: 'error' });
await expect.element(page.getByRole('textbox')).toBeInTheDocument();
const block = document.querySelector('[data-block-id="block-1"]')!;
expect(block.className).toContain('border-error');
});
});
// ─── Interactions ────────────────────────────────────────────────────────────
describe('TranscriptionBlock — interactions', () => {
it('calls onTextChange when typing in textarea', async () => {
const onTextChange = vi.fn();
renderBlock({ onTextChange });
const textarea = page.getByRole('textbox');
await textarea.fill('Neue Zeile');
expect(onTextChange).toHaveBeenCalled();
});
it('calls onFocus when textarea is focused', async () => {
const onFocus = vi.fn();
renderBlock({ onFocus });
const textarea = page.getByRole('textbox');
await textarea.click();
expect(onFocus).toHaveBeenCalled();
});
it('shows Kommentieren button when no comments exist', async () => {
renderBlock();
const btn = page.getByText('Kommentieren');
await expect.element(btn).toBeInTheDocument();
});
});
// ─── Reorder controls ────────────────────────────────────────────────────────
describe('TranscriptionBlock — reorder controls', () => {
it('shows a drag handle element', async () => {
renderBlock();
const handle = document.querySelector('[data-drag-handle]');
expect(handle).not.toBeNull();
});
it('disables move-up button when isFirst', async () => {
renderBlock({ isFirst: true });
const btn = page.getByRole('button', { name: 'Nach oben' });
await expect.element(btn).toBeDisabled();
});
it('disables move-down button when isLast', async () => {
renderBlock({ isLast: true });
const btn = page.getByRole('button', { name: 'Nach unten' });
await expect.element(btn).toBeDisabled();
});
it('calls onMoveUp when up arrow clicked', async () => {
const onMoveUp = vi.fn();
renderBlock({ onMoveUp, isFirst: false });
const btn = page.getByRole('button', { name: 'Nach oben' });
await btn.click();
expect(onMoveUp).toHaveBeenCalled();
});
it('calls onMoveDown when down arrow clicked', async () => {
const onMoveDown = vi.fn();
renderBlock({ onMoveDown, isLast: false });
const btn = page.getByRole('button', { name: 'Nach unten' });
await btn.click();
expect(onMoveDown).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,322 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { SvelteMap } from 'svelte/reactivity';
import TranscriptionBlock from './TranscriptionBlock.svelte';
import type { TranscriptionBlockData } from '$lib/types';
type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
type Props = {
documentId: string;
blocks: TranscriptionBlockData[];
canComment: boolean;
currentUserId: string | null;
onBlockFocus: (blockId: string) => void;
onSaveBlock: (blockId: string, text: string) => Promise<void>;
onDeleteBlock: (blockId: string) => Promise<void>;
};
let {
documentId,
blocks,
canComment,
currentUserId,
onBlockFocus,
onSaveBlock,
onDeleteBlock
}: Props = $props();
let activeBlockId: string | null = $state(null);
let saveStates = new SvelteMap<string, SaveState>();
let debounceTimers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
let pendingTexts = new SvelteMap<string, string>();
let sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
let hasBlocks = $derived(blocks.length > 0);
function getSaveState(blockId: string): SaveState {
return saveStates.get(blockId) ?? 'idle';
}
function setSaveState(blockId: string, state: SaveState) {
saveStates.set(blockId, state);
}
async function executeSave(blockId: string) {
const text = pendingTexts.get(blockId);
if (text === undefined) return;
pendingTexts.delete(blockId);
setSaveState(blockId, 'saving');
try {
await onSaveBlock(blockId, text);
setSaveState(blockId, 'saved');
scheduleSavedFade(blockId);
} catch {
setSaveState(blockId, 'error');
}
}
function scheduleSavedFade(blockId: string) {
setTimeout(() => {
if (getSaveState(blockId) === 'saved') {
setSaveState(blockId, 'fading');
setTimeout(() => {
if (getSaveState(blockId) === 'fading') {
setSaveState(blockId, 'idle');
}
}, 300);
}
}, 2000);
}
function scheduleDebounce(blockId: string) {
clearDebounce(blockId);
const timer = setTimeout(() => {
debounceTimers.delete(blockId);
executeSave(blockId);
}, 1500);
debounceTimers.set(blockId, timer);
}
function clearDebounce(blockId: string) {
const existing = debounceTimers.get(blockId);
if (existing !== undefined) {
clearTimeout(existing);
debounceTimers.delete(blockId);
}
}
function flushAllPending() {
for (const [blockId] of debounceTimers) {
clearDebounce(blockId);
executeSave(blockId);
}
}
function handleTextChange(blockId: string, text: string) {
pendingTexts.set(blockId, text);
scheduleDebounce(blockId);
}
function handleFocus(blockId: string) {
activeBlockId = blockId;
onBlockFocus(blockId);
}
function handleBlur() {
flushAllPending();
}
async function handleRetry(blockId: string) {
const block = blocks.find((b) => b.id === blockId);
if (!block) return;
const pending = pendingTexts.get(blockId);
const text = pending ?? block.text;
pendingTexts.set(blockId, text);
await executeSave(blockId);
}
function handleDelete(blockId: string) {
clearDebounce(blockId);
pendingTexts.delete(blockId);
saveStates.delete(blockId);
onDeleteBlock(blockId);
}
async function reorder(newOrder: string[]) {
try {
const res = await fetch(`/api/documents/${documentId}/transcription-blocks/reorder`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ blockIds: newOrder })
});
if (!res.ok) return;
const updated = await res.json();
// Update blocks with new sort orders from server
for (const b of updated) {
const existing = blocks.find((x) => x.id === b.id);
if (existing) existing.sortOrder = b.sortOrder;
}
} catch {
// ignore
}
}
function handleMoveUp(blockId: string) {
const sorted = [...sortedBlocks];
const idx = sorted.findIndex((b) => b.id === blockId);
if (idx <= 0) return;
[sorted[idx - 1], sorted[idx]] = [sorted[idx], sorted[idx - 1]];
reorder(sorted.map((b) => b.id));
}
function handleMoveDown(blockId: string) {
const sorted = [...sortedBlocks];
const idx = sorted.findIndex((b) => b.id === blockId);
if (idx < 0 || idx >= sorted.length - 1) return;
[sorted[idx], sorted[idx + 1]] = [sorted[idx + 1], sorted[idx]];
reorder(sorted.map((b) => b.id));
}
// ── Pointer-based drag and drop ──────────────────────────────────────────
let draggedBlockId: string | null = $state(null);
let dropTargetIdx: number | null = $state(null);
let dragOffsetY: number = $state(0);
let dragStartY = 0;
let capturedEl: HTMLElement | null = null;
let listEl: HTMLElement | null = null;
function handleGripDown(e: PointerEvent, blockId: string) {
if (!(e.target as HTMLElement).closest('[data-drag-handle]')) return;
e.preventDefault();
draggedBlockId = blockId;
dragStartY = e.clientY;
dragOffsetY = 0;
capturedEl = (e.target as HTMLElement).closest('[data-block-wrapper]') as HTMLElement;
capturedEl?.setPointerCapture(e.pointerId);
}
function handlePointerMove(e: PointerEvent) {
if (!draggedBlockId || !listEl) return;
dragOffsetY = e.clientY - dragStartY;
const wrappers = Array.from(listEl.querySelectorAll('[data-block-wrapper]'));
const dragIdx = sortedBlocks.findIndex((b) => b.id === draggedBlockId);
let target: number | null = null;
for (let i = 0; i < wrappers.length; i++) {
const rect = wrappers[i].getBoundingClientRect();
if (e.clientY < rect.top + rect.height / 2) {
target = i;
break;
}
}
if (target === null) target = wrappers.length;
if (target === dragIdx || target === dragIdx + 1) target = null;
dropTargetIdx = target;
}
function handlePointerUp() {
if (!draggedBlockId) return;
if (dropTargetIdx !== null) {
const sorted = [...sortedBlocks];
const fromIdx = sorted.findIndex((b) => b.id === draggedBlockId);
if (fromIdx >= 0) {
const [moved] = sorted.splice(fromIdx, 1);
const insertAt = dropTargetIdx > fromIdx ? dropTargetIdx - 1 : dropTargetIdx;
sorted.splice(insertAt, 0, moved);
reorder(sorted.map((b) => b.id));
}
}
draggedBlockId = null;
dropTargetIdx = null;
dragOffsetY = 0;
capturedEl = null;
}
function flushViaBeacon() {
for (const [blockId, text] of pendingTexts) {
clearDebounce(blockId);
const url = `/api/documents/${documentId}/transcription-blocks/${blockId}`;
const body = JSON.stringify({ text });
navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }));
pendingTexts.delete(blockId);
}
}
$effect(() => {
function onBeforeUnload() {
flushViaBeacon();
}
window.addEventListener('beforeunload', onBeforeUnload);
return () => {
window.removeEventListener('beforeunload', onBeforeUnload);
for (const timer of debounceTimers.values()) {
clearTimeout(timer);
}
};
});
</script>
<div class="flex h-full flex-col overflow-y-auto bg-surface p-4">
{#if hasBlocks}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex flex-col gap-3"
bind:this={listEl}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
>
{#each sortedBlocks as block, i (block.id)}
{#if dropTargetIdx === i}
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
data-block-wrapper
onblur={handleBlur}
onpointerdown={(e) => handleGripDown(e, block.id)}
class="relative transition-all duration-150 {draggedBlockId === block.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-turquoise/40' : ''}"
style={draggedBlockId === block.id ? `transform: translateY(${dragOffsetY}px) scale(1.02); opacity: 0.9;` : ''}
>
<TranscriptionBlock
blockId={block.id}
documentId={documentId}
blockNumber={i + 1}
text={block.text}
label={block.label}
active={activeBlockId === block.id}
saveState={getSaveState(block.id)}
canComment={canComment}
currentUserId={currentUserId}
onTextChange={(text) => handleTextChange(block.id, text)}
onFocus={() => handleFocus(block.id)}
onDeleteClick={() => handleDelete(block.id)}
onRetry={() => handleRetry(block.id)}
onMoveUp={() => handleMoveUp(block.id)}
onMoveDown={() => handleMoveDown(block.id)}
isFirst={i === 0}
isLast={i === sortedBlocks.length - 1}
/>
</div>
{/each}
{#if dropTargetIdx === sortedBlocks.length}
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
{/if}
<!-- Next block CTA — dashed outline hint -->
<div
class="flex items-center justify-center rounded border border-dashed border-line px-4 py-5 text-center font-sans text-sm text-ink-3"
>
{m.transcription_next_block_cta({ number: sortedBlocks.length + 1 })}
</div>
</div>
{:else}
<div class="flex flex-1 flex-col items-center justify-center px-6 py-12 text-center">
<svg
class="mb-4 h-16 w-16 text-ink-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
<p class="max-w-xs text-sm leading-relaxed text-ink-3">
{m.transcription_empty_cta()}
</p>
</div>
{/if}
</div>

View File

@@ -0,0 +1,78 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TranscriptionEditView from './TranscriptionEditView.svelte';
afterEach(cleanup);
const block1 = {
id: 'b1',
annotationId: 'a1',
documentId: 'doc-1',
text: 'Block eins',
label: null,
sortOrder: 0,
version: 0
};
const block2 = {
id: 'b2',
annotationId: 'a2',
documentId: 'doc-1',
text: 'Block zwei',
label: null,
sortOrder: 1,
version: 0
};
function renderView(overrides: Record<string, unknown> = {}) {
return render(TranscriptionEditView, {
documentId: 'doc-1',
blocks: [block1, block2],
canComment: true,
currentUserId: 'user-1',
onBlockFocus: vi.fn(),
onSaveBlock: vi.fn(),
onDeleteBlock: vi.fn(),
...overrides
});
}
describe('TranscriptionEditView — rendering', () => {
it('renders blocks in sort order', async () => {
renderView();
const textareas = page.getByRole('textbox').all();
expect(textareas.length).toBeGreaterThanOrEqual(2);
});
it('shows next-block CTA after block list', async () => {
renderView();
await expect.element(page.getByText(/Block 3/)).toBeInTheDocument();
});
it('shows empty state when no blocks', async () => {
renderView({ blocks: [] });
await expect.element(page.getByText(/Markiere einen Bereich/)).toBeInTheDocument();
});
});
describe('TranscriptionEditView — reorder', () => {
it('renders move-up button disabled on first block', async () => {
renderView();
const upButtons = page.getByRole('button', { name: 'Nach oben' }).all();
// First block's up button should be disabled
await expect.element(upButtons[0]).toBeDisabled();
});
it('renders move-down button disabled on last block', async () => {
renderView();
const downButtons = page.getByRole('button', { name: 'Nach unten' }).all();
// Last block's down button should be disabled
await expect.element(downButtons[downButtons.length - 1]).toBeDisabled();
});
it('has a drag handle on each block', async () => {
renderView();
const handles = document.querySelectorAll('[data-drag-handle]');
expect(handles.length).toBe(2);
});
});

View File

@@ -49,7 +49,7 @@ let titleValue = $derived(titleDirty ? titleOverride : suggestedTitle || titleOv
titleDirty = true;
}}
required={titleRequired}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
{/if}
@@ -65,7 +65,7 @@ let titleValue = $derived(titleDirty ? titleOverride : suggestedTitle || titleOv
name="documentLocation"
value={initialDocumentLocation}
placeholder={m.form_placeholder_archive_location()}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_location()}</p>
</div>
@@ -87,7 +87,7 @@ let titleValue = $derived(titleDirty ? titleOverride : suggestedTitle || titleOv
name="summary"
rows="5"
placeholder={m.form_placeholder_content()}
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:border-ink focus:ring-ink"
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>{initialSummary}</textarea
>
</div>

View File

@@ -13,7 +13,7 @@ let { initialTranscription = '' }: { initialTranscription?: string } = $props();
name="transcription"
rows="12"
placeholder={m.form_placeholder_transcription()}
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:border-ink focus:ring-ink"
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>{initialTranscription}</textarea
>
</div>

View File

@@ -71,7 +71,7 @@ $effect(() => {
placeholder={m.form_placeholder_date()}
maxlength="10"
class="block w-full rounded border border-line p-2 text-sm shadow-sm
{dateInvalid ? 'border-red-400 focus:border-red-500 focus:ring-red-500' : 'focus:border-ink focus:ring-ink'}"
{dateInvalid ? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500' : 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
aria-describedby={dateInvalid ? 'date-error' : undefined}
/>
<input type="hidden" name="documentDate" value={dateIso} />
@@ -91,7 +91,7 @@ $effect(() => {
name="location"
value={initialLocation}
placeholder={m.form_placeholder_location()}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>

View File

@@ -18,7 +18,7 @@ let selected = $derived([...selectedGroupIds]);
name="groupIds"
value={group.id}
bind:group={selected}
class="rounded border-line text-ink focus:ring-accent"
class="rounded border-line text-ink focus:ring-focus-ring"
/>
{group.name}
</label>

View File

@@ -13,7 +13,7 @@ let { required = false }: { required?: boolean } = $props();
type="password"
name="newPassword"
required={required}
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</label>
@@ -25,7 +25,7 @@ let { required = false }: { required?: boolean } = $props();
type="password"
name="confirmPassword"
required={required}
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</label>
</div>

View File

@@ -37,7 +37,7 @@ function handleBirthDateInput(e: Event) {
type="text"
name="firstName"
value={firstName}
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</label>
@@ -49,7 +49,7 @@ function handleBirthDateInput(e: Event) {
type="text"
name="lastName"
value={lastName}
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</label>
</div>
@@ -63,7 +63,7 @@ function handleBirthDateInput(e: Event) {
placeholder="TT.MM.JJJJ"
value={birthDateDisplay}
oninput={handleBirthDateInput}
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
<input type="hidden" name="birthDate" value={birthDateIso} />
</label>
@@ -76,7 +76,7 @@ function handleBirthDateInput(e: Event) {
type="email"
name="email"
value={email}
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</label>
@@ -88,7 +88,7 @@ function handleBirthDateInput(e: Event) {
name="contact"
rows="3"
placeholder={m.profile_contact_placeholder()}
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>{contact}</textarea
>
</label>

View File

@@ -18,6 +18,8 @@ export type ErrorCode =
| 'INVALID_RESET_TOKEN'
| 'ANNOTATION_NOT_FOUND'
| 'ANNOTATION_OVERLAP'
| 'TRANSCRIPTION_BLOCK_NOT_FOUND'
| 'TRANSCRIPTION_BLOCK_CONFLICT'
| 'COMMENT_NOT_FOUND'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
@@ -74,6 +76,10 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_annotation_not_found();
case 'ANNOTATION_OVERLAP':
return m.error_annotation_overlap();
case 'TRANSCRIPTION_BLOCK_NOT_FOUND':
return m.error_transcription_block_not_found();
case 'TRANSCRIPTION_BLOCK_CONFLICT':
return m.error_transcription_block_conflict();
case 'COMMENT_NOT_FOUND':
return m.error_comment_not_found();
case 'UNAUTHORIZED':

View File

@@ -27,6 +27,16 @@ export type Comment = {
export type DocumentPanelTab = 'metadata' | 'transcription' | 'discussion' | 'history';
export type TranscriptionBlockData = {
id: string;
annotationId: string;
documentId: string;
text: string;
label: string | null;
sortOrder: number;
version: number;
};
export type Annotation = {
id: string;
documentId: string;

View File

@@ -31,6 +31,63 @@ export function germanToIso(german: string): string {
return `${y}-${m}-${d}`;
}
/**
* Formats a raw date string into German DD.MM.YYYY format.
*
* Handles two modes:
* - Pure digit stream (no dots): auto-inserts dots after position 2 and 4
* - Manual dot entry: preserves user-typed dots, pads single-digit day/month,
* and overflows extra digits from day→month and month→year
*/
export function formatGermanDateInput(raw: string): string {
if (!raw.includes('.')) {
const digits = raw.replace(/\D/g, '').slice(0, 8);
if (digits.length <= 2) return digits;
if (digits.length <= 4) return `${digits.slice(0, 2)}.${digits.slice(2)}`;
return `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
}
const trailingDot = raw.endsWith('.');
const parts = raw.split('.').map((p) => p.replace(/\D/g, ''));
let day = parts[0] ?? '';
let month = parts[1] ?? '';
let year = parts[2] ?? '';
let dayOverflowed = false;
if (day.length > 2) {
month = day.slice(2) + month;
day = day.slice(0, 2);
dayOverflowed = true;
}
let monthOverflowed = false;
if (month.length > 2) {
year = month.slice(2) + year;
month = month.slice(0, 2);
monthOverflowed = true;
}
year = year.slice(0, 4);
const afterDay = !dayOverflowed && parts.length >= 2;
if (day.length === 1 && (month || (trailingDot && !dayOverflowed))) {
day = '0' + day;
}
if (month.length === 1 && (year || (trailingDot && afterDay && !monthOverflowed))) {
month = '0' + month;
}
if (year) return `${day}.${month}.${year}`;
if (month) {
const dot2 = trailingDot && afterDay && !monthOverflowed ? '.' : '';
return `${day}.${month}${dot2}`;
}
const dot1 = trailingDot && !dayOverflowed ? '.' : '';
return `${day}${dot1}`;
}
/**
* Handles a date input event for German-format date fields (DD.MM.YYYY).
* Strips non-digits, formats with dots, mutates the input's displayed value,
@@ -38,15 +95,7 @@ export function germanToIso(german: string): string {
*/
export function handleGermanDateInput(e: Event): { display: string; iso: string } {
const input = e.target as HTMLInputElement;
const digits = input.value.replace(/\D/g, '').slice(0, 8);
let display: string;
if (digits.length <= 2) {
display = digits;
} else if (digits.length <= 4) {
display = `${digits.slice(0, 2)}.${digits.slice(2)}`;
} else {
display = `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
}
const display = formatGermanDateInput(input.value);
input.value = display;
return { display, iso: germanToIso(display) };
}

View File

@@ -0,0 +1,171 @@
import { describe, it, expect } from 'vitest';
import {
abbreviateName,
formatXsMeta,
personAvatarColor,
formatDate,
statusDotClass,
statusLabel
} from './personFormat';
// ─── abbreviateName ──────────────────────────────────────────────────────────
describe('abbreviateName', () => {
it('abbreviates first name to initial + last name', () => {
expect(abbreviateName({ firstName: 'Karl', lastName: 'Raddatz' })).toBe('K. Raddatz');
});
it('returns single name as-is when no last name', () => {
expect(abbreviateName({ firstName: 'Elfriede', lastName: '' })).toBe('Elfriede');
});
it('preserves hyphenated last name', () => {
expect(abbreviateName({ firstName: 'Karl', lastName: 'Müller-Schmidt' })).toBe(
'K. Müller-Schmidt'
);
});
it('handles leading/trailing whitespace in names', () => {
expect(abbreviateName({ firstName: ' Karl ', lastName: ' Raddatz ' })).toBe('K. Raddatz');
});
});
// ─── formatXsMeta ────────────────────────────────────────────────────────────
type Doc = {
sender?: { firstName: string; lastName: string } | null;
receivers?: { firstName: string; lastName: string }[];
documentDate?: string | null;
};
describe('formatXsMeta', () => {
const sender = { firstName: 'Karl', lastName: 'Raddatz' };
const receiver1 = { firstName: 'Elfriede', lastName: 'Raddatz' };
const receiver2 = { firstName: 'Anna', lastName: 'Müller' };
const receiver3 = { firstName: 'Hans', lastName: 'Schmidt' };
it('formats sender with no receivers and date', () => {
const doc: Doc = { sender, receivers: [], documentDate: '1943-12-24' };
expect(formatXsMeta(doc)).toBe('K.Raddatz · 24.12.1943');
});
it('formats sender with one receiver and date', () => {
const doc: Doc = { sender, receivers: [receiver1], documentDate: '1943-12-24' };
expect(formatXsMeta(doc)).toBe('K.Raddatz → E.Raddatz · 24.12.1943');
});
it('formats sender with three receivers showing +2', () => {
const doc: Doc = {
sender,
receivers: [receiver1, receiver2, receiver3],
documentDate: '1943-12-24'
};
expect(formatXsMeta(doc)).toBe('K.Raddatz → E.Raddatz +2 · 24.12.1943');
});
it('formats without sender', () => {
const doc: Doc = { sender: null, receivers: [receiver1], documentDate: '1943-12-24' };
expect(formatXsMeta(doc)).toBe('E.Raddatz · 24.12.1943');
});
it('formats without date', () => {
const doc: Doc = { sender, receivers: [], documentDate: null };
expect(formatXsMeta(doc)).toBe('K.Raddatz');
});
it('formats with no sender and no date', () => {
const doc: Doc = { sender: null, receivers: [receiver1], documentDate: null };
expect(formatXsMeta(doc)).toBe('E.Raddatz');
});
});
// ─── personAvatarColor ───────────────────────────────────────────────────────
const PALETTE = ['#012851', '#5A3080', '#007596', '#2A6040', '#803020'];
describe('personAvatarColor', () => {
it('returns a value from the palette', () => {
expect(PALETTE).toContain(personAvatarColor('abc'));
});
it('is deterministic — same id always returns same color', () => {
const id = '550e8400-e29b-41d4-a716-446655440000';
expect(personAvatarColor(id)).toBe(personAvatarColor(id));
});
it('all 5 palette entries are reachable across 1000 random UUIDs', () => {
const seen = new Set<string>();
for (let i = 0; i < 1000; i++) {
seen.add(personAvatarColor(crypto.randomUUID()));
}
expect(seen.size).toBe(5);
});
});
// ─── formatDate ──────────────────────────────────────────────────────────────
describe('formatDate', () => {
it('formats short date as dd.mm.yyyy', () => {
expect(formatDate('1943-12-24', 'short')).toBe('24.12.1943');
});
it('formats long date with German month name', () => {
expect(formatDate('1943-12-24', 'long')).toBe('24. Dezember 1943');
});
it('does not shift Dec 31 to Jan 1 (UTC off-by-one guard)', () => {
expect(formatDate('1943-12-31', 'short')).toBe('31.12.1943');
});
it('does not shift Jan 1 to Dec 31 (UTC off-by-one guard)', () => {
expect(formatDate('1944-01-01', 'short')).toBe('01.01.1944');
});
});
// ─── statusDotClass ──────────────────────────────────────────────────────────
describe('statusDotClass', () => {
it('PLACEHOLDER → bg-gray-400', () => {
expect(statusDotClass('PLACEHOLDER')).toBe('bg-gray-400');
});
it('UPLOADED → bg-emerald-500', () => {
expect(statusDotClass('UPLOADED')).toBe('bg-emerald-500');
});
it('TRANSCRIBED → bg-blue-400', () => {
expect(statusDotClass('TRANSCRIBED')).toBe('bg-blue-400');
});
it('REVIEWED → bg-amber-400', () => {
expect(statusDotClass('REVIEWED')).toBe('bg-amber-400');
});
it('ARCHIVED → bg-emerald-600', () => {
expect(statusDotClass('ARCHIVED')).toBe('bg-emerald-600');
});
});
// ─── statusLabel ─────────────────────────────────────────────────────────────
describe('statusLabel', () => {
it('PLACEHOLDER → "Platzhalter"', () => {
expect(statusLabel('PLACEHOLDER')).toBe('Platzhalter');
});
it('UPLOADED → "Hochgeladen"', () => {
expect(statusLabel('UPLOADED')).toBe('Hochgeladen');
});
it('TRANSCRIBED → "Transkribiert"', () => {
expect(statusLabel('TRANSCRIBED')).toBe('Transkribiert');
});
it('REVIEWED → "Geprüft"', () => {
expect(statusLabel('REVIEWED')).toBe('Geprüft');
});
it('ARCHIVED → "Archiviert"', () => {
expect(statusLabel('ARCHIVED')).toBe('Archiviert');
});
});

View File

@@ -0,0 +1,102 @@
import { formatDocumentStatus } from './documentStatusLabel';
type Person = { firstName: string; lastName: string };
type DocumentStatus = 'PLACEHOLDER' | 'UPLOADED' | 'TRANSCRIBED' | 'REVIEWED' | 'ARCHIVED';
type DocForMeta = {
sender?: Person | null;
receivers?: Person[];
documentDate?: string | null;
};
const AVATAR_PALETTE = ['#012851', '#5A3080', '#007596', '#2A6040', '#803020'] as const;
function djb2(str: string): number {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = (hash * 33) ^ str.charCodeAt(i);
}
return Math.abs(hash);
}
export function abbreviateName(person: Person): string {
const first = person.firstName.trim();
const last = person.lastName.trim();
if (!last) return first;
return `${first.charAt(0)}. ${last}`;
}
function abbreviateCompact(person: Person): string {
const first = person.firstName.trim();
const last = person.lastName.trim();
if (!last) return first;
return `${first.charAt(0)}.${last}`;
}
export function formatXsMeta(doc: DocForMeta): string {
const parts: string[] = [];
const receivers = doc.receivers ?? [];
if (doc.sender) {
const senderAbbr = abbreviateCompact(doc.sender);
if (receivers.length === 0) {
parts.push(senderAbbr);
} else {
const extra = receivers.length - 1;
const firstReceiver = abbreviateCompact(receivers[0]);
parts.push(
extra > 0
? `${senderAbbr}${firstReceiver} +${extra}`
: `${senderAbbr}${firstReceiver}`
);
}
} else if (receivers.length > 0) {
const extra = receivers.length - 1;
const firstReceiver = abbreviateCompact(receivers[0]);
parts.push(extra > 0 ? `${firstReceiver} +${extra}` : firstReceiver);
}
if (doc.documentDate) {
parts.push(formatDate(doc.documentDate, 'short'));
}
return parts.join(' · ');
}
export function personAvatarColor(personId: string): string {
return AVATAR_PALETTE[djb2(personId) % AVATAR_PALETTE.length];
}
export function formatDate(isoDate: string, format: 'short' | 'long'): string {
const date = new Date(isoDate + 'T12:00:00');
if (format === 'short') {
return new Intl.DateTimeFormat('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
}).format(date);
}
return new Intl.DateTimeFormat('de-DE', {
day: 'numeric',
month: 'long',
year: 'numeric'
}).format(date);
}
export function statusDotClass(status: DocumentStatus): string {
switch (status) {
case 'PLACEHOLDER':
return 'bg-gray-400';
case 'UPLOADED':
return 'bg-emerald-500';
case 'TRANSCRIBED':
return 'bg-blue-400';
case 'REVIEWED':
return 'bg-amber-400';
case 'ARCHIVED':
return 'bg-emerald-600';
}
}
export function statusLabel(status: string): string {
return formatDocumentStatus(status);
}

View File

@@ -35,7 +35,8 @@ const userInitials = $derived.by(() => {
<div class="min-h-screen bg-canvas" data-hydrated={hydrated || undefined}>
{#if !isAuthPage}
<header class="sticky top-0 z-50 border-b border-line-2 bg-surface">
<header class="sticky top-0 z-50 bg-header">
<div class="h-1 bg-accent"></div>
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 justify-between">
<!-- Logo & Nav -->
@@ -45,9 +46,9 @@ const userInitials = $derived.by(() => {
<div class="flex items-center gap-3">
<!-- Language selector (desktop only — mobile lives in nav drawer) -->
<div
class="hidden items-center gap-1 border-r border-line pr-3 sm:flex [&_button]:px-1.5 [&_button]:py-1 [&_button]:text-xs"
class="hidden items-center gap-1 pr-3 lg:flex [&_button]:px-1.5 [&_button]:py-1 [&_button]:text-xs"
>
<LanguageSwitcher />
<LanguageSwitcher inverted />
</div>
<!-- Theme toggle -->

View File

@@ -3,7 +3,7 @@ import { createApiClient } from '$lib/api.server';
import type { components } from '$lib/generated/api';
type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO'];
type NotificationDTO = components['schemas']['NotificationDTO'];
type StatsDTO = components['schemas']['StatsDTO'];
type Document = components['schemas']['Document'];
export async function load({ url, fetch }) {
@@ -55,19 +55,19 @@ export async function load({ url, fetch }) {
const receiverObj = allPersons.find((p) => p.id === receiverId);
// Dashboard widgets — failures are isolated and don't crash the page
let mentions: NotificationDTO[] = [];
let stats: StatsDTO | null = null;
let incompleteDocs: IncompleteDocumentDTO[] = [];
let recentDocs: Document[] = [];
if (isDashboard) {
const [mentionsResult, incompleteResult, recentResult] = await Promise.allSettled([
api.GET('/api/notifications', { params: { query: { size: 5 } } }),
api.GET('/api/documents/incomplete', { params: { query: { size: 5 } } }),
const [statsResult, incompleteResult, recentResult] = await Promise.allSettled([
api.GET('/api/stats'),
api.GET('/api/documents/incomplete', { params: { query: { size: 3 } } }),
api.GET('/api/documents/recent-activity', { params: { query: { size: 5 } } })
]);
if (mentionsResult.status === 'fulfilled' && mentionsResult.value.response.ok) {
mentions = mentionsResult.value.data?.content ?? [];
if (statsResult.status === 'fulfilled' && statsResult.value.response.ok) {
stats = statsResult.value.data ?? null;
}
if (incompleteResult.status === 'fulfilled' && incompleteResult.value.response.ok) {
incompleteDocs = incompleteResult.value.data ?? [];
@@ -80,7 +80,7 @@ export async function load({ url, fetch }) {
return {
isDashboard,
documents,
mentions,
stats,
incompleteDocs,
recentDocs,
initialValues: {
@@ -96,7 +96,7 @@ export async function load({ url, fetch }) {
return {
isDashboard,
documents: [],
mentions: [],
stats: null,
incompleteDocs: [],
recentDocs: [],
initialValues: { senderName: '', receiverName: '' },

View File

@@ -6,7 +6,6 @@ import SearchFilterBar from './SearchFilterBar.svelte';
import DropZone from './DropZone.svelte';
import DocumentList from './DocumentList.svelte';
import DashboardResumeStrip from '$lib/components/DashboardResumeStrip.svelte';
import DashboardMentions from '$lib/components/DashboardMentions.svelte';
import DashboardNeedsMetadata from '$lib/components/DashboardNeedsMetadata.svelte';
import DashboardRecentDocuments from '$lib/components/DashboardRecentDocuments.svelte';
import { m } from '$lib/paraglide/messages.js';
@@ -69,6 +68,11 @@ $effect(() => {
tagNames = data.filters?.tags || [];
if (hasAdvancedFilters(data.filters)) showAdvanced = true;
});
// Right column is only rendered when there is something to show.
// Omitting it prevents an empty 300px ghost column for read-only users
// with a complete archive.
const showRightColumn = $derived(data.canWrite || (data.incompleteDocs?.length ?? 0) > 0);
</script>
<svelte:head>
@@ -94,20 +98,25 @@ $effect(() => {
{#if data.isDashboard}
<DashboardResumeStrip />
{#if data.canWrite}
<div class="mt-4">
<DropZone />
</div>
{/if}
<!-- Classic Split: right column first in DOM so it appears above recent docs on mobile.
lg:order-last moves it back to the visual right on desktop. -->
<!-- No items-start — CSS Grid stretch default makes both columns equal height -->
<div class="mt-4 grid grid-cols-1 gap-4 {showRightColumn ? 'lg:grid-cols-[1fr_300px]' : ''}">
{#if showRightColumn}
<div data-testid="dashboard-right-column" class="flex h-full flex-col gap-4 lg:order-last">
{#if data.canWrite}
<DropZone />
{/if}
<!-- flex-1 + min-h-0 fills remaining height after DropZone.
min-h-0 overrides the default min-height:auto that prevents flex
children from shrinking below their content size. -->
<div class="flex min-h-0 flex-1 flex-col">
<DashboardNeedsMetadata incompleteDocs={data.incompleteDocs ?? []} />
</div>
</div>
{/if}
<div
class="mt-6 grid gap-4 {(data.mentions?.length ?? 0) > 0 && (data.incompleteDocs?.length ?? 0) > 0 ? 'lg:grid-cols-2' : ''}"
>
<DashboardMentions mentions={data.mentions ?? []} />
<DashboardNeedsMetadata incompleteDocs={data.incompleteDocs ?? []} />
</div>
<div class="mt-4">
<DashboardRecentDocuments recentDocs={data.recentDocs ?? []} />
<DashboardRecentDocuments recentDocs={data.recentDocs ?? []} stats={data.stats} />
</div>
{:else}
<DocumentList documents={data.documents ?? []} canWrite={data.canWrite} error={data.error} />

View File

@@ -28,53 +28,53 @@ function handleOverlayKeydown(event: KeyboardEvent) {
}
</script>
<div class="flex items-center">
<div class="mr-10 hidden flex-shrink-0 items-center md:flex">
<div class="flex items-stretch">
<div class="mr-10 flex flex-shrink-0 items-center">
<a href="/" class="flex items-center" aria-label="Familienarchiv">
<span class="font-sans text-xl font-bold tracking-widest text-ink uppercase"
<span class="font-sans text-xl font-bold tracking-widest text-white uppercase"
>Familienarchiv</span
>
</a>
</div>
<!-- Desktop nav -->
<nav class="hidden items-center sm:flex sm:space-x-1">
<nav class="hidden items-stretch lg:flex lg:space-x-1">
<a
href="/"
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
? 'rounded bg-nav-active text-ink'
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
? 'border-b-2 border-accent text-white'
: 'text-white/70 hover:text-white'}"
>
{m.nav_documents()}
</a>
<a
href="/persons"
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
{page.url.pathname.startsWith('/persons')
? 'rounded bg-nav-active text-ink'
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
? 'border-b-2 border-accent text-white'
: 'text-white/70 hover:text-white'}"
>
{m.nav_persons()}
</a>
<a
href="/korrespondenz"
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
{page.url.pathname.startsWith('/korrespondenz')
? 'rounded bg-nav-active text-ink'
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
? 'border-b-2 border-accent text-white'
: 'text-white/70 hover:text-white'}"
>
{m.nav_conversations()}
</a>
{#if isAdmin}
<a
href="/admin"
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
{page.url.pathname.startsWith('/admin')
? 'rounded bg-nav-active text-ink'
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
? 'border-b-2 border-accent text-white'
: 'text-white/70 hover:text-white'}"
>
{m.nav_admin()}
</a>
@@ -83,7 +83,7 @@ function handleOverlayKeydown(event: KeyboardEvent) {
<!-- Hamburger toggle (mobile only) -->
<button
class="ml-auto flex h-11 w-11 items-center justify-center rounded text-ink-2 transition-colors hover:bg-muted hover:text-ink sm:hidden"
class="ml-auto flex h-11 w-11 items-center justify-center self-center rounded text-white/70 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring lg:hidden"
aria-label={mobileNavOpen ? 'Menü schließen' : 'Menü öffnen'}
aria-expanded={mobileNavOpen}
aria-controls="mobile-nav"
@@ -131,41 +131,41 @@ function handleOverlayKeydown(event: KeyboardEvent) {
<!-- Mobile nav overlay -->
{#if mobileNavOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 top-[68px] z-40 sm:hidden" onkeydown={handleOverlayKeydown}>
<div class="fixed inset-0 top-[68px] z-40 lg:hidden" onkeydown={handleOverlayKeydown}>
<!-- Backdrop -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="absolute inset-0 bg-black/20" onclick={closeMobileNav}></div>
<!-- Panel -->
<!-- Panel — white background with navy text (reverses the dark header) -->
<div class="relative border-b border-line bg-surface shadow-md">
<nav id="mobile-nav">
<a
href="/"
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
? 'bg-nav-active text-ink'
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
? 'bg-accent-bg text-ink'
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
>
{m.nav_documents()}
</a>
<a
href="/persons"
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
{page.url.pathname.startsWith('/persons')
? 'bg-nav-active text-ink'
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
? 'bg-accent-bg text-ink'
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
>
{m.nav_persons()}
</a>
<a
href="/korrespondenz"
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
{page.url.pathname.startsWith('/korrespondenz')
? 'bg-nav-active text-ink'
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
? 'bg-accent-bg text-ink'
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
>
{m.nav_conversations()}
</a>
@@ -173,10 +173,10 @@ function handleOverlayKeydown(event: KeyboardEvent) {
{#if isAdmin}
<a
href="/admin"
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
{page.url.pathname.startsWith('/admin')
? 'bg-nav-active text-ink'
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
? 'bg-accent-bg text-ink'
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
>
{m.nav_admin()}
</a>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
</script>
<header class="bg-header">
<div class="h-1 bg-accent"></div>
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-12 items-center justify-between">
<a href="/" class="flex items-center" aria-label="Familienarchiv">
<span class="font-sans text-sm font-bold tracking-widest text-white uppercase"
>Familienarchiv</span
>
</a>
<div class="flex items-center gap-1">
<LanguageSwitcher inverted />
</div>
</div>
</div>
</header>

View File

@@ -46,7 +46,7 @@ let {
onblur={onblur}
aria-label={m.docs_search_placeholder()}
placeholder={m.docs_search_placeholder()}
class="block w-full border-line py-2.5 pr-10 pl-3 placeholder-ink-3 shadow-sm focus:border-ink focus:ring-ink"
class="block w-full border-line py-2.5 pr-10 pl-3 placeholder-ink-3 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<img

View File

@@ -1,27 +1,17 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/actions/clickOutside';
let { userInitials }: { userInitials: string | null } = $props();
let userMenuOpen = $state(false);
function clickOutside(node: HTMLElement) {
const handleClick = (event: MouseEvent) => {
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
userMenuOpen = false;
}
};
document.addEventListener('click', handleClick, true);
return () => {
document.removeEventListener('click', handleClick, true);
};
}
</script>
<div
class="relative"
{@attach clickOutside}
use:clickOutside
onclickoutside={() => (userMenuOpen = false)}
onkeydown={(e) => {
if (e.key === 'Escape') userMenuOpen = false;
}}
@@ -33,7 +23,7 @@ function clickOutside(node: HTMLElement) {
aria-expanded={userMenuOpen}
aria-haspopup="true"
onclick={() => (userMenuOpen = !userMenuOpen)}
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary font-sans text-xs font-bold text-primary-fg transition-opacity hover:opacity-80"
class="flex h-8 w-8 items-center justify-center rounded-full bg-white font-sans text-xs font-bold text-brand-navy transition-opacity hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{userInitials}
</button>
@@ -44,13 +34,13 @@ function clickOutside(node: HTMLElement) {
aria-expanded={userMenuOpen}
aria-haspopup="true"
onclick={() => (userMenuOpen = !userMenuOpen)}
class="inline-flex items-center gap-1.5 px-3 py-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase transition-colors hover:text-ink"
class="group rounded-sm p-2 transition-colors hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<img
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Account-SM.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 opacity-50"
class="h-5 w-5 opacity-65 brightness-0 invert transition-opacity group-hover:opacity-100"
/>
</button>
{/if}

View File

@@ -63,7 +63,7 @@ function handleKeydown(event: KeyboardEvent) {
>
<!-- Desktop-only heading -->
<div
class="hidden px-3 pt-3 pb-1 text-[9px] font-extrabold tracking-widest text-white/30 uppercase lg:block"
class="hidden px-3 pt-3 pb-1 text-[9px] font-extrabold tracking-widest text-white/50 uppercase lg:block"
>
{m.admin_heading()}
</div>
@@ -123,7 +123,7 @@ function handleKeydown(event: KeyboardEvent) {
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
<span class="text-[13px] font-black {isActive('users') ? 'text-white/65' : 'text-white/20'}">
<span class="text-[13px] font-black {isActive('users') ? 'text-white/65' : 'text-white/50'}">
{userCount}
</span>
<span
@@ -190,7 +190,7 @@ function handleKeydown(event: KeyboardEvent) {
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
/>
</svg>
<span class="text-[13px] font-black {isActive('groups') ? 'text-white/65' : 'text-white/20'}">
<span class="text-[13px] font-black {isActive('groups') ? 'text-white/65' : 'text-white/50'}">
{groupCount}
</span>
<span
@@ -259,7 +259,7 @@ function handleKeydown(event: KeyboardEvent) {
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
</svg>
<span class="text-[13px] font-black {isActive('tags') ? 'text-white/65' : 'text-white/20'}">
<span class="text-[13px] font-black {isActive('tags') ? 'text-white/65' : 'text-white/50'}">
{tagCount}
</span>
<span
@@ -355,7 +355,7 @@ function handleKeydown(event: KeyboardEvent) {
transition:fly={{ x: -160, duration: 180 }}
>
<!-- Heading -->
<div class="px-3 pt-3 pb-1 text-[9px] font-extrabold tracking-widest text-white/30 uppercase">
<div class="px-3 pt-3 pb-1 text-[9px] font-extrabold tracking-widest text-white/50 uppercase">
{m.admin_heading()}
</div>
@@ -384,7 +384,7 @@ function handleKeydown(event: KeyboardEvent) {
/>
</svg>
<span
class="text-[13px] font-black {isActive('users') ? 'text-white/65' : 'text-white/20'}"
class="text-[13px] font-black {isActive('users') ? 'text-white/65' : 'text-white/50'}"
>
{userCount}
</span>
@@ -422,7 +422,7 @@ function handleKeydown(event: KeyboardEvent) {
/>
</svg>
<span
class="text-[13px] font-black {isActive('groups') ? 'text-white/65' : 'text-white/20'}"
class="text-[13px] font-black {isActive('groups') ? 'text-white/65' : 'text-white/50'}"
>
{groupCount}
</span>
@@ -460,7 +460,7 @@ function handleKeydown(event: KeyboardEvent) {
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
</svg>
<span class="text-[13px] font-black {isActive('tags') ? 'text-white/65' : 'text-white/20'}">
<span class="text-[13px] font-black {isActive('tags') ? 'text-white/65' : 'text-white/50'}">
{tagCount}
</span>
<span

View File

@@ -133,7 +133,7 @@ const ADMIN_PERMISSIONS = $derived([
name="name"
value={data.group.name}
required
class="bg-background w-full rounded-sm border border-line px-3 py-2 font-sans text-sm text-ink placeholder:text-ink-3 focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none"
class="bg-background w-full rounded-sm border border-line px-3 py-2 font-sans text-sm text-ink placeholder:text-ink-3 focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
@@ -150,7 +150,7 @@ const ADMIN_PERMISSIONS = $derived([
name="permissions"
value={perm.value}
checked={data.group.permissions.includes(perm.value)}
class="h-4 w-4 rounded border-line text-primary focus:ring-primary"
class="h-4 w-4 rounded border-line text-primary focus:ring-focus-ring"
/>
{perm.label}
</label>

View File

@@ -109,10 +109,12 @@ describe('GroupsListPanel — collapse toggle', () => {
});
it('persists collapse state using the groups-specific localStorage key', async () => {
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
render(GroupsListPanel, { groups });
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
document.querySelector<HTMLButtonElement>('[aria-label="Liste einklappen"]')!.click();
expect(setSpy).toHaveBeenCalledWith('admin_groups_list_collapsed', 'true');
await vi.waitFor(() =>
expect(setSpy).toHaveBeenCalledWith('admin_groups_list_collapsed', 'true')
);
setSpy.mockRestore();
});
});

View File

@@ -101,7 +101,7 @@ beforeNavigate(({ cancel, to }) => {
name="name"
placeholder={m.admin_group_name_placeholder()}
required
class="w-full rounded-sm border border-line bg-surface px-3 py-2 text-sm text-ink placeholder:text-ink-3 focus:ring-1 focus:ring-primary focus:outline-none"
class="w-full rounded-sm border border-line bg-surface px-3 py-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
@@ -117,7 +117,7 @@ beforeNavigate(({ cancel, to }) => {
type="checkbox"
name="permissions"
value={perm.value}
class="rounded border-line text-primary focus:ring-primary"
class="rounded border-line text-primary focus:ring-focus-ring"
/>
<span class="font-mono text-xs font-bold uppercase">{perm.value}</span>
<span class="text-ink-3">{perm.label}</span>
@@ -146,7 +146,7 @@ beforeNavigate(({ cancel, to }) => {
type="checkbox"
name="permissions"
value={perm.value}
class="rounded border-line text-primary focus:ring-primary"
class="rounded border-line text-primary focus:ring-focus-ring"
/>
<span class="font-mono text-xs font-bold uppercase">{perm.value}</span>
<span class="font-normal text-ink-3">{perm.label}</span>

View File

@@ -104,7 +104,7 @@ $effect(() => {
name="name"
value={data.tag.name}
required
class="w-full rounded-sm border border-line bg-surface px-3 py-2 text-sm text-ink focus:ring-1 focus:ring-primary focus:outline-none"
class="w-full rounded-sm border border-line bg-surface px-3 py-2 text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
</form>

View File

@@ -88,10 +88,12 @@ describe('TagsListPanel — collapse toggle', () => {
});
it('persists collapse state using the tags-specific localStorage key', async () => {
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
render(TagsListPanel, { tags });
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
document.querySelector<HTMLButtonElement>('[aria-label="Liste einklappen"]')!.click();
expect(setSpy).toHaveBeenCalledWith('admin_tags_list_collapsed', 'true');
await vi.waitFor(() =>
expect(setSpy).toHaveBeenCalledWith('admin_tags_list_collapsed', 'true')
);
setSpy.mockRestore();
});
});

Some files were not shown because too many files have changed in this diff Show More