# Frontend Optimization Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Fix all code-health issues (dead files, config mismatches, startup script errors) and close prototype fidelity gaps (design tokens, sidebar brand, search wiring, loading states, upload modal) in the T-Systems Regulation Hub frontend.

**Architecture:** No new architectural layers. The CSS design-token system in `globals.css` is the single source of truth for styling; Tailwind dark-mode config is corrected to match it. Dead files are deleted. The upload modal is a new self-contained React component wired into DocsPage and StatusPage. All page logic remains in the page files.

**Tech Stack:** React 19 + TypeScript + Vite 8 + TailwindCSS 4.2 + globals.css CSS-variable system + Lucide React icons. Working directory for all npm commands: `frontend/`.

---

## File Map

**DELETE (confirmed dead — no imports anywhere):**
- `frontend/src/types/theme.ts`
- `frontend/src/components/layout/shell-config.ts`
- `frontend/src/components/ui/Badge.tsx`
- `frontend/src/components/ui/Button.tsx`
- `frontend/src/components/ui/Card.tsx`
- `frontend/src/components/ui/Input.tsx`
- `frontend/src/components/ui/ProgressBar.tsx`
- `frontend/src/components/ui/ScoreBar.tsx`

**MODIFY:**
- `frontend/tailwind.config.js` — fix darkMode strategy
- `frontend/index.html` — fix app title
- `frontend/start.sh` — fix port and stale comments
- `frontend/src/components/ui/index.ts` — remove dead exports
- `frontend/src/styles/globals.css` — update tokens, add modal CSS, add loading spinner
- `frontend/src/components/layout/Sidebar.tsx` — fix brand SVG + text hierarchy
- `frontend/src/pages/Perception/PerceptionPage.tsx` — wire search input
- `frontend/src/pages/Status/StatusPage.tsx` — loading state + refresh + upload button
- `frontend/src/pages/Docs/DocsPage.tsx` — wire upload button to modal

**CREATE:**
- `frontend/src/pages/Docs/UploadModal.tsx` — upload dialog component

---

## Task 1: Fix configs, startup script, and delete dead code

**Files:**
- Delete: `frontend/src/types/theme.ts`
- Delete: `frontend/src/components/layout/shell-config.ts`
- Delete: `frontend/src/components/ui/Badge.tsx`
- Delete: `frontend/src/components/ui/Button.tsx`
- Delete: `frontend/src/components/ui/Card.tsx`
- Delete: `frontend/src/components/ui/Input.tsx`
- Delete: `frontend/src/components/ui/ProgressBar.tsx`
- Delete: `frontend/src/components/ui/ScoreBar.tsx`
- Modify: `frontend/src/components/ui/index.ts`
- Modify: `frontend/tailwind.config.js`
- Modify: `frontend/index.html`
- Modify: `frontend/start.sh`

- [ ] **Step 1: Verify dead files have no imports**

```powershell
# From project root. Each command should print nothing (no matches).
Select-String -Path "frontend\src\**\*.ts","frontend\src\**\*.tsx" -Pattern "types/theme|ThemeColors|ThemeMode|darkTheme|dimTheme|lightTheme" -Recurse
Select-String -Path "frontend\src\**\*.ts","frontend\src\**\*.tsx" -Pattern "shell-config|shellFrameClassName|shellMeta" -Recurse
Select-String -Path "frontend\src\**\*.ts","frontend\src\**\*.tsx" -Pattern "from.*components/ui/(Badge|Button|Card|Input|ProgressBar|ScoreBar)" -Recurse
```

Expected: no output (no matches). If any imports ARE found, update those files to remove the imports before deleting.

- [ ] **Step 2: Delete the dead files**

```powershell
Remove-Item frontend\src\types\theme.ts
Remove-Item frontend\src\components\layout\shell-config.ts
Remove-Item frontend\src\components\ui\Badge.tsx
Remove-Item frontend\src\components\ui\Button.tsx
Remove-Item frontend\src\components\ui\Card.tsx
Remove-Item frontend\src\components\ui\Input.tsx
Remove-Item frontend\src\components\ui\ProgressBar.tsx
Remove-Item frontend\src\components\ui\ScoreBar.tsx
```

- [ ] **Step 3: Clear dead exports from ui/index.ts**

Replace the entire file `frontend/src/components/ui/index.ts` with:

```ts
// UI components — add exports here as new components are created
```

(The file must remain so the `components/ui/` directory stays as a future home for shared components.)

- [ ] **Step 4: Fix tailwind.config.js dark mode**

Current line 7: `darkMode: 'class',`

Replace with:

```js
darkMode: ['selector', '[data-theme="dark"]'],
```

Full updated `frontend/tailwind.config.js`:

