Files
familienarchiv/docs/specs/sort-inline-final-spec.html
Marcel e6f12e6d90
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
docs(design): add sort integration specs for issue #180
Exploration spec (sort-integration-spec.html) covers 4 placement variants
with comparison matrix. Final spec (sort-inline-final-spec.html) locks in
Variant A (inline sort in search bar row) with full desktop/mobile states,
dropdown interaction anatomy, loading/empty states, and backend wiring checklist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 12:09:00 +02:00

1053 lines
57 KiB
HTML
Raw Permalink 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>Sort — Inline Variant A · Final Design Spec · Familienarchiv #180</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5}
.doc{max-width:1400px;margin:0 auto;padding:48px 32px}
/* ── Masthead ─── */
.mast{background:#0D2240;border-radius:10px;padding:32px 40px;margin-bottom:48px}
.mast-top{display:flex;align-items:flex-start;justify-content:space-between;gap:24px;margin-bottom:16px}
.mast h1{font-size:22px;font-weight:900;color:#fff;letter-spacing:-.4px;margin-bottom:6px}
.mast p{font-size:12px;color:rgba(255,255,255,.5);max-width:640px;line-height:1.7}
.mast-badge{font-size:9px;font-weight:800;padding:3px 9px;border-radius:20px;text-transform:uppercase;letter-spacing:.8px;white-space:nowrap;flex-shrink:0;margin-top:4px}
.mb-final{background:#A6DAD8;color:#002850}
.decisions{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:20px;border-top:1px solid rgba(255,255,255,.1);padding-top:16px}
.dec{background:rgba(255,255,255,.06);border-radius:6px;padding:10px 12px}
.dec-label{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:rgba(255,255,255,.35);margin-bottom:5px}
.dec-value{font-size:9.5px;font-weight:700;color:#fff;line-height:1.5}
/* ── Section headings ─── */
.sec{margin-bottom:64px}
.sec+.sec{border-top:2px dashed #C8C4BE;padding-top:56px}
.sec-h{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#888;margin-bottom:20px;display:flex;align-items:center;gap:10px}
.sec-h::after{content:'';flex:1;height:1px;background:#D8D4CE}
.sec-num{background:#0D2240;color:#fff;font-size:9px;font-weight:900;padding:2px 7px;border-radius:10px}
/* ── Grid ─── */
.sg{display:grid;gap:20px;align-items:start}
.sg-2{grid-template-columns:1fr 1fr}
.sg-3{grid-template-columns:1fr 1fr 1fr}
.sg-4{grid-template-columns:1fr 1fr 1fr 1fr}
.sg-mob{grid-template-columns:1fr 220px}
.sb{display:flex;flex-direction:column}
.sl{font-size:9px;font-weight:800;color:#888;text-transform:uppercase;letter-spacing:1.5px;margin-bottom:6px;display:flex;align-items:center;gap:6px}
.sc{font-size:10px;color:#888;margin-top:8px;font-style:italic;line-height:1.5}
/* ── Browser chrome ─── */
.wf{background:#fff;border:2px solid #B8B4AE;border-radius:10px;overflow:visible;box-shadow:0 4px 18px rgba(0,0,0,.08);max-width:860px}
.wf-inner{border-radius:10px;overflow:hidden}
.wf-bar{height:24px;background:#E8E4DF;border-bottom:1px solid #C8C4BE;display:flex;align-items:center;padding:0 9px;gap:4px}
.dot{width:7px;height:7px;border-radius:50%;background:#C8C4BE}
.dot.r{background:#F87171}.dot.y{background:#FCD34D}.dot.g{background:#4ADE80}
.urlbar{flex:1;height:11px;background:#D8D4CE;border-radius:3px;margin-left:6px;display:flex;align-items:center;padding:0 5px}
.urlbar span{font-size:7.5px;color:#888;font-family:monospace}
.N{height:40px;background:#0D2240;display:flex;align-items:center;padding:0 16px;gap:12px;flex-shrink:0}
.logo{font-size:9px;font-weight:900;color:#fff;letter-spacing:1px}
.nl{font-size:7.5px;color:rgba(255,255,255,.4);font-weight:700;text-transform:uppercase;letter-spacing:.5px}
.nl.on{color:#fff;border-bottom:2px solid #A6DAD8;padding-bottom:2px}
.nr{margin-left:auto;display:flex;gap:7px;align-items:center}
.nico{width:20px;height:20px;background:rgba(255,255,255,.1);border-radius:4px}
.MAIN{padding:14px 18px;background:#ECEAE4}
/* ── Search card ─── */
.SCARD{background:#fff;border:1.5px solid #E0DDD6;border-radius:3px;padding:11px 14px;box-shadow:0 1px 3px rgba(0,0,0,.06);margin-bottom:10px}
.SROW1{display:flex;align-items:center;gap:6px}
.SINPUT{flex:1;height:24px;border:1.5px solid #D1D5DB;border-radius:2px;display:flex;align-items:center;padding:0 8px;font-size:8.5px;color:#9CA3AF;font-style:italic;gap:5px}
.SINPUT.has-value{color:#1A1A1A;font-style:normal}
.SINPUT.focused{border-color:#002850;box-shadow:0 0 0 2px rgba(0,40,80,.07)}
.SICON{font-size:9px;opacity:.35;flex-shrink:0}
.SPINNER{width:9px;height:9px;border:1.5px solid #E0DDD6;border-top-color:#002850;border-radius:50%;flex-shrink:0;animation:spin .7s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
/* ── Sort trigger button ─── */
.SBTN-SORT{height:24px;border:1.5px solid #D1D5DB;border-radius:2px;display:flex;align-items:center;padding:0 8px;font-size:8px;font-weight:700;color:#666;gap:4px;cursor:pointer;white-space:nowrap;background:#F7F5F2;flex-shrink:0}
.SBTN-SORT .sort-prefix{font-size:7px;font-weight:400;color:#AAA}
.SBTN-SORT .sort-chev{font-size:7px;opacity:.5;margin-left:1px}
/* States */
.SBTN-SORT.hover{background:#ECEAE4;color:#444}
.SBTN-SORT.active{border-color:#002850;color:#002850;background:#fff}
.SBTN-SORT.active .sort-prefix{color:rgba(0,40,80,.5)}
.SBTN-SORT.active .sort-chev{opacity:.8}
.SBTN-SORT.focus{border-color:#002850;box-shadow:0 0 0 2px rgba(0,40,80,.12)}
/* ── Filter + Reset buttons ─── */
.SBTN-FILTER{height:24px;border:1.5px solid #D1D5DB;border-radius:2px;display:flex;align-items:center;padding:0 9px;font-size:8px;font-weight:700;color:#555;gap:4px;cursor:pointer;white-space:nowrap;background:#F7F5F2;flex-shrink:0}
.SBTN-FILTER.open{border-color:#002850;color:#002850;background:#fff}
.SBTN-RESET{height:24px;border:1.5px solid transparent;border-radius:2px;display:flex;align-items:center;justify-content:center;padding:0 6px;font-size:9px;color:#9CA3AF;cursor:pointer;flex-shrink:0}
.SBTN-RESET.hover{color:#DC2626}
/* ── Sort dropdown ─── */
.SDROP-WRAP{position:relative;flex-shrink:0}
.SDROP{position:absolute;top:calc(100% + 2px);left:0;min-width:175px;background:#fff;border:1.5px solid #002850;border-radius:3px;box-shadow:0 6px 16px rgba(0,0,0,.12);z-index:50}
.SDROP-HDR{font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#AAA;padding:5px 9px 3px;border-bottom:1px solid #F0EDE8}
.SDROP-ROW{display:flex;align-items:center;padding:5px 9px;border-bottom:1px solid #F0EDE8;cursor:pointer;gap:6px;min-height:24px}
.SDROP-ROW:last-child{border-bottom:none}
.SDROP-ROW.hover{background:#F7F5F2}
.SDROP-ROW.active{background:#EFF6FF}
.SDROP-LABEL{flex:1;font-size:8px;font-weight:600;color:#1A1A1A}
.SDROP-ROW.active .SDROP-LABEL{color:#002850;font-weight:700}
.SDROP-DIRS{display:flex;gap:2px;flex-shrink:0}
.SDIR{font-size:7px;font-weight:800;padding:1px 5px;border-radius:2px;border:1px solid #E0DDD6;color:#AAA;cursor:pointer}
.SDIR.hover{border-color:#888;color:#555}
.SDIR.active{background:#002850;color:#fff;border-color:#002850}
/* ── Result header ─── */
.RESHEAD{display:flex;align-items:center;gap:6px;padding:0 2px;margin-bottom:8px}
.RESCOUNT{font-size:8px;font-weight:600;color:#666;flex:1}
.RESCOUNT strong{color:#002850;font-weight:800}
.RESLIVE{font-size:7.5px;color:#AAA;font-style:italic}
/* ── Document list ─── */
.DOCROW{background:#fff;border:1.5px solid #E0DDD6;border-radius:3px;padding:8px 12px;margin-bottom:5px;display:flex;align-items:flex-start;gap:8px;cursor:pointer}
.DOCROW:hover{border-color:#A6DAD8}
.THUMB{width:22px;height:28px;background:#F0EDE8;border:1px solid #E0DDD6;border-radius:2px;display:flex;align-items:center;justify-content:center;font-size:6.5px;color:#AAA;flex-shrink:0}
.DOCBODY{flex:1;min-width:0}
.DOCTITLE{font-size:8.5px;font-weight:700;color:#0D2240;margin-bottom:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.DOCMETA{font-size:7.5px;color:#888;display:flex;align-items:center;gap:5px;flex-wrap:wrap}
.METASEP{color:#D1CCC8}
.DOCTAG{background:#F0EDE8;border-radius:20px;padding:1px 6px;font-size:6.5px;font-weight:700;color:#555}
.PH{height:6px;background:#E8E4DF;border-radius:2px}
.w90{width:90%}.w80{width:80%}.w70{width:70%}.w60{width:60%}.w50{width:50%}.w40{width:40%}.w30{width:30%}
/* ── State pill labels ─── */
.state-label{display:inline-block;font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;border-radius:3px;padding:1px 5px;margin-bottom:5px}
.st-default{background:#E8E4DF;color:#555}
.st-hover{background:#DBEAFE;color:#1D4ED8}
.st-focus{background:#EDE9FE;color:#6D28D9}
.st-active{background:#DCFCE7;color:#166534}
.st-loading{background:#FEF9C3;color:#854D0E}
.st-open{background:#FEE2E2;color:#991B1B}
/* ── Mobile chrome ─── */
.WF-M{background:#fff;border:2px solid #B8B4AE;border-radius:16px;overflow:hidden;box-shadow:0 4px 18px rgba(0,0,0,.08);width:215px}
.WF-M-STATUS{height:16px;background:#0D2240;display:flex;align-items:center;justify-content:space-between;padding:0 10px}
.WF-M-TIME{font-size:6px;color:#fff;font-weight:700}
.WF-M-ICONS{display:flex;gap:3px}
.WF-M-ICON{width:5px;height:5px;background:rgba(255,255,255,.5);border-radius:1px}
.N-M{height:34px;background:#0D2240;display:flex;align-items:center;padding:0 10px;justify-content:space-between}
.MAIN-SM{padding:8px 10px;background:#ECEAE4;display:flex;flex-direction:column;gap:7px}
.SCARD-SM{background:#fff;border:1.5px solid #E0DDD6;border-radius:3px;padding:8px 10px}
.SROW1-SM{display:flex;gap:4px;margin-bottom:5px}
.SROW2-SM{display:flex;gap:4px}
.SBTN-SORT-SM{height:20px;border:1.5px solid #D1D5DB;border-radius:2px;display:flex;align-items:center;justify-content:center;padding:0 8px;font-size:7.5px;font-weight:700;color:#666;gap:3px;cursor:pointer;flex:1;white-space:nowrap;background:#F7F5F2}
.SBTN-SORT-SM.active{border-color:#002850;color:#002850;background:#fff}
.SBTN-FILTER-SM{height:20px;border:1.5px solid #D1D5DB;border-radius:2px;display:flex;align-items:center;justify-content:center;padding:0 8px;font-size:7.5px;font-weight:700;color:#555;gap:3px;cursor:pointer;flex:1;white-space:nowrap;background:#F7F5F2}
.SBTN-RESET-SM{height:20px;border:1.5px solid transparent;display:flex;align-items:center;padding:0 5px;font-size:9px;color:#9CA3AF;flex-shrink:0}
/* ── Annotation callouts ─── */
.ann-block{background:#FFF7ED;border:1px solid #FDBA74;border-radius:5px;padding:10px 14px;font-size:10.5px;color:#7C2D12;line-height:1.6;margin-top:14px}
.ann-block strong{font-weight:800}
.ann-block ul{padding-left:16px;display:flex;flex-direction:column;gap:3px;margin-top:6px}
.ann-info{background:#EFF6FF;border:1px solid #BFDBFE;border-radius:5px;padding:10px 14px;font-size:10.5px;color:#1E3A5F;line-height:1.6;margin-top:14px}
.ann-info strong{font-weight:800}
/* ── Spec disclaimer ─── */
.spec-disclaimer{background:#FFF8E1;border:1.5px solid #FFC107;border-radius:6px;padding:11px 16px;font-size:11px;color:#6D4C00;margin-bottom:32px;line-height:1.6}
.spec-disclaimer strong{font-weight:800}
/* ── impl-ref ─── */
.impl-ref{background:#0d1117;border-radius:8px;margin-top:20px;overflow:hidden;border:1px solid #30363d}
.impl-ref-hdr{background:#161b22;padding:9px 16px;font-size:9.5px;font-weight:800;color:#f0883e;border-bottom:1px solid #30363d;display:flex;align-items:center;gap:8px;letter-spacing:.4px;text-transform:uppercase}
.impl-ref-hdr::before{content:'⚙';font-size:12px}
.impl-ref-hdr span{color:rgba(240,136,62,.55);font-weight:400;margin-left:auto;font-size:9px;text-transform:none;letter-spacing:0}
.impl-ref table{width:100%;border-collapse:collapse;font-size:10px}
.impl-ref th{text-align:left;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#8b949e;padding:8px 14px;border-bottom:1px solid #21262d}
.impl-ref td{padding:6px 14px;border-bottom:1px solid #161b22;vertical-align:top;line-height:1.6;color:#c9d1d9}
.impl-ref tr:last-child td{border-bottom:none}
.impl-ref td:first-child{color:#79c0ff;font-weight:700;white-space:nowrap;width:210px}
.impl-ref td code{font-family:'SFMono-Regular',Consolas,monospace;font-size:9.5px;background:#161b22;color:#a5d6ff;padding:1px 5px;border-radius:3px;white-space:nowrap}
.impl-ref .ir-px{color:#7ee787;font-family:monospace;font-size:9.5px}
</style>
</head>
<body>
<div class="doc">
<!-- ══════════════════════════════════════
MASTHEAD
══════════════════════════════════════ -->
<div class="mast">
<div class="mast-top">
<div>
<h1>Sort — Inline Variant · Final Design Spec</h1>
<p>Sort dropdown sits inline in the search bar row, between the text input and the Filter button. Always visible, always labelled. Selected from 4 exploration variants (see <code style="font-size:10px;color:#A6DAD8">sort-integration-spec.html</code>). Addresses Issue #180 Problem 1.</p>
</div>
<span class="mast-badge mb-final">Final · Ready for implementation</span>
</div>
<div class="decisions">
<div class="dec">
<div class="dec-label">Issue</div>
<div class="dec-value">#180 — Sort &amp; Search UX</div>
</div>
<div class="dec">
<div class="dec-label">Sort options (6)</div>
<div class="dec-value">Datum · Titel · Absender · Empfänger · Tag · Hochgeladen</div>
</div>
<div class="dec">
<div class="dec-label">Default</div>
<div class="dec-value">Datum ↓ (newest first) — no URL param needed for default</div>
</div>
<div class="dec">
<div class="dec-label">New URL params</div>
<div class="dec-value"><code style="font-size:8px;color:#A6DAD8">?sort=date&amp;dir=desc</code> — omitted when default</div>
</div>
</div>
</div>
<!-- ── spec disclaimer ── -->
<div class="spec-disclaimer">
<strong>📐 Mockup scale notice —</strong> all font-size, height, and padding values
in the mockup CSS are scaled to ~55% of actual implementation values.
<strong>Do not copy sizes from mockup CSS.</strong> Use the ⚙ Implementation
Reference tables after each section.
</div>
<!-- ══════════════════════════════════════
SECTION 1 — ANATOMY AT REST (DESKTOP)
══════════════════════════════════════ -->
<div class="sec">
<div class="sec-h">
<span class="sec-num">1</span>
Anatomy at rest — desktop
</div>
<div class="sg sg-2" style="gap:24px;align-items:start">
<!-- State: default (q empty, default sort) -->
<div class="sb">
<span class="state-label st-default">Default — no active search, default sort</span>
<div class="wf">
<div class="wf-inner">
<div class="wf-bar">
<div class="dot r"></div><div class="dot y"></div><div class="dot g"></div>
<div class="urlbar"><span>localhost:3000/</span></div>
</div>
<div class="N">
<span class="logo">FAMILIENARCHIV</span>
<span class="nl on">Dokumente</span>
<span class="nl">Personen</span>
<div class="nr"><div class="nico"></div></div>
</div>
<div class="MAIN">
<div class="SCARD">
<div class="SROW1">
<div class="SINPUT">
<span class="SICON"></span>
<span style="font-size:8px;color:#9CA3AF;font-style:italic">Titel, Personen, Tags durchsuchen …</span>
</div>
<!-- Sort button: default state, label shows default -->
<div class="SBTN-SORT">
<span class="sort-prefix">Sortieren:</span>
Datum ↓
<span class="sort-chev"></span>
</div>
<div class="SBTN-FILTER">⊞ Filter <span style="font-size:7px;opacity:.5"></span></div>
<div class="SBTN-RESET"></div>
</div>
</div>
<!-- Dashboard content placeholder -->
<div style="background:#fff;border:1.5px solid #E0DDD6;border-radius:3px;padding:14px;font-size:8px;color:#AAA;text-align:center">Dashboard widgets appear here when no search is active</div>
</div>
</div>
</div>
<div class="sc">On the dashboard (no active search) the sort button is still visible and operable. Sorting here has no effect until a search is triggered — the button communicates the "ready" state.</div>
</div>
<!-- State: active search, non-default sort -->
<div class="sb">
<span class="state-label st-active">Active — search running, custom sort set</span>
<div class="wf">
<div class="wf-inner">
<div class="wf-bar">
<div class="dot r"></div><div class="dot y"></div><div class="dot g"></div>
<div class="urlbar"><span>localhost:3000/?q=raddatz&amp;sort=sender&amp;dir=asc</span></div>
</div>
<div class="N">
<span class="logo">FAMILIENARCHIV</span>
<span class="nl on">Dokumente</span>
<span class="nl">Personen</span>
<div class="nr"><div class="nico"></div></div>
</div>
<div class="MAIN">
<div class="SCARD">
<div class="SROW1">
<div class="SINPUT has-value">
<span class="SICON"></span>
<span style="font-size:8px">raddatz</span>
</div>
<!-- Sort button: active state (non-default sort) -->
<div class="SBTN-SORT active">
<span class="sort-prefix">Sortieren:</span>
Absender ↑
<span class="sort-chev"></span>
</div>
<div class="SBTN-FILTER">⊞ Filter <span style="font-size:7px;opacity:.5"></span></div>
<div class="SBTN-RESET"></div>
</div>
</div>
<div class="RESHEAD">
<span class="RESCOUNT"><strong>8 Dokumente</strong> gefunden</span>
</div>
<div class="DOCROW">
<div class="THUMB">PDF</div>
<div class="DOCBODY">
<div class="DOCTITLE">Brief an Tante Klara, Weihnachten 1954</div>
<div class="DOCMETA"><span>Ernst Raddatz</span><span class="METASEP">·</span><span>15. Dez 1954</span><span class="METASEP">·</span><span class="DOCTAG">Briefe</span></div>
</div>
</div>
<div class="DOCROW">
<div class="THUMB">PDF</div>
<div class="DOCBODY">
<div class="DOCTITLE">Urlaubspostkarte Schwarzwald 1962</div>
<div class="DOCMETA"><span>Hildegard Raddatz</span><span class="METASEP">·</span><span>8. Aug 1962</span><span class="METASEP">·</span><span class="DOCTAG">Karten</span></div>
</div>
</div>
</div>
</div>
</div>
<div class="sc">Active sort changes the button border and text color to navy. The label updates to the selected field + direction arrow. Reset button (✕) clears both search and sort, returning to the dashboard.</div>
</div>
</div>
<div class="impl-ref">
<div class="impl-ref-hdr">Implementation Reference — §1 Search bar anatomy
<span>Real values · mockup above is ~55% scale · do not copy mockup CSS</span>
</div>
<table>
<thead><tr><th>Element</th><th>Tailwind classes</th><th>Real size</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td>Search card container</td>
<td><code>bg-surface border border-line rounded-sm p-4 shadow-sm mb-2</code></td>
<td><span class="ir-px">p 16px</span></td>
<td>Existing <code>SearchFilterBar.svelte</code> outer wrapper — no change needed</td>
</tr>
<tr>
<td>Row 1 flex container</td>
<td><code>flex items-center gap-3</code></td>
<td></td>
<td>Existing — insert sort button between <code>SINPUT</code> and filter button</td>
</tr>
<tr>
<td>Search input</td>
<td><code>flex-1 h-10 flex items-center gap-2 px-3 border border-line rounded-sm text-base placeholder-ink-3 focus-visible:ring-2 focus-visible:ring-focus-ring focus:outline-none</code></td>
<td><span class="ir-px">h 40px, text 16px</span></td>
<td>Placeholder: <em>"Titel, Personen, Tags durchsuchen …"</em>. Addresses #180 Problem 2 discoverability.</td>
</tr>
<tr>
<td>Sort trigger button — default</td>
<td><code>h-10 shrink-0 flex items-center gap-1.5 px-3 border border-line rounded-sm bg-muted text-sm font-bold text-ink-2 hover:bg-surface hover:text-ink transition whitespace-nowrap cursor-pointer</code></td>
<td><span class="ir-px">h 40px, px 12px, text 14px / 700</span></td>
<td>Shows "Sortieren: Datum ↓". "Sortieren:" prefix: <code>text-xs font-normal text-ink-3 mr-0.5</code></td>
</tr>
<tr>
<td>Sort trigger button — active</td>
<td>Same + override: <code>border-brand-navy text-brand-navy bg-surface</code></td>
<td></td>
<td>Active when sort ≠ default (date/desc). "Sortieren:" prefix color: <code>text-brand-navy/50</code></td>
</tr>
<tr>
<td>Sort trigger button — focus</td>
<td>Add: <code>focus-visible:ring-2 focus-visible:ring-focus-ring focus:outline-none</code></td>
<td></td>
<td>Never remove focus ring. Tab order: input → sort → filter → reset</td>
</tr>
<tr>
<td>Filter button (unchanged)</td>
<td><code>h-10 shrink-0 flex items-center gap-2 px-3 border border-line rounded-sm bg-muted text-sm font-bold text-ink-2 uppercase tracking-wide hover:text-ink transition</code></td>
<td><span class="ir-px">h 40px</span></td>
<td>Existing — no change</td>
</tr>
<tr>
<td>Reset button (unchanged)</td>
<td><code>h-10 shrink-0 flex items-center justify-center px-2 border border-transparent text-ink-3 hover:text-red-500 transition</code></td>
<td><span class="ir-px">h 40px, min-w 40px</span></td>
<td>Existing — no change</td>
</tr>
<tr>
<td>Result count line</td>
<td><code>text-sm font-medium text-ink-2 mb-3</code><code>&lt;strong class="text-brand-navy font-bold"&gt;</code></td>
<td><span class="ir-px">14px / 500</span></td>
<td><code>aria-live="polite"</code>. Only rendered when <code>!isDashboard</code>.</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- ══════════════════════════════════════
SECTION 2 — DROPDOWN OPEN STATES
══════════════════════════════════════ -->
<div class="sec">
<div class="sec-h">
<span class="sec-num">2</span>
Sort dropdown — open states &amp; interaction anatomy
</div>
<div class="sg sg-3" style="gap:20px;align-items:start">
<!-- Dropdown: default sort active -->
<div class="sb">
<span class="state-label st-default">Open — default sort (Datum ↓)</span>
<div style="background:#ECEAE4;padding:10px;border-radius:6px">
<!-- Trigger -->
<div class="SBTN-SORT active" style="display:inline-flex;margin-bottom:2px">
<span class="sort-prefix">Sortieren:</span>
Datum ↓
<span class="sort-chev" style="transform:rotate(180deg);display:inline-block"></span>
</div>
<!-- Dropdown -->
<div class="SDROP" style="position:static;width:175px">
<div class="SDROP-HDR">Sortieren nach</div>
<div class="SDROP-ROW active">
<span class="SDROP-LABEL">Datum</span>
<div class="SDROP-DIRS">
<span class="SDIR"></span>
<span class="SDIR active"></span>
</div>
</div>
<div class="SDROP-ROW">
<span class="SDROP-LABEL">Titel</span>
<div class="SDROP-DIRS"><span class="SDIR"></span><span class="SDIR"></span></div>
</div>
<div class="SDROP-ROW">
<span class="SDROP-LABEL">Absender</span>
<div class="SDROP-DIRS"><span class="SDIR"></span><span class="SDIR"></span></div>
</div>
<div class="SDROP-ROW">
<span class="SDROP-LABEL">Empfänger</span>
<div class="SDROP-DIRS"><span class="SDIR"></span><span class="SDIR"></span></div>
</div>
<div class="SDROP-ROW">
<span class="SDROP-LABEL">Tag</span>
<div class="SDROP-DIRS"><span class="SDIR"></span><span class="SDIR"></span></div>
</div>
<div class="SDROP-ROW">
<span class="SDROP-LABEL">Hochgeladen</span>
<div class="SDROP-DIRS"><span class="SDIR"></span><span class="SDIR"></span></div>
</div>
</div>
</div>
<div class="sc">Active option row has light blue tint. The currently selected direction arrow is navy-filled. Inactive rows show both ↑ ↓ with no fill.</div>
</div>
<!-- Dropdown: hovering a non-active row -->
<div class="sb">
<span class="state-label st-hover">Hover — "Absender" row, ↑ direction hovered</span>
<div style="background:#ECEAE4;padding:10px;border-radius:6px">
<div class="SBTN-SORT active" style="display:inline-flex;margin-bottom:2px">
<span class="sort-prefix">Sortieren:</span>
Datum ↓
<span class="sort-chev" style="transform:rotate(180deg);display:inline-block"></span>
</div>
<div class="SDROP" style="position:static;width:175px">
<div class="SDROP-HDR">Sortieren nach</div>
<div class="SDROP-ROW active">
<span class="SDROP-LABEL">Datum</span>
<div class="SDROP-DIRS"><span class="SDIR"></span><span class="SDIR active"></span></div>
</div>
<div class="SDROP-ROW">
<span class="SDROP-LABEL">Titel</span>
<div class="SDROP-DIRS"><span class="SDIR"></span><span class="SDIR"></span></div>
</div>
<!-- Hovered row -->
<div class="SDROP-ROW hover">
<span class="SDROP-LABEL" style="color:#002850">Absender</span>
<div class="SDROP-DIRS">
<span class="SDIR hover"></span>
<span class="SDIR"></span>
</div>
</div>
<div class="SDROP-ROW">
<span class="SDROP-LABEL">Empfänger</span>
<div class="SDROP-DIRS"><span class="SDIR"></span><span class="SDIR"></span></div>
</div>
<div class="SDROP-ROW">
<span class="SDROP-LABEL">Tag</span>
<div class="SDROP-DIRS"><span class="SDIR"></span><span class="SDIR"></span></div>
</div>
<div class="SDROP-ROW">
<span class="SDROP-LABEL">Hochgeladen</span>
<div class="SDROP-DIRS"><span class="SDIR"></span><span class="SDIR"></span></div>
</div>
</div>
</div>
<div class="sc">Hovering a row highlights the whole row in muted gray. Hovering a specific direction arrow highlights that arrow with a border. Clicking either closes the dropdown and triggers a search.</div>
</div>
<!-- Dropdown: new sort selected (Absender ↑) -->
<div class="sb">
<span class="state-label st-active">After selection — "Absender ↑" now active</span>
<div style="background:#ECEAE4;padding:10px;border-radius:6px">
<div class="SBTN-SORT active" style="display:inline-flex;margin-bottom:2px">
<span class="sort-prefix">Sortieren:</span>
Absender ↑
<span class="sort-chev"></span>
</div>
<div class="SDROP" style="position:static;width:175px">
<div class="SDROP-HDR">Sortieren nach</div>
<div class="SDROP-ROW">
<span class="SDROP-LABEL">Datum</span>
<div class="SDROP-DIRS"><span class="SDIR"></span><span class="SDIR"></span></div>
</div>
<div class="SDROP-ROW">
<span class="SDROP-LABEL">Titel</span>
<div class="SDROP-DIRS"><span class="SDIR"></span><span class="SDIR"></span></div>
</div>
<div class="SDROP-ROW active">
<span class="SDROP-LABEL">Absender</span>
<div class="SDROP-DIRS">
<span class="SDIR active"></span>
<span class="SDIR"></span>
</div>
</div>
<div class="SDROP-ROW">
<span class="SDROP-LABEL">Empfänger</span>
<div class="SDROP-DIRS"><span class="SDIR"></span><span class="SDIR"></span></div>
</div>
<div class="SDROP-ROW">
<span class="SDROP-LABEL">Tag</span>
<div class="SDROP-DIRS"><span class="SDIR"></span><span class="SDIR"></span></div>
</div>
<div class="SDROP-ROW">
<span class="SDROP-LABEL">Hochgeladen</span>
<div class="SDROP-DIRS"><span class="SDIR"></span><span class="SDIR"></span></div>
</div>
</div>
</div>
<div class="sc">After selection: trigger button label updates instantly. Dropdown typically closes after a direction is chosen. The user can re-open to change direction without re-selecting the field.</div>
</div>
</div>
<div class="ann-info" style="margin-top:0">
<strong>Interaction model:</strong> Clicking a <em>field label</em> selects that field and retains the last-used direction for that field (defaults to ↓ if no prior selection). Clicking a <em>direction arrow</em> selects that field + direction and closes the dropdown. This means two distinct affordances — quick tap on a label re-sorts with same direction, precise tap on an arrow picks direction explicitly.
</div>
<div class="impl-ref">
<div class="impl-ref-hdr">Implementation Reference — §2 Sort dropdown
<span>Real values · mockup above is ~55% scale</span>
</div>
<table>
<thead><tr><th>Element</th><th>Tailwind classes</th><th>Real size</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td>Dropdown container</td>
<td><code>absolute top-[calc(100%+4px)] left-0 z-50 min-w-[200px] bg-surface border border-brand-navy rounded-sm shadow-[0_6px_20px_rgba(0,0,0,.12)]</code></td>
<td><span class="ir-px">min-w 200px</span></td>
<td>Use Svelte's <code>use:clickOutside</code> action to close. Also close on <kbd>Escape</kbd>. Position: <code>position: relative</code> on <code>.SDROP-WRAP</code>.</td>
</tr>
<tr>
<td>Dropdown header</td>
<td><code>text-xs font-bold uppercase tracking-widest text-ink-3 px-3 py-2 border-b border-line</code></td>
<td><span class="ir-px">12px / 700</span></td>
<td>"SORTIEREN NACH" — orientation label for screen readers + seniors</td>
</tr>
<tr>
<td>Option row — default</td>
<td><code>flex items-center px-3 py-2.5 gap-4 border-b border-line last:border-b-0 cursor-pointer hover:bg-muted</code></td>
<td><span class="ir-px">h ~44px, py 10px</span></td>
<td>Touch target exactly 44px. Most commonly undersized element — do not reduce py.</td>
</tr>
<tr>
<td>Option row — active</td>
<td>Same + <code>bg-blue-50</code></td>
<td></td>
<td><code>aria-selected="true"</code> on the active row. Role: <code>role="option"</code> on each row, <code>role="listbox"</code> on dropdown.</td>
</tr>
<tr>
<td>Option label</td>
<td><code>flex-1 text-sm font-semibold text-ink</code></td>
<td><span class="ir-px">14px / 600</span></td>
<td>Active row: <code>text-brand-navy font-bold</code></td>
</tr>
<tr>
<td>Direction toggle group</td>
<td><code>flex gap-1 shrink-0</code></td>
<td></td>
<td><code>role="group"</code> with <code>aria-label="Richtung"</code></td>
</tr>
<tr>
<td>Direction button — inactive</td>
<td><code>text-xs font-bold px-2 py-1 rounded-sm border border-line text-ink-3 hover:border-ink-2 hover:text-ink-2 transition min-w-[28px] text-center</code></td>
<td><span class="ir-px">28px min-width, 24px h</span></td>
<td>Labels: "↑" + <code>aria-label="Aufsteigend"</code> / "↓" + <code>aria-label="Absteigend"</code></td>
</tr>
<tr>
<td>Direction button — active</td>
<td>Same + <code>bg-brand-navy text-white border-brand-navy</code></td>
<td></td>
<td><code>aria-pressed="true"</code></td>
</tr>
<tr>
<td>Keyboard navigation</td>
<td></td>
<td></td>
<td><kbd></kbd>/<kbd></kbd> to move between rows. <kbd>Enter</kbd> / <kbd>Space</kbd> to select. <kbd>Escape</kbd> to close without change. <kbd>Tab</kbd> moves through direction arrows within a row. Focus returns to trigger on close.</td>
</tr>
<tr>
<td>Svelte state</td>
<td></td>
<td></td>
<td><code>let sort = $state('date')</code>, <code>let dir = $state('desc')</code>. Add both to <code>triggerSearch()</code> URL params. Bind to new <code>sort</code> and <code>dir</code> props on <code>SearchFilterBar</code>.</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- ══════════════════════════════════════
SECTION 3 — MOBILE LAYOUT (320px)
══════════════════════════════════════ -->
<div class="sec">
<div class="sec-h">
<span class="sec-num">3</span>
Mobile layout — 320px breakpoint
</div>
<div class="sg sg-3" style="gap:20px;align-items:start">
<!-- Mobile: default state -->
<div class="sb">
<span class="state-label st-default">Default — no search</span>
<div class="WF-M">
<div class="WF-M-STATUS">
<span class="WF-M-TIME">9:41</span>
<div class="WF-M-ICONS"><div class="WF-M-ICON"></div><div class="WF-M-ICON"></div><div class="WF-M-ICON"></div></div>
</div>
<div class="N-M"><span class="logo" style="font-size:7px">FAMILIENARCHIV</span><div class="nico"></div></div>
<div class="MAIN-SM">
<div class="SCARD-SM">
<!-- Row 1: input + reset -->
<div class="SROW1-SM">
<div class="SINPUT" style="height:22px;flex:1">
<span class="SICON"></span>
<span style="font-size:7.5px;color:#9CA3AF;font-style:italic">Durchsuchen…</span>
</div>
<div class="SBTN-RESET-SM"></div>
</div>
<!-- Row 2: sort + filter equal-width -->
<div class="SROW2-SM">
<div class="SBTN-SORT-SM">Datum ↓ <span style="font-size:7px;opacity:.5"></span></div>
<div class="SBTN-FILTER-SM">⊞ Filter</div>
</div>
</div>
<div style="background:#fff;border:1.5px solid #E0DDD6;border-radius:3px;padding:10px;font-size:7px;color:#AAA;text-align:center">Dashboard</div>
</div>
</div>
<div class="sc">Row 1: input + reset only. Row 2: sort and filter as equal-width buttons, full-width of the card.</div>
</div>
<!-- Mobile: active search + custom sort -->
<div class="sb">
<span class="state-label st-active">Active — custom sort set</span>
<div class="WF-M">
<div class="WF-M-STATUS">
<span class="WF-M-TIME">9:41</span>
<div class="WF-M-ICONS"><div class="WF-M-ICON"></div><div class="WF-M-ICON"></div><div class="WF-M-ICON"></div></div>
</div>
<div class="N-M"><span class="logo" style="font-size:7px">FAMILIENARCHIV</span><div class="nico"></div></div>
<div class="MAIN-SM">
<div class="SCARD-SM">
<div class="SROW1-SM">
<div class="SINPUT has-value" style="height:22px;flex:1">
<span class="SICON"></span>
<span style="font-size:7.5px">raddatz</span>
</div>
<div class="SBTN-RESET-SM"></div>
</div>
<div class="SROW2-SM">
<!-- Active sort button on mobile -->
<div class="SBTN-SORT-SM active">Absender ↑ <span style="font-size:7px;opacity:.8"></span></div>
<div class="SBTN-FILTER-SM">⊞ Filter</div>
</div>
</div>
<div style="font-size:7px;font-weight:700;color:#555;padding:0 2px">8 Dokumente gefunden</div>
<div class="DOCROW" style="padding:6px 9px">
<div class="THUMB" style="width:18px;height:22px">PDF</div>
<div class="DOCBODY"><div class="PH w80" style="margin-bottom:4px"></div><div class="PH w50"></div></div>
</div>
<div class="DOCROW" style="padding:6px 9px">
<div class="THUMB" style="width:18px;height:22px">PDF</div>
<div class="DOCBODY"><div class="PH w70" style="margin-bottom:4px"></div><div class="PH w40"></div></div>
</div>
</div>
</div>
<div class="sc">Sort button adopts navy border + text when non-default, identical to desktop active state. Button label shortens to field name + direction (no "Sortieren:" prefix).</div>
</div>
<!-- Mobile: sort dropdown open (bottom sheet) -->
<div class="sb">
<span class="state-label st-open">Dropdown open — bottom sheet on mobile</span>
<div class="WF-M" style="position:relative">
<div class="WF-M-STATUS">
<span class="WF-M-TIME">9:41</span>
<div class="WF-M-ICONS"><div class="WF-M-ICON"></div><div class="WF-M-ICON"></div><div class="WF-M-ICON"></div></div>
</div>
<div class="N-M"><span class="logo" style="font-size:7px">FAMILIENARCHIV</span><div class="nico"></div></div>
<div class="MAIN-SM">
<div class="SCARD-SM">
<div class="SROW1-SM">
<div class="SINPUT has-value" style="height:22px;flex:1"><span style="font-size:7.5px">raddatz</span></div>
<div class="SBTN-RESET-SM"></div>
</div>
<div class="SROW2-SM">
<div class="SBTN-SORT-SM active">Absender ↑ <span style="font-size:7px"></span></div>
<div class="SBTN-FILTER-SM">⊞ Filter</div>
</div>
</div>
</div>
<!-- Bottom sheet overlay -->
<div style="position:absolute;bottom:0;left:0;right:0;background:#fff;border-top:2px solid #002850;border-radius:12px 12px 0 0;box-shadow:0 -4px 20px rgba(0,0,0,.15);z-index:100">
<!-- Handle -->
<div style="display:flex;justify-content:center;padding:6px 0 3px">
<div style="width:24px;height:3px;background:#D1D5DB;border-radius:2px"></div>
</div>
<div class="SDROP-HDR" style="padding:4px 10px 4px;font-size:6.5px">Sortieren nach</div>
<div class="SDROP-ROW" style="padding:6px 10px;min-height:26px">
<span class="SDROP-LABEL">Datum</span>
<div class="SDROP-DIRS"><span class="SDIR"></span><span class="SDIR"></span></div>
</div>
<div class="SDROP-ROW" style="padding:6px 10px;min-height:26px">
<span class="SDROP-LABEL">Titel</span>
<div class="SDROP-DIRS"><span class="SDIR"></span><span class="SDIR"></span></div>
</div>
<div class="SDROP-ROW active" style="padding:6px 10px;min-height:26px">
<span class="SDROP-LABEL">Absender</span>
<div class="SDROP-DIRS"><span class="SDIR active"></span><span class="SDIR"></span></div>
</div>
<div class="SDROP-ROW" style="padding:6px 10px;min-height:26px">
<span class="SDROP-LABEL">Empfänger</span>
<div class="SDROP-DIRS"><span class="SDIR"></span><span class="SDIR"></span></div>
</div>
<div class="SDROP-ROW" style="padding:6px 10px;min-height:26px">
<span class="SDROP-LABEL">Tag</span>
<div class="SDROP-DIRS"><span class="SDIR"></span><span class="SDIR"></span></div>
</div>
<div class="SDROP-ROW" style="padding:6px 10px;border-bottom:none;min-height:26px">
<span class="SDROP-LABEL">Hochgeladen</span>
<div class="SDROP-DIRS"><span class="SDIR"></span><span class="SDIR"></span></div>
</div>
</div>
</div>
<div class="sc">On mobile (&lt;768px) the dropdown renders as a bottom sheet with a drag handle and rounded top corners. Overlay taps the backdrop to close. Option rows use generous py for thumb targets.</div>
</div>
</div>
<div class="impl-ref">
<div class="impl-ref-hdr">Implementation Reference — §3 Mobile layout
<span>Real values · mockup above is ~55% scale</span>
</div>
<table>
<thead><tr><th>Element</th><th>Tailwind classes</th><th>Real size</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td>Search card row 1 (mobile)</td>
<td><code>flex items-center gap-2 mb-2</code></td>
<td></td>
<td>Input + reset only. Sort and filter move to row 2. Breakpoint: <code>sm:hidden</code> on row 2 wrapper, show on mobile only.</td>
</tr>
<tr>
<td>Search card row 2 (mobile)</td>
<td><code>flex items-center gap-2 sm:hidden</code></td>
<td></td>
<td>Sort + filter as two equal-width buttons. Desktop keeps sort in row 1 between input and filter.</td>
</tr>
<tr>
<td>Sort button label — mobile</td>
<td></td>
<td></td>
<td>Omit "Sortieren:" prefix (hidden with <code>hidden sm:inline</code>). Show field name + direction only: "Absender ↑"</td>
</tr>
<tr>
<td>Sort button width — mobile</td>
<td><code>flex-1 justify-center min-h-[44px]</code></td>
<td><span class="ir-px">h 44px min, flex-1</span></td>
<td>Equal width with filter button. Most commonly undersized on mobile.</td>
</tr>
<tr>
<td>Bottom sheet wrapper</td>
<td><code>fixed inset-x-0 bottom-0 z-50 bg-surface border-t-2 border-brand-navy rounded-t-2xl shadow-[0_-4px_24px_rgba(0,0,0,.15)] sm:hidden</code></td>
<td></td>
<td>Only on <code>&lt;768px</code>. Desktop: <code>absolute</code> dropdown. Backdrop: <code>fixed inset-0 z-40 bg-black/20</code> behind sheet.</td>
</tr>
<tr>
<td>Bottom sheet drag handle</td>
<td><code>mx-auto mt-2 mb-1 w-10 h-1 rounded-full bg-line</code></td>
<td><span class="ir-px">40px × 4px</span></td>
<td>Decorative — does not need to be draggable. Tap backdrop or select option to close.</td>
</tr>
<tr>
<td>Bottom sheet option rows</td>
<td><code>flex items-center px-4 py-3.5 gap-4 border-b border-line last:border-b-0</code></td>
<td><span class="ir-px">py 14px → row h ~52px</span></td>
<td>Taller than desktop rows for thumb comfort. 52px exceeds 44px minimum — intentional for seniors.</td>
</tr>
<tr>
<td>Direction buttons — mobile</td>
<td><code>text-sm font-bold min-w-[40px] min-h-[40px] flex items-center justify-center rounded-sm border border-line</code></td>
<td><span class="ir-px">40px × 40px min</span></td>
<td>Larger touch target than desktop. Active: <code>bg-brand-navy text-white border-brand-navy</code></td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- ══════════════════════════════════════
SECTION 4 — LOADING & EMPTY STATES
══════════════════════════════════════ -->
<div class="sec">
<div class="sec-h">
<span class="sec-num">4</span>
Loading &amp; empty states
</div>
<div class="sg sg-2" style="gap:24px;align-items:start">
<!-- Loading: search in progress -->
<div class="sb">
<span class="state-label st-loading">Loading — search request in flight</span>
<div class="wf">
<div class="wf-inner">
<div class="wf-bar">
<div class="dot r"></div><div class="dot y"></div><div class="dot g"></div>
<div class="urlbar"><span>localhost:3000/?q=raddatz&amp;sort=sender&amp;dir=asc</span></div>
</div>
<div class="N">
<span class="logo">FAMILIENARCHIV</span>
<span class="nl on">Dokumente</span>
<span class="nl">Personen</span>
<div class="nr"><div class="nico"></div></div>
</div>
<div class="MAIN">
<div class="SCARD">
<div class="SROW1">
<div class="SINPUT has-value">
<!-- Spinner replaces search icon while loading -->
<div class="SPINNER"></div>
<span style="font-size:8px">raddatz</span>
</div>
<div class="SBTN-SORT active">
<span class="sort-prefix">Sortieren:</span>
Absender ↑
<span class="sort-chev"></span>
</div>
<div class="SBTN-FILTER">⊞ Filter <span style="font-size:7px;opacity:.5"></span></div>
<div class="SBTN-RESET"></div>
</div>
</div>
<!-- Loading skeleton -->
<div style="background:#fff;border:1.5px solid #E0DDD6;border-radius:3px;padding:10px 12px;margin-bottom:5px">
<div class="PH w60" style="margin-bottom:6px"></div>
<div class="PH w40"></div>
</div>
<div style="background:#fff;border:1.5px solid #E0DDD6;border-radius:3px;padding:10px 12px;margin-bottom:5px">
<div class="PH w80" style="margin-bottom:6px"></div>
<div class="PH w50"></div>
</div>
<div style="background:#fff;border:1.5px solid #E0DDD6;border-radius:3px;padding:10px 12px;opacity:.6">
<div class="PH w70" style="margin-bottom:6px"></div>
<div class="PH w30"></div>
</div>
</div>
</div>
</div>
<div class="sc">Spinner (animated ring) replaces the search icon inside the input while the request is in-flight. Skeleton rows replace the document list. Sort button remains interactive during loading so users can change sort without waiting.</div>
</div>
<!-- Empty state -->
<div class="sb">
<span class="state-label st-default">Empty state — no results for query</span>
<div class="wf">
<div class="wf-inner">
<div class="wf-bar">
<div class="dot r"></div><div class="dot y"></div><div class="dot g"></div>
<div class="urlbar"><span>localhost:3000/?q=beethoven&amp;sort=date&amp;dir=desc</span></div>
</div>
<div class="N">
<span class="logo">FAMILIENARCHIV</span>
<span class="nl on">Dokumente</span>
<span class="nl">Personen</span>
<div class="nr"><div class="nico"></div></div>
</div>
<div class="MAIN">
<div class="SCARD">
<div class="SROW1">
<div class="SINPUT has-value">
<span class="SICON"></span>
<span style="font-size:8px">beethoven</span>
</div>
<div class="SBTN-SORT" style="color:#9CA3AF;border-color:#E0DDD6">
<span class="sort-prefix">Sortieren:</span>
Datum ↓
<span class="sort-chev"></span>
</div>
<div class="SBTN-FILTER">⊞ Filter <span style="font-size:7px;opacity:.5"></span></div>
<div class="SBTN-RESET"></div>
</div>
</div>
<!-- Empty state message -->
<div style="background:#fff;border:1.5px solid #E0DDD6;border-radius:3px;display:flex;flex-direction:column;align-items:center;padding:32px 20px;text-align:center">
<div style="width:36px;height:36px;background:#F0EDE8;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:16px;margin-bottom:10px">🔍</div>
<div style="font-size:11px;font-weight:800;color:#0D2240;font-family:Georgia,serif;margin-bottom:4px">Keine Dokumente gefunden</div>
<div style="font-size:8.5px;color:#888;max-width:240px;line-height:1.7;margin-bottom:12px">Keine Treffer für <strong>„beethoven"</strong>. Versuche andere Begriffe oder entferne Filter.</div>
<a href="/" style="font-size:8px;font-weight:700;color:#002850;text-decoration:none;border:1.5px solid #002850;border-radius:2px;padding:4px 10px">Suche zurücksetzen</a>
</div>
</div>
</div>
</div>
<div class="sc">Zero-results state shows an icon, a friendly headline, and the search term quoted back. Sort button dims to indicate it has no effect. A single CTA resets the search. Addresses #180 Problem 4 (empty state).</div>
</div>
</div>
<div class="impl-ref">
<div class="impl-ref-hdr">Implementation Reference — §4 Loading &amp; empty states
<span>Real values · mockup above is ~55% scale</span>
</div>
<table>
<thead><tr><th>Element</th><th>Tailwind classes</th><th>Real size</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td>Spinner (inside input)</td>
<td><code>w-4 h-4 rounded-full border-2 border-line border-t-brand-navy shrink-0 animate-spin</code></td>
<td><span class="ir-px">16px × 16px</span></td>
<td>Replaces search icon during <code>isLoading</code> state. <code>aria-label="Suche läuft"</code> on a visually hidden span. Add <code>let isLoading = $state(false)</code> to <code>+page.svelte</code>; set true on <code>goto()</code>, false when <code>data</code> reactive updates.</td>
</tr>
<tr>
<td>Skeleton row</td>
<td><code>bg-surface border border-line rounded-sm px-4 py-3 mb-2 flex flex-col gap-2</code></td>
<td><span class="ir-px">py 12px</span></td>
<td>Two <code>&lt;div class="h-3 rounded bg-muted animate-pulse"&gt;</code> lines (80% and 50% width). 3 skeleton rows sufficient.</td>
</tr>
<tr>
<td>Sort button — no results</td>
<td>Add <code>opacity-40 pointer-events-none</code> when result count is 0</td>
<td></td>
<td>Dimmed to signal no effect. Do not disable entirely — allow user to change sort to try different result order.</td>
</tr>
<tr>
<td>Empty state container</td>
<td><code>bg-surface border border-line rounded-sm py-16 px-6 flex flex-col items-center text-center</code></td>
<td><span class="ir-px">py 64px</span></td>
<td>Only rendered when <code>!isLoading &amp;&amp; documents.length === 0 &amp;&amp; !isDashboard</code></td>
</tr>
<tr>
<td>Empty state icon</td>
<td><code>w-14 h-14 rounded-full bg-muted flex items-center justify-center text-2xl mb-4</code></td>
<td><span class="ir-px">56px × 56px</span></td>
<td>Use a search/magnifier SVG, not an emoji, in production</td>
</tr>
<tr>
<td>Empty state headline</td>
<td><code>font-serif text-xl font-bold text-ink mb-2</code></td>
<td><span class="ir-px">20px / 700</span></td>
<td>"Keine Dokumente gefunden" — most commonly undersized on empty states</td>
</tr>
<tr>
<td>Empty state subtext</td>
<td><code>text-sm text-ink-3 max-w-xs leading-relaxed mb-5</code></td>
<td><span class="ir-px">14px, leading 1.625</span></td>
<td>Quote the search term: <code>„{q}"</code>. Paraglide key: <code>docs_empty_state_text</code></td>
</tr>
<tr>
<td>Empty state CTA</td>
<td><code>text-sm font-bold text-brand-navy border border-brand-navy rounded-sm px-4 py-2 hover:bg-brand-navy hover:text-white transition</code></td>
<td><span class="ir-px">h ~40px</span></td>
<td><code>&lt;a href="/"&gt;</code> — no JS needed. Paraglide key: <code>docs_btn_reset_title</code></td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- ══════════════════════════════════════
SECTION 5 — BACKEND & DATA WIRING
══════════════════════════════════════ -->
<div class="sec">
<div class="sec-h">
<span class="sec-num">5</span>
Backend &amp; data wiring — implementation checklist
</div>
<div class="impl-ref" style="margin-top:0">
<div class="impl-ref-hdr">Implementation Reference — §5 Full-stack wiring
<span>Not a visual section — agent checklist only</span>
</div>
<table>
<thead><tr><th>Layer</th><th>What to change</th><th>Detail</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td>Frontend state</td>
<td>Add <code>sort</code> + <code>dir</code> to <code>+page.svelte</code></td>
<td><code>let sort = $state(untrack(() =&gt; data.filters?.sort || 'date'))</code><br><code>let dir = $state(untrack(() =&gt; data.filters?.dir || 'desc'))</code></td>
<td>Default: date/desc. Omit from URL when default to keep URLs clean.</td>
</tr>
<tr>
<td>Frontend search trigger</td>
<td>Add sort + dir to <code>triggerSearch()</code></td>
<td><code>if (sort !== 'date' || dir !== 'desc') { params.set('sort', sort); params.set('dir', dir); }</code></td>
<td>Only append if non-default — avoids polluting the URL for most users.</td>
</tr>
<tr>
<td>Frontend server load</td>
<td>Read <code>sort</code> + <code>dir</code> in <code>+page.server.ts</code></td>
<td><code>const sort = url.searchParams.get('sort') || 'date';</code><br><code>const dir = url.searchParams.get('dir') || 'desc';</code></td>
<td>Pass to <code>/api/documents/search</code> query params. Add to the returned <code>filters</code> object for sync.</td>
</tr>
<tr>
<td>Frontend sync effect</td>
<td>Sync <code>sort</code> + <code>dir</code> in the <code>$effect</code> that syncs filter state from data</td>
<td><code>sort = data.filters?.sort || 'date';</code><br><code>dir = data.filters?.dir || 'desc';</code></td>
<td>Keeps sort state consistent on browser back/forward navigation.</td>
</tr>
<tr>
<td>SearchFilterBar props</td>
<td>Add <code>sort</code> + <code>dir</code> as bindable props</td>
<td><code>sort = $bindable('date')</code>, <code>dir = $bindable('desc')</code></td>
<td>Pass <code>bind:sort</code> + <code>bind:dir</code> from <code>+page.svelte</code>.</td>
</tr>
<tr>
<td>Backend endpoint</td>
<td>Extend <code>GET /api/documents/search</code></td>
<td>Add query params: <code>sort</code> (string, default "date") and <code>dir</code> (string, default "desc")</td>
<td>Map values: <code>date→documentDate</code>, <code>title→title</code>, <code>sender→sender.lastName</code>, <code>receiver→receivers[0].lastName</code>, <code>tag→tags[0].name</code>, <code>uploaded→createdAt</code></td>
</tr>
<tr>
<td>Backend sort mapping</td>
<td>In <code>DocumentService.search()</code>, add <code>Sort</code> parameter</td>
<td>Build <code>Sort sort = Sort.by(direction, field)</code>. Pass to <code>repository.findAll(spec, pageable)</code> or equivalent query method.</td>
<td>Sender/receiver sort requires a JOIN — use <code>@Query</code> with explicit ORDER BY or a <code>Specification</code> with <code>Join</code>. Tag sort: sort by first tag name alphabetically.</td>
</tr>
<tr>
<td>API type regeneration</td>
<td>Run <code>npm run generate:api</code> after backend changes</td>
<td>Requires backend running with <code>--spring.profiles.active=dev</code></td>
<td>The new <code>sort</code> + <code>dir</code> params must appear in the OpenAPI spec for the typed client to pick them up.</td>
</tr>
<tr>
<td>i18n keys needed</td>
<td>Add to <code>messages/de.json</code>, <code>en.json</code>, <code>es.json</code></td>
<td><code>docs_sort_label</code>, <code>docs_sort_date</code>, <code>docs_sort_title</code>, <code>docs_sort_sender</code>, <code>docs_sort_receiver</code>, <code>docs_sort_tag</code>, <code>docs_sort_uploaded</code>, <code>docs_sort_dir_asc</code>, <code>docs_sort_dir_desc</code>, <code>docs_empty_state_text</code></td>
<td>Also update <code>docs_search_placeholder</code> to "Titel, Personen, Tags durchsuchen …" to address #180 Problem 2.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div><!-- /doc -->
</body>
</html>