UI/UX Spec · Implementation-ready
Bulk upload — split-panel with file switcher
Extends the #294 split-panel layout so the same screen covers 0 files, 1 file, or N files.
When the user drops multiple PDFs, each gets its own title (pre-filled from the filename, editable) while
every other metadata field — sender, receiver, date, location, tags, archive box — is shared across all
documents. A single POST to /api/documents/quick-upload creates N documents in one pass.
Leonie Voss · 2026-04-24 · Final spec · Supersedes the 3-concept exploration
feature
ui
a11y 320px+
light + dark
backend ready
The one-screen model
/documents/new is a single route with three visual states. The same DOM skeleton
renders for all three — only three pieces of chrome appear/disappear based on file count:
the PDF preview area, the file-switcher strip, and the per-file title scope card.
N = 0
Empty
Left panel shows a drop zone with copy: "Eine oder mehrere Dateien ablegen…".
Right panel shows the shared metadata form, pre-disabled until a file lands.
No per-file title card. No file switcher.
→
N = 1
Single file
Left = PDF preview. Right = title card + shared card.
Byte-identical to the shipped #294 layout. No file switcher,
no "1/1" subtitles — this state should feel unchanged to existing users.
→
N ≥ 2
Multiple files
Left = PDF preview + file-switcher strip along the bottom of the PDF panel.
Right = per-file title card ("Nur diese Datei · 1/5") + shared card ("Gilt für alle 5").
Save button reads "5 speichern →". One multipart POST on save.
State 1 of 3
Empty state — drop zone with bulk-first copy
When the user hits /documents/new the PDF panel is the drop target. It's not a tiny button —
the whole left panel is the affordance. The copy makes it explicit that the user can drop one
file or many; the supporting line lists accepted formats and the per-file size limit. Right panel shows
the shared form in a muted "waiting" state so the user sees what they'll need to fill in once files
land.
Empty · N = 0
desktop · 1280
Light
Familienarchiv
Dokumente
Personen
Briefwechsel
Chronik
← Dokumente
Neues Dokument
⇪
Eine oder mehrere Dateien ablegen
Für jede Datei wird ein eigenes Dokument erstellt.
Der Titel wird aus dem Dateinamen vorausgefüllt und ist pro Datei
editierbar — alle anderen Felder gelten für alle Dokumente gemeinsam.
Dateien auswählen
PDF · JPEG · PNG · TIFF · max 50 MB pro Datei
Empty · N = 0
desktop · 1280
Dark
Familienarchiv
Dokumente
Personen
Briefwechsel
Chronik
← Dokumente
Neues Dokument
⇪
Eine oder mehrere Dateien ablegen
Für jede Datei wird ein eigenes Dokument erstellt.
Der Titel wird aus dem Dateinamen vorausgefüllt und ist pro Datei
editierbar — alle anderen Felder gelten für alle Dokumente gemeinsam.
Dateien auswählen
PDF · JPEG · PNG · TIFF · max 50 MB pro Datei
Empty · N = 0
mobile · 375
Light
One-panel view — tabs appear only when a file is loaded
⇪
Eine oder mehrere Dateien ablegen
Jede Datei wird ein eigenes Dokument. Der Titel kommt aus dem Dateinamen —
alle anderen Felder gelten für alle.
Dateien auswählen
PDF · JPEG · PNG · TIFF · max 50 MB
State 2 of 3
Single-file state — zero change from #294
When exactly one file is loaded, the screen is byte-identical to the #294 DocumentEditLayout
single-document flow. No file-switcher strip, no "1/1" subtitle, no "Alle verwerfen" link. This is
the invariant that makes the bulk extension a safe ship — existing users see no new chrome for the
single-file case they've always used.
Single · N = 1
desktop · 1280
Light
Reference only — no new components render here
Familienarchiv
Dokumente
Personen
Briefwechsel
← Dokumente
Neues Dokument
State 3 of 3
Multi-file state — file switcher + two-card form
When N ≥ 2, three things appear: (1) a count pill in the top bar "5 werden erstellt" plus an
"Alle verwerfen" link; (2) a file-switcher strip below the PDF toolbar, with the active file in a mint pill,
inactive files in subtle pills, and any upload-errored file in red-dashed; (3) a two-card form: the mint-tinted
Nur diese Datei card on top (title only), the neutral Gilt für alle 5 card below
(everything else).
Multi · N = 5
desktop · 1280
Light
Familienarchiv
Dokumente
Personen
Briefwechsel
Chronik
← Dokumente
Neue Dokumente
5 werden erstellt
Alle verwerfen
‹
1Brief_1940_Hans.pdf
2Brief_1940_Anna.pdf
3Brief_1941_Clara.pdf
4Postkarte_Venedig.jpg
5Urkunde_1942.pdf
›
1Count pill
Mint background, navy text (7.2:1 AAA). Only visible at N ≥ 2. Live-region announces changes.
2"Alle verwerfen"
Danger-coloured link at the far right of the top bar. Triggers a confirm dialog; never a silent wipe.
3File switcher
Active file uses mint bg + aria-current + a ▸ caret. Three redundant cues for colour-blind users.
4"Nur diese Datei"
Mint-tinted card: the ONE thing that differs per file lives here. Currently just the title.
5"Gilt für alle N"
Neutral card with everything that applies to every document. The count is interpolated from N.
6Save CTA
"5 speichern" — count is plural-aware via Paraglide. Becomes a progress bar on slow saves.
Multi · N = 5
desktop · 1280
Dark
Familienarchiv
Dokumente
Personen
Briefwechsel
← Dokumente
Neue Dokumente
5 werden erstellt
Alle verwerfen
‹
1Brief_1940_Hans.pdf
2Brief_1940_Anna.pdf
3Brief_1941_Clara.pdf ⚠
4Postkarte_Venedig.jpg
5Urkunde_1942.pdf
›
Multi · N = 5
tablet · 768
Light
Split is kept — PDF pane narrows, form stays readable
Familienarchiv
Dokumente
Personen
← Dok
Neue Dokumente
5
Verwerfen
‹
1Brief_Hans
2Brief_Anna
3Brief_Clara
›
Multi · N = 5
tablet · 768
Dark
Familienarchiv
Dokumente
Personen
← Dok
Neue Dokumente
5
Verwerfen
‹
1Brief_Hans
2Brief_Anna
3Brief_Clara
›
Multi · N = 5
mobile · 375
Light
Split collapses into "Vorschau / Angaben" tabs — reuses DocumentEditLayout's pattern
1Brief_Hans
2Brief_Anna
3Brief_Clara
Multi · N = 5
mobile · 375
Dark
1Brief_Hans
2Brief_Anna
3Brief_Clara
Multi · N = 5
mobile · 320
Light
Narrowest supported viewport — same structure, tighter paddings
Implementation reference — tokens, classes, behaviour
Empty-state drop zone (PDF panel, N = 0)
| Element | Tailwind | Px / value | Note |
| Outer panel (drop target) |
flex-1 bg-pdf-bg flex flex-col |
full height |
the entire left panel accepts drops, not just the inner box |
| Inner dashed box |
border-2 border-dashed border-accent rounded-md p-9 max-w-[340px] bg-surface/50 dark:bg-white/5 |
padding 36px |
visual anchor for mouse drops; not the hit target |
| Upload icon circle |
w-14 h-14 rounded-full bg-accent text-primary flex items-center justify-center text-2xl font-extrabold |
56×56 |
mint on white · primary on dark (token swap is automatic) |
| Headline |
font-serif text-base font-bold text-ink |
16px · 700 |
Paraglide upload_dropzone_heading |
| Supporting copy |
text-sm text-ink-2 leading-relaxed max-w-prose |
14px |
Paraglide upload_dropzone_body — explains title vs. shared semantics |
| "Dateien auswählen" CTA |
h-11 px-4 bg-primary text-primary-fg font-extrabold text-sm rounded-sm uppercase tracking-wide focus-visible:ring-2 focus-visible:ring-focus-ring |
44px min · 14px |
triggers native <input type=file multiple> |
| Formats line |
text-xs text-ink-3 tracking-wide |
12px |
Paraglide upload_dropzone_formats |
Top bar — count pill + discard link (N ≥ 2)
| Element | Tailwind | Px / value | Note |
| Count pill |
bg-accent text-primary dark:bg-primary dark:text-primary-fg rounded-full px-3 py-1 text-sm font-bold |
14px · 700 |
text "{n} werden erstellt" · Paraglide plural |
| Discard link |
ml-auto text-sm font-bold text-danger hover:underline focus-visible:outline-2 focus-visible:outline-danger focus-visible:outline-offset-2 min-h-[44px] flex items-center |
14px · 44px tap |
fires confirm dialog; never silent |
FileSwitcherStrip (new — only renders at N ≥ 2)
| Element | Tailwind | Px / value | Note |
| Strip container |
flex items-center gap-1 bg-pdf-ctrl border-t border-line px-2 py-2 |
height 48px |
sits under the PDF toolbar, on the dark PDF panel |
| Arrow buttons (desktop only) |
hidden md:flex h-10 w-10 rounded-sm bg-ink/10 dark:bg-white/10 hover:bg-ink/20 dark:hover:bg-white/20 text-ink-3 items-center justify-center focus-visible:outline-2 focus-visible:outline-focus-ring |
40×40 (with px-2 pad → 44) |
aria-label="Vorherige Datei" / "Nächste Datei" |
| Track (scroll container) |
flex-1 flex gap-1 overflow-x-auto snap-x snap-mandatory scroll-smooth |
— |
mobile: gesture-swipe; desktop: arrows scroll scrollBy({left:±120}) |
| File chip · inactive |
snap-start shrink-0 px-3 py-2 min-h-[40px] rounded-sm bg-ink/5 dark:bg-white/8 text-sm font-bold text-ink-2 hover:bg-ink/10 focus-visible:outline-2 focus-visible:outline-focus-ring flex items-center gap-2 |
14px / h 40px |
max-width 180px, title truncates with truncate |
| File chip · active |
same base + bg-accent text-primary dark:bg-primary dark:text-primary-fg + aria-current="true" |
14px / h 40px |
caret "▸" prefix via ::before — redundant non-colour cue |
| File chip · error |
bg-danger/15 text-danger border border-dashed border-danger |
— |
tooltip = error message, ⚠ suffix, still clickable |
| Chip number prefix |
bg-primary/20 dark:bg-primary-fg/20 rounded-sm px-1 text-xs font-extrabold |
12px · 800 |
"1", "2", … |
"Nur diese Datei" card (per-file scope, title only)
| Element | Tailwind | Px / value | Note |
| Card container |
bg-accent-bg border border-accent rounded-sm p-4 mb-4 |
padding 16px |
renders for N ≥ 2 only; at N = 1 title lives outside any card |
| Scope badge |
bg-primary text-primary-fg rounded-sm px-2 py-1 text-xs font-extrabold uppercase tracking-wide |
12px · 800 |
Paraglide bulk_only_this_file |
| Subtitle (1 / 5 · filename) |
text-xs font-bold text-ink-2 tracking-tight truncate |
12px |
filename truncates when long |
| Title input |
h-11 w-full text-base font-semibold text-ink bg-surface border border-accent rounded-sm px-3 focus-visible:border-ink focus-visible:ring-2 focus-visible:ring-focus-ring |
44px · 16px |
starts as suggested state; mint border drops to border-line on first user edit |
"Gilt für alle" card (shared scope)
| Element | Tailwind | Px / value | Note |
| Card container |
bg-muted border border-line rounded-sm p-4 mb-3 |
padding 16px |
neutral (no accent tint) — signals "shared, not special" |
| Scope badge |
bg-accent text-primary dark:bg-primary dark:text-primary-fg rounded-sm px-2 py-1 text-xs font-extrabold uppercase tracking-wide |
12px · 800 |
Paraglide bulk_shared_count "Gilt für alle {count}" |
| Field grid |
grid grid-cols-1 md:grid-cols-2 gap-3 |
12px gap |
single column at ≤ 767px, two cols at ≥ 768px |
| Disabled (empty) state |
wrap card in aria-disabled="true" opacity-60 pointer-events-none |
— |
only when N = 0; turns live on first file drop |
Save bar (primary CTA, tri-mode)
| Element | Tailwind | Px / value | Note |
| Primary save · idle |
h-11 px-5 bg-green-700 hover:bg-green-800 dark:bg-green-800 dark:hover:bg-green-700 text-white font-extrabold rounded-sm text-sm focus-visible:ring-2 focus-visible:ring-green-900 |
44px · 14px |
label {count} speichern → — Paraglide plural "Speichern" at 1, "N speichern" at ≥ 2 |
| Primary save · saving |
same + bg-green-700/90 cursor-progress relative overflow-hidden with inner progress bar absolute inset-y-0 left-0 bg-white/20 transition-[width] |
— |
replaces label with "Lade Datei {i} von {n}…" when > 500ms |
| "Als Platzhalter" |
h-11 px-4 border border-line bg-surface text-ink-2 font-bold rounded-sm text-sm |
44px |
posts with metadataComplete=false for all N documents |
| "Verwerfen" skip link |
text-sm font-bold text-ink-3 hover:text-ink min-h-[44px] flex items-center |
14px · 44px |
at N ≥ 2 this is a link to "Alle verwerfen" in the top bar; hide in save bar to avoid duplicate |
Mobile responsive (≤ 767px)
| Element | Tailwind | Px / value | Note |
| Tab bar |
flex h-[38px] border-b border-line — reuses DocumentEditLayout's mobile tabs |
38px |
labels: "Vorschau" (with "1/N" pill) · "Angaben" |
| Tab (active) |
flex-1 h-full flex items-center justify-center text-sm font-extrabold text-ink border-b-2 border-accent dark:border-primary uppercase tracking-wide |
14px · 48px tap |
aria-selected="true" |
| "1/N" tab pill |
ml-2 bg-accent text-primary dark:bg-primary dark:text-primary-fg rounded-full px-2 py-0.5 text-xs font-extrabold |
12px |
hide at N = 1 |
| File switcher on mobile |
arrows removed (md:flex), track full-width, snap-swipe |
— |
announcer fires on snap-end: "Datei 3 von 5" |
Paraglide keys (de/en/es)
| Key | de | en | es |
| upload_dropzone_heading | Eine oder mehrere Dateien ablegen | Drop one or more files here | Suelta uno o más archivos aquí |
| upload_dropzone_body | Für jede Datei wird ein eigenes Dokument erstellt. Der Titel wird aus dem Dateinamen vorausgefüllt und ist pro Datei editierbar — alle anderen Felder gelten gemeinsam. | Each file becomes its own document. The title is pre-filled from the filename and editable per file — all other fields are shared. | Cada archivo se convierte en su propio documento. El título se rellena con el nombre del archivo y se puede editar por archivo; los demás campos son compartidos. |
| upload_dropzone_cta | Dateien auswählen | Choose files | Elegir archivos |
| upload_dropzone_formats | PDF · JPEG · PNG · TIFF · max 50 MB pro Datei | PDF · JPEG · PNG · TIFF · max 50 MB per file | PDF · JPEG · PNG · TIFF · máx. 50 MB por archivo |
| bulk_count_creating | {count} werden erstellt | {count} will be created | {count} se crearán |
| bulk_discard_all | Alle verwerfen | Discard all | Descartar todo |
| bulk_discard_confirm | {count} Dateien verwerfen? | Discard {count} files? | ¿Descartar {count} archivos? |
| bulk_only_this_file | Nur diese Datei | This file only | Solo este archivo |
| bulk_only_subtitle | {index} / {count} · {filename} | {index} / {count} · {filename} | {index} / {count} · {filename} |
| bulk_shared_count | Gilt für alle {count} | Applies to all {count} | Se aplica a todos los {count} |
| bulk_save_cta | {count, plural, one {Speichern →} other {{count} speichern →}} | {count, plural, one {Save →} other {Save {count} →}} | {count, plural, one {Guardar →} other {Guardar {count} →}} |
| bulk_save_progress | Lade Datei {i} von {count}… | Uploading file {i} of {count}… | Subiendo archivo {i} de {count}… |
| bulk_save_placeholder | Als Platzhalter | Save as placeholder | Guardar como marcador |
| bulk_file_nav_prev | Vorherige Datei | Previous file | Archivo anterior |
| bulk_file_nav_next | Nächste Datei | Next file | Siguiente archivo |
| bulk_announce_count | {count} Dateien bereit zum Speichern | {count} files ready to save | {count} archivos listos para guardar |
Interaction + behaviour spec
- Drop a file after the first batch → append to the end of the switcher, auto-focus the new chip, the title input inherits the filename-derived suggestion.
- Remove a file via X button on the active chip → confirm only for the currently-previewed file, else silent. When count drops to 1 the switcher animates away (200ms); to 0 the page returns to empty state.
- Filename → title:
basename.replace(/\.(pdf|jpe?g|png|tiff?)$/i, '').replace(/[_-]+/g, ' ').trim(). Input marked suggested (mint border + accent-bg) until the user edits it, then border drops to border-line.
- Title always rendered: at N = 1 the title input sits above the shared card with no wrapping; at N ≥ 2 it's inside the "Nur diese Datei" card. Zero layout jump between 1 and 2.
- Keyboard nav in the switcher: ←/→ cycle files when focus is inside the strip. Tab moves focus out. Enter/Space selects a chip (identical to click).
- Focus on file switch: the title input of the new file auto-focuses so it's the first editable field under the user's cursor — matches the priority of the per-file scope.
- Save flow: one
POST /api/documents/quick-upload with files[] + a JSON metadata part containing shared fields plus a titles[] array matched by index. Response splits into created[] / updated[] / errors[]; show a post-save toast + mark error chips red. Successful creates redirect to /documents (not to a single detail page — we just made N).
- Progress indicator: on save, replace the save button body with a determinate progress bar + text "Lade Datei {i} von {n}…". Switch to indeterminate shimmer only if the server doesn't stream progress.
- Live-region announces:
<div role="status" aria-live="polite"> in the top bar. Fires on (a) file count change (add/remove), (b) active file switch ("Datei 3 von 5"), (c) save progress, (d) save complete.
- Page leave protection: if N ≥ 1 and shared form has any non-default value,
beforeunload prompts. Suppress during explicit "Alle verwerfen" / save.
Edge cases + a11y requirements
- Single file, matching #294 exactly: at N = 1 do NOT render
FileSwitcherStrip, do NOT render the count pill or "Alle verwerfen" link, do NOT render the "Nur diese Datei" card (title sits directly above shared). Any deviation breaks the invariant — covered by a component-level snapshot test.
- Duplicate filenames in one batch: accept both, show a warning icon next to both chips. Backend creates two documents with distinct UUIDs.
- Mixed content types: PDF + image + TIFF in one batch — preview panel switches renderer per active file (DocumentEditLayout already handles this).
- One file fails upload: chip goes red-dashed. Other files still create. Post-save toast lists the failure with the backend error code mapped via
getErrorMessage(). "Retry" button on the chip re-POSTs that file alone.
- Large batches (> 20 files): switcher scrolls horizontally. At > 30 consider a "Jump to file…" combobox (follow-up, not v1).
- Screen reader on file switch:
role="tablist" is wrong here because the chips aren't tabs for a tabbed content; use a visually-hidden "Datei auswählen" label with aria-live="polite" announcement on selection change.
- Colour-alone check (WCAG 1.4.1): active chip = colour and caret prefix and
aria-current="true". Error chip = colour and dashed border and ⚠ suffix. Three redundant cues on both states.
- Contrast — light: mint (#a1dcd8) on navy (#012851) = 7.2:1 AAA · navy on mint = 7.2:1 (inverse). Title field
suggested uses ink (#012851) on accent-bg (15% mint) = ~9:1 AAA.
- Contrast — dark: mint-fg (#a1dcd8) on navy surface (#011526) = 9.2:1 AAA · turquoise accent (#00c7b1) on surface = 5.4:1 AA large. Active chip uses primary (mint) bg with navy fg — 9.2:1 AAA.
- Reduced motion: the 200ms switcher-fade and progress bar fill respect
prefers-reduced-motion: reduce via the existing global @media rule — cuts transition-duration to 0.01ms.
- Touch targets: every interactive element — arrow buttons, file chips, remove X, save button, tab headers — ≥ 44×44. Chip X is outside the chip's clickable area (uses
::after + a sibling button) to avoid the "I tried to select but removed" trap.
Component tree (frontend)
| Component | Status | Responsibility |
documents/new/+page.svelte |
rewrite |
State owner: files array, active index, shared metadata. Mode switches by files.length. |
DocumentEditLayout.svelte |
accept props |
Takes { files, activeIndex }; emits switch + remove events. Existing props for single-file unchanged. |
BulkDropZone.svelte |
new |
Full-panel drop target for N=0 and for "add more" at N≥1. Wraps the dashed box + CTA + formats line. |
FileSwitcherStrip.svelte |
new |
Horizontal chip list + arrows + aria-live region. Emits select + remove. |
ScopeCard.svelte |
new |
Wraps "Nur diese Datei" / "Gilt für alle N" with the correct badge + tint. One component, two variants. |
UploadSaveBar.svelte |
extend |
Already exists for single-file. Add plural-count label + determinate progress state. |
Backend contract — single POST for the whole batch
| Element | Tailwind / Value | Note |
| Endpoint |
POST /api/documents/quick-upload |
already exists — accepts List<MultipartFile> files |
| Request — files part |
repeated files multipart entries, one per file, in UI order |
backend preserves order so titles[i] matches files[i] |
| Request — metadata part |
JSON part named metadata containing { senderId, receiverId, documentDate, location, tags[], archiveBox, archiveFolder, metadataComplete, titles[] } |
backend change: add a new overload of quickUpload that reads the JSON part and applies shared fields to every created Document |
| Response |
{ created: DocRef[], updated: DocRef[], errors: { filename, code }[] } |
existing shape — no breaking change |
Out of scope for this spec
- Per-file metadata overrides beyond title: a future "expand row" could let the user override sender / date per file. Not in v1.
- Drag-to-reorder: the order of created documents matches the drop order. Reordering is a nice-to-have.
- Resume interrupted uploads: if the browser crashes mid-save, the user restarts. Chunked/resumable is a follow-up.
- Folder upload (
webkitdirectory): expands the batch beyond what the user meant. Off by default; revisit if users ask.
- Pre-populate sender/date from OCR on a sample file: interesting but async — ships as a second feature.