```js
/** @type {import('tailwindcss').Config} */
export default {
  content: [
    "./index.html",
    "./src/**/*.{js,ts,jsx,tsx}",
  ],
  darkMode: ['selector', '[data-theme="dark"]'],
  theme: {
    extend: {
      colors: {
        't-bg': 'var(--bg)',
        't-surface': 'var(--surface)',
        't-fg': 'var(--fg)',
        't-muted': 'var(--muted)',
        't-border': 'var(--border)',
        't-accent': '#e20074',
        't-accent-hover': '#c8006a',
        't-success': 'var(--success)',
        't-warn': 'var(--warn)',
        't-danger': 'var(--danger)',
      },
      fontFamily: {
        'display': ['TeleNeoWeb-Bold', 'Inter', 'sans-serif'],
        'body': ['TeleNeoWeb-Regular', 'Inter', 'sans-serif'],
        'mono': ['ui-monospace', 'JetBrains Mono', 'Menlo', 'monospace'],
      },
      boxShadow: {
        'card': 'var(--shadow-card)',
      },
    },
  },
  plugins: [],
}
```

- [ ] **Step 5: Fix index.html title**

In `frontend/index.html` change line 7:

Old: `<title>regulation-rag</title>`
New: `<title>T-Systems Regulation Hub</title>`

Full updated `frontend/index.html`:

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>T-Systems Regulation Hub</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
```

- [ ] **Step 6: Fix start.sh port and comments**

Full replacement for `frontend/start.sh`:

```bash
#!/bin/bash
# Start the Vite dev server.
# Port defaults to FRONTEND_PORT env var, or 5173 if unset (matches vite.config.ts default).
cd "$(dirname "$0")"
PORT="${FRONTEND_PORT:-5173}"
echo "Starting dev server on http://0.0.0.0:${PORT}"
npx vite --host 0.0.0.0 --port "$PORT"
```

- [ ] **Step 7: Verify the build still passes**

```powershell
cd frontend
npm run build
```

Expected: `✓ built in ...` with zero TypeScript errors. If `tsc` complains about missing types from deleted files, track down the import and remove it.

- [ ] **Step 8: Commit**

```bash
git add frontend/tailwind.config.js frontend/index.html frontend/start.sh \
        frontend/src/components/ui/index.ts
git rm frontend/src/types/theme.ts \
       frontend/src/components/layout/shell-config.ts \
       frontend/src/components/ui/Badge.tsx \
       frontend/src/components/ui/Button.tsx \
       frontend/src/components/ui/Card.tsx \
       frontend/src/components/ui/Input.tsx \
       frontend/src/components/ui/ProgressBar.tsx \
       frontend/src/components/ui/ScoreBar.tsx
git commit -m "chore: delete dead code, fix tailwind dark mode, fix title and start.sh port"
```

---

## Task 2: Update design tokens to match prototype

**Files:**
- Modify: `frontend/src/styles/globals.css` (`:root` and `[data-theme="dark"]` blocks only)

The prototype files (`Prototype/cc29bcb0.../index.html`, `upload-modal.html`) define the authoritative token values. Current `globals.css` diverges on sidebar width, border radii, warn color, and dark-mode surface colors.

- [ ] **Step 1: Update the `:root` token block**

Replace the existing `:root { ... }` block (lines 1–40 of globals.css) with:

```css
/* ── Design Tokens ──────────────────────────────── */
:root {
  color-scheme: light;

  --rail-bg: #ffffff;
  --rail-surface: #f7f8fa;
  --rail-fg: #111827;
  --rail-muted: #8b929e;
  --rail-border: #e8eaed;
  --rail-hover: rgba(0,0,0,.04);
  --rail-active: rgba(226,0,116,.07);

  --bg: #f2f4f7;
  --surface: #ffffff;
  --fg: #111111;
  --muted: #6b7280;
  --border: #e5e5e5;
  --border-strong: #d1d5db;

  --accent: #e20074;
  --accent-dim: rgba(226,0,116,.10);
  --accent-hover: #c8006a;
  --success: #17a34a;
  --success-bg: rgba(23,163,74,.08);
  --warn: #eab308;
  --warn-bg: rgba(234,179,8,.08);
  --danger: #dc2626;
  --danger-bg: rgba(220,38,38,.08);
  --info: #2563eb;
  --info-bg: rgba(37,99,235,.08);

  --font-display: "TeleNeoWeb-Bold", "Inter", -apple-system, sans-serif;
  --font-body: "TeleNeoWeb-Regular", "Inter", -apple-system, sans-serif;
  --font-mono: ui-monospace, "JetBrains Mono", Menlo, monospace;

  --sidebar-w: 240px;
  --topbar-h: 54px;
  --radius-sm: 8px;
  --radius-md: 12px;
  --radius-pill: 9999px;
  --shadow-card: 0 2px 8px rgba(0,0,0,.06), 0 0 0 1px rgba(0,0,0,.04);
}
```

- [ ] **Step 2: Update the `[data-theme="dark"]` block and add auto dark mode**

Replace the existing `[data-theme="dark"] { ... }` block (lines 42–57 of globals.css) with:

```css
/* Auto dark mode: matches system preference unless user has explicitly chosen light */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    color-scheme: dark;
    --rail-bg: #17181d;
    --rail-surface: #1d1f26;
    --rail-fg: #f5f7fb;
    --rail-muted: #a2a9b8;
    --rail-border: #2a2d35;
    --rail-hover: rgba(255,255,255,.06);
    --rail-active: rgba(226,0,116,.15);

    --bg: #0f1014;
    --surface: #17181d;
    --fg: #f5f7fb;
    --muted: #a2a9b8;
    --border: #2a2d35;
    --border-strong: #3a3d48;

    --success: #22c55e;
    --success-bg: rgba(34,197,94,.10);
    --warn: #facc15;
    --warn-bg: rgba(250,204,21,.10);
    --danger: #f87171;
    --danger-bg: rgba(248,113,113,.10);
  }
}

