Merge pull request #28 from codex/react-migration
feat(ui): migrate dashboard runtime to React
This commit is contained in:
commit
bbc81cea0d
168 changed files with 5054 additions and 3137 deletions
|
|
@ -1,15 +1,14 @@
|
||||||
import type { StorybookConfig } from "@storybook/sveltekit";
|
import type { StorybookConfig } from "@storybook/react-vite";
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
stories: ["../src/**/*.stories.@(js|ts|svelte)"],
|
stories: ["../src/**/*.stories.@(js|ts|tsx)"],
|
||||||
staticDirs: ["../static"],
|
staticDirs: ["../static"],
|
||||||
addons: [
|
addons: [
|
||||||
"@storybook/addon-svelte-csf",
|
|
||||||
"@storybook/addon-a11y",
|
"@storybook/addon-a11y",
|
||||||
"@storybook/addon-vitest",
|
"@storybook/addon-vitest",
|
||||||
],
|
],
|
||||||
framework: {
|
framework: {
|
||||||
name: "@storybook/sveltekit",
|
name: "@storybook/react-vite",
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
docs: {
|
docs: {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import "../src/app.css";
|
import "../src/app.css";
|
||||||
import type { Preview } from "@storybook/sveltekit";
|
import type { Preview } from "@storybook/react-vite";
|
||||||
import { setupWorker } from "msw/browser";
|
import { setupWorker } from "msw/browser";
|
||||||
import { externalApiHandlers } from "../src/lib/testing/external-api-mocks";
|
import { externalApiHandlers } from "../src/lib/testing/external-api-mocks";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ ENV DASHBOARD_MIGRATIONS_DIR=/app/drizzle
|
||||||
COPY package.json bun.lock ./
|
COPY package.json bun.lock ./
|
||||||
RUN bun install --frozen-lockfile --production
|
RUN bun install --frozen-lockfile --production
|
||||||
COPY --from=build /app/build ./build
|
COPY --from=build /app/build ./build
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
COPY --from=build /app/drizzle ./drizzle
|
COPY --from=build /app/drizzle ./drizzle
|
||||||
|
|
||||||
RUN mkdir -p /data
|
RUN mkdir -p /data
|
||||||
|
|
|
||||||
22
README.md
22
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
# Dimension Lab Website
|
# 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
|
This project is not a Homepage customization and does not depend on Homepage
|
||||||
runtime, frontend code, or configuration. The dashboard will be model-driven:
|
runtime, frontend code, or configuration. The dashboard will be model-driven:
|
||||||
|
|
@ -18,8 +18,8 @@ This MVP uses Drizzle with Bun SQLite for local file-backed persistence.
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
- `bun run dev`: start the local development server.
|
- `bun run dev`: start the local Vite development server with a Bun API proxy.
|
||||||
- `bun run check`: run Svelte and TypeScript checks.
|
- `bun run check`: run TypeScript checks.
|
||||||
- `bun run test`: run Vitest.
|
- `bun run test`: run Vitest.
|
||||||
- `bun run test:unit`: run Vitest explicitly as the unit test stage.
|
- `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.
|
- `bun run test:e2e`: build and run Playwright browser smoke and QA checks.
|
||||||
|
|
@ -63,9 +63,11 @@ can replace them without changing presentation components.
|
||||||
|
|
||||||
## Runtime Shape
|
## Runtime Shape
|
||||||
|
|
||||||
The SvelteKit build uses the Node adapter, and the current persistence runtime
|
The browser app is built with Vite and React. Local development starts Vite for
|
||||||
is Bun because the MVP SQLite driver is `bun:sqlite`. Later issues add the
|
HMR and a loopback Bun API server for `/api/*` routes. Production uses a small
|
||||||
seed data expansion and rendering pipeline.
|
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
|
## Storybook
|
||||||
|
|
||||||
|
|
@ -87,7 +89,7 @@ Run the CI-ready release gate with:
|
||||||
bun run test:qa
|
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
|
persistence, renderer, datasource mocks, and presentation boundaries, the
|
||||||
production build, the static Storybook build, and Playwright desktop/mobile
|
production build, the static Storybook build, and Playwright desktop/mobile
|
||||||
smoke checks against the built adapter output. Playwright also performs
|
smoke checks against the built adapter output. Playwright also performs
|
||||||
|
|
@ -104,8 +106,8 @@ belong in validated model data, not reusable components.
|
||||||
|
|
||||||
## Deployment Notes
|
## Deployment Notes
|
||||||
|
|
||||||
The production build uses the SvelteKit Node adapter, but the current SQLite
|
The production build emits Vite client assets under `dist/` and a Bun server
|
||||||
driver depends on Bun. A minimal deployment flow is:
|
entry at `build/index.js`. A minimal deployment flow is:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
bun install --frozen-lockfile
|
bun install --frozen-lockfile
|
||||||
|
|
@ -119,7 +121,7 @@ the checked-in `drizzle/` directory so startup migrations can run.
|
||||||
|
|
||||||
### Internal Container
|
### 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
|
runtime image. For the Dimension Lab internal host, run it behind Caddy on a
|
||||||
loopback port and mount persistent state at `/data`:
|
loopback port and mount persistent state at `/data`:
|
||||||
|
|
||||||
|
|
|
||||||
25
components.json
Normal file
25
components.json
Normal 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": {}
|
||||||
|
}
|
||||||
592
docs/superpowers/plans/2026-06-19-react-runtime-migration.md
Normal file
592
docs/superpowers/plans/2026-06-19-react-runtime-migration.md
Normal 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`.
|
||||||
|
|
@ -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.
|
||||||
13
index.html
Normal file
13
index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Dimension Lab dashboard runtime" />
|
||||||
|
<title>Dimension Lab</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
33
package.json
33
package.json
|
|
@ -4,12 +4,12 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "bun src/server/dev.ts",
|
||||||
"build": "vite build",
|
"build": "vite build && bun build src/server/index.ts --target bun --outdir build",
|
||||||
"preview": "vite preview --host 0.0.0.0",
|
"preview": "HOST=0.0.0.0 PORT=4173 bun build/index.js",
|
||||||
"storybook": "storybook dev -p 6006 --host 0.0.0.0",
|
"storybook": "storybook dev -p 6006 --host 0.0.0.0",
|
||||||
"build-storybook": "storybook build",
|
"build-storybook": "storybook build",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:unit": "vitest run",
|
"test:unit": "vitest run",
|
||||||
"test:e2e": "env -u NO_COLOR playwright test",
|
"test:e2e": "env -u NO_COLOR playwright test",
|
||||||
|
|
@ -18,30 +18,39 @@
|
||||||
"db:check": "drizzle-kit check"
|
"db:check": "drizzle-kit check"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@iconify/svelte": "^5.2.2",
|
"@fontsource-variable/geist": "^5.2.9",
|
||||||
|
"@iconify/react": "^6.0.2",
|
||||||
"@sinclair/typebox": "^0.34.49",
|
"@sinclair/typebox": "^0.34.49",
|
||||||
"ajv": "^8.20.0",
|
"ajv": "^8.20.0",
|
||||||
"ajv-formats": "^3.0.1",
|
"ajv-formats": "^3.0.1",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
|
"lucide-react": "^1.21.0",
|
||||||
|
"radix-ui": "^1.6.0",
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
"uplot": "^1.6.32"
|
"uplot": "^1.6.32"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "^4.11.3",
|
"@axe-core/playwright": "^4.11.3",
|
||||||
"@playwright/test": "^1.61.0",
|
"@playwright/test": "^1.61.0",
|
||||||
"@storybook/addon-a11y": "^10.4.6",
|
"@storybook/addon-a11y": "^10.4.6",
|
||||||
"@storybook/addon-svelte-csf": "^5.1.2",
|
|
||||||
"@storybook/addon-vitest": "^10.4.6",
|
"@storybook/addon-vitest": "^10.4.6",
|
||||||
"@storybook/sveltekit": "^10.4.6",
|
"@storybook/react-vite": "^10.4.6",
|
||||||
"@sveltejs/adapter-node": "^5.5.4",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
"@sveltejs/kit": "^2.65.2",
|
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"@types/node": "^25.9.3",
|
"@types/node": "^25.9.3",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"msw": "^2.14.6",
|
"msw": "^2.14.6",
|
||||||
|
"shadcn": "^4.11.0",
|
||||||
"storybook": "^10.4.6",
|
"storybook": "^10.4.6",
|
||||||
"svelte": "^5.56.3",
|
"tailwindcss": "^4.3.1",
|
||||||
"svelte-check": "^4.6.0",
|
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.16",
|
"vite": "^8.0.16",
|
||||||
"vitest": "^4.1.9"
|
"vitest": "^4.1.9"
|
||||||
|
|
|
||||||
21
src/App.test.tsx
Normal file
21
src/App.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
142
src/App.tsx
Normal file
142
src/App.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
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 function resolveDocumentMetadata(dashboard: DashboardRuntimeState): {
|
||||||
|
description: string;
|
||||||
|
title: string;
|
||||||
|
} {
|
||||||
|
if (dashboard.state === "ready") {
|
||||||
|
const uiDashboard = dashboardDocumentToUiDashboard(dashboard.document);
|
||||||
|
return {
|
||||||
|
title: uiDashboard.title,
|
||||||
|
description:
|
||||||
|
uiDashboard.subtitle ||
|
||||||
|
dashboard.document.metadata.description ||
|
||||||
|
uiDashboard.title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: dashboard.title,
|
||||||
|
description: dashboard.subtitle || dashboard.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [dashboard, setDashboard] =
|
||||||
|
useState<DashboardRuntimeState>(loadingDashboardState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const metadata = resolveDocumentMetadata(dashboard);
|
||||||
|
document.title = metadata.title;
|
||||||
|
|
||||||
|
let description = document.querySelector<HTMLMetaElement>(
|
||||||
|
'meta[name="description"]',
|
||||||
|
);
|
||||||
|
if (!description) {
|
||||||
|
description = document.createElement("meta");
|
||||||
|
description.name = "description";
|
||||||
|
document.head.append(description);
|
||||||
|
}
|
||||||
|
description.content = metadata.description;
|
||||||
|
}, [dashboard]);
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
166
src/app.css
166
src/app.css
|
|
@ -1 +1,167 @@
|
||||||
|
@import "tailwindcss";
|
||||||
@import "./lib/ui/tokens.css";
|
@import "./lib/ui/tokens.css";
|
||||||
|
@import "./lib/ui/components/styles.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
11
src/app.html
11
src/app.html
|
|
@ -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>
|
|
||||||
76
src/lib/components/ui/alert.tsx
Normal file
76
src/lib/components/ui/alert.tsx
Normal 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 }
|
||||||
49
src/lib/components/ui/badge.tsx
Normal file
49
src/lib/components/ui/badge.tsx
Normal 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 }
|
||||||
67
src/lib/components/ui/button.tsx
Normal file
67
src/lib/components/ui/button.tsx
Normal 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 }
|
||||||
103
src/lib/components/ui/card.tsx
Normal file
103
src/lib/components/ui/card.tsx
Normal 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,
|
||||||
|
}
|
||||||
29
src/lib/components/ui/progress.tsx
Normal file
29
src/lib/components/ui/progress.tsx
Normal 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 }
|
||||||
28
src/lib/components/ui/separator.tsx
Normal file
28
src/lib/components/ui/separator.tsx
Normal 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 }
|
||||||
13
src/lib/components/ui/skeleton.tsx
Normal file
13
src/lib/components/ui/skeleton.tsx
Normal 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 }
|
||||||
|
|
@ -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 { join } from "node:path";
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
|
@ -30,14 +30,22 @@ describe("presentation content boundary", () => {
|
||||||
|
|
||||||
expect(forbiddenTerms.filter((term) => source.includes(term))).toEqual([]);
|
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 {
|
function readPresentationSource(path: string): string {
|
||||||
|
if (!existsSync(path)) return "";
|
||||||
|
|
||||||
const stats = statSync(path);
|
const stats = statSync(path);
|
||||||
if (stats.isFile()) {
|
if (stats.isFile()) {
|
||||||
if (path.endsWith(".test.ts")) return "";
|
if (path.endsWith(".test.ts")) return "";
|
||||||
if (path.includes(`${join("src", "lib", "ui", "stories")}${"/"}`)) 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");
|
return readFileSync(path, "utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,3 +53,10 @@ function readPresentationSource(path: string): string {
|
||||||
.map((entry) => readPresentationSource(join(path, entry)))
|
.map((entry) => readPresentationSource(join(path, entry)))
|
||||||
.join("\n");
|
.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));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
|
||||||
11
src/lib/ui/components/Badge.tsx
Normal file
11
src/lib/ui/components/Badge.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
39
src/lib/ui/components/Button.tsx
Normal file
39
src/lib/ui/components/Button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
26
src/lib/ui/components/CornerBracketFrame.tsx
Normal file
26
src/lib/ui/components/CornerBracketFrame.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
48
src/lib/ui/components/DashboardFrame.tsx
Normal file
48
src/lib/ui/components/DashboardFrame.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
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";
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
30
src/lib/ui/components/DashboardHeader.tsx
Normal file
30
src/lib/ui/components/DashboardHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
24
src/lib/ui/components/DiagonalStripeField.tsx
Normal file
24
src/lib/ui/components/DiagonalStripeField.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
42
src/lib/ui/components/FooterCell.tsx
Normal file
42
src/lib/ui/components/FooterCell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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} />
|
|
||||||
6
src/lib/ui/components/FooterStatusCell.tsx
Normal file
6
src/lib/ui/components/FooterStatusCell.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import type { UiStatusItem } from "../types";
|
||||||
|
import { FooterCell } from "./FooterCell";
|
||||||
|
|
||||||
|
export function FooterStatusCell({ item }: { item: UiStatusItem }) {
|
||||||
|
return <FooterCell item={item} />;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
13
src/lib/ui/components/GridFrame.tsx
Normal file
13
src/lib/ui/components/GridFrame.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
33
src/lib/ui/components/IconButton.tsx
Normal file
33
src/lib/ui/components/IconButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
21
src/lib/ui/components/IconGlyph.tsx
Normal file
21
src/lib/ui/components/IconGlyph.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
114
src/lib/ui/components/LineChart.tsx
Normal file
114
src/lib/ui/components/LineChart.tsx
Normal 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";
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
32
src/lib/ui/components/ModuleCard.tsx
Normal file
32
src/lib/ui/components/ModuleCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
19
src/lib/ui/components/Panel.tsx
Normal file
19
src/lib/ui/components/Panel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
34
src/lib/ui/components/ProgressMeter.tsx
Normal file
34
src/lib/ui/components/ProgressMeter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
21
src/lib/ui/components/ScanlineField.tsx
Normal file
21
src/lib/ui/components/ScanlineField.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
19
src/lib/ui/components/Separator.tsx
Normal file
19
src/lib/ui/components/Separator.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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} />
|
|
||||||
6
src/lib/ui/components/ServiceGroupPanel.tsx
Normal file
6
src/lib/ui/components/ServiceGroupPanel.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import type { UiServiceGroup } from "../types";
|
||||||
|
import { ServicePanel } from "./ServicePanel";
|
||||||
|
|
||||||
|
export function ServiceGroupPanel({ group }: { group: UiServiceGroup }) {
|
||||||
|
return <ServicePanel group={group} />;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
27
src/lib/ui/components/ServicePanel.tsx
Normal file
27
src/lib/ui/components/ServicePanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
50
src/lib/ui/components/ServiceRow.tsx
Normal file
50
src/lib/ui/components/ServiceRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
26
src/lib/ui/components/SignalTrace.tsx
Normal file
26
src/lib/ui/components/SignalTrace.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
37
src/lib/ui/components/Sparkline.tsx
Normal file
37
src/lib/ui/components/Sparkline.tsx
Normal 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(" ");
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
14
src/lib/ui/components/StatusBadge.tsx
Normal file
14
src/lib/ui/components/StatusBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
18
src/lib/ui/components/StatusStrip.tsx
Normal file
18
src/lib/ui/components/StatusStrip.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
26
src/lib/ui/components/SystemState.tsx
Normal file
26
src/lib/ui/components/SystemState.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
51
src/lib/ui/components/TelemetryCard.tsx
Normal file
51
src/lib/ui/components/TelemetryCard.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
16
src/lib/ui/components/TelemetryGrid.tsx
Normal file
16
src/lib/ui/components/TelemetryGrid.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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} />
|
|
||||||
6
src/lib/ui/components/TelemetryStrip.tsx
Normal file
6
src/lib/ui/components/TelemetryStrip.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import type { UiTelemetryCard } from "../types";
|
||||||
|
import { TelemetryGrid } from "./TelemetryGrid";
|
||||||
|
|
||||||
|
export function TelemetryStrip({ cards }: { cards: UiTelemetryCard[] }) {
|
||||||
|
return <TelemetryGrid cards={cards} />;
|
||||||
|
}
|
||||||
|
|
@ -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} />
|
|
||||||
6
src/lib/ui/components/WeatherModule.tsx
Normal file
6
src/lib/ui/components/WeatherModule.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import type { UiModuleBlock } from "../types";
|
||||||
|
import { ModuleCard } from "./ModuleCard";
|
||||||
|
|
||||||
|
export function WeatherModule({ module }: { module: UiModuleBlock }) {
|
||||||
|
return <ModuleCard module={module} />;
|
||||||
|
}
|
||||||
|
|
@ -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\"");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
147
src/lib/ui/components/render.test.tsx
Normal file
147
src/lib/ui/components/render.test.tsx
Normal 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\"");
|
||||||
|
});
|
||||||
|
});
|
||||||
655
src/lib/ui/components/styles.css
Normal file
655
src/lib/ui/components/styles.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,32 +1,32 @@
|
||||||
export { default as Badge } from "./components/Badge.svelte";
|
export { Badge } from "./components/Badge";
|
||||||
export { default as Button } from "./components/Button.svelte";
|
export { Button } from "./components/Button";
|
||||||
export { default as CornerBracketFrame } from "./components/CornerBracketFrame.svelte";
|
export { CornerBracketFrame } from "./components/CornerBracketFrame";
|
||||||
export { default as DashboardHeader } from "./components/DashboardHeader.svelte";
|
export { DashboardHeader } from "./components/DashboardHeader";
|
||||||
export { default as DashboardFrame } from "./components/DashboardFrame.svelte";
|
export { DashboardFrame } from "./components/DashboardFrame";
|
||||||
export { default as DiagonalStripeField } from "./components/DiagonalStripeField.svelte";
|
export { DiagonalStripeField } from "./components/DiagonalStripeField";
|
||||||
export { default as FooterCell } from "./components/FooterCell.svelte";
|
export { FooterCell } from "./components/FooterCell";
|
||||||
export { default as FooterStatusCell } from "./components/FooterStatusCell.svelte";
|
export { FooterStatusCell } from "./components/FooterStatusCell";
|
||||||
export { default as GridFrame } from "./components/GridFrame.svelte";
|
export { GridFrame } from "./components/GridFrame";
|
||||||
export { default as IconGlyph } from "./components/IconGlyph.svelte";
|
export { IconGlyph } from "./components/IconGlyph";
|
||||||
export { default as IconButton } from "./components/IconButton.svelte";
|
export { IconButton } from "./components/IconButton";
|
||||||
export { default as LineChart } from "./components/LineChart.svelte";
|
export { LineChart } from "./components/LineChart";
|
||||||
export { default as ModuleCard } from "./components/ModuleCard.svelte";
|
export { ModuleCard } from "./components/ModuleCard";
|
||||||
export { default as Panel } from "./components/Panel.svelte";
|
export { Panel } from "./components/Panel";
|
||||||
export { default as ProgressMeter } from "./components/ProgressMeter.svelte";
|
export { ProgressMeter } from "./components/ProgressMeter";
|
||||||
export { default as Separator } from "./components/Separator.svelte";
|
export { Separator } from "./components/Separator";
|
||||||
export { default as ServiceGroupPanel } from "./components/ServiceGroupPanel.svelte";
|
export { ServiceGroupPanel } from "./components/ServiceGroupPanel";
|
||||||
export { default as ServicePanel } from "./components/ServicePanel.svelte";
|
export { ServicePanel } from "./components/ServicePanel";
|
||||||
export { default as ServiceRow } from "./components/ServiceRow.svelte";
|
export { ServiceRow } from "./components/ServiceRow";
|
||||||
export { default as ScanlineField } from "./components/ScanlineField.svelte";
|
export { ScanlineField } from "./components/ScanlineField";
|
||||||
export { default as SignalTrace } from "./components/SignalTrace.svelte";
|
export { SignalTrace } from "./components/SignalTrace";
|
||||||
export { default as Sparkline } from "./components/Sparkline.svelte";
|
export { Sparkline } from "./components/Sparkline";
|
||||||
export { default as StatusBadge } from "./components/StatusBadge.svelte";
|
export { StatusBadge } from "./components/StatusBadge";
|
||||||
export { default as StatusStrip } from "./components/StatusStrip.svelte";
|
export { StatusStrip } from "./components/StatusStrip";
|
||||||
export { default as SystemState } from "./components/SystemState.svelte";
|
export { SystemState } from "./components/SystemState";
|
||||||
export { default as TelemetryCard } from "./components/TelemetryCard.svelte";
|
export { TelemetryCard } from "./components/TelemetryCard";
|
||||||
export { default as TelemetryGrid } from "./components/TelemetryGrid.svelte";
|
export { TelemetryGrid } from "./components/TelemetryGrid";
|
||||||
export { default as TelemetryStrip } from "./components/TelemetryStrip.svelte";
|
export { TelemetryStrip } from "./components/TelemetryStrip";
|
||||||
export { default as WeatherModule } from "./components/WeatherModule.svelte";
|
export { WeatherModule } from "./components/WeatherModule";
|
||||||
export { dashboardPreviewFixtures } from "./fixtures";
|
export { dashboardPreviewFixtures } from "./fixtures";
|
||||||
export { dashboardDocumentToUiDashboard } from "./model-renderer";
|
export { dashboardDocumentToUiDashboard } from "./model-renderer";
|
||||||
export type {
|
export type {
|
||||||
|
|
|
||||||
|
|
@ -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" }}
|
|
||||||
/>
|
|
||||||
16
src/lib/ui/stories/Badge.stories.tsx
Normal file
16
src/lib/ui/stories/Badge.stories.tsx
Normal 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 = {};
|
||||||
|
|
@ -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" }} />
|
|
||||||
21
src/lib/ui/stories/Button.stories.tsx
Normal file
21
src/lib/ui/stories/Button.stories.tsx
Normal 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",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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>
|
|
||||||
16
src/lib/ui/stories/CornerBracketFrame.stories.tsx
Normal file
16
src/lib/ui/stories/CornerBracketFrame.stories.tsx
Normal 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 = {};
|
||||||
|
|
@ -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 }} />
|
|
||||||
21
src/lib/ui/stories/DashboardFrame.stories.tsx
Normal file
21
src/lib/ui/stories/DashboardFrame.stories.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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" }} />
|
|
||||||
19
src/lib/ui/stories/DashboardHeader.stories.tsx
Normal file
19
src/lib/ui/stories/DashboardHeader.stories.tsx
Normal 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 = {};
|
||||||
|
|
@ -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>
|
|
||||||
16
src/lib/ui/stories/DashboardOnePager.stories.tsx
Normal file
16
src/lib/ui/stories/DashboardOnePager.stories.tsx
Normal 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 = {};
|
||||||
|
|
@ -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>
|
|
||||||
16
src/lib/ui/stories/DiagonalStripeField.stories.tsx
Normal file
16
src/lib/ui/stories/DiagonalStripeField.stories.tsx
Normal 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 = {};
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue