diff --git a/.env.local.example b/.env.local.example
new file mode 100644
index 0000000..1b877ec
--- /dev/null
+++ b/.env.local.example
@@ -0,0 +1,8 @@
+# URL of the NexusRAG API (server-side proxy target)
+NEXUSRAG_API_URL=http://localhost:8000
+
+# API key passed as Bearer token from the browser (public — no secrets here)
+NEXT_PUBLIC_API_KEY=your-api-key-here
+
+# Corpus ID to use in the /run page
+NEXT_PUBLIC_DEFAULT_CORPUS_ID=c1
diff --git a/.gitignore b/.gitignore
index 3c9040f..f920d81 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,30 @@
-.venv/
+# Vercel
+.vercel
+
+# Next.js
+.next/
+out/
+next-env.d.ts.bak
+tsconfig.tsbuildinfo
+
+# Node
+node_modules/
+package-lock.json
+
+# Python
__pycache__/
-.pytest_cache/
*.pyc
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
+.venv/
+*.egg-info/
dist/
build/
-*.egg-info/
-.vercel
+
+# OS
+.DS_Store
+
+# Local env
+.env.local
+.env*.local
diff --git a/README.md b/README.md
index 10006ea..c442b31 100644
--- a/README.md
+++ b/README.md
@@ -1,111 +1,53 @@
-# EvalOps Workbench
+# Agent Runbook Orchestrator — Showcase Dashboard
-A local-first evaluation harness for prompts, tools, and agents with regression tracking and experiment history.
+Next.js 14 dashboard for the Agent Runbook Orchestrator showcase deploy.
+Same Vercel-grade design system as the NexusRAG dashboard, adapted for
+showcase tier (no auth, no BFF — only the public `/api/stats` endpoint
+is real).
-## Problem
+## Stack
-LLM teams lack a lightweight way to compare prompt and tool changes before shipping.
+- Next.js 14 App Router · TypeScript strict · Tailwind 3 · Geist Sans + Mono
+- Radix UI primitives · cmdk (⌘K) · sonner · next-themes · framer-motion
+- vitest + Testing Library
-## Users
+## Routes
-Agent builders, prompt engineers, applied AI teams
+| path | what it shows |
+|---|---|
+| `/` | Overview — pitch banner, live `/api/stats` Tier-B counters, system status, audience + stack |
+| `/telemetry` | Polling Tier-B telemetry consumer — full metric grid, raw JSON, contract docs, 30s visibility-aware polling |
+| `/capabilities` | MVP scope, problem statement, why-now, audience, stack — sourced from `project.json` |
+| `/roadmap` | Three-phase timeline (showcase → MVP build → Tier-A graduation) |
+| `/settings` | Theme + project metadata |
-## Core Capabilities
-
-- Load datasets from JSON or CSV
-- Run prompt or agent variants
-- Score outputs with rubric functions
-- Compare runs and export regressions
-
-## Why This Matters
-
-Evaluation is moving from optional best practice to baseline engineering hygiene.
-
-## Architecture
-
-- `core`: domain logic for evalops workbench.
-- `cli`: operator-facing entrypoint for local workflows and smoke checks.
-- `docs/`: product notes, roadmap, and architecture decisions.
-- `tests/`: baseline regression coverage for the project contract.
-
-## Local Usage
+## Local development
```bash
-uv run evalops-workbench summary
-uv run evalops-workbench capabilities
-uv run evalops-workbench roadmap
+cd frontend
+npm install
+npm run dev # http://localhost:3000
```
-## Initial Stack Direction
+## Scripts
-Python, Typer, DuckDB, OpenTelemetry
+| command | what it does |
+|---|---|
+| `npm run dev` | Local dev server |
+| `npm run build` | Production build |
+| `npm run lint` | Next.js ESLint |
+| `npm run type-check` | `tsc --noEmit` |
+| `npm test` | Run the vitest suite |
-## Delivery Standard
-
-- Clear product thesis
-- Setup that works locally
-- Tests for the primary contract
-- Documentation for roadmap and architecture
-- Space for production integrations in the next iteration
-
-## Showcase
-
-This repository ships with a static Vercel-ready landing page for demos and previews.
-
-```bash
-vercel deploy -y
-```
+## Deployment
-The deployed site presents EvalOps Workbench as a standalone product page.
+Deploys as its own Vercel project pointing at `/frontend` rootDir; the
+existing `agent-runbook-orchestrator` Vercel project continues to serve
+the static landing page and `/api/stats` Python serverless function.
-## Production telemetry
+## Keyboard shortcuts
-This deployment exposes public, aggregate metrics at `/api/stats`. The endpoint
-is consumed by the Production Telemetry panel on https://eleventh.dev. The
-schema is documented at
-https://github.com/IgnazioDS/IgnazioDS/blob/main/TELEMETRY_SCHEMA.md.
-
-This system is in **showcase mode** — the Vercel deploy is a public landing
-page, not a system processing production workload. The endpoint exposes real
-GitHub-derived metrics about the codebase rather than fabricated activity
-counters. Tier-A workload metrics (`eval_runs_total`, `last_pass_rate`,
-`regressions_caught_30d`, etc.) are added when the system is promoted from
-showcase to production.
-
-Sample response:
-
-```bash
-$ curl -i https://evalops-workbench.vercel.app/api/stats
-HTTP/1.1 200 OK
-Content-Type: application/json
-Cache-Control: public, max-age=30, stale-while-revalidate=60
-Access-Control-Allow-Origin: *
-
-{
- "system": "evalops",
- "mode": "showcase",
- "status": "operational",
- "last_deployed_at": "2026-04-27T18:41:57Z",
- "last_commit_at": "2026-04-01T16:54:50Z",
- "metrics": {
- "commits_30d": 1,
- "commits_total": 3,
- "primary_language": "Python",
- "repo_stars": 0,
- "lines_of_code": 1177
- },
- "schema_version": 1,
- "generated_at": "2026-04-27T18:42:18Z"
-}
-```
-
-The endpoint never returns HTTP 5xx. If GitHub is unreachable, the response
-status flips to `"degraded"` and metric values fall back to last known good
-(or zero) values, while the JSON contract remains valid.
-
-To regenerate `lines_of_code` before deploying:
-
-```bash
-python3 scripts/compute_telemetry_static.py
-git add api/_telemetry_static.json
-```
+| keys | action |
+|---|---|
+| ⌘K / Ctrl+K | Command palette |
+| G then O / T / C / R | Overview / Telemetry / Capabilities / Roadmap |
diff --git a/index.html b/index.html
deleted file mode 100644
index c385cb4..0000000
--- a/index.html
+++ /dev/null
@@ -1,137 +0,0 @@
-
-
-
-
-
- EvalOps Workbench
-
-
-
-
-
-
-
-
-
-
-
-
- evalops-workbench
-
-
-
-
-
-
Shipping System
-
EvalOps Workbench
-
A local-first evaluation harness for prompts, tools, and agents with regression tracking and experiment history.
-
-
-
-
-
- Status
- Researching
-
-
- Category
- Developer Tool
-
-
- Track
- LLM
-
-
- Audience
- Agent builders, prompt engineers, applied AI teams
-
-
-
-
-
-
-
-
- Problem
- Operational pain, made explicit.
- LLM teams lack a lightweight way to compare prompt and tool changes before shipping.
-
-
-
- Why Now
- Built for a market that already feels the gap.
- Evaluation is moving from optional best practice to baseline engineering hygiene.
-
-
-
-
- Core Capabilities
- Focused scope, credible surface area.
-
-
-
-
- Capability 1
- Load datasets from JSON or CSV
- Designed as a production-facing workflow instead of a throwaway demo path.
-
-
-
- Capability 2
- Run prompt or agent variants
- Designed as a production-facing workflow instead of a throwaway demo path.
-
-
-
- Capability 3
- Score outputs with rubric functions
- Designed as a production-facing workflow instead of a throwaway demo path.
-
-
-
- Capability 4
- Compare runs and export regressions
- Designed as a production-facing workflow instead of a throwaway demo path.
-
-
-
-
-
- Stack Direction
- Implementation posture
-
- Python Typer DuckDB OpenTelemetry
-
-
-
-
- Local Entry Points
- Minimal interface, easy to demo
- uv run evalops-workbench summary
-uv run evalops-workbench capabilities
-uv run evalops-workbench roadmap
-vercel deploy -y
-
-
-
-
-
-
diff --git a/next-env.d.ts b/next-env.d.ts
new file mode 100644
index 0000000..4f11a03
--- /dev/null
+++ b/next-env.d.ts
@@ -0,0 +1,5 @@
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/basic-features/typescript for more information.
diff --git a/next.config.mjs b/next.config.mjs
new file mode 100644
index 0000000..ea3ba96
--- /dev/null
+++ b/next.config.mjs
@@ -0,0 +1,31 @@
+// Hybrid Next.js + Python deploy. The dashboard and the public /api/stats
+// Python serverless function (api/stats.py) live in the same Vercel project,
+// so the dashboard reaches /api/stats via Vercel's standard routing — no
+// rewrite required.
+
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ reactStrictMode: true,
+ poweredByHeader: false,
+ experimental: {
+ optimizePackageImports: ["lucide-react", "recharts"],
+ },
+ async headers() {
+ return [
+ {
+ source: "/(.*)",
+ headers: [
+ { key: "X-Content-Type-Options", value: "nosniff" },
+ { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
+ { key: "X-Frame-Options", value: "DENY" },
+ {
+ key: "Permissions-Policy",
+ value: "camera=(), microphone=(), geolocation=()",
+ },
+ ],
+ },
+ ];
+ },
+};
+
+export default nextConfig;
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..d693205
--- /dev/null
+++ b/package.json
@@ -0,0 +1,64 @@
+{
+ "name": "evalops-dashboard",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint",
+ "type-check": "tsc --noEmit",
+ "test": "vitest run",
+ "test:watch": "vitest"
+ },
+ "dependencies": {
+ "next": "14.2.5",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "next-themes": "^0.3.0",
+ "geist": "^1.3.1",
+ "lucide-react": "^0.436.0",
+ "clsx": "^2.1.1",
+ "tailwind-merge": "^2.5.2",
+ "class-variance-authority": "^0.7.0",
+ "framer-motion": "^11.5.0",
+ "sonner": "^1.5.0",
+ "cmdk": "^1.0.0",
+ "vaul": "^0.9.4",
+ "recharts": "^2.12.7",
+ "zod": "^3.23.8",
+ "@radix-ui/react-slot": "^1.1.0",
+ "@radix-ui/react-select": "^2.1.1",
+ "@radix-ui/react-progress": "^1.1.0",
+ "@radix-ui/react-separator": "^1.1.0",
+ "@radix-ui/react-tooltip": "^1.1.2",
+ "@radix-ui/react-dialog": "^1.1.1",
+ "@radix-ui/react-dropdown-menu": "^2.1.1",
+ "@radix-ui/react-tabs": "^1.1.0",
+ "@radix-ui/react-popover": "^1.1.1",
+ "@radix-ui/react-toggle-group": "^1.1.0",
+ "@radix-ui/react-collapsible": "^1.1.0",
+ "@radix-ui/react-avatar": "^1.1.0",
+ "@radix-ui/react-scroll-area": "^1.1.0",
+ "@radix-ui/react-visually-hidden": "^1.1.0"
+ },
+ "devDependencies": {
+ "typescript": "^5.5.4",
+ "@types/node": "^22.5.1",
+ "@types/react": "^18.3.4",
+ "@types/react-dom": "^18.3.0",
+ "tailwindcss": "^3.4.10",
+ "tailwindcss-animate": "^1.0.7",
+ "autoprefixer": "^10.4.20",
+ "postcss": "^8.4.45",
+ "eslint": "^8.57.0",
+ "eslint-config-next": "14.2.5",
+ "vitest": "^2.0.5",
+ "@vitest/ui": "^2.0.5",
+ "@testing-library/react": "^16.0.1",
+ "@testing-library/jest-dom": "^6.5.0",
+ "@testing-library/user-event": "^14.5.2",
+ "jsdom": "^25.0.0",
+ "@vitejs/plugin-react": "^4.3.1"
+ }
+}
diff --git a/postcss.config.js b/postcss.config.js
new file mode 100644
index 0000000..12a703d
--- /dev/null
+++ b/postcss.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..1b1919c
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/pyproject.toml b/pyproject.toml
index e05e939..daee323 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -19,6 +19,10 @@ evalops-workbench = "evalops_workbench.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
+# Scope discovery to the Python package only — the Next.js dashboard
+# also lives under src/ and we don't want setuptools treating those
+# directories as Python modules.
+include = ["evalops_workbench*"]
[tool.setuptools.package-data]
evalops_workbench = ["project.json"]
diff --git a/src/app/capabilities/page.tsx b/src/app/capabilities/page.tsx
new file mode 100644
index 0000000..e1cf3fd
--- /dev/null
+++ b/src/app/capabilities/page.tsx
@@ -0,0 +1,147 @@
+import {
+ Boxes,
+ CheckCircle2,
+ Sparkles,
+ Target,
+ Users,
+} from "lucide-react";
+import { TopBar } from "@/components/layout/TopBar";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { PROJECT } from "@/lib/project";
+
+export const metadata = { title: "Capabilities" };
+
+export default function CapabilitiesPage() {
+ return (
+ <>
+
+
+
+ {/* Pitch */}
+
+
+
+ {PROJECT.stage}
+ {PROJECT.category}
+ {PROJECT.track}
+
+ {PROJECT.name}
+
+ {PROJECT.summary}
+
+
+
+
+ {/* Problem + Why now */}
+
+
+
+
+
+ Problem
+
+
+
+
+ {PROJECT.problem}
+
+
+
+
+
+
+
+ Why now
+
+
+
+
+ {PROJECT.why_now}
+
+
+
+
+
+ {/* MVP scope */}
+
+
+
+
+ MVP scope
+
+
+ What ships first. Each item is a hard commitment, not a
+ stretch goal.
+
+
+
+
+ {PROJECT.mvp.map((item, i) => (
+
+
+ {i + 1}
+
+
+ {item}
+
+
+ ))}
+
+
+
+
+ {/* Audience + Stack */}
+
+
+
+
+
+ Built for
+
+
+
+
+ {PROJECT.users}
+
+
+
+
+
+
+
+ Stack
+
+
+ Initial direction — subject to refinement once the MVP is
+ exercised against real workload.
+
+
+
+
+ {PROJECT.stack.map((s) => (
+
+ {s}
+
+ ))}
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/error.tsx b/src/app/error.tsx
new file mode 100644
index 0000000..e4f919a
--- /dev/null
+++ b/src/app/error.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import { useEffect } from "react";
+import { AlertOctagon, RefreshCw } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+/**
+ * Root error boundary. Next.js mounts this when a rendered route throws.
+ * Keep the UI uncluttered; the goal is to reassure the user the app didn't
+ * fall on its face and offer one obvious recovery action.
+ */
+export default function Error({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ useEffect(() => {
+ if (typeof window !== "undefined") {
+ // eslint-disable-next-line no-console
+ console.error("Route error:", error);
+ }
+ }, [error]);
+
+ return (
+
+
+
+
+ Something went wrong
+
+
+ The page hit an unexpected error.
+ {error.digest && (
+
+ ref: {error.digest}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/app/globals.css b/src/app/globals.css
new file mode 100644
index 0000000..1cfe08c
--- /dev/null
+++ b/src/app/globals.css
@@ -0,0 +1,238 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+/* ──────────────────────────────────────────────────────────────────────────
+ Design tokens — calibrated for a Vercel-grade dashboard.
+
+ Discipline: neutral surfaces + a single brand accent fired sparingly.
+ Indigo only for active state, brand mark, and primary CTA. Everything
+ else lives in calibrated grays.
+ ────────────────────────────────────────────────────────────────────────── */
+@layer base {
+ :root,
+ [data-theme="dark"] {
+ /* Surfaces — true near-black with imperceptible cool tilt */
+ --background: 240 10% 3.5%;
+ --surface: 240 8% 6%;
+ --surface-2: 240 7% 9%;
+ --surface-3: 240 6% 12%;
+
+ /* Text tiers */
+ --foreground: 0 0% 96%;
+ --foreground-muted: 240 5% 64%;
+ --foreground-subtle: 240 5% 46%;
+ --foreground-faint: 240 5% 32%;
+
+ /* Borders — three calibrated weights */
+ --border: 240 6% 14%;
+ --border-strong: 240 6% 22%;
+ --border-subtle: 240 6% 9%;
+
+ /* Brand */
+ --brand: 238 84% 67%;
+ --brand-strong: 238 84% 60%;
+ --brand-foreground: 0 0% 100%;
+ --ring: 238 84% 67%;
+
+ /* Semantic */
+ --success: 158 64% 52%;
+ --warning: 38 92% 50%;
+ --danger: 0 72% 60%;
+ --info: 199 89% 48%;
+
+ /* Radii — tight Vercel-style */
+ --radius-sm: 4px;
+ --radius-md: 6px;
+ --radius-lg: 8px;
+ --radius-xl: 12px;
+ --radius: var(--radius-lg);
+ }
+
+ [data-theme="light"] {
+ --background: 0 0% 100%;
+ --surface: 0 0% 100%;
+ --surface-2: 240 5% 98%;
+ --surface-3: 240 5% 95%;
+ --foreground: 240 10% 8%;
+ --foreground-muted: 240 5% 36%;
+ --foreground-subtle: 240 5% 50%;
+ --foreground-faint: 240 5% 64%;
+ --border: 240 6% 90%;
+ --border-strong: 240 6% 84%;
+ --border-subtle: 240 6% 94%;
+ --ring: 238 84% 67%;
+ }
+
+ *,
+ *::before,
+ *::after {
+ @apply border-border box-border;
+ }
+
+ html {
+ @apply scroll-smooth;
+ font-feature-settings: "cv02", "cv03", "cv04", "cv11", "ss01";
+ }
+
+ body {
+ @apply bg-background text-foreground antialiased font-sans;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ }
+
+ ::selection {
+ background: hsl(var(--brand) / 0.4);
+ color: hsl(var(--foreground));
+ }
+
+ /* Scrollbars — minimal, only visible when needed */
+ ::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+ }
+ ::-webkit-scrollbar-track {
+ background: transparent;
+ }
+ ::-webkit-scrollbar-thumb {
+ background: hsl(var(--border-strong));
+ border-radius: 9999px;
+ border: 1px solid transparent;
+ background-clip: padding-box;
+ }
+ ::-webkit-scrollbar-thumb:hover {
+ background: hsl(var(--foreground-faint));
+ background-clip: padding-box;
+ }
+
+ /* Focus — visible but quiet */
+ :focus-visible {
+ @apply outline-none ring-1 ring-ring ring-offset-1 ring-offset-background;
+ }
+
+ /* Tabular numbers everywhere — dashboards live and die on alignment */
+ .tabular-nums {
+ font-variant-numeric: tabular-nums;
+ }
+}
+
+/* ──────────────────────────────────────────────────────────────────────────
+ Utility classes — short, composable, no business logic
+ ────────────────────────────────────────────────────────────────────────── */
+@layer components {
+ /* Subtle dot grid — used as a faint background ornament */
+ .dot-grid {
+ background-image: radial-gradient(
+ hsl(var(--foreground) / 0.05) 1px,
+ transparent 1px
+ );
+ background-size: 24px 24px;
+ background-position: 0 0;
+ }
+
+ /* Linear gradient over the dot grid for stronger framing */
+ .grid-fade {
+ mask-image: radial-gradient(
+ ellipse at center,
+ black 40%,
+ transparent 80%
+ );
+ }
+
+ /* Surface helper — used by Card/Popover. Keeps consistent stacking. */
+ .surface {
+ @apply bg-surface border border-border rounded-lg;
+ }
+
+ /* Skeleton shimmer */
+ .skeleton-shimmer {
+ background: linear-gradient(
+ 90deg,
+ hsl(var(--surface-2)) 0%,
+ hsl(var(--surface-3)) 50%,
+ hsl(var(--surface-2)) 100%
+ );
+ background-size: 200% 100%;
+ animation: shimmer 1.6s linear infinite;
+ }
+
+ /* Pulse halo — for status dots indicating live activity */
+ .pulse-halo {
+ position: relative;
+ }
+ .pulse-halo::after {
+ content: "";
+ position: absolute;
+ inset: -4px;
+ border-radius: 50%;
+ background: currentColor;
+ opacity: 0.6;
+ animation: pulse-ring 1.6s cubic-bezier(0.215, 0.61, 0.355, 1) infinite;
+ }
+
+ /* Inline keyboard chip */
+ .kbd {
+ @apply inline-flex items-center justify-center min-w-[1.25rem] h-5
+ px-1 rounded-sm bg-surface-2 border border-border-strong
+ font-mono text-2xs font-medium text-foreground-muted;
+ }
+
+ /* Code-grade gradient text — used VERY sparingly (logo only) */
+ .text-gradient-brand {
+ background: linear-gradient(
+ 135deg,
+ hsl(var(--brand)) 0%,
+ hsl(var(--brand-strong)) 100%
+ );
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ }
+
+ /* Subtle elevated card hover treatment */
+ .card-interactive {
+ @apply transition-[border-color,background-color,transform] duration-200 ease-out-expo;
+ }
+ .card-interactive:hover {
+ @apply border-border-strong;
+ }
+
+ /* Page transition wrapper applied to every route's root */
+ .page-enter {
+ animation: fade-up 320ms cubic-bezier(0.16, 1, 0.3, 1);
+ }
+}
+
+/* sonner overrides to match Vercel-grade palette */
+[data-sonner-toaster] {
+ --normal-bg: hsl(var(--surface-2));
+ --normal-border: hsl(var(--border-strong));
+ --normal-text: hsl(var(--foreground));
+ --success-bg: hsl(var(--surface-2));
+ --success-border: hsl(var(--success) / 0.4);
+ --success-text: hsl(var(--success));
+ --error-bg: hsl(var(--surface-2));
+ --error-border: hsl(var(--danger) / 0.4);
+ --error-text: hsl(var(--danger));
+}
+
+/* cmdk styling — match the rest of the design system */
+[cmdk-root] {
+ font-family: inherit;
+}
+[cmdk-input] {
+ @apply outline-none border-0 bg-transparent text-foreground placeholder:text-foreground-faint;
+}
+[cmdk-item] {
+ @apply px-3 py-2 rounded-md text-sm cursor-pointer flex items-center gap-3
+ text-foreground-muted transition-colors;
+}
+[cmdk-item][data-selected="true"] {
+ @apply bg-surface-3 text-foreground;
+}
+[cmdk-empty] {
+ @apply px-3 py-6 text-center text-sm text-foreground-subtle;
+}
+[cmdk-group-heading] {
+ @apply px-3 pt-3 pb-1 text-2xs font-medium uppercase tracking-wider text-foreground-faint;
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
new file mode 100644
index 0000000..789e6c4
--- /dev/null
+++ b/src/app/layout.tsx
@@ -0,0 +1,74 @@
+import type { Metadata, Viewport } from "next";
+import { GeistSans } from "geist/font/sans";
+import { GeistMono } from "geist/font/mono";
+import "./globals.css";
+
+import { ThemeProvider } from "@/components/providers/theme-provider";
+import { Toaster } from "@/components/providers/toaster";
+import { CommandPaletteProvider } from "@/components/layout/CommandPalette";
+import { TooltipProvider } from "@/components/ui/tooltip";
+import { Sidebar } from "@/components/layout/Sidebar";
+import { PROJECT } from "@/lib/project";
+
+export const metadata: Metadata = {
+ metadataBase: new URL(`https://${PROJECT.slug}.vercel.app`),
+ title: {
+ default: `${PROJECT.name} — ${PROJECT.summary.replace(/\.$/, "")}`,
+ template: `%s · ${PROJECT.name}`,
+ },
+ description: PROJECT.summary,
+ applicationName: PROJECT.name,
+ authors: [{ name: "Ignazio De Santis" }],
+ keywords: [PROJECT.category, PROJECT.track, ...PROJECT.stack],
+ openGraph: {
+ type: "website",
+ title: PROJECT.name,
+ description: PROJECT.summary,
+ siteName: PROJECT.name,
+ },
+ twitter: {
+ card: "summary",
+ title: PROJECT.name,
+ description: PROJECT.summary,
+ },
+ icons: {
+ icon: [{ url: "/favicon.svg", type: "image/svg+xml" }],
+ },
+};
+
+export const viewport: Viewport = {
+ themeColor: [
+ { media: "(prefers-color-scheme: dark)", color: "#08080d" },
+ { media: "(prefers-color-scheme: light)", color: "#ffffff" },
+ ],
+};
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/loading.tsx b/src/app/loading.tsx
new file mode 100644
index 0000000..e8a50ce
--- /dev/null
+++ b/src/app/loading.tsx
@@ -0,0 +1,34 @@
+import { Skeleton } from "@/components/ui/skeleton";
+
+/**
+ * Root-level loading skeleton. Next.js renders this between route
+ * transitions while the page's data dependencies resolve.
+ */
+export default function Loading() {
+ return (
+ <>
+
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
new file mode 100644
index 0000000..6d00a45
--- /dev/null
+++ b/src/app/not-found.tsx
@@ -0,0 +1,32 @@
+import Link from "next/link";
+import { ArrowLeft, FileQuestion } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+export default function NotFound() {
+ return (
+
+
+
+
+
+
+ 404
+
+
+ Page not found
+
+
+ The page you're looking for doesn't exist or has moved.
+
+
+
+
+
+ Back to overview
+
+
+
+
+
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
new file mode 100644
index 0000000..523db47
--- /dev/null
+++ b/src/app/page.tsx
@@ -0,0 +1,269 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import {
+ ArrowRight,
+ ExternalLink,
+ GitCommit,
+ Github,
+ Lightbulb,
+ Star,
+ TrendingUp,
+ Users,
+} from "lucide-react";
+import { fetchPublicStats, type PublicStats } from "@/lib/api";
+import { TopBar } from "@/components/layout/TopBar";
+import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { StatusDot } from "@/components/ui/status-dot";
+import { StatCard } from "@/components/dashboard/StatCard";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Sparkline } from "@/components/ui/sparkline";
+import { PROJECT } from "@/lib/project";
+import { formatRelative } from "@/lib/utils";
+
+/**
+ * Build a deterministic 10-point shape derived from the live value, so
+ * StatCard sparklines convey velocity without claiming a measured history
+ * the showcase tier doesn't have.
+ */
+function shapeFromValue(target: number, points = 10): number[] {
+ if (target <= 0) return Array(points).fill(0);
+ const result: number[] = [];
+ for (let i = 0; i < points; i++) {
+ const ratio = i / (points - 1);
+ const eased = ratio * ratio;
+ const wobble = Math.sin(i + target) * 0.06;
+ result.push(target * (eased + wobble + 0.1));
+ }
+ return result;
+}
+
+export default function OverviewPage() {
+ const [stats, setStats] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ fetchPublicStats()
+ .then(setStats)
+ .catch(() => null)
+ .finally(() => setLoading(false));
+ }, []);
+
+ const commitsTotal = (stats?.metrics.commits_total as number | undefined) ?? 0;
+ const commits30d = (stats?.metrics.commits_30d as number | undefined) ?? 0;
+ const stars = (stats?.metrics.repo_stars as number | undefined) ?? 0;
+ const loc = (stats?.metrics.lines_of_code as number | undefined) ?? 0;
+
+ return (
+ <>
+
+
+ Open telemetry
+
+
+
+ }
+ />
+
+
+ {/* Pitch banner */}
+
+
+
+
+ {PROJECT.stage}
+ {PROJECT.category}
+ {PROJECT.track}
+
+
+ {PROJECT.summary}
+
+
+ Problem. {PROJECT.problem}{" "}
+ Why now. {PROJECT.why_now}
+
+
+
+
+
+
+ {/* Stat row — wired to real /api/stats Tier-B values */}
+
+
+
+
+
+
+
+ {/* Status row */}
+
+
+ System status
+
+
+ {stats?.status ?? "unknown"}
+
+
+
+
+
+
+
+
+
+
+ {/* Users + audience */}
+
+
+
+
+
+ Built for
+
+ {PROJECT.users}
+
+
+
+ Stack
+
+
+ {PROJECT.stack.map((s) => (
+
+ {s}
+
+ ))}
+
+
+
+
+
+ What ships first
+ The MVP scope this project commits to.
+
+
+
+ {PROJECT.mvp.map((item, i) => (
+
+
+ {item}
+
+ ))}
+
+
+
+
+
+
+ >
+ );
+}
+
+function StatusCell({
+ label,
+ value,
+ hint,
+}: {
+ label: string;
+ value: string;
+ hint?: string;
+}) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+ {hint && (
+
+ {hint}
+
+ )}
+
+ );
+}
diff --git a/src/app/roadmap/page.tsx b/src/app/roadmap/page.tsx
new file mode 100644
index 0000000..d5179c8
--- /dev/null
+++ b/src/app/roadmap/page.tsx
@@ -0,0 +1,162 @@
+import { Activity, Compass, Github, Map as MapIcon, Rocket } from "lucide-react";
+import { TopBar } from "@/components/layout/TopBar";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { PROJECT } from "@/lib/project";
+
+export const metadata = { title: "Roadmap" };
+
+interface Phase {
+ label: string;
+ status: "now" | "next" | "later";
+ description: string;
+ items: string[];
+}
+
+const PHASES: Phase[] = [
+ {
+ label: "Now — Showcase deploy",
+ status: "now",
+ description:
+ "The Vercel deploy is a public landing page with a public telemetry endpoint. The API skeleton is documented; production workload is not yet routed through it.",
+ items: [
+ "Static landing page is live at vercel.app",
+ "Public /api/stats Tier-B telemetry endpoint",
+ "GitHub-derived metrics behind a 5-minute cache",
+ "TELEMETRY_SCHEMA.md as the public contract",
+ ],
+ },
+ {
+ label: "Next — MVP build",
+ status: "next",
+ description: `Implement the four MVP commitments. After this phase the system is promoted from "showcase" to "live" and the telemetry contract upgrades to Tier-A.`,
+ items: PROJECT.mvp,
+ },
+ {
+ label: "Later — Production graduation",
+ status: "later",
+ description:
+ "Once the MVP runs real workload, the dashboard upgrades to Tier-A telemetry with workload counters (per-system metric set), middleware-recorded query/run logs, and Postgres-persisted aggregations.",
+ items: [
+ "Tier-A live telemetry replaces Tier-B GitHub-derived metrics",
+ "Postgres persistence for workload counters",
+ "Middleware-driven recording with privacy invariants",
+ "Audit trail surfaced in the dashboard",
+ ],
+ },
+];
+
+const TONE: Record = {
+ now: "brand",
+ next: "info",
+ later: "muted",
+};
+
+export default function RoadmapPage() {
+ return (
+ <>
+
+
+
+ GitHub
+
+
+ }
+ />
+
+
+ {/* Stage card */}
+
+
+
+
+
+ Current stage
+
+
+ Where this project sits today.
+
+
+
+ {PROJECT.stage}
+
+
+
+
+ {/* Phases */}
+
+ {PHASES.map((phase, i) => (
+
+ {/* Vertical timeline connector */}
+ {i < PHASES.length - 1 && (
+
+ )}
+
+
+
+ {phase.status === "now" ? (
+
+ ) : phase.status === "next" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {phase.label}
+
+ {phase.status}
+
+
+
+ {phase.description}
+
+
+
+
+ {phase.items.map((item, idx) => (
+
+
+ {item}
+
+ ))}
+
+
+
+
+
+ ))}
+
+
+
+ >
+ );
+}
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx
new file mode 100644
index 0000000..987b213
--- /dev/null
+++ b/src/app/settings/page.tsx
@@ -0,0 +1,157 @@
+"use client";
+
+import { Github, Globe, Sparkles } from "lucide-react";
+import { useTheme } from "next-themes";
+import { TopBar } from "@/components/layout/TopBar";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Badge } from "@/components/ui/badge";
+import { PROJECT } from "@/lib/project";
+
+export default function SettingsPage() {
+ const { theme, setTheme } = useTheme();
+
+ return (
+ <>
+
+
+
+
+
+
+
+ Appearance
+
+
+ Switches between dark, light, and system-default themes.
+
+
+
+
+
Theme
+
+
+
+
+
+ Dark
+ Light
+ System
+
+
+
+
+
+
+
+
+
+
+ Project
+
+
+ Static project metadata sourced from{" "}
+ project.json.
+
+
+
+
+
+
+
+
+
+
+ Stack
+
+
+ {PROJECT.stack.map((s) => (
+
+ {s}
+
+ ))}
+
+
+
+
+
+
+
+ Resources
+
+ External links for documentation and source.
+
+
+
+
+
+
+ GitHub
+
+
+
+
+ Landing page
+
+
+
+
+ Telemetry schema
+
+
+
+
+
+
+ >
+ );
+}
+
+function KV({
+ label,
+ value,
+ mono,
+}: {
+ label: string;
+ value: string;
+ mono?: boolean;
+}) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
diff --git a/src/app/telemetry/page.tsx b/src/app/telemetry/page.tsx
new file mode 100644
index 0000000..f888169
--- /dev/null
+++ b/src/app/telemetry/page.tsx
@@ -0,0 +1,353 @@
+"use client";
+
+import { useState } from "react";
+import {
+ CheckCircle2,
+ Code2,
+ GitCommit,
+ Layers,
+ RefreshCw,
+ Star,
+} from "lucide-react";
+import { fetchPublicStats, type PublicStats } from "@/lib/api";
+import { TopBar } from "@/components/layout/TopBar";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { StatusDot } from "@/components/ui/status-dot";
+import { CodeBlock } from "@/components/ui/code-block";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { usePolling } from "@/lib/hooks";
+import { PROJECT } from "@/lib/project";
+import {
+ formatDate,
+ formatNumber,
+ formatRelative,
+} from "@/lib/utils";
+
+const POLL_INTERVAL_MS = 30_000;
+
+export default function TelemetryPage() {
+ const { data: stats, loading, error, refetch } = usePolling(
+ fetchPublicStats,
+ POLL_INTERVAL_MS,
+ );
+ const [tab, setTab] = useState("overview");
+
+ return (
+ <>
+
+
+ Refresh
+
+ }
+ />
+
+
+ {/* Header status */}
+
+
+
+
+
+
+ {stats?.system ?? PROJECT.system_slug}
+
+
+ {stats
+ ? `${stats.status} · schema v${stats.schema_version}`
+ : "loading…"}
+
+
+
+
+
+ {stats?.mode ?? "showcase"}
+
+
+ generated {formatRelative(stats?.generated_at)}
+
+
+
+
+
+ {error && !stats && (
+
+
+
+
+
+ Couldn't reach the telemetry endpoint
+
+
+ {error.message}
+
+
+
+
+ )}
+
+ {/* Tier-B metric grid */}
+
+
+
+
+
+
+
+
+
+
+
+ {/* Tabbed details */}
+
+
+
+
+ Overview
+ Contract
+ Raw response
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ This endpoint runs in{" "}
+
+ mode: "showcase"
+ {" "}
+ per the public schema at{" "}
+
+ TELEMETRY_SCHEMA.md
+
+ . Counters are sourced from the GitHub REST API
+ (commits, language, stars) plus a build-time line-of-code
+ snapshot, behind a 5-minute module-scope cache.
+
+
+ The endpoint never returns 5xx — GitHub failures degrade
+ to{" "}
+
+ status: "degraded"
+ {" "}
+ with the last cached response (or zeros) and a
+ contract-valid envelope.
+
+
+ {`curl -i https://${PROJECT.slug}.vercel.app/api/stats`}
+
+
+
+
+
+
+ {stats
+ ? JSON.stringify(stats, null, 2)
+ : "Loading current response…"}
+
+
+
+
+
+
+
+ >
+ );
+}
+
+function MetricTile({
+ label,
+ value,
+ icon: Icon,
+ loading,
+}: {
+ label: string;
+ value: string;
+ icon: typeof GitCommit;
+ loading: boolean;
+}) {
+ return (
+
+
+
+ {loading ? (
+
+ ) : (
+
+ {value}
+
+ )}
+
+
+ );
+}
+
+function DetailRow({
+ label,
+ value,
+ hint,
+ mono,
+ tone,
+}: {
+ label: string;
+ value: string;
+ hint?: string;
+ mono?: boolean;
+ tone?: "success" | "warning" | "danger";
+}) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+ {hint && (
+
+ {hint}
+
+ )}
+
+ );
+}
diff --git a/src/components/dashboard/StatCard.test.tsx b/src/components/dashboard/StatCard.test.tsx
new file mode 100644
index 0000000..ad30391
--- /dev/null
+++ b/src/components/dashboard/StatCard.test.tsx
@@ -0,0 +1,44 @@
+import { describe, expect, it } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { Activity } from "lucide-react";
+import { StatCard } from "./StatCard";
+
+describe("StatCard", () => {
+ it("renders a skeleton in loading state", () => {
+ const { container } = render(
+ ,
+ );
+ // Skeleton class is `skeleton-shimmer`
+ expect(container.querySelectorAll(".skeleton-shimmer").length).toBeGreaterThan(0);
+ });
+
+ it("renders the title and value", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText(/Queries/i)).toBeInTheDocument();
+ // Animated number starts at 0 and ticks up; assert presence of a tabular number container
+ expect(screen.getByText(/Queries/i).parentElement).toBeTruthy();
+ });
+
+ it("uses display override when provided", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("42ms")).toBeInTheDocument();
+ });
+});
diff --git a/src/components/dashboard/StatCard.tsx b/src/components/dashboard/StatCard.tsx
new file mode 100644
index 0000000..e7e53b7
--- /dev/null
+++ b/src/components/dashboard/StatCard.tsx
@@ -0,0 +1,112 @@
+"use client";
+
+import { Skeleton } from "@/components/ui/skeleton";
+import { Sparkline } from "@/components/ui/sparkline";
+import { cn, formatNumber } from "@/lib/utils";
+import type { LucideIcon } from "lucide-react";
+import { ArrowDown, ArrowRight, ArrowUp } from "lucide-react";
+import { useAnimatedNumber } from "@/lib/hooks";
+
+interface StatCardProps {
+ title: string;
+ /** Numeric value drives the count-up animation. Pass undefined when loading. */
+ value: number | undefined;
+ /** Optional human-friendly subtitle ("vs. yesterday"). */
+ subtitle?: string;
+ /** Pre-formatted display override. Use for "—" / "1.2k" cases the formatter can't infer. */
+ display?: string;
+ icon: LucideIcon;
+ trend?: { direction: "up" | "down" | "flat"; value: string };
+ sparkData?: number[];
+ loading?: boolean;
+}
+
+/**
+ * StatCard — primary KPI surface. Numeric values count up on first render
+ * (useAnimatedNumber). Sparkline lives in the lower-right when data is
+ * available. The accent is intentionally restrained — no glow, no gradient,
+ * just a subtle border treatment that strengthens on hover.
+ */
+export function StatCard({
+ title,
+ value,
+ subtitle,
+ display,
+ icon: Icon,
+ trend,
+ sparkData,
+ loading,
+}: StatCardProps) {
+ const animated = useAnimatedNumber(value ?? 0, 600);
+ const formatted = display ?? formatNumber(Math.round(animated));
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {formatted}
+
+
+ {trend && (
+
+ {trend.direction === "up" ? (
+
+ ) : trend.direction === "down" ? (
+
+ ) : (
+
+ )}
+ {trend.value}
+
+ )}
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+
+ {sparkData && sparkData.length > 1 && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/layout/Breadcrumbs.tsx b/src/components/layout/Breadcrumbs.tsx
new file mode 100644
index 0000000..0613391
--- /dev/null
+++ b/src/components/layout/Breadcrumbs.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { ChevronRight, LayoutDashboard } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+const SEGMENT_LABELS: Record = {
+ "": "Overview",
+ telemetry: "Telemetry",
+ capabilities: "Capabilities",
+ roadmap: "Roadmap",
+ settings: "Settings",
+};
+
+function labelFor(segment: string): string {
+ return SEGMENT_LABELS[segment] ?? segment;
+}
+
+export function Breadcrumbs() {
+ const pathname = usePathname();
+ if (pathname === "/") return null;
+
+ const segments = pathname.split("/").filter(Boolean);
+ const crumbs: { href: string; label: string }[] = [
+ { href: "/", label: "Home" },
+ ];
+ let acc = "";
+ for (const segment of segments) {
+ acc += `/${segment}`;
+ crumbs.push({ href: acc, label: labelFor(segment) });
+ }
+
+ return (
+
+ {crumbs.map((c, i) => {
+ const isLast = i === crumbs.length - 1;
+ return (
+
+ {i === 0 ? (
+
+
+
+ ) : isLast ? (
+ {c.label}
+ ) : (
+
+ {c.label}
+
+ )}
+ {!isLast && (
+
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/layout/CommandPalette.tsx b/src/components/layout/CommandPalette.tsx
new file mode 100644
index 0000000..852588e
--- /dev/null
+++ b/src/components/layout/CommandPalette.tsx
@@ -0,0 +1,213 @@
+"use client";
+
+import { Command } from "cmdk";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { useRouter } from "next/navigation";
+import {
+ Activity,
+ ExternalLink,
+ Github,
+ LayoutDashboard,
+ ListChecks,
+ Map,
+ Moon,
+ Settings,
+ Sparkles,
+ Sun,
+} from "lucide-react";
+import { useTheme } from "next-themes";
+import { createContext, useContext, useEffect, useState } from "react";
+import { cn } from "@/lib/utils";
+import { useHotkey } from "@/lib/hooks";
+import { PROJECT } from "@/lib/project";
+
+interface CommandPaletteContextValue {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+}
+
+const CommandPaletteContext = createContext(
+ null,
+);
+
+export function useCommandPalette(): CommandPaletteContextValue {
+ const ctx = useContext(CommandPaletteContext);
+ if (!ctx) throw new Error("CommandPalette context not mounted");
+ return ctx;
+}
+
+export function CommandPaletteProvider({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const [open, setOpen] = useState(false);
+ const router = useRouter();
+ const { setTheme, resolvedTheme } = useTheme();
+
+ useHotkey("k", (e) => {
+ e.preventDefault();
+ setOpen((o) => !o);
+ }, { meta: true });
+
+ const navigate = (href: string) => {
+ setOpen(false);
+ router.push(href);
+ };
+
+ return (
+
+ {children}
+
+
+
+
+
+ Command Palette
+
+
+ Type a command or search.
+
+
+
+
+
+ esc
+
+
+
+ No results.
+
+
+ navigate("/")}>
+
+ Overview
+ G O
+
+ navigate("/telemetry")}>
+
+ Telemetry
+ G T
+
+ navigate("/capabilities")}>
+
+ Capabilities
+ G C
+
+ navigate("/roadmap")}>
+
+ Roadmap
+ G R
+
+ navigate("/settings")}>
+
+ Settings
+
+
+
+
+ {
+ setOpen(false);
+ window.open(PROJECT.github_url, "_blank");
+ }}
+ >
+
+ GitHub repo
+
+
+ {
+ setOpen(false);
+ window.open(
+ `https://${PROJECT.slug}.vercel.app`,
+ "_blank",
+ );
+ }}
+ >
+
+ Landing page
+
+
+
+
+ {
+ setTheme(resolvedTheme === "dark" ? "light" : "dark");
+ setOpen(false);
+ }}
+ >
+ {resolvedTheme === "dark" ? (
+
+ ) : (
+
+ )}
+ Toggle theme
+
+
+
+
+
+
+
+
+ );
+}
+
+export function useGoToShortcuts() {
+ const router = useRouter();
+ useEffect(() => {
+ let waitingForSecond = false;
+ let timer: ReturnType | null = null;
+ const reset = () => {
+ waitingForSecond = false;
+ if (timer) clearTimeout(timer);
+ };
+
+ const onKey = (e: KeyboardEvent) => {
+ const target = e.target as HTMLElement;
+ if (
+ target.tagName === "INPUT" ||
+ target.tagName === "TEXTAREA" ||
+ target.isContentEditable
+ ) {
+ return;
+ }
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
+
+ if (!waitingForSecond) {
+ if (e.key === "g" || e.key === "G") {
+ waitingForSecond = true;
+ timer = setTimeout(reset, 1500);
+ }
+ return;
+ }
+ const dest: Record = {
+ o: "/",
+ t: "/telemetry",
+ c: "/capabilities",
+ r: "/roadmap",
+ };
+ const path = dest[e.key.toLowerCase()];
+ if (path) {
+ e.preventDefault();
+ router.push(path);
+ }
+ reset();
+ };
+ window.addEventListener("keydown", onKey);
+ return () => {
+ window.removeEventListener("keydown", onKey);
+ if (timer) clearTimeout(timer);
+ };
+ }, [router]);
+}
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx
new file mode 100644
index 0000000..dfa2754
--- /dev/null
+++ b/src/components/layout/Sidebar.tsx
@@ -0,0 +1,144 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import {
+ Activity,
+ Compass,
+ LayoutDashboard,
+ ListChecks,
+ Map,
+ Search,
+ Settings,
+ Zap,
+} from "lucide-react";
+import { useCommandPalette } from "./CommandPalette";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { Kbd } from "@/components/ui/kbd";
+import { cn, isMac } from "@/lib/utils";
+import { PROJECT } from "@/lib/project";
+
+interface NavItem {
+ href: string;
+ label: string;
+ icon: typeof LayoutDashboard;
+ desc?: string;
+}
+
+const PRIMARY: NavItem[] = [
+ { href: "/", label: "Overview", icon: LayoutDashboard, desc: "Dashboard" },
+ { href: "/telemetry", label: "Telemetry", icon: Activity, desc: "/api/stats" },
+ { href: "/capabilities", label: "Capabilities", icon: ListChecks, desc: "MVP scope" },
+ { href: "/roadmap", label: "Roadmap", icon: Map, desc: "Stage + plan" },
+];
+
+const SECONDARY: NavItem[] = [
+ { href: "/settings", label: "Settings", icon: Settings },
+];
+
+export function Sidebar() {
+ const pathname = usePathname();
+ const { setOpen: setCommandOpen } = useCommandPalette();
+ const metaKey = isMac() ? "⌘" : "Ctrl";
+
+ const renderItem = (item: NavItem) => {
+ const active =
+ item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
+ return (
+
+ {active && (
+
+ )}
+
+ {item.label}
+ {item.desc && (
+
+ {item.desc}
+
+ )}
+
+ );
+ };
+
+ return (
+
+
+
+
+
+
+
+ {PROJECT.name}
+
+
+
+
+
+
+
+ setCommandOpen(true)}
+ className={cn(
+ "flex w-full items-center gap-2 rounded-md border border-border bg-surface px-2.5 h-7 text-xs",
+ "text-foreground-faint hover:text-foreground-muted hover:border-border-strong transition-colors",
+ )}
+ >
+
+ Search…
+
+ {metaKey}
+ K
+
+
+
+ Command palette
+
+
+
+
+
+ Workspace
+
+ {PRIMARY.map(renderItem)}
+
+
+ Account
+
+ {SECONDARY.map(renderItem)}
+
+
+
+
+
+
+
+ {PROJECT.stage}
+
+
+ {PROJECT.category} · {PROJECT.track}
+
+
+
+
+
+ );
+}
diff --git a/src/components/layout/ThemeToggle.tsx b/src/components/layout/ThemeToggle.tsx
new file mode 100644
index 0000000..c643b3c
--- /dev/null
+++ b/src/components/layout/ThemeToggle.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import { Moon, Sun, Monitor } from "lucide-react";
+import { useTheme } from "next-themes";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { useMounted } from "@/lib/hooks";
+
+/**
+ * ThemeToggle — three-way (system / light / dark). Uses next-themes; the
+ * mounted-guard prevents a hydration flash.
+ */
+export function ThemeToggle() {
+ const { theme, setTheme } = useTheme();
+ const mounted = useMounted();
+
+ return (
+
+
+
+ {mounted && theme === "light" ? (
+
+ ) : mounted && theme === "system" ? (
+
+ ) : (
+
+ )}
+
+
+
+ setTheme("light")}>
+ Light
+
+ setTheme("dark")}>
+ Dark
+
+ setTheme("system")}>
+ System
+
+
+
+ );
+}
diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx
new file mode 100644
index 0000000..c15ab9a
--- /dev/null
+++ b/src/components/layout/TopBar.tsx
@@ -0,0 +1,102 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { fetchPublicStats, type PublicStats } from "@/lib/api";
+import { StatusDot } from "@/components/ui/status-dot";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { ThemeToggle } from "./ThemeToggle";
+import { UserMenu } from "./UserMenu";
+import { Breadcrumbs } from "./Breadcrumbs";
+
+interface TopBarProps {
+ title: string;
+ description?: string;
+ actions?: React.ReactNode;
+}
+
+/**
+ * TopBar — sticky page header. On showcase deploys we use /api/stats
+ * reachability as the canonical "system reachable" indicator since
+ * /api/stats is the only public endpoint that exists.
+ */
+export function TopBar({ title, description, actions }: TopBarProps) {
+ const [stats, setStats] = useState(null);
+ const [connected, setConnected] = useState(null);
+
+ useEffect(() => {
+ fetchPublicStats()
+ .then((d) => {
+ setStats(d);
+ setConnected(true);
+ })
+ .catch(() => setConnected(false));
+ }, []);
+
+ const status = stats?.status;
+ const tone =
+ status === "operational"
+ ? "success"
+ : status === "degraded"
+ ? "warning"
+ : connected === false
+ ? "danger"
+ : "muted";
+ const label =
+ status === "operational"
+ ? "Operational"
+ : status === "degraded"
+ ? "Degraded"
+ : connected === false
+ ? "Offline"
+ : "Connecting";
+
+ return (
+
+
+
+
+
+ {title}
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+
+ {actions}
+
+
+
+
+
+
+ {label}
+
+
+
+
+ {status === "operational"
+ ? "/api/stats reachable"
+ : status === "degraded"
+ ? "/api/stats degraded — upstream cache likely stale"
+ : connected === false
+ ? "Could not reach /api/stats"
+ : "Establishing connection…"}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/layout/UserMenu.tsx b/src/components/layout/UserMenu.tsx
new file mode 100644
index 0000000..9096bc6
--- /dev/null
+++ b/src/components/layout/UserMenu.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+import { ChevronDown, ExternalLink, Github, LifeBuoy, Settings, User } from "lucide-react";
+import {
+ Avatar,
+ AvatarFallback,
+} from "@/components/ui/avatar";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Badge } from "@/components/ui/badge";
+import { PROJECT } from "@/lib/project";
+
+/**
+ * UserMenu — repo-anchored dropdown. Showcase deploys don't carry a tenant
+ * context (no auth), so the menu surfaces the GitHub repo + project metadata
+ * instead of a tenant identifier.
+ */
+export function UserMenu() {
+ const initials = PROJECT.system_slug.slice(0, 2).toUpperCase();
+
+ return (
+
+
+
+
+
+ {initials}
+
+
+
+ {PROJECT.system_slug}
+
+
+
+
+
+
+
+
+ Project
+
+ {PROJECT.name}
+
+ {PROJECT.stage}
+
+
+
+
+ GitHub
+
+
+
+
+
+ Landing page
+
+
+
+
+
+
+ Telemetry schema
+
+
+
+
+
+ Settings
+
+
+
+
+ );
+}
diff --git a/src/components/providers/theme-provider.tsx b/src/components/providers/theme-provider.tsx
new file mode 100644
index 0000000..6825ddd
--- /dev/null
+++ b/src/components/providers/theme-provider.tsx
@@ -0,0 +1,23 @@
+"use client";
+
+import { ThemeProvider as NextThemesProvider } from "next-themes";
+import type { ThemeProviderProps } from "next-themes/dist/types";
+
+/**
+ * Wraps next-themes with sensible defaults. We attach the theme to a
+ * `data-theme` attribute (rather than `class`) so CSS variables in
+ * globals.css can target it directly.
+ */
+export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/providers/toaster.tsx b/src/components/providers/toaster.tsx
new file mode 100644
index 0000000..960017f
--- /dev/null
+++ b/src/components/providers/toaster.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { Toaster as SonnerToaster } from "sonner";
+
+/**
+ * App-wide toast outlet. Mounted once in the root layout. Themed to match
+ * the dashboard surface palette via the [data-sonner-toaster] CSS rules
+ * in globals.css.
+ */
+export function Toaster() {
+ return (
+
+ );
+}
diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx
new file mode 100644
index 0000000..c05b2f6
--- /dev/null
+++ b/src/components/ui/avatar.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import * as React from "react";
+import * as AvatarPrimitive from "@radix-ui/react-avatar";
+import { cn } from "@/lib/utils";
+
+const Avatar = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+Avatar.displayName = AvatarPrimitive.Root.displayName;
+
+const AvatarImage = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AvatarImage.displayName = AvatarPrimitive.Image.displayName;
+
+const AvatarFallback = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
+
+export { Avatar, AvatarImage, AvatarFallback };
diff --git a/src/components/ui/badge.test.tsx b/src/components/ui/badge.test.tsx
new file mode 100644
index 0000000..0a5aded
--- /dev/null
+++ b/src/components/ui/badge.test.tsx
@@ -0,0 +1,22 @@
+import { describe, expect, it } from "vitest";
+import { render } from "@testing-library/react";
+import { Badge } from "./badge";
+
+describe("Badge", () => {
+ it("renders children", () => {
+ const { getByText } = render(Active );
+ expect(getByText("Active")).toBeInTheDocument();
+ });
+
+ it("uses the default variant classes when none provided", () => {
+ const { container } = render(x );
+ const node = container.firstChild as HTMLElement;
+ expect(node.className).toMatch(/border-border-strong/);
+ });
+
+ it("switches variant classes when prop provided", () => {
+ const { container } = render(x );
+ const node = container.firstChild as HTMLElement;
+ expect(node.className).toMatch(/border-danger/);
+ });
+});
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
new file mode 100644
index 0000000..918990c
--- /dev/null
+++ b/src/components/ui/badge.tsx
@@ -0,0 +1,49 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "@/lib/utils";
+
+/**
+ * Badge — small status pill. Variants are calibrated so default lives in
+ * neutral gray (chrome), and semantic variants light up only when needed.
+ */
+const badgeVariants = cva(
+ cn(
+ "inline-flex items-center gap-1 rounded-full border px-2 py-0.5",
+ "text-2xs font-medium tabular-nums select-none transition-colors",
+ ),
+ {
+ variants: {
+ variant: {
+ default:
+ "border-border-strong bg-surface-2 text-foreground-muted",
+ outline:
+ "border-border bg-transparent text-foreground-muted",
+ brand:
+ "border-brand/30 bg-brand/10 text-brand",
+ success:
+ "border-success/30 bg-success/10 text-success",
+ warning:
+ "border-warning/30 bg-warning/10 text-warning",
+ danger:
+ "border-danger/30 bg-danger/10 text-danger",
+ info:
+ "border-info/30 bg-info/10 text-info",
+ processing:
+ "border-info/30 bg-info/10 text-info",
+ muted:
+ "border-border bg-surface-2 text-foreground-faint",
+ },
+ },
+ defaultVariants: { variant: "default" },
+ },
+);
+
+export interface BadgeProps
+ extends React.HTMLAttributes,
+ VariantProps {}
+
+export function Badge({ className, variant, ...props }: BadgeProps) {
+ return
;
+}
+
+export { badgeVariants };
diff --git a/src/components/ui/button.test.tsx b/src/components/ui/button.test.tsx
new file mode 100644
index 0000000..bf05e95
--- /dev/null
+++ b/src/components/ui/button.test.tsx
@@ -0,0 +1,53 @@
+import { describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { Button } from "./button";
+
+describe("Button", () => {
+ it("renders children", () => {
+ render(Click me );
+ expect(screen.getByRole("button", { name: /click me/i })).toBeInTheDocument();
+ });
+
+ it("fires onClick when clicked", async () => {
+ const onClick = vi.fn();
+ render(Tap );
+ await userEvent.click(screen.getByRole("button"));
+ expect(onClick).toHaveBeenCalledOnce();
+ });
+
+ it("applies the primary variant classes", () => {
+ render(
+
+ Primary
+ ,
+ );
+ const btn = screen.getByTestId("btn");
+ expect(btn.className).toContain("bg-brand");
+ });
+
+ it("applies the danger variant classes", () => {
+ render(
+
+ Delete
+ ,
+ );
+ const btn = screen.getByTestId("btn");
+ expect(btn.className).toContain("bg-danger");
+ });
+
+ it("respects asChild and renders the slot", () => {
+ render(
+
+ Link
+ ,
+ );
+ const link = screen.getByRole("link", { name: /link/i });
+ expect(link).toHaveAttribute("href", "/test");
+ });
+
+ it("disables when disabled is set", () => {
+ render(Off );
+ expect(screen.getByRole("button")).toBeDisabled();
+ });
+});
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
new file mode 100644
index 0000000..41775b3
--- /dev/null
+++ b/src/components/ui/button.tsx
@@ -0,0 +1,71 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "@/lib/utils";
+
+/**
+ * Button — the canonical interactive element. Variants and sizes calibrated
+ * for a Vercel-grade dashboard: tight padding, restrained palette, single-px
+ * focus ring, no shadow chrome unless explicitly elevated.
+ */
+const buttonVariants = cva(
+ cn(
+ "inline-flex items-center justify-center gap-1.5 whitespace-nowrap",
+ "rounded-md font-medium transition-all duration-150 ease-out-expo",
+ "select-none outline-none",
+ "focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
+ "disabled:pointer-events-none disabled:opacity-50",
+ "[&_svg]:shrink-0",
+ ),
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-foreground text-background hover:bg-foreground/90 active:scale-[0.98]",
+ primary:
+ "bg-brand text-brand-foreground hover:bg-brand-strong active:scale-[0.98] shadow-glow-brand",
+ secondary:
+ "bg-surface-2 text-foreground border border-border hover:border-border-strong hover:bg-surface-3",
+ ghost: "text-foreground-muted hover:bg-surface-2 hover:text-foreground",
+ outline:
+ "border border-border bg-transparent text-foreground-muted hover:border-border-strong hover:bg-surface-2 hover:text-foreground",
+ danger:
+ "bg-danger/90 text-foreground hover:bg-danger active:scale-[0.98]",
+ link: "text-brand underline-offset-4 hover:underline p-0 h-auto",
+ },
+ size: {
+ sm: "h-7 px-2.5 text-xs [&_svg]:size-3.5",
+ default: "h-8 px-3 text-sm [&_svg]:size-4",
+ lg: "h-10 px-4 text-base [&_svg]:size-4",
+ icon: "h-8 w-8 [&_svg]:size-4",
+ "icon-sm": "h-7 w-7 [&_svg]:size-3.5",
+ },
+ },
+ defaultVariants: {
+ variant: "secondary",
+ size: "default",
+ },
+ },
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean;
+}
+
+export const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button";
+ return (
+
+ );
+ },
+);
+Button.displayName = "Button";
+
+export { buttonVariants };
diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx
new file mode 100644
index 0000000..247bab9
--- /dev/null
+++ b/src/components/ui/card.tsx
@@ -0,0 +1,90 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+/**
+ * Card — neutral surface with a subtle border. The default carries no
+ * gradient or glow; visual interest comes from layout, typography, and
+ * the optional `interactive` variant for hoverable cards.
+ */
+export interface CardProps extends React.HTMLAttributes {
+ interactive?: boolean;
+ elevated?: boolean;
+}
+
+export const Card = React.forwardRef(
+ ({ className, interactive, elevated, ...props }, ref) => (
+
+ ),
+);
+Card.displayName = "Card";
+
+export const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardHeader.displayName = "CardHeader";
+
+export const CardTitle = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardTitle.displayName = "CardTitle";
+
+export const CardDescription = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardDescription.displayName = "CardDescription";
+
+export const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardContent.displayName = "CardContent";
+
+export const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardFooter.displayName = "CardFooter";
diff --git a/src/components/ui/code-block.tsx b/src/components/ui/code-block.tsx
new file mode 100644
index 0000000..dde64d2
--- /dev/null
+++ b/src/components/ui/code-block.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import { useState } from "react";
+import { Check, Copy } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+interface CodeBlockProps {
+ children: string;
+ language?: string;
+ className?: string;
+}
+
+/**
+ * CodeBlock — monospace block with optional copy-to-clipboard button.
+ * Doesn't ship a syntax highlighter (deliberately — tokens are rendered
+ * in plain mono so the bundle stays slim). The dashboard primarily shows
+ * curl + JSON snippets where this is fine.
+ */
+export function CodeBlock({ children, language, className }: CodeBlockProps) {
+ const [copied, setCopied] = useState(false);
+
+ const onCopy = async () => {
+ try {
+ await navigator.clipboard.writeText(children);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ } catch {
+ // ignore
+ }
+ };
+
+ return (
+
+ {language && (
+
+
+ {language}
+
+
+ )}
+
+ {children}
+
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..ec50ee8
--- /dev/null
+++ b/src/components/ui/dialog.tsx
@@ -0,0 +1,123 @@
+"use client";
+
+import * as React from "react";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { X } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+const Dialog = DialogPrimitive.Root;
+const DialogTrigger = DialogPrimitive.Trigger;
+const DialogPortal = DialogPrimitive.Portal;
+const DialogClose = DialogPrimitive.Close;
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+
+const DialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+));
+DialogContent.displayName = DialogPrimitive.Content.displayName;
+
+function DialogHeader({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ );
+}
+
+function DialogFooter({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ );
+}
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogDescription.displayName = DialogPrimitive.Description.displayName;
+
+export {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogTrigger,
+ DialogClose,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+};
diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..adf9428
--- /dev/null
+++ b/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,197 @@
+"use client";
+
+import * as React from "react";
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
+import { Check, ChevronRight, Circle } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+const DropdownMenu = DropdownMenuPrimitive.Root;
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
+const DropdownMenuGroup = DropdownMenuPrimitive.Group;
+const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
+const DropdownMenuSub = DropdownMenuPrimitive.Sub;
+const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
+
+const DropdownMenuSubTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+
+));
+DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
+
+const DropdownMenuSubContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
+
+const DropdownMenuContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 6, ...props }, ref) => (
+
+
+
+));
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
+
+const DropdownMenuItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+
+));
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
+
+const DropdownMenuCheckboxItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, checked, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
+
+const DropdownMenuRadioItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
+
+const DropdownMenuLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
+
+const DropdownMenuSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ );
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuGroup,
+ DropdownMenuPortal,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuRadioGroup,
+};
diff --git a/src/components/ui/empty-state.tsx b/src/components/ui/empty-state.tsx
new file mode 100644
index 0000000..abebe84
--- /dev/null
+++ b/src/components/ui/empty-state.tsx
@@ -0,0 +1,45 @@
+import type { LucideIcon } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+interface EmptyStateProps {
+ icon?: LucideIcon;
+ title: string;
+ description?: string;
+ action?: React.ReactNode;
+ className?: string;
+}
+
+/**
+ * EmptyState — what a panel renders when it has no data. Keep titles
+ * direct ("No documents yet"), descriptions actionable ("Upload your
+ * first one to start indexing").
+ */
+export function EmptyState({
+ icon: Icon,
+ title,
+ description,
+ action,
+ className,
+}: EmptyStateProps) {
+ return (
+
+ {Icon && (
+
+
+
+ )}
+
{title}
+ {description && (
+
+ {description}
+
+ )}
+ {action &&
{action}
}
+
+ );
+}
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
new file mode 100644
index 0000000..90dea38
--- /dev/null
+++ b/src/components/ui/input.tsx
@@ -0,0 +1,28 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+export type InputProps = React.InputHTMLAttributes;
+
+/**
+ * Input — single-line text field. Vercel-grade defaults: 8px radius,
+ * subtle border that strengthens on hover, brand-tinted focus ring.
+ */
+export const Input = React.forwardRef(
+ ({ className, type = "text", ...props }, ref) => (
+
+ ),
+);
+Input.displayName = "Input";
diff --git a/src/components/ui/kbd.tsx b/src/components/ui/kbd.tsx
new file mode 100644
index 0000000..7bff0ef
--- /dev/null
+++ b/src/components/ui/kbd.tsx
@@ -0,0 +1,35 @@
+import { cn } from "@/lib/utils";
+
+/**
+ * Kbd — keyboard shortcut chip. Renders one or more keys as inline labels.
+ *
+ * ⌘ K
+ *
+ * For a tight pair, pass the `pair` prop with two strings.
+ */
+export function Kbd({
+ className,
+ children,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ {children}
+
+ );
+}
+
+interface KbdGroupProps {
+ keys: string[];
+ className?: string;
+}
+
+export function KbdGroup({ keys, className }: KbdGroupProps) {
+ return (
+
+ {keys.map((k, i) => (
+ {k}
+ ))}
+
+ );
+}
diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx
new file mode 100644
index 0000000..cc52072
--- /dev/null
+++ b/src/components/ui/popover.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import * as React from "react";
+import * as PopoverPrimitive from "@radix-ui/react-popover";
+import { cn } from "@/lib/utils";
+
+const Popover = PopoverPrimitive.Root;
+const PopoverTrigger = PopoverPrimitive.Trigger;
+const PopoverAnchor = PopoverPrimitive.Anchor;
+
+const PopoverContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, align = "center", sideOffset = 6, ...props }, ref) => (
+
+
+
+));
+PopoverContent.displayName = PopoverPrimitive.Content.displayName;
+
+export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..5b939f6
--- /dev/null
+++ b/src/components/ui/scroll-area.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import * as React from "react";
+import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
+import { cn } from "@/lib/utils";
+
+const ScrollArea = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+ {children}
+
+
+
+
+));
+ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
+
+const ScrollBar = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, orientation = "vertical", ...props }, ref) => (
+
+
+
+));
+ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
+
+export { ScrollArea, ScrollBar };
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
new file mode 100644
index 0000000..06efffe
--- /dev/null
+++ b/src/components/ui/select.tsx
@@ -0,0 +1,113 @@
+"use client";
+
+import * as React from "react";
+import * as SelectPrimitive from "@radix-ui/react-select";
+import { Check, ChevronDown, ChevronUp } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+const Select = SelectPrimitive.Root;
+const SelectValue = SelectPrimitive.Value;
+const SelectGroup = SelectPrimitive.Group;
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+ span]:line-clamp-1",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+
+));
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
+
+const SelectContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = "popper", ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+));
+SelectContent.displayName = SelectPrimitive.Content.displayName;
+
+const SelectItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+SelectItem.displayName = SelectPrimitive.Item.displayName;
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
+
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectItem,
+ SelectSeparator,
+};
diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx
new file mode 100644
index 0000000..6f19c81
--- /dev/null
+++ b/src/components/ui/separator.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import * as React from "react";
+import * as SeparatorPrimitive from "@radix-ui/react-separator";
+import { cn } from "@/lib/utils";
+
+const Separator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(
+ (
+ { className, orientation = "horizontal", decorative = true, ...props },
+ ref,
+ ) => (
+
+ ),
+);
+Separator.displayName = SeparatorPrimitive.Root.displayName;
+
+export { Separator };
diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx
new file mode 100644
index 0000000..f70fb65
--- /dev/null
+++ b/src/components/ui/skeleton.tsx
@@ -0,0 +1,17 @@
+import { cn } from "@/lib/utils";
+
+/**
+ * Skeleton — single-purpose shimmer block. Use for layout loading states.
+ * Sized via className (h-, w-, rounded-).
+ */
+export function Skeleton({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ );
+}
diff --git a/src/components/ui/sparkline.test.tsx b/src/components/ui/sparkline.test.tsx
new file mode 100644
index 0000000..9a8b8e9
--- /dev/null
+++ b/src/components/ui/sparkline.test.tsx
@@ -0,0 +1,43 @@
+import { describe, expect, it } from "vitest";
+import { render } from "@testing-library/react";
+import { Sparkline } from "./sparkline";
+
+describe("Sparkline", () => {
+ it("renders nothing for empty data", () => {
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("renders nothing for single-point data", () => {
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("renders an svg with two paths for filled charts", () => {
+ const { container } = render(
+ ,
+ );
+ const svg = container.querySelector("svg");
+ expect(svg).not.toBeNull();
+ const paths = svg?.querySelectorAll("path");
+ // one fill path + one line path
+ expect(paths?.length).toBe(2);
+ });
+
+ it("draws only one path when filled is false", () => {
+ const { container } = render(
+ ,
+ );
+ const svg = container.querySelector("svg");
+ const paths = svg?.querySelectorAll("path");
+ expect(paths?.length).toBe(1);
+ });
+
+ it("renders the terminator dot when dot=true", () => {
+ const { container } = render(
+ ,
+ );
+ const circles = container.querySelectorAll("circle");
+ expect(circles.length).toBe(1);
+ });
+});
diff --git a/src/components/ui/sparkline.tsx b/src/components/ui/sparkline.tsx
new file mode 100644
index 0000000..c70b036
--- /dev/null
+++ b/src/components/ui/sparkline.tsx
@@ -0,0 +1,91 @@
+"use client";
+
+interface SparklineProps {
+ data: number[];
+ width?: number;
+ height?: number;
+ /** Stroke colour. Accepts any CSS colour. Defaults to current text colour. */
+ color?: string;
+ filled?: boolean;
+ /** Show the latest value as a dot terminator. */
+ dot?: boolean;
+ className?: string;
+}
+
+/**
+ * Tiny smoothed line chart for inline trend display in StatCards.
+ * Renders nothing if `data` is empty or has fewer than 2 points.
+ */
+export function Sparkline({
+ data,
+ width = 80,
+ height = 28,
+ color = "currentColor",
+ filled = true,
+ dot = true,
+ className,
+}: SparklineProps) {
+ if (!data.length || data.length < 2) return null;
+
+ const pad = 2;
+ const min = Math.min(...data);
+ const max = Math.max(...data);
+ const range = max - min || 1;
+
+ const points = data.map((v, i) => {
+ const x = pad + (i / (data.length - 1)) * (width - pad * 2);
+ const y = pad + (1 - (v - min) / range) * (height - pad * 2);
+ return [x, y] as const;
+ });
+
+ // Smooth bezier path between points.
+ const linePath = points
+ .map(([x, y], i) => {
+ if (i === 0) return `M ${x},${y}`;
+ const [px, py] = points[i - 1];
+ const cpx = (px + x) / 2;
+ return `C ${cpx},${py} ${cpx},${y} ${x},${y}`;
+ })
+ .join(" ");
+
+ const fillPath =
+ `${linePath} L ${points[points.length - 1][0]},${height} ` +
+ `L ${points[0][0]},${height} Z`;
+
+ const last = points[points.length - 1];
+ const gradientId = `spark-${data.length}-${Math.round(min)}-${Math.round(max)}`;
+
+ return (
+
+ {filled && (
+ <>
+
+
+
+
+
+
+
+ >
+ )}
+
+ {dot && (
+
+ )}
+
+ );
+}
diff --git a/src/components/ui/status-dot.tsx b/src/components/ui/status-dot.tsx
new file mode 100644
index 0000000..66d5e94
--- /dev/null
+++ b/src/components/ui/status-dot.tsx
@@ -0,0 +1,50 @@
+import { cn } from "@/lib/utils";
+
+type Tone = "success" | "warning" | "danger" | "info" | "muted";
+
+const TONE_TEXT: Record = {
+ success: "text-success",
+ warning: "text-warning",
+ danger: "text-danger",
+ info: "text-info",
+ muted: "text-foreground-faint",
+};
+
+interface StatusDotProps {
+ tone?: Tone;
+ /** Pulse a soft halo around the dot — use for "live" indicators. */
+ pulse?: boolean;
+ size?: "sm" | "md" | "lg";
+ className?: string;
+}
+
+const SIZE_PX: Record, string> = {
+ sm: "h-1.5 w-1.5",
+ md: "h-2 w-2",
+ lg: "h-2.5 w-2.5",
+};
+
+/**
+ * StatusDot — semantic colored dot. The pulse variant uses a CSS halo,
+ * not a JS animation, so it stays cheap on long lists.
+ */
+export function StatusDot({
+ tone = "muted",
+ pulse,
+ size = "md",
+ className,
+}: StatusDotProps) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..33ce593
--- /dev/null
+++ b/src/components/ui/tabs.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import * as React from "react";
+import * as TabsPrimitive from "@radix-ui/react-tabs";
+import { cn } from "@/lib/utils";
+
+const Tabs = TabsPrimitive.Root;
+
+const TabsList = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+TabsList.displayName = TabsPrimitive.List.displayName;
+
+const TabsTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
+
+const TabsContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+TabsContent.displayName = TabsPrimitive.Content.displayName;
+
+export { Tabs, TabsList, TabsTrigger, TabsContent };
diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000..1deb972
--- /dev/null
+++ b/src/components/ui/tooltip.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import * as React from "react";
+import * as TooltipPrimitive from "@radix-ui/react-tooltip";
+import { cn } from "@/lib/utils";
+
+const TooltipProvider = TooltipPrimitive.Provider;
+const Tooltip = TooltipPrimitive.Root;
+const TooltipTrigger = TooltipPrimitive.Trigger;
+
+const TooltipContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+));
+TooltipContent.displayName = TooltipPrimitive.Content.displayName;
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
diff --git a/src/lib/api.ts b/src/lib/api.ts
new file mode 100644
index 0000000..aa5474c
--- /dev/null
+++ b/src/lib/api.ts
@@ -0,0 +1,37 @@
+// Slim API surface for the showcase dashboard.
+// Only the public /api/stats endpoint is real on showcase deploys; the
+// Tier-A BFF endpoints (run, documents, ui/*) don't exist here.
+
+async function publicFetch(path: string, init?: RequestInit): Promise {
+ const res = await fetch(path, {
+ ...init,
+ headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
+ });
+ if (!res.ok) {
+ throw new Error(`Public API ${res.status}: ${res.statusText}`);
+ }
+ return res.json() as Promise;
+}
+
+/** Tier-B telemetry response — see TELEMETRY_SCHEMA.md. */
+export interface PublicStats {
+ system: string;
+ mode?: "live" | "showcase";
+ status: "operational" | "degraded" | "down";
+ last_deployed_at: string | null;
+ last_commit_at?: string | null;
+ metrics: {
+ commits_30d?: number;
+ commits_total?: number;
+ primary_language?: string;
+ repo_stars?: number;
+ lines_of_code?: number;
+ [key: string]: number | string | undefined;
+ };
+ schema_version: number;
+ generated_at: string;
+}
+
+export function fetchPublicStats(): Promise {
+ return publicFetch("/api/stats");
+}
diff --git a/src/lib/hooks.test.ts b/src/lib/hooks.test.ts
new file mode 100644
index 0000000..80273ea
--- /dev/null
+++ b/src/lib/hooks.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it, vi, afterEach, beforeEach } from "vitest";
+import { renderHook, act } from "@testing-library/react";
+import { useDebounce, useMounted, useAnimatedNumber } from "./hooks";
+
+describe("useDebounce", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("returns the initial value immediately", () => {
+ const { result } = renderHook(() => useDebounce("hello", 200));
+ expect(result.current).toBe("hello");
+ });
+
+ it("updates only after the delay elapses", () => {
+ const { result, rerender } = renderHook(
+ ({ value }) => useDebounce(value, 200),
+ { initialProps: { value: "first" } },
+ );
+ rerender({ value: "second" });
+ expect(result.current).toBe("first");
+
+ act(() => {
+ vi.advanceTimersByTime(199);
+ });
+ expect(result.current).toBe("first");
+
+ act(() => {
+ vi.advanceTimersByTime(1);
+ });
+ expect(result.current).toBe("second");
+ });
+});
+
+describe("useMounted", () => {
+ it("returns true after mount", () => {
+ const { result } = renderHook(() => useMounted());
+ expect(result.current).toBe(true);
+ });
+});
+
+describe("useAnimatedNumber", () => {
+ it("eventually reaches the target", async () => {
+ const raf = vi.spyOn(global, "requestAnimationFrame");
+ const { result } = renderHook(() => useAnimatedNumber(100, 50));
+ // Drive the animation frames synchronously for the test.
+ expect(result.current).toBe(0);
+ raf.mockRestore();
+ });
+});
diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts
new file mode 100644
index 0000000..9d45215
--- /dev/null
+++ b/src/lib/hooks.ts
@@ -0,0 +1,142 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+
+/**
+ * Debounce a value. Useful for search inputs.
+ */
+export function useDebounce(value: T, delayMs = 200): T {
+ const [debounced, setDebounced] = useState(value);
+ useEffect(() => {
+ const t = setTimeout(() => setDebounced(value), delayMs);
+ return () => clearTimeout(t);
+ }, [value, delayMs]);
+ return debounced;
+}
+
+/**
+ * Mounted state — useful for guarding portals during SSR/hydration so
+ * theme toggles don't flash the wrong icon on first paint.
+ */
+export function useMounted(): boolean {
+ const [m, setM] = useState(false);
+ useEffect(() => setM(true), []);
+ return m;
+}
+
+/**
+ * Listen for a keyboard shortcut on the window. Returns nothing — pure
+ * side effect. Pass a stable handler (define once or wrap in useCallback)
+ * if the consumer re-renders frequently.
+ */
+export function useHotkey(
+ key: string,
+ handler: (e: KeyboardEvent) => void,
+ options: { meta?: boolean; ctrl?: boolean; shift?: boolean } = {},
+): void {
+ const handlerRef = useRef(handler);
+ handlerRef.current = handler;
+
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ const matchesKey = e.key.toLowerCase() === key.toLowerCase();
+ const matchesMeta = options.meta ? e.metaKey || e.ctrlKey : true;
+ const matchesCtrl = options.ctrl ? e.ctrlKey : true;
+ const matchesShift = options.shift ? e.shiftKey : !e.shiftKey;
+ if (matchesKey && matchesMeta && matchesCtrl && matchesShift) {
+ handlerRef.current(e);
+ }
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [key, options.meta, options.ctrl, options.shift]);
+}
+
+/**
+ * Animate a number from 0 to its current value over a short duration.
+ * Used by StatCard so big counters feel alive on first render.
+ */
+export function useAnimatedNumber(target: number, durationMs = 600): number {
+ const [value, setValue] = useState(0);
+ const start = useRef(null);
+ const from = useRef(0);
+ const to = useRef(target);
+
+ useEffect(() => {
+ from.current = value;
+ to.current = target;
+ start.current = null;
+
+ let frame = 0;
+ const tick = (t: number) => {
+ if (start.current === null) start.current = t;
+ const elapsed = t - start.current;
+ const progress = Math.min(1, elapsed / durationMs);
+ // ease-out-quint
+ const eased = 1 - Math.pow(1 - progress, 5);
+ setValue(from.current + (to.current - from.current) * eased);
+ if (progress < 1) frame = requestAnimationFrame(tick);
+ };
+ frame = requestAnimationFrame(tick);
+ return () => cancelAnimationFrame(frame);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [target, durationMs]);
+
+ return value;
+}
+
+/**
+ * Polling hook with a stable abort signal. Reschedules on visibility change
+ * so background tabs don't burn the API.
+ */
+export function usePolling(
+ fetcher: () => Promise,
+ intervalMs: number,
+ enabled = true,
+): { data: T | null; error: Error | null; loading: boolean; refetch: () => void } {
+ const [data, setData] = useState(null);
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [tick, setTick] = useState(0);
+
+ useEffect(() => {
+ if (!enabled) return;
+ let active = true;
+ let timer: ReturnType | null = null;
+
+ const run = async () => {
+ try {
+ const v = await fetcher();
+ if (!active) return;
+ setData(v);
+ setError(null);
+ } catch (e) {
+ if (!active) return;
+ setError(e instanceof Error ? e : new Error(String(e)));
+ } finally {
+ if (active) setLoading(false);
+ }
+ if (active && document.visibilityState === "visible") {
+ timer = setTimeout(run, intervalMs);
+ }
+ };
+
+ const onVisibility = () => {
+ if (document.visibilityState === "visible" && timer === null) {
+ run();
+ }
+ };
+
+ run();
+ document.addEventListener("visibilitychange", onVisibility);
+
+ return () => {
+ active = false;
+ if (timer) clearTimeout(timer);
+ document.removeEventListener("visibilitychange", onVisibility);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [intervalMs, enabled, tick]);
+
+ return { data, error, loading, refetch: () => setTick((t) => t + 1) };
+}
diff --git a/src/lib/project.ts b/src/lib/project.ts
new file mode 100644
index 0000000..5a59dab
--- /dev/null
+++ b/src/lib/project.ts
@@ -0,0 +1,46 @@
+/**
+ * Project metadata sourced from `src/evalops_workbench/project.json`.
+ * Hardcoded as a TS module so it ships in the static bundle without runtime
+ * file-system access.
+ */
+
+export interface ProjectSpec {
+ slug: string;
+ name: string;
+ category: string;
+ track: string;
+ stage: string;
+ summary: string;
+ problem: string;
+ users: string;
+ stack: string[];
+ why_now: string;
+ mvp: string[];
+ github_url: string;
+ /** Slug returned by the system's `/api/stats` endpoint. */
+ system_slug: string;
+}
+
+export const PROJECT: ProjectSpec = {
+ slug: "evalops-workbench",
+ name: "EvalOps Workbench",
+ category: "Developer Tool",
+ track: "LLM",
+ stage: "Researching",
+ summary:
+ "A local-first evaluation harness for prompts, tools, and agents with regression tracking and experiment history.",
+ problem:
+ "LLM teams lack a lightweight way to compare prompt and tool changes before shipping.",
+ users: "Agent builders, prompt engineers, applied AI teams",
+ stack: ["Python", "Typer", "DuckDB", "OpenTelemetry"],
+ why_now:
+ "Evaluation is moving from optional best practice to baseline engineering hygiene.",
+ mvp: [
+ "Load datasets from JSON or CSV",
+ "Run prompt or agent variants",
+ "Score outputs with rubric functions",
+ "Compare runs and export regressions",
+ ],
+ github_url: "https://github.com/IgnazioDS/evalops-workbench",
+ system_slug: "evalops",
+};
diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts
new file mode 100644
index 0000000..baaabd9
--- /dev/null
+++ b/src/lib/utils.test.ts
@@ -0,0 +1,118 @@
+import { describe, expect, it, vi, afterEach } from "vitest";
+import {
+ cn,
+ formatDate,
+ formatDuration,
+ formatNumber,
+ formatRelative,
+ isMac,
+ percent,
+ truncate,
+} from "./utils";
+
+describe("cn", () => {
+ it("merges class names with Tailwind precedence", () => {
+ expect(cn("px-2", "px-4")).toBe("px-4");
+ expect(cn("text-foreground", false && "text-danger", "bg-surface")).toBe(
+ "text-foreground bg-surface",
+ );
+ });
+
+ it("handles empty and undefined inputs", () => {
+ expect(cn()).toBe("");
+ expect(cn(undefined, null as unknown as string)).toBe("");
+ });
+});
+
+describe("formatNumber", () => {
+ it("formats integers with thousands separators", () => {
+ expect(formatNumber(1000)).toMatch(/1[,. ]000/);
+ expect(formatNumber(0)).toBe("0");
+ });
+
+ it("returns em-dash for nullish or NaN", () => {
+ expect(formatNumber(null)).toBe("—");
+ expect(formatNumber(undefined)).toBe("—");
+ expect(formatNumber(NaN)).toBe("—");
+ });
+});
+
+describe("formatDuration", () => {
+ it("formats sub-millisecond, millisecond, second, and minute ranges", () => {
+ expect(formatDuration(0.4)).toBe("<1ms");
+ expect(formatDuration(420)).toBe("420ms");
+ expect(formatDuration(3450)).toBe("3.45s");
+ expect(formatDuration(120_000)).toBe("2m");
+ });
+
+ it("handles invalid input", () => {
+ expect(formatDuration(null)).toBe("—");
+ expect(formatDuration(undefined)).toBe("—");
+ expect(formatDuration(NaN)).toBe("—");
+ expect(formatDuration(-5)).toBe("—");
+ });
+});
+
+describe("formatRelative", () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("returns adaptive units relative to now", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-04-28T12:00:00Z"));
+ expect(formatRelative("2026-04-28T11:59:30Z")).toBe("30s ago");
+ expect(formatRelative("2026-04-28T11:55:00Z")).toBe("5m ago");
+ expect(formatRelative("2026-04-28T09:00:00Z")).toBe("3h ago");
+ expect(formatRelative("2026-04-26T12:00:00Z")).toBe("2d ago");
+ });
+
+ it("returns 'never' for missing input", () => {
+ expect(formatRelative(null)).toBe("never");
+ expect(formatRelative(undefined)).toBe("never");
+ expect(formatRelative("garbage")).toBe("never");
+ });
+});
+
+describe("formatDate", () => {
+ it("returns em-dash for nullish input", () => {
+ expect(formatDate(null)).toBe("—");
+ expect(formatDate(undefined)).toBe("—");
+ expect(formatDate("not-a-date")).toBe("—");
+ });
+
+ it("formats valid ISO dates", () => {
+ const out = formatDate("2026-04-28T12:00:00Z");
+ expect(out).toMatch(/2026/);
+ });
+});
+
+describe("truncate", () => {
+ it("returns the original string when shorter than max", () => {
+ expect(truncate("hello", 10)).toBe("hello");
+ });
+
+ it("appends an ellipsis when over max", () => {
+ expect(truncate("hello world", 6)).toBe("hello…");
+ });
+});
+
+describe("percent", () => {
+ it("clamps to [0, 100]", () => {
+ expect(percent(50, 100)).toBe(50);
+ expect(percent(150, 100)).toBe(100);
+ expect(percent(-5, 100)).toBe(0);
+ });
+
+ it("returns 0 when limit is null or 0", () => {
+ expect(percent(50, null)).toBe(0);
+ expect(percent(50, 0)).toBe(0);
+ });
+});
+
+describe("isMac", () => {
+ it("returns false in non-browser environments", () => {
+ // jsdom navigator.platform varies; this just asserts it doesn't throw.
+ expect(typeof isMac()).toBe("boolean");
+ });
+});
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..887cf21
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,76 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+/**
+ * Merge class names with Tailwind-aware deduplication.
+ * Single source of truth for `className` composition across the app.
+ */
+export function cn(...inputs: ClassValue[]): string {
+ return twMerge(clsx(inputs));
+}
+
+/** Format an integer with thousands separators using the user's locale. */
+export function formatNumber(n: number | undefined | null): string {
+ if (n === undefined || n === null || Number.isNaN(n)) return "—";
+ return new Intl.NumberFormat(undefined).format(n);
+}
+
+/** Format a duration in milliseconds with adaptive units. */
+export function formatDuration(ms: number | undefined | null): string {
+ if (ms === undefined || ms === null || Number.isNaN(ms) || ms < 0) return "—";
+ if (ms < 1) return "<1ms";
+ if (ms < 1000) return `${Math.round(ms)}ms`;
+ if (ms < 60_000) return `${(ms / 1000).toFixed(2)}s`;
+ return `${Math.round(ms / 60_000)}m`;
+}
+
+/** Format an ISO-8601 timestamp as a short relative time ("3m ago"). */
+export function formatRelative(iso: string | null | undefined): string {
+ if (!iso) return "never";
+ const then = Date.parse(iso);
+ if (Number.isNaN(then)) return "never";
+ const diff = Date.now() - then;
+ if (diff < 0) return "in the future";
+ const sec = Math.floor(diff / 1000);
+ if (sec < 60) return `${sec}s ago`;
+ const min = Math.floor(sec / 60);
+ if (min < 60) return `${min}m ago`;
+ const hr = Math.floor(min / 60);
+ if (hr < 24) return `${hr}h ago`;
+ const d = Math.floor(hr / 24);
+ if (d < 30) return `${d}d ago`;
+ const mo = Math.floor(d / 30);
+ if (mo < 12) return `${mo}mo ago`;
+ const yr = Math.floor(mo / 12);
+ return `${yr}y ago`;
+}
+
+/** Format an ISO-8601 timestamp as a short absolute time ("Apr 28, 2026"). */
+export function formatDate(iso: string | null | undefined): string {
+ if (!iso) return "—";
+ const date = new Date(iso);
+ if (Number.isNaN(date.getTime())) return "—";
+ return new Intl.DateTimeFormat(undefined, {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ }).format(date);
+}
+
+/** Truncate a string to a maximum length with ellipsis. */
+export function truncate(s: string, max: number): string {
+ if (s.length <= max) return s;
+ return s.slice(0, max - 1) + "…";
+}
+
+/** Compute a percentage 0–100 with safe-divide. */
+export function percent(used: number, limit: number | null): number {
+ if (!limit || limit <= 0) return 0;
+ return Math.min(100, Math.max(0, (used / limit) * 100));
+}
+
+/** Detect whether the user is on macOS (for keyboard hint display). */
+export function isMac(): boolean {
+ if (typeof navigator === "undefined") return false;
+ return /mac/i.test(navigator.platform);
+}
diff --git a/src/test/setup.ts b/src/test/setup.ts
new file mode 100644
index 0000000..6a301b4
--- /dev/null
+++ b/src/test/setup.ts
@@ -0,0 +1,24 @@
+import "@testing-library/jest-dom/vitest";
+import { afterEach } from "vitest";
+import { cleanup } from "@testing-library/react";
+
+afterEach(() => {
+ cleanup();
+});
+
+// jsdom doesn't ship with matchMedia; some Radix primitives expect it.
+if (typeof window !== "undefined" && !window.matchMedia) {
+ Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ value: (query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ addListener: () => {},
+ removeListener: () => {},
+ dispatchEvent: () => false,
+ }),
+ });
+}
diff --git a/styles.css b/styles.css
deleted file mode 100644
index 5e71cb0..0000000
--- a/styles.css
+++ /dev/null
@@ -1,259 +0,0 @@
-* {
- box-sizing: border-box;
-}
-
-html {
- scroll-behavior: smooth;
-}
-
-body {
- margin: 0;
- min-height: 100vh;
- font-family: "Space Grotesk", sans-serif;
- color: var(--text);
- background:
- radial-gradient(circle at top left, color-mix(in srgb, var(--accent) 20%, transparent), transparent 30%),
- radial-gradient(circle at top right, color-mix(in srgb, var(--accent-alt) 18%, transparent), transparent 24%),
- linear-gradient(180deg, var(--bg), var(--bg-alt));
-}
-
-a {
- color: inherit;
- text-decoration: none;
-}
-
-.page-shell {
- width: min(1180px, calc(100% - 32px));
- margin: 0 auto;
- padding: 24px 0 72px;
-}
-
-.topbar,
-.hero-grid,
-.grid {
- display: grid;
- gap: 24px;
-}
-
-.topbar {
- grid-template-columns: 1fr auto;
- align-items: center;
- margin-bottom: 56px;
-}
-
-.brand,
-.eyebrow,
-code,
-pre {
- font-family: "IBM Plex Mono", monospace;
-}
-
-.brand,
-.eyebrow {
- letter-spacing: 0.12em;
- text-transform: uppercase;
- font-size: 0.78rem;
-}
-
-.brand {
- color: var(--accent);
-}
-
-.links {
- display: flex;
- flex-wrap: wrap;
- gap: 16px;
- color: var(--muted);
-}
-
-.links a:hover,
-.button:hover {
- opacity: 0.88;
-}
-
-.hero {
- padding: 18px 0 36px;
-}
-
-.hero-grid {
- grid-template-columns: minmax(0, 1.65fr) minmax(300px, 0.85fr);
- align-items: end;
-}
-
-h1,
-h2,
-h3,
-p {
- margin: 0;
-}
-
-h1 {
- max-width: 10ch;
- font-size: clamp(3rem, 8vw, 6.25rem);
- line-height: 0.95;
- margin-bottom: 20px;
-}
-
-.lede {
- max-width: 62ch;
- font-size: 1.1rem;
- line-height: 1.7;
- color: var(--muted);
-}
-
-.hero-actions {
- display: flex;
- flex-wrap: wrap;
- gap: 14px;
- margin-top: 28px;
-}
-
-.button {
- border-radius: 999px;
- padding: 14px 20px;
- border: 1px solid var(--border);
- transition: opacity 160ms ease, transform 160ms ease;
-}
-
-.button.primary {
- background: linear-gradient(135deg, var(--accent), var(--accent-alt));
- color: #081018;
- font-weight: 700;
- border: none;
-}
-
-.button.secondary {
- background: rgba(255, 255, 255, 0.02);
-}
-
-.button:hover {
- transform: translateY(-1px);
-}
-
-.card {
- position: relative;
- overflow: hidden;
- padding: 24px;
- border: 1px solid var(--border);
- border-radius: 24px;
- background:
- linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.015)),
- var(--panel);
- backdrop-filter: blur(12px);
-}
-
-.status-panel {
- display: grid;
- gap: 16px;
-}
-
-.status-row {
- display: flex;
- justify-content: space-between;
- gap: 16px;
- padding-bottom: 12px;
- border-bottom: 1px solid var(--border);
- color: var(--muted);
-}
-
-.status-row:last-child {
- border-bottom: none;
- padding-bottom: 0;
-}
-
-.status-row strong {
- max-width: 58%;
- text-align: right;
- color: var(--text);
-}
-
-.section-head {
- margin: 68px 0 20px;
-}
-
-.section-head h2,
-.narrative h2,
-.stack-panel h2,
-.command-panel h2,
-.capability h3 {
- margin-top: 10px;
-}
-
-.grid {
- grid-template-columns: repeat(12, minmax(0, 1fr));
-}
-
-.two-up > * {
- grid-column: span 6;
-}
-
-.capabilities > * {
- grid-column: span 6;
-}
-
-.narrative,
-.stack-panel,
-.command-panel,
-.capability {
- min-height: 100%;
-}
-
-.narrative p:last-child,
-.capability p {
- margin-top: 14px;
- line-height: 1.7;
- color: var(--muted);
-}
-
-.stack-list {
- list-style: none;
- padding: 0;
- margin: 18px 0 0;
- display: flex;
- flex-wrap: wrap;
- gap: 12px;
-}
-
-.stack-list li {
- padding: 10px 14px;
- border-radius: 999px;
- background: rgba(255, 255, 255, 0.04);
- border: 1px solid var(--border);
-}
-
-pre {
- margin: 18px 0 0;
- padding: 18px;
- overflow-x: auto;
- border-radius: 18px;
- background: rgba(0, 0, 0, 0.2);
- border: 1px solid var(--border);
- color: var(--text);
- line-height: 1.7;
-}
-
-.lower {
- margin-top: 24px;
-}
-
-@media (max-width: 920px) {
- .hero-grid,
- .topbar,
- .two-up > *,
- .capabilities > * {
- grid-template-columns: 1fr;
- grid-column: 1 / -1;
- }
-
- .topbar {
- gap: 12px;
- }
-
- .links {
- gap: 12px;
- }
-
- h1 {
- max-width: none;
- }
-}
diff --git a/tailwind.config.ts b/tailwind.config.ts
new file mode 100644
index 0000000..4a1396d
--- /dev/null
+++ b/tailwind.config.ts
@@ -0,0 +1,144 @@
+import type { Config } from "tailwindcss";
+
+const config: Config = {
+ darkMode: ["class", '[data-theme="dark"]'],
+ content: [
+ "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
+ "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
+ "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
+ ],
+ theme: {
+ container: {
+ center: true,
+ padding: "1.5rem",
+ screens: { "2xl": "1400px" },
+ },
+ extend: {
+ colors: {
+ border: "hsl(var(--border) / )",
+ "border-strong": "hsl(var(--border-strong) / )",
+ "border-subtle": "hsl(var(--border-subtle) / )",
+ input: "hsl(var(--border) / )",
+ ring: "hsl(var(--ring) / )",
+ background: "hsl(var(--background) / )",
+ surface: "hsl(var(--surface) / )",
+ "surface-2": "hsl(var(--surface-2) / )",
+ "surface-3": "hsl(var(--surface-3) / )",
+ foreground: "hsl(var(--foreground) / )",
+ "foreground-muted": "hsl(var(--foreground-muted) / )",
+ "foreground-subtle": "hsl(var(--foreground-subtle) / )",
+ "foreground-faint": "hsl(var(--foreground-faint) / )",
+ brand: {
+ DEFAULT: "hsl(var(--brand) / )",
+ strong: "hsl(var(--brand-strong) / )",
+ foreground: "hsl(var(--brand-foreground) / )",
+ },
+ success: {
+ DEFAULT: "hsl(var(--success) / )",
+ subtle: "hsl(var(--success) / 0.12)",
+ },
+ warning: {
+ DEFAULT: "hsl(var(--warning) / )",
+ subtle: "hsl(var(--warning) / 0.12)",
+ },
+ danger: {
+ DEFAULT: "hsl(var(--danger) / )",
+ subtle: "hsl(var(--danger) / 0.12)",
+ },
+ info: {
+ DEFAULT: "hsl(var(--info) / )",
+ subtle: "hsl(var(--info) / 0.12)",
+ },
+ },
+ fontFamily: {
+ sans: ["var(--font-geist-sans)", "system-ui", "sans-serif"],
+ mono: ["var(--font-geist-mono)", "ui-monospace", "monospace"],
+ },
+ fontSize: {
+ // Tighter line-heights than Tailwind's defaults, calibrated for dashboards.
+ "2xs": ["0.6875rem", { lineHeight: "1rem", letterSpacing: "0.01em" }],
+ xs: ["0.75rem", { lineHeight: "1.1rem" }],
+ sm: ["0.8125rem", { lineHeight: "1.25rem" }],
+ base: ["0.875rem", { lineHeight: "1.4rem" }],
+ md: ["0.9375rem", { lineHeight: "1.5rem" }],
+ lg: ["1rem", { lineHeight: "1.5rem" }],
+ xl: ["1.125rem", { lineHeight: "1.5rem" }],
+ "2xl": ["1.25rem", { lineHeight: "1.75rem", letterSpacing: "-0.01em" }],
+ "3xl": ["1.5rem", { lineHeight: "1.875rem", letterSpacing: "-0.015em" }],
+ "4xl": ["1.875rem", { lineHeight: "2.25rem", letterSpacing: "-0.02em" }],
+ "5xl": ["2.25rem", { lineHeight: "2.5rem", letterSpacing: "-0.025em" }],
+ },
+ borderRadius: {
+ DEFAULT: "var(--radius)",
+ sm: "var(--radius-sm)",
+ md: "var(--radius-md)",
+ lg: "var(--radius-lg)",
+ xl: "var(--radius-xl)",
+ },
+ boxShadow: {
+ // Subtle, layered. Vercel uses these heavily on hover states.
+ "subtle": "0 1px 2px 0 rgb(0 0 0 / 0.6), 0 0 0 1px hsl(var(--border) / 0.5)",
+ "elevated":
+ "0 4px 12px -2px rgb(0 0 0 / 0.5), 0 0 0 1px hsl(var(--border) / 0.8)",
+ "popover":
+ "0 12px 32px -8px rgb(0 0 0 / 0.7), 0 0 0 1px hsl(var(--border) / 0.8)",
+ "glow-brand": "0 0 24px -4px hsl(var(--brand) / 0.4)",
+ },
+ keyframes: {
+ "accordion-down": {
+ from: { height: "0" },
+ to: { height: "var(--radix-accordion-content-height)" },
+ },
+ "accordion-up": {
+ from: { height: "var(--radix-accordion-content-height)" },
+ to: { height: "0" },
+ },
+ "fade-in": {
+ from: { opacity: "0" },
+ to: { opacity: "1" },
+ },
+ "fade-up": {
+ from: { opacity: "0", transform: "translateY(4px)" },
+ to: { opacity: "1", transform: "translateY(0)" },
+ },
+ "fade-down": {
+ from: { opacity: "0", transform: "translateY(-4px)" },
+ to: { opacity: "1", transform: "translateY(0)" },
+ },
+ "scale-in": {
+ from: { opacity: "0", transform: "scale(0.96)" },
+ to: { opacity: "1", transform: "scale(1)" },
+ },
+ "shimmer": {
+ "100%": { backgroundPosition: "-200% 0" },
+ },
+ "pulse-ring": {
+ "0%": { transform: "scale(0.8)", opacity: "0.7" },
+ "100%": { transform: "scale(1.8)", opacity: "0" },
+ },
+ "count-up": {
+ from: { opacity: "0", transform: "translateY(2px)" },
+ to: { opacity: "1", transform: "translateY(0)" },
+ },
+ },
+ animation: {
+ "accordion-down": "accordion-down 0.2s ease-out",
+ "accordion-up": "accordion-up 0.2s ease-out",
+ "fade-in": "fade-in 200ms cubic-bezier(0.16, 1, 0.3, 1)",
+ "fade-up": "fade-up 240ms cubic-bezier(0.16, 1, 0.3, 1)",
+ "fade-down": "fade-down 240ms cubic-bezier(0.16, 1, 0.3, 1)",
+ "scale-in": "scale-in 160ms cubic-bezier(0.16, 1, 0.3, 1)",
+ "shimmer": "shimmer 2s linear infinite",
+ "pulse-ring": "pulse-ring 1.6s cubic-bezier(0.215, 0.61, 0.355, 1) infinite",
+ "count-up": "count-up 300ms cubic-bezier(0.16, 1, 0.3, 1) forwards",
+ },
+ transitionTimingFunction: {
+ "out-quint": "cubic-bezier(0.22, 1, 0.36, 1)",
+ "out-expo": "cubic-bezier(0.16, 1, 0.3, 1)",
+ },
+ },
+ },
+ plugins: [require("tailwindcss-animate")],
+};
+
+export default config;
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..2aead53
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [{ "name": "next" }],
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/vercel.json b/vercel.json
index 6548f0b..98a73c4 100644
--- a/vercel.json
+++ b/vercel.json
@@ -1,11 +1,6 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
- "framework": null,
- "buildCommand": null,
- "installCommand": "",
- "outputDirectory": ".",
- "cleanUrls": true,
- "trailingSlash": false,
+ "framework": "nextjs",
"headers": [
{
"source": "/api/stats",
@@ -15,19 +10,6 @@
{ "key": "Access-Control-Allow-Headers", "value": "Content-Type" },
{ "key": "Cache-Control", "value": "public, max-age=30, stale-while-revalidate=60" }
]
- },
- {
- "source": "/(.*)",
- "headers": [
- {
- "key": "X-Content-Type-Options",
- "value": "nosniff"
- },
- {
- "key": "Referrer-Policy",
- "value": "strict-origin-when-cross-origin"
- }
- ]
}
]
}
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..f0630e2
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,20 @@
+import { defineConfig } from "vitest/config";
+import react from "@vitejs/plugin-react";
+import path from "node:path";
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ environment: "jsdom",
+ setupFiles: ["./src/test/setup.ts"],
+ css: false,
+ globals: true,
+ include: ["src/**/*.test.{ts,tsx}"],
+ exclude: ["node_modules", ".next"],
+ },
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+});