feat: add react api and shadcn foundation

This commit is contained in:
vince 2026-06-19 23:35:46 +02:00
parent f6d6d39a7b
commit a71d66b1b8
21 changed files with 1524 additions and 36 deletions

728
bun.lock

File diff suppressed because it is too large Load diff

25
components.json Normal file
View file

@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"lib": "$lib/.",
"hooks": "$lib/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

View file

@ -6,7 +6,7 @@
**Architecture:** The browser runtime becomes React mounted from `src/main.tsx`. A Bun server at `src/server/index.ts` serves the static React build and exposes JSON API routes for dashboard reads and agent dashboard mutations. Existing model, persistence, datasource, and agent-config modules remain framework-agnostic TypeScript with import path updates as needed. **Architecture:** The browser runtime becomes React mounted from `src/main.tsx`. A Bun server at `src/server/index.ts` serves the static React build and exposes JSON API routes for dashboard reads and agent dashboard mutations. Existing model, persistence, datasource, and agent-config modules remain framework-agnostic TypeScript with import path updates as needed.
**Tech Stack:** Bun, Vite, React, React DOM, TypeScript, Vitest, Storybook React Vite, Playwright, Drizzle ORM, Bun SQLite, MSW, uPlot, Iconify React. **Tech Stack:** Bun, Vite, React, React DOM, TypeScript, Tailwind CSS v4, shadcn/ui, Vitest, Storybook React Vite, Playwright, Drizzle ORM, Bun SQLite, MSW, uPlot, Iconify React.
--- ---
@ -23,13 +23,16 @@
- Create `src/server/routes/agent-dashboard.test.ts`: agent API delegation/auth tests. - Create `src/server/routes/agent-dashboard.test.ts`: agent API delegation/auth tests.
- Create `src/lib/ui/components/*.tsx`: React ports of current reusable Svelte components. - Create `src/lib/ui/components/*.tsx`: React ports of current reusable Svelte components.
- Create `src/lib/ui/components/styles.css`: component CSS migrated from Svelte style blocks. - Create `src/lib/ui/components/styles.css`: component CSS migrated from Svelte style blocks.
- Create `components.json`: shadcn/ui configuration for Vite, Radix, Nova, Tailwind v4, and `$lib` aliases.
- Create `src/lib/components/ui/*.tsx`: selected shadcn primitives, not the full registry.
- Create `src/lib/utils.ts`: `cn()` helper for shadcn and dashboard components.
- Replace `src/lib/ui/components/render.test.ts`: React `renderToString` component tests. - Replace `src/lib/ui/components/render.test.ts`: React `renderToString` component tests.
- Replace `src/lib/ui/stories/*.stories.svelte`: React `.stories.tsx` stories. - Replace `src/lib/ui/stories/*.stories.svelte`: React `.stories.tsx` stories.
- Modify `.storybook/main.ts`: use `@storybook/react-vite` and React story globs. - Modify `.storybook/main.ts`: use `@storybook/react-vite` and React story globs.
- Modify `.storybook/preview.ts`: use React Storybook types and keep MSW setup/global CSS. - Modify `.storybook/preview.ts`: use React Storybook types and keep MSW setup/global CSS.
- Modify `vite.config.ts`: use React plugin, alias `$lib` to `src/lib`, build client, and keep Vitest config. - Modify `vite.config.ts`: use React and Tailwind plugins, alias `$lib` to `src/lib`, build client, and keep Vitest config.
- Modify `tsconfig.json`: remove `.svelte-kit` inheritance, enable JSX, and define path aliases. - Modify `tsconfig.json`: remove `.svelte-kit` inheritance, enable JSX, and define path aliases.
- Modify `package.json`: swap Svelte/SvelteKit dependencies for React tooling and update scripts. - Modify `package.json`: swap Svelte/SvelteKit dependencies for React/Tailwind/shadcn tooling and update scripts.
- Modify `playwright.config.ts`: build React client and Bun server before e2e. - Modify `playwright.config.ts`: build React client and Bun server before e2e.
- Modify `Containerfile`: copy React/Bun build artifacts and keep `bun build/index.js` command. - Modify `Containerfile`: copy React/Bun build artifacts and keep `bun build/index.js` command.
- Modify `README.md`: document React runtime, Bun server, scripts, QA gate, deployment. - Modify `README.md`: document React runtime, Bun server, scripts, QA gate, deployment.
@ -104,11 +107,12 @@ Update `package.json` scripts and dependencies:
} }
``` ```
Install React packages with Bun so `bun.lock` updates: Install React, Tailwind, and shadcn packages with Bun so `bun.lock` updates:
```sh ```sh
bun add @iconify/react @vitejs/plugin-react react react-dom bun add @iconify/react @vitejs/plugin-react react react-dom
bun add -d @storybook/react-vite @types/react @types/react-dom bun add class-variance-authority clsx lucide-react radix-ui tailwind-merge tw-animate-css @fontsource-variable/geist
bun add -d @storybook/react-vite @tailwindcss/vite @types/react @types/react-dom shadcn tailwindcss
``` ```
Create `index.html`: Create `index.html`:
@ -181,7 +185,16 @@ createRoot(root).render(
Update `tsconfig.json` with React JSX and path aliases. Update `tsconfig.json` with React JSX and path aliases.
Update `vite.config.ts` to use `@vitejs/plugin-react` and `$lib` alias. Update `vite.config.ts` to use `@vitejs/plugin-react`, `@tailwindcss/vite`,
and the `$lib` alias. Initialize shadcn with:
```sh
bunx --bun shadcn@latest init --template vite --base radix --preset nova --yes --css-variables
bunx --bun shadcn@latest add badge card progress separator skeleton alert
```
Do not run `bunx --bun shadcn@latest add --all`; the dashboard should only
vendor primitives it actually uses.
- [ ] **Step 4: Run checks for the scaffold** - [ ] **Step 4: Run checks for the scaffold**

View file

@ -18,7 +18,10 @@ state is a React runtime and React component library.
Use Vite React for the browser app and a small Bun HTTP server for production Use Vite React for the browser app and a small Bun HTTP server for production
runtime. This keeps the existing Bun SQLite persistence model and avoids adding runtime. This keeps the existing Bun SQLite persistence model and avoids adding
a heavier React framework where the current route/API surface is small. a heavier React framework where the current route/API surface is small. Use
Tailwind CSS v4 and shadcn/ui as the reusable primitive layer for React
components, but do not vendor the full shadcn registry. Add only primitives
that map to the dashboard surface.
The migrated app will have these boundaries: The migrated app will have these boundaries:
@ -36,6 +39,10 @@ The migrated app will have these boundaries:
TypeScript business logic with minimal import-path updates. TypeScript business logic with minimal import-path updates.
- `src/lib/ui/components/*.tsx` contains the reusable React component library. - `src/lib/ui/components/*.tsx` contains the reusable React component library.
- `src/lib/ui/stories/*.stories.tsx` contains React Storybook stories. - `src/lib/ui/stories/*.stories.tsx` contains React Storybook stories.
- `src/lib/components/ui/*.tsx` contains shadcn/ui primitives used by the
dashboard component library.
- `components.json` records the shadcn configuration with Vite, Radix, the Nova
preset, and `$lib` import aliases.
## Component Migration ## Component Migration
@ -76,10 +83,12 @@ data-model identifiers, severity attributes, focusable links, reduced-motion
behavior, and screenshot-tested dashboard layout must stay equivalent to the behavior, and screenshot-tested dashboard layout must stay equivalent to the
current Svelte implementation. current Svelte implementation.
CSS will remain in `src/lib/ui/tokens.css` and component-specific CSS files. CSS will keep the existing design tokens in `src/lib/ui/tokens.css`.
React components should import their own CSS modules or regular CSS files in a `src/app.css` imports Tailwind, shadcn CSS, font assets, and the existing
consistent local pattern. Shared UI types and `dashboardDocumentToUiDashboard` Dimension Lab token file. shadcn semantic variables must be mapped to the dark
remain framework-agnostic TypeScript. console palette so generated primitives fit the dashboard instead of resetting
the app to a light generic theme. Shared UI types and
`dashboardDocumentToUiDashboard` remain framework-agnostic TypeScript.
## Runtime And API Behavior ## Runtime And API Behavior
@ -101,8 +110,11 @@ owned by `src/lib/server/agent-config/index.ts`.
`package.json` will move from Svelte/SvelteKit dependencies to React tooling: `package.json` will move from Svelte/SvelteKit dependencies to React tooling:
- Runtime dependencies include `@vitejs/plugin-react`, `react`, `react-dom`, - Runtime dependencies include `react`, `react-dom`, `@iconify/react`,
`@iconify/react`, and existing non-Svelte libraries that still apply. selected shadcn primitive dependencies, Tailwind merge helpers, and existing
non-Svelte libraries that still apply.
- Dev dependencies include `@vitejs/plugin-react`, `@tailwindcss/vite`,
`tailwindcss`, and the shadcn package needed by the generated CSS import.
- Storybook moves from `@storybook/sveltekit` and Svelte CSF to - Storybook moves from `@storybook/sveltekit` and Svelte CSF to
`@storybook/react-vite`. `@storybook/react-vite`.
- `svelte.config.js`, `src/app.html`, `src/routes/**`, and `.svelte` files are - `svelte.config.js`, `src/app.html`, `src/routes/**`, and `.svelte` files are
@ -139,6 +151,8 @@ The migration is complete only when current evidence proves all of these:
- There are no `.svelte` app, component, route, or story files left. - There are no `.svelte` app, component, route, or story files left.
- `package.json` has no Svelte, SvelteKit, or Svelte Storybook dependencies. - `package.json` has no Svelte, SvelteKit, or Svelte Storybook dependencies.
- shadcn is configured for Vite/Radix with `$lib` aliases and only selected
primitives, not the full registry.
- `bun run check` passes. - `bun run check` passes.
- `bun run test:unit` passes. - `bun run test:unit` passes.
- `bun run build` produces the React client build and Bun server entry. - `bun run build` produces the React client build and Bun server entry.

View file

@ -5,8 +5,8 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
"build": "vite build", "build": "vite build && bun build src/server/index.ts --target bun --outdir build",
"preview": "vite preview --host 0.0.0.0", "preview": "HOST=0.0.0.0 PORT=4173 bun build/index.js",
"storybook": "storybook dev -p 6006 --host 0.0.0.0", "storybook": "storybook dev -p 6006 --host 0.0.0.0",
"build-storybook": "storybook build", "build-storybook": "storybook build",
"check": "tsc --noEmit", "check": "tsc --noEmit",
@ -18,14 +18,21 @@
"db:check": "drizzle-kit check" "db:check": "drizzle-kit check"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/geist": "^5.2.9",
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@iconify/svelte": "^5.2.2", "@iconify/svelte": "^5.2.2",
"@sinclair/typebox": "^0.34.49", "@sinclair/typebox": "^0.34.49",
"ajv": "^8.20.0", "ajv": "^8.20.0",
"ajv-formats": "^3.0.1", "ajv-formats": "^3.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"lucide-react": "^1.21.0",
"radix-ui": "^1.6.0",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"uplot": "^1.6.32" "uplot": "^1.6.32"
}, },
"devDependencies": { "devDependencies": {
@ -39,6 +46,7 @@
"@sveltejs/adapter-node": "^5.5.4", "@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.65.2", "@sveltejs/kit": "^2.65.2",
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.1",
"@types/bun": "^1.3.14", "@types/bun": "^1.3.14",
"@types/node": "^25.9.3", "@types/node": "^25.9.3",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
@ -46,9 +54,11 @@
"@vitejs/plugin-react": "^6.0.2", "@vitejs/plugin-react": "^6.0.2",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"msw": "^2.14.6", "msw": "^2.14.6",
"shadcn": "^4.11.0",
"storybook": "^10.4.6", "storybook": "^10.4.6",
"svelte": "^5.56.3", "svelte": "^5.56.3",
"svelte-check": "^4.6.0", "svelte-check": "^4.6.0",
"tailwindcss": "^4.3.1",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "^8.0.16", "vite": "^8.0.16",
"vitest": "^4.1.9" "vitest": "^4.1.9"

View file

@ -1,5 +1,13 @@
import { useEffect, useState } from "react";
import type { DashboardRuntimeState } from "$lib/server/dashboard"; import type { DashboardRuntimeState } from "$lib/server/dashboard";
const loadingDashboardState: DashboardRuntimeState = {
state: "loading",
title: "Loading Dashboard",
subtitle: "Fetching active model",
message: "Waiting for the active dashboard document.",
};
export function AppStateView({ export function AppStateView({
dashboard, dashboard,
}: { }: {
@ -19,14 +27,45 @@ export function AppStateView({
} }
export default function App() { export default function App() {
return ( const [dashboard, setDashboard] =
<AppStateView useState<DashboardRuntimeState>(loadingDashboardState);
dashboard={{
state: "loading", useEffect(() => {
title: "Loading Dashboard", let cancelled = false;
subtitle: "Fetching active model", let refreshTimer: number | undefined;
message: "Waiting for the active dashboard document.",
}} async function loadDashboard() {
/> const response = await fetch("/api/dashboard");
); const nextDashboard = (await response.json()) as DashboardRuntimeState;
if (cancelled) return;
setDashboard(nextDashboard);
if (refreshTimer) {
window.clearInterval(refreshTimer);
refreshTimer = undefined;
}
const refreshIntervalSeconds =
nextDashboard.state === "ready"
? nextDashboard.document.metadata.refreshIntervalSeconds
: undefined;
if (refreshIntervalSeconds) {
refreshTimer = window.setInterval(
() => void loadDashboard(),
refreshIntervalSeconds * 1000,
);
}
}
void loadDashboard();
return () => {
cancelled = true;
if (refreshTimer) window.clearInterval(refreshTimer);
};
}, []);
return <AppStateView dashboard={dashboard} />;
} }

View file

@ -1 +1,145 @@
@import "tailwindcss";
@import "./lib/ui/tokens.css"; @import "./lib/ui/tokens.css";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-sans);
--font-sans: 'Geist Variable', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root,
.dark {
--background: var(--ui-color-canvas);
--foreground: var(--ui-color-text);
--card: var(--ui-color-surface);
--card-foreground: var(--ui-color-text);
--popover: var(--ui-color-surface-raised);
--popover-foreground: var(--ui-color-text);
--primary: var(--ui-color-accent);
--primary-foreground: var(--ui-color-canvas);
--secondary: var(--ui-color-surface-raised);
--secondary-foreground: var(--ui-color-text);
--muted: var(--ui-color-surface-raised);
--muted-foreground: var(--ui-color-muted);
--accent: var(--ui-color-accent);
--accent-foreground: var(--ui-color-canvas);
--destructive: var(--ui-color-danger);
--border: rgba(244, 244, 244, 0.16);
--input: rgba(244, 244, 244, 0.18);
--ring: var(--ui-color-accent);
--chart-1: var(--ui-color-accent);
--chart-2: var(--ui-color-ok);
--chart-3: var(--ui-color-warning);
--chart-4: var(--ui-color-danger);
--chart-5: var(--ui-color-stale);
--radius: 0.5rem;
--sidebar: var(--ui-color-surface);
--sidebar-foreground: var(--ui-color-text);
--sidebar-primary: var(--ui-color-accent);
--sidebar-primary-foreground: var(--ui-color-canvas);
--sidebar-accent: var(--ui-color-surface-raised);
--sidebar-accent-foreground: var(--ui-color-text);
--sidebar-border: rgba(244, 244, 244, 0.16);
--sidebar-ring: var(--ui-color-accent);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
html {
background: var(--ui-color-canvas);
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
background:
linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px),
var(--ui-color-canvas);
background-size: 48px 48px, 48px 48px, auto;
color: var(--ui-color-text);
font-family: var(--ui-font-mono);
text-rendering: geometricPrecision;
}
button,
input,
textarea,
select {
font: inherit;
}
button:focus-visible,
a:focus-visible,
[tabindex]:focus-visible {
outline: 0;
box-shadow: var(--ui-focus-ring);
}
a {
color: inherit;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}

View file

@ -0,0 +1,76 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "$lib/utils"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }

View file

@ -0,0 +1,49 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "$lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View file

@ -0,0 +1,67 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "$lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View file

@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "$lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View file

@ -0,0 +1,29 @@
import * as React from "react"
import { Progress as ProgressPrimitive } from "radix-ui"
import { cn } from "$lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="size-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View file

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "$lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

View file

@ -0,0 +1,13 @@
import { cn } from "$lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

6
src/lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

84
src/server/index.ts Normal file
View file

@ -0,0 +1,84 @@
import { extname, normalize } from "node:path";
import { handleAgentDashboardRoute } from "./routes/agent-dashboard";
import { handleDashboardRoute } from "./routes/dashboard";
const host = process.env.HOST || "0.0.0.0";
const port = Number(process.env.PORT || 3000);
const distRoot = `${process.cwd()}/dist`;
const contentTypes = new Map([
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
[".js", "text/javascript; charset=utf-8"],
[".json", "application/json; charset=utf-8"],
[".svg", "image/svg+xml"],
[".wasm", "application/wasm"],
]);
export async function handleRequest(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/api/dashboard") {
if (request.method !== "GET") return methodNotAllowed(["GET"]);
return handleDashboardRoute();
}
if (url.pathname === "/api/agent/dashboard") {
if (request.method !== "POST") return methodNotAllowed(["POST"]);
return handleAgentDashboardRoute(request);
}
if (url.pathname.startsWith("/api/")) {
return Response.json({ ok: false, message: "Not found" }, { status: 404 });
}
return serveStaticAsset(url.pathname);
}
async function serveStaticAsset(pathname: string): Promise<Response> {
const safePath = normalize(pathname).replace(/^(\.\.(\/|\\|$))+/, "");
const assetPath = safePath === "/" || safePath === "." ? "/index.html" : safePath;
const file = Bun.file(`${distRoot}${assetPath}`);
if (await file.exists()) {
return new Response(file, {
headers: contentTypeHeaders(assetPath),
});
}
const fallback = Bun.file(`${distRoot}/index.html`);
if (await fallback.exists()) {
return new Response(fallback, {
headers: contentTypeHeaders(".html"),
});
}
return new Response("Build output not found", { status: 404 });
}
function methodNotAllowed(allowedMethods: string[]): Response {
return Response.json(
{ ok: false, message: "Method not allowed" },
{
status: 405,
headers: {
Allow: allowedMethods.join(", "),
},
},
);
}
function contentTypeHeaders(pathname: string): HeadersInit {
const contentType = contentTypes.get(extname(pathname));
return contentType ? { "Content-Type": contentType } : {};
}
if (import.meta.main) {
Bun.serve({
hostname: host,
port,
fetch: handleRequest,
});
console.info(`Dimension Lab website listening on http://${host}:${port}`);
}

View file

@ -0,0 +1,12 @@
import { describe, expect, test } from "vitest";
import { handleAgentDashboardRoute } from "./agent-dashboard";
describe("agent dashboard API route", () => {
test("delegates unauthorized requests to the existing agent handler", async () => {
const response = await handleAgentDashboardRoute(
new Request("http://localhost/api/agent/dashboard", { method: "POST" }),
);
expect(response.status).toBe(401);
});
});

View file

@ -0,0 +1,5 @@
import { handleAgentDashboardRequest } from "$lib/server/agent-config";
export function handleAgentDashboardRoute(request: Request): Promise<Response> {
return handleAgentDashboardRequest(request);
}

View file

@ -0,0 +1,19 @@
import { describe, expect, test } from "vitest";
import { dimensionLabDashboardFixture } from "$lib/model/fixtures/dimensionlab";
import { loadDashboardResponse } from "./dashboard";
describe("dashboard API route", () => {
test("returns ready dashboard runtime state from the existing model loader", async () => {
const response = await loadDashboardResponse({
disableLiveDatasources: true,
refreshSeedDocument: true,
seedIfEmpty: true,
});
expect(response.state).toBe("ready");
if (response.state !== "ready") throw new Error("expected ready dashboard");
expect(response.document.metadata.title).toBe(
dimensionLabDashboardFixture.metadata.title,
);
});
});

View file

@ -0,0 +1,41 @@
import {
loadDashboardRuntime,
type DashboardRuntimeOptions,
type DashboardRuntimeState,
} from "$lib/server/dashboard";
import { resolveDashboardDatasources } from "$lib/server/datasources";
export interface LoadDashboardResponseOptions
extends Pick<
DashboardRuntimeOptions,
"refreshSeedDocument" | "seedIfEmpty" | "seedDocument"
> {
disableLiveDatasources?: boolean;
}
export async function loadDashboardResponse(
options: LoadDashboardResponseOptions = {},
): Promise<DashboardRuntimeState> {
const dashboard = loadDashboardRuntime(undefined, {
refreshSeedDocument: options.refreshSeedDocument ?? true,
seedIfEmpty: options.seedIfEmpty ?? true,
seedDocument: options.seedDocument,
});
if (dashboard.state !== "ready") {
return dashboard;
}
if (options.disableLiveDatasources || process.env.DISABLE_LIVE_DATASOURCES === "1") {
return dashboard;
}
return {
...dashboard,
document: await resolveDashboardDatasources(dashboard.document),
};
}
export async function handleDashboardRoute(): Promise<Response> {
return Response.json(await loadDashboardResponse());
}

View file

@ -1,16 +1,17 @@
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { svelte } from "@sveltejs/vite-plugin-svelte"; import { svelte } from "@sveltejs/vite-plugin-svelte";
import { defineConfig, configDefaults } from "vitest/config"; import { defineConfig, configDefaults } from "vitest/config";
export default defineConfig({ export default defineConfig({
plugins: [react(), svelte()], plugins: [react(), tailwindcss(), svelte()],
resolve: { resolve: {
alias: { alias: {
$lib: fileURLToPath(new URL("./src/lib", import.meta.url)), $lib: fileURLToPath(new URL("./src/lib", import.meta.url)),
}, },
}, },
test: { test: {
exclude: [...configDefaults.exclude, "tests/e2e/**"], exclude: [...configDefaults.exclude, "tests/e2e/**", "src/routes/**"],
}, },
}); });