1128 lines
37 KiB
Markdown
1128 lines
37 KiB
Markdown
# Frontend Optimization Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Fix all code-health issues (dead files, config mismatches, startup script errors) and close prototype fidelity gaps (design tokens, sidebar brand, search wiring, loading states, upload modal) in the T-Systems Regulation Hub frontend.
|
||
|
||
**Architecture:** No new architectural layers. The CSS design-token system in `globals.css` is the single source of truth for styling; Tailwind dark-mode config is corrected to match it. Dead files are deleted. The upload modal is a new self-contained React component wired into DocsPage and StatusPage. All page logic remains in the page files.
|
||
|
||
**Tech Stack:** React 19 + TypeScript + Vite 8 + TailwindCSS 4.2 + globals.css CSS-variable system + Lucide React icons. Working directory for all npm commands: `frontend/`.
|
||
|
||
---
|
||
|
||
## File Map
|
||
|
||
**DELETE (confirmed dead — no imports anywhere):**
|
||
- `frontend/src/types/theme.ts`
|
||
- `frontend/src/components/layout/shell-config.ts`
|
||
- `frontend/src/components/ui/Badge.tsx`
|
||
- `frontend/src/components/ui/Button.tsx`
|
||
- `frontend/src/components/ui/Card.tsx`
|
||
- `frontend/src/components/ui/Input.tsx`
|
||
- `frontend/src/components/ui/ProgressBar.tsx`
|
||
- `frontend/src/components/ui/ScoreBar.tsx`
|
||
|
||
**MODIFY:**
|
||
- `frontend/tailwind.config.js` — fix darkMode strategy
|
||
- `frontend/index.html` — fix app title
|
||
- `frontend/start.sh` — fix port and stale comments
|
||
- `frontend/src/components/ui/index.ts` — remove dead exports
|
||
- `frontend/src/styles/globals.css` — update tokens, add modal CSS, add loading spinner
|
||
- `frontend/src/components/layout/Sidebar.tsx` — fix brand SVG + text hierarchy
|
||
- `frontend/src/pages/Perception/PerceptionPage.tsx` — wire search input
|
||
- `frontend/src/pages/Status/StatusPage.tsx` — loading state + refresh + upload button
|
||
- `frontend/src/pages/Docs/DocsPage.tsx` — wire upload button to modal
|
||
|
||
**CREATE:**
|
||
- `frontend/src/pages/Docs/UploadModal.tsx` — upload dialog component
|
||
|
||
---
|
||
|
||
## Task 1: Fix configs, startup script, and delete dead code
|
||
|
||
**Files:**
|
||
- Delete: `frontend/src/types/theme.ts`
|
||
- Delete: `frontend/src/components/layout/shell-config.ts`
|
||
- Delete: `frontend/src/components/ui/Badge.tsx`
|
||
- Delete: `frontend/src/components/ui/Button.tsx`
|
||
- Delete: `frontend/src/components/ui/Card.tsx`
|
||
- Delete: `frontend/src/components/ui/Input.tsx`
|
||
- Delete: `frontend/src/components/ui/ProgressBar.tsx`
|
||
- Delete: `frontend/src/components/ui/ScoreBar.tsx`
|
||
- Modify: `frontend/src/components/ui/index.ts`
|
||
- Modify: `frontend/tailwind.config.js`
|
||
- Modify: `frontend/index.html`
|
||
- Modify: `frontend/start.sh`
|
||
|
||
- [ ] **Step 1: Verify dead files have no imports**
|
||
|
||
```powershell
|
||
# From project root. Each command should print nothing (no matches).
|
||
Select-String -Path "frontend\src\**\*.ts","frontend\src\**\*.tsx" -Pattern "types/theme|ThemeColors|ThemeMode|darkTheme|dimTheme|lightTheme" -Recurse
|
||
Select-String -Path "frontend\src\**\*.ts","frontend\src\**\*.tsx" -Pattern "shell-config|shellFrameClassName|shellMeta" -Recurse
|
||
Select-String -Path "frontend\src\**\*.ts","frontend\src\**\*.tsx" -Pattern "from.*components/ui/(Badge|Button|Card|Input|ProgressBar|ScoreBar)" -Recurse
|
||
```
|
||
|
||
Expected: no output (no matches). If any imports ARE found, update those files to remove the imports before deleting.
|
||
|
||
- [ ] **Step 2: Delete the dead files**
|
||
|
||
```powershell
|
||
Remove-Item frontend\src\types\theme.ts
|
||
Remove-Item frontend\src\components\layout\shell-config.ts
|
||
Remove-Item frontend\src\components\ui\Badge.tsx
|
||
Remove-Item frontend\src\components\ui\Button.tsx
|
||
Remove-Item frontend\src\components\ui\Card.tsx
|
||
Remove-Item frontend\src\components\ui\Input.tsx
|
||
Remove-Item frontend\src\components\ui\ProgressBar.tsx
|
||
Remove-Item frontend\src\components\ui\ScoreBar.tsx
|
||
```
|
||
|
||
- [ ] **Step 3: Clear dead exports from ui/index.ts**
|
||
|
||
Replace the entire file `frontend/src/components/ui/index.ts` with:
|
||
|
||
```ts
|
||
// UI components — add exports here as new components are created
|
||
```
|
||
|
||
(The file must remain so the `components/ui/` directory stays as a future home for shared components.)
|
||
|
||
- [ ] **Step 4: Fix tailwind.config.js dark mode**
|
||
|
||
Current line 7: `darkMode: 'class',`
|
||
|
||
Replace with:
|
||
|
||
```js
|
||
darkMode: ['selector', '[data-theme="dark"]'],
|
||
```
|
||
|
||
Full updated `frontend/tailwind.config.js`:
|
||
|
||
```js
|
||
/** @type {import('tailwindcss').Config} */
|
||
export default {
|
||
content: [
|
||
"./index.html",
|
||
"./src/**/*.{js,ts,jsx,tsx}",
|
||
],
|
||
darkMode: ['selector', '[data-theme="dark"]'],
|
||
theme: {
|
||
extend: {
|
||
colors: {
|
||
't-bg': 'var(--bg)',
|
||
't-surface': 'var(--surface)',
|
||
't-fg': 'var(--fg)',
|
||
't-muted': 'var(--muted)',
|
||
't-border': 'var(--border)',
|
||
't-accent': '#e20074',
|
||
't-accent-hover': '#c8006a',
|
||
't-success': 'var(--success)',
|
||
't-warn': 'var(--warn)',
|
||
't-danger': 'var(--danger)',
|
||
},
|
||
fontFamily: {
|
||
'display': ['TeleNeoWeb-Bold', 'Inter', 'sans-serif'],
|
||
'body': ['TeleNeoWeb-Regular', 'Inter', 'sans-serif'],
|
||
'mono': ['ui-monospace', 'JetBrains Mono', 'Menlo', 'monospace'],
|
||
},
|
||
boxShadow: {
|
||
'card': 'var(--shadow-card)',
|
||
},
|
||
},
|
||
},
|
||
plugins: [],
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Fix index.html title**
|
||
|
||
In `frontend/index.html` change line 7:
|
||
|
||
Old: `<title>regulation-rag</title>`
|
||
New: `<title>T-Systems Regulation Hub</title>`
|
||
|
||
Full updated `frontend/index.html`:
|
||
|
||
```html
|
||
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>T-Systems Regulation Hub</title>
|
||
</head>
|
||
<body>
|
||
<div id="root"></div>
|
||
<script type="module" src="/src/main.tsx"></script>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
- [ ] **Step 6: Fix start.sh port and comments**
|
||
|
||
Full replacement for `frontend/start.sh`:
|
||
|
||
```bash
|
||
#!/bin/bash
|
||
# Start the Vite dev server.
|
||
# Port defaults to FRONTEND_PORT env var, or 5173 if unset (matches vite.config.ts default).
|
||
cd "$(dirname "$0")"
|
||
PORT="${FRONTEND_PORT:-5173}"
|
||
echo "Starting dev server on http://0.0.0.0:${PORT}"
|
||
npx vite --host 0.0.0.0 --port "$PORT"
|
||
```
|
||
|
||
- [ ] **Step 7: Verify the build still passes**
|
||
|
||
```powershell
|
||
cd frontend
|
||
npm run build
|
||
```
|
||
|
||
Expected: `✓ built in ...` with zero TypeScript errors. If `tsc` complains about missing types from deleted files, track down the import and remove it.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add frontend/tailwind.config.js frontend/index.html frontend/start.sh \
|
||
frontend/src/components/ui/index.ts
|
||
git rm frontend/src/types/theme.ts \
|
||
frontend/src/components/layout/shell-config.ts \
|
||
frontend/src/components/ui/Badge.tsx \
|
||
frontend/src/components/ui/Button.tsx \
|
||
frontend/src/components/ui/Card.tsx \
|
||
frontend/src/components/ui/Input.tsx \
|
||
frontend/src/components/ui/ProgressBar.tsx \
|
||
frontend/src/components/ui/ScoreBar.tsx
|
||
git commit -m "chore: delete dead code, fix tailwind dark mode, fix title and start.sh port"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Update design tokens to match prototype
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/styles/globals.css` (`:root` and `[data-theme="dark"]` blocks only)
|
||
|
||
The prototype files (`Prototype/cc29bcb0.../index.html`, `upload-modal.html`) define the authoritative token values. Current `globals.css` diverges on sidebar width, border radii, warn color, and dark-mode surface colors.
|
||
|
||
- [ ] **Step 1: Update the `:root` token block**
|
||
|
||
Replace the existing `:root { ... }` block (lines 1–40 of globals.css) with:
|
||
|
||
```css
|
||
/* ── Design Tokens ──────────────────────────────── */
|
||
:root {
|
||
color-scheme: light;
|
||
|
||
--rail-bg: #ffffff;
|
||
--rail-surface: #f7f8fa;
|
||
--rail-fg: #111827;
|
||
--rail-muted: #8b929e;
|
||
--rail-border: #e8eaed;
|
||
--rail-hover: rgba(0,0,0,.04);
|
||
--rail-active: rgba(226,0,116,.07);
|
||
|
||
--bg: #f2f4f7;
|
||
--surface: #ffffff;
|
||
--fg: #111111;
|
||
--muted: #6b7280;
|
||
--border: #e5e5e5;
|
||
--border-strong: #d1d5db;
|
||
|
||
--accent: #e20074;
|
||
--accent-dim: rgba(226,0,116,.10);
|
||
--accent-hover: #c8006a;
|
||
--success: #17a34a;
|
||
--success-bg: rgba(23,163,74,.08);
|
||
--warn: #eab308;
|
||
--warn-bg: rgba(234,179,8,.08);
|
||
--danger: #dc2626;
|
||
--danger-bg: rgba(220,38,38,.08);
|
||
--info: #2563eb;
|
||
--info-bg: rgba(37,99,235,.08);
|
||
|
||
--font-display: "TeleNeoWeb-Bold", "Inter", -apple-system, sans-serif;
|
||
--font-body: "TeleNeoWeb-Regular", "Inter", -apple-system, sans-serif;
|
||
--font-mono: ui-monospace, "JetBrains Mono", Menlo, monospace;
|
||
|
||
--sidebar-w: 240px;
|
||
--topbar-h: 54px;
|
||
--radius-sm: 8px;
|
||
--radius-md: 12px;
|
||
--radius-pill: 9999px;
|
||
--shadow-card: 0 2px 8px rgba(0,0,0,.06), 0 0 0 1px rgba(0,0,0,.04);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Update the `[data-theme="dark"]` block and add auto dark mode**
|
||
|
||
Replace the existing `[data-theme="dark"] { ... }` block (lines 42–57 of globals.css) with:
|
||
|
||
```css
|
||
/* Auto dark mode: matches system preference unless user has explicitly chosen light */
|
||
@media (prefers-color-scheme: dark) {
|
||
:root:not([data-theme="light"]) {
|
||
color-scheme: dark;
|
||
--rail-bg: #17181d;
|
||
--rail-surface: #1d1f26;
|
||
--rail-fg: #f5f7fb;
|
||
--rail-muted: #a2a9b8;
|
||
--rail-border: #2a2d35;
|
||
--rail-hover: rgba(255,255,255,.06);
|
||
--rail-active: rgba(226,0,116,.15);
|
||
|
||
--bg: #0f1014;
|
||
--surface: #17181d;
|
||
--fg: #f5f7fb;
|
||
--muted: #a2a9b8;
|
||
--border: #2a2d35;
|
||
--border-strong: #3a3d48;
|
||
|
||
--success: #22c55e;
|
||
--success-bg: rgba(34,197,94,.10);
|
||
--warn: #facc15;
|
||
--warn-bg: rgba(250,204,21,.10);
|
||
--danger: #f87171;
|
||
--danger-bg: rgba(248,113,113,.10);
|
||
}
|
||
}
|
||
|
||
/* Explicit dark mode (user toggled) */
|
||
[data-theme="dark"] {
|
||
color-scheme: dark;
|
||
--rail-bg: #17181d;
|
||
--rail-surface: #1d1f26;
|
||
--rail-fg: #f5f7fb;
|
||
--rail-muted: #a2a9b8;
|
||
--rail-border: #2a2d35;
|
||
--rail-hover: rgba(255,255,255,.06);
|
||
--rail-active: rgba(226,0,116,.15);
|
||
|
||
--bg: #0f1014;
|
||
--surface: #17181d;
|
||
--fg: #f5f7fb;
|
||
--muted: #a2a9b8;
|
||
--border: #2a2d35;
|
||
--border-strong: #3a3d48;
|
||
|
||
--success: #22c55e;
|
||
--success-bg: rgba(34,197,94,.10);
|
||
--warn: #facc15;
|
||
--warn-bg: rgba(250,204,21,.10);
|
||
--danger: #f87171;
|
||
--danger-bg: rgba(248,113,113,.10);
|
||
}
|
||
|
||
/* Explicit light mode (overrides auto dark) */
|
||
[data-theme="light"] {
|
||
color-scheme: light;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Verify build**
|
||
|
||
```powershell
|
||
cd frontend
|
||
npm run build
|
||
```
|
||
|
||
Expected: zero errors.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/styles/globals.css
|
||
git commit -m "fix: align design tokens with prototype (radii, warn color, dark mode values)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Fix Sidebar brand to match prototype
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/components/layout/Sidebar.tsx`
|
||
|
||
The prototype brand section shows: a `26×26px` magenta rounded box containing a bar-chart/regulation SVG icon, then "T-Systems" (bold, display font) as primary label and "Regulation Hub" (mono, small, muted) as sub-label. Current React code has them reversed ("Regulation Hub" bold, "T-Systems AI" small) and uses a text mark "TS" instead of the SVG.
|
||
|
||
- [ ] **Step 1: Update the brand section in Sidebar.tsx**
|
||
|
||
Replace the `<div className="sidebar-brand">` block (lines 56–62 in current file) with:
|
||
|
||
```tsx
|
||
<div className="sidebar-brand">
|
||
<div className="brand-mark">
|
||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||
<path d="M2 3.5h12v1.5H2zm5.25 1.5h1.5v8h-1.5zm-3 2h2.25v1.5H4.25zm5.25 0h2.25v1.5H9.5zm-3 3h2.5v1.5H6.5z" fill="currentColor"/>
|
||
</svg>
|
||
</div>
|
||
<div className="brand-text">
|
||
<div className="brand-name">T-Systems</div>
|
||
<div className="brand-sub">Regulation Hub</div>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
The CSS for `.brand-mark`, `.brand-name`, `.brand-sub` already exists in `globals.css` and is correct — `.brand-mark` renders the accent box, `.brand-name` is 13px bold display font, `.brand-sub` is 10px muted. No CSS changes needed.
|
||
|
||
- [ ] **Step 2: Verify build**
|
||
|
||
```powershell
|
||
cd frontend
|
||
npm run build
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/components/layout/Sidebar.tsx
|
||
git commit -m "fix: update sidebar brand to match prototype (SVG icon, T-Systems primary label)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Wire Perception page search input
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/pages/Perception/PerceptionPage.tsx`
|
||
|
||
The search box renders `<input placeholder="Search signals..." />` with no state wiring (no `value`, no `onChange`). The `filtered` computation filters by `sourceFilter` and `impactFilter` only. Add a `searchQuery` state and wire it into both the input and the filter logic.
|
||
|
||
- [ ] **Step 1: Add searchQuery state**
|
||
|
||
After line 71 (`const [streaming, setStreaming] = useState(false);`), insert:
|
||
|
||
```tsx
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
```
|
||
|
||
- [ ] **Step 2: Add search term to filtered computation**
|
||
|
||
Replace lines 83–86:
|
||
|
||
```tsx
|
||
const filtered = MOCK_SIGNALS.filter(s =>
|
||
(sourceFilter === 'All' || s.source === sourceFilter) &&
|
||
(impactFilter === 'All' || s.impact === impactFilter)
|
||
);
|
||
```
|
||
|
||
With:
|
||
|
||
```tsx
|
||
const filtered = MOCK_SIGNALS.filter(s => {
|
||
if (sourceFilter !== 'All' && s.source !== sourceFilter) return false;
|
||
if (impactFilter !== 'All' && s.impact !== impactFilter) return false;
|
||
if (searchQuery) {
|
||
const q = searchQuery.toLowerCase();
|
||
if (!s.title.toLowerCase().includes(q) && !s.summary.toLowerCase().includes(q)) return false;
|
||
}
|
||
return true;
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Wire the search input**
|
||
|
||
Replace lines 138–141:
|
||
|
||
```tsx
|
||
<div className="search-box">
|
||
<input placeholder="Search signals..." />
|
||
</div>
|
||
```
|
||
|
||
With:
|
||
|
||
```tsx
|
||
<div className="search-box">
|
||
<input
|
||
placeholder="Search signals..."
|
||
value={searchQuery}
|
||
onChange={e => setSearchQuery(e.target.value)}
|
||
/>
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 4: Verify build**
|
||
|
||
```powershell
|
||
cd frontend
|
||
npm run build
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/pages/Perception/PerceptionPage.tsx
|
||
git commit -m "fix: wire Perception page search input to filter signals"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Add loading state and refresh to Status page
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/styles/globals.css` — add loading spinner + skeleton styles
|
||
- Modify: `frontend/src/pages/Status/StatusPage.tsx`
|
||
|
||
The status page fetches from `/api/v1/perception/stats` but shows `'—'` for all values while loading with no visual loading indicator. The topbar "Export status" button has no handler. Add a loading skeleton to the stats grid, a `refreshKey` to re-trigger fetch on demand, and a working Export button.
|
||
|
||
- [ ] **Step 1: Add loading spinner CSS to globals.css**
|
||
|
||
Append to the end of `frontend/src/styles/globals.css`:
|
||
|
||
```css
|
||
/* ── Loading States ─────────────────────────────── */
|
||
.loading-shimmer {
|
||
background: linear-gradient(90deg, var(--border) 25%, var(--bg) 50%, var(--border) 75%);
|
||
background-size: 200% 100%;
|
||
animation: shimmer 1.4s ease-in-out infinite;
|
||
border-radius: var(--radius-sm);
|
||
display: inline-block;
|
||
}
|
||
@keyframes shimmer {
|
||
0% { background-position: 200% 0; }
|
||
100% { background-position: -200% 0; }
|
||
}
|
||
.stat-value-loading {
|
||
height: 32px;
|
||
width: 64px;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add loading and refreshKey state to StatusPage**
|
||
|
||
Replace lines 41–48 (the `StatusPage` function opening + existing `useState`/`useEffect`):
|
||
|
||
```tsx
|
||
export function StatusPage() {
|
||
const [stats, setStats] = useState<Stats | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [refreshKey, setRefreshKey] = useState(0);
|
||
|
||
useEffect(() => {
|
||
setLoading(true);
|
||
fetch('/api/v1/perception/stats')
|
||
.then(r => r.json())
|
||
.then(d => { setStats(d); setLoading(false); })
|
||
.catch(() => {
|
||
setStats({ total_documents: 42, vector_chunks: 3841, high_impact: 7, last_90_days: 14 });
|
||
setLoading(false);
|
||
});
|
||
}, [refreshKey]);
|
||
```
|
||
|
||
- [ ] **Step 3: Wire the Export and Refresh buttons in the Topbar actions**
|
||
|
||
Replace the topbar actions block (lines 53–65, the `<>...</>` JSX inside `actions={...}`):
|
||
|
||
```tsx
|
||
actions={
|
||
<>
|
||
<div className="search-box">
|
||
<Search size={13} />
|
||
<input placeholder="Search..." />
|
||
</div>
|
||
<button
|
||
className="btn sm"
|
||
onClick={() => {
|
||
const data = JSON.stringify(stats, null, 2);
|
||
const blob = new Blob([data], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url; a.download = 'regulation-hub-status.json'; a.click();
|
||
URL.revokeObjectURL(url);
|
||
}}
|
||
>
|
||
<Download size={13} />Export status
|
||
</button>
|
||
<button className="btn sm" onClick={() => setRefreshKey(k => k + 1)}>
|
||
<RefreshCw size={13} />Refresh
|
||
</button>
|
||
<button className="btn sm primary"><Upload size={13} />New upload</button>
|
||
</>
|
||
}
|
||
```
|
||
|
||
Note: add `RefreshCw` to the lucide-react import at the top of the file. Current imports are `{ Search, Upload, Download }` — change to `{ Search, Upload, Download, RefreshCw }`.
|
||
|
||
- [ ] **Step 4: Show loading shimmer in stats grid**
|
||
|
||
Replace the four `<div className="stat-cell">` blocks in the stats grid:
|
||
|
||
```tsx
|
||
<div className="stats-grid">
|
||
{(['Documents indexed', 'Vector chunks', 'High-impact signals', 'Last 90 days'] as const).map((label, i) => {
|
||
const values = stats
|
||
? [stats.total_documents, stats.vector_chunks?.toLocaleString(), stats.high_impact, stats.last_90_days]
|
||
: [null, null, null, null];
|
||
const isDanger = i === 2;
|
||
return (
|
||
<div key={label} className={`stat-cell${isDanger ? ' danger' : ''}`}>
|
||
{loading
|
||
? <span className="loading-shimmer stat-value-loading" />
|
||
: <div className="stat-value">{values[i] ?? '—'}</div>
|
||
}
|
||
<div className="stat-label">{label}</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 5: Verify build**
|
||
|
||
```powershell
|
||
cd frontend
|
||
npm run build
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/styles/globals.css frontend/src/pages/Status/StatusPage.tsx
|
||
git commit -m "feat: add loading shimmer, refresh button, and export to Status page"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Add Upload Modal
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/styles/globals.css` — add modal and upload form CSS
|
||
- Create: `frontend/src/pages/Docs/UploadModal.tsx`
|
||
- Modify: `frontend/src/pages/Docs/DocsPage.tsx` — wire Upload button
|
||
- Modify: `frontend/src/pages/Status/StatusPage.tsx` — wire New Upload button
|
||
|
||
The prototype `upload-modal.html` defines a split two-panel dialog: left panel has a drag-drop zone, staged file list, and metadata form; right panel shows import queue stages. Implement this as a React modal overlay.
|
||
|
||
- [ ] **Step 1: Add modal CSS to globals.css**
|
||
|
||
Append to the end of `frontend/src/styles/globals.css`:
|
||
|
||
```css
|
||
/* ── Upload Modal ───────────────────────────────── */
|
||
.modal-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0,0,0,.45);
|
||
backdrop-filter: blur(3px);
|
||
z-index: 100;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 24px;
|
||
}
|
||
.modal-dialog {
|
||
display: grid;
|
||
grid-template-columns: 1.15fr 0.85fr;
|
||
max-width: 1000px;
|
||
width: 100%;
|
||
max-height: 90vh;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 18px;
|
||
overflow: hidden;
|
||
box-shadow: 0 14px 48px rgba(0,0,0,.22);
|
||
}
|
||
.modal-panel {
|
||
padding: 28px;
|
||
overflow-y: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0;
|
||
}
|
||
.modal-panel + .modal-panel {
|
||
border-left: 1px solid var(--border);
|
||
background: color-mix(in srgb, var(--surface) 76%, var(--bg) 24%);
|
||
}
|
||
.modal-eyebrow {
|
||
font-family: var(--font-mono);
|
||
font-size: 10px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
color: var(--accent);
|
||
margin-bottom: 8px;
|
||
}
|
||
.modal-title { font-size: 22px; font-weight: 700; font-family: var(--font-display); line-height: 1.2; }
|
||
.modal-lead { font-size: 13px; color: var(--muted); margin-top: 8px; line-height: 1.6; }
|
||
.modal-close {
|
||
position: absolute;
|
||
top: 16px; right: 20px;
|
||
background: none;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
width: 30px; height: 30px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
cursor: pointer; color: var(--muted);
|
||
transition: background 0.12s;
|
||
}
|
||
.modal-close:hover { background: var(--bg); color: var(--fg); }
|
||
|
||
.dropzone {
|
||
margin-top: 20px;
|
||
border: 1px dashed rgba(226,0,116,.46);
|
||
border-radius: var(--radius-md);
|
||
background: rgba(226,0,116,.04);
|
||
padding: 28px 20px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 12px;
|
||
text-align: center;
|
||
min-height: 180px;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
transition: background 0.15s, border-color 0.15s;
|
||
}
|
||
.dropzone.drag-over {
|
||
background: rgba(226,0,116,.08);
|
||
border-color: var(--accent);
|
||
}
|
||
.drop-icon {
|
||
width: 52px; height: 52px;
|
||
border-radius: 14px;
|
||
border: 1px solid rgba(226,0,116,.35);
|
||
background: var(--surface);
|
||
display: flex; align-items: center; justify-content: center;
|
||
color: var(--accent);
|
||
font-family: var(--font-mono);
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
.drop-label { font-size: 15px; font-weight: 600; font-family: var(--font-display); }
|
||
.drop-hint { font-size: 12px; color: var(--muted); }
|
||
.drop-actions { display: flex; gap: 10px; margin-top: 4px; }
|
||
|
||
.staged-files { display: flex; flex-direction: column; gap: 10px; margin-top: 18px; }
|
||
.file-row {
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--surface);
|
||
padding: 12px 14px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.file-row-top { display: flex; justify-content: space-between; align-items: center; gap: 10px; }
|
||
.file-name { font-size: 13px; font-weight: 500; }
|
||
.file-meta { font-size: 11px; color: var(--muted); font-family: var(--font-mono); }
|
||
.file-remove { background: none; border: none; cursor: pointer; color: var(--muted); padding: 2px; }
|
||
.file-remove:hover { color: var(--danger); }
|
||
.file-progress { height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; }
|
||
.file-progress-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.3s; }
|
||
|
||
.upload-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 18px; }
|
||
.upload-field { display: flex; flex-direction: column; gap: 5px; }
|
||
.upload-field label { font-size: 12px; color: var(--muted); }
|
||
.upload-field input,
|
||
.upload-field select,
|
||
.upload-field textarea {
|
||
min-height: 36px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--surface);
|
||
padding: 0 10px;
|
||
font-size: 13px;
|
||
color: var(--fg);
|
||
font-family: var(--font-body);
|
||
outline: none;
|
||
transition: border-color 0.12s;
|
||
}
|
||
.upload-field input:focus,
|
||
.upload-field select:focus,
|
||
.upload-field textarea:focus { border-color: var(--accent); }
|
||
.upload-field textarea { min-height: 72px; padding: 8px 10px; resize: vertical; }
|
||
.upload-field.full-width { grid-column: 1 / -1; }
|
||
.modal-actions { display: flex; gap: 10px; margin-top: 20px; }
|
||
|
||
.queue-section { display: flex; flex-direction: column; gap: 10px; margin-top: 18px; }
|
||
.queue-card {
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--surface);
|
||
padding: 12px 14px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.queue-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; }
|
||
.queue-name { font-size: 13px; font-weight: 600; }
|
||
.queue-desc { font-size: 11px; color: var(--muted); }
|
||
.queue-progress { height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; }
|
||
.queue-progress-fill { height: 100%; border-radius: 3px; background: color-mix(in srgb, var(--accent) 74%, white 26%); transition: width 0.4s ease; }
|
||
|
||
.summary-cards { display: flex; flex-direction: column; gap: 10px; margin-top: 18px; }
|
||
.summary-card-sm {
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
background: color-mix(in srgb, var(--surface) 88%, var(--bg) 12%);
|
||
padding: 12px 14px;
|
||
}
|
||
.summary-card-sm strong { font-size: 13px; font-family: var(--font-mono); }
|
||
.summary-card-hint { font-size: 11px; color: var(--muted); margin-top: 4px; }
|
||
```
|
||
|
||
- [ ] **Step 2: Create UploadModal.tsx**
|
||
|
||
Create `frontend/src/pages/Docs/UploadModal.tsx` with this exact content:
|
||
|
||
```tsx
|
||
import { useState, useRef } from 'react';
|
||
import { X, Upload } from 'lucide-react';
|
||
|
||
interface Props {
|
||
onClose: () => void;
|
||
}
|
||
|
||
const REG_TYPES = ['EU Regulation', 'ISO Standard', 'National Draft', 'Internal Policy'];
|
||
const OWNERS = ['Homologation Office', 'Battery Safety Team', 'Connected Fleet', 'Cybersecurity Team'];
|
||
const PARSERS = ['Aliyun parser', 'Legacy local parser'];
|
||
|
||
const QUEUE_STAGES = [
|
||
{ name: 'Preflight validation', desc: 'Duplicate ID scan, file-type validation, metadata completeness' },
|
||
{ name: 'Object storage upload', desc: 'Transient object names attached to batch' },
|
||
{ name: 'Parse submission', desc: 'Polling every 5s · timeout 900s' },
|
||
{ name: 'Chunking + embedding', desc: 'Semantic blocks, vector chunks, 1024-d embeddings' },
|
||
];
|
||
|
||
export function UploadModal({ onClose }: Props) {
|
||
const [files, setFiles] = useState<File[]>([]);
|
||
const [regType, setRegType] = useState(REG_TYPES[0]);
|
||
const [version, setVersion] = useState('');
|
||
const [owner, setOwner] = useState(OWNERS[0]);
|
||
const [parser, setParser] = useState(PARSERS[0]);
|
||
const [note, setNote] = useState('');
|
||
const [dragging, setDragging] = useState(false);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [submitError, setSubmitError] = useState('');
|
||
const [submitted, setSubmitted] = useState(false);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
function addFiles(newFiles: FileList | null) {
|
||
if (!newFiles) return;
|
||
setFiles(prev => [...prev, ...Array.from(newFiles)]);
|
||
}
|
||
|
||
function removeFile(index: number) {
|
||
setFiles(prev => prev.filter((_, i) => i !== index));
|
||
}
|
||
|
||
function handleDrop(e: React.DragEvent) {
|
||
e.preventDefault();
|
||
setDragging(false);
|
||
addFiles(e.dataTransfer.files);
|
||
}
|
||
|
||
async function handleSubmit() {
|
||
if (files.length === 0) return;
|
||
setSubmitting(true);
|
||
setSubmitError('');
|
||
const form = new FormData();
|
||
files.forEach(f => form.append('files', f));
|
||
form.append('regulation_type', regType);
|
||
form.append('version', version);
|
||
form.append('owner', owner);
|
||
form.append('parser', parser);
|
||
if (note) form.append('note', note);
|
||
try {
|
||
const res = await fetch('/api/v1/documents/upload', { method: 'POST', body: form });
|
||
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
|
||
setSubmitted(true);
|
||
} catch (e) {
|
||
setSubmitError(e instanceof Error ? e.message : 'Upload failed. Check API connection.');
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
}
|
||
|
||
if (submitted) {
|
||
return (
|
||
<div className="modal-overlay" onClick={onClose}>
|
||
<div className="modal-dialog" style={{ gridTemplateColumns: '1fr', maxWidth: 480 }} onClick={e => e.stopPropagation()}>
|
||
<div className="modal-panel" style={{ alignItems: 'center', justifyContent: 'center', textAlign: 'center', gap: 16, padding: 48 }}>
|
||
<div style={{ width: 52, height: 52, borderRadius: '50%', background: 'var(--success-bg)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--success)', fontSize: 24 }}>✓</div>
|
||
<div className="modal-title" style={{ fontSize: 18 }}>Import queued</div>
|
||
<p className="modal-lead">{files.length} file{files.length > 1 ? 's' : ''} submitted for parsing and indexing. Processing typically takes 2–5 minutes.</p>
|
||
<button className="btn primary" onClick={onClose}>Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="modal-overlay" onClick={onClose}>
|
||
<div className="modal-dialog" onClick={e => e.stopPropagation()} style={{ position: 'relative' }}>
|
||
<button className="modal-close" onClick={onClose} aria-label="Close"><X size={14} /></button>
|
||
|
||
{/* Left panel — upload form */}
|
||
<div className="modal-panel">
|
||
<div className="modal-eyebrow">Upload documents</div>
|
||
<div className="modal-title">Stage files for parsing and indexing.</div>
|
||
<p className="modal-lead">Supports PDF, DOCX, and evidence bundles. Metadata is captured up front so the downstream parser and retrieval pipeline stay normalized.</p>
|
||
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
multiple
|
||
accept=".pdf,.docx,.doc,.txt"
|
||
style={{ display: 'none' }}
|
||
onChange={e => addFiles(e.target.files)}
|
||
/>
|
||
|
||
<div
|
||
className={`dropzone${dragging ? ' drag-over' : ''}`}
|
||
onDragOver={e => { e.preventDefault(); setDragging(true); }}
|
||
onDragLeave={() => setDragging(false)}
|
||
onDrop={handleDrop}
|
||
onClick={() => fileInputRef.current?.click()}
|
||
>
|
||
<div className="drop-icon">PDF</div>
|
||
<div className="drop-label">Drop files here or browse</div>
|
||
<p className="drop-hint">Up to 20 files · 200 MB combined · one regulation family per batch</p>
|
||
<div className="drop-actions">
|
||
<button
|
||
className="btn sm primary"
|
||
onClick={e => { e.stopPropagation(); fileInputRef.current?.click(); }}
|
||
>
|
||
<Upload size={12} />Choose files
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{files.length > 0 && (
|
||
<div className="staged-files">
|
||
{files.map((f, i) => (
|
||
<div key={i} className="file-row">
|
||
<div className="file-row-top">
|
||
<div>
|
||
<div className="file-name">{f.name}</div>
|
||
<div className="file-meta">{(f.size / 1024).toFixed(0)} KB · {f.type || 'unknown type'}</div>
|
||
</div>
|
||
<button className="file-remove" onClick={() => removeFile(i)} aria-label="Remove file"><X size={13} /></button>
|
||
</div>
|
||
<div className="file-progress"><div className="file-progress-fill" style={{ width: '100%' }} /></div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="upload-fields">
|
||
<div className="upload-field">
|
||
<label htmlFor="um-reg-type">Regulation type</label>
|
||
<select id="um-reg-type" value={regType} onChange={e => setRegType(e.target.value)}>
|
||
{REG_TYPES.map(t => <option key={t}>{t}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="upload-field">
|
||
<label htmlFor="um-version">Version / release</label>
|
||
<input id="um-version" type="text" placeholder="e.g. 2024 final" value={version} onChange={e => setVersion(e.target.value)} />
|
||
</div>
|
||
<div className="upload-field">
|
||
<label htmlFor="um-owner">Owning team</label>
|
||
<select id="um-owner" value={owner} onChange={e => setOwner(e.target.value)}>
|
||
{OWNERS.map(o => <option key={o}>{o}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="upload-field">
|
||
<label htmlFor="um-parser">Parser backend</label>
|
||
<select id="um-parser" value={parser} onChange={e => setParser(e.target.value)}>
|
||
{PARSERS.map(p => <option key={p}>{p}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="upload-field full-width">
|
||
<label htmlFor="um-note">Reviewer note</label>
|
||
<textarea id="um-note" placeholder="Optional context for reviewers..." value={note} onChange={e => setNote(e.target.value)} />
|
||
</div>
|
||
</div>
|
||
|
||
{submitError && (
|
||
<p style={{ color: 'var(--danger)', fontSize: 12, marginTop: 10 }}>{submitError}</p>
|
||
)}
|
||
|
||
<div className="modal-actions">
|
||
<button className="btn" onClick={onClose}>Cancel</button>
|
||
<button
|
||
className="btn primary"
|
||
onClick={handleSubmit}
|
||
disabled={files.length === 0 || submitting}
|
||
>
|
||
{submitting ? 'Submitting…' : 'Start import queue'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right panel — import queue */}
|
||
<div className="modal-panel">
|
||
<div className="modal-eyebrow">Import queue</div>
|
||
<div className="modal-title" style={{ fontSize: 18 }}>Batch progress and validation feedback</div>
|
||
<p className="modal-lead" style={{ fontSize: 13 }}>Monitor preflight checks before the parser job is submitted, then watch queue depth without leaving the modal.</p>
|
||
|
||
<div className="summary-cards">
|
||
<div className="summary-card-sm">
|
||
<strong>{files.length} file{files.length !== 1 ? 's' : ''} staged</strong>
|
||
<div className="summary-card-hint">Ready for metadata validation and submission</div>
|
||
</div>
|
||
<div className="summary-card-sm">
|
||
<strong>live queue</strong>
|
||
<div className="summary-card-hint">Current parser queue depth populates after submission</div>
|
||
</div>
|
||
<div className="summary-card-sm">
|
||
<strong>text-embedding-v3</strong>
|
||
<div className="summary-card-hint">Embedding target applied after semantic block extraction</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="queue-section">
|
||
{QUEUE_STAGES.map((stage, i) => {
|
||
const progress = submitting ? [100, 65, 30, 8][i] : submitted ? 100 : [0, 0, 0, 0][i];
|
||
const stageStatus = submitted ? 'ok' : submitting && i === 0 ? 'ok' : 'info';
|
||
return (
|
||
<div key={stage.name} className="queue-card">
|
||
<div className="queue-top">
|
||
<div>
|
||
<div className="queue-name">{stage.name}</div>
|
||
<div className="queue-desc">{stage.desc}</div>
|
||
</div>
|
||
<span className={`status ${stageStatus}`}>
|
||
{submitted ? 'Done' : submitting && i === 0 ? 'Passed' : 'Waiting'}
|
||
</span>
|
||
</div>
|
||
<div className="queue-progress">
|
||
<div className="queue-progress-fill" style={{ width: `${progress}%` }} />
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Wire Upload button in DocsPage.tsx**
|
||
|
||
In `frontend/src/pages/Docs/DocsPage.tsx`:
|
||
|
||
a) Add `showUpload` state after existing state declarations (line 34):
|
||
|
||
```tsx
|
||
const [showUpload, setShowUpload] = useState(false);
|
||
```
|
||
|
||
b) Add import at the top of the file (after the existing import lines):
|
||
|
||
```tsx
|
||
import { UploadModal } from './UploadModal';
|
||
```
|
||
|
||
c) Change the Upload button (line 76) from:
|
||
|
||
```tsx
|
||
<button className="btn sm primary"><Upload size={13} />Upload document</button>
|
||
```
|
||
|
||
To:
|
||
|
||
```tsx
|
||
<button className="btn sm primary" onClick={() => setShowUpload(true)}>
|
||
<Upload size={13} />Upload document
|
||
</button>
|
||
```
|
||
|
||
d) Render the modal at the bottom of the `return` statement, just before the closing `</div>` of `.docs-page`:
|
||
|
||
```tsx
|
||
{showUpload && <UploadModal onClose={() => setShowUpload(false)} />}
|
||
</div>
|
||
);
|
||
```
|
||
|
||
- [ ] **Step 4: Wire "New upload" button in StatusPage.tsx**
|
||
|
||
In `frontend/src/pages/Status/StatusPage.tsx`:
|
||
|
||
a) Add import at top:
|
||
|
||
```tsx
|
||
import { UploadModal } from '../Docs/UploadModal';
|
||
```
|
||
|
||
b) Add `showUpload` state after existing state declarations:
|
||
|
||
```tsx
|
||
const [showUpload, setShowUpload] = useState(false);
|
||
```
|
||
|
||
c) Change the "New upload" button in the topbar actions to:
|
||
|
||
```tsx
|
||
<button className="btn sm primary" onClick={() => setShowUpload(true)}>
|
||
<Upload size={13} />New upload
|
||
</button>
|
||
```
|
||
|
||
d) Render the modal at the bottom of the `return`, just before the closing `</div>` of `.status-page`:
|
||
|
||
```tsx
|
||
{showUpload && <UploadModal onClose={() => setShowUpload(false)} />}
|
||
</div>
|
||
);
|
||
```
|
||
|
||
- [ ] **Step 5: Verify build**
|
||
|
||
```powershell
|
||
cd frontend
|
||
npm run build
|
||
```
|
||
|
||
Expected: zero TypeScript errors. Zero Vite warnings about missing modules.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/styles/globals.css \
|
||
frontend/src/pages/Docs/UploadModal.tsx \
|
||
frontend/src/pages/Docs/DocsPage.tsx \
|
||
frontend/src/pages/Status/StatusPage.tsx
|
||
git commit -m "feat: add upload modal with drag-drop, metadata form, and queue panel"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
**1. Spec coverage:**
|
||
|
||
| Issue | Task |
|
||
|---|---|
|
||
| tailwind darkMode: 'class' mismatch | Task 1 |
|
||
| index.html title "regulation-rag" | Task 1 |
|
||
| start.sh port 8001 hardcoded | Task 1 |
|
||
| types/theme.ts dead code | Task 1 |
|
||
| shell-config.ts dead code | Task 1 |
|
||
| components/ui/* dead code | Task 1 |
|
||
| sidebar-w, radius-sm/md, warn color off | Task 2 |
|
||
| Missing @media prefers-color-scheme | Task 2 |
|
||
| Sidebar brand SVG + wrong text hierarchy | Task 3 |
|
||
| Perception page search unconnected | Task 4 |
|
||
| Status page no loading state | Task 5 |
|
||
| Status page Refresh/Export unwired | Task 5 |
|
||
| Upload button has no modal | Task 6 |
|
||
|
||
**2. Placeholder scan:** No "TBD" or "TODO" in any task. All code blocks are complete and runnable.
|
||
|
||
**3. Type consistency:**
|
||
- `UploadModal` props: `{ onClose: () => void }` — used consistently in DocsPage and StatusPage.
|
||
- `showUpload: boolean` state — consistent across both pages.
|
||
- `refreshKey: number` state — used only in StatusPage.
|
||
- `searchQuery: string` state — used only in PerceptionPage.
|
||
- `loading: boolean` state — used only in StatusPage.
|