/* Explicit dark mode (user toggled) */
[data-theme="dark"] {
  color-scheme: dark;
  --rail-bg: #17181d;
  --rail-surface: #1d1f26;
  --rail-fg: #f5f7fb;
  --rail-muted: #a2a9b8;
  --rail-border: #2a2d35;
  --rail-hover: rgba(255,255,255,.06);
  --rail-active: rgba(226,0,116,.15);

  --bg: #0f1014;
  --surface: #17181d;
  --fg: #f5f7fb;
  --muted: #a2a9b8;
  --border: #2a2d35;
  --border-strong: #3a3d48;

  --success: #22c55e;
  --success-bg: rgba(34,197,94,.10);
  --warn: #facc15;
  --warn-bg: rgba(250,204,21,.10);
  --danger: #f87171;
  --danger-bg: rgba(248,113,113,.10);
}

/* Explicit light mode (overrides auto dark) */
[data-theme="light"] {
  color-scheme: light;
}
```

- [ ] **Step 3: Verify build**

```powershell
cd frontend
npm run build
```

Expected: zero errors.

- [ ] **Step 4: Commit**

```bash
git add frontend/src/styles/globals.css
git commit -m "fix: align design tokens with prototype (radii, warn color, dark mode values)"
```

---

## Task 3: Fix Sidebar brand to match prototype

**Files:**
- Modify: `frontend/src/components/layout/Sidebar.tsx`

The prototype brand section shows: a `26×26px` magenta rounded box containing a bar-chart/regulation SVG icon, then "T-Systems" (bold, display font) as primary label and "Regulation Hub" (mono, small, muted) as sub-label. Current React code has them reversed ("Regulation Hub" bold, "T-Systems AI" small) and uses a text mark "TS" instead of the SVG.

- [ ] **Step 1: Update the brand section in Sidebar.tsx**

Replace the `<div className="sidebar-brand">` block (lines 56–62 in current file) with:

```tsx
<div className="sidebar-brand">
  <div className="brand-mark">
    <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
      <path d="M2 3.5h12v1.5H2zm5.25 1.5h1.5v8h-1.5zm-3 2h2.25v1.5H4.25zm5.25 0h2.25v1.5H9.5zm-3 3h2.5v1.5H6.5z" fill="currentColor"/>
    </svg>
  </div>
  <div className="brand-text">
    <div className="brand-name">T-Systems</div>
    <div className="brand-sub">Regulation Hub</div>
  </div>
</div>
```

The CSS for `.brand-mark`, `.brand-name`, `.brand-sub` already exists in `globals.css` and is correct — `.brand-mark` renders the accent box, `.brand-name` is 13px bold display font, `.brand-sub` is 10px muted. No CSS changes needed.

- [ ] **Step 2: Verify build**

```powershell
cd frontend
npm run build
```

- [ ] **Step 3: Commit**

```bash
git add frontend/src/components/layout/Sidebar.tsx
git commit -m "fix: update sidebar brand to match prototype (SVG icon, T-Systems primary label)"
```

---

## Task 4: Wire Perception page search input

**Files:**
- Modify: `frontend/src/pages/Perception/PerceptionPage.tsx`

The search box renders `<input placeholder="Search signals..." />` with no state wiring (no `value`, no `onChange`). The `filtered` computation filters by `sourceFilter` and `impactFilter` only. Add a `searchQuery` state and wire it into both the input and the filter logic.

- [ ] **Step 1: Add searchQuery state**

After line 71 (`const [streaming, setStreaming] = useState(false);`), insert:

```tsx
const [searchQuery, setSearchQuery] = useState('');
```

- [ ] **Step 2: Add search term to filtered computation**

Replace lines 83–86:

```tsx
const filtered = MOCK_SIGNALS.filter(s =>
  (sourceFilter === 'All' || s.source === sourceFilter) &&
  (impactFilter === 'All' || s.impact === impactFilter)
);
```

With:

```tsx
const filtered = MOCK_SIGNALS.filter(s => {
  if (sourceFilter !== 'All' && s.source !== sourceFilter) return false;
  if (impactFilter !== 'All' && s.impact !== impactFilter) return false;
  if (searchQuery) {
    const q = searchQuery.toLowerCase();
    if (!s.title.toLowerCase().includes(q) && !s.summary.toLowerCase().includes(q)) return false;
  }
  return true;
});
```

- [ ] **Step 3: Wire the search input**

Replace lines 138–141:

```tsx
<div className="search-box">
  <input placeholder="Search signals..." />
