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

Shipping System

-

EvalOps Workbench

-

A local-first evaluation harness for prompts, tools, and agents with regression tracking and experiment history.

- -
- - -
-
- -
-
-
-

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

+
+ +
+
+
+ ); +} 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

+ +
+
+
+ + + + + + Project + + + Static project metadata sourced from{" "} + project.json. + + + + + + + + +
+

+ Stack +

+
+ {PROJECT.stack.map((s) => ( + + {s} + + ))} +
+
+
+
+ + + + Resources + + External links for documentation and source. + + + + + + + + +
+
+ + ); +} + +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 ( + +
+
+

+ {label} +

+
+ +
+
+ {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 ( +
+
+

+ {title} +

+
+ +
+
+ +
+
+

+ {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 ( + + ); +} 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 ( + + ); +} 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 ( + + + + + + 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 ( + + + + + + +
+ + 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(); + expect(screen.getByRole("button", { name: /click me/i })).toBeInTheDocument(); + }); + + it("fires onClick when clicked", async () => { + const onClick = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button")); + expect(onClick).toHaveBeenCalledOnce(); + }); + + it("applies the primary variant classes", () => { + render( + , + ); + const btn = screen.getByTestId("btn"); + expect(btn.className).toContain("bg-brand"); + }); + + it("applies the danger variant classes", () => { + render( + , + ); + const btn = screen.getByTestId("btn"); + expect(btn.className).toContain("bg-danger"); + }); + + it("respects asChild and renders the slot", () => { + render( + , + ); + const link = screen.getByRole("link", { name: /link/i }); + expect(link).toHaveAttribute("href", "/test"); + }); + + it("disables when disabled is set", () => { + render(); + 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}
+      
+ +
+ ); +} 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"), + }, + }, +});