DocumentTopBar — Final Implementation Spec

Authoritative implementation reference for the responsive DocumentTopBar component. Incorporates all resolutions from the issue #161 team review (Felix Brandt, Markus Keller, Sara Holt, Nora Steiner, Tobias Wendt, Leonie Voss). Supersedes document-topbar-b1-responsive.html — refer to that file for additional visual mockup detail.

📐 Mockup scale notice — all font-size, height, and padding values in the mockup CSS below are scaled to ~55% of actual implementation values. Do not copy sizes from mockup CSS. Use the ⚙ Implementation Reference tables after each section. Mockup CSS is for visual preview only.
⚠ This spec overrides the B1 spec — font sizes, heights, status chip, overflow pill, and touch targets have all changed. Key corrections: title min text-[11px], chip names text-[9px], topbar heights h-12/h-14, status chip dot-only, edit button icon-only on mobile.

0 · Component architecture

Decompose into these components. Never merge into a single monolith — each has a clear single visual responsibility and must be independently testable.

Component filePropsResponsibilityNotes
DocumentTopBar.sveltedoc, canWrite, canAnnotate, fileUrl, annotateMode (bindable)Orchestrator. Owns overflowOpen: $state(false). Passes props down. Contains back link, title, action buttons.Parent layout must wrap in <header>. No direct DOM measurement.
PersonChipRow.sveltesender, receivers, abbreviated: booleanChip row with arrow. Visible at ≥375px. Hidden at XS via hidden xs:flex.Renders plain-text fallback slot at XS via parent.
PersonChip.svelteperson, abbreviated: booleanSingle chip: avatar initials + name. Abbreviated = first initial + last name.Avatar colour from personAvatarColor(person.id).
OverflowPill.svelteextraCount, persons (for tooltip), open (bindable)At ≥768px: interactive <button> with tooltip. At <768px: <span aria-hidden="true"> — non-interactive.aria-haspopup="listbox", aria-expanded, aria-label. See tooltip rules.
DocumentStatusChip.sveltestatus: DocumentStatusDot-only indicator. Hidden below 768px. title + aria-label carry the label text.No text label — removes i18n requirement.
AnnotateHintStrip.svelteannotateMode: boolean18px strip below main row. Only rendered when annotateMode === true AND viewport ≥768px.Use {#if annotateMode} — no CSS height animation. Hidden via parent responsive class.
Implementation Reference — Svelte state & derived values Svelte 5 runes
ValueTypeImplementationNotes
overflowOpen$state(false)let overflowOpen = $state(false)In DocumentTopBar. Passed as bindable to OverflowPill.
visibleReceivers$derived$derived(doc.receivers.slice(0, viewportGe768 ? 2 : 1))CSS-only: at <768px always 1 shown. At ≥768px show 2 if count==2, else 1. Use CSS to hide — no JS.
extraCount$derived$derived(doc.receivers.length - visibleReceivers.length)0 = no pill needed.
formattedDate$derivedSee utility module — formatDate(doc.documentDate, format)Format switches via CSS responsive classes, not JS viewport check.
xsMetaLine$derived$derived(formatXsMeta(doc))Used only at XS. Import from $lib/utils/personFormat.
annotateModebindable proplet { annotateMode = $bindable(false) } = $props()Parent page owns state. TopBar toggles it.

1 · Design tokens

All CSS custom properties used by the topbar. No hardcoded colours in any component — all must reference these tokens.

Light theme
bg-surface#FFFFFF — topbar bg
border-line#E4E2D8 — bottom border, dividers, chip borders
bg-primary#012851 — accent bar (light), primary btn, avatars
text-primary-fg#A1DCD8 — text on navy bg
text-ink#1A1A1A — title, chip names
text-ink-2#AAAAAA — date, meta (spec ink-3 mapped here)
bg-muted#F0EFE9 — chip bg, back btn bg at XS
Accent barborder-l-[3px] border-primary — always present, all breakpoints
Dark theme
bg-surface#0F1923
border-line#1E2D3D
Accent bar (dark)#A1DCD8 — teal replaces navy for accent bar
text-ink#EAE8E2 — title
text-ink-2 (meta)#3E5065 — date, meta
Chip bg#0A1218 · border #1E2D3D
Primary btn (dark)bg #A1DCD8 · text #012851 — inverted
Implementation Reference — Design Tokens Real values · CSS custom properties
Token / concernTailwind classCSS varNotes
Accent barborder-l-[3px] border-primaryvar(--color-primary)Light: #012851. Dark: resolves to #A1DCD8 via theme. Never hardcode.
Topbar bgbg-surfacevar(--color-surface)Auto light/dark via CSS custom property.
Bottom borderborder-b border-linevar(--color-line)1px, both themes.
Chip bgbg-mutedvar(--color-muted)Light #F0EFE9, dark #0A1218.
Chip borderborder-lineSame token as bottom border.
Hint strip bg (light)bg-[rgba(1,40,81,0.05)]⚠ --color-primary must be RGB format (1 40 81) for bg-primary/5 to work. If hex, use explicit rgba fallback.
Hint strip bg (dark)dark:bg-[rgba(161,220,216,0.04)]Use explicit rgba. Verify --color-primary-fg is also RGB.
Avatar paletteinline style only5 values: ['#012851','#5A3080','#007596','#2A6040','#803020']. Index = hash(id) % 5.

2 · Receiver overflow patterns

Rule: always show sender + 1st receiver, collapse remaining. At <768px: max 1 receiver shown, overflow pill is a non-interactive span. At ≥768px: show 2 receivers if count==2 (no pill); show 1 + pill if count≥3.

Light theme — all receiver counts
0 receivers
KR
Karl Raddatz
Sender only. No arrow. Diary entries, certificates.
1 receiver
KR
Karl Raddatz
ER
Elfriede Raddatz
Both shown. No pill.
2 receivers
KR
Karl Raddatz
ER
Elfriede Raddatz
·
HR
Hans Raddatz
≥768px only: both shown, no pill. At <768px: collapse to 1st + "+1" span.
3 receivers
KR
Karl Raddatz
ER
Elfriede Raddatz
+2 weitere
≥768px: "+2 weitere" interactive button. <768px: "+2" non-interactive span, aria-hidden.
No sender
ER
Elfriede Raddatz
First available person shown. No arrow. Photos, undated documents.
Implementation Reference — Chip & Overflow Logic Real values · mockup above is ~55% scale
ElementTailwind classesReal sizeNotes
PersonChip containerinline-flex items-center gap-1 rounded-full border border-line bg-muted px-2 py-1 whitespace-nowrap shrink-0h ~28px, px 8px, py 4pxNot interactive — no hover/focus styles needed.
Avatar circleflex w-[18px] h-[18px] shrink-0 items-center justify-center rounded-full text-[7px] font-bold18×18pxbg from personAvatarColor(id) — inline style. text-primary-fg for navy bg, white for others.
Chip name (full)text-[9px] font-semibold text-ink9px / 600⚠ Most commonly undersized — minimum 9px. Original spec said 6.5px — corrected.
Chip name (abbreviated)text-[9px] font-semibold text-ink9px / 600"K. Raddatz" format at <768px. Same styling as full name.
Arrow between chipstext-ink-2 shrink-0 text-[11px] aria-hidden="true"11pxUnicode → (U+2192). aria-hidden always present.
Overflow pill (≥768px)inline-flex items-center rounded-full border border-line bg-muted px-2 py-1 text-[9px] font-bold text-ink-2 whitespace-nowrap shrink-0 min-h-[44px] md:min-h-09px / 700Interactive button at ≥768px. Active state: bg-primary border-primary text-primary-fg.
Overflow pill (<768px)inline-flex items-center rounded-full border border-line bg-muted px-2 py-1 text-[9px] font-bold text-ink-2 whitespace-nowrap shrink-09px / 700<span aria-hidden="true"> — not a button. No tap behaviour.
PersonChipRow wrapperhidden xs:flex items-center gap-1.5 min-w-0 overflow-hiddenHidden at XS (<375px). flex at ≥375px.
XS plain-text metablock xs:hidden text-[9px] text-ink-2 truncate mt-0.59pxFormat: "K.Raddatz → E.Raddatz +4 · 24.12.1943". From formatXsMeta(doc).

3 · Viewport-by-viewport

Visual reference for each breakpoint. See B1 spec for full set. Key states shown below.

320 px · XS Mobile
320pxLight
Brief a. Großmutter
K.Raddatz → E.Raddatz · 24.12.1943
XS: square back btn, plain-text meta below title, icon-only edit button. No chips, no annotate, no download.
320pxDark
Brief a. Großmutter
K.Raddatz → E.Raddatz · 24.12.1943
Dark XS: teal accent bar, icon-only edit, dark chip-less meta.
375 px · Standard Mobile
375pxLight · 1 receiver
Brief a. Großmutter, 1943
KR
K. Raddatz
ER
E. Raddatz
375px: circle back btn, chip row with abbreviated names, icon-only edit. Annotate hidden.
375pxLight · 3+ receivers
Rundbrief, 1951
KR
K. Raddatz
ER
E. Raddatz
+2
375px overflow: "+2" is a non-interactive <span aria-hidden>. No "weitere" — no tooltip on mobile.
768 px · Tablet
768pxLight · 1 receiver
Brief an Großmutter, Weihnachten 1943
24.12.1943
·
KR
Karl Raddatz
ER
Elfriede Raddatz
Annotieren
Bearbeiten
768px: full names, dot-only status indicator, Annotate + Bearbeiten + download icon. Status dot replaces text chip.
768pxDark · annotate active
Brief an Großmutter
KR
Karl Raddatz
ER
Elfriede Raddatz
Beenden
Annotierungsmodus aktiv
Klicken Sie auf eine Textstelle.
Annotate active: Edit + Download removed. "Beenden" fills teal. Hint strip visible. PDF outline teal at 15%.
1024 px · Laptop / 1440 px · Desktop
1024pxLight · 5 receivers (overflow open)
Rundbrief an die Familie, Sommer 1951
18.07.1951 · Berlin
·
KR
Karl Raddatz
ER
Elfriede Raddatz
+4 weitere
Weitere Empfänger
HR
Hans Raddatz
MR
Maria Raddatz
GR
Gerhard Raddatz
Annotieren
Bearbeiten
Active overflow pill fills navy. Tooltip drops below: "Weitere Empfänger" + avatar + name link per person.
Implementation Reference — Topbar Layout per Breakpoint Real values · mockup above is ~55% scale
ElementXS <375pxMobile 375–767pxTablet/Desktop ≥768pxNotes
Topbar heighth-12 48pxh-14 56pxh-14 56px⚠ Increased from B1 spec (was 44/50/52px) to fit text-[11px]+ chip row.
Back button wrappera href="/documents" w-11 h-11 -ml-2 flex items-center justify-center shrink-0 focus-visible:ring-2w-11 h-11 = 44×44px touch area. -ml-2 = visual alignment. aria-label="Zurück zur Dokumentenliste" always.
Back button visualw-6 h-6 rounded-sm bg-muted flex items-center justify-centerw-6 h-6 rounded-full border border-line flex items-center justify-centerw-7 h-7 rounded-full border border-line flex items-center justify-centerShape changes at xs breakpoint. Inner chevron SVG 10×10px.
Titlefont-serif font-extrabold text-[11px] text-ink truncatefont-serif font-extrabold text-[11px] text-ink truncatefont-serif font-extrabold text-[12px] lg:text-[13px] text-ink truncate⚠ Minimum 11px — original spec said 10px, corrected.
Status indicatorHidden hiddenHidden hiddenhidden md:block w-2.5 h-2.5 rounded-full shrink-0Dot only. title + aria-label carry label. See statusDotClass() for colours.
Date textIn xsMetaLine stringtext-xs text-ink-2 shrink-0 format: "24.12.1943"text-xs text-ink-2 shrink-0 format: ≥1024px long ("24. Dezember 1943")Date format switches via Tailwind: <span class="lg:hidden">24.12.1943</span><span class="hidden lg:inline">{longDate}</span>
Location in metaHiddenHiddenShown if doc.location present at ≥768px: hidden md:inline// TODO: show location when doc.location field available on DTO
Edit button (XS/mobile)inline-flex items-center justify-center w-11 h-11 -mr-2 shrink-0 with pencil SVG 18×18px. aria-label="Bearbeiten".⚠ Icon-only on mobile. "Bearbeiten" text hidden below 768px.
Edit button (tablet+)hidden md:inline-flex h-10 items-center gap-2 px-4 bg-primary text-primary-fg text-[11px] font-bold uppercase tracking-wide rounded-smShows pencil icon + "Bearbeiten" label at ≥768px.
Annotate buttonHidden hiddenHidden hiddenhidden md:inline-flex h-10 items-center gap-2 px-3 border border-line text-ink-2 text-[11px] font-bold uppercase tracking-wide rounded-smActive: bg-primary border-primary text-primary-fg. Label "Annotieren" → "Beenden". aria-pressed={annotateMode}.
Download buttonHiddenHiddenhidden md:inline-flex w-10 h-10 items-center justify-center border border-line rounded-sm text-ink-2Icon only — download SVG 18×18px.
DividerHiddenHiddenhidden md:block w-px h-4 bg-line shrink-0Between download icon and Bearbeiten button.

4 · Responsive rules (authoritative)

All heights and font sizes updated from resolved review. These values override the B1 spec.

Element≤ 374px (XS)375–767px (mobile)768–1023px (tablet)≥ 1024px (desktop)
Topbar heighth-12 (48px)h-14 (56px)h-14 (56px)h-14 (56px)
Back buttonSquare 24×24 rounded-sm bg-mutedCircle 24×24 rounded-full border-lineCircle 28×28 rounded-full border-lineCircle 28×28
Touch targetw-11 h-11 -ml-2 wrapper around all back button variants. Always 44×44px.
Title sizetext-[11px] / 800text-[11px] / 800text-[12px] / 800text-[13px] / 800
Chip rowHidden → plain-text xsMetaLineShown — abbreviated namesShown — full namesShown — full names + location
Chip name textN/Atext-[9px] / 600text-[9px] / 600text-[9px] / 600
Date formatdd.mm.yyyy in xsMetaLine24.12.194324.12.194324. Dezember 1943
Status indicatorHiddenHiddenDot only — w-2.5 h-2.5 rounded-fullDot only
Annotate buttonHiddenHiddenShown — "Annotieren"Shown — "Annotieren"
Edit buttonIcon only (pencil SVG)Icon onlyIcon + "Bearbeiten"Icon + "Bearbeiten"
Download buttonHiddenHiddenIcon onlyIcon only
Overflow pillN/A (chips hidden)<span aria-hidden> "+N"<button> "+N weitere" → tooltip<button> "+N weitere" → tooltip
Hint stripHiddenHidden18px strip when annotateMode18px strip when annotateMode
Implementation Reference — Tailwind breakpoint setup tailwind.config.ts
ItemValueNotes
Custom xs breakpointxs: '375px' in theme.extend.screens⚠ Tailwind 4 may use different config syntax — verify before using xs: prefix classes. Without this, xs:flex silently does nothing.
Usage for chip rowhidden xs:flexHidden at <375px, flex at ≥375px.
Usage for XS metablock xs:hiddenOnly visible below 375px.
Usage for overflow pill typeCSS only: hidden xs:inline-flex for button, always-rendered span for mobile with md:hiddenPrefer CSS over JS viewport check — no SSR issues.

5 · Overflow tooltip

Clicking "+N weitere" opens a floating panel. Only at ≥768px. Mobile overflow pill is a non-interactive span.

Implementation Reference — OverflowPill component Real values · Svelte 5
ElementTailwind classesReal sizeNotes
Pill (interactive ≥768px)inline-flex items-center rounded-full border border-line bg-muted px-2 py-1 text-[9px] font-bold text-ink-2 whitespace-nowrap shrink-0 cursor-pointerh ~28px, 9px textActive state: bg-primary border-primary text-primary-fg. aria-haspopup="listbox" aria-expanded={open} aria-label="{count} weitere Empfänger".
Pill (non-interactive <768px)same visual classes as above on <span aria-hidden="true">No button, no onclick, no tooltip. Users access full receiver list via document edit page.
Tooltip panelabsolute top-full left-0 mt-1 bg-surface border border-line rounded-md shadow-lg p-3 min-w-[160px] z-50min 160px wide, p 12pxposition:relative on chip row container. role="listbox".
Tooltip headertext-[9px] font-bold uppercase tracking-wide text-ink-2 border-b border-line pb-2 mb-29px / 700Text: "Weitere Empfänger".
Person rowflex items-center gap-2 py-1 rounded hover:bg-mutedmin 32px tallrole="option". Each row is an <a href="/persons/{id}">. Avatar 16×16px.
Person name in tooltiptext-[11px] font-medium text-ink11px / 500Full name always shown in tooltip.
Keyboard: openEnter/Space on pill → open tooltip → focus first role="option".
Keyboard: navigateTab/Shift+Tab between options inside tooltip.
Keyboard: closeEscape → close + return focus to pill. Click-outside → close. Second click on pill → close.
Click-outsideUse use:clickOutside Svelte action — do not use document.addEventListener directly in $effect.
Implementation Reference — Annotate hint strip AnnotateHintStrip.svelte
ElementTailwind classesReal sizeNotes
Strip wrapperhidden md:flex h-[18px] items-center gap-2 border-t border-dashed px-3.5h 18pxOnly rendered when annotateMode===true via Svelte {#if}. hidden below md.
Strip bg (light)bg-[rgba(1,40,81,0.05)]⚠ Use explicit rgba — bg-primary/5 requires RGB --color-primary. Fallback if not converted.
Strip border (light)border-[rgba(1,40,81,0.20)]Same caveat — explicit rgba.
Strip bg (dark)dark:bg-[rgba(161,220,216,0.04)]Explicit rgba.
Label texttext-[9px] font-bold uppercase tracking-wide text-primary9px / 700⚠ Corrected from spec's 5.5px — minimum 9px. Dark: text-primary-fg.
Body texttext-[9px] text-ink-29px"Klicken Sie auf eine Textstelle im Dokument, um eine Anmerkung hinzuzufügen."

6 · Utility functions — src/lib/utils/personFormat.ts

Pure functions. Write Vitest unit tests for each before implementing. No DOM, no side effects.

FunctionSignatureBehaviour & edge cases
abbreviateName abbreviateName(person: Person): string "Karl Raddatz" → "K. Raddatz"
"Elfriede" (single name) → "Elfriede" (no initial, return as-is)
"Karl Müller-Schmidt" → "K. Müller-Schmidt" (preserve hyphenated last name)
Split on first space only. First character of first word + ". " + rest.
formatXsMeta formatXsMeta(doc: Document): string 0 receivers: "K.Raddatz · 24.12.1943"
1 receiver: "K.Raddatz → E.Raddatz · 24.12.1943"
3 receivers: "K.Raddatz → E.Raddatz +2 · 24.12.1943"
No sender: "E.Raddatz · 24.12.1943"
Abbreviated format: first initial + dot + last name, no space (e.g. "K.Raddatz").
Date: dd.mm.yyyy format, no spaces.
personAvatarColor personAvatarColor(personId: string): string Returns one of: ['#012851','#5A3080','#007596','#2A6040','#803020']
Must be deterministic: same ID always returns same colour.
Implementation: PALETTE[simpleHash(id) % PALETTE.length]
simpleHash: sum of char codes, or djb2. Never interpolate the ID string into CSS directly.
Test: 1000 random UUIDs → all map to valid palette entry.
formatDate formatDate(isoDate: string, format: 'short' | 'long'): string short: "24.12.1943" — use Intl.DateTimeFormat('de-DE', {day:'2-digit', month:'2-digit', year:'numeric'})
long: "24. Dezember 1943" — use month: 'long'
Always parse with new Date(isoDate + 'T12:00:00') to avoid UTC off-by-one.
statusDotClass statusDotClass(status: DocumentStatus): string PLACEHOLDER → 'bg-gray-400'
UPLOADED → 'bg-emerald-500'
TRANSCRIBED → 'bg-blue-400'
REVIEWED → 'bg-amber-400'
ARCHIVED → 'bg-emerald-600'
statusLabel (for title/aria) statusLabel(status: DocumentStatus): string German labels (not shown as text, used in title + aria-label only):
PLACEHOLDER → "Platzhalter" · UPLOADED → "Hochgeladen" · TRANSCRIBED → "Transkribiert"
REVIEWED → "Geprüft" · ARCHIVED → "Archiviert"

7 · Accessibility requirements (WCAG 2.2 AA)

ElementRequirement
LandmarkParent page must wrap topbar in <header role="banner"> or the topbar must itself be in <header>. Verify parent layout.
Back linkaria-label="Zurück zur Dokumentenliste" always present — icon is the only visible element at XS.
Edit button (icon-only mobile)aria-label="Bearbeiten" always present, even at mobile where it renders icon-only.
Annotate buttonaria-pressed={annotateMode}. Label changes: "Annotieren" → "Beenden".
Overflow pillaria-haspopup="listbox" (not "true"), aria-expanded={overflowOpen}, aria-label="{extraCount} weitere Empfänger anzeigen".
Overflow tooltiprole="listbox" on panel. role="option" on each person row. Not a modal — no focus trap needed.
Tooltip focus flowOpening tooltip moves focus to first role="option". Tab/Shift+Tab navigates within. Escape closes + returns focus to pill.
Arrow between chipsaria-hidden="true" on the → character. Directionality conveyed by order, not just the arrow.
Status dottitle={statusLabel(doc.status)} for hover tooltip. aria-label={statusLabel(doc.status)}. No text label rendered.
Touch targetsBack button: 44×44px via wrapper. Edit (mobile): 44×44px via wrapper. Overflow pill: naturally ≥44px wide at ≥375px. All verified.
Focus ringsNever outline:none without a replacement. All interactive elements: focus-visible:ring-2 focus-visible:ring-primary.
Colour aloneStatus uses colour + aria-label. Never colour as only signal. Annotate mode uses label change "Beenden" + visual fill + hint strip.

8 · Acceptance criteria

IDCriterion
AC-01Topbar renders correctly at 320px, 375px, 768px, 1024px, 1440px — matches spec screenshots. Verified by /proofshot at all 5 widths.
AC-02Light and dark themes match token table — no hardcoded hex values in any component file.
AC-030-receiver, 1-receiver, 2-receiver, 3-receiver, and 5-receiver cases all render correctly per Section 2.
AC-04No-sender case: chip row shows receivers only (or first person if no sender), no arrow rendered.
AC-05Overflow tooltip opens/closes on click, Enter/Space, Escape. Escape returns focus to pill. Click-outside closes.
AC-06Opening tooltip moves focus to first person link inside tooltip.
AC-07Overflow tooltip links navigate to correct /persons/{id} URL.
AC-08Overflow pill at <768px is a non-interactive span — no tooltip, no tap action.
AC-09Annotate mode: Edit + Download hidden; hint strip visible; button label "Beenden"; aria-pressed=true.
AC-10Annotate hint strip NOT rendered below 768px even when annotateMode===true.
AC-11Status dot visible at ≥768px with correct colour per status. Hidden below.
AC-12All touch targets ≥44×44px at mobile (back button, edit button, overflow pill where interactive).
AC-13aria-pressed, aria-haspopup="listbox", aria-expanded, aria-label on all interactive elements.
AC-14svelte-check passes with no new type errors.
AC-15Unit tests pass for: abbreviateName (full, single, hyphenated), formatXsMeta (0/1/3+ receivers, no sender), personAvatarColor (deterministic, palette-only), statusDotClass (all 5 values), statusLabel (all 5 values), formatDate (short, long, UTC boundary).
AC-16Visual proof: /proofshot against document detail page at all 5 viewport widths, both light and dark themes (10 screenshots).

DocumentTopBar Final Spec · Familienarchiv · 2026-03-31 · Leonie Voss
Supersedes document-topbar-b1-responsive.html · All resolutions from issue #161 review incorporated