Files
AIRegulation-DocAnalysis/docs/superpowers/plans/2026-06-04-frontend-optimization.md

1128 lines
37 KiB
Markdown
Raw Normal View History

2026-06-05 09:00:36 +08:00
# 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 140 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 4257 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 5662 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 8386:
```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 138141:
```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 4148 (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 5365, 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 25 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.