</div>
```

With:

```tsx
<div className="search-box">
  <input
    placeholder="Search signals..."
    value={searchQuery}
    onChange={e => setSearchQuery(e.target.value)}
  />
</div>
```

- [ ] **Step 4: Verify build**

```powershell
cd frontend
npm run build
```

- [ ] **Step 5: Commit**

```bash
git add frontend/src/pages/Perception/PerceptionPage.tsx
git commit -m "fix: wire Perception page search input to filter signals"
```

---

## Task 5: Add loading state and refresh to Status page

**Files:**
- Modify: `frontend/src/styles/globals.css` — add loading spinner + skeleton styles
- Modify: `frontend/src/pages/Status/StatusPage.tsx`

The status page fetches from `/api/v1/perception/stats` but shows `'—'` for all values while loading with no visual loading indicator. The topbar "Export status" button has no handler. Add a loading skeleton to the stats grid, a `refreshKey` to re-trigger fetch on demand, and a working Export button.

- [ ] **Step 1: Add loading spinner CSS to globals.css**

Append to the end of `frontend/src/styles/globals.css`:

```css
/* ── Loading States ─────────────────────────────── */
.loading-shimmer {
  background: linear-gradient(90deg, var(--border) 25%, var(--bg) 50%, var(--border) 75%);
  background-size: 200% 100%;
  animation: shimmer 1.4s ease-in-out infinite;
  border-radius: var(--radius-sm);
  display: inline-block;
}
@keyframes shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}
.stat-value-loading {
  height: 32px;
  width: 64px;
}
```

- [ ] **Step 2: Add loading and refreshKey state to StatusPage**

Replace lines 41–48 (the `StatusPage` function opening + existing `useState`/`useEffect`):

```tsx
export function StatusPage() {
  const [stats, setStats] = useState<Stats | null>(null);
  const [loading, setLoading] = useState(true);
  const [refreshKey, setRefreshKey] = useState(0);

  useEffect(() => {
    setLoading(true);
    fetch('/api/v1/perception/stats')
      .then(r => r.json())
      .then(d => { setStats(d); setLoading(false); })
      .catch(() => {
        setStats({ total_documents: 42, vector_chunks: 3841, high_impact: 7, last_90_days: 14 });
        setLoading(false);
      });
  }, [refreshKey]);
```

- [ ] **Step 3: Wire the Export and Refresh buttons in the Topbar actions**

Replace the topbar actions block (lines 53–65, the `<>...</>` JSX inside `actions={...}`):

```tsx
actions={
  <>
    <div className="search-box">
      <Search size={13} />
      <input placeholder="Search..." />
    </div>
    <button
      className="btn sm"
      onClick={() => {
        const data = JSON.stringify(stats, null, 2);
        const blob = new Blob([data], { type: 'application/json' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url; a.download = 'regulation-hub-status.json'; a.click();
        URL.revokeObjectURL(url);
      }}
    >
      <Download size={13} />Export status
    </button>
    <button className="btn sm" onClick={() => setRefreshKey(k => k + 1)}>
      <RefreshCw size={13} />Refresh
    </button>
    <button className="btn sm primary"><Upload size={13} />New upload</button>
  </>
}
```

Note: add `RefreshCw` to the lucide-react import at the top of the file. Current imports are `{ Search, Upload, Download }` — change to `{ Search, Upload, Download, RefreshCw }`.

- [ ] **Step 4: Show loading shimmer in stats grid**

Replace the four `<div className="stat-cell">` blocks in the stats grid:

```tsx
<div className="stats-grid">
  {(['Documents indexed', 'Vector chunks', 'High-impact signals', 'Last 90 days'] as const).map((label, i) => {
    const values = stats
      ? [stats.total_documents, stats.vector_chunks?.toLocaleString(), stats.high_impact, stats.last_90_days]
      : [null, null, null, null];
    const isDanger = i === 2;
    return (
      <div key={label} className={`stat-cell${isDanger ? ' danger' : ''}`}>
        {loading
          ? <span className="loading-shimmer stat-value-loading" />
          : <div className="stat-value">{values[i] ?? '—'}</div>
        }
        <div className="stat-label">{label}</div>
      </div>
    );
  })}
</div>
```

- [ ] **Step 5: Verify build**

```powershell
cd frontend
npm run build
```

- [ ] **Step 6: Commit**

```bash
git add frontend/src/styles/globals.css frontend/src/pages/Status/StatusPage.tsx
git commit -m "feat: add loading shimmer, refresh button, and export to Status page"
```

---

## Task 6: Add Upload Modal

**Files:**
- Modify: `frontend/src/styles/globals.css` — add modal and upload form CSS
- Create: `frontend/src/pages/Docs/UploadModal.tsx`
- Modify: `frontend/src/pages/Docs/DocsPage.tsx` — wire Upload button
- Modify: `frontend/src/pages/Status/StatusPage.tsx` — wire New Upload button

The prototype `upload-modal.html` defines a split two-panel dialog: left panel has a drag-drop zone, staged file list, and metadata form; right panel shows import queue stages. Implement this as a React modal overlay.

- [ ] **Step 1: Add modal CSS to globals.css**

Append to the end of `frontend/src/styles/globals.css`:

```css
/* ── Upload Modal ───────────────────────────────── */
.modal-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0,0,0,.45);
  backdrop-filter: blur(3px);
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
}
.modal-dialog {
  display: grid;
  grid-template-columns: 1.15fr 0.85fr;
  max-width: 1000px;
  width: 100%;
  max-height: 90vh;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 18px;
  overflow: hidden;
  box-shadow: 0 14px 48px rgba(0,0,0,.22);
}
.modal-panel {
  padding: 28px;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 0;
}
.modal-panel + .modal-panel {
  border-left: 1px solid var(--border);
  background: color-mix(in srgb, var(--surface) 76%, var(--bg) 24%);
}
.modal-eyebrow {
  font-family: var(--font-mono);
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--accent);
  margin-bottom: 8px;
}
.modal-title { font-size: 22px; font-weight: 700; font-family: var(--font-display); line-height: 1.2; }
.modal-lead { font-size: 13px; color: var(--muted); margin-top: 8px; line-height: 1.6; }
.modal-close {
  position: absolute;
  top: 16px; right: 20px;
  background: none;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  width: 30px; height: 30px;
  display: flex; align-items: center; justify-content: center;
  cursor: pointer; color: var(--muted);
  transition: background 0.12s;
}
.modal-close:hover { background: var(--bg); color: var(--fg); }

