Files
AIRegulation-DocAnalysis/docs/superpowers/plans/2026-06-04-frontend-optimization.md
2026-06-05 09:00:36 +08:00

1128 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.