Compare commits

..

73 Commits

Author SHA1 Message Date
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
84 changed files with 5369 additions and 549 deletions

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

@@ -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",
@@ -321,6 +327,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 +382,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",

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",
@@ -321,6 +327,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 +382,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",

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",
@@ -321,6 +327,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 +382,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",

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

@@ -0,0 +1,22 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
type Props = {
annotateMode: boolean;
};
let { annotateMode }: Props = $props();
</script>
{#if annotateMode}
<div
data-testid="annotate-hint-strip"
class="hidden h-[29px] items-center gap-2 border-t border-dashed px-3.5 md:flex"
style="background: rgba(1,40,81,0.05); border-color: rgba(1,40,81,0.20)"
>
<span class="text-[16px] font-bold tracking-wide text-primary uppercase"
>{m.doc_panel_annotate()}</span
>
<span class="text-[16px] text-ink-2">{m.doc_panel_annotate_hint()}</span>
</div>
{/if}

View File

@@ -0,0 +1,27 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import AnnotateHintStrip from './AnnotateHintStrip.svelte';
afterEach(cleanup);
describe('AnnotateHintStrip', () => {
it('is absent from the DOM when annotateMode is false', async () => {
render(AnnotateHintStrip, { annotateMode: false });
const strip = page.getByTestId('annotate-hint-strip');
await expect.element(strip).not.toBeInTheDocument();
});
it('is present in the DOM when annotateMode is true', async () => {
render(AnnotateHintStrip, { annotateMode: true });
const strip = page.getByTestId('annotate-hint-strip');
await expect.element(strip).toBeInTheDocument();
});
it('has hidden md:flex class to hide below 768px', async () => {
render(AnnotateHintStrip, { annotateMode: true });
const strip = page.getByTestId('annotate-hint-strip');
await expect.element(strip).toHaveClass('hidden');
await expect.element(strip).toHaveClass('md:flex');
});
});

View File

@@ -312,7 +312,7 @@ onMount(async () => {
<div
data-comment-id={thread.id}
class={highlightedCommentId === thread.id
? 'rounded ring-2 ring-accent ring-offset-1 transition-shadow'
? 'rounded outline-2 outline-offset-1 outline-accent transition-shadow outline-dotted'
: ''}
>
{@render commentEntry(thread, thread.id, thread.replies.length === 0)}
@@ -323,7 +323,7 @@ onMount(async () => {
<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'
? 'rounded outline-2 outline-offset-1 outline-accent transition-shadow outline-dotted'
: ''}"
>
{@render commentEntry(reply, thread.id, ri === thread.replies.length - 1)}

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

@@ -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,5 +1,10 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/personFormat';
import { clickOutside } from '$lib/actions/clickOutside';
import PersonChipRow from './PersonChipRow.svelte';
import AnnotateHintStrip from './AnnotateHintStrip.svelte';
import OverflowPillButton from './OverflowPillButton.svelte';
type Person = { id: string; firstName: string; lastName: string };
@@ -25,128 +30,207 @@ type Props = {
let { doc, canWrite, canAnnotate, fileUrl, annotateMode = $bindable() }: Props = $props();
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 annotateBtn(mobile: boolean)}
<button
onclick={() => {
annotateMode = true;
if (mobile) mobileMenuOpen = false;
}}
aria-label={m.doc_panel_annotate()}
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'}
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Note/Note-Add-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5 shrink-0"
/>
{m.doc_panel_annotate()}
</button>
{/snippet}
{#snippet annotateStopBtn(mobile: boolean)}
<button
onclick={() => {
annotateMode = false;
if (mobile) mobileMenuOpen = false;
}}
aria-label={m.doc_panel_annotate_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'}
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Note/Note-Add-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5 shrink-0 invert"
/>
{m.doc_panel_annotate_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}
<!-- 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 canAnnotate && isPdf && !annotateMode}
{@render annotateBtn(false)}
{/if}
{#if canAnnotate && isPdf && annotateMode}
{@render annotateStopBtn(false)}
{/if}
{#if canWrite && !annotateMode}
<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 && !annotateMode}
{@render downloadLink(false)}
{/if}
<!-- Kebab menu — mobile only, contains actions hidden below md -->
{#if (canAnnotate && 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 canAnnotate && isPdf && !annotateMode}
{@render annotateBtn(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>
<!-- Hint strip — only when annotateMode, only at ≥768px -->
<AnnotateHintStrip annotateMode={annotateMode} />
</div>

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

@@ -187,7 +187,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}

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

@@ -302,7 +302,7 @@ $effect(() => {
<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"
class="w-full rounded border border-line bg-surface px-2 py-1 font-sans text-xs text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<option value=""></option>
{#each versions as v, i (v.id)}
@@ -317,7 +317,7 @@ $effect(() => {
<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"
class="w-full rounded border border-line bg-surface px-2 py-1 font-sans text-xs text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<option value=""></option>
{#each versions as v, i (v.id)}

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

@@ -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

@@ -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();
});
});

View File

@@ -106,7 +106,7 @@ const filtered = $derived(
type="search"
bind:value={searchQuery}
placeholder={m.admin_users_search_placeholder()}
class="w-full rounded-sm border border-line bg-surface px-2 py-1.5 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-2 py-1.5 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>

View File

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

View File

@@ -14,7 +14,7 @@ import { m } from '$lib/paraglide/messages.js';
type="text"
name="username"
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>
@@ -26,6 +26,6 @@ import { m } from '$lib/paraglide/messages.js';
type="password"
name="password"
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>

View File

@@ -31,7 +31,7 @@ let {
<div class="mb-6 grid grid-cols-1 items-end gap-4 md:grid-cols-[1fr_auto_1fr] md:gap-6">
<!-- Sender -->
<div
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_input]:focus:border-ink [&_input]:focus:ring-ink [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
>
<PersonTypeahead
name="senderId"
@@ -73,7 +73,7 @@ let {
<!-- Receiver -->
<div
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_input]:focus:border-ink [&_input]:focus:ring-ink [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
>
<PersonTypeahead
name="receiverId"
@@ -99,7 +99,7 @@ let {
type="date"
bind:value={fromDate}
onchange={() => onapplyFilters()}
class="block w-full border-line py-2.5 text-sm shadow-sm focus:border-ink focus:ring-ink"
class="block w-full border-line py-2.5 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
@@ -113,7 +113,7 @@ let {
type="date"
bind:value={toDate}
onchange={() => onapplyFilters()}
class="block w-full border-line py-2.5 text-sm shadow-sm focus:border-ink focus:ring-ink"
class="block w-full border-line py-2.5 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>

View File

@@ -48,9 +48,9 @@ const withDocs = {
// ─── Empty state ──────────────────────────────────────────────────────────────
describe('Conversations page empty state', () => {
it('shows the "select two persons" prompt when no persons are selected', async () => {
it('shows the empty-state heading when no persons are selected', async () => {
render(Page, { data: baseData });
await expect.element(page.getByText(/Wählen Sie zwei Personen aus/i)).toBeInTheDocument();
await expect.element(page.getByText(/Korrespondenz durchsuchen/i)).toBeInTheDocument();
});
it('hides the swap button when no persons are selected', async () => {

View File

@@ -90,7 +90,7 @@ $effect(() => {
titleOverride = (e.target as HTMLInputElement).value;
titleDirty = true;
}}
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"
placeholder="Titel eingeben…"
/>
</div>

View File

@@ -1,10 +1,12 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import AuthHeader from '../AuthHeader.svelte';
let { form }: { form?: { error?: string; success?: boolean } } = $props();
</script>
<div class="relative flex min-h-screen flex-col bg-surface">
<div class="flex min-h-screen flex-col bg-canvas">
<AuthHeader />
<div class="flex flex-1 items-center justify-center px-4">
<div class="w-full max-w-sm">
<!-- Logo -->
@@ -44,7 +46,7 @@ let { form }: { form?: { error?: string; success?: boolean } } = $props();
id="email"
required
autocomplete="email"
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none"
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>

View File

@@ -31,7 +31,7 @@ let {
<div class="mb-6 grid grid-cols-1 items-end gap-4 md:grid-cols-[1fr_auto_1fr] md:gap-6">
<!-- Sender -->
<div
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_input]:focus:border-ink [&_input]:focus:ring-ink [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
>
<PersonTypeahead
name="senderId"
@@ -73,7 +73,7 @@ let {
<!-- Receiver -->
<div
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_input]:focus:border-ink [&_input]:focus:ring-ink [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
>
<PersonTypeahead
name="receiverId"
@@ -99,7 +99,7 @@ let {
type="date"
bind:value={fromDate}
onchange={() => onapplyFilters()}
class="block w-full border-line py-2.5 text-sm shadow-sm focus:border-ink focus:ring-ink"
class="block w-full border-line py-2.5 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
@@ -113,7 +113,7 @@ let {
type="date"
bind:value={toDate}
onchange={() => onapplyFilters()}
class="block w-full border-line py-2.5 text-sm shadow-sm focus:border-ink focus:ring-ink"
class="block w-full border-line py-2.5 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/actions/clickOutside';
interface Correspondent {
id: string;
@@ -17,20 +18,6 @@ interface Props {
let { correspondents, loading, senderName, onselect, onclose }: Props = $props();
function clickOutside(node: HTMLElement) {
const handleClick = (event: MouseEvent) => {
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
onclose();
}
};
document.addEventListener('click', handleClick, true);
return {
destroy() {
document.removeEventListener('click', handleClick, true);
}
};
}
function getOptionElements(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>('[role="option"]'));
}
@@ -60,6 +47,7 @@ function getInitials(person: Correspondent): string {
<div
use:clickOutside
onclickoutside={onclose}
role="listbox"
tabindex="-1"
aria-label={m.conv_suggestions_heading()}
@@ -78,7 +66,7 @@ function getInitials(person: Correspondent): string {
role="option"
aria-selected="false"
tabindex="0"
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm text-ink hover:bg-muted focus:bg-muted focus:outline-none"
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm text-ink hover:bg-muted focus:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset"
onclick={() => onselect(person.id)}
onkeydown={(e) => e.key === 'Enter' && onselect(person.id)}
>
@@ -103,7 +91,7 @@ function getInitials(person: Correspondent): string {
role="option"
aria-selected="false"
tabindex="0"
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm text-ink hover:bg-muted focus:bg-muted focus:outline-none"
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm text-ink hover:bg-muted focus:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset"
onclick={() => onselect('')}
onkeydown={(e) => e.key === 'Enter' && onselect('')}
>

View File

@@ -42,7 +42,7 @@ let isActive = $derived(!!(fromDate || toDate || sortDir !== 'DESC'));
bind:value={fromDate}
onchange={() => onapplyFilters()}
placeholder={m.conv_strip_from_placeholder()}
class="h-8 w-[100px] rounded border bg-surface px-2 text-xs text-ink focus:outline-none {fromDate ? 'border-primary' : 'border-line'}"
class="h-8 w-[100px] rounded border bg-surface px-2 text-xs text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {fromDate ? 'border-primary' : 'border-line'}"
/>
<span class="text-xs text-ink-3"></span>
@@ -52,7 +52,7 @@ let isActive = $derived(!!(fromDate || toDate || sortDir !== 'DESC'));
bind:value={toDate}
onchange={() => onapplyFilters()}
placeholder={m.conv_strip_to_placeholder()}
class="h-8 w-[100px] rounded border bg-surface px-2 text-xs text-ink focus:outline-none {toDate ? 'border-primary' : 'border-line'}"
class="h-8 w-[100px] rounded border bg-surface px-2 text-xs text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {toDate ? 'border-primary' : 'border-line'}"
/>
<!-- Document count -->

View File

@@ -5,6 +5,9 @@
/* ─── 2. Raw palette — never used directly in components ──────────────────── */
@theme {
/* Breakpoints */
--breakpoint-xs: 375px;
/* Brand palette constants */
--palette-navy: #012851;
--palette-mint: #a1dcd8;
@@ -48,14 +51,17 @@
--color-primary: var(--c-primary);
--color-primary-fg: var(--c-primary-fg);
/* Nav active state */
--color-nav-active: var(--c-nav-active);
/* PDF viewer */
--color-pdf-bg: var(--c-pdf-bg);
--color-pdf-ctrl: var(--c-pdf-ctrl);
--color-pdf-text: var(--c-pdf-text);
/* Header surface — independent from canvas/surface for per-mode control */
--color-header: var(--c-header);
/* Focus ring — keyboard focus indicator, mode-aware (navy in light, mint in dark) */
--color-focus-ring: var(--c-focus-ring);
/* Static brand tokens (not themed) */
--color-brand-navy: var(--palette-navy);
--color-brand-mint: var(--palette-mint);
@@ -84,7 +90,11 @@
--c-primary: #012851;
--c-primary-fg: #ffffff;
--c-nav-active: rgba(180, 185, 255, 0.15);
/* Header is brand-navy in light mode; same in dark mode for contrast compliance */
--c-header: #012851;
/* Focus ring: brand-navy in light mode — 14:1 on white, ~11:1 on sand */
--c-focus-ring: #012851;
--c-pdf-bg: #ebebeb;
--c-pdf-ctrl: #d8d8d8;
@@ -92,19 +102,26 @@
}
/* ─── 5. Dark mode ─────────────────────────────────────────────────────────── */
/*
Navy-tinted dark palette derived from brand-navy (#012851).
KEEP THESE TWO BLOCKS IN SYNC — they cover the same design intent via
different activation paths (system preference vs. manual toggle).
*/
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--c-canvas: #0d0d0d;
--c-surface: #1a1a1a;
--c-overlay: #242424;
--c-muted: #252525;
color-scheme: dark;
--c-line: #3d3d3d;
--c-line-2: #2e2e2e;
--c-canvas: #010e1e;
--c-surface: #011526;
--c-overlay: #011e38;
--c-muted: #011a30;
--c-line: #0d3358;
--c-line-2: #092843;
--c-ink: #f0efe9;
--c-ink-2: #9ca3af; /* gray-400 — 7.5:1 on dark surface — WCAG AAA ✓ */
--c-ink-3: #8b97a5; /* gray-450 — 6.5:1 on dark surface — WCAG AA ✓ */
--c-ink-2: #9ca3af; /* 7.5:1 on #011526 — WCAG AAA ✓ */
--c-ink-3: #8b97a5; /* 7.1:1 on #011526 — WCAG AAA ✓ */
--c-accent: #00c7b1;
--c-accent-bg: rgba(0, 199, 177, 0.12);
@@ -112,27 +129,34 @@
--c-primary: #a1dcd8;
--c-primary-fg: #012851;
--c-nav-active: rgba(180, 185, 255, 0.12);
/* Header at brand-navy: 4.99:1 with ink-3 (WCAG AA ✓), visually above canvas */
--c-header: #012851;
--c-pdf-bg: #1e1e1e;
--c-pdf-ctrl: #2a2a2a;
--c-pdf-text: #d1d1d1;
/* Focus ring: brand-mint in dark mode — 9.2:1 on canvas, 7.1:1 on surface */
--c-focus-ring: #a1dcd8;
--c-pdf-bg: #010e1e;
--c-pdf-ctrl: #011526;
--c-pdf-text: #f0efe9;
}
}
/* Manual dark override — takes precedence over media query */
/* KEEP IN SYNC with the @media block above */
:root[data-theme='dark'] {
--c-canvas: #0d0d0d;
--c-surface: #1a1a1a;
--c-overlay: #242424;
--c-muted: #252525;
color-scheme: dark;
--c-line: #3d3d3d;
--c-line-2: #2e2e2e;
--c-canvas: #010e1e;
--c-surface: #011526;
--c-overlay: #011e38;
--c-muted: #011a30;
--c-line: #0d3358;
--c-line-2: #092843;
--c-ink: #f0efe9;
--c-ink-2: #9ca3af;
--c-ink-3: #6b7280;
--c-ink-2: #9ca3af; /* 7.5:1 on #011526 — WCAG AAA ✓ */
--c-ink-3: #8b97a5; /* 7.1:1 on #011526 — WCAG AAA ✓ */
--c-accent: #00c7b1;
--c-accent-bg: rgba(0, 199, 177, 0.12);
@@ -140,11 +164,15 @@
--c-primary: #a1dcd8;
--c-primary-fg: #012851;
--c-nav-active: rgba(180, 185, 255, 0.12);
/* Header at brand-navy: 4.99:1 with ink-3 (WCAG AA ✓), visually above canvas */
--c-header: #012851;
--c-pdf-bg: #1e1e1e;
--c-pdf-ctrl: #2a2a2a;
--c-pdf-text: #d1d1d1;
/* Focus ring: brand-mint in dark mode — 9.2:1 on canvas, 7.1:1 on surface */
--c-focus-ring: #a1dcd8;
--c-pdf-bg: #010e1e;
--c-pdf-ctrl: #011526;
--c-pdf-text: #f0efe9;
}
/* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */
@@ -219,4 +247,17 @@
text-decoration-thickness: 2px;
text-underline-offset: 4px;
}
/* Fallback focus ring for any interactive element not styled with ring-focus-ring */
:focus-visible {
outline: 2px solid var(--c-focus-ring);
outline-offset: 2px;
}
}
/* Ensure focus rings are visible in Windows High Contrast / forced-colors mode */
@media (forced-colors: active) {
:focus-visible {
outline: 3px solid ButtonText;
}
}

View File

@@ -1,34 +1,16 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { setLocale, getLocale } from '$lib/paraglide/runtime';
import AuthHeader from '../AuthHeader.svelte';
let { form }: { form?: { error?: string; success?: boolean } } = $props();
const locales = ['DE', 'EN', 'ES'] as const;
const localeMap = { DE: 'de', EN: 'en', ES: 'es' } as const;
const activeLocale = $derived(getLocale().toUpperCase());
</script>
<svelte:head>
<title>{m.page_title_login()}</title>
</svelte:head>
<div class="relative flex min-h-screen flex-col bg-canvas">
<!-- Language switcher -->
<div class="absolute top-4 right-4 flex items-center gap-1">
{#each locales as locale (locale)}
<button
type="button"
onclick={() => setLocale(localeMap[locale])}
class="px-1.5 py-1 font-sans text-xs tracking-widest transition-colors
{activeLocale === locale
? 'font-bold text-ink'
: 'font-normal text-ink-3 hover:text-ink'}"
>
{locale}
</button>
{/each}
</div>
<div class="flex min-h-screen flex-col bg-canvas">
<AuthHeader />
<div class="flex flex-1 items-center justify-center px-4">
<div class="w-full max-w-sm">
@@ -60,7 +42,7 @@ const activeLocale = $derived(getLocale().toUpperCase());
id="username"
required
autocomplete="username"
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none"
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
@@ -76,7 +58,7 @@ const activeLocale = $derived(getLocale().toUpperCase());
id="password"
required
autocomplete="current-password"
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none"
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>

View File

@@ -10,7 +10,9 @@ afterEach(cleanup);
describe('Login page rendering', () => {
it('renders the page title', async () => {
render(LoginPage, {});
await expect.element(page.getByRole('link', { name: 'Familienarchiv' })).toBeInTheDocument();
await expect
.element(page.getByRole('link', { name: 'Familienarchiv' }).first())
.toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/login-default.png' });
});

View File

@@ -111,7 +111,7 @@ function typeBadgeLabel(type: NotificationItem['type']): string {
aria-checked={activeType === null && activeReadFilter === null}
onclick={() => setFilter({ type: null, read: null })}
class={[
'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2',
'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2',
activeType === null && activeReadFilter === null
? 'bg-primary text-primary-fg'
: 'bg-muted text-ink'
@@ -126,7 +126,7 @@ function typeBadgeLabel(type: NotificationItem['type']): string {
aria-checked={activeReadFilter === 'false'}
onclick={() => setFilter({ read: 'false', type: null })}
class={[
'inline-flex items-center gap-1.5 rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2',
'inline-flex items-center gap-1.5 rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2',
activeReadFilter === 'false'
? 'bg-primary text-primary-fg'
: 'bg-muted text-ink'
@@ -149,7 +149,7 @@ function typeBadgeLabel(type: NotificationItem['type']): string {
aria-checked={activeType === 'MENTION'}
onclick={() => setFilter({ type: 'MENTION', read: null })}
class={[
'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2',
'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2',
activeType === 'MENTION'
? 'bg-primary text-primary-fg'
: 'bg-muted text-ink'
@@ -164,7 +164,7 @@ function typeBadgeLabel(type: NotificationItem['type']): string {
aria-checked={activeType === 'REPLY'}
onclick={() => setFilter({ type: 'REPLY', read: null })}
class={[
'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2',
'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2',
activeType === 'REPLY'
? 'bg-primary text-primary-fg'
: 'bg-muted text-ink'

View File

@@ -22,14 +22,14 @@ function makeUrl(params: Record<string, string | string[]> = {}) {
// ─── dashboard mode (no search filters) ──────────────────────────────────────
describe('home page load — dashboard mode', () => {
it('sets isDashboard true and fetches all three widget APIs', async () => {
it('sets isDashboard true and fetches stats, incomplete, and recent APIs', async () => {
const mockGet = vi
.fn()
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
.mockResolvedValueOnce({
response: { ok: true },
data: { content: [{ id: 'n1' }] }
}) // notifications
data: { totalDocuments: 42, totalPersons: 7 }
}) // stats
.mockResolvedValueOnce({ response: { ok: true }, data: [{ id: 'd1' }] }) // incomplete
.mockResolvedValueOnce({ response: { ok: true }, data: [{ id: 'd2' }] }); // recent
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
@@ -39,17 +39,20 @@ describe('home page load — dashboard mode', () => {
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
expect(result.isDashboard).toBe(true);
expect(result.mentions).toHaveLength(1);
expect(result.stats).toEqual({ totalDocuments: 42, totalPersons: 7 });
expect(result.incompleteDocs).toHaveLength(1);
expect(result.recentDocs).toHaveLength(1);
expect(result.documents).toEqual([]);
});
it('defaults mentions to [] when notifications API rejects', async () => {
it('returns stats with totalDocuments from /api/stats', async () => {
const mockGet = vi
.fn()
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
.mockRejectedValueOnce(new Error('network')) // notifications
.mockResolvedValueOnce({
response: { ok: true },
data: { totalDocuments: 248, totalPersons: 34 }
}) // stats
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
.mockResolvedValueOnce({ response: { ok: true }, data: [] }); // recent
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
@@ -58,7 +61,24 @@ describe('home page load — dashboard mode', () => {
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
expect(result.mentions).toEqual([]);
expect(result.stats?.totalDocuments).toBe(248);
expect(result.stats?.totalPersons).toBe(34);
});
it('returns stats: null when /api/stats rejects', async () => {
const mockGet = vi
.fn()
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
.mockRejectedValueOnce(new Error('network')) // stats
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
.mockResolvedValueOnce({ response: { ok: true }, data: [] }); // recent
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
expect(result.stats).toBeNull();
});
it('defaults incompleteDocs to [] when incomplete API rejects', async () => {
@@ -81,7 +101,10 @@ describe('home page load — dashboard mode', () => {
const mockGet = vi
.fn()
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
.mockResolvedValueOnce({ response: { ok: true }, data: { content: [] } }) // notifications
.mockResolvedValueOnce({
response: { ok: true },
data: { totalDocuments: 0, totalPersons: 0 }
}) // stats
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
.mockRejectedValueOnce(new Error('network')); // recent
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
@@ -113,7 +136,7 @@ describe('home page load — search mode', () => {
expect(result.isDashboard).toBe(false);
expect(result.documents).toHaveLength(1);
expect(result.mentions).toEqual([]);
expect(result.stats).toBeNull();
expect(result.incompleteDocs).toEqual([]);
expect(result.recentDocs).toEqual([]);
// Only two API calls — no widget calls

View File

@@ -21,8 +21,12 @@ const emptyData = {
user: undefined,
canWrite: true,
canAnnotate: false,
isDashboard: false,
filters: { q: '', from: '', to: '', senderId: '', receiverId: '', tags: [] },
documents: [],
incompleteDocs: [],
recentDocs: [],
stats: null,
incompleteCount: 0,
initialValues: { senderName: '', receiverName: '' },
error: null
@@ -189,6 +193,42 @@ describe('Home page search input keystroke preservation', () => {
});
});
// ─── Dashboard mode ───────────────────────────────────────────────────────────
describe('Home page dashboard mode', () => {
const dashboardData = {
...emptyData,
isDashboard: true,
canWrite: false,
incompleteDocs: [],
recentDocs: []
};
it('hides the right column when canWrite is false and incompleteDocs is empty', async () => {
render(Page, { data: dashboardData });
const rightCol = page.getByTestId('dashboard-right-column');
await expect.element(rightCol).not.toBeInTheDocument();
});
it('shows the right column when canWrite is true', async () => {
render(Page, { data: { ...dashboardData, canWrite: true } });
const rightCol = page.getByTestId('dashboard-right-column');
await expect.element(rightCol).toBeInTheDocument();
});
it('shows the right column when incompleteDocs is non-empty', async () => {
render(Page, {
data: {
...dashboardData,
canWrite: false,
incompleteDocs: [{ id: 'd1', title: 'Taufschein' }]
}
});
const rightCol = page.getByTestId('dashboard-right-column');
await expect.element(rightCol).toBeInTheDocument();
});
});
// ─── Error state ──────────────────────────────────────────────────────────────
describe('Home page error state', () => {

View File

@@ -54,7 +54,7 @@ function handleSearch() {
oninput={handleSearch}
onfocus={() => (qFocused = true)}
onblur={() => (qFocused = false)}
class="block w-56 rounded-sm border border-line bg-surface py-2.5 pr-10 pl-4 font-sans text-sm text-ink placeholder-ink-3 shadow-sm focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none"
class="block w-56 rounded-sm border border-line bg-surface py-2.5 pr-10 pl-4 font-sans text-sm text-ink 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 text-ink-3"

View File

@@ -26,7 +26,7 @@ let {
type="text"
required
value={person.firstName}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div>
@@ -39,7 +39,7 @@ let {
type="text"
required
value={person.lastName}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div class="md:col-span-2">
@@ -51,7 +51,7 @@ let {
name="alias"
type="text"
value={person.alias ?? ''}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div>
@@ -66,7 +66,7 @@ let {
max="2100"
placeholder={m.person_placeholder_year()}
value={person.birthYear ?? ''}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div>
@@ -81,7 +81,7 @@ let {
max="2100"
placeholder={m.person_placeholder_year()}
value={person.deathYear ?? ''}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div class="md:col-span-2">
@@ -93,7 +93,7 @@ let {
name="notes"
rows="4"
placeholder={m.person_placeholder_notes()}
class="block w-full resize-y rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
class="block w-full resize-y rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>{person.notes ?? ''}</textarea
>
</div>

View File

@@ -48,7 +48,7 @@ let { form } = $props();
name="firstName"
type="text"
required
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>
@@ -61,7 +61,7 @@ let { form } = $props();
name="lastName"
type="text"
required
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>
@@ -74,7 +74,7 @@ let { form } = $props();
name="alias"
type="text"
placeholder={m.form_placeholder_alias()}
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>
@@ -89,7 +89,7 @@ let { form } = $props();
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
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>
@@ -104,7 +104,7 @@ let { form } = $props();
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
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>
@@ -117,7 +117,7 @@ let { form } = $props();
name="notes"
rows="4"
placeholder={m.person_placeholder_notes()}
class="block w-full resize-y rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
class="block w-full resize-y rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
></textarea>
</div>
</div>

View File

@@ -64,7 +64,7 @@ describe('Persons page rendering', () => {
it('shows alias in italic when provided', async () => {
render(Page, { data: { ...emptyData, persons: [makePerson({ alias: 'Maxi' })] } });
await expect.element(page.getByText('"Maxi"')).toBeInTheDocument();
await expect.element(page.getByText('Maxi"')).toBeInTheDocument();
});
it('shows life date range when birthYear is provided', async () => {

View File

@@ -39,7 +39,7 @@ let {
type="password"
name="currentPassword"
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>
@@ -51,7 +51,7 @@ let {
type="password"
name="newPassword"
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>
@@ -63,13 +63,14 @@ let {
type="password"
name="confirmPassword"
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>
<button
type="submit"
data-testid="submit-password"
class="mt-5 rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
>
{m.btn_save()}

View File

@@ -57,7 +57,7 @@ function handleBirthDateInput(e: Event) {
type="text"
name="firstName"
value={user?.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>
@@ -69,7 +69,7 @@ function handleBirthDateInput(e: Event) {
type="text"
name="lastName"
value={user?.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>
@@ -82,7 +82,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>
@@ -95,7 +95,7 @@ function handleBirthDateInput(e: Event) {
type="email"
name="email"
value={user?.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>
@@ -107,7 +107,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"
>{user?.contact ?? ''}</textarea
>
</label>

View File

@@ -53,7 +53,7 @@ let {
id="newPassword"
required
autocomplete="new-password"
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none"
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
@@ -69,7 +69,7 @@ let {
id="confirmPassword"
required
autocomplete="new-password"
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none"
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>