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.
text-[11px], chip names text-[9px], topbar heights h-12/h-14, status chip dot-only, edit button icon-only on mobile.
Decompose into these components. Never merge into a single monolith — each has a clear single visual responsibility and must be independently testable.
| Component file | Props | Responsibility | Notes |
|---|---|---|---|
| DocumentTopBar.svelte | doc, 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.svelte | sender, receivers, abbreviated: boolean | Chip row with arrow. Visible at ≥375px. Hidden at XS via hidden xs:flex. | Renders plain-text fallback slot at XS via parent. |
| PersonChip.svelte | person, abbreviated: boolean | Single chip: avatar initials + name. Abbreviated = first initial + last name. | Avatar colour from personAvatarColor(person.id). |
| OverflowPill.svelte | extraCount, 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.svelte | status: DocumentStatus | Dot-only indicator. Hidden below 768px. title + aria-label carry the label text. | No text label — removes i18n requirement. |
| AnnotateHintStrip.svelte | annotateMode: boolean | 18px strip below main row. Only rendered when annotateMode === true AND viewport ≥768px. | Use {#if annotateMode} — no CSS height animation. Hidden via parent responsive class. |
| Value | Type | Implementation | Notes |
|---|---|---|---|
| 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 | $derived | See 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. |
| annotateMode | bindable prop | let { annotateMode = $bindable(false) } = $props() | Parent page owns state. TopBar toggles it. |
All CSS custom properties used by the topbar. No hardcoded colours in any component — all must reference these tokens.
| Token / concern | Tailwind class | CSS var | Notes |
|---|---|---|---|
| Accent bar | border-l-[3px] border-primary | var(--color-primary) | Light: #012851. Dark: resolves to #A1DCD8 via theme. Never hardcode. |
| Topbar bg | bg-surface | var(--color-surface) | Auto light/dark via CSS custom property. |
| Bottom border | border-b border-line | var(--color-line) | 1px, both themes. |
| Chip bg | bg-muted | var(--color-muted) | Light #F0EFE9, dark #0A1218. |
| Chip border | border-line | — | Same 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 palette | inline style only | — | 5 values: ['#012851','#5A3080','#007596','#2A6040','#803020']. Index = hash(id) % 5. |
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.
| Element | Tailwind classes | Real size | Notes |
|---|---|---|---|
| PersonChip container | inline-flex items-center gap-1 rounded-full border border-line bg-muted px-2 py-1 whitespace-nowrap shrink-0 | h ~28px, px 8px, py 4px | Not interactive — no hover/focus styles needed. |
| Avatar circle | flex w-[18px] h-[18px] shrink-0 items-center justify-center rounded-full text-[7px] font-bold | 18×18px | bg from personAvatarColor(id) — inline style. text-primary-fg for navy bg, white for others. |
| Chip name (full) | text-[9px] font-semibold text-ink | 9px / 600 | ⚠ Most commonly undersized — minimum 9px. Original spec said 6.5px — corrected. |
| Chip name (abbreviated) | text-[9px] font-semibold text-ink | 9px / 600 | "K. Raddatz" format at <768px. Same styling as full name. |
| Arrow between chips | text-ink-2 shrink-0 text-[11px] aria-hidden="true" | 11px | Unicode → (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-0 | 9px / 700 | Interactive 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-0 | 9px / 700 | <span aria-hidden="true"> — not a button. No tap behaviour. |
| PersonChipRow wrapper | hidden xs:flex items-center gap-1.5 min-w-0 overflow-hidden | — | Hidden at XS (<375px). flex at ≥375px. |
| XS plain-text meta | block xs:hidden text-[9px] text-ink-2 truncate mt-0.5 | 9px | Format: "K.Raddatz → E.Raddatz +4 · 24.12.1943". From formatXsMeta(doc). |
Visual reference for each breakpoint. See B1 spec for full set. Key states shown below.
| Element | XS <375px | Mobile 375–767px | Tablet/Desktop ≥768px | Notes |
|---|---|---|---|---|
| Topbar height | h-12 48px | h-14 56px | h-14 56px | ⚠ Increased from B1 spec (was 44/50/52px) to fit text-[11px]+ chip row. |
| Back button wrapper | a href="/documents" w-11 h-11 -ml-2 flex items-center justify-center shrink-0 focus-visible:ring-2 | w-11 h-11 = 44×44px touch area. -ml-2 = visual alignment. aria-label="Zurück zur Dokumentenliste" always. | ||
| Back button visual | w-6 h-6 rounded-sm bg-muted flex items-center justify-center | w-6 h-6 rounded-full border border-line flex items-center justify-center | w-7 h-7 rounded-full border border-line flex items-center justify-center | Shape changes at xs breakpoint. Inner chevron SVG 10×10px. |
| Title | font-serif font-extrabold text-[11px] text-ink truncate | font-serif font-extrabold text-[11px] text-ink truncate | font-serif font-extrabold text-[12px] lg:text-[13px] text-ink truncate | ⚠ Minimum 11px — original spec said 10px, corrected. |
| Status indicator | Hidden hidden | Hidden hidden | hidden md:block w-2.5 h-2.5 rounded-full shrink-0 | Dot only. title + aria-label carry label. See statusDotClass() for colours. |
| Date text | In xsMetaLine string | text-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 meta | Hidden | Hidden | Shown 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-sm | Shows pencil icon + "Bearbeiten" label at ≥768px. |
| Annotate button | Hidden hidden | Hidden hidden | hidden 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-sm | Active: bg-primary border-primary text-primary-fg. Label "Annotieren" → "Beenden". aria-pressed={annotateMode}. |
| Download button | Hidden | Hidden | hidden md:inline-flex w-10 h-10 items-center justify-center border border-line rounded-sm text-ink-2 | Icon only — download SVG 18×18px. |
| Divider | Hidden | Hidden | hidden md:block w-px h-4 bg-line shrink-0 | Between download icon and Bearbeiten button. |
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 height | h-12 (48px) | h-14 (56px) | h-14 (56px) | h-14 (56px) |
| Back button | Square 24×24 rounded-sm bg-muted | Circle 24×24 rounded-full border-line | Circle 28×28 rounded-full border-line | Circle 28×28 |
| Touch target | w-11 h-11 -ml-2 wrapper around all back button variants. Always 44×44px. | |||
| Title size | text-[11px] / 800 | text-[11px] / 800 | text-[12px] / 800 | text-[13px] / 800 |
| Chip row | Hidden → plain-text xsMetaLine | Shown — abbreviated names | Shown — full names | Shown — full names + location |
| Chip name text | N/A | text-[9px] / 600 | text-[9px] / 600 | text-[9px] / 600 |
| Date format | dd.mm.yyyy in xsMetaLine | 24.12.1943 | 24.12.1943 | 24. Dezember 1943 |
| Status indicator | Hidden | Hidden | Dot only — w-2.5 h-2.5 rounded-full | Dot only |
| Annotate button | Hidden | Hidden | Shown — "Annotieren" | Shown — "Annotieren" |
| Edit button | Icon only (pencil SVG) | Icon only | Icon + "Bearbeiten" | Icon + "Bearbeiten" |
| Download button | Hidden | Hidden | Icon only | Icon only |
| Overflow pill | N/A (chips hidden) | <span aria-hidden> "+N" | <button> "+N weitere" → tooltip | <button> "+N weitere" → tooltip |
| Hint strip | Hidden | Hidden | 18px strip when annotateMode | 18px strip when annotateMode |
| Item | Value | Notes |
|---|---|---|
| Custom xs breakpoint | xs: '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 row | hidden xs:flex | Hidden at <375px, flex at ≥375px. |
| Usage for XS meta | block xs:hidden | Only visible below 375px. |
| Usage for overflow pill type | CSS only: hidden xs:inline-flex for button, always-rendered span for mobile with md:hidden | Prefer CSS over JS viewport check — no SSR issues. |
Clicking "+N weitere" opens a floating panel. Only at ≥768px. Mobile overflow pill is a non-interactive span.
| Element | Tailwind classes | Real size | Notes |
|---|---|---|---|
| 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-pointer | h ~28px, 9px text | Active 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 panel | absolute top-full left-0 mt-1 bg-surface border border-line rounded-md shadow-lg p-3 min-w-[160px] z-50 | min 160px wide, p 12px | position:relative on chip row container. role="listbox". |
| Tooltip header | text-[9px] font-bold uppercase tracking-wide text-ink-2 border-b border-line pb-2 mb-2 | 9px / 700 | Text: "Weitere Empfänger". |
| Person row | flex items-center gap-2 py-1 rounded hover:bg-muted | min 32px tall | role="option". Each row is an <a href="/persons/{id}">. Avatar 16×16px. |
| Person name in tooltip | text-[11px] font-medium text-ink | 11px / 500 | Full name always shown in tooltip. |
| Keyboard: open | — | — | Enter/Space on pill → open tooltip → focus first role="option". |
| Keyboard: navigate | — | — | Tab/Shift+Tab between options inside tooltip. |
| Keyboard: close | — | — | Escape → close + return focus to pill. Click-outside → close. Second click on pill → close. |
| Click-outside | — | — | Use use:clickOutside Svelte action — do not use document.addEventListener directly in $effect. |
| Element | Tailwind classes | Real size | Notes |
|---|---|---|---|
| Strip wrapper | hidden md:flex h-[18px] items-center gap-2 border-t border-dashed px-3.5 | h 18px | Only 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 text | text-[9px] font-bold uppercase tracking-wide text-primary | 9px / 700 | ⚠ Corrected from spec's 5.5px — minimum 9px. Dark: text-primary-fg. |
| Body text | text-[9px] text-ink-2 | 9px | "Klicken Sie auf eine Textstelle im Dokument, um eine Anmerkung hinzuzufügen." |
src/lib/utils/personFormat.tsPure functions. Write Vitest unit tests for each before implementing. No DOM, no side effects.
| Function | Signature | Behaviour & 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" |
| Element | Requirement |
|---|---|
| Landmark | Parent page must wrap topbar in <header role="banner"> or the topbar must itself be in <header>. Verify parent layout. |
| Back link | aria-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 button | aria-pressed={annotateMode}. Label changes: "Annotieren" → "Beenden". |
| Overflow pill | aria-haspopup="listbox" (not "true"), aria-expanded={overflowOpen}, aria-label="{extraCount} weitere Empfänger anzeigen". |
| Overflow tooltip | role="listbox" on panel. role="option" on each person row. Not a modal — no focus trap needed. |
| Tooltip focus flow | Opening tooltip moves focus to first role="option". Tab/Shift+Tab navigates within. Escape closes + returns focus to pill. |
| Arrow between chips | aria-hidden="true" on the → character. Directionality conveyed by order, not just the arrow. |
| Status dot | title={statusLabel(doc.status)} for hover tooltip. aria-label={statusLabel(doc.status)}. No text label rendered. |
| Touch targets | Back button: 44×44px via wrapper. Edit (mobile): 44×44px via wrapper. Overflow pill: naturally ≥44px wide at ≥375px. All verified. |
| Focus rings | Never outline:none without a replacement. All interactive elements: focus-visible:ring-2 focus-visible:ring-primary. |
| Colour alone | Status uses colour + aria-label. Never colour as only signal. Annotate mode uses label change "Beenden" + visual fill + hint strip. |
| ID | Criterion |
|---|---|
| AC-01 | Topbar renders correctly at 320px, 375px, 768px, 1024px, 1440px — matches spec screenshots. Verified by /proofshot at all 5 widths. |
| AC-02 | Light and dark themes match token table — no hardcoded hex values in any component file. |
| AC-03 | 0-receiver, 1-receiver, 2-receiver, 3-receiver, and 5-receiver cases all render correctly per Section 2. |
| AC-04 | No-sender case: chip row shows receivers only (or first person if no sender), no arrow rendered. |
| AC-05 | Overflow tooltip opens/closes on click, Enter/Space, Escape. Escape returns focus to pill. Click-outside closes. |
| AC-06 | Opening tooltip moves focus to first person link inside tooltip. |
| AC-07 | Overflow tooltip links navigate to correct /persons/{id} URL. |
| AC-08 | Overflow pill at <768px is a non-interactive span — no tooltip, no tap action. |
| AC-09 | Annotate mode: Edit + Download hidden; hint strip visible; button label "Beenden"; aria-pressed=true. |
| AC-10 | Annotate hint strip NOT rendered below 768px even when annotateMode===true. |
| AC-11 | Status dot visible at ≥768px with correct colour per status. Hidden below. |
| AC-12 | All touch targets ≥44×44px at mobile (back button, edit button, overflow pill where interactive). |
| AC-13 | aria-pressed, aria-haspopup="listbox", aria-expanded, aria-label on all interactive elements. |
| AC-14 | svelte-check passes with no new type errors. |
| AC-15 | Unit 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-16 | Visual proof: /proofshot against document detail page at all 5 viewport widths, both light and dark themes (10 screenshots). |
document-topbar-b1-responsive.html · All resolutions from issue #161 review incorporated