37 KiB
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.tsfrontend/src/components/layout/shell-config.tsfrontend/src/components/ui/Badge.tsxfrontend/src/components/ui/Button.tsxfrontend/src/components/ui/Card.tsxfrontend/src/components/ui/Input.tsxfrontend/src/components/ui/ProgressBar.tsxfrontend/src/components/ui/ScoreBar.tsx
MODIFY:
frontend/tailwind.config.js— fix darkMode strategyfrontend/index.html— fix app titlefrontend/start.sh— fix port and stale commentsfrontend/src/components/ui/index.ts— remove dead exportsfrontend/src/styles/globals.css— update tokens, add modal CSS, add loading spinnerfrontend/src/components/layout/Sidebar.tsx— fix brand SVG + text hierarchyfrontend/src/pages/Perception/PerceptionPage.tsx— wire search inputfrontend/src/pages/Status/StatusPage.tsx— loading state + refresh + upload buttonfrontend/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
# 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
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:
// 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:
darkMode: ['selector', '[data-theme="dark"]'],
Full updated frontend/tailwind.config.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:
<!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:
#!/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
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
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(:rootand[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
:roottoken block
Replace the existing :root { ... } block (lines 1–40 of globals.css) with:
/* ── 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:
/* 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
cd frontend
npm run build
Expected: zero errors.
- Step 4: Commit
git add frontend/src/styles/globals.css
git commit -m "fix: align design tokens with prototype (radii, warn color, dark mode values)"
Task 3: Fix Sidebar brand to match prototype
Files:
- Modify:
frontend/src/components/layout/Sidebar.tsx
The prototype brand section shows: a 26×26px magenta rounded box containing a bar-chart/regulation SVG icon, then "T-Systems" (bold, display font) as primary label and "Regulation Hub" (mono, small, muted) as sub-label. Current React code has them reversed ("Regulation Hub" bold, "T-Systems AI" small) and uses a text mark "TS" instead of the SVG.
- Step 1: Update the brand section in Sidebar.tsx
Replace the <div className="sidebar-brand"> block (lines 56–62 in current file) with:
<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
cd frontend
npm run build
- Step 3: Commit
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:
const [searchQuery, setSearchQuery] = useState('');
- Step 2: Add search term to filtered computation
Replace lines 83–86:
const filtered = MOCK_SIGNALS.filter(s =>
(sourceFilter === 'All' || s.source === sourceFilter) &&
(impactFilter === 'All' || s.impact === impactFilter)
);
With:
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:
<div className="search-box">
<input placeholder="Search signals..." />
</div>
With:
<div className="search-box">
<input
placeholder="Search signals..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
</div>
- Step 4: Verify build
cd frontend
npm run build
- Step 5: Commit
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:
/* ── 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):
export function StatusPage() {
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
const [refreshKey, setRefreshKey] = useState(0);
useEffect(() => {
setLoading(true);
fetch('/api/v1/perception/stats')
.then(r => r.json())
.then(d => { setStats(d); setLoading(false); })
.catch(() => {
setStats({ total_documents: 42, vector_chunks: 3841, high_impact: 7, last_90_days: 14 });
setLoading(false);
});
}, [refreshKey]);
- Step 3: Wire the Export and Refresh buttons in the Topbar actions
Replace the topbar actions block (lines 53–65, the <>...</> JSX inside actions={...}):
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:
<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
cd frontend
npm run build
- Step 6: Commit
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:
/* ── 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:
import { useState, useRef } from 'react';
import { X, Upload } from 'lucide-react';
interface Props {
onClose: () => void;
}
const REG_TYPES = ['EU Regulation', 'ISO Standard', 'National Draft', 'Internal Policy'];
const OWNERS = ['Homologation Office', 'Battery Safety Team', 'Connected Fleet', 'Cybersecurity Team'];
const PARSERS = ['Aliyun parser', 'Legacy local parser'];
const QUEUE_STAGES = [
{ name: 'Preflight validation', desc: 'Duplicate ID scan, file-type validation, metadata completeness' },
{ name: 'Object storage upload', desc: 'Transient object names attached to batch' },
{ name: 'Parse submission', desc: 'Polling every 5s · timeout 900s' },
{ name: 'Chunking + embedding', desc: 'Semantic blocks, vector chunks, 1024-d embeddings' },
];
export function UploadModal({ onClose }: Props) {
const [files, setFiles] = useState<File[]>([]);
const [regType, setRegType] = useState(REG_TYPES[0]);
const [version, setVersion] = useState('');
const [owner, setOwner] = useState(OWNERS[0]);
const [parser, setParser] = useState(PARSERS[0]);
const [note, setNote] = useState('');
const [dragging, setDragging] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState('');
const [submitted, setSubmitted] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
function addFiles(newFiles: FileList | null) {
if (!newFiles) return;
setFiles(prev => [...prev, ...Array.from(newFiles)]);
}
function removeFile(index: number) {
setFiles(prev => prev.filter((_, i) => i !== index));
}
function handleDrop(e: React.DragEvent) {
e.preventDefault();
setDragging(false);
addFiles(e.dataTransfer.files);
}
async function handleSubmit() {
if (files.length === 0) return;
setSubmitting(true);
setSubmitError('');
const form = new FormData();
files.forEach(f => form.append('files', f));
form.append('regulation_type', regType);
form.append('version', version);
form.append('owner', owner);
form.append('parser', parser);
if (note) form.append('note', note);
try {
const res = await fetch('/api/v1/documents/upload', { method: 'POST', body: form });
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
setSubmitted(true);
} catch (e) {
setSubmitError(e instanceof Error ? e.message : 'Upload failed. Check API connection.');
} finally {
setSubmitting(false);
}
}
if (submitted) {
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-dialog" style={{ gridTemplateColumns: '1fr', maxWidth: 480 }} onClick={e => e.stopPropagation()}>
<div className="modal-panel" style={{ alignItems: 'center', justifyContent: 'center', textAlign: 'center', gap: 16, padding: 48 }}>
<div style={{ width: 52, height: 52, borderRadius: '50%', background: 'var(--success-bg)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--success)', fontSize: 24 }}>✓</div>
<div className="modal-title" style={{ fontSize: 18 }}>Import queued</div>
<p className="modal-lead">{files.length} file{files.length > 1 ? 's' : ''} submitted for parsing and indexing. Processing typically takes 2–5 minutes.</p>
<button className="btn primary" onClick={onClose}>Close</button>
</div>
</div>
</div>
);
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-dialog" onClick={e => e.stopPropagation()} style={{ position: 'relative' }}>
<button className="modal-close" onClick={onClose} aria-label="Close"><X size={14} /></button>
{/* Left panel — upload form */}
<div className="modal-panel">
<div className="modal-eyebrow">Upload documents</div>
<div className="modal-title">Stage files for parsing and indexing.</div>
<p className="modal-lead">Supports PDF, DOCX, and evidence bundles. Metadata is captured up front so the downstream parser and retrieval pipeline stay normalized.</p>
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.docx,.doc,.txt"
style={{ display: 'none' }}
onChange={e => addFiles(e.target.files)}
/>
<div
className={`dropzone${dragging ? ' drag-over' : ''}`}
onDragOver={e => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<div className="drop-icon">PDF</div>
<div className="drop-label">Drop files here or browse</div>
<p className="drop-hint">Up to 20 files · 200 MB combined · one regulation family per batch</p>
<div className="drop-actions">
<button
className="btn sm primary"
onClick={e => { e.stopPropagation(); fileInputRef.current?.click(); }}
>
<Upload size={12} />Choose files
</button>
</div>
</div>
{files.length > 0 && (
<div className="staged-files">
{files.map((f, i) => (
<div key={i} className="file-row">
<div className="file-row-top">
<div>
<div className="file-name">{f.name}</div>
<div className="file-meta">{(f.size / 1024).toFixed(0)} KB · {f.type || 'unknown type'}</div>
</div>
<button className="file-remove" onClick={() => removeFile(i)} aria-label="Remove file"><X size={13} /></button>
</div>
<div className="file-progress"><div className="file-progress-fill" style={{ width: '100%' }} /></div>
</div>
))}
</div>
)}
<div className="upload-fields">
<div className="upload-field">
<label htmlFor="um-reg-type">Regulation type</label>
<select id="um-reg-type" value={regType} onChange={e => setRegType(e.target.value)}>
{REG_TYPES.map(t => <option key={t}>{t}</option>)}
</select>
</div>
<div className="upload-field">
<label htmlFor="um-version">Version / release</label>
<input id="um-version" type="text" placeholder="e.g. 2024 final" value={version} onChange={e => setVersion(e.target.value)} />
</div>
<div className="upload-field">
<label htmlFor="um-owner">Owning team</label>
<select id="um-owner" value={owner} onChange={e => setOwner(e.target.value)}>
{OWNERS.map(o => <option key={o}>{o}</option>)}
</select>
</div>
<div className="upload-field">
<label htmlFor="um-parser">Parser backend</label>
<select id="um-parser" value={parser} onChange={e => setParser(e.target.value)}>
{PARSERS.map(p => <option key={p}>{p}</option>)}
</select>
</div>
<div className="upload-field full-width">
<label htmlFor="um-note">Reviewer note</label>
<textarea id="um-note" placeholder="Optional context for reviewers..." value={note} onChange={e => setNote(e.target.value)} />
</div>
</div>
{submitError && (
<p style={{ color: 'var(--danger)', fontSize: 12, marginTop: 10 }}>{submitError}</p>
)}
<div className="modal-actions">
<button className="btn" onClick={onClose}>Cancel</button>
<button
className="btn primary"
onClick={handleSubmit}
disabled={files.length === 0 || submitting}
>
{submitting ? 'Submitting…' : 'Start import queue'}
</button>
</div>
</div>
{/* Right panel — import queue */}
<div className="modal-panel">
<div className="modal-eyebrow">Import queue</div>
<div className="modal-title" style={{ fontSize: 18 }}>Batch progress and validation feedback</div>
<p className="modal-lead" style={{ fontSize: 13 }}>Monitor preflight checks before the parser job is submitted, then watch queue depth without leaving the modal.</p>
<div className="summary-cards">
<div className="summary-card-sm">
<strong>{files.length} file{files.length !== 1 ? 's' : ''} staged</strong>
<div className="summary-card-hint">Ready for metadata validation and submission</div>
</div>
<div className="summary-card-sm">
<strong>live queue</strong>
<div className="summary-card-hint">Current parser queue depth populates after submission</div>
</div>
<div className="summary-card-sm">
<strong>text-embedding-v3</strong>
<div className="summary-card-hint">Embedding target applied after semantic block extraction</div>
</div>
</div>
<div className="queue-section">
{QUEUE_STAGES.map((stage, i) => {
const progress = submitting ? [100, 65, 30, 8][i] : submitted ? 100 : [0, 0, 0, 0][i];
const stageStatus = submitted ? 'ok' : submitting && i === 0 ? 'ok' : 'info';
return (
<div key={stage.name} className="queue-card">
<div className="queue-top">
<div>
<div className="queue-name">{stage.name}</div>
<div className="queue-desc">{stage.desc}</div>
</div>
<span className={`status ${stageStatus}`}>
{submitted ? 'Done' : submitting && i === 0 ? 'Passed' : 'Waiting'}
</span>
</div>
<div className="queue-progress">
<div className="queue-progress-fill" style={{ width: `${progress}%` }} />
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}
- Step 3: Wire Upload button in DocsPage.tsx
In frontend/src/pages/Docs/DocsPage.tsx:
a) Add showUpload state after existing state declarations (line 34):
const [showUpload, setShowUpload] = useState(false);
b) Add import at the top of the file (after the existing import lines):
import { UploadModal } from './UploadModal';
c) Change the Upload button (line 76) from:
<button className="btn sm primary"><Upload size={13} />Upload document</button>
To:
<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:
{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:
import { UploadModal } from '../Docs/UploadModal';
b) Add showUpload state after existing state declarations:
const [showUpload, setShowUpload] = useState(false);
c) Change the "New upload" button in the topbar actions to:
<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:
{showUpload && <UploadModal onClose={() => setShowUpload(false)} />}
</div>
);
- Step 5: Verify build
cd frontend
npm run build
Expected: zero TypeScript errors. Zero Vite warnings about missing modules.
- Step 6: Commit
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:
UploadModalprops:{ onClose: () => void }— used consistently in DocsPage and StatusPage.showUpload: booleanstate — consistent across both pages.refreshKey: numberstate — used only in StatusPage.searchQuery: stringstate — used only in PerceptionPage.loading: booleanstate — used only in StatusPage.