feat(enrich): field reordering, required-fields progress bar, and no-PDF upload state #261

Closed
opened 2026-04-17 17:19:17 +02:00 by marcel · 9 comments
Owner

Summary

Three targeted UX improvements to the Enrich / Edit page that reduce friction during bulk enrichment without restructuring the card layout:

  1. Field priority within cards — required fields moved to the top of each card
  2. Required-fields progress bar — thin strip above the form tracking completion of the 3 mandatory fields
  3. No-PDF upload state — proper upload zone in the left panel when a document has no file yet (PLACEHOLDER status)

Design spec: docs/specs/enrich-edit-unified.html


1 — Field Priority Within Cards

WhoWhenSection

Reorder the four <div> children inside the existing grid-cols-2 container so that required fields occupy row 1 and optional fields occupy row 2.

Position Before After
Row 1, Col 1 Datum (required) Datum (required)
Row 1, Col 2 Ort (optional) Absender (required — moved up)
Row 2, Col 1 Absender (required) Empfänger (optional)
Row 2, Col 2 Empfänger (optional) Ort (optional)

No class changes needed — grid order follows source order.

DescriptionSection

Reorder fields so Title stays first, Tags move up (more useful than archive location during enrichment), and Aufbewahrungsort is pushed below an "Optional" divider.

Position Before After
1 Titel (required, full row) Titel (required, full row)
2 Aufbewahrungsort (optional) Schlagworte (moved up)
3 Schlagworte (optional) Kurzinhalt
4 Kurzinhalt (optional) — Optional divider —
5 Aufbewahrungsort (moved last)

Add a divider element directly before the documentLocation field:

<div class="flex items-center gap-2 my-3">
  <div class="flex-1 border-t border-line"></div>
  <span class="text-[9px] font-bold uppercase tracking-widest text-ink-3">Optional</span>
  <div class="flex-1 border-t border-line"></div>
</div>

Auto-focus first empty required field

In WhoWhenSection.svelte, apply autofocus to the Date input when initialDateIso is empty; otherwise to the Sender PersonTypeahead:

{#if !initialDateIso}
  <input ... autofocus />
{:else}
  <PersonTypeahead ... autofocus />
{/if}

2 — Required-Fields Progress Bar

A 3 px-tall strip placed between the enrich top bar and the form scroll area. It tracks how many of the 3 required fields (Title, Date, Sender) are non-empty and fills a progress track accordingly.

Derivation (in enrich/[id]/+page.svelte)

const requiredFilled = $derived(
  [doc.title || titleValue, dateIso, senderId].filter(Boolean).length
);
const requiredPct = $derived((requiredFilled / 3) * 100);

Markup

<div class="flex items-center gap-3 border-b border-line bg-surface px-6 py-1.5">
  <span class="text-[9px] font-bold uppercase tracking-widest text-ink-3">Pflichtfelder</span>
  <div class="h-0.5 flex-1 rounded-full bg-line">
    <div
      class="h-full rounded-full bg-brand-navy transition-all duration-300"
      style="width:{requiredPct}%"
    ></div>
  </div>
  <span class="text-[10px] font-bold text-brand-navy">{requiredFilled} / 3</span>
</div>

Place this strip inside the form panel, above <div class="form-scroll ...">, so it stays fixed while the form scrolls.


3 — No-PDF Upload State

When !doc.filePath (i.e. status is PLACEHOLDER), render an upload zone in the left panel instead of the PDF viewer.

States

State A — No file uploaded

{#if !doc.filePath}
  <div class="flex-1 flex items-center justify-center bg-[#4A4846]">
    <div
      class="border border-dashed border-white/20 rounded-sm p-8 flex flex-col items-center gap-3 text-center"
      class:border-brand-mint={isDragging}
      class:bg-brand-mint/5={isDragging}
      ondragover={(e) => { e.preventDefault(); isDragging = true; }}
      ondragleave={() => (isDragging = false)}
      ondrop={(e) => { e.preventDefault(); isDragging = false; handleFile(e.dataTransfer?.files[0]); }}
    >
      <div class="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center text-white/40"></div>
      <p class="text-xs font-medium text-white/50 truncate max-w-[200px]">{doc.originalFilename}</p>
      <p class="text-[10px] text-white/30">Noch keine Datei hochgeladen</p>
      <label class="bg-brand-navy text-white/90 text-[10px] font-bold uppercase tracking-widest px-4 py-1.5 rounded-sm cursor-pointer">
        Datei auswählen
        <input type="file" class="sr-only" onchange={(e) => handleFile(e.currentTarget.files?.[0])} />
      </label>
      <p class="text-[9px] text-white/20">oder Datei hier ablegen</p>
    </div>
  </div>
{:else}
  <!-- existing PDF viewer -->
{/if}

State B — File uploading (indeterminate progress)

When isUploading is true, replace the zone content with:

<div class="w-full h-0.5 bg-white/10 rounded-full overflow-hidden">
  <div class="h-full bg-brand-mint/70 animate-[slide_1.4s_ease-in-out_infinite]"></div>
</div>
<p class="text-[10px] font-medium text-brand-mint/70 truncate max-w-[200px]">{doc.originalFilename}</p>
<p class="text-[9px] text-white/40">Wird hochgeladen …</p>
<button class="text-[9px] text-white/20 hover:text-white/40" onclick={cancelUpload}>Abbrechen</button>

Add the keyframe to app.css (or layout.css):

@keyframes slide {
  0%   { transform: translateX(-100%); }
  100% { transform: translateX(350%); }
}

Upload handler

let isUploading = $state(false);
let isDragging = $state(false);

async function handleFile(file: File | undefined) {
  if (!file) return;
  isUploading = true;
  try {
    const formData = new FormData();
    formData.append('file', file);
    const res = await fetch(`/api/documents/${doc.id}/file`, { method: 'POST', body: formData });
    if (!res.ok) throw new Error('Upload failed');
    // Invalidate / reload the page data so the PDF viewer appears
    await invalidate('app:document');
  } finally {
    isUploading = false;
  }
}

Replace file (edit mode)

In FileSectionEdit.svelte, move the "Datei ersetzen" trigger out of the form scroll and into the PDF toolbar as a ghost button:

<!-- inside .pdf-toolbar -->
<label class="ml-auto text-[10px] font-bold uppercase tracking-widest text-white/40 hover:text-white/70 transition-colors cursor-pointer">
  Datei ersetzen
  <input type="file" class="sr-only" onchange={handleReplaceFile} />
</label>

Affected Files

File Change
src/routes/enrich/[id]/+page.svelte Progress bar, no-PDF conditional block, drag-and-drop, upload handler
src/lib/components/WhoWhenSection.svelte Field reorder, autofocus logic
src/lib/components/DescriptionSection.svelte Field reorder, Optional divider
src/lib/components/FileSectionEdit.svelte Move "Datei ersetzen" to PDF toolbar
src/app.css (or layout.css) @keyframes slide for upload animation

Acceptance Criteria

  • In WhoWhenSection, Datum and Absender appear in the first grid row; Empfänger and Ort in the second
  • In DescriptionSection, field order is: Titel → Schlagworte → Kurzinhalt → [Optional divider] → Aufbewahrungsort
  • The "Optional" divider renders visually between Kurzinhalt and Aufbewahrungsort
  • On page load, the first empty required field receives focus automatically
  • The required-fields progress bar shows the correct fill and count (x / 3) as fields are populated
  • Progress bar transitions smoothly (transition-all duration-300) as fields are filled/cleared
  • When doc.filePath is null, the left panel shows the dark upload zone (not a blank area)
  • doc.originalFilename is displayed inside the upload zone
  • Dragging a file over the zone activates the teal/mint border highlight
  • Dropping or selecting a file triggers the upload and shows the indeterminate progress animation
  • After a successful upload the panel transitions to the normal PDF viewer (page data invalidated)
  • In edit mode, "Datei ersetzen" appears as a ghost button in the PDF toolbar (not inside the form scroll)
  • The form stays fully editable during upload (independent operations)
## Summary Three targeted UX improvements to the Enrich / Edit page that reduce friction during bulk enrichment without restructuring the card layout: 1. **Field priority within cards** — required fields moved to the top of each card 2. **Required-fields progress bar** — thin strip above the form tracking completion of the 3 mandatory fields 3. **No-PDF upload state** — proper upload zone in the left panel when a document has no file yet (`PLACEHOLDER` status) Design spec: `docs/specs/enrich-edit-unified.html` --- ## 1 — Field Priority Within Cards ### WhoWhenSection Reorder the four `<div>` children inside the existing `grid-cols-2` container so that required fields occupy row 1 and optional fields occupy row 2. | Position | Before | After | |----------|--------|-------| | Row 1, Col 1 | Datum *(required)* | Datum *(required)* | | Row 1, Col 2 | Ort *(optional)* | **Absender** *(required — moved up)* | | Row 2, Col 1 | Absender *(required)* | Empfänger *(optional)* | | Row 2, Col 2 | Empfänger *(optional)* | Ort *(optional)* | No class changes needed — grid order follows source order. ### DescriptionSection Reorder fields so Title stays first, Tags move up (more useful than archive location during enrichment), and Aufbewahrungsort is pushed below an "Optional" divider. | Position | Before | After | |----------|--------|-------| | 1 | Titel *(required, full row)* | Titel *(required, full row)* | | 2 | Aufbewahrungsort *(optional)* | **Schlagworte** *(moved up)* | | 3 | Schlagworte *(optional)* | Kurzinhalt | | 4 | Kurzinhalt *(optional)* | — Optional divider — | | 5 | — | Aufbewahrungsort *(moved last)* | Add a divider element directly before the `documentLocation` field: ```svelte <div class="flex items-center gap-2 my-3"> <div class="flex-1 border-t border-line"></div> <span class="text-[9px] font-bold uppercase tracking-widest text-ink-3">Optional</span> <div class="flex-1 border-t border-line"></div> </div> ``` ### Auto-focus first empty required field In `WhoWhenSection.svelte`, apply `autofocus` to the Date input when `initialDateIso` is empty; otherwise to the Sender `PersonTypeahead`: ```svelte {#if !initialDateIso} <input ... autofocus /> {:else} <PersonTypeahead ... autofocus /> {/if} ``` --- ## 2 — Required-Fields Progress Bar A 3 px-tall strip placed between the enrich top bar and the form scroll area. It tracks how many of the 3 required fields (Title, Date, Sender) are non-empty and fills a progress track accordingly. ### Derivation (in `enrich/[id]/+page.svelte`) ```svelte const requiredFilled = $derived( [doc.title || titleValue, dateIso, senderId].filter(Boolean).length ); const requiredPct = $derived((requiredFilled / 3) * 100); ``` ### Markup ```svelte <div class="flex items-center gap-3 border-b border-line bg-surface px-6 py-1.5"> <span class="text-[9px] font-bold uppercase tracking-widest text-ink-3">Pflichtfelder</span> <div class="h-0.5 flex-1 rounded-full bg-line"> <div class="h-full rounded-full bg-brand-navy transition-all duration-300" style="width:{requiredPct}%" ></div> </div> <span class="text-[10px] font-bold text-brand-navy">{requiredFilled} / 3</span> </div> ``` Place this strip inside the form panel, above `<div class="form-scroll ...">`, so it stays fixed while the form scrolls. --- ## 3 — No-PDF Upload State When `!doc.filePath` (i.e. status is `PLACEHOLDER`), render an upload zone in the left panel instead of the PDF viewer. ### States **State A — No file uploaded** ```svelte {#if !doc.filePath} <div class="flex-1 flex items-center justify-center bg-[#4A4846]"> <div class="border border-dashed border-white/20 rounded-sm p-8 flex flex-col items-center gap-3 text-center" class:border-brand-mint={isDragging} class:bg-brand-mint/5={isDragging} ondragover={(e) => { e.preventDefault(); isDragging = true; }} ondragleave={() => (isDragging = false)} ondrop={(e) => { e.preventDefault(); isDragging = false; handleFile(e.dataTransfer?.files[0]); }} > <div class="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center text-white/40">↑</div> <p class="text-xs font-medium text-white/50 truncate max-w-[200px]">{doc.originalFilename}</p> <p class="text-[10px] text-white/30">Noch keine Datei hochgeladen</p> <label class="bg-brand-navy text-white/90 text-[10px] font-bold uppercase tracking-widest px-4 py-1.5 rounded-sm cursor-pointer"> Datei auswählen <input type="file" class="sr-only" onchange={(e) => handleFile(e.currentTarget.files?.[0])} /> </label> <p class="text-[9px] text-white/20">oder Datei hier ablegen</p> </div> </div> {:else} <!-- existing PDF viewer --> {/if} ``` **State B — File uploading (indeterminate progress)** When `isUploading` is true, replace the zone content with: ```svelte <div class="w-full h-0.5 bg-white/10 rounded-full overflow-hidden"> <div class="h-full bg-brand-mint/70 animate-[slide_1.4s_ease-in-out_infinite]"></div> </div> <p class="text-[10px] font-medium text-brand-mint/70 truncate max-w-[200px]">{doc.originalFilename}</p> <p class="text-[9px] text-white/40">Wird hochgeladen …</p> <button class="text-[9px] text-white/20 hover:text-white/40" onclick={cancelUpload}>Abbrechen</button> ``` Add the keyframe to `app.css` (or `layout.css`): ```css @keyframes slide { 0% { transform: translateX(-100%); } 100% { transform: translateX(350%); } } ``` ### Upload handler ```typescript let isUploading = $state(false); let isDragging = $state(false); async function handleFile(file: File | undefined) { if (!file) return; isUploading = true; try { const formData = new FormData(); formData.append('file', file); const res = await fetch(`/api/documents/${doc.id}/file`, { method: 'POST', body: formData }); if (!res.ok) throw new Error('Upload failed'); // Invalidate / reload the page data so the PDF viewer appears await invalidate('app:document'); } finally { isUploading = false; } } ``` ### Replace file (edit mode) In `FileSectionEdit.svelte`, move the "Datei ersetzen" trigger out of the form scroll and into the PDF toolbar as a ghost button: ```svelte <!-- inside .pdf-toolbar --> <label class="ml-auto text-[10px] font-bold uppercase tracking-widest text-white/40 hover:text-white/70 transition-colors cursor-pointer"> Datei ersetzen <input type="file" class="sr-only" onchange={handleReplaceFile} /> </label> ``` --- ## Affected Files | File | Change | |------|--------| | `src/routes/enrich/[id]/+page.svelte` | Progress bar, no-PDF conditional block, drag-and-drop, upload handler | | `src/lib/components/WhoWhenSection.svelte` | Field reorder, autofocus logic | | `src/lib/components/DescriptionSection.svelte` | Field reorder, Optional divider | | `src/lib/components/FileSectionEdit.svelte` | Move "Datei ersetzen" to PDF toolbar | | `src/app.css` (or `layout.css`) | `@keyframes slide` for upload animation | --- ## Acceptance Criteria - [ ] In **WhoWhenSection**, Datum and Absender appear in the first grid row; Empfänger and Ort in the second - [ ] In **DescriptionSection**, field order is: Titel → Schlagworte → Kurzinhalt → [Optional divider] → Aufbewahrungsort - [ ] The "Optional" divider renders visually between Kurzinhalt and Aufbewahrungsort - [ ] On page load, the first empty required field receives focus automatically - [ ] The required-fields progress bar shows the correct fill and count (`x / 3`) as fields are populated - [ ] Progress bar transitions smoothly (`transition-all duration-300`) as fields are filled/cleared - [ ] When `doc.filePath` is null, the left panel shows the dark upload zone (not a blank area) - [ ] `doc.originalFilename` is displayed inside the upload zone - [ ] Dragging a file over the zone activates the teal/mint border highlight - [ ] Dropping or selecting a file triggers the upload and shows the indeterminate progress animation - [ ] After a successful upload the panel transitions to the normal PDF viewer (page data invalidated) - [ ] In edit mode, "Datei ersetzen" appears as a ghost button in the PDF toolbar (not inside the form scroll) - [ ] The form stays fully editable during upload (independent operations)
marcel added the featureui labels 2026-04-17 17:19:22 +02:00
Author
Owner

🏗️ Markus Keller — Application Architect

Observations

Progress bar state lifting is a hidden prerequisite. The issue proposes $derived([doc.title || titleValue, dateIso, senderId].filter(Boolean).length) in enrich/[id]/+page.svelte. But titleValue lives inside DescriptionSection.svelte as internal state, and dateIso lives inside WhoWhenSection.svelte as internal state. Neither is currently exposed as a bindable prop. This isn't mentioned in the issue — it's a real change that needs to happen before the progress bar can be wired up.

The upload zone belongs in its own component. The spec puts drag-and-drop, indeterminate animation, file state, uploading state, and cancel logic all inline in enrich/[id]/+page.svelte. That page is already 150+ lines. Extract to $lib/components/document/UploadZone.svelte — it's a coherent visual region with its own state machine, which is exactly the splitting criterion.

"Datei ersetzen" in the PDF toolbar is architecturally awkward. FileSectionEdit.svelte is route-colocated in documents/[id]/edit/. Moving the trigger into the PDF toolbar means either (a) coupling DocumentViewer.svelte to a file-replace concern it doesn't own, or (b) passing a Svelte snippet into DocumentViewer from the edit page. Snippet threading is the right call — don't add file-replace awareness to a component used on the enrich, transcription, and detail pages.

DocumentViewer already handles !doc.filePath (lines 77–88 in DocumentViewer.svelte) with a generic icon. The upload zone in the enrich page should bypass DocumentViewer entirely with an outer conditional in enrich/[id]/+page.svelte, rather than adding upload logic inside the viewer. DocumentViewer should stay generic.

Recommendations

  • Add dateIso = $bindable('') to WhoWhenSection props and expose titleValue (or a derived currentTitle) as a bindable from DescriptionSection before wiring the progress bar.
  • Extract the upload zone to $lib/components/document/UploadZone.svelte with props: filename, isUploading, isDragging, onFile, onCancel.
  • In enrich/[id]/+page.svelte, wrap the left panel as: {#if !doc.filePath}<UploadZone .../>{:else}<DocumentViewer .../>{/if}.
  • For the "Datei ersetzen" button: add an actionSnippet snippet prop to DocumentViewer so the edit page can inject toolbar actions without DocumentViewer knowing about file replacement.
## 🏗️ Markus Keller — Application Architect ### Observations **Progress bar state lifting is a hidden prerequisite.** The issue proposes `$derived([doc.title || titleValue, dateIso, senderId].filter(Boolean).length)` in `enrich/[id]/+page.svelte`. But `titleValue` lives inside `DescriptionSection.svelte` as internal state, and `dateIso` lives inside `WhoWhenSection.svelte` as internal state. Neither is currently exposed as a bindable prop. This isn't mentioned in the issue — it's a real change that needs to happen before the progress bar can be wired up. **The upload zone belongs in its own component.** The spec puts drag-and-drop, indeterminate animation, file state, uploading state, and cancel logic all inline in `enrich/[id]/+page.svelte`. That page is already 150+ lines. Extract to `$lib/components/document/UploadZone.svelte` — it's a coherent visual region with its own state machine, which is exactly the splitting criterion. **"Datei ersetzen" in the PDF toolbar is architecturally awkward.** `FileSectionEdit.svelte` is route-colocated in `documents/[id]/edit/`. Moving the trigger into the PDF toolbar means either (a) coupling `DocumentViewer.svelte` to a file-replace concern it doesn't own, or (b) passing a Svelte snippet into DocumentViewer from the edit page. Snippet threading is the right call — don't add file-replace awareness to a component used on the enrich, transcription, and detail pages. **`DocumentViewer` already handles `!doc.filePath`** (lines 77–88 in `DocumentViewer.svelte`) with a generic icon. The upload zone in the enrich page should _bypass_ `DocumentViewer` entirely with an outer conditional in `enrich/[id]/+page.svelte`, rather than adding upload logic inside the viewer. DocumentViewer should stay generic. ### Recommendations - Add `dateIso = $bindable('')` to `WhoWhenSection` props and expose `titleValue` (or a derived `currentTitle`) as a bindable from `DescriptionSection` before wiring the progress bar. - Extract the upload zone to `$lib/components/document/UploadZone.svelte` with props: `filename`, `isUploading`, `isDragging`, `onFile`, `onCancel`. - In `enrich/[id]/+page.svelte`, wrap the left panel as: `{#if !doc.filePath}<UploadZone .../>{:else}<DocumentViewer .../>{/if}`. - For the "Datei ersetzen" button: add an `actionSnippet` snippet prop to `DocumentViewer` so the edit page can inject toolbar actions without DocumentViewer knowing about file replacement.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Observations

The upload API endpoint doesn't exist. The issue's handleFile calls fetch('/api/documents/${doc.id}/file', { method: 'POST' }). No such endpoint exists on the backend. The actual upload goes through PUT /api/documents/{id} as a multipart request (handled by the save form action). This call will return 404. Either: (a) add a dedicated POST /api/documents/{id}/file endpoint in DocumentController, or (b) submit the file through the existing SvelteKit form action as a partial form post. Option (a) is simpler for an async upload; option (b) avoids backend changes but triggers a full form save.

invalidate('app:document') won't work. The load function in +page.server.ts never calls depends('app:document'). Without that, calling invalidate('app:document') from the client will do nothing — the page data won't refresh after the upload completes, so the PDF viewer will never appear. Add depends('app:document') to the load function, or use invalidateAll() as a simpler fallback.

titleValue and dateIso are not bindable. WhoWhenSection.svelte computes dateIso as internal $state. DescriptionSection.svelte derives titleValue internally. Neither is in the parent's scope. To compute the progress bar in enrich/[id]/+page.svelte, add dateIso = $bindable('') to WhoWhenSection and expose titleValue (or a currentTitle bindable) from DescriptionSection.

PersonTypeahead has no autofocus prop. The autofocus spec (<PersonTypeahead ... autofocus />) can't work — PersonTypeahead doesn't accept an autofocus prop (confirmed by reading its props interface). Add autofocus?: boolean = false and forward it to the underlying <input> element.

cancelUpload is referenced but never defined. The upload zone spec shows onclick={cancelUpload} but there's no definition anywhere. Decide: use AbortController to cancel the fetch, or just disallow cancel (let upload finish). If using AbortController, hold the reference as $state<AbortController | null>(null).

DescriptionSection field reorder note. Current order is Titel → Aufbewahrungsort → Schlagworte → Inhalt. The reorder moves Aufbewahrungsort to the bottom. The $state and $derived in the component are position-independent, so this is a pure template reorder — no logic changes needed.

Recommendations

  • Add a POST /api/documents/{id}/file endpoint that accepts a single multipart file, updates the document's filePath, and returns the updated document. Annotate with @RequirePermission(Permission.WRITE_ALL).
  • Add depends('app:document') to the enrich load function.
  • Add bindable output props to WhoWhenSection and DescriptionSection for the live field values needed by the progress bar.
  • Add autofocus?: boolean prop to PersonTypeahead forwarded to the input element.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### Observations **The upload API endpoint doesn't exist.** The issue's `handleFile` calls `fetch('/api/documents/${doc.id}/file', { method: 'POST' })`. No such endpoint exists on the backend. The actual upload goes through `PUT /api/documents/{id}` as a multipart request (handled by the `save` form action). This call will return 404. Either: (a) add a dedicated `POST /api/documents/{id}/file` endpoint in `DocumentController`, or (b) submit the file through the existing SvelteKit form action as a partial form post. Option (a) is simpler for an async upload; option (b) avoids backend changes but triggers a full form save. **`invalidate('app:document')` won't work.** The load function in `+page.server.ts` never calls `depends('app:document')`. Without that, calling `invalidate('app:document')` from the client will do nothing — the page data won't refresh after the upload completes, so the PDF viewer will never appear. Add `depends('app:document')` to the load function, or use `invalidateAll()` as a simpler fallback. **`titleValue` and `dateIso` are not bindable.** `WhoWhenSection.svelte` computes `dateIso` as internal `$state`. `DescriptionSection.svelte` derives `titleValue` internally. Neither is in the parent's scope. To compute the progress bar in `enrich/[id]/+page.svelte`, add `dateIso = $bindable('')` to `WhoWhenSection` and expose `titleValue` (or a `currentTitle` bindable) from `DescriptionSection`. **`PersonTypeahead` has no `autofocus` prop.** The autofocus spec (`<PersonTypeahead ... autofocus />`) can't work — `PersonTypeahead` doesn't accept an `autofocus` prop (confirmed by reading its props interface). Add `autofocus?: boolean = false` and forward it to the underlying `<input>` element. **`cancelUpload` is referenced but never defined.** The upload zone spec shows `onclick={cancelUpload}` but there's no definition anywhere. Decide: use `AbortController` to cancel the fetch, or just disallow cancel (let upload finish). If using `AbortController`, hold the reference as `$state<AbortController | null>(null)`. **DescriptionSection field reorder note.** Current order is Titel → Aufbewahrungsort → Schlagworte → Inhalt. The reorder moves Aufbewahrungsort to the bottom. The `$state` and `$derived` in the component are position-independent, so this is a pure template reorder — no logic changes needed. ### Recommendations - Add a `POST /api/documents/{id}/file` endpoint that accepts a single multipart file, updates the document's `filePath`, and returns the updated document. Annotate with `@RequirePermission(Permission.WRITE_ALL)`. - Add `depends('app:document')` to the enrich load function. - Add bindable output props to `WhoWhenSection` and `DescriptionSection` for the live field values needed by the progress bar. - Add `autofocus?: boolean` prop to `PersonTypeahead` forwarded to the input element.
Author
Owner

🔐 Nora "NullX" Steiner — Security Engineer

Observations

Client-side fetch to the backend API bypasses server middleware — acceptable here, but needs auth awareness. The handleFile function calls fetch('/api/documents/${doc.id}/file', ...) directly from the browser. SvelteKit proxies all /api/* requests to the backend, so the session cookie is sent along — authentication is maintained. No SSRF risk (the URL is hardcoded, not user-controlled). This is a known pattern for async file uploads. Just make sure the new backend endpoint enforces @RequirePermission(Permission.WRITE_ALL).

No client-side file type validation before upload starts. The handleFile function passes the file directly to FormData without checking file.type. The backend's updateDocument path does validate ALLOWED_CONTENT_TYPES (confirmed in DocumentController), so an invalid type will be rejected. But the user gets no feedback until the server responds. Add a client-side type check for fast rejection:

const ALLOWED_TYPES = new Set(['application/pdf', 'image/jpeg', 'image/png', 'image/tiff']);
if (!ALLOWED_TYPES.has(file.type)) {
    // show inline error, don't upload
    return;
}

No file size limit before upload. A user could drop a 2 GB file and tie up the connection for minutes. The backend presumably has a max file size configured in Spring (default is 1 MB for multipart, likely overridden). Add a client-side check against a known limit (e.g. 50 MB) and show an inline error immediately.

cancelUpload is undefined. If the cancel button appears during upload but cancelUpload is not implemented, clicking it will throw a runtime error. Use AbortController and expose a cancel() method. Alternatively, omit the cancel button until the function is implemented — a button that throws on click is worse than no button.

IDOR risk on the upload endpoint. The upload endpoint must verify that the requesting user has access to the document being uploaded to. If the new POST /api/documents/{id}/file endpoint calls documentService.updateDocument(id, ...), the existing @RequirePermission(Permission.WRITE_ALL) check covers role but doesn't check document ownership. This is consistent with the rest of the app's access model (role-based, not per-document), so no new risk is introduced — just confirming parity.

Recommendations

  • Add client-side MIME type check in handleFile before starting the upload.
  • Add client-side file size check (50 MB limit suggested) with an inline error message.
  • Implement cancelUpload via AbortController or remove the button from the spec until it's properly implemented.
  • Ensure the new backend file endpoint annotated with @RequirePermission(Permission.WRITE_ALL) — same as the existing update endpoint.
## 🔐 Nora "NullX" Steiner — Security Engineer ### Observations **Client-side `fetch` to the backend API bypasses server middleware — acceptable here, but needs auth awareness.** The `handleFile` function calls `fetch('/api/documents/${doc.id}/file', ...)` directly from the browser. SvelteKit proxies all `/api/*` requests to the backend, so the session cookie is sent along — authentication is maintained. No SSRF risk (the URL is hardcoded, not user-controlled). This is a known pattern for async file uploads. Just make sure the new backend endpoint enforces `@RequirePermission(Permission.WRITE_ALL)`. **No client-side file type validation before upload starts.** The `handleFile` function passes the file directly to `FormData` without checking `file.type`. The backend's `updateDocument` path does validate `ALLOWED_CONTENT_TYPES` (confirmed in `DocumentController`), so an invalid type will be rejected. But the user gets no feedback until the server responds. Add a client-side type check for fast rejection: ```typescript const ALLOWED_TYPES = new Set(['application/pdf', 'image/jpeg', 'image/png', 'image/tiff']); if (!ALLOWED_TYPES.has(file.type)) { // show inline error, don't upload return; } ``` **No file size limit before upload.** A user could drop a 2 GB file and tie up the connection for minutes. The backend presumably has a max file size configured in Spring (default is 1 MB for multipart, likely overridden). Add a client-side check against a known limit (e.g. 50 MB) and show an inline error immediately. **`cancelUpload` is undefined.** If the cancel button appears during upload but `cancelUpload` is not implemented, clicking it will throw a runtime error. Use `AbortController` and expose a `cancel()` method. Alternatively, omit the cancel button until the function is implemented — a button that throws on click is worse than no button. **IDOR risk on the upload endpoint.** The upload endpoint must verify that the requesting user has access to the document being uploaded to. If the new `POST /api/documents/{id}/file` endpoint calls `documentService.updateDocument(id, ...)`, the existing `@RequirePermission(Permission.WRITE_ALL)` check covers role but doesn't check document ownership. This is consistent with the rest of the app's access model (role-based, not per-document), so no new risk is introduced — just confirming parity. ### Recommendations - Add client-side MIME type check in `handleFile` before starting the upload. - Add client-side file size check (50 MB limit suggested) with an inline error message. - Implement `cancelUpload` via `AbortController` or remove the button from the spec until it's properly implemented. - Ensure the new backend file endpoint annotated with `@RequirePermission(Permission.WRITE_ALL)` — same as the existing update endpoint.
Author
Owner

🧪 Sara Holt — QA Engineer

Observations

Upload failure has no acceptance criterion and no specified UI state. The 13 ACs cover the happy path (upload → progress → PDF viewer) and drag-and-drop styling, but nothing about what happens when the upload fails (network error, server 4xx/5xx, unsupported type). Users need a recoverable error state — the upload zone should return to its idle state with an inline error message. Without this AC, the behavior will be underdefined.

cancelUpload has no acceptance criterion. AC mentions "Abbrechen" appears during upload but doesn't specify what the UI returns to after cancel, or whether a cancelled upload resets the upload zone cleanly. This is testable behavior that needs specifying.

invalidate('app:document') is not currently set up in the load function. The load function doesn't call depends('app:document'), so the invalidation call won't trigger a data reload. A Vitest test for the load function won't catch this — it'll need an integration or E2E test that: (1) visits an enrich page for a PLACEHOLDER doc, (2) uploads a file, (3) verifies the PDF viewer appears. Without this test, the transition from upload zone to PDF viewer could silently regress.

Progress bar requires bindable state not yet exposed by child components. The derivation [doc.title || titleValue, dateIso, senderId] references titleValue and dateIso as in-scope variables — but they're internal to child components. When these bindable props are added, add a Vitest test for the derivation: mock the three state values and assert requiredFilled counts correctly for all 8 combinations (0/1/2/3 filled).

Autofocus AC is underspecified for the "all empty" case. The spec says: focus Date when initialDateIso is empty; otherwise focus Sender. A freshly loaded PLACEHOLDER doc has neither date nor sender — the Date input should receive focus. This is correct per the spec but the AC doesn't state it explicitly. Add: "When both Datum and Absender are empty on load, Datum receives focus."

Recommendations

Add these missing ACs:

  • When the upload fails (network or server error), the upload zone returns to idle state and an inline error message is shown
  • Clicking "Abbrechen" during upload cancels the request and restores the idle upload zone
  • When both required who/when fields are empty on load, Datum receives autofocus
  • The PDF viewer appears after a successful upload (requires depends('app:document') wired up)

Test strategy:

  • Unit (Vitest): requiredFilled derivation for all 8 field-filled combinations
  • Unit (Vitest): handleFile rejects invalid MIME types without uploading
  • Integration (load function test): verify load function returns null filePath for PLACEHOLDER docs
  • E2E (Playwright): upload a PDF on a PLACEHOLDER document, verify transition to PDF viewer
## 🧪 Sara Holt — QA Engineer ### Observations **Upload failure has no acceptance criterion and no specified UI state.** The 13 ACs cover the happy path (upload → progress → PDF viewer) and drag-and-drop styling, but nothing about what happens when the upload fails (network error, server 4xx/5xx, unsupported type). Users need a recoverable error state — the upload zone should return to its idle state with an inline error message. Without this AC, the behavior will be underdefined. **`cancelUpload` has no acceptance criterion.** AC mentions "Abbrechen" appears during upload but doesn't specify what the UI returns to after cancel, or whether a cancelled upload resets the upload zone cleanly. This is testable behavior that needs specifying. **`invalidate('app:document')` is not currently set up in the load function.** The load function doesn't call `depends('app:document')`, so the invalidation call won't trigger a data reload. A Vitest test for the load function won't catch this — it'll need an integration or E2E test that: (1) visits an enrich page for a PLACEHOLDER doc, (2) uploads a file, (3) verifies the PDF viewer appears. Without this test, the transition from upload zone to PDF viewer could silently regress. **Progress bar requires bindable state not yet exposed by child components.** The derivation `[doc.title || titleValue, dateIso, senderId]` references `titleValue` and `dateIso` as in-scope variables — but they're internal to child components. When these bindable props are added, add a Vitest test for the derivation: mock the three state values and assert `requiredFilled` counts correctly for all 8 combinations (0/1/2/3 filled). **Autofocus AC is underspecified for the "all empty" case.** The spec says: focus Date when `initialDateIso` is empty; otherwise focus Sender. A freshly loaded PLACEHOLDER doc has neither date nor sender — the Date input should receive focus. This is correct per the spec but the AC doesn't state it explicitly. Add: "When both Datum and Absender are empty on load, Datum receives focus." ### Recommendations Add these missing ACs: - When the upload fails (network or server error), the upload zone returns to idle state and an inline error message is shown - Clicking "Abbrechen" during upload cancels the request and restores the idle upload zone - When both required who/when fields are empty on load, Datum receives autofocus - The PDF viewer appears after a successful upload (requires `depends('app:document')` wired up) **Test strategy:** - Unit (Vitest): `requiredFilled` derivation for all 8 field-filled combinations - Unit (Vitest): `handleFile` rejects invalid MIME types without uploading - Integration (load function test): verify load function returns `null` filePath for PLACEHOLDER docs - E2E (Playwright): upload a PDF on a PLACEHOLDER document, verify transition to PDF viewer
Author
Owner

🎨 Leonie Voss — UX Design Lead

Observations

Hardcoded hex bg-[#4A4846] breaks the token system. The upload zone background is specified as bg-[#4A4846], a raw hex value. The project's dark panel color is already expressed as bg-pdf-bg (used in DocumentViewer.svelte). Use bg-pdf-bg — it matches the PDF viewer panel color, ensures consistency when the panel transitions from upload zone to PDF viewer, and keeps all dark-panel color decisions in one place.

Three text sizes in the upload zone are below the 12 px floor. The spec uses text-[9px] for the "Optional" divider label and the drag hint, and text-[10px] for the filename and progress labels. Our senior-audience minimum is 12 px. Raise them:

  • "oder Datei hier ablegen" (text-[9px]) → text-[11px] minimum (use text-xs = 12px)
  • "Noch keine Datei hochgeladen" subtitle → text-xs (12px)
  • The cancel "Abbrechen" link → text-xs minimum

The "Abbrechen" cancel link is nearly invisible. text-[9px] text-white/20 is 9 px text at 20% opacity on a dark background — it fails every WCAG contrast level and cannot be reliably tapped. If cancel is included, it must be at least text-xs text-white/40 hover:text-white/60, and the touch target must be min-h-[44px] or wrapped in a <button class="min-h-[44px]">.

"Datei auswählen" button touch target needs enforcement. The spec's <label> wrapping the file input has px-4 py-1.5 padding — at 10px font size, this renders roughly 28px tall. WCAG 2.2 SC 2.5.8 requires 24×24 px minimum, but our audience (60+) needs 44×44 px. Add min-h-[44px] flex items-center to the label.

The drag-highlight token bg-mint/5 is wrong. The spec references bg-brand-mint/5 in one place and bg-mint/5 in another. Only bg-brand-mint/5 is a valid token in this project. Use bg-brand-mint/5 consistently.

No aria-live region for upload state changes. When the zone switches from idle → uploading → done, screen readers won't announce anything. Add an aria-live="polite" region or update an aria-label on the zone container to describe the current state. For the uploading state, "Datei wird hochgeladen" should be announced.

Progress bar accessibility. The <div class="h-0.5 flex-1 rounded-full bg-line"> bar has no ARIA. Add role="progressbar" aria-valuenow={requiredFilled} aria-valuemin={0} aria-valuemax={3} aria-label="Pflichtfelder" to the fill container so screen readers announce completion state.

Recommendations

  • Replace bg-[#4A4846] with bg-pdf-bg
  • All text in the upload zone: minimum text-xs (12px)
  • "Abbrechen" button: text-xs text-white/40 hover:text-white/60 min-h-[44px] or remove from spec until properly styled
  • "Datei auswählen" label: add min-h-[44px] flex items-center
  • Fix bg-mint/5bg-brand-mint/5
  • Add aria-live="polite" region and role="progressbar" on the required-fields bar
## 🎨 Leonie Voss — UX Design Lead ### Observations **Hardcoded hex `bg-[#4A4846]` breaks the token system.** The upload zone background is specified as `bg-[#4A4846]`, a raw hex value. The project's dark panel color is already expressed as `bg-pdf-bg` (used in `DocumentViewer.svelte`). Use `bg-pdf-bg` — it matches the PDF viewer panel color, ensures consistency when the panel transitions from upload zone to PDF viewer, and keeps all dark-panel color decisions in one place. **Three text sizes in the upload zone are below the 12 px floor.** The spec uses `text-[9px]` for the "Optional" divider label and the drag hint, and `text-[10px]` for the filename and progress labels. Our senior-audience minimum is 12 px. Raise them: - "oder Datei hier ablegen" (`text-[9px]`) → `text-[11px]` minimum (use `text-xs` = 12px) - "Noch keine Datei hochgeladen" subtitle → `text-xs` (12px) - The cancel "Abbrechen" link → `text-xs` minimum **The "Abbrechen" cancel link is nearly invisible.** `text-[9px] text-white/20` is 9 px text at 20% opacity on a dark background — it fails every WCAG contrast level and cannot be reliably tapped. If cancel is included, it must be at least `text-xs text-white/40 hover:text-white/60`, and the touch target must be `min-h-[44px]` or wrapped in a `<button class="min-h-[44px]">`. **"Datei auswählen" button touch target needs enforcement.** The spec's `<label>` wrapping the file input has `px-4 py-1.5` padding — at 10px font size, this renders roughly 28px tall. WCAG 2.2 SC 2.5.8 requires 24×24 px minimum, but our audience (60+) needs 44×44 px. Add `min-h-[44px] flex items-center` to the label. **The drag-highlight token `bg-mint/5` is wrong.** The spec references `bg-brand-mint/5` in one place and `bg-mint/5` in another. Only `bg-brand-mint/5` is a valid token in this project. Use `bg-brand-mint/5` consistently. **No `aria-live` region for upload state changes.** When the zone switches from idle → uploading → done, screen readers won't announce anything. Add an `aria-live="polite"` region or update an `aria-label` on the zone container to describe the current state. For the uploading state, "Datei wird hochgeladen" should be announced. **Progress bar accessibility.** The `<div class="h-0.5 flex-1 rounded-full bg-line">` bar has no ARIA. Add `role="progressbar" aria-valuenow={requiredFilled} aria-valuemin={0} aria-valuemax={3} aria-label="Pflichtfelder"` to the fill container so screen readers announce completion state. ### Recommendations - Replace `bg-[#4A4846]` with `bg-pdf-bg` - All text in the upload zone: minimum `text-xs` (12px) - "Abbrechen" button: `text-xs text-white/40 hover:text-white/60 min-h-[44px]` or remove from spec until properly styled - "Datei auswählen" label: add `min-h-[44px] flex items-center` - Fix `bg-mint/5` → `bg-brand-mint/5` - Add `aria-live="polite"` region and `role="progressbar"` on the required-fields bar
Author
Owner

🛠️ Tobias Wendt — DevOps & Platform Engineer

Observations

No infrastructure impact. This is a pure frontend change — three Svelte components modified, one enrich page extended, one CSS animation added. No new services, no Docker changes, no Flyway migrations, no environment variables. From a platform perspective this is clean.

@keyframes slide goes in layout.css, not app.css. The issue says "Add the keyframe to app.css (or layout.css)". Prefer layout.css. app.css is the Tailwind 4 entry point — it should stay lean (only @import "tailwindcss" and CSS custom properties). Animation keyframes that aren't Tailwind utilities belong in layout.css alongside the existing brand token definitions.

CI impact is minimal. No new test infrastructure needed. The frontend Vitest suite and svelte-check type-check will cover the component changes. If E2E tests exist for the enrich flow, they should be extended to cover the upload zone, but the pipeline itself doesn't change.

One thing to verify: the production file size limit. The new upload path calls PUT /api/documents/{id} with a raw file. Spring Boot's multipart limit is configured in application.yaml (typically spring.servlet.multipart.max-file-size and max-request-size). Make sure the configured limit is appropriate for family photos and scanned PDFs — 50 MB is a reasonable ceiling for this use case. If it's currently at the Spring default (1 MB), the upload will silently fail for most real documents.

## 🛠️ Tobias Wendt — DevOps & Platform Engineer ### Observations **No infrastructure impact.** This is a pure frontend change — three Svelte components modified, one enrich page extended, one CSS animation added. No new services, no Docker changes, no Flyway migrations, no environment variables. From a platform perspective this is clean. **`@keyframes slide` goes in `layout.css`, not `app.css`.** The issue says "Add the keyframe to `app.css` (or `layout.css`)". Prefer `layout.css`. `app.css` is the Tailwind 4 entry point — it should stay lean (only `@import "tailwindcss"` and CSS custom properties). Animation keyframes that aren't Tailwind utilities belong in `layout.css` alongside the existing brand token definitions. **CI impact is minimal.** No new test infrastructure needed. The frontend Vitest suite and `svelte-check` type-check will cover the component changes. If E2E tests exist for the enrich flow, they should be extended to cover the upload zone, but the pipeline itself doesn't change. **One thing to verify: the production file size limit.** The new upload path calls `PUT /api/documents/{id}` with a raw file. Spring Boot's multipart limit is configured in `application.yaml` (typically `spring.servlet.multipart.max-file-size` and `max-request-size`). Make sure the configured limit is appropriate for family photos and scanned PDFs — 50 MB is a reasonable ceiling for this use case. If it's currently at the Spring default (1 MB), the upload will silently fail for most real documents.
Author
Owner

🗳️ Decision Queue — Action Required

2 decisions need your input before implementation starts.

Architecture

  • How should the async file upload work? Two options: (a) Add a dedicated POST /api/documents/{id}/file backend endpoint — simpler to call from the browser via raw fetch, returns the updated document, and the frontend can then invalidate to show the PDF viewer. (b) Reuse the existing SvelteKit form action (PUT /api/documents/{id}) by submitting a partial FormData — avoids backend changes but triggers a full form save and a page redirect, breaking the async UX. Option (a) requires writing a new backend endpoint and test; option (b) avoids backend work but conflicts with the "form stays editable during upload" acceptance criterion. (Raised by: Felix)

UX / Scope

  • Cancel button: include now or defer? The spec shows "Abbrechen" during upload, but cancelUpload is undefined, the touch target is inaccessible, and the font is below the 12 px minimum. Including it properly requires: AbortController implementation, accessible sizing (min-h-[44px]), and readable contrast (text-white/40). Deferring keeps this issue focused — the upload zone still works without a cancel button (upload completes normally). Decision: implement cancel properly in this issue, or ship the upload zone without cancel and track cancel as a follow-up? (Raised by: Felix, Nora, Leonie)
## 🗳️ Decision Queue — Action Required _2 decisions need your input before implementation starts._ ### Architecture - **How should the async file upload work?** Two options: (a) Add a dedicated `POST /api/documents/{id}/file` backend endpoint — simpler to call from the browser via raw `fetch`, returns the updated document, and the frontend can then invalidate to show the PDF viewer. (b) Reuse the existing SvelteKit form action (`PUT /api/documents/{id}`) by submitting a partial `FormData` — avoids backend changes but triggers a full form save and a page redirect, breaking the async UX. Option (a) requires writing a new backend endpoint and test; option (b) avoids backend work but conflicts with the "form stays editable during upload" acceptance criterion. _(Raised by: Felix)_ ### UX / Scope - **Cancel button: include now or defer?** The spec shows "Abbrechen" during upload, but `cancelUpload` is undefined, the touch target is inaccessible, and the font is below the 12 px minimum. Including it properly requires: `AbortController` implementation, accessible sizing (`min-h-[44px]`), and readable contrast (`text-white/40`). Deferring keeps this issue focused — the upload zone still works without a cancel button (upload completes normally). Decision: implement cancel properly in this issue, or ship the upload zone without cancel and track cancel as a follow-up? _(Raised by: Felix, Nora, Leonie)_
Author
Owner

👨‍💻 Felix Brandt — Implementation Discussion Summary

Working through the open items from my review comment. All 5 resolved.

Resolved

1. Upload endpoint — new dedicated backend endpoint
Add POST /api/documents/{id}/file to DocumentController. Accepts a single @RequestPart MultipartFile, annotated @RequirePermission(Permission.WRITE_ALL), delegates to a new documentService.attachFile(id, file) method, returns the updated Document. Test coverage: @WebMvcTest happy path + 403 for missing permission.

2. Cancel button — implement now with AbortController
Hold let abortController = $state<AbortController | null>(null). On upload start, assign a new controller and pass signal to fetch. cancelUpload() calls abortController?.abort(). Catch AbortError and reset to idle. Button styled min-h-[44px] text-xs text-white/40 hover:text-white/60 per Leonie's accessibility requirements.

3. Bindable props for progress bar

  • WhoWhenSection: add dateIso = $bindable('') to props — already $state internally, straightforward to expose
  • DescriptionSection: rename titleOverride to currentTitle and expose as a read-only bindable — parent binds but never writes, no $effect needed
  • Progress bar derivation in the page: const requiredFilled = $derived([currentTitle, dateIso, senderId].filter(Boolean).length)

4. Post-upload data refresh — targeted invalidate
Add depends('app:document') to the enrich load function. Call invalidate('app:document') after a successful upload. Explicit and self-documenting — invalidateAll() was ruled out as too blunt.

5. UploadZone extraction — separate component
Extract to $lib/components/document/UploadZone.svelte. Purely presentational: all state (abortController, isUploading, isDragging, error) lives in enrich/[id]/+page.svelte. Props: filename, isUploading, isDragging, error, onFile, onCancel. The component owns the markup and animation; the parent owns the state machine.

Overall read

The field reordering and progress bar are straightforward once the bindable props are in place. The upload zone has enough moving parts (abort, error state, drag, animation) that extracting it to its own component is the right call before writing a line of it. The new backend endpoint is the only non-frontend work — small but necessary for the async UX to hold together.

## 👨‍💻 Felix Brandt — Implementation Discussion Summary Working through the open items from my review comment. All 5 resolved. ### Resolved **1. Upload endpoint — new dedicated backend endpoint** Add `POST /api/documents/{id}/file` to `DocumentController`. Accepts a single `@RequestPart MultipartFile`, annotated `@RequirePermission(Permission.WRITE_ALL)`, delegates to a new `documentService.attachFile(id, file)` method, returns the updated `Document`. Test coverage: `@WebMvcTest` happy path + 403 for missing permission. **2. Cancel button — implement now with `AbortController`** Hold `let abortController = $state<AbortController | null>(null)`. On upload start, assign a new controller and pass `signal` to `fetch`. `cancelUpload()` calls `abortController?.abort()`. Catch `AbortError` and reset to idle. Button styled `min-h-[44px] text-xs text-white/40 hover:text-white/60` per Leonie's accessibility requirements. **3. Bindable props for progress bar** - `WhoWhenSection`: add `dateIso = $bindable('')` to props — already `$state` internally, straightforward to expose - `DescriptionSection`: rename `titleOverride` to `currentTitle` and expose as a read-only bindable — parent binds but never writes, no `$effect` needed - Progress bar derivation in the page: `const requiredFilled = $derived([currentTitle, dateIso, senderId].filter(Boolean).length)` **4. Post-upload data refresh — targeted invalidate** Add `depends('app:document')` to the enrich load function. Call `invalidate('app:document')` after a successful upload. Explicit and self-documenting — `invalidateAll()` was ruled out as too blunt. **5. UploadZone extraction — separate component** Extract to `$lib/components/document/UploadZone.svelte`. Purely presentational: all state (`abortController`, `isUploading`, `isDragging`, `error`) lives in `enrich/[id]/+page.svelte`. Props: `filename`, `isUploading`, `isDragging`, `error`, `onFile`, `onCancel`. The component owns the markup and animation; the parent owns the state machine. ### Overall read The field reordering and progress bar are straightforward once the bindable props are in place. The upload zone has enough moving parts (abort, error state, drag, animation) that extracting it to its own component is the right call before writing a line of it. The new backend endpoint is the only non-frontend work — small but necessary for the async UX to hold together.
Author
Owner

Implementation complete — branch feat/issue-261-enrich-field-reorder-progress-bar-upload

All 14 planned tasks implemented with TDD (red → green → commit). Summary:

Backend (1 commit)

  • 57ed937 — New POST /api/documents/{id}/file endpoint (@RequirePermission(WRITE_ALL)), delegating to DocumentService.attachFile(). 2 new @WebMvcTest tests (200 with WRITE_ALL, 403 without).

Frontend (8 commits)

  • 255eeb6PersonTypeahead: added autofocus?: boolean prop forwarded to the text <input>.
  • 77db791WhoWhenSection: exposed dateIso = $bindable(''), reordered grid (Datum+Absender in row 1, Empfänger+Ort in row 2), conditional autofocus on first empty required field.
  • 765d282DescriptionSection: exposed currentTitle = $bindable(''), reordered fields (Titel → Schlagworte → Kurzinhalt → [Optional divider] → Aufbewahrungsort).
  • 44c7adbenrich/[id]/+page.server.ts: added depends('app:document') so post-upload invalidate() triggers a data reload.
  • 3893a10layout.css: added @keyframes slide for the indeterminate upload animation.
  • 14cfe8c — New UploadZone.svelte component: idle/uploading/error states, drag-and-drop, client-side MIME + 50 MB validation, min-h-[44px] touch targets, aria-live="polite", bg-pdf-bg. 8 Vitest tests.
  • 93fc869countRequiredFilled() utility + 8 Vitest tests covering all field-filled combinations.
  • 836d30eenrich/[id]/+page.svelte wired up: progress bar (role="progressbar" ARIA, requiredFilled derived from bound currentTitle/dateIso/senderId), conditional left panel (UploadZone vs DocumentViewer), upload state machine with AbortController + invalidate('app:document'), "Datei ersetzen" ghost toolbar above PDF.

Test results

  • Backend: 1068 tests, 0 failures
  • Frontend: 989 tests, 0 failures (1 pre-existing flaky Chromium test in TagInput unrelated to this issue)

All acceptance criteria satisfied

  • WhoWhenSection: Datum + Absender in row 1, Empfänger + Ort in row 2
  • DescriptionSection: Titel → Schlagworte → Kurzinhalt → [Optional divider] → Aufbewahrungsort
  • Optional divider renders between Kurzinhalt and Aufbewahrungsort
  • First empty required field receives autofocus on page load
  • Progress bar tracks completion of 3 required fields with x / 3 label
  • Progress bar transitions smoothly (transition-all duration-300)
  • Upload zone shown when doc.filePath is null (dark background using bg-pdf-bg)
  • doc.originalFilename displayed in upload zone
  • Dragging activates teal/mint border highlight
  • Dropping/selecting a file triggers upload with indeterminate animation
  • After successful upload, panel transitions to PDF viewer (invalidate('app:document'))
  • "Datei ersetzen" appears as ghost button in toolbar above PDF (not in form scroll)
  • Form stays editable during upload (independent state machine)
## ✅ Implementation complete — branch `feat/issue-261-enrich-field-reorder-progress-bar-upload` All 14 planned tasks implemented with TDD (red → green → commit). Summary: ### Backend (1 commit) - `57ed937` — New `POST /api/documents/{id}/file` endpoint (`@RequirePermission(WRITE_ALL)`), delegating to `DocumentService.attachFile()`. 2 new `@WebMvcTest` tests (200 with WRITE_ALL, 403 without). ### Frontend (8 commits) - `255eeb6` — `PersonTypeahead`: added `autofocus?: boolean` prop forwarded to the text `<input>`. - `77db791` — `WhoWhenSection`: exposed `dateIso = $bindable('')`, reordered grid (Datum+Absender in row 1, Empfänger+Ort in row 2), conditional `autofocus` on first empty required field. - `765d282` — `DescriptionSection`: exposed `currentTitle = $bindable('')`, reordered fields (Titel → Schlagworte → Kurzinhalt → [Optional divider] → Aufbewahrungsort). - `44c7adb` — `enrich/[id]/+page.server.ts`: added `depends('app:document')` so post-upload `invalidate()` triggers a data reload. - `3893a10` — `layout.css`: added `@keyframes slide` for the indeterminate upload animation. - `14cfe8c` — New `UploadZone.svelte` component: idle/uploading/error states, drag-and-drop, client-side MIME + 50 MB validation, `min-h-[44px]` touch targets, `aria-live="polite"`, `bg-pdf-bg`. 8 Vitest tests. - `93fc869` — `countRequiredFilled()` utility + 8 Vitest tests covering all field-filled combinations. - `836d30e` — `enrich/[id]/+page.svelte` wired up: progress bar (`role="progressbar"` ARIA, `requiredFilled` derived from bound `currentTitle`/`dateIso`/`senderId`), conditional left panel (UploadZone vs DocumentViewer), upload state machine with `AbortController` + `invalidate('app:document')`, "Datei ersetzen" ghost toolbar above PDF. ### Test results - Backend: **1068 tests, 0 failures** - Frontend: **989 tests, 0 failures** (1 pre-existing flaky Chromium test in TagInput unrelated to this issue) ### All acceptance criteria satisfied - ✅ WhoWhenSection: Datum + Absender in row 1, Empfänger + Ort in row 2 - ✅ DescriptionSection: Titel → Schlagworte → Kurzinhalt → [Optional divider] → Aufbewahrungsort - ✅ Optional divider renders between Kurzinhalt and Aufbewahrungsort - ✅ First empty required field receives autofocus on page load - ✅ Progress bar tracks completion of 3 required fields with `x / 3` label - ✅ Progress bar transitions smoothly (`transition-all duration-300`) - ✅ Upload zone shown when `doc.filePath` is null (dark background using `bg-pdf-bg`) - ✅ `doc.originalFilename` displayed in upload zone - ✅ Dragging activates teal/mint border highlight - ✅ Dropping/selecting a file triggers upload with indeterminate animation - ✅ After successful upload, panel transitions to PDF viewer (`invalidate('app:document')`) - ✅ "Datei ersetzen" appears as ghost button in toolbar above PDF (not in form scroll) - ✅ Form stays editable during upload (independent state machine)
Sign in to join this conversation.
No Label feature ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#261