.dropzone {
  margin-top: 20px;
  border: 1px dashed rgba(226,0,116,.46);
  border-radius: var(--radius-md);
  background: rgba(226,0,116,.04);
  padding: 28px 20px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
  text-align: center;
  min-height: 180px;
  justify-content: center;
  cursor: pointer;
  transition: background 0.15s, border-color 0.15s;
}
.dropzone.drag-over {
  background: rgba(226,0,116,.08);
  border-color: var(--accent);
}
.drop-icon {
  width: 52px; height: 52px;
  border-radius: 14px;
  border: 1px solid rgba(226,0,116,.35);
  background: var(--surface);
  display: flex; align-items: center; justify-content: center;
  color: var(--accent);
  font-family: var(--font-mono);
  font-size: 12px;
  font-weight: 700;
}
.drop-label { font-size: 15px; font-weight: 600; font-family: var(--font-display); }
.drop-hint { font-size: 12px; color: var(--muted); }
.drop-actions { display: flex; gap: 10px; margin-top: 4px; }

.staged-files { display: flex; flex-direction: column; gap: 10px; margin-top: 18px; }
.file-row {
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface);
  padding: 12px 14px;
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.file-row-top { display: flex; justify-content: space-between; align-items: center; gap: 10px; }
.file-name { font-size: 13px; font-weight: 500; }
.file-meta { font-size: 11px; color: var(--muted); font-family: var(--font-mono); }
.file-remove { background: none; border: none; cursor: pointer; color: var(--muted); padding: 2px; }
.file-remove:hover { color: var(--danger); }
.file-progress { height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; }
.file-progress-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.3s; }

