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
1280 · Desktop · Light
← 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
← 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
← 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
← 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
← 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
← 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
← 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
← 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
←
Neue Dokumente
5
Vorschau 1/5
Angaben
◀
▶
Seite 1 / 2
1Brief_Hans
2Brief_Anna
3Brief_Clara
5 speichern
375 · Tab: Angaben
←
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
←
Neue Dokumente
5
Vorschau 1/5
Angaben
◀
▶
Seite 1 / 2
1Brief_Hans
2Brief_Anna
3Brief_Clara
5 speichern
375 · Dark · Angaben
←
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
←
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)

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.