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

37 KiB
Raw Blame History

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

# 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 (: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:

/* ── 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:

/* 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 5662 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 8386:

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 138141:

<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 4148 (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 5365, 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 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):

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:

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