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.

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
1280 · Desktop · Light
MR
← Dokumente
Neues Dokument
Keine Datei ausgewählt
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
Gilt für alle Gemeinsame Angaben
Absender
Empfänger
Datum
Ort
Tags
Archivbox
Mappe
Speichern →
Empty · N = 0 desktop · 1280 Dark
1280 · Desktop · Dark
MR
← Dokumente
Neues Dokument
Keine Datei ausgewählt
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
Gilt für alle Gemeinsame Angaben
Absender
Empfänger
Datum
Ort
Tags
Speichern →
Empty · N = 0 mobile · 375 Light One-panel view — tabs appear only when a file is loaded
375 · Mob
MR
← Zurück
Neues Dokument
Keine Datei ausgewählt
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
Speichern →
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
1280 · Desktop · Light
MR
← Dokumente
Neues Dokument
+
Seite 1 / 2
Titel *
Brief an Anna, 1940
Angaben
Absender
Hans Müller
Empfänger
Anna Schmidt
Datum
15.06.1940
Ort
Berlin
Tags
Familie × Krieg ×
Verwerfen
Als Platzhalter
Speichern →
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
1280 · Desktop · Light
MR
← Dokumente
Neue Dokumente
5 werden erstellt
Alle verwerfen
+
Seite 1 / 2 · Datei 1 / 5
1Brief_1940_Hans.pdf
2Brief_1940_Anna.pdf
3Brief_1941_Clara.pdf
4Postkarte_Venedig.jpg
5Urkunde_1942.pdf
Nur diese Datei 1 / 5 · Brief_1940_Hans.pdf
Titel *
Brief an Anna, 1940
Gilt für alle 5 Gemeinsame Angaben
Absender
Hans Müller
Empfänger
Anna Schmidt
Datum
15.06.1940
Ort
z.B. Berlin
Tags
Familie × Krieg × Briefwechsel ×
Archivbox
z.B. B-12
Mappe
z.B. M-3
Verwerfen
Als Platzhalter
5 speichern →
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
1280 · Desktop · Dark
MR
← Dokumente
Neue Dokumente
5 werden erstellt
Alle verwerfen
+
Seite 1 / 2 · Datei 1 / 5
1Brief_1940_Hans.pdf
2Brief_1940_Anna.pdf
3Brief_1941_Clara.pdf ⚠
4Postkarte_Venedig.jpg
5Urkunde_1942.pdf
Nur diese Datei 1 / 5 · Brief_1940_Hans.pdf
Titel *
Brief an Anna, 1940
Gilt für alle 5 Gemeinsame Angaben
Absender
Hans Müller
Empfänger
Anna Schmidt
Datum
15.06.1940
Ort
z.B. Berlin
Tags
Familie × Krieg ×
Verwerfen
Als Platzhalter
5 speichern →
Multi · N = 5 tablet · 768 Light Split is kept — PDF pane narrows, form stays readable
768 · Tab
MR
← Dok
Neue Dokumente
5
Verwerfen
1/5
1Brief_Hans
2Brief_Anna
3Brief_Clara
Diese Datei 1/5
Titel *
Brief an Anna, 1940
Alle 5
Absender
Hans Müller
Empfänger
Anna Schmidt
Datum
1940
Ort
5 speichern
Multi · N = 5 tablet · 768 Dark
768 · Tab · Dark
MR
← Dok
Neue Dokumente
5
Verwerfen
1/5
1Brief_Hans
2Brief_Anna
3Brief_Clara
Diese Datei 1/5
Titel *
Brief an Anna, 1940
Alle 5
Absender
Hans Müller
Datum
1940
Ort
5 speichern
Multi · N = 5 mobile · 375 Light Split collapses into "Vorschau / Angaben" tabs — reuses DocumentEditLayout's pattern
375 · Tab: Vorschau
MR
Neue Dokumente
5
Vorschau 1/5
Angaben
Seite 1 / 2
1Brief_Hans
2Brief_Anna
3Brief_Clara
5 speichern
375 · Tab: Angaben
MR
Neue Dokumente
5
Vorschau 1/5
Angaben
Datei 1/5 Brief_1940_Hans.pdf
Titel *
Brief an Anna, 1940
Alle 5
Absender
Hans Müller
Empfänger
Anna Schmidt
Datum
15.06.1940
Tags
Familie × Krieg ×
5 speichern
Multi · N = 5 mobile · 375 Dark
375 · Dark · Vorschau
MR
Neue Dokumente
5
Vorschau 1/5
Angaben
Seite 1 / 2
1Brief_Hans
2Brief_Anna
3Brief_Clara
5 speichern
375 · Dark · Angaben
MR
Neue Dokumente
5
Vorschau 1/5
Angaben
Datei 1/5 Brief_1940_Hans.pdf
Titel *
Brief an Anna, 1940
Alle 5
Absender
Hans Müller
Datum
1940
Tags
Familie × Krieg ×
5 speichern
Multi · N = 5 mobile · 320 Light Narrowest supported viewport — same structure, tighter paddings
320
MR
Neue Dokumente
5
Vorschau 1/5
Angaben
1/5 Brief_Hans.pdf
Titel *
Brief an Anna, 1940
Alle 5
Absender
Hans Müller
Empfänger
Anna Schmidt
5 speichern

Implementation reference — tokens, classes, behaviour

Empty-state drop zone (PDF panel, N = 0)

ElementTailwindPx / valueNote
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)

ElementTailwindPx / valueNote
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)

ElementTailwindPx / valueNote
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-fgaria-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)

ElementTailwindPx / valueNote
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)

ElementTailwindPx / valueNote
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)

ElementTailwindPx / valueNote
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)

ElementTailwindPx / valueNote
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)

Keydeenes
upload_dropzone_headingEine oder mehrere Dateien ablegenDrop one or more files hereSuelta uno o más archivos aquí
upload_dropzone_bodyFü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_ctaDateien auswählenChoose filesElegir archivos
upload_dropzone_formatsPDF · JPEG · PNG · TIFF · max 50 MB pro DateiPDF · JPEG · PNG · TIFF · max 50 MB per filePDF · 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_allAlle verwerfenDiscard allDescartar todo
bulk_discard_confirm{count} Dateien verwerfen?Discard {count} files?¿Descartar {count} archivos?
bulk_only_this_fileNur diese DateiThis file onlySolo este archivo
bulk_only_subtitle{index} / {count} · {filename}{index} / {count} · {filename}{index} / {count} · {filename}
bulk_shared_countGilt 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_progressLade Datei {i} von {count}…Uploading file {i} of {count}…Subiendo archivo {i} de {count}…
bulk_save_placeholderAls PlatzhalterSave as placeholderGuardar como marcador
bulk_file_nav_prevVorherige DateiPrevious fileArchivo anterior
bulk_file_nav_nextNächste DateiNext fileSiguiente 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)

ComponentStatusResponsibility
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

ElementTailwind / ValueNote
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.