Compare commits

...

9 commits

Author SHA1 Message Date
vince
b1d196e4ca fix(ui): restore react dashboard visual parity 2026-06-19 23:56:49 +02:00
vince
72d56cb3c9 refactor: remove svelte runtime 2026-06-19 23:49:48 +02:00
vince
76fe468df5 feat(storybook): migrate stories to react 2026-06-19 23:45:54 +02:00
vince
92a8990e7a feat(app): render dashboard with react 2026-06-19 23:42:25 +02:00
vince
73abcc1b69 feat(ui): port dashboard components to react 2026-06-19 23:41:19 +02:00
vince
a71d66b1b8 feat: add react api and shadcn foundation 2026-06-19 23:35:46 +02:00
vince
f6d6d39a7b build: add react vite scaffold 2026-06-19 23:26:28 +02:00
vince
abfb885851 docs: plan react runtime migration 2026-06-19 23:23:45 +02:00
vince
ef9715c5a6 docs: define react runtime migration 2026-06-19 23:21:09 +02:00
166 changed files with 4864 additions and 3135 deletions

View file

@ -1,15 +1,14 @@
import type { StorybookConfig } from "@storybook/sveltekit";
import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
stories: ["../src/**/*.stories.@(js|ts|svelte)"],
stories: ["../src/**/*.stories.@(js|ts|tsx)"],
staticDirs: ["../static"],
addons: [
"@storybook/addon-svelte-csf",
"@storybook/addon-a11y",
"@storybook/addon-vitest",
],
framework: {
name: "@storybook/sveltekit",
name: "@storybook/react-vite",
options: {},
},
docs: {

View file

@ -1,5 +1,5 @@
import "../src/app.css";
import type { Preview } from "@storybook/sveltekit";
import type { Preview } from "@storybook/react-vite";
import { setupWorker } from "msw/browser";
import { externalApiHandlers } from "../src/lib/testing/external-api-mocks";

View file

@ -21,6 +21,7 @@ ENV DASHBOARD_MIGRATIONS_DIR=/app/drizzle
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production
COPY --from=build /app/build ./build
COPY --from=build /app/dist ./dist
COPY --from=build /app/drizzle ./drizzle
RUN mkdir -p /data

View file

@ -1,6 +1,6 @@
# Dimension Lab Website
Standalone SvelteKit runtime for the Dimension Lab system overview dashboard.
Standalone React runtime for the Dimension Lab system overview dashboard.
This project is not a Homepage customization and does not depend on Homepage
runtime, frontend code, or configuration. The dashboard will be model-driven:
@ -19,7 +19,7 @@ This MVP uses Drizzle with Bun SQLite for local file-backed persistence.
## Scripts
- `bun run dev`: start the local development server.
- `bun run check`: run Svelte and TypeScript checks.
- `bun run check`: run TypeScript checks.
- `bun run test`: run Vitest.
- `bun run test:unit`: run Vitest explicitly as the unit test stage.
- `bun run test:e2e`: build and run Playwright browser smoke and QA checks.
@ -63,9 +63,10 @@ can replace them without changing presentation components.
## Runtime Shape
The SvelteKit build uses the Node adapter, and the current persistence runtime
is Bun because the MVP SQLite driver is `bun:sqlite`. Later issues add the
seed data expansion and rendering pipeline.
The browser app is built with Vite and React. Production uses a small Bun HTTP
server at `build/index.js` to serve the Vite `dist/` assets and JSON API routes.
The current persistence runtime is Bun because the MVP SQLite driver is
`bun:sqlite`.
## Storybook
@ -87,7 +88,7 @@ Run the CI-ready release gate with:
bun run test:qa
```
The gate runs Svelte/TypeScript checks, Vitest coverage for model,
The gate runs TypeScript checks, Vitest coverage for model,
persistence, renderer, datasource mocks, and presentation boundaries, the
production build, the static Storybook build, and Playwright desktop/mobile
smoke checks against the built adapter output. Playwright also performs
@ -104,8 +105,8 @@ belong in validated model data, not reusable components.
## Deployment Notes
The production build uses the SvelteKit Node adapter, but the current SQLite
driver depends on Bun. A minimal deployment flow is:
The production build emits Vite client assets under `dist/` and a Bun server
entry at `build/index.js`. A minimal deployment flow is:
```sh
bun install --frozen-lockfile
@ -119,7 +120,7 @@ the checked-in `drizzle/` directory so startup migrations can run.
### Internal Container
The checked-in `Containerfile` builds the SvelteKit adapter output into a Bun
The checked-in `Containerfile` builds the React client and Bun server into a
runtime image. For the Dimension Lab internal host, run it behind Caddy on a
loopback port and mount persistent state at `/data`:

889
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

@ -0,0 +1,592 @@
# React Runtime Migration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the SvelteKit dashboard app with a React-based Vite app and Bun production server while preserving model-driven dashboard behavior and reusable presentation boundaries.
**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, Tailwind CSS v4, shadcn/ui, Vitest, Storybook React Vite, Playwright, Drizzle ORM, Bun SQLite, MSW, uPlot, Iconify React.
---
## File Structure
- Create `index.html`: Vite app shell with `<div id="root"></div>` and `/src/main.tsx`.
- Create `src/main.tsx`: React DOM bootstrap and global CSS import.
- Create `src/App.tsx`: Dashboard fetch, refresh interval, ready/empty/loading/invalid rendering.
- Create `src/App.test.tsx`: React server-rendering and hook behavior tests for dashboard states.
- Create `src/server/index.ts`: Bun HTTP server, static asset serving, API router, production entry.
- Create `src/server/routes/dashboard.ts`: `GET /api/dashboard` runtime loader and datasource resolver.
- Create `src/server/routes/agent-dashboard.ts`: `POST /api/agent/dashboard` adapter for `handleAgentDashboardRequest`.
- Create `src/server/routes/dashboard.test.ts`: dashboard API state and datasource-disable 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/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/stories/*.stories.svelte`: React `.stories.tsx` stories.
- 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 `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 `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 `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.
- Remove `svelte.config.js`, `src/app.html`, `src/routes/**`, and all `.svelte` files after replacements pass.
## Task 1: React Toolchain And Typecheck Scaffold
**Files:**
- Modify: `package.json`
- Modify: `bun.lock`
- Modify: `tsconfig.json`
- Modify: `vite.config.ts`
- Create: `index.html`
- Create: `src/main.tsx`
- Create: `src/App.tsx`
- Create: `src/App.test.tsx`
- [ ] **Step 1: Write the failing React scaffold test**
Create `src/App.test.tsx`:
```tsx
import { renderToString } from "react-dom/server";
import { describe, expect, test } from "vitest";
import { AppStateView } from "./App";
describe("React app dashboard state view", () => {
test("renders loading dashboard state", () => {
const html = renderToString(
<AppStateView
dashboard={{
state: "loading",
title: "Loading Dashboard",
subtitle: "Fetching active model",
message: "Waiting for the active dashboard document.",
}}
/>,
);
expect(html).toContain("Loading Dashboard");
expect(html).toContain("Fetching active model");
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `bun run test:unit src/App.test.tsx`
Expected: FAIL because React dependencies and `src/App.tsx` do not exist.
- [ ] **Step 3: Add React dependencies and scaffold files**
Update `package.json` scripts and dependencies:
```json
{
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build && bun build src/server/index.ts --target bun --outdir build",
"preview": "HOST=0.0.0.0 PORT=4173 bun build/index.js",
"storybook": "storybook dev -p 6006 --host 0.0.0.0",
"build-storybook": "storybook build",
"check": "tsc --noEmit",
"test": "vitest run",
"test:unit": "vitest run",
"test:e2e": "env -u NO_COLOR playwright test",
"test:qa": "bun run check && bun run test:unit && bun run build && bun run build-storybook && bun run test:e2e",
"db:generate": "drizzle-kit generate",
"db:check": "drizzle-kit check"
}
}
```
Install React, Tailwind, and shadcn packages with Bun so `bun.lock` updates:
```sh
bun add @iconify/react @vitejs/plugin-react react 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`:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dimension Lab</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
```
Create minimal `src/App.tsx`:
```tsx
import type { DashboardRuntimeState } from "$lib/server/dashboard";
export function AppStateView({ dashboard }: { dashboard: DashboardRuntimeState }) {
if (dashboard.state === "ready") {
return <main>{dashboard.document.metadata.title}</main>;
}
return (
<main className="state-shell" data-dashboard-state={dashboard.state}>
<h1>{dashboard.title}</h1>
<p>{dashboard.subtitle}</p>
<p>{dashboard.message}</p>
</main>
);
}
export default function App() {
return (
<AppStateView
dashboard={{
state: "loading",
title: "Loading Dashboard",
subtitle: "Fetching active model",
message: "Waiting for the active dashboard document.",
}}
/>
);
}
```
Create `src/main.tsx`:
```tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./app.css";
const root = document.getElementById("root");
if (!root) throw new Error("Missing React root element");
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
```
Update `tsconfig.json` with React JSX and path aliases.
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**
Run: `bun run test:unit src/App.test.tsx && bun run check`
Expected: PASS.
- [ ] **Step 5: Commit**
```sh
git add package.json bun.lock tsconfig.json vite.config.ts index.html src/main.tsx src/App.tsx src/App.test.tsx
git commit -m "build: add react vite scaffold"
```
## Task 2: Bun Server And Dashboard API
**Files:**
- Create: `src/server/index.ts`
- Create: `src/server/routes/dashboard.ts`
- Create: `src/server/routes/dashboard.test.ts`
- Create: `src/server/routes/agent-dashboard.ts`
- Create: `src/server/routes/agent-dashboard.test.ts`
- Modify: `src/App.tsx`
- Modify: `playwright.config.ts`
- [ ] **Step 1: Write failing API route tests**
Create `src/server/routes/dashboard.test.ts`:
```ts
import { describe, expect, test } from "vitest";
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).toContain("Dimension Lab");
});
});
```
Create `src/server/routes/agent-dashboard.test.ts`:
```ts
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);
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `bun run test:unit src/server/routes/dashboard.test.ts src/server/routes/agent-dashboard.test.ts`
Expected: FAIL because the route modules do not exist.
- [ ] **Step 3: Implement server route modules and server entry**
Implement `loadDashboardResponse()` by calling `loadDashboardRuntime()` and
`resolveDashboardDatasources()` exactly like the current SvelteKit load
function. Implement `handleAgentDashboardRoute()` by returning
`handleAgentDashboardRequest(request)`. Implement `src/server/index.ts` with
Bun.serve routes for `/api/dashboard`, `/api/agent/dashboard`, static Vite
assets, and SPA fallback to `index.html`.
- [ ] **Step 4: Update React app to fetch `/api/dashboard`**
`src/App.tsx` should export `AppStateView` for tests and make the default
`App` fetch dashboard state with `useEffect`. It should clear refresh timers
when state changes and on unmount.
- [ ] **Step 5: Run route and app tests**
Run:
```sh
bun run test:unit src/server/routes/dashboard.test.ts src/server/routes/agent-dashboard.test.ts src/App.test.tsx
```
Expected: PASS.
- [ ] **Step 6: Commit**
```sh
git add src/server src/App.tsx src/App.test.tsx playwright.config.ts
git commit -m "feat(server): add bun dashboard api"
```
## Task 3: React UI Component Library
**Files:**
- Create: `src/lib/ui/components/*.tsx`
- Create: `src/lib/ui/components/styles.css`
- Modify: `src/lib/ui/components/render.test.ts`
- Modify: `src/lib/ui/index.ts`
- Keep: `src/lib/ui/types.ts`
- Keep: `src/lib/ui/model-renderer.ts`
- Keep: `src/lib/ui/fixtures.ts`
- [ ] **Step 1: Replace Svelte SSR tests with failing React render tests**
Rewrite `src/lib/ui/components/render.test.ts` to import React components and
`renderToString` from `react-dom/server`. Keep the current assertions for:
- dashboard fixture content
- generic secondary fixture content
- optional service/status links
- stable model IDs
- progress bar behavior
- uPlot chart surface marker
- native attributes on Button and IconButton
Run: `bun run test:unit src/lib/ui/components/render.test.ts`
Expected: FAIL because React component files do not exist yet.
- [ ] **Step 2: Port atomic components**
Create React equivalents for `Badge`, `Button`, `IconGlyph`, `IconButton`,
`ProgressMeter`, `Separator`, `Sparkline`, `SignalTrace`, `LineChart`, and
`StatusBadge`. Preserve class names and `data-*` attributes from Svelte.
Run: `bun run test:unit src/lib/ui/components/render.test.ts`
Expected: remaining FAILs only for dashboard composite components.
- [ ] **Step 3: Port layout and card components**
Create React equivalents for `Panel`, `CornerBracketFrame`, `GridFrame`,
`DiagonalStripeField`, `ModuleCard`, `TelemetryCard`, `TelemetryGrid`,
`FooterCell`, `FooterStatusCell`, and `StatusStrip`.
Run: `bun run test:unit src/lib/ui/components/render.test.ts`
Expected: remaining FAILs only for service/dashboard shell components.
- [ ] **Step 4: Port service and dashboard shell components**
Create React equivalents for `ServiceRow`, `ServicePanel`,
`ServiceGroupPanel`, `SystemState`, `DashboardHeader`, `DashboardFrame`,
`TelemetryStrip`, and `WeatherModule`.
Run: `bun run test:unit src/lib/ui/components/render.test.ts`
Expected: PASS.
- [ ] **Step 5: Export React components**
Update `src/lib/ui/index.ts` to export `.tsx` React components and continue
exporting fixtures, renderer, and UI types.
Run:
```sh
bun run test:unit src/lib/ui/components/render.test.ts src/lib/ui/model-renderer.test.ts src/lib/ui/content-boundary.test.ts
```
Expected: PASS.
- [ ] **Step 6: Commit**
```sh
git add src/lib/ui/components src/lib/ui/index.ts
git commit -m "feat(ui): port dashboard components to react"
```
## Task 4: React Dashboard App Rendering
**Files:**
- Modify: `src/App.tsx`
- Modify: `src/App.test.tsx`
- Modify: `src/app.css`
- Delete after `src/page.test.tsx` passes: `src/routes/page.test.ts`
- Create: `src/page.test.tsx`
- [ ] **Step 1: Write failing React page tests**
Create `src/page.test.tsx` with React `renderToString` assertions equivalent to
the current Svelte `src/routes/page.test.ts`:
- ready dashboard renders model content
- invalid model state renders validation errors
- empty and loading states render without crashing
Run: `bun run test:unit src/page.test.tsx`
Expected: FAIL until `AppStateView` uses the React `DashboardFrame` and
`SystemState` components.
- [ ] **Step 2: Implement app state rendering**
Use `dashboardDocumentToUiDashboard()` and `DashboardFrame` for ready state.
Use `SystemState` for empty/loading/invalid states. Preserve state shell CSS
and validation error list markup.
- [ ] **Step 3: Run page tests**
Run: `bun run test:unit src/page.test.tsx src/App.test.tsx`
Expected: PASS.
- [ ] **Step 4: Commit**
```sh
git add src/App.tsx src/App.test.tsx src/page.test.tsx src/app.css
git commit -m "feat(app): render dashboard with react"
```
## Task 5: React Storybook
**Files:**
- Modify: `.storybook/main.ts`
- Modify: `.storybook/preview.ts`
- Create: `src/lib/ui/stories/*.stories.tsx`
- Delete after replacement: `src/lib/ui/stories/*.stories.svelte`
- Delete after replacement: `src/lib/ui/stories/FocusPreview.svelte`
- Modify: `src/lib/ui/storybook.test.ts`
- [ ] **Step 1: Update storybook boundary test first**
Change `src/lib/ui/storybook.test.ts` so it requires React `.stories.tsx`
files and rejects `.stories.svelte` files.
Run: `bun run test:unit src/lib/ui/storybook.test.ts`
Expected: FAIL while Svelte stories still exist.
- [ ] **Step 2: Configure React Storybook**
Update `.storybook/main.ts` to use `@storybook/react-vite`, React story globs,
and the existing addons. Update `.storybook/preview.ts` type imports to React
Storybook while preserving global CSS, MSW setup, backgrounds, controls, and
fullscreen layout.
- [ ] **Step 3: Port stories to React**
Create `.stories.tsx` files for each existing Svelte story. Import React
components from `src/lib/ui` and generic story data from
`src/lib/ui/stories/story-data.ts`.
- [ ] **Step 4: Remove Svelte stories and run Storybook checks**
Run:
```sh
bun run test:unit src/lib/ui/storybook.test.ts
bun run build-storybook
```
Expected: PASS.
- [ ] **Step 5: Commit**
```sh
git add .storybook src/lib/ui/stories src/lib/ui/storybook.test.ts
git commit -m "feat(storybook): migrate stories to react"
```
## Task 6: Remove SvelteKit Runtime
**Files:**
- Delete: `svelte.config.js`
- Delete: `src/app.html`
- Delete: `src/routes/**`
- Delete: all remaining `*.svelte`
- Modify: `package.json`
- Modify: `bun.lock`
- Modify: `README.md`
- Modify: `Containerfile`
- Modify: `playwright.config.ts`
- Modify: `src/lib/presentation-boundary.test.ts`
- [ ] **Step 1: Write/adjust cleanup tests**
Add assertions to presentation or storybook boundary tests that no `.svelte`
files remain under `src/`.
Run: `bun run test:unit src/lib/presentation-boundary.test.ts src/lib/ui/storybook.test.ts`
Expected: FAIL while Svelte files remain.
- [ ] **Step 2: Delete Svelte runtime and dependencies**
Remove all Svelte files and Svelte dependencies. Run `bun install
--frozen-lockfile` only after `package.json` and `bun.lock` are consistent, or
run `bun remove` commands to update both together:
```sh
bun remove @iconify/svelte @storybook/addon-svelte-csf @storybook/sveltekit @sveltejs/adapter-node @sveltejs/kit @sveltejs/vite-plugin-svelte svelte svelte-check
```
- [ ] **Step 3: Update docs, container, and e2e build command**
README should describe React, Vite, Bun server, and unchanged persistence.
`Containerfile` should copy the Vite client output and Bun server output.
`playwright.config.ts` should build and start the Bun server.
- [ ] **Step 4: Run cleanup checks**
Run:
```sh
rg -n "\\.svelte|svelte" package.json src .storybook vite.config.ts tsconfig.json README.md Containerfile
bun run check
bun run test:unit
```
Expected: `rg` finds no Svelte app/runtime references except historical docs in
the committed design/plan, and checks pass.
- [ ] **Step 5: Commit**
```sh
git add -A
git commit -m "refactor: remove svelte runtime"
```
## Task 7: QA Gate, PR, Review, And Merge
**Files:**
- Modify only files needed to fix failures found by this task.
- [ ] **Step 1: Run full QA gate**
Run: `bun run test:qa`
Expected: PASS for check, unit tests, production build, Storybook build, and
Playwright desktop/mobile tests.
- [ ] **Step 2: Inspect current diff**
Run:
```sh
git status --short
git diff --stat main...HEAD
git diff --name-only main...HEAD
```
Expected: only React migration files and docs changed.
- [ ] **Step 3: Push and open ready PR**
Run:
```sh
git push -u origin codex/react-migration
```
Open a ready PR against `main` with title:
```text
refactor: migrate dashboard runtime to react
```
- [ ] **Step 4: Independent review**
Dispatch an independent reviewer to inspect the issue goal, spec, plan, and PR
diff in code-review mode. Blocking findings must be fixed on the same branch.
- [ ] **Step 5: Fix review findings and re-run QA**
For each blocking finding, write or update the relevant failing test first,
make the minimal fix, and run the focused test plus `bun run test:qa`.
- [ ] **Step 6: Merge only after green checks and no blocking review findings**
Merge the PR into `main`, sync the worktree back to `main`, and mark the goal
complete only after the completion criteria in the spec are proven by current
state.
## Plan Self-Review
- Spec coverage: Tasks cover React scaffold, Bun API server, UI component
migration, app rendering, Storybook migration, Svelte removal, QA, PR,
independent review, and merge.
- Red-flag scan: The plan has no incomplete-work markers and no unspecified
acceptance gates.
- Type consistency: Public names used across tasks are `AppStateView`,
`loadDashboardResponse`, and `handleAgentDashboardRoute`.

View file

@ -0,0 +1,177 @@
# React Runtime Migration Design
## Context
The current Dimension Lab website is a SvelteKit application. It uses Bun,
Vite, Svelte 5, SvelteKit server routes, Svelte Storybook stories, and
Svelte SSR component tests. The app has one dashboard route, one agent
configuration API route, reusable Svelte UI components, typed dashboard model
data, Drizzle-backed SQLite persistence, datasource resolution, Playwright
desktop/mobile checks, and a Bun-based container runtime.
The migration goal is to make the project React-based so dashboard UI
components can be reused outside the current app. A partial React island inside
SvelteKit would leave the app split across two component systems, so the target
state is a React runtime and React component library.
## Target Architecture
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
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:
- `src/main.tsx` mounts the React application in the browser.
- `src/App.tsx` owns dashboard loading, refresh timing, document-to-UI mapping,
and non-ready dashboard states.
- `src/server/index.ts` serves the Vite build output in production and exposes
JSON API routes.
- `src/server/routes/dashboard.ts` loads the dashboard runtime and resolves live
datasources unless `DISABLE_LIVE_DATASOURCES=1`.
- `src/server/routes/agent-dashboard.ts` delegates POST requests to the existing
`handleAgentDashboardRequest` function.
- `src/lib/model/**`, `src/lib/server/db/**`, `src/lib/server/dashboard.ts`,
`src/lib/server/datasources/**`, and `src/lib/server/agent-config/**` remain
TypeScript business logic with minimal import-path updates.
- `src/lib/ui/components/*.tsx` contains the reusable React component library.
- `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
Every current Svelte UI component will be ported to React with typed props:
- Badge
- Button
- CornerBracketFrame
- DashboardFrame
- DashboardHeader
- DiagonalStripeField
- FooterCell
- FooterStatusCell
- GridFrame
- IconButton
- IconGlyph
- LineChart
- ModuleCard
- Panel
- ProgressMeter
- ScanlineField
- Separator
- ServiceGroupPanel
- ServicePanel
- ServiceRow
- SignalTrace
- Sparkline
- StatusBadge
- StatusStrip
- SystemState
- TelemetryCard
- TelemetryGrid
- TelemetryStrip
- WeatherModule
The visual design, CSS custom property tokens, accessibility attributes,
data-model identifiers, severity attributes, focusable links, reduced-motion
behavior, and screenshot-tested dashboard layout must stay equivalent to the
current Svelte implementation.
CSS will keep the existing design tokens in `src/lib/ui/tokens.css`.
`src/app.css` imports Tailwind, shadcn CSS, font assets, and the existing
Dimension Lab token file. shadcn semantic variables must be mapped to the dark
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
The React app will fetch `GET /api/dashboard` on page load. When the runtime
state is ready, the response includes the dashboard document and metadata. When
the runtime state is empty, loading, or invalid, the React app renders the same
state shell that the Svelte page currently renders.
Ready dashboard documents use `metadata.refreshIntervalSeconds` to schedule a
refresh. The React implementation will clear old timers when the dashboard
state changes and on unmount.
The existing agent configuration endpoint remains available at
`POST /api/agent/dashboard`. The request validation, token authorization,
preview, publish, rollback, JSON patch behavior, and persistence behavior remain
owned by `src/lib/server/agent-config/index.ts`.
## Build, Storybook, And Deployment
`package.json` will move from Svelte/SvelteKit dependencies to React tooling:
- Runtime dependencies include `react`, `react-dom`, `@iconify/react`,
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/react-vite`.
- `svelte.config.js`, `src/app.html`, `src/routes/**`, and `.svelte` files are
removed after equivalent React/server files exist.
The production build still creates `build/index.js` as the Bun server entry so
the current container command remains:
```sh
DATABASE_URL=file:/data/dimensionlab.sqlite HOST=0.0.0.0 PORT=3000 bun build/index.js
```
The `Containerfile` continues to install with Bun, build with Bun, copy the
client/server build output plus `drizzle/`, and run the Bun server.
## Testing Strategy
The migration is verified with equivalent or stronger tests:
- TypeScript check covers React TSX, server modules, and shared model code.
- Current model, validation, database, datasource, and agent-config unit tests
remain in place.
- Svelte SSR component tests become React `react-dom/server` tests.
- Page rendering tests become React app/server response tests.
- Storybook boundary tests are updated to require React story files and continue
preventing Dimension Lab-specific content in reusable presentation stories.
- Playwright desktop/mobile tests continue to run against the production Bun
server and the built React app.
- The full QA gate remains `bun run test:qa`.
## Completion Criteria
The migration is complete only when current evidence proves all of these:
- There are no `.svelte` app, component, route, or story files left.
- `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 test:unit` passes.
- `bun run build` produces the React client build and Bun server entry.
- `bun run build-storybook` passes with React stories.
- `bun run test:e2e` passes on desktop and mobile.
- The dashboard renders from the same validated dashboard model data.
- `POST /api/agent/dashboard` still exercises the existing agent config logic.
- The production container still runs with `bun build/index.js`.
## Migration Approach
The work should be implemented in focused commits on `codex/react-migration`:
1. Establish React/Vite/Bun server scaffolding and tests while keeping the
current Svelte code available for reference.
2. Port reusable UI components to React and update render tests.
3. Port the dashboard app route and refresh behavior to React.
4. Port Storybook stories and presentation boundary tests to React.
5. Remove SvelteKit runtime files and dependencies.
6. Update build, e2e, README, and container behavior.
7. Run the full QA gate, push the branch, open a ready PR, perform independent
review, fix blockers, and merge only after checks and review pass.

12
index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dimension Lab</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -5,11 +5,11 @@
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0",
"build": "vite build && bun build src/server/index.ts --target bun --outdir build",
"preview": "HOST=0.0.0.0 PORT=4173 bun build/index.js",
"storybook": "storybook dev -p 6006 --host 0.0.0.0",
"build-storybook": "storybook build",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check": "tsc --noEmit",
"test": "vitest run",
"test:unit": "vitest run",
"test:e2e": "env -u NO_COLOR playwright test",
@ -18,30 +18,39 @@
"db:check": "drizzle-kit check"
},
"dependencies": {
"@iconify/svelte": "^5.2.2",
"@fontsource-variable/geist": "^5.2.9",
"@iconify/react": "^6.0.2",
"@sinclair/typebox": "^0.34.49",
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.2",
"lucide-react": "^1.21.0",
"radix-ui": "^1.6.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"uplot": "^1.6.32"
},
"devDependencies": {
"@axe-core/playwright": "^4.11.3",
"@playwright/test": "^1.61.0",
"@storybook/addon-a11y": "^10.4.6",
"@storybook/addon-svelte-csf": "^5.1.2",
"@storybook/addon-vitest": "^10.4.6",
"@storybook/sveltekit": "^10.4.6",
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.65.2",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@storybook/react-vite": "^10.4.6",
"@tailwindcss/vite": "^4.3.1",
"@types/bun": "^1.3.14",
"@types/node": "^25.9.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"drizzle-kit": "^0.31.10",
"msw": "^2.14.6",
"shadcn": "^4.11.0",
"storybook": "^10.4.6",
"svelte": "^5.56.3",
"svelte-check": "^4.6.0",
"tailwindcss": "^4.3.1",
"typescript": "^6.0.3",
"vite": "^8.0.16",
"vitest": "^4.1.9"

21
src/App.test.tsx Normal file
View file

@ -0,0 +1,21 @@
import { renderToString } from "react-dom/server";
import { describe, expect, test } from "vitest";
import { AppStateView } from "./App";
describe("React app dashboard state view", () => {
test("renders loading dashboard state", () => {
const html = renderToString(
<AppStateView
dashboard={{
state: "loading",
title: "Loading Dashboard",
subtitle: "Fetching active model",
message: "Waiting for the active dashboard document.",
}}
/>,
);
expect(html).toContain("Loading Dashboard");
expect(html).toContain("Fetching active model");
});
});

106
src/App.tsx Normal file
View file

@ -0,0 +1,106 @@
import { useEffect, useState } from "react";
import type { DashboardRuntimeState } from "$lib/server/dashboard";
import {
DashboardFrame,
SystemState,
dashboardDocumentToUiDashboard,
type UiSeverity,
} from "$lib/ui";
const loadingDashboardState: DashboardRuntimeState = {
state: "loading",
title: "Loading Dashboard",
subtitle: "Fetching active model",
message: "Waiting for the active dashboard document.",
};
export function AppStateView({
dashboard,
}: {
dashboard: DashboardRuntimeState;
}) {
if (dashboard.state === "ready") {
return (
<DashboardFrame
dashboard={dashboardDocumentToUiDashboard(dashboard.document)}
/>
);
}
const detail = `${dashboard.subtitle}: ${dashboard.message}`;
const errors = dashboard.state === "invalid" ? dashboard.errors : [];
return (
<main className="state-shell" data-dashboard-state={dashboard.state}>
<SystemState
title={dashboard.title}
detail={detail}
severity={stateSeverity(dashboard.state)}
icon={stateIcon(dashboard.state)}
/>
{errors.length ? (
<ul aria-label="Validation errors">
{errors.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
) : null}
</main>
);
}
export default function App() {
const [dashboard, setDashboard] =
useState<DashboardRuntimeState>(loadingDashboardState);
useEffect(() => {
let cancelled = false;
let refreshTimer: number | undefined;
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} />;
}
function stateSeverity(state: DashboardRuntimeState["state"]): UiSeverity {
if (state === "invalid") return "danger";
if (state === "loading") return "loading";
return "stale";
}
function stateIcon(state: DashboardRuntimeState["state"]): string {
if (state === "invalid") return "mdi:file-alert-outline";
if (state === "loading") return "mdi:progress-clock";
return "mdi:tray";
}

View file

@ -1 +1,166 @@
@import "tailwindcss";
@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;
}
.state-shell {
display: grid;
min-height: 100vh;
align-content: center;
gap: var(--ui-space-3);
padding: var(--ui-space-4);
}
.state-shell ul {
display: grid;
max-width: 56rem;
gap: var(--ui-space-2);
margin: 0;
border: var(--ui-border);
background: rgba(6, 8, 7, 0.82);
color: var(--ui-color-muted);
font-size: 0.76rem;
list-style-position: inside;
padding: var(--ui-space-3);
}
@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

@ -1,11 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

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 }

View file

@ -1,4 +1,4 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
@ -30,14 +30,22 @@ describe("presentation content boundary", () => {
expect(forbiddenTerms.filter((term) => source.includes(term))).toEqual([]);
});
test("does not keep legacy presentation component files in the React runtime", () => {
const legacyExtension = [".sve", "lte"].join("");
expect(findFiles(join(process.cwd(), "src"), legacyExtension)).toEqual([]);
});
});
function readPresentationSource(path: string): string {
if (!existsSync(path)) return "";
const stats = statSync(path);
if (stats.isFile()) {
if (path.endsWith(".test.ts")) return "";
if (path.includes(`${join("src", "lib", "ui", "stories")}${"/"}`)) return "";
if (!/\.(svelte|svelte\.js|ts|js|mjs|css|json)$/.test(path)) return "";
if (!/\.(tsx|ts|js|mjs|css|json)$/.test(path)) return "";
return readFileSync(path, "utf8");
}
@ -45,3 +53,10 @@ function readPresentationSource(path: string): string {
.map((entry) => readPresentationSource(join(path, entry)))
.join("\n");
}
function findFiles(path: string, extension: string): string[] {
const stats = statSync(path);
if (stats.isFile()) return path.endsWith(extension) ? [path] : [];
return readdirSync(path).flatMap((entry) => findFiles(join(path, entry), extension));
}

View file

@ -1,14 +0,0 @@
<script lang="ts">
import type { UiSeverity } from "../types";
import StatusBadge from "./StatusBadge.svelte";
let {
label,
severity = "neutral",
}: {
label: string;
severity?: UiSeverity;
} = $props();
</script>
<StatusBadge {label} {severity} />

View file

@ -0,0 +1,11 @@
import type { UiSeverity } from "../types";
import { StatusBadge } from "./StatusBadge";
export interface BadgeProps {
label: string;
severity?: UiSeverity;
}
export function Badge({ label, severity = "neutral" }: BadgeProps) {
return <StatusBadge label={label} severity={severity} />;
}

View file

@ -1,85 +0,0 @@
<script lang="ts">
import type { HTMLButtonAttributes } from "svelte/elements";
import IconGlyph from "./IconGlyph.svelte";
type ButtonProps = Omit<HTMLButtonAttributes, "type"> & {
label: string;
variant?: "primary" | "secondary" | "danger" | "ghost";
size?: "default" | "compact";
icon?: string;
loading?: boolean;
type?: "button" | "submit" | "reset";
};
let {
label,
variant = "primary",
size = "default",
icon,
disabled = false,
loading = false,
type = "button",
class: className = "",
...buttonProps
}: ButtonProps = $props();
</script>
<button
{...buttonProps}
class={className ? `ui-button ${className}` : "ui-button"}
data-variant={variant}
data-size={size}
data-loading={loading}
{disabled}
{type}
>
{#if icon}
<IconGlyph name={icon} size="sm" />
{/if}
<span>{loading ? "Loading" : label}</span>
</button>
<style>
.ui-button {
display: inline-grid;
grid-auto-flow: column;
gap: var(--ui-space-2);
align-items: center;
justify-content: center;
min-height: 2.5rem;
border: var(--ui-border);
background: var(--ui-color-accent);
color: var(--ui-color-canvas);
cursor: pointer;
font-size: 0.76rem;
font-weight: 850;
letter-spacing: 0;
padding: 0 var(--ui-space-4);
text-transform: uppercase;
}
.ui-button[data-size="compact"] {
min-height: 2rem;
padding-inline: var(--ui-space-3);
}
.ui-button[data-variant="secondary"] {
background: var(--ui-color-surface-raised);
color: var(--ui-color-text);
}
.ui-button[data-variant="danger"] {
background: var(--ui-color-danger);
color: var(--ui-color-text);
}
.ui-button[data-variant="ghost"] {
background: transparent;
color: var(--ui-color-muted);
}
.ui-button:disabled {
cursor: not-allowed;
opacity: 0.48;
}
</style>

View file

@ -0,0 +1,39 @@
import type { ButtonHTMLAttributes } from "react";
import { IconGlyph } from "./IconGlyph";
export interface ButtonProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "type"> {
label: string;
variant?: "primary" | "secondary" | "danger" | "ghost";
size?: "default" | "compact";
icon?: string;
loading?: boolean;
type?: "button" | "submit" | "reset";
}
export function Button({
label,
variant = "primary",
size = "default",
icon,
disabled = false,
loading = false,
type = "button",
className = "",
...buttonProps
}: ButtonProps) {
return (
<button
{...buttonProps}
className={className ? `ui-button ${className} ` : "ui-button "}
data-variant={variant}
data-size={size}
data-loading={loading}
disabled={disabled}
type={type}
>
{icon ? <IconGlyph name={icon} size="sm" /> : null}
<span>{loading ? "Loading" : label}</span>
</button>
);
}

View file

@ -1,97 +0,0 @@
<script lang="ts">
let {
density = "regular",
size = "md",
tone = "neutral",
}: {
density?: "regular" | "tight";
size?: "sm" | "md" | "lg";
tone?: "neutral" | "accent" | "danger";
} = $props();
</script>
<div
class="corner-bracket-frame"
data-density={density}
data-size={size}
data-tone={tone}
aria-hidden="true"
>
<span data-corner="top-left"></span>
<span data-corner="top-right"></span>
<span data-corner="bottom-left"></span>
<span data-corner="bottom-right"></span>
</div>
<style>
.corner-bracket-frame {
--corner-color: var(--ui-color-line-strong);
--corner-offset: 0.5rem;
--corner-size: 1.45rem;
position: relative;
min-height: 5rem;
border: 1px solid transparent;
background:
linear-gradient(90deg, color-mix(in srgb, var(--corner-color), transparent 78%) 1px, transparent 1px),
linear-gradient(color-mix(in srgb, var(--corner-color), transparent 84%) 1px, transparent 1px),
rgba(2, 3, 2, 0.64);
background-size: 2rem 2rem, 2rem 2rem, auto;
}
span {
position: absolute;
width: var(--corner-size);
height: var(--corner-size);
border-color: var(--corner-color);
}
[data-corner="top-left"] {
top: var(--corner-offset);
left: var(--corner-offset);
border-top: 1px solid;
border-left: 1px solid;
}
[data-corner="top-right"] {
top: var(--corner-offset);
right: var(--corner-offset);
border-top: 1px solid;
border-right: 1px solid;
}
[data-corner="bottom-left"] {
bottom: var(--corner-offset);
left: var(--corner-offset);
border-bottom: 1px solid;
border-left: 1px solid;
}
[data-corner="bottom-right"] {
right: var(--corner-offset);
bottom: var(--corner-offset);
border-right: 1px solid;
border-bottom: 1px solid;
}
.corner-bracket-frame[data-density="tight"] {
--corner-offset: 0.28rem;
}
.corner-bracket-frame[data-size="sm"] {
--corner-size: 0.95rem;
min-height: 3rem;
}
.corner-bracket-frame[data-size="lg"] {
--corner-size: 2.25rem;
min-height: 8rem;
}
.corner-bracket-frame[data-tone="accent"] {
--corner-color: var(--ui-color-accent);
}
.corner-bracket-frame[data-tone="danger"] {
--corner-color: var(--ui-color-danger);
}
</style>

View file

@ -0,0 +1,26 @@
export interface CornerBracketFrameProps {
density?: "regular" | "tight";
size?: "sm" | "md" | "lg";
tone?: "neutral" | "accent" | "danger";
}
export function CornerBracketFrame({
density = "regular",
size = "md",
tone = "neutral",
}: CornerBracketFrameProps) {
return (
<div
className="corner-bracket-frame"
data-density={density}
data-size={size}
data-tone={tone}
aria-hidden="true"
>
<span data-corner="top-left" />
<span data-corner="top-right" />
<span data-corner="bottom-left" />
<span data-corner="bottom-right" />
</div>
);
}

View file

@ -1,154 +0,0 @@
<script lang="ts">
import type { UiDashboardPreview } from "../types";
import ModuleCard from "./ModuleCard.svelte";
import ServicePanel from "./ServicePanel.svelte";
import StatusStrip from "./StatusStrip.svelte";
import TelemetryGrid from "./TelemetryGrid.svelte";
const generatedTitleId = $props.id();
let {
dashboard,
titleId = `${generatedTitleId}-title`,
}: {
dashboard: UiDashboardPreview;
titleId?: string;
} = $props();
</script>
<main class="dashboard-frame" aria-labelledby={titleId}>
<header class="dashboard-frame__header">
<div>
{#if dashboard.eyebrow}
<p>{dashboard.eyebrow}</p>
{/if}
<h1 id={titleId}>{dashboard.title}</h1>
{#if dashboard.subtitle}
<span>{dashboard.subtitle}</span>
{/if}
</div>
{#if dashboard.modules.length}
<div class="dashboard-frame__modules">
{#each dashboard.modules as module (module.id)}
<ModuleCard {module} />
{/each}
</div>
{/if}
</header>
<TelemetryGrid cards={dashboard.telemetry} />
<section class="dashboard-frame__panels" aria-label="Service groups">
{#each dashboard.serviceGroups as group (group.id)}
<ServicePanel {group} />
{/each}
</section>
<StatusStrip id={dashboard.statusStripId} items={dashboard.statusItems} />
</main>
<style>
.dashboard-frame {
display: grid;
box-sizing: border-box;
height: 100vh;
min-height: 100vh;
gap: 0.36rem;
grid-template-rows: auto auto minmax(0, 1fr) auto;
overflow: hidden;
padding: clamp(0.42rem, 0.65vw, 0.62rem);
background:
linear-gradient(90deg, transparent 0 49%, rgba(255, 255, 255, 0.08) 50%, transparent 51%),
rgba(0, 0, 0, 0.18);
}
.dashboard-frame__header {
display: grid;
grid-template-columns: minmax(30rem, 1fr) minmax(30rem, 0.9fr);
gap: 0.5rem;
align-items: start;
border-bottom: var(--ui-border);
min-height: 5.35rem;
padding-bottom: 0.34rem;
}
.dashboard-frame__header p,
.dashboard-frame__header span,
h1 {
margin: 0;
text-transform: uppercase;
}
.dashboard-frame__header p,
.dashboard-frame__header span {
color: var(--ui-color-muted);
font-size: 0.78rem;
font-weight: 800;
}
h1 {
max-width: 100%;
overflow-wrap: anywhere;
font-family: var(--ui-font-display);
font-size: clamp(2.2rem, 3.3vw, 2.95rem);
font-weight: 850;
letter-spacing: 0;
line-height: 0.8;
white-space: nowrap;
}
.dashboard-frame__modules {
display: grid;
grid-template-columns: minmax(15rem, 0.66fr) minmax(17rem, 1fr);
justify-content: end;
gap: 0.5rem;
}
.dashboard-frame__panels {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
align-content: start;
gap: 0.36rem;
min-height: 0;
overflow: hidden;
}
.dashboard-frame__panels :global(.service-panel[data-layout="grid"]) {
grid-column: 1 / -1;
}
.dashboard-frame__panels
:global(.service-panel[data-layout="grid"] .panel__body) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (max-width: 780px) {
.dashboard-frame {
height: auto;
min-height: 100vh;
overflow: visible;
}
.dashboard-frame__header {
grid-template-columns: 1fr;
}
.dashboard-frame__modules {
grid-template-columns: 1fr;
}
h1 {
white-space: normal;
}
.dashboard-frame__panels {
grid-template-columns: 1fr;
overflow: visible;
}
.dashboard-frame__panels
:global(.service-panel[data-layout="grid"] .panel__body) {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -0,0 +1,49 @@
import { useId } from "react";
import type { UiDashboardPreview } from "../types";
import { ModuleCard } from "./ModuleCard";
import { ServicePanel } from "./ServicePanel";
import { StatusStrip } from "./StatusStrip";
import { TelemetryGrid } from "./TelemetryGrid";
import "./styles.css";
export interface DashboardFrameProps {
dashboard: UiDashboardPreview;
titleId?: string;
}
export function DashboardFrame({ dashboard, titleId }: DashboardFrameProps) {
const generatedTitleId = useId();
const resolvedTitleId = titleId || `${generatedTitleId}-title`;
return (
<main className="dashboard-frame" aria-labelledby={resolvedTitleId}>
<header className="dashboard-frame__header">
<div>
{dashboard.eyebrow ? <p>{dashboard.eyebrow}</p> : null}
<h1 id={resolvedTitleId}>{dashboard.title}</h1>
{dashboard.subtitle ? <span>{dashboard.subtitle}</span> : null}
</div>
{dashboard.modules.length ? (
<div className="dashboard-frame__modules">
{dashboard.modules.map((module) => (
<ModuleCard key={module.id} module={module} />
))}
</div>
) : null}
</header>
<TelemetryGrid cards={dashboard.telemetry} />
<section className="dashboard-frame__panels" aria-label="Service groups">
{dashboard.serviceGroups.map((group) => (
<ServicePanel key={group.id} group={group} />
))}
</section>
<StatusStrip
id={dashboard.statusStripId}
items={dashboard.statusItems}
/>
</main>
);
}

View file

@ -1,71 +0,0 @@
<script lang="ts">
import type { UiModuleBlock } from "../types";
import ModuleCard from "./ModuleCard.svelte";
let {
title,
subtitle,
eyebrow,
module,
}: {
title: string;
subtitle?: string;
eyebrow?: string;
module?: UiModuleBlock;
} = $props();
const titleId = $props.id();
</script>
<header class="dashboard-header" aria-labelledby={titleId}>
<div>
{#if eyebrow}
<p>{eyebrow}</p>
{/if}
<h1 id={titleId}>{title}</h1>
{#if subtitle}
<span>{subtitle}</span>
{/if}
</div>
{#if module}
<ModuleCard {module} />
{/if}
</header>
<style>
.dashboard-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--ui-space-4);
align-items: start;
border-bottom: var(--ui-border);
padding: var(--ui-space-3);
}
p,
span,
h1 {
margin: 0;
text-transform: uppercase;
}
p,
span {
color: var(--ui-color-muted);
font-size: 0.78rem;
font-weight: 800;
}
h1 {
overflow-wrap: anywhere;
font-family: var(--ui-font-display);
font-size: clamp(2.45rem, 5vw, 4.2rem);
line-height: 0.85;
}
@media (max-width: 780px) {
.dashboard-header {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -0,0 +1,30 @@
import { useId } from "react";
import type { UiModuleBlock } from "../types";
import { ModuleCard } from "./ModuleCard";
export interface DashboardHeaderProps {
title: string;
subtitle?: string;
eyebrow?: string;
module?: UiModuleBlock;
}
export function DashboardHeader({
title,
subtitle,
eyebrow,
module,
}: DashboardHeaderProps) {
const titleId = useId();
return (
<header className="dashboard-header" aria-labelledby={titleId}>
<div>
{eyebrow ? <p>{eyebrow}</p> : null}
<h1 id={titleId}>{title}</h1>
{subtitle ? <span>{subtitle}</span> : null}
</div>
{module ? <ModuleCard module={module} /> : null}
</header>
);
}

View file

@ -1,77 +0,0 @@
<script lang="ts">
let {
density = "regular",
direction = "forward",
size = "md",
tone = "accent",
}: {
density?: "open" | "regular" | "tight";
direction?: "forward" | "backward";
size?: "sm" | "md" | "lg";
tone?: "neutral" | "accent" | "warning" | "danger";
} = $props();
</script>
<div
class="diagonal-stripe-field"
data-density={density}
data-direction={direction}
data-size={size}
data-tone={tone}
aria-hidden="true"
></div>
<style>
.diagonal-stripe-field {
--stripe-angle: 135deg;
--stripe-color: var(--ui-color-line-strong);
--stripe-gap: 1.1rem;
--stripe-width: 0.18rem;
display: block;
min-height: 3rem;
border: var(--ui-border);
background:
repeating-linear-gradient(
var(--stripe-angle),
transparent 0 var(--stripe-gap),
color-mix(in srgb, var(--stripe-color), transparent 26%) var(--stripe-gap)
calc(var(--stripe-gap) + var(--stripe-width))
),
linear-gradient(90deg, rgba(255, 255, 255, 0.05), transparent 42%),
rgba(2, 3, 2, 0.82);
}
.diagonal-stripe-field[data-direction="backward"] {
--stripe-angle: 45deg;
}
.diagonal-stripe-field[data-density="open"] {
--stripe-gap: 1.65rem;
--stripe-width: 0.16rem;
}
.diagonal-stripe-field[data-density="tight"] {
--stripe-gap: 0.72rem;
--stripe-width: 0.14rem;
}
.diagonal-stripe-field[data-size="sm"] {
min-height: 1.75rem;
}
.diagonal-stripe-field[data-size="lg"] {
min-height: 5.5rem;
}
.diagonal-stripe-field[data-tone="accent"] {
--stripe-color: var(--ui-color-accent);
}
.diagonal-stripe-field[data-tone="warning"] {
--stripe-color: var(--ui-color-warning);
}
.diagonal-stripe-field[data-tone="danger"] {
--stripe-color: var(--ui-color-danger);
}
</style>

View file

@ -0,0 +1,24 @@
export interface DiagonalStripeFieldProps {
density?: "open" | "regular" | "tight";
direction?: "forward" | "backward";
size?: "sm" | "md" | "lg";
tone?: "neutral" | "accent" | "warning" | "danger";
}
export function DiagonalStripeField({
density = "regular",
direction = "forward",
size = "md",
tone = "accent",
}: DiagonalStripeFieldProps) {
return (
<div
className="diagonal-stripe-field"
data-density={density}
data-direction={direction}
data-size={size}
data-tone={tone}
aria-hidden="true"
/>
);
}

View file

@ -1,90 +0,0 @@
<script lang="ts">
import type { UiStatusItem } from "../types";
let { item }: { item: UiStatusItem } = $props();
let target = $derived(item.link?.external ? "_blank" : undefined);
let rel = $derived(item.link?.external ? "noreferrer" : undefined);
</script>
{#snippet cellContent()}
<span>{item.label}</span>
<strong>{item.value}</strong>
{/snippet}
{#if item.link}
<a
class="footer-cell"
data-severity={item.severity || "neutral"}
data-model-id={item.id}
href={item.link.href}
target={target}
rel={rel}
aria-label={item.link.label}
>
{@render cellContent()}
</a>
{:else}
<div class="footer-cell" data-severity={item.severity || "neutral"} data-model-id={item.id}>
{@render cellContent()}
</div>
{/if}
<style>
.footer-cell {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
min-height: 1.9rem;
align-items: center;
background: rgba(2, 3, 2, 0.92);
color: inherit;
text-decoration: none;
}
span,
strong {
min-width: 0;
padding: 0.34rem 0.52rem;
overflow-wrap: anywhere;
text-transform: uppercase;
}
span {
height: 100%;
border-right: var(--ui-border);
color: var(--ui-color-muted);
font-size: 0.52rem;
font-weight: 800;
}
strong {
color: var(--ui-color-text);
font-size: 0.58rem;
}
.footer-cell[data-severity="ok"] strong {
background: var(--ui-color-accent);
color: #060706;
}
.footer-cell[data-severity="warning"] strong {
color: var(--ui-color-warning);
}
.footer-cell[data-severity="danger"] strong {
color: var(--ui-color-danger);
}
.footer-cell[data-severity="stale"] strong,
.footer-cell[data-severity="unavailable"] strong {
color: var(--ui-color-stale);
}
.footer-cell[data-severity="loading"] strong {
color: var(--ui-color-accent);
}
.footer-cell:where(a):hover strong {
color: var(--ui-color-accent);
}
</style>

View file

@ -0,0 +1,42 @@
import type { UiStatusItem } from "../types";
export interface FooterCellProps {
item: UiStatusItem;
}
export function FooterCell({ item }: FooterCellProps) {
const target = item.link?.external ? "_blank" : undefined;
const rel = item.link?.external ? "noreferrer" : undefined;
const content = (
<>
<span>{item.label}</span>
<strong>{item.value}</strong>
</>
);
if (item.link) {
return (
<a
className="footer-cell"
data-severity={item.severity || "neutral"}
data-model-id={item.id}
href={item.link.href}
target={target}
rel={rel}
aria-label={item.link.label}
>
{content}
</a>
);
}
return (
<div
className="footer-cell"
data-severity={item.severity || "neutral"}
data-model-id={item.id}
>
{content}
</div>
);
}

View file

@ -1,8 +0,0 @@
<script lang="ts">
import type { UiStatusItem } from "../types";
import FooterCell from "./FooterCell.svelte";
let { item }: { item: UiStatusItem } = $props();
</script>
<FooterCell {item} />

View file

@ -0,0 +1,6 @@
import type { UiStatusItem } from "../types";
import { FooterCell } from "./FooterCell";
export function FooterStatusCell({ item }: { item: UiStatusItem }) {
return <FooterCell item={item} />;
}

View file

@ -1,30 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
let {
children,
density = "dense",
}: {
children?: Snippet;
density?: "compact" | "dense";
} = $props();
</script>
<section class="grid-frame" data-density={density}>
{@render children?.()}
</section>
<style>
.grid-frame {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
gap: var(--ui-space-3);
padding: var(--ui-space-3);
}
.grid-frame[data-density="compact"] {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
gap: var(--ui-space-2);
padding: var(--ui-space-2);
}
</style>

View file

@ -0,0 +1,13 @@
import type { PropsWithChildren } from "react";
export interface GridFrameProps extends PropsWithChildren {
density?: "compact" | "dense";
}
export function GridFrame({ children, density = "dense" }: GridFrameProps) {
return (
<section className="grid-frame" data-density={density}>
{children}
</section>
);
}

View file

@ -1,57 +0,0 @@
<script lang="ts">
import type { HTMLButtonAttributes } from "svelte/elements";
import IconGlyph from "./IconGlyph.svelte";
type IconButtonProps = Omit<HTMLButtonAttributes, "type"> & {
icon: string;
label: string;
active?: boolean;
type?: "button" | "submit" | "reset";
};
let {
icon,
label,
active = false,
disabled = false,
type = "button",
class: className = "",
...buttonProps
}: IconButtonProps = $props();
</script>
<button
{...buttonProps}
class={className ? `icon-button ${className}` : "icon-button"}
aria-label={label}
data-active={active}
{disabled}
{type}
>
<IconGlyph name={icon} size="sm" />
</button>
<style>
.icon-button {
display: inline-grid;
width: 2.5rem;
height: 2.5rem;
place-items: center;
border: var(--ui-border);
background: rgba(2, 3, 2, 0.92);
color: var(--ui-color-muted);
cursor: pointer;
padding: 0;
}
.icon-button[data-active="true"],
.icon-button:hover {
border-color: var(--ui-color-accent);
color: var(--ui-color-accent);
}
.icon-button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
</style>

View file

@ -0,0 +1,33 @@
import type { ButtonHTMLAttributes } from "react";
import { IconGlyph } from "./IconGlyph";
export interface IconButtonProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "type"> {
icon: string;
label: string;
active?: boolean;
type?: "button" | "submit" | "reset";
}
export function IconButton({
icon,
label,
active = false,
disabled = false,
type = "button",
className = "",
...buttonProps
}: IconButtonProps) {
return (
<button
{...buttonProps}
className={className ? `icon-button ${className} ` : "icon-button "}
aria-label={label}
data-active={active}
disabled={disabled}
type={type}
>
<IconGlyph name={icon} size="sm" />
</button>
);
}

View file

@ -1,53 +0,0 @@
<script lang="ts">
import Icon from "@iconify/svelte";
let {
name,
label,
size = "md",
}: {
name?: string;
label?: string;
size?: "sm" | "md" | "lg";
} = $props();
</script>
<span
class="icon-glyph"
data-size={size}
data-icon-name={name}
aria-label={label}
aria-hidden={label ? undefined : "true"}
>
{#if name}
<Icon icon={name} />
{/if}
</span>
<style>
.icon-glyph {
display: inline-grid;
width: 1.55rem;
height: 1.55rem;
place-items: center;
border: var(--ui-border);
background: #050605;
color: currentColor;
line-height: 1;
}
.icon-glyph :global(svg) {
width: 0.95rem;
height: 0.95rem;
}
.icon-glyph[data-size="sm"] {
width: 1.1rem;
height: 1.1rem;
}
.icon-glyph[data-size="lg"] {
width: 2.35rem;
height: 2.35rem;
}
</style>

View file

@ -0,0 +1,21 @@
import { Icon } from "@iconify/react";
export interface IconGlyphProps {
name?: string;
label?: string;
size?: "sm" | "md" | "lg";
}
export function IconGlyph({ name, label, size = "md" }: IconGlyphProps) {
return (
<span
className="icon-glyph"
data-size={size}
data-icon-name={name}
aria-label={label}
aria-hidden={label ? undefined : "true"}
>
{name ? <Icon icon={name} /> : null}
</span>
);
}

View file

@ -1,147 +0,0 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import "uplot/dist/uPlot.min.css";
import type { UiSeverity } from "../types";
let {
values = [],
severity = "neutral",
label = "Telemetry trend",
}: {
values?: number[];
severity?: UiSeverity;
label?: string;
} = $props();
let chartElement: HTMLDivElement;
let chart: ChartInstance | null = null;
let resizeObserver: ResizeObserver | null = null;
onMount(async () => {
const module = await import("uplot");
chart = new module.default(chartOptions(chartElement, severity), chartData(values), chartElement);
resizeObserver = new ResizeObserver(() => {
chart?.setSize(chartSize(chartElement));
});
resizeObserver.observe(chartElement);
});
$effect(() => {
chart?.setData(chartData(values));
chart?.setSize(chartSize(chartElement));
});
onDestroy(() => {
resizeObserver?.disconnect();
chart?.destroy();
});
</script>
<div
class="line-chart"
data-chart-library="uplot"
data-severity={severity}
role="img"
aria-label={label}
>
<div class="line-chart__canvas" bind:this={chartElement} aria-hidden="true"></div>
</div>
<style>
.line-chart {
position: relative;
min-width: 0;
height: 1rem;
color: var(--ui-color-accent);
}
.line-chart[data-severity="warning"] {
color: var(--ui-color-warning);
}
.line-chart[data-severity="danger"] {
color: var(--ui-color-danger);
}
.line-chart[data-severity="stale"],
.line-chart[data-severity="unavailable"] {
color: var(--ui-color-stale);
}
.line-chart__canvas {
width: 100%;
height: 100%;
}
.line-chart__canvas :global(.uplot) {
width: 100% !important;
height: 100% !important;
background: transparent;
font-family: var(--ui-font-mono);
}
.line-chart__canvas :global(.u-over),
.line-chart__canvas :global(.u-under) {
overflow: visible;
}
</style>
<script lang="ts" module>
type ChartInstance = {
destroy(): void;
setData(data: import("uplot").AlignedData): void;
setSize(size: { width: number; height: number }): void;
};
function chartData(values: number[]): import("uplot").AlignedData {
const normalized = values.length ? values : [0, 0];
return [
normalized.map((_, index) => index),
normalized.map((value) => Math.max(0, Number(value) || 0)),
];
}
function chartOptions(
element: HTMLElement,
severity: import("../types").UiSeverity,
): import("uplot").Options {
return {
...chartSize(element),
cursor: { show: false },
legend: { show: false },
padding: [2, 0, 2, 0],
scales: {
x: { time: false },
y: { auto: true },
},
axes: [
{ show: false },
{ show: false },
],
series: [
{},
{
stroke: chartStroke(element, severity),
width: 2,
points: { show: false },
},
],
};
}
function chartSize(element?: HTMLElement): { width: number; height: number } {
return {
width: Math.max(80, Math.round(element?.clientWidth || 120)),
height: Math.max(20, Math.round(element?.clientHeight || 24)),
};
}
function chartStroke(element: HTMLElement, severity: import("../types").UiSeverity): string {
const styles = window.getComputedStyle(element.closest(".line-chart") || element);
const currentColor = styles.color;
if (currentColor) return currentColor;
if (severity === "danger") return "#ff1744";
if (severity === "warning") return "#ffb020";
return "#d7ff00";
}
</script>

View file

@ -0,0 +1,114 @@
import "uplot/dist/uPlot.min.css";
import { useEffect, useRef } from "react";
import type { UiSeverity } from "../types";
export interface LineChartProps {
values?: number[];
severity?: UiSeverity;
label?: string;
}
export function LineChart({
values = [],
severity = "neutral",
label = "Telemetry trend",
}: LineChartProps) {
const chartElementRef = useRef<HTMLDivElement>(null);
const valuesKey = values.join(",");
useEffect(() => {
const chartElement = chartElementRef.current;
if (!chartElement) return;
let chart: ChartInstance | null = null;
let resizeObserver: ResizeObserver | null = null;
let disposed = false;
void import("uplot").then((module) => {
if (disposed || !chartElementRef.current) return;
const element = chartElementRef.current;
chart = new module.default(chartOptions(element, severity), chartData(values), element);
if (typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(() => {
chart?.setSize(chartSize(element));
});
resizeObserver.observe(element);
}
});
return () => {
disposed = true;
resizeObserver?.disconnect();
chart?.destroy();
};
}, [severity, valuesKey, values]);
return (
<div
className="line-chart"
data-chart-library="uplot"
data-severity={severity}
data-values={values.join(",")}
role="img"
aria-label={label}
>
<div ref={chartElementRef} className="line-chart__canvas" aria-hidden="true" />
</div>
);
}
type ChartInstance = {
destroy(): void;
setSize(size: { width: number; height: number }): void;
};
function chartData(values: number[]): import("uplot").AlignedData {
const normalized = values.length ? values : [0, 0];
return [
normalized.map((_, index) => index),
normalized.map((value) => Math.max(0, Number(value) || 0)),
];
}
function chartOptions(
element: HTMLElement,
severity: UiSeverity,
): import("uplot").Options {
return {
...chartSize(element),
cursor: { show: false },
legend: { show: false },
padding: [2, 0, 2, 0],
scales: {
x: { time: false },
y: { auto: true },
},
axes: [{ show: false }, { show: false }],
series: [
{},
{
stroke: chartStroke(element, severity),
width: 2,
points: { show: false },
},
],
};
}
function chartSize(element?: HTMLElement): { width: number; height: number } {
return {
width: Math.max(80, Math.round(element?.clientWidth || 120)),
height: Math.max(20, Math.round(element?.clientHeight || 24)),
};
}
function chartStroke(element: HTMLElement, severity: UiSeverity): string {
const styles = window.getComputedStyle(element.closest(".line-chart") || element);
const currentColor = styles.color;
if (currentColor) return currentColor;
if (severity === "danger") return "#ff1744";
if (severity === "warning") return "#ffb020";
return "#d7ff00";
}

View file

@ -1,104 +0,0 @@
<script lang="ts">
import type { UiModuleBlock } from "../types";
import IconGlyph from "./IconGlyph.svelte";
const generatedId = $props.id();
let { module }: { module: UiModuleBlock } = $props();
let titleId = $derived(`${generatedId}-title`);
let ariaLabel = $derived(module.title ? undefined : module.label || module.id);
</script>
<aside
class="module-card"
data-severity={module.severity || "neutral"}
data-model-id={module.id}
aria-labelledby={module.title ? titleId : undefined}
aria-label={ariaLabel}
>
<div>
{#if module.title}
<h2 id={titleId}>{module.title}</h2>
{/if}
{#if module.value}
<strong>{module.value}</strong>
{/if}
{#if module.detail || module.label}
<p>{module.detail || module.label}</p>
{/if}
</div>
{#if module.icon}
<IconGlyph name={module.icon} size="lg" />
{/if}
</aside>
<style>
.module-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.5rem;
align-items: start;
min-width: 0;
min-height: 4.15rem;
border: var(--ui-border);
background: rgba(6, 8, 7, 0.82);
padding: 0.5rem;
}
h2,
p {
margin: 0;
}
h2 {
color: var(--ui-color-muted);
font-size: 0.56rem;
line-height: 1;
text-transform: uppercase;
}
strong {
display: block;
margin-top: 0.15rem;
font-family: var(--ui-font-display);
font-size: clamp(1.25rem, 1.75vw, 1.72rem);
line-height: 0.82;
overflow-wrap: anywhere;
}
p {
margin-top: 0.2rem;
color: var(--ui-color-muted);
font-size: 0.49rem;
line-height: 1.05;
overflow: hidden;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.module-card[data-severity="ok"] :global(.icon-glyph) {
background: var(--ui-color-accent);
color: #050605;
}
.module-card[data-severity="warning"] {
border-color: color-mix(in srgb, var(--ui-color-warning), transparent 42%);
color: var(--ui-color-warning);
}
.module-card[data-severity="danger"] {
border-color: color-mix(in srgb, var(--ui-color-danger), transparent 32%);
color: var(--ui-color-danger);
}
.module-card[data-severity="stale"],
.module-card[data-severity="unavailable"] {
color: var(--ui-color-stale);
}
.module-card[data-severity="loading"] {
color: var(--ui-color-accent);
}
</style>

View file

@ -0,0 +1,32 @@
import { useId } from "react";
import type { UiModuleBlock } from "../types";
import { IconGlyph } from "./IconGlyph";
export interface ModuleCardProps {
module: UiModuleBlock;
}
export function ModuleCard({ module }: ModuleCardProps) {
const generatedId = useId();
const titleId = `${generatedId}-title`;
const ariaLabel = module.title ? undefined : module.label || module.id;
return (
<aside
className="module-card"
data-severity={module.severity || "neutral"}
data-model-id={module.id}
aria-labelledby={module.title ? titleId : undefined}
aria-label={ariaLabel}
>
<div>
{module.title ? <h2 id={titleId}>{module.title}</h2> : null}
{module.value ? <strong>{module.value}</strong> : null}
{module.detail || module.label ? (
<p>{module.detail || module.label}</p>
) : null}
</div>
{module.icon ? <IconGlyph name={module.icon} size="lg" /> : null}
</aside>
);
}

View file

@ -1,81 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
let {
title,
density = "dense",
children,
}: {
title?: string;
density?: "compact" | "dense";
children?: Snippet;
} = $props();
</script>
<section class="panel" data-density={density}>
{#if title}
<header class="panel__header">
<h2>{title}</h2>
</header>
{/if}
<div class="panel__body">
{@render children?.()}
</div>
</section>
<style>
.panel {
position: relative;
min-width: 0;
border: var(--ui-border);
background: rgba(3, 4, 3, 0.86);
box-shadow: var(--ui-shadow-hard);
}
.panel::before,
.panel::after {
position: absolute;
width: 0.55rem;
height: 0.55rem;
border-color: var(--ui-color-line-strong);
content: "";
}
.panel::before {
top: 0.2rem;
right: 0.2rem;
border-top: 1px solid;
border-right: 1px solid;
}
.panel::after {
right: 0.2rem;
bottom: 0.2rem;
border-right: 1px solid;
border-bottom: 1px solid;
}
.panel__header {
padding: 0.42rem 0.55rem 0;
}
h2 {
margin: 0;
font-family: var(--ui-font-display);
font-size: clamp(1.25rem, 1.8vw, 1.75rem);
font-weight: 800;
letter-spacing: 0;
line-height: 0.92;
text-transform: uppercase;
}
.panel__body {
display: grid;
gap: 0.25rem;
padding: 0.42rem 0.55rem 0.55rem;
}
.panel[data-density="compact"] .panel__body {
padding: var(--ui-space-2);
}
</style>

View file

@ -0,0 +1,19 @@
import type { PropsWithChildren } from "react";
export interface PanelProps extends PropsWithChildren {
title?: string;
density?: "compact" | "dense";
}
export function Panel({ title, density = "dense", children }: PanelProps) {
return (
<section className="panel" data-density={density}>
{title ? (
<header className="panel__header">
<h2>{title}</h2>
</header>
) : null}
<div className="panel__body">{children}</div>
</section>
);
}

View file

@ -1,58 +0,0 @@
<script lang="ts">
import { clampPercent } from "../format";
import type { UiSeverity } from "../types";
let {
value,
severity = "neutral",
label = "Progress",
}: {
value?: number;
severity?: UiSeverity;
label?: string;
} = $props();
let progress = $derived(value === undefined ? null : clampPercent(value));
</script>
<div
class="progress-meter"
data-severity={severity}
role="meter"
aria-label={label}
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={progress ?? undefined}
data-empty={progress === null}
>
{#if progress !== null}
<span style={`--meter-progress: ${progress}%`}></span>
{/if}
</div>
<style>
.progress-meter {
height: 0.4rem;
border: var(--ui-border);
background: #030403;
}
.progress-meter span {
display: block;
width: var(--meter-progress);
height: 100%;
background: var(--ui-color-accent);
}
.progress-meter[data-severity="warning"] span {
background: var(--ui-color-warning);
}
.progress-meter[data-severity="danger"] span {
background: var(--ui-color-danger);
}
.progress-meter[data-empty="true"] {
opacity: 0.46;
}
</style>

View file

@ -0,0 +1,34 @@
import type { CSSProperties } from "react";
import { clampPercent } from "../format";
import type { UiSeverity } from "../types";
export interface ProgressMeterProps {
value?: number;
severity?: UiSeverity;
label?: string;
}
export function ProgressMeter({
value,
severity = "neutral",
label = "Progress",
}: ProgressMeterProps) {
const progress = value === undefined ? null : clampPercent(value);
return (
<div
className="progress-meter"
data-severity={severity}
role="meter"
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress ?? undefined}
data-empty={progress === null}
>
{progress !== null ? (
<span style={{ "--meter-progress": `${progress}%` } as CSSProperties} />
) : null}
</div>
);
}

View file

@ -1,66 +0,0 @@
<script lang="ts">
let {
intensity = "medium",
size = "md",
tone = "neutral",
}: {
intensity?: "soft" | "medium" | "hard";
size?: "sm" | "md" | "lg";
tone?: "neutral" | "accent" | "warning";
} = $props();
</script>
<div
class="scanline-field"
data-intensity={intensity}
data-size={size}
data-tone={tone}
aria-hidden="true"
></div>
<style>
.scanline-field {
--scan-color: rgba(243, 244, 237, 0.12);
--scan-gap: 0.48rem;
--scan-weight: 1px;
display: block;
min-height: 4rem;
border: var(--ui-border);
background:
radial-gradient(circle at 18% 24%, color-mix(in srgb, var(--scan-color), transparent 35%) 0 1px, transparent 2px),
radial-gradient(circle at 76% 62%, color-mix(in srgb, var(--scan-color), transparent 44%) 0 1px, transparent 2px),
repeating-linear-gradient(
180deg,
transparent 0 calc(var(--scan-gap) - var(--scan-weight)),
var(--scan-color) calc(var(--scan-gap) - var(--scan-weight)) var(--scan-gap)
),
rgba(6, 7, 6, 0.78);
background-size: 3.1rem 3.1rem, 4.7rem 4.7rem, auto, auto;
}
.scanline-field[data-intensity="soft"] {
--scan-color: rgba(243, 244, 237, 0.07);
--scan-gap: 0.62rem;
}
.scanline-field[data-intensity="hard"] {
--scan-color: rgba(215, 255, 0, 0.2);
--scan-gap: 0.36rem;
}
.scanline-field[data-size="sm"] {
min-height: 2rem;
}
.scanline-field[data-size="lg"] {
min-height: 6.25rem;
}
.scanline-field[data-tone="accent"] {
--scan-color: rgba(215, 255, 0, 0.16);
}
.scanline-field[data-tone="warning"] {
--scan-color: rgba(255, 176, 32, 0.16);
}
</style>

View file

@ -0,0 +1,21 @@
export interface ScanlineFieldProps {
intensity?: "soft" | "medium" | "hard";
size?: "sm" | "md" | "lg";
tone?: "neutral" | "accent" | "warning";
}
export function ScanlineField({
intensity = "medium",
size = "md",
tone = "neutral",
}: ScanlineFieldProps) {
return (
<div
className="scanline-field"
data-intensity={intensity}
data-size={size}
data-tone={tone}
aria-hidden="true"
/>
);
}

View file

@ -1,39 +0,0 @@
<script lang="ts">
let {
orientation = "horizontal",
dense = false,
}: {
orientation?: "horizontal" | "vertical";
dense?: boolean;
} = $props();
</script>
<div
class="separator"
data-orientation={orientation}
data-dense={dense}
role="separator"
aria-orientation={orientation}
></div>
<style>
.separator {
background: var(--ui-color-line);
}
.separator[data-orientation="horizontal"] {
width: 100%;
height: 1px;
margin-block: var(--ui-space-3);
}
.separator[data-orientation="vertical"] {
width: 1px;
min-height: 3rem;
align-self: stretch;
}
.separator[data-dense="true"] {
margin-block: var(--ui-space-1);
}
</style>

View file

@ -0,0 +1,19 @@
export interface SeparatorProps {
orientation?: "horizontal" | "vertical";
dense?: boolean;
}
export function Separator({
orientation = "horizontal",
dense = false,
}: SeparatorProps) {
return (
<div
className="separator"
data-orientation={orientation}
data-dense={dense}
role="separator"
aria-orientation={orientation}
/>
);
}

View file

@ -1,8 +0,0 @@
<script lang="ts">
import type { UiServiceGroup } from "../types";
import ServicePanel from "./ServicePanel.svelte";
let { group }: { group: UiServiceGroup } = $props();
</script>
<ServicePanel {group} />

View file

@ -0,0 +1,6 @@
import type { UiServiceGroup } from "../types";
import { ServicePanel } from "./ServicePanel";
export function ServiceGroupPanel({ group }: { group: UiServiceGroup }) {
return <ServicePanel group={group} />;
}

View file

@ -1,30 +0,0 @@
<script lang="ts">
import type { UiServiceGroup } from "../types";
import Panel from "./Panel.svelte";
import ServiceRow from "./ServiceRow.svelte";
import StatusStrip from "./StatusStrip.svelte";
let { group }: { group: UiServiceGroup } = $props();
</script>
<div class="service-panel" data-layout={group.layout || "list"} data-model-id={group.id}>
<Panel title={group.title}>
{#if group.summary?.length}
<StatusStrip id={`${group.id}:summary`} items={group.summary} compact />
{/if}
{#each group.services as service (service.id)}
<ServiceRow {service} />
{/each}
</Panel>
</div>
<style>
.service-panel :global(.panel__body) {
gap: 0;
padding-block: 0.24rem 0.42rem;
}
.service-panel :global(.status-strip) {
margin-bottom: 0.16rem;
}
</style>

View file

@ -0,0 +1,27 @@
import type { UiServiceGroup } from "../types";
import { Panel } from "./Panel";
import { ServiceRow } from "./ServiceRow";
import { StatusStrip } from "./StatusStrip";
export interface ServicePanelProps {
group: UiServiceGroup;
}
export function ServicePanel({ group }: ServicePanelProps) {
return (
<div
className="service-panel"
data-layout={group.layout || "list"}
data-model-id={group.id}
>
<Panel title={group.title}>
{group.summary?.length ? (
<StatusStrip id={`${group.id}:summary`} items={group.summary} compact />
) : null}
{group.services.map((service) => (
<ServiceRow key={service.id} service={service} />
))}
</Panel>
</div>
);
}

View file

@ -1,115 +0,0 @@
<script lang="ts">
import type { UiServiceRow } from "../types";
import IconGlyph from "./IconGlyph.svelte";
import StatusBadge from "./StatusBadge.svelte";
let { service }: { service: UiServiceRow } = $props();
let target = $derived(service.link?.external ? "_blank" : undefined);
let rel = $derived(service.link?.external ? "noreferrer" : undefined);
</script>
{#snippet rowContent()}
<IconGlyph name={service.icon} />
<div class="service-row__main">
<h3>{service.label}</h3>
<p>{service.description}</p>
</div>
{#if service.detail}
<StatusBadge label={service.detail} severity={service.severity} />
{/if}
{/snippet}
{#if service.link}
<a
class="service-row"
data-severity={service.severity}
data-model-id={service.id}
href={service.link.href}
target={target}
rel={rel}
aria-label={service.link.label}
>
{@render rowContent()}
</a>
{:else}
<article class="service-row" data-severity={service.severity} data-model-id={service.id}>
{@render rowContent()}
</article>
{/if}
<style>
.service-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 0.24rem;
align-items: center;
min-height: 2.02rem;
border: 0;
border-bottom: var(--ui-border);
background: transparent;
color: inherit;
padding: 0.14rem 0;
text-decoration: none;
}
.service-row[data-severity="warning"] {
border-bottom-color: color-mix(in srgb, var(--ui-color-warning), transparent 54%);
}
.service-row[data-severity="danger"],
.service-row[data-severity="unavailable"] {
border-bottom-color: color-mix(in srgb, var(--ui-color-danger), transparent 50%);
}
.service-row[data-severity="loading"] {
border-bottom-color: color-mix(in srgb, var(--ui-color-accent), transparent 56%);
}
.service-row:where(a):hover {
border-bottom-color: var(--ui-color-accent);
background: rgba(244, 244, 244, 0.035);
}
.service-row__main {
min-width: 0;
}
h3,
p {
margin: 0;
}
h3 {
overflow: hidden;
font-size: 0.72rem;
font-weight: 850;
letter-spacing: 0;
line-height: 1.05;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
p {
margin-top: 0.12rem;
overflow: hidden;
color: var(--ui-color-muted);
font-size: 0.56rem;
line-height: 1.08;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
@media (max-width: 580px) {
.service-row {
grid-template-columns: auto minmax(0, 1fr);
}
.service-row :global(.status-badge) {
grid-column: 2;
justify-self: start;
}
}
</style>

View file

@ -0,0 +1,50 @@
import type { UiServiceRow } from "../types";
import { IconGlyph } from "./IconGlyph";
import { StatusBadge } from "./StatusBadge";
export interface ServiceRowProps {
service: UiServiceRow;
}
export function ServiceRow({ service }: ServiceRowProps) {
const target = service.link?.external ? "_blank" : undefined;
const rel = service.link?.external ? "noreferrer" : undefined;
const content = (
<>
<IconGlyph name={service.icon} />
<div className="service-row__main">
<h3>{service.label}</h3>
<p>{service.description}</p>
</div>
{service.detail ? (
<StatusBadge label={service.detail} severity={service.severity} />
) : null}
</>
);
if (service.link) {
return (
<a
className="service-row"
data-severity={service.severity}
data-model-id={service.id}
href={service.link.href}
target={target}
rel={rel}
aria-label={service.link.label}
>
{content}
</a>
);
}
return (
<article
className="service-row"
data-severity={service.severity}
data-model-id={service.id}
>
{content}
</article>
);
}

View file

@ -1,112 +0,0 @@
<script lang="ts">
let {
density = "regular",
orientation = "horizontal",
tone = "accent",
}: {
density?: "regular" | "tight";
orientation?: "horizontal" | "vertical";
tone?: "neutral" | "accent" | "warning";
} = $props();
</script>
<div
class="signal-trace"
data-density={density}
data-orientation={orientation}
data-tone={tone}
aria-hidden="true"
>
<span class="signal-trace__rail"></span>
<span class="signal-trace__node" data-node="start"></span>
<span class="signal-trace__node" data-node="middle"></span>
<span class="signal-trace__node" data-node="end"></span>
</div>
<style>
.signal-trace {
--trace-color: var(--ui-color-accent);
--trace-track: min(100%, 20rem);
position: relative;
display: block;
width: 100%;
min-height: 1.5rem;
}
.signal-trace__rail {
position: absolute;
inset: 50% 0 auto;
height: 1px;
background:
linear-gradient(90deg, transparent, var(--trace-color) 18%, var(--trace-color) 82%, transparent),
linear-gradient(90deg, transparent 0 35%, rgba(255, 255, 255, 0.42) 35% 38%, transparent 38%);
}
.signal-trace__node {
position: absolute;
top: 50%;
width: 0.55rem;
height: 0.55rem;
border: 1px solid var(--trace-color);
background: var(--ui-color-canvas);
transform: translate(-50%, -50%) rotate(45deg);
}
.signal-trace__node[data-node="start"] {
left: 18%;
}
.signal-trace__node[data-node="middle"] {
left: 50%;
}
.signal-trace__node[data-node="end"] {
left: 82%;
}
.signal-trace[data-density="tight"] {
min-height: 0.95rem;
}
.signal-trace[data-density="tight"] .signal-trace__node {
width: 0.38rem;
height: 0.38rem;
}
.signal-trace[data-orientation="vertical"] {
width: 1.5rem;
min-height: 12rem;
}
.signal-trace[data-orientation="vertical"] .signal-trace__rail {
inset: 0 auto 0 50%;
width: 1px;
height: auto;
background:
linear-gradient(180deg, transparent, var(--trace-color) 18%, var(--trace-color) 82%, transparent),
linear-gradient(180deg, transparent 0 35%, rgba(255, 255, 255, 0.42) 35% 38%, transparent 38%);
}
.signal-trace[data-orientation="vertical"] .signal-trace__node[data-node="start"] {
top: 18%;
left: 50%;
}
.signal-trace[data-orientation="vertical"] .signal-trace__node[data-node="middle"] {
top: 50%;
left: 50%;
}
.signal-trace[data-orientation="vertical"] .signal-trace__node[data-node="end"] {
top: 82%;
left: 50%;
}
.signal-trace[data-tone="neutral"] {
--trace-color: var(--ui-color-line-strong);
}
.signal-trace[data-tone="warning"] {
--trace-color: var(--ui-color-warning);
}
</style>

View file

@ -0,0 +1,26 @@
export interface SignalTraceProps {
density?: "regular" | "tight";
orientation?: "horizontal" | "vertical";
tone?: "neutral" | "accent" | "warning";
}
export function SignalTrace({
density = "regular",
orientation = "horizontal",
tone = "accent",
}: SignalTraceProps) {
return (
<div
className="signal-trace"
data-density={density}
data-orientation={orientation}
data-tone={tone}
aria-hidden="true"
>
<span className="signal-trace__rail" />
<span className="signal-trace__node" data-node="start" />
<span className="signal-trace__node" data-node="middle" />
<span className="signal-trace__node" data-node="end" />
</div>
);
}

View file

@ -1,58 +0,0 @@
<script lang="ts">
import type { UiSeverity } from "../types";
let {
values = [],
severity = "neutral",
}: {
values?: number[];
severity?: UiSeverity;
} = $props();
let points = $derived(toPoints(values));
</script>
<svg class="sparkline" data-severity={severity} viewBox="0 0 100 24" aria-hidden="true">
{#if points}
<polyline {points} />
{/if}
</svg>
<style>
.sparkline {
width: 100%;
height: 1.5rem;
color: var(--ui-color-accent);
}
.sparkline[data-severity="warning"] {
color: var(--ui-color-warning);
}
.sparkline[data-severity="danger"] {
color: var(--ui-color-danger);
}
polyline {
fill: none;
stroke: currentColor;
stroke-linecap: square;
stroke-linejoin: miter;
stroke-width: 2;
}
</style>
<script lang="ts" module>
function toPoints(values: number[]): string {
if (values.length === 0) return "";
const max = Math.max(...values, 1);
const step = values.length > 1 ? 100 / (values.length - 1) : 100;
return values
.map((value, index) => {
const x = index * step;
const y = 24 - (Math.max(0, value) / max) * 22;
return `${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(" ");
}
</script>

View file

@ -0,0 +1,37 @@
import type { UiSeverity } from "../types";
export interface SparklineProps {
values?: number[];
severity?: UiSeverity;
}
export function Sparkline({
values = [],
severity = "neutral",
}: SparklineProps) {
const points = toPoints(values);
return (
<svg
className="sparkline"
data-severity={severity}
viewBox="0 0 100 24"
aria-hidden="true"
>
{points ? <polyline points={points} /> : null}
</svg>
);
}
function toPoints(values: number[]): string {
if (values.length === 0) return "";
const max = Math.max(...values, 1);
const step = values.length > 1 ? 100 / (values.length - 1) : 100;
return values
.map((value, index) => {
const x = index * step;
const y = 24 - (Math.max(0, value) / max) * 22;
return `${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(" ");
}

View file

@ -1,53 +0,0 @@
<script lang="ts">
import type { UiSeverity } from "../types";
let {
label,
severity = "neutral",
}: {
label: string;
severity?: UiSeverity;
} = $props();
</script>
<span class="status-badge" data-severity={severity}>{label}</span>
<style>
.status-badge {
display: inline-grid;
min-height: 1.12rem;
align-items: center;
border: var(--ui-border);
padding: 0 0.3rem;
color: var(--ui-color-muted);
font-size: 0.55rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
white-space: nowrap;
}
.status-badge[data-severity="ok"] {
border-color: color-mix(in srgb, var(--ui-color-ok), transparent 42%);
color: var(--ui-color-ok);
}
.status-badge[data-severity="warning"] {
border-color: color-mix(in srgb, var(--ui-color-warning), transparent 35%);
color: var(--ui-color-warning);
}
.status-badge[data-severity="danger"] {
border-color: color-mix(in srgb, var(--ui-color-danger), transparent 30%);
color: var(--ui-color-danger);
}
.status-badge[data-severity="stale"],
.status-badge[data-severity="unavailable"] {
color: var(--ui-color-stale);
}
.status-badge[data-severity="loading"] {
color: var(--ui-color-accent);
}
</style>

View file

@ -0,0 +1,14 @@
import type { UiSeverity } from "../types";
export interface StatusBadgeProps {
label: string;
severity?: UiSeverity;
}
export function StatusBadge({ label, severity = "neutral" }: StatusBadgeProps) {
return (
<span className="status-badge" data-severity={severity}>
{label}
</span>
);
}

View file

@ -1,34 +0,0 @@
<script lang="ts">
import type { UiStatusItem } from "../types";
import FooterCell from "./FooterCell.svelte";
let {
id,
items,
compact = false,
}: {
id?: string;
items: UiStatusItem[];
compact?: boolean;
} = $props();
</script>
<section class="status-strip" data-compact={compact} data-model-id={id}>
{#each items as item (item.id)}
<FooterCell {item} />
{/each}
</section>
<style>
.status-strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
gap: 1px;
border: var(--ui-border);
background: var(--ui-color-line);
}
.status-strip[data-compact="true"] {
grid-template-columns: repeat(auto-fit, minmax(6rem, 1fr));
}
</style>

View file

@ -0,0 +1,18 @@
import type { UiStatusItem } from "../types";
import { FooterCell } from "./FooterCell";
export interface StatusStripProps {
id?: string;
items: UiStatusItem[];
compact?: boolean;
}
export function StatusStrip({ id, items, compact = false }: StatusStripProps) {
return (
<section className="status-strip" data-compact={compact} data-model-id={id}>
{items.map((item) => (
<FooterCell key={item.id} item={item} />
))}
</section>
);
}

View file

@ -1,67 +0,0 @@
<script lang="ts">
import type { UiSeverity } from "../types";
import IconGlyph from "./IconGlyph.svelte";
let {
title,
detail,
icon = "mdi:information-outline",
severity = "neutral",
}: {
title: string;
detail?: string;
icon?: string;
severity?: UiSeverity;
} = $props();
</script>
<section class="system-state" data-severity={severity}>
<IconGlyph name={icon} size="lg" />
<div>
<h2>{title}</h2>
{#if detail}
<p>{detail}</p>
{/if}
</div>
</section>
<style>
.system-state {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--ui-space-3);
align-items: center;
border: var(--ui-border);
background: rgba(6, 8, 7, 0.82);
padding: var(--ui-space-4);
}
.system-state[data-severity="warning"] {
color: var(--ui-color-warning);
}
.system-state[data-severity="danger"],
.system-state[data-severity="unavailable"] {
color: var(--ui-color-danger);
}
.system-state[data-severity="loading"] {
color: var(--ui-color-accent);
}
h2,
p {
margin: 0;
text-transform: uppercase;
}
h2 {
font-size: 1rem;
}
p {
margin-top: var(--ui-space-1);
color: var(--ui-color-muted);
font-size: 0.72rem;
}
</style>

View file

@ -0,0 +1,26 @@
import type { UiSeverity } from "../types";
import { IconGlyph } from "./IconGlyph";
export interface SystemStateProps {
title: string;
detail?: string;
icon?: string;
severity?: UiSeverity;
}
export function SystemState({
title,
detail,
icon = "mdi:information-outline",
severity = "neutral",
}: SystemStateProps) {
return (
<section className="system-state" data-severity={severity}>
<IconGlyph name={icon} size="lg" />
<div>
<h2>{title}</h2>
{detail ? <p>{detail}</p> : null}
</div>
</section>
);
}

View file

@ -1,140 +0,0 @@
<script lang="ts">
import { formatMetricValue } from "../format";
import type { UiTelemetryCard } from "../types";
import IconGlyph from "./IconGlyph.svelte";
import LineChart from "./LineChart.svelte";
let { card }: { card: UiTelemetryCard } = $props();
let progress = $derived(resolveProgress(card));
let value = $derived(formatMetricValue(card.value));
</script>
<article class="telemetry-card" data-severity={card.severity} data-model-id={card.id}>
<header>
{#if card.icon}
<IconGlyph name={card.icon} size="sm" />
{/if}
<h3>{card.label}</h3>
</header>
<strong>{value}</strong>
{#if progress !== null}
<div class="telemetry-card__bar" aria-hidden="true">
<span style={`--metric-progress: ${progress}%`}></span>
</div>
{/if}
{#if card.sparkline?.length}
<LineChart values={card.sparkline} severity={card.severity} label={`${card.label} trend`} />
{/if}
{#if card.detail || card.description}
<p>{card.detail || card.description}</p>
{/if}
</article>
<style>
.telemetry-card {
display: grid;
min-height: 5.15rem;
border: var(--ui-border);
background: linear-gradient(180deg, rgba(13, 15, 14, 0.82), rgba(2, 3, 2, 0.92));
padding: 0.45rem 0.55rem 0.42rem;
color: var(--ui-color-text);
gap: 0.25rem;
}
.telemetry-card[data-severity="warning"] {
border-color: color-mix(in srgb, var(--ui-color-warning), transparent 42%);
}
.telemetry-card[data-severity="danger"] {
border-color: color-mix(in srgb, var(--ui-color-danger), transparent 22%);
color: var(--ui-color-danger);
}
.telemetry-card[data-severity="stale"],
.telemetry-card[data-severity="unavailable"] {
color: var(--ui-color-stale);
}
.telemetry-card[data-severity="loading"] {
color: var(--ui-color-accent);
}
.telemetry-card[data-severity="loading"] .telemetry-card__bar span {
min-width: 1.25rem;
}
header {
display: flex;
min-width: 0;
align-items: center;
gap: 0.35rem;
}
header :global(.icon-glyph) {
display: none;
}
h3,
p {
margin: 0;
}
h3 {
overflow-wrap: anywhere;
color: inherit;
font-size: 0.58rem;
font-weight: 800;
letter-spacing: 0;
line-height: 1.02;
text-transform: uppercase;
}
strong {
font-family: var(--ui-font-display);
font-size: clamp(1.55rem, 2.45vw, 2.22rem);
font-weight: 800;
letter-spacing: 0;
line-height: 0.76;
white-space: nowrap;
}
p {
overflow: hidden;
color: var(--ui-color-muted);
font-size: 0.49rem;
line-height: 1.05;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.telemetry-card__bar {
height: 0.25rem;
border: var(--ui-border);
background: #030403;
}
.telemetry-card__bar span {
display: block;
width: var(--metric-progress);
height: 100%;
background: currentColor;
}
</style>
<script lang="ts" module>
import { clampPercent } from "../format";
function resolveProgress(card: {
progress?: number;
value: { kind: string; value: number | string };
}): number | null {
if (typeof card.progress === "number") return clampPercent(card.progress);
if (card.value.kind !== "percent") return null;
const progress = Number(card.value.value);
return Number.isFinite(progress) ? clampPercent(progress) : null;
}
</script>

View file

@ -0,0 +1,51 @@
import type { CSSProperties } from "react";
import { clampPercent, formatMetricValue } from "../format";
import type { UiTelemetryCard } from "../types";
import { IconGlyph } from "./IconGlyph";
import { LineChart } from "./LineChart";
export interface TelemetryCardProps {
card: UiTelemetryCard;
}
export function TelemetryCard({ card }: TelemetryCardProps) {
const progress = resolveProgress(card);
const value = formatMetricValue(card.value);
return (
<article
className="telemetry-card"
data-severity={card.severity}
data-model-id={card.id}
>
<header>
{card.icon ? <IconGlyph name={card.icon} size="sm" /> : null}
<h3>{card.label}</h3>
</header>
<strong>{value}</strong>
{progress !== null ? (
<div className="telemetry-card__bar" aria-hidden="true">
<span style={{ "--metric-progress": `${progress}%` } as CSSProperties} />
</div>
) : null}
{card.sparkline?.length ? (
<LineChart
values={card.sparkline}
severity={card.severity}
label={`${card.label} trend`}
/>
) : null}
{card.detail || card.description ? (
<p>{card.detail || card.description}</p>
) : null}
</article>
);
}
function resolveProgress(card: UiTelemetryCard): number | null {
if (typeof card.progress === "number") return clampPercent(card.progress);
if (card.value.kind !== "percent") return null;
const progress = Number(card.value.value);
return Number.isFinite(progress) ? clampPercent(progress) : null;
}

View file

@ -1,34 +0,0 @@
<script lang="ts">
import type { UiTelemetryCard } from "../types";
import TelemetryCard from "./TelemetryCard.svelte";
let { cards }: { cards: UiTelemetryCard[] } = $props();
</script>
<section class="telemetry-grid" aria-label="Telemetry">
{#each cards as card (card.id)}
<TelemetryCard {card} />
{/each}
</section>
<style>
.telemetry-grid {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 1px;
border: var(--ui-border);
background: var(--ui-color-line);
}
@media (max-width: 1100px) {
.telemetry-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 700px) {
.telemetry-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>

View file

@ -0,0 +1,16 @@
import type { UiTelemetryCard } from "../types";
import { TelemetryCard } from "./TelemetryCard";
export interface TelemetryGridProps {
cards: UiTelemetryCard[];
}
export function TelemetryGrid({ cards }: TelemetryGridProps) {
return (
<section className="telemetry-grid" aria-label="Telemetry">
{cards.map((card) => (
<TelemetryCard key={card.id} card={card} />
))}
</section>
);
}

View file

@ -1,8 +0,0 @@
<script lang="ts">
import type { UiTelemetryCard } from "../types";
import TelemetryGrid from "./TelemetryGrid.svelte";
let { cards }: { cards: UiTelemetryCard[] } = $props();
</script>
<TelemetryGrid {cards} />

View file

@ -0,0 +1,6 @@
import type { UiTelemetryCard } from "../types";
import { TelemetryGrid } from "./TelemetryGrid";
export function TelemetryStrip({ cards }: { cards: UiTelemetryCard[] }) {
return <TelemetryGrid cards={cards} />;
}

View file

@ -1,8 +0,0 @@
<script lang="ts">
import type { UiModuleBlock } from "../types";
import ModuleCard from "./ModuleCard.svelte";
let { module }: { module: UiModuleBlock } = $props();
</script>
<ModuleCard {module} />

View file

@ -0,0 +1,6 @@
import type { UiModuleBlock } from "../types";
import { ModuleCard } from "./ModuleCard";
export function WeatherModule({ module }: { module: UiModuleBlock }) {
return <ModuleCard module={module} />;
}

View file

@ -1,153 +0,0 @@
import { render } from "svelte/server";
import { describe, expect, test } from "vitest";
import Button from "./Button.svelte";
import DashboardFrame from "./DashboardFrame.svelte";
import FooterCell from "./FooterCell.svelte";
import IconButton from "./IconButton.svelte";
import ServiceRow from "./ServiceRow.svelte";
import TelemetryCard from "./TelemetryCard.svelte";
import { dashboardPreviewFixtures } from "../fixtures";
describe("dashboard UI components", () => {
test("renders the primary generic dashboard fixture", () => {
const { body } = render(DashboardFrame, {
props: {
dashboard: dashboardPreviewFixtures.primary,
},
});
expect(body).toContain("Operations Console");
expect(body).toContain("Core Throughput");
expect(body).toContain("Queue Workers");
expect(body).toContain("data-icon-name=\"mdi:server-network\"");
expect(body).toContain("data-severity=\"warning\"");
expect(body).not.toContain("dashboard-title");
});
test("renders another generic dashboard fixture through the same component", () => {
const { body } = render(DashboardFrame, {
props: {
dashboard: dashboardPreviewFixtures.secondary,
},
});
expect(body).toContain("Support Desk");
expect(body).toContain("Response Window");
expect(body).toContain("Regional Nodes");
expect(body).toContain("data-icon-name=\"mdi:headset\"");
expect(body).toContain("data-severity=\"loading\"");
});
test("renders optional service and status links as focusable anchors", () => {
const service = render(ServiceRow, {
props: {
service: {
id: "linked-service",
label: "Linked Service",
description: "Generic linked destination",
severity: "ok",
detail: "ready",
link: { href: "https://example.test/service", external: true },
},
},
});
const footer = render(FooterCell, {
props: {
item: {
id: "linked-status",
label: "Linked Status",
value: "open",
severity: "neutral",
link: { href: "https://example.test/status" },
},
},
});
expect(service.body).toContain("<a ");
expect(service.body).toContain("href=\"https://example.test/service\"");
expect(service.body).toContain("target=\"_blank\"");
expect(footer.body).toContain("<a ");
expect(footer.body).toContain("href=\"https://example.test/status\"");
});
test("renders stable model IDs on group and status containers", () => {
const { body } = render(DashboardFrame, {
props: {
dashboard: dashboardPreviewFixtures.primary,
},
});
expect(body).toContain("data-model-id=\"queue-workers\"");
expect(body).toContain("data-model-id=\"dashboard-status\"");
});
test("does not render progress bars for non-percent metrics without explicit progress", () => {
const withoutProgress = render(TelemetryCard, {
props: {
card: {
id: "bytes",
label: "Bytes Metric",
value: { kind: "bytes", value: 2048 },
severity: "neutral",
},
},
});
const withProgress = render(TelemetryCard, {
props: {
card: {
id: "bytes-with-progress",
label: "Bytes With Progress",
value: { kind: "bytes", value: 2048 },
progress: 42,
severity: "neutral",
},
},
});
expect(withoutProgress.body).not.toContain("telemetry-card__bar");
expect(withProgress.body).toContain("--metric-progress: 42%");
});
test("renders telemetry trends through the uPlot chart surface", () => {
const { body } = render(TelemetryCard, {
props: {
card: {
id: "trend-card",
label: "Trend Card",
value: { kind: "percent", value: 64 },
sparkline: [18, 24, 64],
severity: "ok",
},
},
});
expect(body).toContain('data-chart-library="uplot"');
});
test("base button controls forward native attributes", () => {
const button = render(Button, {
props: {
label: "Refresh",
id: "refresh-action",
class: "custom-action",
"aria-controls": "refresh-target",
},
});
const iconButton = render(IconButton, {
props: {
icon: "mdi:refresh",
label: "Refresh status",
id: "refresh-icon-action",
class: "custom-icon-action",
"aria-expanded": "false",
},
});
expect(button.body).toContain("id=\"refresh-action\"");
expect(button.body).toContain("class=\"ui-button custom-action ");
expect(button.body).toContain("aria-controls=\"refresh-target\"");
expect(iconButton.body).toContain("id=\"refresh-icon-action\"");
expect(iconButton.body).toContain("class=\"icon-button custom-icon-action ");
expect(iconButton.body).toContain("aria-expanded=\"false\"");
});
});

View file

@ -0,0 +1,147 @@
import { renderToString } from "react-dom/server";
import { describe, expect, test } from "vitest";
import { Button } from "./Button";
import { DashboardFrame } from "./DashboardFrame";
import { FooterCell } from "./FooterCell";
import { IconButton } from "./IconButton";
import { ServiceRow } from "./ServiceRow";
import { TelemetryCard } from "./TelemetryCard";
import { dashboardPreviewFixtures } from "../fixtures";
describe("dashboard UI components", () => {
test("renders the primary generic dashboard fixture", () => {
const body = renderToString(
<DashboardFrame dashboard={dashboardPreviewFixtures.primary} />,
);
expect(body).toContain("Operations Console");
expect(body).toContain("Core Throughput");
expect(body).toContain("Queue Workers");
expect(body).toContain("data-icon-name=\"mdi:server-network\"");
expect(body).toContain("data-severity=\"warning\"");
expect(body).not.toContain("dashboard-title");
});
test("renders another generic dashboard fixture through the same component", () => {
const body = renderToString(
<DashboardFrame dashboard={dashboardPreviewFixtures.secondary} />,
);
expect(body).toContain("Support Desk");
expect(body).toContain("Response Window");
expect(body).toContain("Regional Nodes");
expect(body).toContain("data-icon-name=\"mdi:headset\"");
expect(body).toContain("data-severity=\"loading\"");
});
test("renders optional service and status links as focusable anchors", () => {
const service = renderToString(
<ServiceRow
service={{
id: "linked-service",
label: "Linked Service",
description: "Generic linked destination",
severity: "ok",
detail: "ready",
link: { href: "https://example.test/service", external: true },
}}
/>,
);
const footer = renderToString(
<FooterCell
item={{
id: "linked-status",
label: "Linked Status",
value: "open",
severity: "neutral",
link: { href: "https://example.test/status" },
}}
/>,
);
expect(service).toContain("<a ");
expect(service).toContain("href=\"https://example.test/service\"");
expect(service).toContain("target=\"_blank\"");
expect(footer).toContain("<a ");
expect(footer).toContain("href=\"https://example.test/status\"");
});
test("renders stable model IDs on group and status containers", () => {
const body = renderToString(
<DashboardFrame dashboard={dashboardPreviewFixtures.primary} />,
);
expect(body).toContain("data-model-id=\"queue-workers\"");
expect(body).toContain("data-model-id=\"dashboard-status\"");
});
test("does not render progress bars for non-percent metrics without explicit progress", () => {
const withoutProgress = renderToString(
<TelemetryCard
card={{
id: "bytes",
label: "Bytes Metric",
value: { kind: "bytes", value: 2048 },
severity: "neutral",
}}
/>,
);
const withProgress = renderToString(
<TelemetryCard
card={{
id: "bytes-with-progress",
label: "Bytes With Progress",
value: { kind: "bytes", value: 2048 },
progress: 42,
severity: "neutral",
}}
/>,
);
expect(withoutProgress).not.toContain("telemetry-card__bar");
expect(withProgress).toContain("--metric-progress:42%");
});
test("renders telemetry trends through the uPlot chart surface", () => {
const body = renderToString(
<TelemetryCard
card={{
id: "trend-card",
label: "Trend Card",
value: { kind: "percent", value: 64 },
sparkline: [18, 24, 64],
severity: "ok",
}}
/>,
);
expect(body).toContain('data-chart-library="uplot"');
});
test("base button controls forward native attributes", () => {
const button = renderToString(
<Button
label="Refresh"
id="refresh-action"
className="custom-action"
aria-controls="refresh-target"
/>,
);
const iconButton = renderToString(
<IconButton
icon="mdi:refresh"
label="Refresh status"
id="refresh-icon-action"
className="custom-icon-action"
aria-expanded="false"
/>,
);
expect(button).toContain("id=\"refresh-action\"");
expect(button).toContain("class=\"ui-button custom-action ");
expect(button).toContain("aria-controls=\"refresh-target\"");
expect(iconButton).toContain("id=\"refresh-icon-action\"");
expect(iconButton).toContain("class=\"icon-button custom-icon-action ");
expect(iconButton).toContain("aria-expanded=\"false\"");
});
});

View file

@ -0,0 +1,655 @@
.dashboard-frame {
display: grid;
box-sizing: border-box;
height: 100vh;
min-height: 100vh;
gap: 0.36rem;
grid-template-rows: auto auto minmax(0, 1fr) auto;
overflow: hidden;
padding: clamp(0.42rem, 0.65vw, 0.62rem);
background:
linear-gradient(90deg, transparent 0 49%, rgba(255, 255, 255, 0.08) 50%, transparent 51%),
rgba(0, 0, 0, 0.18);
}
.dashboard-frame__header,
.dashboard-header {
display: grid;
gap: 0.5rem;
align-items: start;
border-bottom: var(--ui-border);
}
.dashboard-frame__header {
grid-template-columns: minmax(30rem, 1fr) minmax(30rem, 0.9fr);
min-height: 5.35rem;
padding-bottom: 0.34rem;
}
.dashboard-header {
grid-template-columns: minmax(0, 1fr) auto;
padding: var(--ui-space-3);
}
.dashboard-frame__header p,
.dashboard-frame__header span,
.dashboard-header p,
.dashboard-header span,
.dashboard-frame h1,
.dashboard-header h1 {
margin: 0;
text-transform: uppercase;
}
.dashboard-frame__header p,
.dashboard-frame__header span,
.dashboard-header p,
.dashboard-header span {
color: var(--ui-color-muted);
font-size: 0.78rem;
font-weight: 800;
}
.dashboard-frame h1,
.dashboard-header h1 {
max-width: 100%;
overflow-wrap: anywhere;
font-family: var(--ui-font-display);
font-size: clamp(2.2rem, 3.3vw, 2.95rem);
font-weight: 850;
letter-spacing: 0;
line-height: 0.8;
white-space: nowrap;
}
.dashboard-frame__modules {
display: grid;
grid-template-columns: minmax(15rem, 0.66fr) minmax(17rem, 1fr);
justify-content: end;
gap: 0.5rem;
}
.dashboard-frame__panels {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
align-content: start;
gap: 0.36rem;
min-height: 0;
overflow: hidden;
}
.dashboard-frame__panels .service-panel[data-layout="grid"] {
grid-column: 1 / -1;
}
.dashboard-frame__panels .service-panel[data-layout="grid"] .panel__body {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.telemetry-grid {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 1px;
border: var(--ui-border);
background: var(--ui-color-line);
}
.telemetry-card,
.module-card,
.panel,
.system-state {
border: var(--ui-border);
background: rgba(3, 4, 3, 0.86);
color: var(--ui-color-text);
}
.telemetry-card {
display: grid;
min-height: 5.15rem;
gap: 0.25rem;
background: linear-gradient(180deg, rgba(13, 15, 14, 0.82), rgba(2, 3, 2, 0.92));
padding: 0.45rem 0.55rem 0.42rem;
}
.telemetry-card[data-severity="warning"] {
border-color: color-mix(in srgb, var(--ui-color-warning), transparent 42%);
}
.telemetry-card[data-severity="danger"] {
border-color: color-mix(in srgb, var(--ui-color-danger), transparent 22%);
color: var(--ui-color-danger);
}
.telemetry-card[data-severity="stale"],
.telemetry-card[data-severity="unavailable"] {
color: var(--ui-color-stale);
}
.telemetry-card[data-severity="loading"] {
color: var(--ui-color-accent);
}
.telemetry-card header {
display: flex;
min-width: 0;
align-items: center;
gap: 0.35rem;
}
.telemetry-card header .icon-glyph {
display: none;
}
.telemetry-card h3,
.telemetry-card p,
.module-card h2,
.module-card p,
.service-row h3,
.service-row p,
.panel h2,
.system-state h2,
.system-state p {
margin: 0;
}
.telemetry-card h3,
.service-row h3 {
overflow: hidden;
font-weight: 850;
letter-spacing: 0;
line-height: 1.05;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.telemetry-card h3 {
overflow-wrap: anywhere;
color: inherit;
font-size: 0.58rem;
font-weight: 800;
line-height: 1.02;
white-space: normal;
}
.service-row h3 {
font-size: 0.72rem;
}
.telemetry-card strong,
.module-card strong {
display: block;
font-family: var(--ui-font-display);
font-size: clamp(1.55rem, 2.45vw, 2.22rem);
font-weight: 800;
letter-spacing: 0;
line-height: 0.76;
white-space: nowrap;
}
.telemetry-card p,
.module-card p,
.service-row p {
overflow: hidden;
color: var(--ui-color-muted);
line-height: 1.08;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.telemetry-card p {
font-size: 0.49rem;
line-height: 1.05;
}
.module-card p,
.service-row p {
font-size: 0.56rem;
}
.telemetry-card__bar,
.progress-meter {
height: 0.25rem;
border: var(--ui-border);
background: #030403;
}
.telemetry-card__bar span,
.progress-meter span {
display: block;
width: var(--metric-progress, var(--meter-progress));
height: 100%;
background: currentColor;
}
.telemetry-card[data-severity="loading"] .telemetry-card__bar span {
min-width: 1.25rem;
}
.line-chart {
position: relative;
min-width: 0;
height: 1rem;
color: var(--ui-color-accent);
}
.line-chart[data-severity="warning"] {
color: var(--ui-color-warning);
}
.line-chart[data-severity="danger"] {
color: var(--ui-color-danger);
}
.line-chart[data-severity="stale"],
.line-chart[data-severity="unavailable"] {
color: var(--ui-color-stale);
}
.line-chart__canvas {
width: 100%;
height: 100%;
}
.line-chart__canvas .uplot {
width: 100% !important;
height: 100% !important;
background: transparent;
font-family: var(--ui-font-mono);
}
.line-chart__canvas .u-over,
.line-chart__canvas .u-under {
overflow: visible;
}
.module-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.5rem;
align-items: start;
min-width: 0;
min-height: 4.15rem;
background: rgba(6, 8, 7, 0.82);
padding: 0.5rem;
}
.module-card h2 {
color: var(--ui-color-muted);
font-size: 0.56rem;
line-height: 1;
text-transform: uppercase;
}
.module-card strong {
margin-top: 0.15rem;
font-size: clamp(1.25rem, 1.75vw, 1.72rem);
line-height: 0.82;
overflow-wrap: anywhere;
}
.module-card p {
margin-top: 0.2rem;
font-size: 0.49rem;
line-height: 1.05;
}
.module-card[data-severity="ok"] .icon-glyph {
background: var(--ui-color-accent);
color: #050605;
}
.module-card[data-severity="warning"] {
border-color: color-mix(in srgb, var(--ui-color-warning), transparent 42%);
color: var(--ui-color-warning);
}
.module-card[data-severity="danger"] {
border-color: color-mix(in srgb, var(--ui-color-danger), transparent 32%);
color: var(--ui-color-danger);
}
.module-card[data-severity="stale"],
.module-card[data-severity="unavailable"] {
color: var(--ui-color-stale);
}
.module-card[data-severity="loading"] {
color: var(--ui-color-accent);
}
.service-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 0.24rem;
align-items: center;
min-height: 2.02rem;
border: 0;
border-bottom: var(--ui-border);
background: transparent;
color: inherit;
padding: 0.14rem 0;
text-decoration: none;
}
.service-row[data-severity="warning"] {
border-bottom-color: color-mix(in srgb, var(--ui-color-warning), transparent 54%);
}
.service-row[data-severity="danger"],
.service-row[data-severity="unavailable"] {
border-bottom-color: color-mix(in srgb, var(--ui-color-danger), transparent 50%);
}
.service-row[data-severity="loading"] {
border-bottom-color: color-mix(in srgb, var(--ui-color-accent), transparent 56%);
}
.service-row:where(a):hover {
border-bottom-color: var(--ui-color-accent);
background: rgba(244, 244, 244, 0.035);
}
.service-row__main {
min-width: 0;
}
.status-badge {
display: inline-grid;
min-height: 1.12rem;
align-items: center;
border: var(--ui-border);
padding: 0 0.3rem;
color: var(--ui-color-muted);
font-size: 0.55rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
white-space: nowrap;
}
.status-badge[data-severity="ok"] {
border-color: color-mix(in srgb, var(--ui-color-ok), transparent 42%);
color: var(--ui-color-ok);
}
.status-badge[data-severity="warning"] {
border-color: color-mix(in srgb, var(--ui-color-warning), transparent 35%);
color: var(--ui-color-warning);
}
.status-badge[data-severity="danger"] {
border-color: color-mix(in srgb, var(--ui-color-danger), transparent 30%);
color: var(--ui-color-danger);
}
.status-badge[data-severity="stale"],
.status-badge[data-severity="unavailable"] {
color: var(--ui-color-stale);
}
.status-badge[data-severity="loading"] {
color: var(--ui-color-accent);
}
.footer-cell {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
min-height: 1.9rem;
align-items: center;
background: rgba(2, 3, 2, 0.92);
color: inherit;
text-decoration: none;
}
.footer-cell span,
.footer-cell strong {
min-width: 0;
padding: 0.34rem 0.52rem;
overflow-wrap: anywhere;
text-transform: uppercase;
}
.footer-cell span {
height: 100%;
border-right: var(--ui-border);
color: var(--ui-color-muted);
font-size: 0.52rem;
font-weight: 800;
}
.footer-cell strong {
color: var(--ui-color-text);
font-size: 0.58rem;
}
.footer-cell[data-severity="ok"] strong {
background: var(--ui-color-accent);
color: #060706;
}
.footer-cell[data-severity="warning"] strong {
color: var(--ui-color-warning);
}
.footer-cell[data-severity="danger"] strong {
color: var(--ui-color-danger);
}
.footer-cell[data-severity="stale"] strong,
.footer-cell[data-severity="unavailable"] strong {
color: var(--ui-color-stale);
}
.footer-cell[data-severity="loading"] strong {
color: var(--ui-color-accent);
}
.footer-cell:where(a):hover strong {
color: var(--ui-color-accent);
}
.status-strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
gap: 1px;
border: var(--ui-border);
background: var(--ui-color-line);
}
.status-strip[data-compact="true"] {
grid-template-columns: repeat(auto-fit, minmax(6rem, 1fr));
}
.panel {
position: relative;
min-width: 0;
box-shadow: var(--ui-shadow-hard);
}
.panel::before,
.panel::after {
position: absolute;
width: 0.55rem;
height: 0.55rem;
border-color: var(--ui-color-line-strong);
content: "";
}
.panel::before {
top: 0.2rem;
right: 0.2rem;
border-top: 1px solid;
border-right: 1px solid;
}
.panel::after {
right: 0.2rem;
bottom: 0.2rem;
border-right: 1px solid;
border-bottom: 1px solid;
}
.panel__header {
padding: 0.42rem 0.55rem 0;
}
.panel h2 {
font-family: var(--ui-font-display);
font-size: clamp(1.25rem, 1.8vw, 1.75rem);
font-weight: 800;
line-height: 0.92;
text-transform: uppercase;
}
.panel__body {
display: grid;
gap: 0.25rem;
padding: 0.42rem 0.55rem 0.55rem;
}
.service-panel .panel__body {
gap: 0;
padding-block: 0.24rem 0.42rem;
}
.ui-button,
.icon-button {
display: inline-grid;
align-items: center;
justify-content: center;
border: var(--ui-border);
cursor: pointer;
}
.ui-button {
grid-auto-flow: column;
gap: var(--ui-space-2);
min-height: 2.5rem;
background: var(--ui-color-accent);
color: var(--ui-color-canvas);
font-size: 0.76rem;
font-weight: 850;
padding: 0 var(--ui-space-4);
text-transform: uppercase;
}
.icon-button {
width: 2.5rem;
height: 2.5rem;
place-items: center;
background: rgba(2, 3, 2, 0.92);
color: var(--ui-color-muted);
padding: 0;
}
.icon-glyph {
display: inline-grid;
width: 1.55rem;
height: 1.55rem;
place-items: center;
border: var(--ui-border);
background: #050605;
color: currentColor;
line-height: 1;
}
.icon-glyph svg {
width: 0.95rem;
height: 0.95rem;
}
.icon-glyph[data-size="sm"] {
width: 1.1rem;
height: 1.1rem;
}
.icon-glyph[data-size="lg"] {
width: 2.35rem;
height: 2.35rem;
}
.system-state {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--ui-space-3);
align-items: center;
padding: var(--ui-space-4);
}
.grid-frame {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
gap: var(--ui-space-3);
padding: var(--ui-space-3);
}
.separator {
background: var(--ui-color-line);
}
.separator[data-orientation="horizontal"] {
width: 100%;
height: 1px;
margin-block: var(--ui-space-3);
}
.corner-bracket-frame,
.diagonal-stripe-field,
.scanline-field,
.signal-trace {
display: block;
min-height: 3rem;
}
@media (max-width: 1100px) {
.telemetry-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 780px) {
.dashboard-frame {
height: auto;
min-height: 100vh;
overflow: visible;
}
.dashboard-frame__header,
.dashboard-header,
.dashboard-frame__modules,
.dashboard-frame__panels {
grid-template-columns: 1fr;
}
.dashboard-frame h1,
.dashboard-header h1 {
white-space: normal;
}
.dashboard-frame__panels {
overflow: visible;
}
.dashboard-frame__panels .service-panel[data-layout="grid"] .panel__body {
grid-template-columns: 1fr;
}
}
@media (max-width: 700px) {
.telemetry-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 580px) {
.service-row {
grid-template-columns: auto minmax(0, 1fr);
}
.service-row .status-badge {
grid-column: 2;
justify-self: start;
}
}

View file

@ -1,32 +1,32 @@
export { default as Badge } from "./components/Badge.svelte";
export { default as Button } from "./components/Button.svelte";
export { default as CornerBracketFrame } from "./components/CornerBracketFrame.svelte";
export { default as DashboardHeader } from "./components/DashboardHeader.svelte";
export { default as DashboardFrame } from "./components/DashboardFrame.svelte";
export { default as DiagonalStripeField } from "./components/DiagonalStripeField.svelte";
export { default as FooterCell } from "./components/FooterCell.svelte";
export { default as FooterStatusCell } from "./components/FooterStatusCell.svelte";
export { default as GridFrame } from "./components/GridFrame.svelte";
export { default as IconGlyph } from "./components/IconGlyph.svelte";
export { default as IconButton } from "./components/IconButton.svelte";
export { default as LineChart } from "./components/LineChart.svelte";
export { default as ModuleCard } from "./components/ModuleCard.svelte";
export { default as Panel } from "./components/Panel.svelte";
export { default as ProgressMeter } from "./components/ProgressMeter.svelte";
export { default as Separator } from "./components/Separator.svelte";
export { default as ServiceGroupPanel } from "./components/ServiceGroupPanel.svelte";
export { default as ServicePanel } from "./components/ServicePanel.svelte";
export { default as ServiceRow } from "./components/ServiceRow.svelte";
export { default as ScanlineField } from "./components/ScanlineField.svelte";
export { default as SignalTrace } from "./components/SignalTrace.svelte";
export { default as Sparkline } from "./components/Sparkline.svelte";
export { default as StatusBadge } from "./components/StatusBadge.svelte";
export { default as StatusStrip } from "./components/StatusStrip.svelte";
export { default as SystemState } from "./components/SystemState.svelte";
export { default as TelemetryCard } from "./components/TelemetryCard.svelte";
export { default as TelemetryGrid } from "./components/TelemetryGrid.svelte";
export { default as TelemetryStrip } from "./components/TelemetryStrip.svelte";
export { default as WeatherModule } from "./components/WeatherModule.svelte";
export { Badge } from "./components/Badge";
export { Button } from "./components/Button";
export { CornerBracketFrame } from "./components/CornerBracketFrame";
export { DashboardHeader } from "./components/DashboardHeader";
export { DashboardFrame } from "./components/DashboardFrame";
export { DiagonalStripeField } from "./components/DiagonalStripeField";
export { FooterCell } from "./components/FooterCell";
export { FooterStatusCell } from "./components/FooterStatusCell";
export { GridFrame } from "./components/GridFrame";
export { IconGlyph } from "./components/IconGlyph";
export { IconButton } from "./components/IconButton";
export { LineChart } from "./components/LineChart";
export { ModuleCard } from "./components/ModuleCard";
export { Panel } from "./components/Panel";
export { ProgressMeter } from "./components/ProgressMeter";
export { Separator } from "./components/Separator";
export { ServiceGroupPanel } from "./components/ServiceGroupPanel";
export { ServicePanel } from "./components/ServicePanel";
export { ServiceRow } from "./components/ServiceRow";
export { ScanlineField } from "./components/ScanlineField";
export { SignalTrace } from "./components/SignalTrace";
export { Sparkline } from "./components/Sparkline";
export { StatusBadge } from "./components/StatusBadge";
export { StatusStrip } from "./components/StatusStrip";
export { SystemState } from "./components/SystemState";
export { TelemetryCard } from "./components/TelemetryCard";
export { TelemetryGrid } from "./components/TelemetryGrid";
export { TelemetryStrip } from "./components/TelemetryStrip";
export { WeatherModule } from "./components/WeatherModule";
export { dashboardPreviewFixtures } from "./fixtures";
export { dashboardDocumentToUiDashboard } from "./model-renderer";
export type {

View file

@ -1,21 +0,0 @@
<script module lang="ts">
import { defineMeta } from "@storybook/addon-svelte-csf";
import Badge from "../components/Badge.svelte";
const { Story } = defineMeta({
title: "Base UI/Badge",
component: Badge,
tags: ["autodocs"],
});
</script>
<Story name="Neutral" args={{ label: "Neutral", severity: "neutral" }} />
<Story name="Ok" args={{ label: "Operational", severity: "ok" }} />
<Story name="Warning" args={{ label: "Warning", severity: "warning" }} />
<Story name="Danger" args={{ label: "Incident", severity: "danger" }} />
<Story name="Stale" args={{ label: "Stale", severity: "stale" }} />
<Story name="Unavailable" args={{ label: "Unavailable", severity: "unavailable" }} />
<Story
name="Long Label"
args={{ label: "Long generic operational label", severity: "warning" }}
/>

View file

@ -0,0 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Badge } from "../components/Badge";
const meta = {
title: "UI/Badge",
component: Badge,
args: {
label: "Ready",
severity: "ok",
},
} satisfies Meta<typeof Badge>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};

View file

@ -1,19 +0,0 @@
<script module lang="ts">
import { defineMeta } from "@storybook/addon-svelte-csf";
import Button from "../components/Button.svelte";
const { Story } = defineMeta({
title: "Base UI/Button",
component: Button,
tags: ["autodocs"],
});
</script>
<Story name="Default" args={{ label: "Refresh" }} />
<Story name="Secondary" args={{ label: "Preview", variant: "secondary" }} />
<Story name="Danger" args={{ label: "Stop", variant: "danger" }} />
<Story name="Ghost Outline" args={{ label: "Inspect", variant: "ghost" }} />
<Story name="Disabled" args={{ label: "Publish", disabled: true }} />
<Story name="Loading" args={{ label: "Sync", loading: true }} />
<Story name="Icon Leading" args={{ label: "Open", icon: "mdi:open-in-new" }} />
<Story name="Compact" args={{ label: "Apply", size: "compact" }} />

View file

@ -0,0 +1,21 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Button } from "../components/Button";
const meta = {
title: "UI/Button",
component: Button,
args: {
label: "Refresh",
icon: "mdi:refresh",
},
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {};
export const Secondary: Story = {
args: {
variant: "secondary",
},
};

View file

@ -1,22 +0,0 @@
<script module lang="ts">
import { defineMeta } from "@storybook/addon-svelte-csf";
import CornerBracketFrame from "../components/CornerBracketFrame.svelte";
import GridFrame from "../components/GridFrame.svelte";
const { Story } = defineMeta({
title: "Style Primitives/CornerBracketFrame",
component: CornerBracketFrame,
tags: ["autodocs"],
});
</script>
<Story name="Neutral Medium" args={{ tone: "neutral", size: "md" }} />
<Story name="Accent Tight" args={{ tone: "accent", density: "tight" }} />
<Story name="Large Danger" args={{ tone: "danger", size: "lg" }} />
<Story name="Grid Set" asChild>
<GridFrame density="compact">
<CornerBracketFrame tone="neutral" size="sm" />
<CornerBracketFrame tone="accent" size="sm" />
<CornerBracketFrame tone="danger" size="sm" density="tight" />
</GridFrame>
</Story>

View file

@ -0,0 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { CornerBracketFrame } from "../components/CornerBracketFrame";
const meta = {
title: "UI/CornerBracketFrame",
component: CornerBracketFrame,
args: {
tone: "accent",
size: "md",
},
} satisfies Meta<typeof CornerBracketFrame>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};

View file

@ -1,15 +0,0 @@
<script module lang="ts">
import { defineMeta } from "@storybook/addon-svelte-csf";
import DashboardFrame from "../components/DashboardFrame.svelte";
import { emptyDashboard, fullCompositionDashboard, primaryDashboard } from "./story-data";
const { Story } = defineMeta({
title: "Composition/DashboardFrame",
component: DashboardFrame,
tags: ["autodocs"],
});
</script>
<Story name="Generic Runtime" args={{ dashboard: primaryDashboard }} />
<Story name="Dense Generic Runtime" args={{ dashboard: fullCompositionDashboard }} />
<Story name="Empty Regions" args={{ dashboard: emptyDashboard }} />

View file

@ -0,0 +1,21 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DashboardFrame } from "../components/DashboardFrame";
import { fullCompositionDashboard, secondaryDashboard } from "./story-data";
const meta = {
title: "UI/DashboardFrame",
component: DashboardFrame,
args: {
dashboard: fullCompositionDashboard,
},
} satisfies Meta<typeof DashboardFrame>;
export default meta;
type Story = StoryObj<typeof meta>;
export const FullComposition: Story = {};
export const Secondary: Story = {
args: {
dashboard: secondaryDashboard,
},
};

View file

@ -1,31 +0,0 @@
<script module lang="ts">
import { defineMeta } from "@storybook/addon-svelte-csf";
import DashboardHeader from "../components/DashboardHeader.svelte";
import { moduleBlocks } from "./story-data";
const { Story } = defineMeta({
title: "Composition/DashboardHeader",
component: DashboardHeader,
tags: ["autodocs"],
});
</script>
<Story
name="Title Subtitle Module"
args={{
eyebrow: "Command Surface",
title: "Operations Console",
subtitle: "Capacity and queue health",
module: moduleBlocks.compact,
}}
/>
<Story
name="Long Title"
args={{
eyebrow: "Preview Surface",
title: "Long Generic Dashboard Title That Must Wrap",
subtitle: "Secondary status text wraps independently",
module: moduleBlocks.long,
}}
/>
<Story name="No Module" args={{ title: "Standalone Console", subtitle: "Header without side module" }} />

View file

@ -0,0 +1,19 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DashboardHeader } from "../components/DashboardHeader";
import { moduleBlocks } from "./story-data";
const meta = {
title: "UI/DashboardHeader",
component: DashboardHeader,
args: {
eyebrow: "Preview Surface",
title: "Operations Console",
subtitle: "Generic dashboard header",
module: moduleBlocks.compact,
},
} satisfies Meta<typeof DashboardHeader>;
export default meta;
type Story = StoryObj<typeof meta>;
export const WithModule: Story = {};

View file

@ -1,18 +0,0 @@
<script module lang="ts">
import { defineMeta } from "@storybook/addon-svelte-csf";
import DashboardFrame from "../components/DashboardFrame.svelte";
import { fullCompositionDashboard } from "./story-data";
const { Story } = defineMeta({
title: "Composition/DashboardOnePager",
component: DashboardFrame,
tags: ["autodocs"],
});
</script>
<Story name="Desktop Generic Dashboard" args={{ dashboard: fullCompositionDashboard }} />
<Story name="Mobile Generic Dashboard" asChild>
<div style="max-width: 390px">
<DashboardFrame dashboard={fullCompositionDashboard} />
</div>
</Story>

View file

@ -0,0 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DashboardFrame } from "../components/DashboardFrame";
import { fullCompositionDashboard } from "./story-data";
const meta = {
title: "UI/DashboardOnePager",
component: DashboardFrame,
args: {
dashboard: fullCompositionDashboard,
},
} satisfies Meta<typeof DashboardFrame>;
export default meta;
type Story = StoryObj<typeof meta>;
export const OnePager: Story = {};

View file

@ -1,23 +0,0 @@
<script module lang="ts">
import { defineMeta } from "@storybook/addon-svelte-csf";
import DiagonalStripeField from "../components/DiagonalStripeField.svelte";
import Panel from "../components/Panel.svelte";
import SignalTrace from "../components/SignalTrace.svelte";
const { Story } = defineMeta({
title: "Style Primitives/DiagonalStripeField",
component: DiagonalStripeField,
tags: ["autodocs"],
});
</script>
<Story name="Accent Regular" args={{ density: "regular", tone: "accent", size: "md" }} />
<Story name="Tight Warning" args={{ density: "tight", tone: "warning", direction: "backward" }} />
<Story name="Large Danger" args={{ density: "open", tone: "danger", size: "lg" }} />
<Story name="Composed Divider" asChild>
<Panel title="Generic Surface">
<DiagonalStripeField density="tight" size="sm" />
<SignalTrace tone="accent" />
<DiagonalStripeField direction="backward" tone="neutral" />
</Panel>
</Story>

View file

@ -0,0 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DiagonalStripeField } from "../components/DiagonalStripeField";
const meta = {
title: "UI/DiagonalStripeField",
component: DiagonalStripeField,
args: {
tone: "accent",
density: "regular",
},
} satisfies Meta<typeof DiagonalStripeField>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};

View file

@ -1,33 +0,0 @@
<script lang="ts">
import { onMount, tick, type Snippet } from "svelte";
let {
children,
selector = "button, a[href]",
}: {
children?: Snippet;
selector?: string;
} = $props();
let root: HTMLDivElement;
onMount(async () => {
await tick();
root.querySelector<HTMLElement>(selector)?.focus();
});
</script>
<div class="focus-preview" bind:this={root}>
{@render children?.()}
</div>
<style>
.focus-preview {
display: inline-grid;
padding: var(--ui-space-2);
}
.focus-preview :global(:is(a, button)) {
box-shadow: var(--ui-focus-ring);
}
</style>

View file

@ -1,16 +0,0 @@
<script module lang="ts">
import { defineMeta } from "@storybook/addon-svelte-csf";
import FooterCell from "../components/FooterCell.svelte";
import { statusItems } from "./story-data";
const { Story } = defineMeta({
title: "Dashboard Primitives/FooterCell",
component: FooterCell,
tags: ["autodocs"],
});
</script>
<Story name="Label Value" args={{ item: statusItems.ok }} />
<Story name="Action Cell" args={{ item: statusItems.action }} />
<Story name="Stale" args={{ item: statusItems.stale }} />
<Story name="Long Value" args={{ item: statusItems.long }} />

Some files were not shown because too many files have changed in this diff Show more