.upload-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 18px; }
.upload-field { display: flex; flex-direction: column; gap: 5px; }
.upload-field label { font-size: 12px; color: var(--muted); }
.upload-field input,
.upload-field select,
.upload-field textarea {
  min-height: 36px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface);
  padding: 0 10px;
  font-size: 13px;
  color: var(--fg);
  font-family: var(--font-body);
  outline: none;
  transition: border-color 0.12s;
}
.upload-field input:focus,
.upload-field select:focus,
.upload-field textarea:focus { border-color: var(--accent); }
.upload-field textarea { min-height: 72px; padding: 8px 10px; resize: vertical; }
.upload-field.full-width { grid-column: 1 / -1; }
.modal-actions { display: flex; gap: 10px; margin-top: 20px; }

.queue-section { display: flex; flex-direction: column; gap: 10px; margin-top: 18px; }
.queue-card {
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface);
  padding: 12px 14px;
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.queue-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; }
.queue-name { font-size: 13px; font-weight: 600; }
.queue-desc { font-size: 11px; color: var(--muted); }
.queue-progress { height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; }
.queue-progress-fill { height: 100%; border-radius: 3px; background: color-mix(in srgb, var(--accent) 74%, white 26%); transition: width 0.4s ease; }

.summary-cards { display: flex; flex-direction: column; gap: 10px; margin-top: 18px; }
.summary-card-sm {
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--surface) 88%, var(--bg) 12%);
  padding: 12px 14px;
}
.summary-card-sm strong { font-size: 13px; font-family: var(--font-mono); }
.summary-card-hint { font-size: 11px; color: var(--muted); margin-top: 4px; }
```

- [ ] **Step 2: Create UploadModal.tsx**

Create `frontend/src/pages/Docs/UploadModal.tsx` with this exact content:

```tsx
import { useState, useRef } from 'react';
import { X, Upload } from 'lucide-react';

interface Props {
  onClose: () => void;
}

const REG_TYPES = ['EU Regulation', 'ISO Standard', 'National Draft', 'Internal Policy'];
const OWNERS = ['Homologation Office', 'Battery Safety Team', 'Connected Fleet', 'Cybersecurity Team'];
const PARSERS = ['Aliyun parser', 'Legacy local parser'];

const QUEUE_STAGES = [
  { name: 'Preflight validation', desc: 'Duplicate ID scan, file-type validation, metadata completeness' },
  { name: 'Object storage upload', desc: 'Transient object names attached to batch' },
  { name: 'Parse submission', desc: 'Polling every 5s · timeout 900s' },
  { name: 'Chunking + embedding', desc: 'Semantic blocks, vector chunks, 1024-d embeddings' },
];

