# 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: `regulation-rag` New: `T-Systems Regulation Hub` Full updated `frontend/index.html`: ```html T-Systems Regulation Hub
``` - [ ] **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 `
` block (lines 56–62 in current file) with: ```tsx
T-Systems
Regulation Hub
``` 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 `` 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
``` With: ```tsx
setSearchQuery(e.target.value)} />
``` - [ ] **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(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={ <>
} ``` 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 `
` blocks in the stats grid: ```tsx
{(['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 (
{loading ? :
{values[i] ?? '—'}
}
{label}
); })}
``` - [ ] **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([]); 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(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 (
e.stopPropagation()}>
Import queued

{files.length} file{files.length > 1 ? 's' : ''} submitted for parsing and indexing. Processing typically takes 2–5 minutes.

); } return (
e.stopPropagation()} style={{ position: 'relative' }}> {/* Left panel — upload form */}
Upload documents
Stage files for parsing and indexing.

Supports PDF, DOCX, and evidence bundles. Metadata is captured up front so the downstream parser and retrieval pipeline stay normalized.

addFiles(e.target.files)} />
{ e.preventDefault(); setDragging(true); }} onDragLeave={() => setDragging(false)} onDrop={handleDrop} onClick={() => fileInputRef.current?.click()} >
PDF
Drop files here or browse

Up to 20 files · 200 MB combined · one regulation family per batch

{files.length > 0 && (
{files.map((f, i) => (
{f.name}
{(f.size / 1024).toFixed(0)} KB · {f.type || 'unknown type'}
))}
)}
setVersion(e.target.value)} />