Files
familienarchiv/docs/specs/enrichment-list-block-spec.html
Marcel d9b7b7aad4
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m38s
CI / OCR Service Tests (push) Successful in 39s
CI / Backend Unit Tests (push) Failing after 3m2s
docs(specs): add enrichment list-block dashboard spec
Design spec for a dashboard widget that surfaces documents
needing metadata after batch upload. Placed between Resume
strip and MissionControlStrip rather than as a 4th strip
column (strip at visual capacity; batch reality makes count
tiles useless for seniors).

Covers responsive behavior at 320/768/1440, row anatomy with
72/64px touch targets, state matrix (empty/loading/error/
after-upload), full a11y contract, dark-mode verification
notes, and an impl-ref table with exact Tailwind classes.

Refs #296

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:02:51 +02:00

578 lines
32 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Ergänzungs-Liste — Dashboard-Block · Spec (Issue t.b.d.)</title>
<style>
:root{
--navy:#002850;--mint:#A6DAD8;--sand:#E4E2D7;
--surface:#FAFAF7;--bg:#E8E7E2;--border:#D8D7D0;
--text:#1C1C18;--muted:#6B6A63;--subtle:#9B9A93;
--orange:#C26A00;--orange-bg:#FEF4E2;
--green:#2E6E39;--green-bg:#EAF5EA;
--red:#A83232;--red-bg:#FCEBEB;
--font:system-ui,sans-serif;--mono:'Courier New',monospace;
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14px;line-height:1.6;}
.doc{max-width:1100px;margin:0 auto;padding:48px 32px 96px;}
hr{border:none;border-top:1px solid var(--border);margin:48px 0;}
/* Header */
.hdr{background:var(--navy);color:#fff;padding:32px 32px 28px;border-radius:8px 8px 0 0;}
.hdr h1{font-family:Georgia,serif;font-size:26px;font-weight:400;letter-spacing:-.02em;margin-bottom:8px;}
.hdr-meta{font-family:var(--mono);font-size:11px;color:rgba(255,255,255,.45);margin-top:10px;}
.badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:4px;font-size:10px;font-weight:600;letter-spacing:.05em;background:var(--mint);color:var(--navy);}
.badge-g{background:rgba(255,255,255,.15);color:rgba(255,255,255,.9);}
.badges{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}
.decision-box{background:#fff;border:1px solid var(--border);border-top:none;border-radius:0 0 6px 6px;padding:20px 28px 24px;margin-bottom:40px;}
.decision-box h2{font-family:Georgia,serif;font-size:16px;font-weight:400;color:var(--navy);margin-bottom:8px;}
.prose{font-size:13px;color:var(--muted);line-height:1.65;max-width:720px;margin-bottom:10px;}
.prose:last-child{margin-bottom:0;}
.prose strong{color:var(--text);}
/* Sections */
.sec{margin-bottom:52px;}
.sec-label{font-size:10px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);padding-bottom:8px;border-bottom:1px solid var(--border);margin-bottom:22px;}
.sec-title{font-family:Georgia,serif;font-size:20px;font-weight:400;color:var(--navy);margin-bottom:4px;}
.sec-sub{font-size:13px;color:var(--muted);margin-bottom:20px;max-width:740px;}
/* Callout */
.callout{display:flex;gap:12px;padding:14px 16px;border-radius:4px;margin-bottom:16px;font-size:12px;line-height:1.55;}
.callout.orange{background:var(--orange-bg);border-left:3px solid var(--orange);}
.callout.green{background:var(--green-bg);border-left:3px solid var(--green);}
.callout.navy{background:rgba(0,40,80,.05);border-left:3px solid var(--navy);}
.callout.red{background:var(--red-bg);border-left:3px solid var(--red);}
.callout strong{font-weight:700;}
/* Mock frame wrappers — scaled visual mockups (~55% of real) */
.frames-row{display:flex;gap:32px;flex-wrap:wrap;align-items:flex-start;margin-bottom:20px;}
.frame-caption{font-family:var(--mono);font-size:10px;color:var(--muted);display:block;margin-top:8px;max-width:100%;}
/* ========== MOBILE MOCK (176px wide = 320 × .55) ========== */
.m-phone{width:176px;background:#fff;border:6px solid #2A2A2A;border-radius:22px;overflow:hidden;box-shadow:0 6px 20px rgba(0,0,0,.15);}
.m-status{height:11px;background:#000;}
.m-body{background:var(--surface);padding:7px;}
/* Eyebrow */
.mb-eyebrow{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;padding:0 2px;}
.mb-eyebrow-l{font-size:6px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);}
.mb-eyebrow-count{background:var(--navy);color:#fff;font-size:6px;font-weight:700;padding:1px 5px;border-radius:8px;}
.mb-block{background:#fff;border:1px solid var(--sand);border-radius:3px;overflow:hidden;}
.mb-row{display:flex;align-items:center;padding:6px 7px;gap:5px;border-bottom:1px solid var(--sand);}
.mb-row:last-child{border-bottom:none;}
.mb-icon{width:14px;height:14px;background:rgba(0,40,80,.06);border-radius:2px;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;color:var(--navy);flex-shrink:0;}
.mb-icon.pdf{background:rgba(168,50,50,.12);color:var(--red);}
.mb-icon.jpg{background:rgba(46,110,57,.12);color:var(--green);}
.mb-main{flex:1;min-width:0;}
.mb-title{font-family:Georgia,serif;font-size:7px;color:var(--navy);line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.mb-meta{font-size:5.5px;color:var(--muted);margin-top:1px;}
.mb-chev{color:var(--subtle);font-size:8px;flex-shrink:0;}
.mb-footer{padding:5px 7px;border-top:1px solid var(--sand);background:rgba(0,40,80,.02);text-align:center;}
.mb-footer-link{font-size:6px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--navy);}
/* ========== DESKTOP MOCK (640px wide = ~1160 content-col × .55) ========== */
.d-frame{width:640px;background:var(--surface);border:1px solid var(--border);border-radius:6px;padding:14px;}
.d-eyebrow{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;}
.d-eyebrow-l{font-size:9px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:var(--muted);}
.d-eyebrow-count{background:var(--navy);color:#fff;font-size:9px;font-weight:700;padding:2px 8px;border-radius:10px;}
.d-block{background:#fff;border:1px solid var(--sand);border-radius:4px;overflow:hidden;}
.d-row{display:flex;align-items:center;padding:9px 14px;gap:10px;border-bottom:1px solid var(--sand);cursor:pointer;}
.d-row:last-child{border-bottom:none;}
.d-row:hover{background:rgba(166,218,216,.08);}
.d-row.focused{background:rgba(166,218,216,.08);box-shadow:inset 0 0 0 2px var(--navy);}
.d-icon{width:20px;height:20px;border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:7px;font-weight:700;flex-shrink:0;}
.d-icon.pdf{background:rgba(168,50,50,.12);color:var(--red);}
.d-icon.jpg{background:rgba(46,110,57,.12);color:var(--green);}
.d-icon.tif{background:rgba(91,94,166,.12);color:#5B5EA6;}
.d-main{flex:1;min-width:0;}
.d-title{font-family:Georgia,serif;font-size:12px;color:var(--navy);line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.d-meta{font-size:9px;color:var(--muted);margin-top:2px;}
.d-chev{color:var(--subtle);font-size:14px;flex-shrink:0;}
.d-footer{padding:8px 14px;border-top:1px solid var(--sand);background:rgba(0,40,80,.02);display:flex;justify-content:flex-end;}
.d-footer-link{font-size:9px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--navy);}
/* Empty / not shown state */
.empty-note{background:rgba(46,110,57,.06);border:1px dashed rgba(46,110,57,.25);border-radius:4px;padding:14px 16px;font-size:11px;color:var(--green);text-align:center;font-style:italic;}
/* After-upload banner */
.banner{background:var(--navy);color:#fff;border-radius:4px;padding:10px 14px;display:flex;align-items:center;gap:10px;width:640px;margin-bottom:12px;}
.banner-check{width:18px;height:18px;border-radius:50%;background:var(--mint);color:var(--navy);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;flex-shrink:0;}
.banner-t{font-size:11px;flex:1;}
.banner-t strong{color:var(--mint);}
.banner-cta{background:var(--mint);color:var(--navy);font-size:9px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;padding:4px 10px;border-radius:3px;}
.banner-x{color:rgba(255,255,255,.5);font-size:14px;cursor:pointer;padding:0 4px;}
/* Impl-ref table */
.impl-ref{background:#fff;border:1px solid var(--border);border-radius:6px;overflow:hidden;margin-top:20px;}
.impl-ref table{width:100%;border-collapse:collapse;}
.impl-ref th{background:rgba(0,40,80,.04);font-size:9px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);padding:8px 12px;text-align:left;border-bottom:1px solid var(--border);}
.impl-ref td{font-size:11px;color:var(--text);padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top;line-height:1.55;}
.impl-ref tr:last-child td{border-bottom:none;}
.impl-ref td:first-child{font-weight:700;color:var(--navy);white-space:nowrap;}
.impl-ref code{font-family:var(--mono);font-size:10px;background:rgba(0,40,80,.06);padding:1px 4px;border-radius:2px;color:var(--navy);}
.impl-ref td.px{font-family:var(--mono);font-size:10px;color:var(--muted);white-space:nowrap;}
.impl-ref td.note{font-size:10px;color:var(--muted);font-style:italic;}
/* A11y box */
.a11y-box{background:rgba(91,94,166,.06);border-left:3px solid #5B5EA6;border-radius:0 4px 4px 0;padding:14px 18px;}
.a11y-box h4{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#5B5EA6;margin-bottom:8px;}
.a11y-box ul{list-style:none;padding-left:0;}
.a11y-box li{font-size:12px;color:var(--text);padding-left:18px;position:relative;margin-bottom:6px;line-height:1.55;}
.a11y-box li::before{content:'✓';position:absolute;left:0;color:#5B5EA6;font-weight:700;}
.a11y-box code{font-family:var(--mono);font-size:10px;background:rgba(91,94,166,.1);padding:1px 4px;border-radius:2px;}
/* Two-col grid */
.two-col{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:20px;}
.two-col h3{font-size:12px;font-weight:700;color:var(--navy);margin-bottom:6px;letter-spacing:.02em;}
.two-col p{font-size:12px;color:var(--muted);line-height:1.55;}
/* Inline code */
code.inline{font-family:var(--mono);font-size:11px;background:rgba(0,40,80,.06);padding:1px 5px;border-radius:3px;color:var(--navy);}
</style>
</head>
<body>
<div class="doc">
<!-- ===== HEADER ===== -->
<div class="hdr">
<div class="badges">
<span class="badge">DASHBOARD</span>
<span class="badge badge-g">NEW BLOCK</span>
<span class="badge badge-g">MOBILE-FIRST</span>
</div>
<h1>Ergänzungs-Liste — Dashboard-Block</h1>
<div class="hdr-meta">Familienarchiv · 2026-04-20 · Leonie Voss, UX Lead</div>
</div>
<div class="decision-box">
<h2>The call</h2>
<p class="prose">A dedicated list-block on the dashboard, placed <strong>between the Resume strip and the MissionControlStrip</strong>, surfaces the documents that were just uploaded and still need metadata. It replaces the orphaned <code class="inline">DashboardNeedsMetadata.svelte</code> component with a spec'd, senior-friendly row design.</p>
<p class="prose"><strong>Why here, not a 4th column in MissionControlStrip:</strong> (1) strip is already at 3-column visual capacity; (2) batch-upload reality (1015 docs arriving at once) makes a count tile useless — seniors need to see <em>which</em> docs are waiting; (3) placing it directly under the DropZone sightline makes upload→enrich the most visually-coupled pair on the page.</p>
<p class="prose"><strong>Scope of this spec:</strong> the block itself (layout, row anatomy, empty/loading/error states, a11y, responsive behavior at 320/768/1440). The post-upload success banner is included as a coupled interaction. The strip redesign, pagination, and the other three pipeline stages (segment / transcribe / review) are <em>not</em> in scope — they earn their own specs.</p>
</div>
<!-- ===== SECTION 1 — ANATOMY ===== -->
<div class="sec">
<div class="sec-label">01 — Block anatomy</div>
<h2 class="sec-title">Three pieces: eyebrow, list, footer</h2>
<p class="sec-sub">The block is a single <code class="inline">&lt;section&gt;</code> landmark with an eyebrow heading, a divided list of up to 5 rows, and an optional footer link when more than 5 docs are pending. At <code class="inline">incompleteDocs.length === 0</code> the block renders <strong>nothing</strong> — no "all clear" state, no empty card. A clean dashboard means no work to do.</p>
<div class="frames-row">
<div>
<div class="m-phone">
<div class="m-status"></div>
<div class="m-body">
<div class="mb-eyebrow">
<span class="mb-eyebrow-l">Benötigen Metadaten</span>
<span class="mb-eyebrow-count">12</span>
</div>
<div class="mb-block">
<div class="mb-row">
<div class="mb-icon pdf">PDF</div>
<div class="mb-main">
<div class="mb-title">Brief Oma Hilde 1962</div>
<div class="mb-meta">vor 2 Min.</div>
</div>
<div class="mb-chev"></div>
</div>
<div class="mb-row">
<div class="mb-icon pdf">PDF</div>
<div class="mb-main">
<div class="mb-title">Geburtsurkunde Opa</div>
<div class="mb-meta">vor 2 Min.</div>
</div>
<div class="mb-chev"></div>
</div>
<div class="mb-row">
<div class="mb-icon jpg">JPG</div>
<div class="mb-main">
<div class="mb-title">Foto Hochzeit Tante Elsa</div>
<div class="mb-meta">vor 2 Min.</div>
</div>
<div class="mb-chev"></div>
</div>
<div class="mb-row">
<div class="mb-icon pdf">PDF</div>
<div class="mb-main">
<div class="mb-title">Postkarte Bodensee</div>
<div class="mb-meta">vor 2 Min.</div>
</div>
<div class="mb-chev"></div>
</div>
<div class="mb-row">
<div class="mb-icon pdf">PDF</div>
<div class="mb-main">
<div class="mb-title">Meldebescheinigung 1971</div>
<div class="mb-meta">vor 3 Min.</div>
</div>
<div class="mb-chev"></div>
</div>
<div class="mb-footer">
<span class="mb-footer-link">Alle 12 anzeigen →</span>
</div>
</div>
</div>
</div>
<span class="frame-caption">320px · 12 docs pending · 5 shown, rest via footer link to /enrich</span>
</div>
<div>
<div class="d-frame">
<div class="d-eyebrow">
<span class="d-eyebrow-l">Benötigen Metadaten</span>
<span class="d-eyebrow-count">12</span>
</div>
<div class="d-block">
<div class="d-row">
<div class="d-icon pdf">PDF</div>
<div class="d-main">
<div class="d-title">Brief Oma Hilde 1962 — An meinen lieben Ludwig</div>
<div class="d-meta">vor 2 Minuten hochgeladen · 3 Seiten</div>
</div>
<div class="d-chev"></div>
</div>
<div class="d-row focused">
<div class="d-icon pdf">PDF</div>
<div class="d-main">
<div class="d-title">Geburtsurkunde Opa Friedrich</div>
<div class="d-meta">vor 2 Minuten hochgeladen · 1 Seite</div>
</div>
<div class="d-chev"></div>
</div>
<div class="d-row">
<div class="d-icon jpg">JPG</div>
<div class="d-main">
<div class="d-title">Foto Hochzeit Tante Elsa — Standesamt Ulm</div>
<div class="d-meta">vor 2 Minuten hochgeladen</div>
</div>
<div class="d-chev"></div>
</div>
<div class="d-row">
<div class="d-icon pdf">PDF</div>
<div class="d-main">
<div class="d-title">Postkarte Bodensee 1958</div>
<div class="d-meta">vor 2 Minuten hochgeladen · 2 Seiten</div>
</div>
<div class="d-chev"></div>
</div>
<div class="d-row">
<div class="d-icon tif">TIF</div>
<div class="d-main">
<div class="d-title">Meldebescheinigung Stadt Tübingen 1971</div>
<div class="d-meta">vor 3 Minuten hochgeladen · 1 Seite</div>
</div>
<div class="d-chev"></div>
</div>
<div class="d-footer">
<span class="d-footer-link">Alle 12 anzeigen →</span>
</div>
</div>
</div>
<span class="frame-caption">1440px · main content column (768960px target) · 12 docs pending · row 2 shows keyboard focus state</span>
</div>
</div>
<div class="callout navy">
<div><strong>Row cap = 5.</strong> When pending docs exceed 5, the block shows the 5 most-recently-uploaded and a footer link to <code class="inline">/enrich</code>. This caps block height so the dashboard stays navigable, and reinforces <code class="inline">/enrich</code> as the canonical "long queue" page.</div>
</div>
</div>
<!-- ===== SECTION 2 — ROW ANATOMY ===== -->
<div class="sec">
<div class="sec-label">02 — Row anatomy</div>
<h2 class="sec-title">Icon · title · upload time · chevron</h2>
<p class="sec-sub">The entire row is one <code class="inline">&lt;a href="/enrich/{id}"&gt;</code>. No nested interactive elements — one tap, one destination. Touch target: <strong>72px minimum row height on mobile, 64px on desktop</strong>. Both exceed the 48px WCAG 2.2 AA floor for the senior audience.</p>
<div class="two-col">
<div>
<h3>Left: file-type badge (20×20px)</h3>
<p>Visual differentiation between PDF / JPG / PNG / TIF. Not decorative — gives seniors a scannable category cue without reading. Uses redundant color + text ("PDF", "JPG") so it passes WCAG 1.4.1.</p>
</div>
<div>
<h3>Center: title + upload time</h3>
<p>Title in <code class="inline">font-serif text-base</code> (16px mobile) / <code class="inline">text-lg</code> (18px desktop). Truncated with <code class="inline">text-ellipsis</code> on narrow viewports. Relative time ("vor 2 Min.") in <code class="inline">font-sans text-xs text-ink-2</code>.</p>
</div>
</div>
<div class="two-col">
<div>
<h3>Right: chevron (decorative)</h3>
<p>Navigation affordance. <code class="inline">aria-hidden="true"</code>. Becomes slightly more prominent on hover (<code class="inline">opacity-30 → 70</code>).</p>
</div>
<div>
<h3>States: hover, focus-visible, active</h3>
<p>Hover: <code class="inline">bg-brand-mint/10</code> wash. Focus-visible: <code class="inline">ring-2 ring-brand-navy ring-offset-2</code> <em>inside</em> the row (visible against row bg). Active: <code class="inline">bg-brand-mint/20</code>.</p>
</div>
</div>
<div class="callout orange">
<div><strong>DTO extension needed.</strong> Current <code class="inline">IncompleteDocumentDTO</code> only carries <code class="inline">id</code> and <code class="inline">title</code>. To render "vor 2 Min." and the file-type badge, add <code class="inline">uploadedAt: Instant</code> and <code class="inline">mimeType: String</code>. Small, cheap backend change. Without these fields the block still works (skip meta line, use a generic doc icon) but the senior-facing UX is meaningfully worse.</div>
</div>
</div>
<!-- ===== SECTION 3 — STATES ===== -->
<div class="sec">
<div class="sec-label">03 — States</div>
<h2 class="sec-title">Empty · loading · error · after-upload</h2>
<div class="two-col" style="margin-bottom:24px;">
<div>
<h3>Empty (<code class="inline">length === 0</code>)</h3>
<p>Render <code class="inline">null</code>. The block disappears entirely. Seniors don't need a "nothing to do" card — absence is clear.</p>
<div class="empty-note" style="margin-top:10px;">(block not rendered)</div>
</div>
<div>
<h3>Loading</h3>
<p>On initial dashboard SSR this data comes in with the page load — no spinner needed. If you later add client-side refresh, use a skeleton (3 rows of gray blocks at 72px height, <code class="inline">animate-pulse</code>, respects <code class="inline">prefers-reduced-motion</code>).</p>
</div>
</div>
<div class="two-col">
<div>
<h3>Error</h3>
<p>If the <code class="inline">/api/documents/incomplete</code> call fails, render the block in a muted error state: eyebrow reads "Liste konnte nicht geladen werden", with a retry link. Do not suppress — the user needs to know their queue may be out of date.</p>
</div>
<div>
<h3>After-upload (immediately after DropZone fires)</h3>
<p>Transient success banner above the block. Auto-dismiss after 8s, <strong>with</strong> a manual close X. Content: "12 Dokumente hochgeladen" + "Jetzt ergänzen →" CTA → scrolls/focuses the list block. Use <code class="inline">aria-live="polite"</code> so screen readers announce the count.</p>
</div>
</div>
<!-- Banner mockup -->
<div style="margin-top:24px;">
<div class="banner">
<div class="banner-check"></div>
<div class="banner-t"><strong>12 Dokumente hochgeladen.</strong> Jetzt ergänzen, damit sie durchsuchbar werden.</div>
<span class="banner-cta">Ergänzen →</span>
<span class="banner-x">×</span>
</div>
<span class="frame-caption">After-upload success banner · renders above the list block · auto-dismiss 8s · manual dismiss always available</span>
</div>
<div class="callout green" style="margin-top:16px;">
<div><strong>No auto-redirect.</strong> After a batch of 12 uploads, dumping the user into <code class="inline">/enrich/{firstId}</code> is disorienting. The banner gives them the moment without taking control away. The list block becomes the persistent landing spot for every return visit.</div>
</div>
</div>
<!-- ===== SECTION 4 — RESPONSIVE ===== -->
<div class="sec">
<div class="sec-label">04 — Responsive behavior</div>
<h2 class="sec-title">Mobile-first, three breakpoints</h2>
<div class="impl-ref">
<table>
<thead>
<tr><th style="width:90px;">Breakpoint</th><th>Row height</th><th>Title size</th><th>Meta visibility</th><th>Padding</th></tr>
</thead>
<tbody>
<tr>
<td>320px</td>
<td class="px">72px</td>
<td class="px">text-base (16px)</td>
<td>Relative time only</td>
<td class="px">px-3 py-3</td>
</tr>
<tr>
<td>768px</td>
<td class="px">72px</td>
<td class="px">text-lg (18px)</td>
<td>Time + page count</td>
<td class="px">px-5 py-4</td>
</tr>
<tr>
<td>1440px</td>
<td class="px">64px</td>
<td class="px">text-lg (18px)</td>
<td>Time + page count</td>
<td class="px">px-6 py-4</td>
</tr>
</tbody>
</table>
</div>
<div class="callout navy" style="margin-top:16px;">
<div><strong>Block width follows the main content column.</strong> On the dashboard grid this is <code class="inline">1fr</code> minus the DropZone sidebar — not the full viewport. At 1440px total width, the block lands around 900960px wide.</div>
</div>
</div>
<!-- ===== SECTION 5 — IMPL REF ===== -->
<div class="sec">
<div class="sec-label">05 — Implementation reference</div>
<h2 class="sec-title">Exact Tailwind classes & pixel values</h2>
<p class="sec-sub">Rewire and extend <code class="inline">frontend/src/lib/components/DashboardNeedsMetadata.svelte</code>. The component already exists and is correctly typed — the changes are: new row anatomy, file-type icon, relative time, 5-item cap with footer, a11y landmark, and focus/hover states.</p>
<div class="impl-ref">
<table>
<thead>
<tr><th style="width:170px;">Region</th><th>Tailwind</th><th>Pixel values</th><th class="note">Notes</th></tr>
</thead>
<tbody>
<tr>
<td>Section wrapper</td>
<td><code>&lt;section aria-labelledby="enrich-heading" class="mb-6"&gt;</code></td>
<td class="px">margin-bottom 24px</td>
<td class="note">Landmark for SR nav</td>
</tr>
<tr>
<td>Eyebrow row</td>
<td><code>flex items-center justify-between mb-3 px-1</code></td>
<td class="px">mb 12px</td>
<td class="note">Title + count badge</td>
</tr>
<tr>
<td>Eyebrow heading</td>
<td><code>text-xs font-bold uppercase tracking-widest text-gray-500</code></td>
<td class="px">12px / 700</td>
<td class="note">Not gray-400 — must pass AA on sand bg</td>
</tr>
<tr>
<td>Count badge</td>
<td><code>bg-brand-navy text-white text-xs font-bold px-2 py-0.5 rounded-full</code></td>
<td class="px">12px / 700</td>
<td class="note">Use <code>aria-live="polite"</code></td>
</tr>
<tr>
<td>List container</td>
<td><code>bg-white border border-line rounded-sm shadow-sm overflow-hidden</code></td>
<td class="px">border 1px, radius 2px</td>
<td class="note">Matches card pattern</td>
</tr>
<tr>
<td>List element</td>
<td><code>&lt;ol class="divide-y divide-line-2"&gt;</code></td>
<td class="px">divider 1px</td>
<td class="note">Ordered — upload time is the order</td>
</tr>
<tr>
<td>Row link</td>
<td><code>group flex items-center gap-3 px-3 py-3 md:px-5 md:py-4 min-h-[72px] lg:min-h-[64px] hover:bg-brand-mint/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-inset transition-colors</code></td>
<td class="px">min-height 72px mobile / 64px desktop</td>
<td class="note">Whole row is the &lt;a&gt;</td>
</tr>
<tr>
<td>File-type badge</td>
<td><code>shrink-0 w-5 h-5 md:w-6 md:h-6 rounded-sm flex items-center justify-center text-[10px] font-bold</code></td>
<td class="px">20×20px mobile / 24×24 desktop</td>
<td class="note">Color per mime: red/PDF, green/JPG&#x7c;PNG, purple/TIF</td>
</tr>
<tr>
<td>Title</td>
<td><code>font-serif text-base md:text-lg text-ink group-hover:underline truncate</code></td>
<td class="px">16px mobile / 18px desktop</td>
<td class="note">Single-line truncate</td>
</tr>
<tr>
<td>Meta line</td>
<td><code>font-sans text-xs text-ink-2 mt-0.5</code></td>
<td class="px">12px</td>
<td class="note">"vor N Min./Std." + optional "· N Seiten"</td>
</tr>
<tr>
<td>Chevron</td>
<td><code>shrink-0 w-5 h-5 opacity-30 group-hover:opacity-70 transition-opacity</code></td>
<td class="px">20×20px</td>
<td class="note">Use <code>aria-hidden="true"</code></td>
</tr>
<tr>
<td>Footer (when &gt;5)</td>
<td><code>border-t border-line bg-brand-sand/20 px-5 py-3 flex justify-end</code></td>
<td class="px">py 12px</td>
<td class="note">Only render when <code>length &gt; 5</code></td>
</tr>
<tr>
<td>Footer link</td>
<td><code>font-sans text-xs font-bold uppercase tracking-widest text-brand-navy hover:underline</code></td>
<td class="px">12px / 700</td>
<td class="note">"Alle {n} anzeigen →"</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- ===== SECTION 6 — ACCESSIBILITY ===== -->
<div class="sec">
<div class="sec-label">06 — Accessibility (WCAG 2.2 AA)</div>
<h2 class="sec-title">What axe-playwright must confirm</h2>
<div class="a11y-box">
<h4>A11y contract</h4>
<ul>
<li><strong>Landmark:</strong> <code>&lt;section aria-labelledby="enrich-heading"&gt;</code> with an <code>&lt;h2 id="enrich-heading"&gt;</code>. SR users reach the block via landmark nav.</li>
<li><strong>Ordered list:</strong> <code>&lt;ol&gt;</code>, not <code>&lt;div&gt;</code>. Upload time <em>is</em> the implicit order.</li>
<li><strong>Count live region:</strong> count badge gets <code>aria-live="polite"</code> so SR announces "12 Dokumente benötigen Metadaten" when the number changes after upload.</li>
<li><strong>Touch target:</strong> 72px row height on mobile ≫ WCAG 2.2 floor of 48px. The whole row is tappable, not just the title.</li>
<li><strong>Focus visibility:</strong> <code>focus-visible:ring-2 ring-brand-navy ring-inset</code>. Outer ring would clip; inner ring is always visible against hover/active bg.</li>
<li><strong>File-type cue:</strong> color <em>plus</em> text ("PDF", "JPG") — passes 1.4.1 (not color alone).</li>
<li><strong>Contrast:</strong> title <code>text-ink</code> on white = 14.5:1 (AAA). Meta <code>text-ink-2</code> on white — must verify ≥4.5:1 in <em>both</em> light and dark mode.</li>
<li><strong>Reduced motion:</strong> skeleton pulse and hover transitions respect <code>prefers-reduced-motion</code> (transition-duration 0.01ms).</li>
<li><strong>Banner:</strong> <code>role="status" aria-live="polite"</code>; manual dismiss button labeled <code>aria-label="Benachrichtigung schließen"</code>.</li>
</ul>
</div>
</div>
<!-- ===== SECTION 7 — DARK MODE ===== -->
<div class="sec">
<div class="sec-label">07 — Dark mode</div>
<h2 class="sec-title">Works via semantic tokens, verify contrast separately</h2>
<p class="sec-sub">All colors come from existing tokens (<code class="inline">bg-white</code>, <code class="inline">text-ink</code>, <code class="inline">text-ink-2</code>, <code class="inline">border-line</code>, <code class="inline">bg-surface</code>). No hard-coded hex values. Dark mode inherits the remapped tokens.</p>
<div class="callout orange">
<div><strong>Verify in dark mode:</strong> the file-type badges use mime-specific colors (red/green/purple) against white—in dark mode those same colors sit on a near-black background. Test each badge at its dark-mode contrast ratio; some may need lightness adjustment (e.g. <code class="inline">dark:text-red-300</code> instead of <code class="inline">text-red-600</code>). Run axe-playwright in both themes per project convention.</div>
</div>
</div>
<!-- ===== SECTION 8 — OUT OF SCOPE ===== -->
<div class="sec">
<div class="sec-label">08 — Explicitly out of scope</div>
<h2 class="sec-title">What this spec does NOT cover</h2>
<div class="callout navy">
<div>
<strong>Future work (separate specs):</strong>
<ul style="margin-top:8px;padding-left:20px;">
<li>List-block treatment for the <em>other</em> pipeline stages (segment, transcribe, review). If this pattern works, they likely graduate out of MissionControlStrip too.</li>
<li>Bulk-enrich ("same sender for all", "apply these 3 tags to all") — a natural follow-up for batch-upload reality.</li>
<li>Sort/filter on <code class="inline">/enrich</code> (by upload date, by file type, by uploader).</li>
<li>Strip redesign once a 4th+ pipeline stage forces the issue.</li>
<li>Thumbnails in rows (deferred — depends on backend preview generation).</li>
</ul>
</div>
</div>
</div>
<!-- ===== TEST PLAN ===== -->
<div class="sec">
<div class="sec-label">09 — Test plan</div>
<h2 class="sec-title">What QA (Sara) will verify</h2>
<div class="impl-ref">
<table>
<thead><tr><th style="width:170px;">Test</th><th>What it verifies</th></tr></thead>
<tbody>
<tr><td>Vitest: component renders</td><td>With 0 docs, renders nothing. With 15, no footer. With 6+, footer link shows "Alle {n} anzeigen".</td></tr>
<tr><td>Vitest: count badge</td><td>Badge reflects <code>incompleteDocs.length</code>, not capped list length.</td></tr>
<tr><td>Playwright: axe, light mode</td><td>Dashboard passes a11y with block populated. Focus ring visible on keyboard nav through rows.</td></tr>
<tr><td>Playwright: axe, dark mode</td><td>Same as above with <code>[data-theme="dark"]</code>. File-type badge contrast ratios verified.</td></tr>
<tr><td>Playwright: 320/768/1440 screenshots</td><td>Block renders at all three breakpoints without overflow or truncation beyond title.</td></tr>
<tr><td>Playwright: upload → banner → click CTA</td><td>After DropZone fires with 3+ files: banner appears, list block populates, CTA scrolls/focuses list. Banner auto-dismisses at 8s. Manual X dismisses immediately.</td></tr>
<tr><td>Playwright: keyboard flow</td><td>Tab from DropZone reaches banner (if present), then list rows in order, then footer link. Enter on row navigates to <code>/enrich/{id}</code>.</td></tr>
</tbody>
</table>
</div>
</div>
<hr>
<p style="font-family:var(--mono);font-size:10px;color:var(--muted);text-align:center;">
Leonie Voss · UX Lead · Familienarchiv · 2026-04-20
</p>
</div>
</body>
</html>