export function UploadModal({ onClose }: Props) {
  const [files, setFiles] = useState<File[]>([]);
  const [regType, setRegType] = useState(REG_TYPES[0]);
  const [version, setVersion] = useState('');
  const [owner, setOwner] = useState(OWNERS[0]);
  const [parser, setParser] = useState(PARSERS[0]);
  const [note, setNote] = useState('');
  const [dragging, setDragging] = useState(false);
  const [submitting, setSubmitting] = useState(false);
  const [submitError, setSubmitError] = useState('');
  const [submitted, setSubmitted] = useState(false);
  const fileInputRef = useRef<HTMLInputElement>(null);

  function addFiles(newFiles: FileList | null) {
    if (!newFiles) return;
    setFiles(prev => [...prev, ...Array.from(newFiles)]);
  }

  function removeFile(index: number) {
    setFiles(prev => prev.filter((_, i) => i !== index));
  }

  function handleDrop(e: React.DragEvent) {
    e.preventDefault();
    setDragging(false);
    addFiles(e.dataTransfer.files);
  }

  async function handleSubmit() {
    if (files.length === 0) return;
    setSubmitting(true);
    setSubmitError('');
    const form = new FormData();
    files.forEach(f => form.append('files', f));
    form.append('regulation_type', regType);
    form.append('version', version);
    form.append('owner', owner);
    form.append('parser', parser);
    if (note) form.append('note', note);
    try {
      const res = await fetch('/api/v1/documents/upload', { method: 'POST', body: form });
      if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
      setSubmitted(true);
    } catch (e) {
      setSubmitError(e instanceof Error ? e.message : 'Upload failed. Check API connection.');
    } finally {
      setSubmitting(false);
    }
  }

  if (submitted) {
    return (
      <div className="modal-overlay" onClick={onClose}>
        <div className="modal-dialog" style={{ gridTemplateColumns: '1fr', maxWidth: 480 }} onClick={e => e.stopPropagation()}>
          <div className="modal-panel" style={{ alignItems: 'center', justifyContent: 'center', textAlign: 'center', gap: 16, padding: 48 }}>
            <div style={{ width: 52, height: 52, borderRadius: '50%', background: 'var(--success-bg)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--success)', fontSize: 24 }}>✓</div>
            <div className="modal-title" style={{ fontSize: 18 }}>Import queued</div>
            <p className="modal-lead">{files.length} file{files.length > 1 ? 's' : ''} submitted for parsing and indexing. Processing typically takes 2–5 minutes.</p>
            <button className="btn primary" onClick={onClose}>Close</button>
          </div>
        </div>
      </div>
    );
  }

  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal-dialog" onClick={e => e.stopPropagation()} style={{ position: 'relative' }}>
        <button className="modal-close" onClick={onClose} aria-label="Close"><X size={14} /></button>

        {/* Left panel — upload form */}
        <div className="modal-panel">
          <div className="modal-eyebrow">Upload documents</div>
          <div className="modal-title">Stage files for parsing and indexing.</div>
          <p className="modal-lead">Supports PDF, DOCX, and evidence bundles. Metadata is captured up front so the downstream parser and retrieval pipeline stay normalized.</p>

          <input
            ref={fileInputRef}
            type="file"
            multiple
            accept=".pdf,.docx,.doc,.txt"
            style={{ display: 'none' }}
            onChange={e => addFiles(e.target.files)}
          />

          <div
            className={`dropzone${dragging ? ' drag-over' : ''}`}
            onDragOver={e => { e.preventDefault(); setDragging(true); }}
            onDragLeave={() => setDragging(false)}
            onDrop={handleDrop}
            onClick={() => fileInputRef.current?.click()}
          >
            <div className="drop-icon">PDF</div>
            <div className="drop-label">Drop files here or browse</div>
            <p className="drop-hint">Up to 20 files · 200 MB combined · one regulation family per batch</p>
            <div className="drop-actions">
              <button
                className="btn sm primary"
                onClick={e => { e.stopPropagation(); fileInputRef.current?.click(); }}
              >
                <Upload size={12} />Choose files
              </button>
            </div>
          </div>

          {files.length > 0 && (
            <div className="staged-files">
              {files.map((f, i) => (
                <div key={i} className="file-row">
                  <div className="file-row-top">
                    <div>
                      <div className="file-name">{f.name}</div>
                      <div className="file-meta">{(f.size / 1024).toFixed(0)} KB · {f.type || 'unknown type'}</div>
                    </div>
                    <button className="file-remove" onClick={() => removeFile(i)} aria-label="Remove file"><X size={13} /></button>
                  </div>
                  <div className="file-progress"><div className="file-progress-fill" style={{ width: '100%' }} /></div>
                </div>
              ))}
            </div>
          )}

          <div className="upload-fields">
            <div className="upload-field">
              <label htmlFor="um-reg-type">Regulation type</label>
              <select id="um-reg-type" value={regType} onChange={e => setRegType(e.target.value)}>
                {REG_TYPES.map(t => <option key={t}>{t}</option>)}
              </select>
            </div>
            <div className="upload-field">
              <label htmlFor="um-version">Version / release</label>
              <input id="um-version" type="text" placeholder="e.g. 2024 final" value={version} onChange={e => setVersion(e.target.value)} />
            </div>
            <div className="upload-field">
              <label htmlFor="um-owner">Owning team</label>
              <select id="um-owner" value={owner} onChange={e => setOwner(e.target.value)}>
                {OWNERS.map(o => <option key={o}>{o}</option>)}
              </select>
            </div>
            <div className="upload-field">
              <label htmlFor="um-parser">Parser backend</label>
              <select id="um-parser" value={parser} onChange={e => setParser(e.target.value)}>
                {PARSERS.map(p => <option key={p}>{p}</option>)}
              </select>
            </div>
            <div className="upload-field full-width">
              <label htmlFor="um-note">Reviewer note</label>
              <textarea id="um-note" placeholder="Optional context for reviewers..." value={note} onChange={e => setNote(e.target.value)} />
            </div>
          </div>

          {submitError && (
            <p style={{ color: 'var(--danger)', fontSize: 12, marginTop: 10 }}>{submitError}</p>
          )}

          <div className="modal-actions">
            <button className="btn" onClick={onClose}>Cancel</button>
            <button
              className="btn primary"
              onClick={handleSubmit}
              disabled={files.length === 0 || submitting}
            >
              {submitting ? 'Submitting…' : 'Start import queue'}
            </button>
          </div>
        </div>

        {/* Right panel — import queue */}
        <div className="modal-panel">
          <div className="modal-eyebrow">Import queue</div>
          <div className="modal-title" style={{ fontSize: 18 }}>Batch progress and validation feedback</div>
          <p className="modal-lead" style={{ fontSize: 13 }}>Monitor preflight checks before the parser job is submitted, then watch queue depth without leaving the modal.</p>

          <div className="summary-cards">
            <div className="summary-card-sm">
              <strong>{files.length} file{files.length !== 1 ? 's' : ''} staged</strong>
              <div className="summary-card-hint">Ready for metadata validation and submission</div>
            </div>
            <div className="summary-card-sm">
              <strong>live queue</strong>
              <div className="summary-card-hint">Current parser queue depth populates after submission</div>
            </div>
            <div className="summary-card-sm">
              <strong>text-embedding-v3</strong>
              <div className="summary-card-hint">Embedding target applied after semantic block extraction</div>
            </div>
          </div>

          <div className="queue-section">
            {QUEUE_STAGES.map((stage, i) => {
              const progress = submitting ? [100, 65, 30, 8][i] : submitted ? 100 : [0, 0, 0, 0][i];
              const stageStatus = submitted ? 'ok' : submitting && i === 0 ? 'ok' : 'info';
              return (
                <div key={stage.name} className="queue-card">
                  <div className="queue-top">
                    <div>
                      <div className="queue-name">{stage.name}</div>
                      <div className="queue-desc">{stage.desc}</div>
                    </div>
                    <span className={`status ${stageStatus}`}>
                      {submitted ? 'Done' : submitting && i === 0 ? 'Passed' : 'Waiting'}
                    </span>
                  </div>
                  <div className="queue-progress">
                    <div className="queue-progress-fill" style={{ width: `${progress}%` }} />
                  </div>
                </div>
              );
            })}
          </div>
        </div>
      </div>
    </div>
  );
}
```

- [ ] **Step 3: Wire Upload button in DocsPage.tsx**

In `frontend/src/pages/Docs/DocsPage.tsx`:

a) Add `showUpload` state after existing state declarations (line 34):

```tsx
const [showUpload, setShowUpload] = useState(false);
```

b) Add import at the top of the file (after the existing import lines):

```tsx
import { UploadModal } from './UploadModal';
```

c) Change the Upload button (line 76) from:

```tsx
<button className="btn sm primary"><Upload size={13} />Upload document</button>
```

To:

```tsx
<button className="btn sm primary" onClick={() => setShowUpload(true)}>
  <Upload size={13} />Upload document
</button>
```

d) Render the modal at the bottom of the `return` statement, just before the closing `</div>` of `.docs-page`:

```tsx
      {showUpload && <UploadModal onClose={() => setShowUpload(false)} />}
    </div>
  );
```

- [ ] **Step 4: Wire "New upload" button in StatusPage.tsx**

In `frontend/src/pages/Status/StatusPage.tsx`:

a) Add import at top:

```tsx
import { UploadModal } from '../Docs/UploadModal';
```

b) Add `showUpload` state after existing state declarations:

```tsx
const [showUpload, setShowUpload] = useState(false);
```

c) Change the "New upload" button in the topbar actions to:

```tsx
<button className="btn sm primary" onClick={() => setShowUpload(true)}>
  <Upload size={13} />New upload
</button>
```

d) Render the modal at the bottom of the `return`, just before the closing `</div>` of `.status-page`:

```tsx
      {showUpload && <UploadModal onClose={() => setShowUpload(false)} />}
    </div>
  );
```

- [ ] **Step 5: Verify build**

```powershell
cd frontend
npm run build
```

Expected: zero TypeScript errors. Zero Vite warnings about missing modules.

- [ ] **Step 6: Commit**

```bash
git add frontend/src/styles/globals.css \
        frontend/src/pages/Docs/UploadModal.tsx \
        frontend/src/pages/Docs/DocsPage.tsx \
        frontend/src/pages/Status/StatusPage.tsx
git commit -m "feat: add upload modal with drag-drop, metadata form, and queue panel"
```

---

## Self-Review

**1. Spec coverage:**

| Issue | Task |
|---|---|
| tailwind darkMode: 'class' mismatch | Task 1 |
| index.html title "regulation-rag" | Task 1 |
| start.sh port 8001 hardcoded | Task 1 |
| types/theme.ts dead code | Task 1 |
| shell-config.ts dead code | Task 1 |
| components/ui/* dead code | Task 1 |
| sidebar-w, radius-sm/md, warn color off | Task 2 |
| Missing @media prefers-color-scheme | Task 2 |
| Sidebar brand SVG + wrong text hierarchy | Task 3 |
| Perception page search unconnected | Task 4 |
| Status page no loading state | Task 5 |
| Status page Refresh/Export unwired | Task 5 |
| Upload button has no modal | Task 6 |

**2. Placeholder scan:** No "TBD" or "TODO" in any task. All code blocks are complete and runnable.

**3. Type consistency:**
- `UploadModal` props: `{ onClose: () => void }` — used consistently in DocsPage and StatusPage.
- `showUpload: boolean` state — consistent across both pages.
- `refreshKey: number` state — used only in StatusPage.
- `searchQuery: string` state — used only in PerceptionPage.
- `loading: boolean` state — used only in StatusPage.
