Compare commits
No commits in common. "2a1d94195e12f20341e5bae66730b52833943779" and "23c8f0964c3c21110093d0b4561815a4c544984e" have entirely different histories.
2a1d94195e
...
23c8f0964c
101 changed files with 4501 additions and 135 deletions
4
.gitmodules
vendored
4
.gitmodules
vendored
|
|
@ -1,4 +0,0 @@
|
|||
[submodule "packages/ui"]
|
||||
path = packages/ui
|
||||
url = ssh://git@git.dimensionlab.net/vince/dimensionlab-ui.git
|
||||
branch = main
|
||||
|
|
@ -15,16 +15,14 @@ validated dashboard model state inside the web app.
|
|||
persistence, Playwright e2e checks, and container build.
|
||||
- `packages/dashboard-model`: reusable dashboard schema, validation, and
|
||||
generic model fixtures shared by apps and tooling.
|
||||
- `packages/ui`: Git submodule for the reusable dashboard React components,
|
||||
design tokens, shadcn/radix primitives, generic fixtures, and Storybook.
|
||||
Component source is grouped under `foundation`, `frames`, `operations`, and
|
||||
`telemetry` domains.
|
||||
- `packages/ui`: reusable dashboard React components, design tokens,
|
||||
shadcn/radix primitives, generic fixtures, and Storybook. Component source is
|
||||
grouped under `foundation`, `frames`, `operations`, and `telemetry` domains.
|
||||
- `docs/superpowers`: migration specs and execution plans used for this repo.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
git submodule update --init --recursive
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
|
|
@ -129,7 +127,6 @@ The production build emits Vite client assets under `apps/web/dist/` and a Bun
|
|||
server entry at `apps/web/build/index.js`. A minimal deployment flow is:
|
||||
|
||||
```sh
|
||||
git submodule update --init --recursive
|
||||
bun install --frozen-lockfile
|
||||
bun run build
|
||||
cd apps/web
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { renderToString } from "react-dom/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures";
|
||||
import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab";
|
||||
import { AppStateView, dashboardHydrationTiles } from "./App";
|
||||
import { AppStateView } from "./App";
|
||||
|
||||
describe("React app dashboard state view", () => {
|
||||
test("renders loading dashboard state", () => {
|
||||
|
|
@ -55,7 +54,7 @@ describe("React app dashboard state view", () => {
|
|||
}}
|
||||
hydratingItemIds={new Set([
|
||||
"telemetry:service-uptime",
|
||||
"service:core-services:identity",
|
||||
"service:identity",
|
||||
"module:ambient",
|
||||
"status:runtime:status",
|
||||
])}
|
||||
|
|
@ -74,12 +73,4 @@ describe("React app dashboard state view", () => {
|
|||
expect(html).toContain('data-severity="loading" data-model-id="ambient"');
|
||||
expect(html).toContain('data-severity="loading" data-model-id="runtime:status"');
|
||||
});
|
||||
|
||||
test("hydrates every status cell from the Dimension Lab shell", () => {
|
||||
expect(dashboardHydrationTiles(dimensionLabDashboardFixture)).toContainEqual({
|
||||
kind: "status",
|
||||
stripId: "footer-status",
|
||||
id: "auto-refresh",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import {
|
|||
|
||||
type DashboardTileReference =
|
||||
| { kind: "telemetry"; id: string }
|
||||
| { kind: "service"; groupId: string; id: string }
|
||||
| { kind: "service"; id: string }
|
||||
| { kind: "module"; id: string }
|
||||
| { kind: "status"; stripId: string; id: string };
|
||||
|
||||
|
|
@ -295,7 +295,7 @@ function markHydratingItems(
|
|||
serviceGroups: dashboard.serviceGroups.map((group) => ({
|
||||
...group,
|
||||
services: group.services.map((service) =>
|
||||
hydratingItemIds.has(`service:${group.id}:${service.id}`)
|
||||
hydratingItemIds.has(`service:${service.id}`)
|
||||
? {
|
||||
...service,
|
||||
severity: "loading",
|
||||
|
|
@ -325,20 +325,14 @@ function markHydratingItems(
|
|||
};
|
||||
}
|
||||
|
||||
export function dashboardHydrationTiles(
|
||||
document: DashboardDocument,
|
||||
): DashboardTileReference[] {
|
||||
function dashboardHydrationTiles(document: DashboardDocument): DashboardTileReference[] {
|
||||
const telemetry = document.telemetry
|
||||
.filter((card) => card.datasource?.type === "external")
|
||||
.map((card): DashboardTileReference => ({ kind: "telemetry", id: card.id }));
|
||||
const services = document.serviceGroups.flatMap((group) =>
|
||||
group.services
|
||||
.filter((service) => service.datasource?.type === "external")
|
||||
.map((service): DashboardTileReference => ({
|
||||
kind: "service",
|
||||
groupId: group.id,
|
||||
id: service.id,
|
||||
})),
|
||||
.map((service): DashboardTileReference => ({ kind: "service", id: service.id })),
|
||||
);
|
||||
const modules = (document.modules || [])
|
||||
.filter((module) =>
|
||||
|
|
@ -348,6 +342,7 @@ export function dashboardHydrationTiles(
|
|||
.map((module): DashboardTileReference => ({ kind: "module", id: module.id }));
|
||||
const status = document.statusStrips.flatMap((strip) =>
|
||||
strip.items
|
||||
.filter((item) => item.id !== "auto-refresh")
|
||||
.map((item): DashboardTileReference => ({
|
||||
kind: "status",
|
||||
stripId: strip.id,
|
||||
|
|
@ -359,17 +354,15 @@ export function dashboardHydrationTiles(
|
|||
}
|
||||
|
||||
function dashboardTileKey(tile: DashboardTileReference): string {
|
||||
if (tile.kind === "status") return `${tile.kind}:${tile.stripId}:${tile.id}`;
|
||||
if (tile.kind === "service") return `${tile.kind}:${tile.groupId}:${tile.id}`;
|
||||
return `${tile.kind}:${tile.id}`;
|
||||
return tile.kind === "status"
|
||||
? `${tile.kind}:${tile.stripId}:${tile.id}`
|
||||
: `${tile.kind}:${tile.id}`;
|
||||
}
|
||||
|
||||
function dashboardTileUrl(tile: DashboardTileReference): string {
|
||||
const parts = tile.kind === "status"
|
||||
? ["api", "dashboard", "tile", tile.kind, tile.stripId, tile.id]
|
||||
: tile.kind === "service"
|
||||
? ["api", "dashboard", "tile", tile.kind, tile.groupId, tile.id]
|
||||
: ["api", "dashboard", "tile", tile.kind, tile.id];
|
||||
: ["api", "dashboard", "tile", tile.kind, tile.id];
|
||||
return `/${parts.map(encodeURIComponent).join("/")}`;
|
||||
}
|
||||
|
||||
|
|
@ -387,16 +380,13 @@ function applyDashboardTile(
|
|||
}
|
||||
|
||||
if (response.tile.kind === "service") {
|
||||
const tile = response.tile;
|
||||
return {
|
||||
...document,
|
||||
serviceGroups: document.serviceGroups.map((group) => ({
|
||||
...group,
|
||||
services: group.id === tile.groupId
|
||||
? group.services.map((service) =>
|
||||
service.id === tile.id ? response.item as ServiceEntry : service,
|
||||
)
|
||||
: group.services,
|
||||
services: group.services.map((service) =>
|
||||
service.id === response.tile.id ? response.item as ServiceEntry : service,
|
||||
),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import type {
|
|||
|
||||
export type DashboardTileReference =
|
||||
| { kind: "telemetry"; id: string }
|
||||
| { kind: "service"; groupId: string; id: string }
|
||||
| { kind: "service"; id: string }
|
||||
| { kind: "module"; id: string }
|
||||
| { kind: "status"; stripId: string; id: string };
|
||||
|
||||
|
|
@ -32,11 +32,6 @@ export type DashboardTileResolution =
|
|||
state: "not_found";
|
||||
tile: DashboardTileReference;
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
state: "disabled";
|
||||
tile: DashboardTileReference;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export interface DatasourceResolutionOptions {
|
||||
|
|
@ -95,8 +90,8 @@ export async function resolveDashboardTile(
|
|||
|
||||
if (tile.kind === "service") {
|
||||
const service = document.serviceGroups
|
||||
.find((group) => group.id === tile.groupId)
|
||||
?.services.find((item) => item.id === tile.id);
|
||||
.flatMap((group) => group.services)
|
||||
.find((item) => item.id === tile.id);
|
||||
if (!service) return missingTile(tile);
|
||||
|
||||
return {
|
||||
|
|
@ -837,7 +832,7 @@ function missingTile(tile: DashboardTileReference): DashboardTileResolution {
|
|||
}
|
||||
|
||||
function tileKey(tile: DashboardTileReference): string {
|
||||
if (tile.kind === "status") return `${tile.kind}:${tile.stripId}:${tile.id}`;
|
||||
if (tile.kind === "service") return `${tile.kind}:${tile.groupId}:${tile.id}`;
|
||||
return `${tile.kind}:${tile.id}`;
|
||||
return tile.kind === "status"
|
||||
? `${tile.kind}:${tile.stripId}:${tile.id}`
|
||||
: `${tile.kind}:${tile.id}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,65 +113,6 @@ describe("dashboard API route", () => {
|
|||
expect.objectContaining({ cache: "no-store" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("hydrates a service tile with its service group identity", async () => {
|
||||
const fetch = vi.fn(async () =>
|
||||
jsonResponse({
|
||||
status: "UP",
|
||||
ping: 42,
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await loadDashboardTileResponse(
|
||||
{ kind: "service", groupId: "essentials", id: "vaultwarden" },
|
||||
{
|
||||
fetch,
|
||||
refreshSeedDocument: true,
|
||||
seedIfEmpty: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.state).toBe("ready");
|
||||
if (response.state !== "ready") throw new Error("expected ready tile");
|
||||
expect(response.tile).toEqual({
|
||||
kind: "service",
|
||||
groupId: "essentials",
|
||||
id: "vaultwarden",
|
||||
});
|
||||
expect(response.item).toMatchObject({
|
||||
id: "vaultwarden",
|
||||
severity: "ok",
|
||||
detail: "42 ms",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not hydrate tile routes when live datasources are disabled", async () => {
|
||||
const previous = process.env.DISABLE_LIVE_DATASOURCES;
|
||||
process.env.DISABLE_LIVE_DATASOURCES = "1";
|
||||
const fetch = vi.spyOn(globalThis, "fetch").mockRejectedValue(
|
||||
new Error("live datasource fetch should not run when disabled"),
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await loadDashboardTileResponse(
|
||||
{ kind: "telemetry", id: "infra-ram" },
|
||||
{
|
||||
refreshSeedDocument: true,
|
||||
seedIfEmpty: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.state).toBe("disabled");
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fetch.mockRestore();
|
||||
if (previous === undefined) {
|
||||
delete process.env.DISABLE_LIVE_DATASOURCES;
|
||||
} else {
|
||||
process.env.DISABLE_LIVE_DATASOURCES = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function jsonResponse(payload: unknown): Response {
|
||||
|
|
|
|||
|
|
@ -63,17 +63,6 @@ export async function loadDashboardTileResponse(
|
|||
tile: DashboardTileReference,
|
||||
options: LoadDashboardResponseOptions = {},
|
||||
): Promise<DashboardTileResolution> {
|
||||
if (
|
||||
options.disableLiveDatasources ||
|
||||
process.env.DISABLE_LIVE_DATASOURCES === "1"
|
||||
) {
|
||||
return {
|
||||
state: "disabled",
|
||||
tile,
|
||||
message: "Live datasource hydration is disabled.",
|
||||
};
|
||||
}
|
||||
|
||||
const dashboard = loadDashboardRuntime(undefined, {
|
||||
refreshSeedDocument: options.refreshSeedDocument ?? true,
|
||||
seedIfEmpty: options.seedIfEmpty ?? true,
|
||||
|
|
@ -106,7 +95,7 @@ export async function handleDashboardTileRoute(pathname: string): Promise<Respon
|
|||
|
||||
const response = await loadDashboardTileResponse(tile);
|
||||
return Response.json(response, {
|
||||
status: response.state === "not_found" ? 404 : 200,
|
||||
status: response.state === "ready" ? 200 : 404,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -118,18 +107,10 @@ function parseDashboardTilePath(pathname: string): DashboardTileReference | null
|
|||
}
|
||||
|
||||
const id = decodeURIComponent(firstId);
|
||||
if (kind === "telemetry" || kind === "module") {
|
||||
if (kind === "telemetry" || kind === "service" || kind === "module") {
|
||||
return { kind, id };
|
||||
}
|
||||
|
||||
if (kind === "service" && secondId) {
|
||||
return {
|
||||
kind,
|
||||
groupId: id,
|
||||
id: decodeURIComponent(secondId),
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === "status" && secondId) {
|
||||
return {
|
||||
kind,
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
Subproject commit a7a472083555152b8e1a2dc018d3be5b3b10d60b
|
||||
15
packages/ui/.storybook/main.ts
Normal file
15
packages/ui/.storybook/main.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../src/**/*.stories.@(js|ts|tsx)"],
|
||||
addons: [
|
||||
"@storybook/addon-a11y",
|
||||
"@storybook/addon-vitest",
|
||||
],
|
||||
framework: {
|
||||
name: "@storybook/react-vite",
|
||||
options: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
51
packages/ui/.storybook/preview.ts
Normal file
51
packages/ui/.storybook/preview.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import "../src/styles.css";
|
||||
import type { Preview } from "@storybook/react-vite";
|
||||
|
||||
const preview: Preview = {
|
||||
decorators: [
|
||||
(Story, context) => {
|
||||
const theme = context.globals.theme === "light" ? "light" : "dark";
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.setAttribute("data-ui-theme", theme);
|
||||
}
|
||||
|
||||
return Story();
|
||||
},
|
||||
],
|
||||
globalTypes: {
|
||||
theme: {
|
||||
description: "Dashboard component theme",
|
||||
defaultValue: "dark",
|
||||
toolbar: {
|
||||
title: "Theme",
|
||||
icon: "circlehollow",
|
||||
items: [
|
||||
{ value: "dark", title: "Dark" },
|
||||
{ value: "light", title: "Light" },
|
||||
],
|
||||
dynamicTitle: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
backgrounds: {
|
||||
default: "canvas",
|
||||
values: [
|
||||
{ name: "canvas", value: "#0b0f0d" },
|
||||
{ name: "raised", value: "#151d18" },
|
||||
{ name: "light canvas", value: "#eef2e7" },
|
||||
{ name: "light raised", value: "#f1f5ea" },
|
||||
],
|
||||
},
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
layout: "fullscreen",
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
25
packages/ui/components.json
Normal file
25
packages/ui/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/styles.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "src/components",
|
||||
"utils": "src/utils",
|
||||
"ui": "src/primitives",
|
||||
"lib": "src",
|
||||
"hooks": "src/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
79
packages/ui/package.json
Normal file
79
packages/ui/package.json
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
{
|
||||
"name": "@dimensionlab/ui",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": ["*.css", "**/*.css"],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./foundation": {
|
||||
"types": "./dist/components/foundation/index.d.ts",
|
||||
"development": "./src/components/foundation/index.ts",
|
||||
"default": "./dist/components/foundation/index.js"
|
||||
},
|
||||
"./frames": {
|
||||
"types": "./dist/components/frames/index.d.ts",
|
||||
"development": "./src/components/frames/index.ts",
|
||||
"default": "./dist/components/frames/index.js"
|
||||
},
|
||||
"./operations": {
|
||||
"types": "./dist/components/operations/index.d.ts",
|
||||
"development": "./src/components/operations/index.ts",
|
||||
"default": "./dist/components/operations/index.js"
|
||||
},
|
||||
"./telemetry": {
|
||||
"types": "./dist/components/telemetry/index.d.ts",
|
||||
"development": "./src/components/telemetry/index.ts",
|
||||
"default": "./dist/components/telemetry/index.js"
|
||||
},
|
||||
"./styles.css": {
|
||||
"development": "./src/styles.css",
|
||||
"default": "./dist/styles.css"
|
||||
},
|
||||
"./tokens.css": {
|
||||
"development": "./src/tokens.css",
|
||||
"default": "./dist/tokens.css"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "rm -rf dist && tsc -p tsconfig.build.json && mkdir -p dist/components && cp src/styles.css dist/styles.css && cp src/tokens.css dist/tokens.css && cp src/components/styles.css dist/components/styles.css",
|
||||
"check": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest run",
|
||||
"storybook": "storybook dev -p 6006 --host 0.0.0.0",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
"@iconify/react": "^6.0.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.21.0",
|
||||
"radix-ui": "^1.6.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"uplot": "^1.6.32"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/addon-a11y": "^10.4.6",
|
||||
"@storybook/addon-vitest": "^10.4.6",
|
||||
"@storybook/react-vite": "^10.4.6",
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"bun-types": "^1.3.14",
|
||||
"storybook": "^10.4.6",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
11
packages/ui/src/components/foundation/Badge.tsx
Normal file
11
packages/ui/src/components/foundation/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} />;
|
||||
}
|
||||
39
packages/ui/src/components/foundation/Button.tsx
Normal file
39
packages/ui/src/components/foundation/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>
|
||||
);
|
||||
}
|
||||
33
packages/ui/src/components/foundation/IconButton.tsx
Normal file
33
packages/ui/src/components/foundation/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>
|
||||
);
|
||||
}
|
||||
21
packages/ui/src/components/foundation/IconGlyph.tsx
Normal file
21
packages/ui/src/components/foundation/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>
|
||||
);
|
||||
}
|
||||
34
packages/ui/src/components/foundation/ProgressMeter.tsx
Normal file
34
packages/ui/src/components/foundation/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>
|
||||
);
|
||||
}
|
||||
19
packages/ui/src/components/foundation/Separator.tsx
Normal file
19
packages/ui/src/components/foundation/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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
14
packages/ui/src/components/foundation/StatusBadge.tsx
Normal file
14
packages/ui/src/components/foundation/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>
|
||||
);
|
||||
}
|
||||
36
packages/ui/src/components/foundation/ThemeToggle.tsx
Normal file
36
packages/ui/src/components/foundation/ThemeToggle.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type { UiTheme } from "../../theme";
|
||||
import { getNextUiTheme } from "../../theme";
|
||||
import { IconGlyph } from "./IconGlyph";
|
||||
|
||||
export interface ThemeToggleProps {
|
||||
theme: UiTheme;
|
||||
onThemeChange: (theme: UiTheme) => void;
|
||||
}
|
||||
|
||||
export function ThemeToggle({ theme, onThemeChange }: ThemeToggleProps) {
|
||||
const nextTheme = getNextUiTheme(theme);
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Light theme"
|
||||
aria-pressed={theme === "light"}
|
||||
className="theme-toggle"
|
||||
data-ui-theme-current={theme}
|
||||
data-ui-theme-toggle="true"
|
||||
onClick={() => onThemeChange(nextTheme)}
|
||||
type="button"
|
||||
>
|
||||
<span className="theme-toggle__label">Theme</span>
|
||||
<span className="theme-toggle__switch" aria-hidden="true">
|
||||
<span className="theme-toggle__cell" data-active={theme === "dark"}>
|
||||
<IconGlyph name="mdi:weather-night" size="sm" />
|
||||
<span>Dark</span>
|
||||
</span>
|
||||
<span className="theme-toggle__cell" data-active={theme === "light"}>
|
||||
<IconGlyph name="mdi:white-balance-sunny" size="sm" />
|
||||
<span>Light</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
8
packages/ui/src/components/foundation/index.ts
Normal file
8
packages/ui/src/components/foundation/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export * from "./Badge";
|
||||
export * from "./Button";
|
||||
export * from "./IconButton";
|
||||
export * from "./IconGlyph";
|
||||
export * from "./ProgressMeter";
|
||||
export * from "./Separator";
|
||||
export * from "./StatusBadge";
|
||||
export * from "./ThemeToggle";
|
||||
26
packages/ui/src/components/frames/CornerBracketFrame.tsx
Normal file
26
packages/ui/src/components/frames/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>
|
||||
);
|
||||
}
|
||||
59
packages/ui/src/components/frames/DashboardFrame.tsx
Normal file
59
packages/ui/src/components/frames/DashboardFrame.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { useId } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { UiDashboardPreview } from "../../types";
|
||||
import { ModuleCard } from "./ModuleCard";
|
||||
import { ServicePanel } from "../operations/ServicePanel";
|
||||
import { StatusStrip } from "../operations/StatusStrip";
|
||||
import { TelemetryGrid } from "../telemetry/TelemetryGrid";
|
||||
|
||||
export interface DashboardFrameProps {
|
||||
actions?: ReactNode;
|
||||
dashboard: UiDashboardPreview;
|
||||
titleId?: string;
|
||||
}
|
||||
|
||||
export function DashboardFrame({
|
||||
actions,
|
||||
dashboard,
|
||||
titleId,
|
||||
}: DashboardFrameProps) {
|
||||
const generatedTitleId = useId();
|
||||
const resolvedTitleId = titleId || `${generatedTitleId}-title`;
|
||||
|
||||
return (
|
||||
<main className="dashboard-frame" aria-labelledby={resolvedTitleId}>
|
||||
<header className="dashboard-frame__header console-header">
|
||||
<div className="dashboard-frame__title">
|
||||
<div className="dashboard-frame__title-copy">
|
||||
{dashboard.eyebrow ? <p>{dashboard.eyebrow}</p> : null}
|
||||
<h1 id={resolvedTitleId}>{dashboard.title}</h1>
|
||||
{dashboard.subtitle ? <span>{dashboard.subtitle}</span> : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="dashboard-frame__actions">{actions}</div>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
30
packages/ui/src/components/frames/DashboardHeader.tsx
Normal file
30
packages/ui/src/components/frames/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>
|
||||
);
|
||||
}
|
||||
24
packages/ui/src/components/frames/DiagonalStripeField.tsx
Normal file
24
packages/ui/src/components/frames/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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
42
packages/ui/src/components/frames/FooterCell.tsx
Normal file
42
packages/ui/src/components/frames/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>
|
||||
);
|
||||
}
|
||||
6
packages/ui/src/components/frames/FooterStatusCell.tsx
Normal file
6
packages/ui/src/components/frames/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} />;
|
||||
}
|
||||
13
packages/ui/src/components/frames/GridFrame.tsx
Normal file
13
packages/ui/src/components/frames/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>
|
||||
);
|
||||
}
|
||||
32
packages/ui/src/components/frames/ModuleCard.tsx
Normal file
32
packages/ui/src/components/frames/ModuleCard.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { useId } from "react";
|
||||
import type { UiModuleBlock } from "../../types";
|
||||
import { IconGlyph } from "../foundation/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>
|
||||
);
|
||||
}
|
||||
19
packages/ui/src/components/frames/Panel.tsx
Normal file
19
packages/ui/src/components/frames/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>
|
||||
);
|
||||
}
|
||||
21
packages/ui/src/components/frames/ScanlineField.tsx
Normal file
21
packages/ui/src/components/frames/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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
10
packages/ui/src/components/frames/index.ts
Normal file
10
packages/ui/src/components/frames/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export * from "./CornerBracketFrame";
|
||||
export * from "./DashboardFrame";
|
||||
export * from "./DashboardHeader";
|
||||
export * from "./DiagonalStripeField";
|
||||
export * from "./FooterCell";
|
||||
export * from "./FooterStatusCell";
|
||||
export * from "./GridFrame";
|
||||
export * from "./ModuleCard";
|
||||
export * from "./Panel";
|
||||
export * from "./ScanlineField";
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import type { UiServiceGroup } from "../../types";
|
||||
import { ServicePanel } from "./ServicePanel";
|
||||
|
||||
export function ServiceGroupPanel({ group }: { group: UiServiceGroup }) {
|
||||
return <ServicePanel group={group} />;
|
||||
}
|
||||
27
packages/ui/src/components/operations/ServicePanel.tsx
Normal file
27
packages/ui/src/components/operations/ServicePanel.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import type { UiServiceGroup } from "../../types";
|
||||
import { Panel } from "../frames/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>
|
||||
);
|
||||
}
|
||||
56
packages/ui/src/components/operations/ServiceRow.tsx
Normal file
56
packages/ui/src/components/operations/ServiceRow.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { UiServiceRow } from "../../types";
|
||||
import { IconGlyph } from "../foundation/IconGlyph";
|
||||
import { StatusBadge } from "../foundation/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 = (
|
||||
<>
|
||||
<span className="service-row__status" aria-hidden="true" />
|
||||
<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}
|
||||
{service.link ? (
|
||||
<span className="service-row__launch" aria-hidden="true">
|
||||
<IconGlyph name="mdi:open-in-new" size="sm" />
|
||||
</span>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
23
packages/ui/src/components/operations/StatusStrip.tsx
Normal file
23
packages/ui/src/components/operations/StatusStrip.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { UiStatusItem } from "../../types";
|
||||
import { FooterCell } from "../frames/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}
|
||||
data-variant={compact ? "cluster" : "system-bus"}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<FooterCell key={item.id} item={item} />
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
26
packages/ui/src/components/operations/SystemState.tsx
Normal file
26
packages/ui/src/components/operations/SystemState.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { UiSeverity } from "../../types";
|
||||
import { IconGlyph } from "../foundation/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>
|
||||
);
|
||||
}
|
||||
6
packages/ui/src/components/operations/WeatherModule.tsx
Normal file
6
packages/ui/src/components/operations/WeatherModule.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { UiModuleBlock } from "../../types";
|
||||
import { ModuleCard } from "../frames/ModuleCard";
|
||||
|
||||
export function WeatherModule({ module }: { module: UiModuleBlock }) {
|
||||
return <ModuleCard module={module} />;
|
||||
}
|
||||
6
packages/ui/src/components/operations/index.ts
Normal file
6
packages/ui/src/components/operations/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export * from "./ServiceGroupPanel";
|
||||
export * from "./ServicePanel";
|
||||
export * from "./ServiceRow";
|
||||
export * from "./StatusStrip";
|
||||
export * from "./SystemState";
|
||||
export * from "./WeatherModule";
|
||||
204
packages/ui/src/components/render.test.tsx
Normal file
204
packages/ui/src/components/render.test.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import { renderToString } from "react-dom/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
Button,
|
||||
DashboardFrame,
|
||||
FooterCell,
|
||||
IconButton,
|
||||
ServiceRow,
|
||||
StatusStrip,
|
||||
TelemetryCard,
|
||||
TelemetryGrid,
|
||||
ThemeToggle,
|
||||
} from "../index";
|
||||
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("console-header");
|
||||
expect(body).toContain("telemetry-section");
|
||||
expect(body).toContain("6 Metrics");
|
||||
expect(body).toContain('data-variant="system-bus"');
|
||||
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(service).toContain('class="service-row__status"');
|
||||
expect(service).toContain('class="service-row__launch"');
|
||||
expect(footer).toContain("<a ");
|
||||
expect(footer).toContain("href=\"https://example.test/status\"");
|
||||
});
|
||||
|
||||
test("renders the status strip as a compact system bus", () => {
|
||||
const body = renderToString(
|
||||
<StatusStrip
|
||||
id="dashboard-status"
|
||||
items={[
|
||||
{ id: "system", label: "System", value: "Nominal", severity: "ok" },
|
||||
{ id: "refresh", label: "Refresh", value: "15s", severity: "neutral" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(body).toContain('class="status-strip"');
|
||||
expect(body).toContain('data-variant="system-bus"');
|
||||
expect(body).toContain("Nominal");
|
||||
});
|
||||
|
||||
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("renders telemetry as a framed instrument section with indexed cards", () => {
|
||||
const body = renderToString(
|
||||
<TelemetryGrid cards={dashboardPreviewFixtures.primary.telemetry.slice(0, 2)} />,
|
||||
);
|
||||
|
||||
expect(body).toContain('class="telemetry-section"');
|
||||
expect(body).toContain("Telemetry");
|
||||
expect(body).toContain("2 Metrics");
|
||||
expect(body).toContain('data-metric-index="01"');
|
||||
expect(body).toContain('class="telemetry-card__index"');
|
||||
expect(body).toContain('class="telemetry-card__source"');
|
||||
});
|
||||
|
||||
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\"");
|
||||
});
|
||||
|
||||
test("renders the theme toggle as a segmented instrument control", () => {
|
||||
const body = renderToString(
|
||||
<ThemeToggle theme="light" onThemeChange={() => undefined} />,
|
||||
);
|
||||
|
||||
expect(body).toContain('class="theme-toggle"');
|
||||
expect(body).toContain('data-ui-theme-current="light"');
|
||||
expect(body).toContain('aria-label="Light theme"');
|
||||
expect(body).toContain('aria-pressed="true"');
|
||||
expect(body).toContain('class="theme-toggle__label"');
|
||||
expect(body).toContain('class="theme-toggle__switch"');
|
||||
expect(body).toContain('data-active="true"');
|
||||
expect(body).toContain("Theme");
|
||||
expect(body).toContain("Dark");
|
||||
expect(body).toContain("Light");
|
||||
});
|
||||
});
|
||||
957
packages/ui/src/components/styles.css
Normal file
957
packages/ui/src/components/styles.css
Normal file
|
|
@ -0,0 +1,957 @@
|
|||
.dashboard-frame {
|
||||
display: grid;
|
||||
box-sizing: border-box;
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
gap: 0.45rem;
|
||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||
overflow: hidden;
|
||||
padding: clamp(0.5rem, 0.72vw, 0.74rem);
|
||||
background:
|
||||
radial-gradient(ellipse at 50% 12%, transparent 0 44%, rgba(0, 0, 0, 0.14) 100%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.025), transparent 9rem),
|
||||
var(--ui-color-frame-wash);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dashboard-frame__header,
|
||||
.dashboard-header {
|
||||
display: grid;
|
||||
gap: 0.48rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.dashboard-frame__header {
|
||||
grid-template-columns: minmax(28rem, 1fr) minmax(31rem, 0.92fr);
|
||||
min-height: 4.82rem;
|
||||
border: var(--ui-border-strong);
|
||||
background: var(--ui-color-surface-panel);
|
||||
box-shadow: var(--ui-shadow-inset-panel);
|
||||
padding: 0.48rem;
|
||||
}
|
||||
|
||||
.dashboard-frame__title {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.58rem;
|
||||
align-items: stretch;
|
||||
min-width: 0;
|
||||
min-height: 3.72rem;
|
||||
border-right: var(--ui-border);
|
||||
padding-right: 0.48rem;
|
||||
}
|
||||
|
||||
.dashboard-frame__title-copy {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-frame__actions {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.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.62rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.dashboard-frame h1,
|
||||
.dashboard-header h1 {
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: var(--ui-font-display);
|
||||
font-size: clamp(2.05rem, 3vw, 2.78rem);
|
||||
font-weight: 850;
|
||||
letter-spacing: 0;
|
||||
line-height: 0.82;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-frame__modules {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(15rem, 0.66fr) minmax(17rem, 1fr);
|
||||
justify-content: end;
|
||||
gap: 0.48rem;
|
||||
}
|
||||
|
||||
.dashboard-frame__panels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
align-content: start;
|
||||
gap: 0.45rem;
|
||||
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-section {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
border: var(--ui-border-strong);
|
||||
background: var(--ui-color-surface-panel);
|
||||
box-shadow: var(--ui-shadow-inset-panel);
|
||||
}
|
||||
|
||||
.telemetry-section__header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
min-height: 1.48rem;
|
||||
border-bottom: var(--ui-border);
|
||||
padding: 0.22rem 0.5rem;
|
||||
}
|
||||
|
||||
.telemetry-section__header p,
|
||||
.telemetry-section__header h2,
|
||||
.telemetry-section__header span {
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.telemetry-section__header p {
|
||||
color: var(--ui-color-muted);
|
||||
font-size: 0.46rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.telemetry-section__header h2 {
|
||||
color: var(--ui-color-text);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.telemetry-section__header > span {
|
||||
border: var(--ui-border);
|
||||
color: var(--ui-color-text-secondary);
|
||||
font-size: 0.5rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 0.18rem 0.36rem;
|
||||
}
|
||||
|
||||
.telemetry-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
border: 0;
|
||||
background: var(--ui-color-border-subtle);
|
||||
}
|
||||
|
||||
.telemetry-card,
|
||||
.module-card,
|
||||
.panel,
|
||||
.system-state {
|
||||
border: var(--ui-border);
|
||||
background: var(--ui-color-surface-panel);
|
||||
color: var(--ui-color-text);
|
||||
}
|
||||
|
||||
.telemetry-card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-height: 4.72rem;
|
||||
gap: 0.22rem;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--ui-color-card-gradient-start),
|
||||
var(--ui-color-card-gradient-end)
|
||||
);
|
||||
border-left: 2px solid var(--ui-color-ok);
|
||||
box-shadow: var(--ui-shadow-inset-panel);
|
||||
padding: 0.34rem 0.46rem 0.36rem;
|
||||
}
|
||||
|
||||
.telemetry-card[data-severity="warning"] {
|
||||
border-color: color-mix(in srgb, var(--ui-color-warning), transparent 42%);
|
||||
border-left-color: var(--ui-color-warning);
|
||||
}
|
||||
|
||||
.telemetry-card[data-severity="danger"] {
|
||||
border-color: color-mix(in srgb, var(--ui-color-danger), transparent 22%);
|
||||
border-left-color: var(--ui-color-danger);
|
||||
color: var(--ui-color-danger);
|
||||
}
|
||||
|
||||
.telemetry-card[data-severity="stale"],
|
||||
.telemetry-card[data-severity="unavailable"] {
|
||||
border-left-color: var(--ui-color-stale);
|
||||
color: var(--ui-color-stale);
|
||||
}
|
||||
|
||||
.telemetry-card[data-severity="loading"] {
|
||||
border-left-color: var(--ui-color-accent);
|
||||
color: var(--ui-color-accent);
|
||||
}
|
||||
|
||||
.telemetry-card:hover {
|
||||
background: var(--ui-color-surface-raised);
|
||||
}
|
||||
|
||||
.telemetry-card header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
min-width: 0;
|
||||
align-items: start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.telemetry-card header .icon-glyph {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-color: var(--ui-color-border-subtle);
|
||||
background: transparent;
|
||||
color: var(--ui-color-muted);
|
||||
}
|
||||
|
||||
.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: var(--ui-color-text-secondary);
|
||||
font-size: 0.52rem;
|
||||
font-weight: 850;
|
||||
line-height: 1.02;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.telemetry-card__index {
|
||||
display: inline-grid;
|
||||
min-width: 1.05rem;
|
||||
margin-bottom: 0.08rem;
|
||||
color: var(--ui-color-muted);
|
||||
font-size: 0.45rem;
|
||||
font-weight: 850;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.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.45rem, 2.25vw, 2.05rem);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
line-height: 0.78;
|
||||
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,
|
||||
.telemetry-card__source {
|
||||
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.2rem;
|
||||
border: var(--ui-border);
|
||||
background: var(--ui-color-surface-inset);
|
||||
}
|
||||
|
||||
.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: 0.86rem;
|
||||
color: var(--ui-color-accent);
|
||||
}
|
||||
|
||||
.line-chart::before {
|
||||
position: absolute;
|
||||
inset: 50% 0 auto;
|
||||
height: 1px;
|
||||
background: var(--ui-color-border-subtle);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.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.45rem;
|
||||
align-items: start;
|
||||
min-width: 0;
|
||||
min-height: 3.72rem;
|
||||
border: var(--ui-border);
|
||||
background: var(--ui-color-surface-module);
|
||||
box-shadow: var(--ui-shadow-inset-panel);
|
||||
padding: 0.42rem 0.48rem;
|
||||
}
|
||||
|
||||
.module-card h2 {
|
||||
color: var(--ui-color-muted);
|
||||
font-size: 0.48rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.module-card strong {
|
||||
margin-top: 0.12rem;
|
||||
font-size: clamp(1.1rem, 1.55vw, 1.56rem);
|
||||
line-height: 0.82;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.module-card p {
|
||||
margin-top: 0.18rem;
|
||||
color: var(--ui-color-muted);
|
||||
font-size: 0.45rem;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.module-card[data-severity="ok"] .icon-glyph {
|
||||
border-color: color-mix(in srgb, var(--ui-color-ok), transparent 44%);
|
||||
background: var(--ui-color-accent-soft);
|
||||
color: var(--ui-color-ok);
|
||||
}
|
||||
|
||||
.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: 0.28rem auto minmax(0, 1fr) auto auto;
|
||||
gap: 0.24rem;
|
||||
align-items: center;
|
||||
min-height: 2.08rem;
|
||||
border: 0;
|
||||
border-bottom: var(--ui-border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0.14rem 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.service-row__status {
|
||||
width: 0.26rem;
|
||||
height: 0.72rem;
|
||||
background: var(--ui-color-muted);
|
||||
}
|
||||
|
||||
.service-row[data-severity="ok"] .service-row__status {
|
||||
background: var(--ui-color-ok);
|
||||
}
|
||||
|
||||
.service-row[data-severity="warning"] {
|
||||
border-bottom-color: color-mix(in srgb, var(--ui-color-warning), transparent 54%);
|
||||
}
|
||||
|
||||
.service-row[data-severity="warning"] .service-row__status {
|
||||
background: var(--ui-color-warning);
|
||||
}
|
||||
|
||||
.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="danger"] .service-row__status,
|
||||
.service-row[data-severity="unavailable"] .service-row__status {
|
||||
background: var(--ui-color-danger);
|
||||
}
|
||||
|
||||
.service-row[data-severity="loading"] {
|
||||
border-bottom-color: color-mix(in srgb, var(--ui-color-accent), transparent 56%);
|
||||
}
|
||||
|
||||
.service-row[data-severity="loading"] .service-row__status {
|
||||
background: var(--ui-color-accent);
|
||||
}
|
||||
|
||||
.service-row:where(a):hover {
|
||||
border-bottom-color: var(--ui-color-accent);
|
||||
background: var(--ui-color-hover);
|
||||
}
|
||||
|
||||
.service-row__main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.service-row__launch {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
color: var(--ui-color-muted);
|
||||
}
|
||||
|
||||
.service-row__launch .icon-glyph {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-color: var(--ui-color-border-subtle);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-grid;
|
||||
min-height: 1.12rem;
|
||||
align-items: center;
|
||||
border: var(--ui-border);
|
||||
background: var(--ui-color-surface-inset);
|
||||
padding: 0 0.32rem;
|
||||
color: var(--ui-color-muted);
|
||||
font-size: 0.5rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
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.72rem;
|
||||
align-items: center;
|
||||
background: var(--ui-color-surface-footer);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer-cell span,
|
||||
.footer-cell strong {
|
||||
min-width: 0;
|
||||
padding: 0.29rem 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.48rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.footer-cell strong {
|
||||
color: var(--ui-color-text);
|
||||
font-size: 0.54rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.footer-cell[data-severity="ok"] strong {
|
||||
color: var(--ui-color-ok);
|
||||
}
|
||||
|
||||
.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-strong);
|
||||
background: var(--ui-color-border);
|
||||
box-shadow: var(--ui-shadow-inset-panel);
|
||||
}
|
||||
|
||||
.status-strip[data-compact="true"] {
|
||||
grid-template-columns: repeat(auto-fit, minmax(6rem, 1fr));
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
background: var(--ui-color-surface-panel);
|
||||
box-shadow: var(--ui-shadow-inset-panel);
|
||||
}
|
||||
|
||||
.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 {
|
||||
border-bottom: var(--ui-border);
|
||||
padding: 0.38rem 0.55rem 0.3rem;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
font-family: var(--ui-font-display);
|
||||
font-size: clamp(1.1rem, 1.62vw, 1.55rem);
|
||||
font-weight: 800;
|
||||
line-height: 0.9;
|
||||
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.18rem 0.32rem;
|
||||
}
|
||||
|
||||
.service-panel[data-layout="grid"] .service-row {
|
||||
min-height: 1.95rem;
|
||||
}
|
||||
|
||||
.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: var(--ui-color-surface-footer);
|
||||
color: var(--ui-color-muted);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
display: grid;
|
||||
grid-template-columns: 2.72rem minmax(0, 1fr);
|
||||
align-items: center;
|
||||
width: 9.75rem;
|
||||
min-width: 0;
|
||||
min-height: 2.18rem;
|
||||
border: var(--ui-border-strong);
|
||||
background: var(--ui-color-surface-module);
|
||||
color: var(--ui-color-text);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.55rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0;
|
||||
padding: 0.13rem;
|
||||
text-transform: uppercase;
|
||||
box-shadow: var(--ui-shadow-inset-panel);
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
border-color: var(--ui-color-accent);
|
||||
background: var(--ui-color-surface-raised);
|
||||
}
|
||||
|
||||
.theme-toggle:active {
|
||||
background: var(--ui-color-surface-inset);
|
||||
}
|
||||
|
||||
.theme-toggle__label {
|
||||
display: grid;
|
||||
align-self: stretch;
|
||||
place-items: center;
|
||||
border-right: var(--ui-border);
|
||||
color: var(--ui-color-muted);
|
||||
font-size: 0.48rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.theme-toggle__switch {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
padding-left: 0.1rem;
|
||||
}
|
||||
|
||||
.theme-toggle__cell {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
gap: 0.2rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 1.68rem;
|
||||
border: 1px solid transparent;
|
||||
color: var(--ui-color-muted);
|
||||
line-height: 1;
|
||||
padding: 0 0.22rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.theme-toggle__cell[data-active="true"] {
|
||||
border-color: var(--ui-color-accent);
|
||||
background: var(--ui-color-accent-soft);
|
||||
color: var(--ui-color-accent);
|
||||
}
|
||||
|
||||
.theme-toggle__cell > span {
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.theme-toggle:hover .theme-toggle__cell[data-active="false"] {
|
||||
border-color: var(--ui-color-line);
|
||||
color: var(--ui-color-text);
|
||||
}
|
||||
|
||||
.theme-toggle .icon-glyph {
|
||||
width: 0.82rem;
|
||||
height: 0.82rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.theme-toggle .icon-glyph svg {
|
||||
width: 0.78rem;
|
||||
height: 0.78rem;
|
||||
}
|
||||
|
||||
.icon-glyph {
|
||||
display: inline-grid;
|
||||
width: 1.55rem;
|
||||
height: 1.55rem;
|
||||
place-items: center;
|
||||
border: var(--ui-border);
|
||||
background: var(--ui-color-surface-icon);
|
||||
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: 1180px), (max-height: 840px) {
|
||||
.dashboard-frame {
|
||||
grid-template-rows: auto auto auto auto;
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.dashboard-frame__panels {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.dashboard-frame__header {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-frame__panels {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dashboard-frame__panels .service-panel[data-layout="grid"] .panel__body {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.dashboard-frame__header,
|
||||
.dashboard-frame__title,
|
||||
.dashboard-header,
|
||||
.dashboard-frame__modules,
|
||||
.dashboard-frame__panels {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-frame__header {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dashboard-frame__title {
|
||||
border-right: 0;
|
||||
border-bottom: var(--ui-border);
|
||||
padding-right: 0;
|
||||
padding-bottom: 0.48rem;
|
||||
}
|
||||
|
||||
.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: 0.28rem auto minmax(0, 1fr) auto;
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
.service-row .status-badge {
|
||||
grid-column: 3;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.service-row__launch {
|
||||
grid-column: 4;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.status-strip {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.status-strip {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
114
packages/ui/src/components/telemetry/LineChart.tsx
Normal file
114
packages/ui/src/components/telemetry/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";
|
||||
}
|
||||
26
packages/ui/src/components/telemetry/SignalTrace.tsx
Normal file
26
packages/ui/src/components/telemetry/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>
|
||||
);
|
||||
}
|
||||
37
packages/ui/src/components/telemetry/Sparkline.tsx
Normal file
37
packages/ui/src/components/telemetry/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(" ");
|
||||
}
|
||||
59
packages/ui/src/components/telemetry/TelemetryCard.tsx
Normal file
59
packages/ui/src/components/telemetry/TelemetryCard.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import type { CSSProperties } from "react";
|
||||
import { clampPercent, formatMetricValue } from "../../format";
|
||||
import type { UiTelemetryCard } from "../../types";
|
||||
import { IconGlyph } from "../foundation/IconGlyph";
|
||||
import { LineChart } from "./LineChart";
|
||||
|
||||
export interface TelemetryCardProps {
|
||||
card: UiTelemetryCard;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export function TelemetryCard({ card, index }: TelemetryCardProps) {
|
||||
const progress = resolveProgress(card);
|
||||
const value = formatMetricValue(card.value);
|
||||
const metricIndex = index ? String(index).padStart(2, "0") : undefined;
|
||||
|
||||
return (
|
||||
<article
|
||||
className="telemetry-card"
|
||||
data-severity={card.severity}
|
||||
data-model-id={card.id}
|
||||
data-metric-index={metricIndex}
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
{metricIndex ? (
|
||||
<span className="telemetry-card__index">{metricIndex}</span>
|
||||
) : null}
|
||||
<h3>{card.label}</h3>
|
||||
</div>
|
||||
{card.icon ? <IconGlyph name={card.icon} size="sm" /> : null}
|
||||
</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 className="telemetry-card__source">{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;
|
||||
}
|
||||
29
packages/ui/src/components/telemetry/TelemetryGrid.tsx
Normal file
29
packages/ui/src/components/telemetry/TelemetryGrid.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { useId } from "react";
|
||||
import type { UiTelemetryCard } from "../../types";
|
||||
import { TelemetryCard } from "./TelemetryCard";
|
||||
|
||||
export interface TelemetryGridProps {
|
||||
cards: UiTelemetryCard[];
|
||||
}
|
||||
|
||||
export function TelemetryGrid({ cards }: TelemetryGridProps) {
|
||||
const headingId = useId();
|
||||
const metricCount = `${cards.length} ${cards.length === 1 ? "Metric" : "Metrics"}`;
|
||||
|
||||
return (
|
||||
<section className="telemetry-section" aria-labelledby={headingId}>
|
||||
<header className="telemetry-section__header">
|
||||
<div>
|
||||
<p>Signal Matrix</p>
|
||||
<h2 id={headingId}>Telemetry</h2>
|
||||
</div>
|
||||
<span>{metricCount}</span>
|
||||
</header>
|
||||
<div className="telemetry-grid">
|
||||
{cards.map((card, index) => (
|
||||
<TelemetryCard key={card.id} card={card} index={index + 1} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
6
packages/ui/src/components/telemetry/TelemetryStrip.tsx
Normal file
6
packages/ui/src/components/telemetry/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} />;
|
||||
}
|
||||
6
packages/ui/src/components/telemetry/index.ts
Normal file
6
packages/ui/src/components/telemetry/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export * from "./LineChart";
|
||||
export * from "./SignalTrace";
|
||||
export * from "./Sparkline";
|
||||
export * from "./TelemetryCard";
|
||||
export * from "./TelemetryGrid";
|
||||
export * from "./TelemetryStrip";
|
||||
87
packages/ui/src/content-boundary.test.ts
Normal file
87
packages/ui/src/content-boundary.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
const root = process.cwd();
|
||||
const uiSourceRoot = existsSync(join(root, "packages/ui/src"))
|
||||
? join(root, "packages/ui/src")
|
||||
: join(root, "src");
|
||||
|
||||
const forbiddenTerms = [
|
||||
"dimensionlab",
|
||||
"dimension lab",
|
||||
"vaultwarden",
|
||||
"forgejo",
|
||||
"grafana",
|
||||
"uptime kuma",
|
||||
"prometheus",
|
||||
"backrest",
|
||||
"open webui",
|
||||
"comfyui",
|
||||
"adminer",
|
||||
"cockpit",
|
||||
"ollama",
|
||||
];
|
||||
|
||||
describe("UI package content boundary", () => {
|
||||
test("contains the reusable dashboard component inventory", () => {
|
||||
expect(existsSync(join(uiSourceRoot, "index.ts"))).toBe(true);
|
||||
expect(
|
||||
existsSync(join(uiSourceRoot, "components/frames/DashboardFrame.tsx")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
existsSync(join(uiSourceRoot, "components/foundation/ThemeToggle.tsx")),
|
||||
).toBe(true);
|
||||
expect(existsSync(join(uiSourceRoot, "styles.css"))).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps UI components grouped by domain instead of a flat bucket", () => {
|
||||
const domainFolders = ["foundation", "frames", "operations", "telemetry"];
|
||||
|
||||
for (const folder of domainFolders) {
|
||||
expect(existsSync(join(uiSourceRoot, "components", folder))).toBe(true);
|
||||
}
|
||||
|
||||
const flatComponentFiles = readdirSync(join(uiSourceRoot, "components"))
|
||||
.filter((entry) => entry.endsWith(".tsx") && !entry.endsWith(".test.tsx"))
|
||||
.sort();
|
||||
expect(flatComponentFiles).toEqual([]);
|
||||
});
|
||||
|
||||
test("keeps environment-specific content out of reusable UI source", () => {
|
||||
const source = readUiSource(uiSourceRoot).toLowerCase();
|
||||
|
||||
expect(forbiddenTerms.filter((term) => source.includes(term))).toEqual([]);
|
||||
});
|
||||
|
||||
test("does not import website runtime modules", () => {
|
||||
const source = readUiSource(uiSourceRoot);
|
||||
|
||||
expect(source).not.toMatch(
|
||||
/from ["'](?:apps\/web|\$lib\/server|\$lib\/model)/,
|
||||
);
|
||||
expect(source).not.toContain("../web/");
|
||||
});
|
||||
|
||||
test("keeps icon rendering driven by icon identifiers", () => {
|
||||
const source = readUiSource(uiSourceRoot);
|
||||
|
||||
expect(source).not.toContain("@iconify-json/");
|
||||
expect(source).not.toContain("/icons/");
|
||||
});
|
||||
});
|
||||
|
||||
function readUiSource(path: string): string {
|
||||
if (!existsSync(path)) return "";
|
||||
|
||||
const stats = statSync(path);
|
||||
if (stats.isFile()) {
|
||||
if (path.endsWith(".test.ts") || path.endsWith(".test.tsx")) return "";
|
||||
if (!/\.(tsx|ts|css)$/.test(path)) return "";
|
||||
return readFileSync(path, "utf8");
|
||||
}
|
||||
|
||||
return readdirSync(path)
|
||||
.map((entry) => readUiSource(join(path, entry)))
|
||||
.join("\n");
|
||||
}
|
||||
1
packages/ui/src/css.d.ts
vendored
Normal file
1
packages/ui/src/css.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
declare module "*.css";
|
||||
132
packages/ui/src/fixtures.ts
Normal file
132
packages/ui/src/fixtures.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import type { UiDashboardPreview } from "./types";
|
||||
|
||||
export const dashboardPreviewFixtures: Record<string, UiDashboardPreview> = {
|
||||
primary: {
|
||||
eyebrow: "Command Surface",
|
||||
title: "Operations Console",
|
||||
subtitle: "Capacity, latency, and queue health",
|
||||
telemetry: [
|
||||
metric("core-throughput", "Core Throughput", "mdi:server-network", 82, "ok"),
|
||||
metric("edge-cache", "Edge Cache", "mdi:database-clock", 67, "neutral"),
|
||||
metric("queue-depth", "Queue Depth", "mdi:tray-full", 74, "warning"),
|
||||
metric("error-budget", "Error Budget", "mdi:chart-timeline-variant", 18, "danger"),
|
||||
metric("build-latency", "Build Latency", "mdi:timer-sand", 23, "stale"),
|
||||
metric("worker-load", "Worker Load", "mdi:cpu-64-bit", 55, "ok"),
|
||||
],
|
||||
modules: [
|
||||
{
|
||||
id: "ambient-conditions",
|
||||
title: "Local Node",
|
||||
value: "21.4 C",
|
||||
detail: "clear window",
|
||||
icon: "mdi:weather-partly-cloudy",
|
||||
severity: "ok",
|
||||
},
|
||||
],
|
||||
serviceGroups: [
|
||||
{
|
||||
id: "queue-workers",
|
||||
title: "Queue Workers",
|
||||
services: [
|
||||
service("ingest", "Ingest", "Event intake pipeline", "mdi:arrow-collapse-down", "ok", "1.252 ms"),
|
||||
service("scheduler", "Scheduler", "Timed job coordinator", "mdi:calendar-clock", "warning", "1.487 ms"),
|
||||
service("archive", "Archive", "Cold storage transfer", "mdi:archive-arrow-down", "ok", "1.301 ms"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "regional-nodes",
|
||||
title: "Regional Nodes",
|
||||
services: [
|
||||
service("north", "North", "Primary traffic cell", "mdi:access-point", "ok", "899 ms"),
|
||||
service("west", "West", "Replica traffic cell", "mdi:access-point-network", "neutral", "1.118 ms"),
|
||||
service("south", "South", "Maintenance window", "mdi:wrench-clock", "stale", "paused"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "control-plane",
|
||||
title: "Control Plane",
|
||||
services: [
|
||||
service("identity", "Identity", "Access token exchange", "mdi:account-key", "ok", "721 ms"),
|
||||
service("policy", "Policy", "Rules evaluation", "mdi:shield-check", "ok", "812 ms"),
|
||||
service("audit", "Audit", "Event ledger writer", "mdi:text-box-search", "warning", "1.442 ms"),
|
||||
],
|
||||
},
|
||||
],
|
||||
statusItems: [
|
||||
{ id: "system", label: "System", value: "Nominal", severity: "ok" },
|
||||
{ id: "sync", label: "Last Sync", value: "2 minutes ago", severity: "stale" },
|
||||
{ id: "uptime", label: "Uptime", value: "14d 08h", severity: "neutral" },
|
||||
{ id: "refresh", label: "Refresh", value: "15s", severity: "neutral" },
|
||||
],
|
||||
statusStripId: "dashboard-status",
|
||||
},
|
||||
secondary: {
|
||||
eyebrow: "Support Surface",
|
||||
title: "Support Desk",
|
||||
subtitle: "Response, routing, and regional health",
|
||||
telemetry: [
|
||||
metric("response-window", "Response Window", "mdi:headset", 41, "ok"),
|
||||
metric("ticket-load", "Ticket Load", "mdi:ticket-confirmation", 68, "warning"),
|
||||
metric("handoff-drift", "Handoff Drift", "mdi:swap-horizontal", 11, "neutral"),
|
||||
metric("coverage-gap", "Coverage Gap", "mdi:map-marker-alert", 7, "danger"),
|
||||
metric("routing-warmup", "Routing Warmup", "mdi:progress-clock", 0, "loading"),
|
||||
],
|
||||
modules: [
|
||||
{
|
||||
id: "shift-state",
|
||||
title: "Shift State",
|
||||
value: "covered",
|
||||
detail: "static fixture",
|
||||
icon: "mdi:clipboard-check-outline",
|
||||
severity: "ok",
|
||||
},
|
||||
],
|
||||
serviceGroups: [
|
||||
{
|
||||
id: "regional-nodes",
|
||||
title: "Regional Nodes",
|
||||
services: [
|
||||
service("desk-a", "Desk A", "Frontline queue", "mdi:monitor-dashboard", "ok", "843 ms"),
|
||||
service("desk-b", "Desk B", "Escalation queue", "mdi:monitor-star", "warning", "1.204 ms"),
|
||||
service("desk-c", "Desk C", "Overflow queue", "mdi:monitor-off", "unavailable", "offline"),
|
||||
],
|
||||
},
|
||||
],
|
||||
statusItems: [
|
||||
{ id: "desk", label: "Desk", value: "Covered", severity: "ok" },
|
||||
{ id: "handoff", label: "Handoff", value: "Pending", severity: "warning" },
|
||||
{ id: "routing", label: "Routing", value: "Manual", severity: "neutral" },
|
||||
],
|
||||
statusStripId: "support-status",
|
||||
},
|
||||
};
|
||||
|
||||
function metric(
|
||||
id: string,
|
||||
label: string,
|
||||
icon: string,
|
||||
progress: number,
|
||||
severity: UiDashboardPreview["telemetry"][number]["severity"],
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
icon,
|
||||
progress,
|
||||
severity,
|
||||
value: { kind: "percent" as const, value: progress },
|
||||
detail: "static fixture",
|
||||
sparkline: [12, 18, 15, 28, 24, progress],
|
||||
};
|
||||
}
|
||||
|
||||
function service(
|
||||
id: string,
|
||||
label: string,
|
||||
description: string,
|
||||
icon: string,
|
||||
severity: UiDashboardPreview["serviceGroups"][number]["services"][number]["severity"],
|
||||
detail: string,
|
||||
) {
|
||||
return { id, label, description, icon, severity, detail };
|
||||
}
|
||||
43
packages/ui/src/format.ts
Normal file
43
packages/ui/src/format.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import type { UiMetricValue } from "./types";
|
||||
|
||||
const byteUnits = ["B", "KB", "MB", "GB", "TB"];
|
||||
|
||||
export function formatMetricValue(metric: UiMetricValue): string {
|
||||
if (metric.kind === "text") return String(metric.value);
|
||||
|
||||
const value = typeof metric.value === "number" ? metric.value : Number(metric.value);
|
||||
const precision = metric.precision ?? inferPrecision(value);
|
||||
const unit = metric.unit ?? defaultUnit(metric.kind);
|
||||
|
||||
if (metric.kind === "bytes") return formatBytes(value, precision);
|
||||
if (metric.kind === "percent") return `${value.toFixed(precision)}%`;
|
||||
if (metric.kind === "temperature") return `${value.toFixed(precision)}°C`;
|
||||
return `${value.toFixed(precision)}${unit ? ` ${unit}` : ""}`;
|
||||
}
|
||||
|
||||
export function clampPercent(value = 0): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
function defaultUnit(kind: UiMetricValue["kind"]): string {
|
||||
if (kind === "percent") return "%";
|
||||
if (kind === "temperature") return "C";
|
||||
if (kind === "latency") return "ms";
|
||||
return "";
|
||||
}
|
||||
|
||||
function inferPrecision(value: number): number {
|
||||
return Number.isInteger(value) ? 0 : 1;
|
||||
}
|
||||
|
||||
function formatBytes(value: number, precision: number): string {
|
||||
let size = value;
|
||||
let index = 0;
|
||||
while (size >= 1024 && index < byteUnits.length - 1) {
|
||||
size /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return `${size.toFixed(precision)} ${byteUnits[index]}`;
|
||||
}
|
||||
54
packages/ui/src/index.ts
Normal file
54
packages/ui/src/index.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
export * from "./components/foundation";
|
||||
export * from "./components/frames";
|
||||
export * from "./components/operations";
|
||||
export * from "./components/telemetry";
|
||||
export { Badge } from "./components/foundation/Badge";
|
||||
export { Button } from "./components/foundation/Button";
|
||||
export { IconGlyph } from "./components/foundation/IconGlyph";
|
||||
export { IconButton } from "./components/foundation/IconButton";
|
||||
export { ProgressMeter } from "./components/foundation/ProgressMeter";
|
||||
export { Separator } from "./components/foundation/Separator";
|
||||
export { StatusBadge } from "./components/foundation/StatusBadge";
|
||||
export { ThemeToggle } from "./components/foundation/ThemeToggle";
|
||||
export { CornerBracketFrame } from "./components/frames/CornerBracketFrame";
|
||||
export { DashboardHeader } from "./components/frames/DashboardHeader";
|
||||
export { DashboardFrame } from "./components/frames/DashboardFrame";
|
||||
export { DiagonalStripeField } from "./components/frames/DiagonalStripeField";
|
||||
export { FooterCell } from "./components/frames/FooterCell";
|
||||
export { FooterStatusCell } from "./components/frames/FooterStatusCell";
|
||||
export { GridFrame } from "./components/frames/GridFrame";
|
||||
export { ModuleCard } from "./components/frames/ModuleCard";
|
||||
export { Panel } from "./components/frames/Panel";
|
||||
export { ScanlineField } from "./components/frames/ScanlineField";
|
||||
export { ServiceGroupPanel } from "./components/operations/ServiceGroupPanel";
|
||||
export { ServicePanel } from "./components/operations/ServicePanel";
|
||||
export { ServiceRow } from "./components/operations/ServiceRow";
|
||||
export { StatusStrip } from "./components/operations/StatusStrip";
|
||||
export { SystemState } from "./components/operations/SystemState";
|
||||
export { WeatherModule } from "./components/operations/WeatherModule";
|
||||
export { LineChart } from "./components/telemetry/LineChart";
|
||||
export { SignalTrace } from "./components/telemetry/SignalTrace";
|
||||
export { Sparkline } from "./components/telemetry/Sparkline";
|
||||
export { TelemetryCard } from "./components/telemetry/TelemetryCard";
|
||||
export { TelemetryGrid } from "./components/telemetry/TelemetryGrid";
|
||||
export { TelemetryStrip } from "./components/telemetry/TelemetryStrip";
|
||||
export { dashboardPreviewFixtures } from "./fixtures";
|
||||
export {
|
||||
getNextUiTheme,
|
||||
isUiTheme,
|
||||
persistUiTheme,
|
||||
resolveInitialUiTheme,
|
||||
UI_THEME_STORAGE_KEY,
|
||||
} from "./theme";
|
||||
export type {
|
||||
UiDashboardPreview,
|
||||
UiLink,
|
||||
UiMetricValue,
|
||||
UiModuleBlock,
|
||||
UiServiceGroup,
|
||||
UiServiceRow,
|
||||
UiSeverity,
|
||||
UiStatusItem,
|
||||
UiTelemetryCard,
|
||||
} from "./types";
|
||||
export type { UiTheme } from "./theme";
|
||||
76
packages/ui/src/primitives/alert.tsx
Normal file
76
packages/ui/src/primitives/alert.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "../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
packages/ui/src/primitives/badge.tsx
Normal file
49
packages/ui/src/primitives/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 "../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
packages/ui/src/primitives/button.tsx
Normal file
67
packages/ui/src/primitives/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 "../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
packages/ui/src/primitives/card.tsx
Normal file
103
packages/ui/src/primitives/card.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "../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
packages/ui/src/primitives/progress.tsx
Normal file
29
packages/ui/src/primitives/progress.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import * as React from "react"
|
||||
import { Progress as ProgressPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "../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
packages/ui/src/primitives/separator.tsx
Normal file
28
packages/ui/src/primitives/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 "../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
packages/ui/src/primitives/skeleton.tsx
Normal file
13
packages/ui/src/primitives/skeleton.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { cn } from "../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 }
|
||||
16
packages/ui/src/stories/Badge.stories.tsx
Normal file
16
packages/ui/src/stories/Badge.stories.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Badge } from "../index";
|
||||
|
||||
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 = {};
|
||||
21
packages/ui/src/stories/Button.stories.tsx
Normal file
21
packages/ui/src/stories/Button.stories.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Button } from "../index";
|
||||
|
||||
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",
|
||||
},
|
||||
};
|
||||
16
packages/ui/src/stories/CornerBracketFrame.stories.tsx
Normal file
16
packages/ui/src/stories/CornerBracketFrame.stories.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { CornerBracketFrame } from "../index";
|
||||
|
||||
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 = {};
|
||||
21
packages/ui/src/stories/DashboardFrame.stories.tsx
Normal file
21
packages/ui/src/stories/DashboardFrame.stories.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { DashboardFrame } from "../index";
|
||||
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,
|
||||
},
|
||||
};
|
||||
19
packages/ui/src/stories/DashboardHeader.stories.tsx
Normal file
19
packages/ui/src/stories/DashboardHeader.stories.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { DashboardHeader } from "../index";
|
||||
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 = {};
|
||||
16
packages/ui/src/stories/DashboardOnePager.stories.tsx
Normal file
16
packages/ui/src/stories/DashboardOnePager.stories.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { DashboardFrame } from "../index";
|
||||
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 = {};
|
||||
16
packages/ui/src/stories/DiagonalStripeField.stories.tsx
Normal file
16
packages/ui/src/stories/DiagonalStripeField.stories.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { DiagonalStripeField } from "../index";
|
||||
|
||||
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 = {};
|
||||
21
packages/ui/src/stories/FooterCell.stories.tsx
Normal file
21
packages/ui/src/stories/FooterCell.stories.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { FooterCell } from "../index";
|
||||
import { statusItems } from "./story-data";
|
||||
|
||||
const meta = {
|
||||
title: "UI/FooterCell",
|
||||
component: FooterCell,
|
||||
args: {
|
||||
item: statusItems.ok,
|
||||
},
|
||||
} satisfies Meta<typeof FooterCell>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Operational: Story = {};
|
||||
export const Linked: Story = {
|
||||
args: {
|
||||
item: statusItems.action,
|
||||
},
|
||||
};
|
||||
16
packages/ui/src/stories/FooterStatusCell.stories.tsx
Normal file
16
packages/ui/src/stories/FooterStatusCell.stories.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { FooterStatusCell } from "../index";
|
||||
import { statusItems } from "./story-data";
|
||||
|
||||
const meta = {
|
||||
title: "UI/FooterStatusCell",
|
||||
component: FooterStatusCell,
|
||||
args: {
|
||||
item: statusItems.degraded,
|
||||
},
|
||||
} satisfies Meta<typeof FooterStatusCell>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Warning: Story = {};
|
||||
24
packages/ui/src/stories/GridFrame.stories.tsx
Normal file
24
packages/ui/src/stories/GridFrame.stories.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { GridFrame, ModuleCard } from "../index";
|
||||
import { moduleBlocks } from "./story-data";
|
||||
|
||||
const meta = {
|
||||
title: "UI/GridFrame",
|
||||
component: GridFrame,
|
||||
render: (args) => (
|
||||
<GridFrame {...args}>
|
||||
<ModuleCard module={moduleBlocks.compact} />
|
||||
<ModuleCard module={moduleBlocks.unavailable} />
|
||||
<ModuleCard module={moduleBlocks.long} />
|
||||
</GridFrame>
|
||||
),
|
||||
} satisfies Meta<typeof GridFrame>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Dense: Story = {
|
||||
args: {
|
||||
density: "dense",
|
||||
},
|
||||
};
|
||||
16
packages/ui/src/stories/IconButton.stories.tsx
Normal file
16
packages/ui/src/stories/IconButton.stories.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { IconButton } from "../index";
|
||||
|
||||
const meta = {
|
||||
title: "UI/IconButton",
|
||||
component: IconButton,
|
||||
args: {
|
||||
icon: "mdi:refresh",
|
||||
label: "Refresh status",
|
||||
},
|
||||
} satisfies Meta<typeof IconButton>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
17
packages/ui/src/stories/IconGlyph.stories.tsx
Normal file
17
packages/ui/src/stories/IconGlyph.stories.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { IconGlyph } from "../index";
|
||||
|
||||
const meta = {
|
||||
title: "UI/IconGlyph",
|
||||
component: IconGlyph,
|
||||
args: {
|
||||
name: "mdi:server-network",
|
||||
label: "Generic service",
|
||||
size: "md",
|
||||
},
|
||||
} satisfies Meta<typeof IconGlyph>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
17
packages/ui/src/stories/LineChart.stories.tsx
Normal file
17
packages/ui/src/stories/LineChart.stories.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { LineChart } from "../index";
|
||||
|
||||
const meta = {
|
||||
title: "UI/LineChart",
|
||||
component: LineChart,
|
||||
args: {
|
||||
values: [12, 18, 24, 20, 36, 44],
|
||||
severity: "ok",
|
||||
label: "Generic trend",
|
||||
},
|
||||
} satisfies Meta<typeof LineChart>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Trend: Story = {};
|
||||
21
packages/ui/src/stories/ModuleCard.stories.tsx
Normal file
21
packages/ui/src/stories/ModuleCard.stories.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ModuleCard } from "../index";
|
||||
import { moduleBlocks } from "./story-data";
|
||||
|
||||
const meta = {
|
||||
title: "UI/ModuleCard",
|
||||
component: ModuleCard,
|
||||
args: {
|
||||
module: moduleBlocks.compact,
|
||||
},
|
||||
} satisfies Meta<typeof ModuleCard>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Compact: Story = {};
|
||||
export const Long: Story = {
|
||||
args: {
|
||||
module: moduleBlocks.long,
|
||||
},
|
||||
};
|
||||
22
packages/ui/src/stories/Panel.stories.tsx
Normal file
22
packages/ui/src/stories/Panel.stories.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Panel, ServiceRow } from "../index";
|
||||
import { serviceRows } from "./story-data";
|
||||
|
||||
const meta = {
|
||||
title: "UI/Panel",
|
||||
component: Panel,
|
||||
render: (args) => (
|
||||
<Panel {...args}>
|
||||
<ServiceRow service={serviceRows.normal} />
|
||||
<ServiceRow service={serviceRows.degraded} />
|
||||
</Panel>
|
||||
),
|
||||
args: {
|
||||
title: "Generic Panel",
|
||||
},
|
||||
} satisfies Meta<typeof Panel>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Dense: Story = {};
|
||||
17
packages/ui/src/stories/ProgressMeter.stories.tsx
Normal file
17
packages/ui/src/stories/ProgressMeter.stories.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ProgressMeter } from "../index";
|
||||
|
||||
const meta = {
|
||||
title: "UI/ProgressMeter",
|
||||
component: ProgressMeter,
|
||||
args: {
|
||||
value: 72,
|
||||
severity: "ok",
|
||||
label: "Capacity",
|
||||
},
|
||||
} satisfies Meta<typeof ProgressMeter>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Filled: Story = {};
|
||||
16
packages/ui/src/stories/ScanlineField.stories.tsx
Normal file
16
packages/ui/src/stories/ScanlineField.stories.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ScanlineField } from "../index";
|
||||
|
||||
const meta = {
|
||||
title: "UI/ScanlineField",
|
||||
component: ScanlineField,
|
||||
args: {
|
||||
tone: "accent",
|
||||
intensity: "medium",
|
||||
},
|
||||
} satisfies Meta<typeof ScanlineField>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
15
packages/ui/src/stories/Separator.stories.tsx
Normal file
15
packages/ui/src/stories/Separator.stories.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Separator } from "../index";
|
||||
|
||||
const meta = {
|
||||
title: "UI/Separator",
|
||||
component: Separator,
|
||||
args: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
} satisfies Meta<typeof Separator>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Horizontal: Story = {};
|
||||
16
packages/ui/src/stories/ServiceGroupPanel.stories.tsx
Normal file
16
packages/ui/src/stories/ServiceGroupPanel.stories.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ServiceGroupPanel } from "../index";
|
||||
import { serviceGroups } from "./story-data";
|
||||
|
||||
const meta = {
|
||||
title: "UI/ServiceGroupPanel",
|
||||
component: ServiceGroupPanel,
|
||||
args: {
|
||||
group: serviceGroups.mixed,
|
||||
},
|
||||
} satisfies Meta<typeof ServiceGroupPanel>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Mixed: Story = {};
|
||||
16
packages/ui/src/stories/ServicePanel.stories.tsx
Normal file
16
packages/ui/src/stories/ServicePanel.stories.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ServicePanel } from "../index";
|
||||
import { serviceGroups } from "./story-data";
|
||||
|
||||
const meta = {
|
||||
title: "UI/ServicePanel",
|
||||
component: ServicePanel,
|
||||
args: {
|
||||
group: serviceGroups.long,
|
||||
},
|
||||
} satisfies Meta<typeof ServicePanel>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const LongList: Story = {};
|
||||
21
packages/ui/src/stories/ServiceRow.stories.tsx
Normal file
21
packages/ui/src/stories/ServiceRow.stories.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ServiceRow } from "../index";
|
||||
import { serviceRows } from "./story-data";
|
||||
|
||||
const meta = {
|
||||
title: "UI/ServiceRow",
|
||||
component: ServiceRow,
|
||||
args: {
|
||||
service: serviceRows.normal,
|
||||
},
|
||||
} satisfies Meta<typeof ServiceRow>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Normal: Story = {};
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
service: serviceRows.degraded,
|
||||
},
|
||||
};
|
||||
16
packages/ui/src/stories/SignalTrace.stories.tsx
Normal file
16
packages/ui/src/stories/SignalTrace.stories.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { SignalTrace } from "../index";
|
||||
|
||||
const meta = {
|
||||
title: "UI/SignalTrace",
|
||||
component: SignalTrace,
|
||||
args: {
|
||||
tone: "accent",
|
||||
orientation: "horizontal",
|
||||
},
|
||||
} satisfies Meta<typeof SignalTrace>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Horizontal: Story = {};
|
||||
16
packages/ui/src/stories/Sparkline.stories.tsx
Normal file
16
packages/ui/src/stories/Sparkline.stories.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Sparkline } from "../index";
|
||||
|
||||
const meta = {
|
||||
title: "UI/Sparkline",
|
||||
component: Sparkline,
|
||||
args: {
|
||||
values: [8, 14, 12, 24, 18, 30],
|
||||
severity: "ok",
|
||||
},
|
||||
} satisfies Meta<typeof Sparkline>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
16
packages/ui/src/stories/StatusBadge.stories.tsx
Normal file
16
packages/ui/src/stories/StatusBadge.stories.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { StatusBadge } from "../index";
|
||||
|
||||
const meta = {
|
||||
title: "UI/StatusBadge",
|
||||
component: StatusBadge,
|
||||
args: {
|
||||
label: "Ready",
|
||||
severity: "ok",
|
||||
},
|
||||
} satisfies Meta<typeof StatusBadge>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Ready: Story = {};
|
||||
17
packages/ui/src/stories/StatusStrip.stories.tsx
Normal file
17
packages/ui/src/stories/StatusStrip.stories.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { StatusStrip } from "../index";
|
||||
import { mixedStatusStrip } from "./story-data";
|
||||
|
||||
const meta = {
|
||||
title: "UI/StatusStrip",
|
||||
component: StatusStrip,
|
||||
args: {
|
||||
id: "generic-status",
|
||||
items: mixedStatusStrip,
|
||||
},
|
||||
} satisfies Meta<typeof StatusStrip>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Mixed: Story = {};
|
||||
18
packages/ui/src/stories/SystemState.stories.tsx
Normal file
18
packages/ui/src/stories/SystemState.stories.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { SystemState } from "../index";
|
||||
|
||||
const meta = {
|
||||
title: "UI/SystemState",
|
||||
component: SystemState,
|
||||
args: {
|
||||
title: "Loading Dashboard",
|
||||
detail: "Fetching active model",
|
||||
severity: "loading",
|
||||
icon: "mdi:progress-clock",
|
||||
},
|
||||
} satisfies Meta<typeof SystemState>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Loading: Story = {};
|
||||
21
packages/ui/src/stories/TelemetryCard.stories.tsx
Normal file
21
packages/ui/src/stories/TelemetryCard.stories.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { TelemetryCard } from "../index";
|
||||
import { telemetryCards } from "./story-data";
|
||||
|
||||
const meta = {
|
||||
title: "UI/TelemetryCard",
|
||||
component: TelemetryCard,
|
||||
args: {
|
||||
card: telemetryCards.percent,
|
||||
},
|
||||
} satisfies Meta<typeof TelemetryCard>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Percent: Story = {};
|
||||
export const Danger: Story = {
|
||||
args: {
|
||||
card: telemetryCards.danger,
|
||||
},
|
||||
};
|
||||
21
packages/ui/src/stories/TelemetryGrid.stories.tsx
Normal file
21
packages/ui/src/stories/TelemetryGrid.stories.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { TelemetryGrid } from "../index";
|
||||
import { eightTelemetryCards, sixteenTelemetryCards } from "./story-data";
|
||||
|
||||
const meta = {
|
||||
title: "UI/TelemetryGrid",
|
||||
component: TelemetryGrid,
|
||||
args: {
|
||||
cards: eightTelemetryCards,
|
||||
},
|
||||
} satisfies Meta<typeof TelemetryGrid>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const EightCards: Story = {};
|
||||
export const SixteenCards: Story = {
|
||||
args: {
|
||||
cards: sixteenTelemetryCards,
|
||||
},
|
||||
};
|
||||
16
packages/ui/src/stories/TelemetryStrip.stories.tsx
Normal file
16
packages/ui/src/stories/TelemetryStrip.stories.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { TelemetryStrip } from "../index";
|
||||
import { eightTelemetryCards } from "./story-data";
|
||||
|
||||
const meta = {
|
||||
title: "UI/TelemetryStrip",
|
||||
component: TelemetryStrip,
|
||||
args: {
|
||||
cards: eightTelemetryCards,
|
||||
},
|
||||
} satisfies Meta<typeof TelemetryStrip>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
26
packages/ui/src/stories/ThemeToggle.stories.tsx
Normal file
26
packages/ui/src/stories/ThemeToggle.stories.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ThemeToggle } from "../index";
|
||||
|
||||
const meta = {
|
||||
title: "UI/ThemeToggle",
|
||||
component: ThemeToggle,
|
||||
args: {
|
||||
onThemeChange: () => undefined,
|
||||
theme: "dark",
|
||||
},
|
||||
} satisfies Meta<typeof ThemeToggle>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Dark: Story = {};
|
||||
|
||||
export const Light: Story = {
|
||||
args: {
|
||||
theme: "light",
|
||||
},
|
||||
globals: {
|
||||
theme: "light",
|
||||
},
|
||||
};
|
||||
16
packages/ui/src/stories/WeatherModule.stories.tsx
Normal file
16
packages/ui/src/stories/WeatherModule.stories.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { WeatherModule } from "../index";
|
||||
import { moduleBlocks } from "./story-data";
|
||||
|
||||
const meta = {
|
||||
title: "UI/WeatherModule",
|
||||
component: WeatherModule,
|
||||
args: {
|
||||
module: moduleBlocks.compact,
|
||||
},
|
||||
} satisfies Meta<typeof WeatherModule>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
258
packages/ui/src/stories/story-data.ts
Normal file
258
packages/ui/src/stories/story-data.ts
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import { dashboardPreviewFixtures } from "../fixtures";
|
||||
import type {
|
||||
UiDashboardPreview,
|
||||
UiModuleBlock,
|
||||
UiServiceGroup,
|
||||
UiServiceRow,
|
||||
UiSeverity,
|
||||
UiStatusItem,
|
||||
UiTelemetryCard,
|
||||
} from "../types";
|
||||
|
||||
export const primaryDashboard = dashboardPreviewFixtures.primary;
|
||||
export const secondaryDashboard = dashboardPreviewFixtures.secondary;
|
||||
|
||||
export const emptyDashboard: UiDashboardPreview = {
|
||||
eyebrow: "Preview Surface",
|
||||
title: "Empty Console",
|
||||
subtitle: "No records available",
|
||||
telemetry: [],
|
||||
modules: [],
|
||||
serviceGroups: [],
|
||||
statusItems: [],
|
||||
};
|
||||
|
||||
export const moduleBlocks: Record<string, UiModuleBlock> = {
|
||||
compact: {
|
||||
id: "module-compact",
|
||||
title: "Local Node",
|
||||
value: "21.4 C",
|
||||
detail: "static sample",
|
||||
icon: "mdi:weather-partly-cloudy",
|
||||
severity: "ok",
|
||||
},
|
||||
unavailable: {
|
||||
id: "module-unavailable",
|
||||
title: "External Signal",
|
||||
value: "n/a",
|
||||
detail: "fallback value",
|
||||
icon: "mdi:cloud-off-outline",
|
||||
severity: "unavailable",
|
||||
},
|
||||
long: {
|
||||
id: "module-long",
|
||||
title: "Regional Aggregate Signal",
|
||||
value: "manual review queued",
|
||||
detail: "long generic label wraps without layout shift",
|
||||
icon: "mdi:map-marker-radius-outline",
|
||||
severity: "warning",
|
||||
},
|
||||
};
|
||||
|
||||
export const telemetryCards: Record<string, UiTelemetryCard> = {
|
||||
percent: {
|
||||
id: "telemetry-percent",
|
||||
label: "Capacity Used",
|
||||
value: { kind: "percent", value: 82 },
|
||||
progress: 82,
|
||||
icon: "mdi:gauge",
|
||||
severity: "ok",
|
||||
detail: "static sample",
|
||||
sparkline: [24, 36, 28, 44, 52, 82],
|
||||
},
|
||||
bytes: {
|
||||
id: "telemetry-bytes",
|
||||
label: "Data Volume",
|
||||
value: { kind: "bytes", value: 982451653, precision: 1 },
|
||||
icon: "mdi:database",
|
||||
severity: "neutral",
|
||||
detail: "static sample",
|
||||
sparkline: [8, 12, 20, 18, 24, 29],
|
||||
},
|
||||
temperature: {
|
||||
id: "telemetry-temperature",
|
||||
label: "Node Temperature",
|
||||
value: { kind: "temperature", value: 21.4, precision: 1 },
|
||||
icon: "mdi:thermometer",
|
||||
severity: "neutral",
|
||||
detail: "static sample",
|
||||
sparkline: [18, 20, 19, 21, 20, 21.4],
|
||||
},
|
||||
latency: {
|
||||
id: "telemetry-latency",
|
||||
label: "Median Latency",
|
||||
value: { kind: "latency", value: 87 },
|
||||
icon: "mdi:timer-outline",
|
||||
severity: "warning",
|
||||
detail: "static sample",
|
||||
sparkline: [45, 51, 72, 63, 80, 87],
|
||||
},
|
||||
unavailable: {
|
||||
id: "telemetry-unavailable",
|
||||
label: "Remote Signal",
|
||||
value: { kind: "text", value: "n/a" },
|
||||
icon: "mdi:cloud-off-outline",
|
||||
severity: "unavailable",
|
||||
detail: "fallback value",
|
||||
},
|
||||
stale: {
|
||||
id: "telemetry-stale",
|
||||
label: "Sync Age",
|
||||
value: { kind: "text", value: "stale" },
|
||||
icon: "mdi:clock-alert-outline",
|
||||
severity: "stale",
|
||||
detail: "cached sample",
|
||||
sparkline: [22, 22, 22, 22, 22, 22],
|
||||
},
|
||||
danger: {
|
||||
id: "telemetry-danger",
|
||||
label: "Error Budget",
|
||||
value: { kind: "percent", value: 7 },
|
||||
progress: 7,
|
||||
icon: "mdi:alert-octagon-outline",
|
||||
severity: "danger",
|
||||
detail: "threshold breached",
|
||||
sparkline: [56, 41, 34, 20, 13, 7],
|
||||
},
|
||||
loading: {
|
||||
id: "telemetry-loading",
|
||||
label: "Pending Refresh",
|
||||
value: { kind: "text", value: "sync" },
|
||||
icon: "mdi:progress-clock",
|
||||
severity: "loading",
|
||||
detail: "waiting for update",
|
||||
},
|
||||
long: {
|
||||
id: "telemetry-long",
|
||||
label: "Very Long Generic Source Label That Must Wrap",
|
||||
value: { kind: "percent", value: 63 },
|
||||
progress: 63,
|
||||
icon: "mdi:chart-box-outline",
|
||||
severity: "neutral",
|
||||
detail: "long source label",
|
||||
sparkline: [14, 20, 28, 31, 49, 63],
|
||||
},
|
||||
};
|
||||
|
||||
export const serviceRows: Record<string, UiServiceRow> = {
|
||||
normal: row("service-normal", "Ingest", "Event intake pipeline", "mdi:arrow-collapse-down", "ok", "1.252 ms", {
|
||||
href: "#ingest",
|
||||
label: "Open ingest",
|
||||
}),
|
||||
down: row("service-down", "Replica", "Replica traffic cell", "mdi:access-point-off", "danger", "down"),
|
||||
degraded: row("service-degraded", "Scheduler", "Timed job coordinator", "mdi:calendar-clock", "warning", "1.487 ms"),
|
||||
stale: row("service-stale", "Archive", "Cold storage transfer", "mdi:archive-clock", "stale", "paused"),
|
||||
noLink: row("service-no-link", "Policy", "Rules evaluation", "mdi:shield-check", "neutral", "manual"),
|
||||
long: row(
|
||||
"service-long",
|
||||
"Long Generic Service Name That Wraps Cleanly",
|
||||
"Long generic service description with enough detail to exercise wrapping in constrained panels",
|
||||
"mdi:text-box-search-outline",
|
||||
"warning",
|
||||
"review queued",
|
||||
),
|
||||
};
|
||||
|
||||
export const serviceGroups: Record<string, UiServiceGroup> = {
|
||||
short: {
|
||||
id: "group-short",
|
||||
title: "Short List",
|
||||
services: [serviceRows.normal, serviceRows.degraded],
|
||||
},
|
||||
long: {
|
||||
id: "group-long",
|
||||
title: "Long List",
|
||||
services: [
|
||||
serviceRows.normal,
|
||||
serviceRows.degraded,
|
||||
serviceRows.stale,
|
||||
serviceRows.down,
|
||||
serviceRows.noLink,
|
||||
serviceRows.long,
|
||||
],
|
||||
},
|
||||
empty: {
|
||||
id: "group-empty",
|
||||
title: "Empty Group",
|
||||
services: [],
|
||||
},
|
||||
mixed: {
|
||||
id: "group-mixed",
|
||||
title: "Mixed Statuses",
|
||||
services: [serviceRows.normal, serviceRows.down, serviceRows.degraded, serviceRows.stale],
|
||||
summary: [
|
||||
{ id: "summary-ok", label: "Ok", value: "3", severity: "ok" },
|
||||
{ id: "summary-warn", label: "Warn", value: "1", severity: "warning" },
|
||||
{ id: "summary-down", label: "Down", value: "1", severity: "danger" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const statusItems: Record<string, UiStatusItem> = {
|
||||
ok: { id: "status-ok", label: "System", value: "Operational", severity: "ok" },
|
||||
degraded: { id: "status-degraded", label: "Routing", value: "Degraded", severity: "warning" },
|
||||
incident: { id: "status-incident", label: "Incident", value: "Active", severity: "danger" },
|
||||
syncing: { id: "status-syncing", label: "Refresh", value: "Syncing", severity: "loading" },
|
||||
stale: { id: "status-stale", label: "Last Sync", value: "18 minutes ago", severity: "stale" },
|
||||
action: {
|
||||
id: "status-action",
|
||||
label: "Action",
|
||||
value: "Inspect",
|
||||
severity: "neutral",
|
||||
link: { href: "#inspect", label: "Inspect status" },
|
||||
},
|
||||
long: {
|
||||
id: "status-long",
|
||||
label: "Long Generic Status Label",
|
||||
value: "long generic status value wraps",
|
||||
severity: "neutral",
|
||||
},
|
||||
};
|
||||
|
||||
export const eightTelemetryCards = [
|
||||
telemetryCards.percent,
|
||||
telemetryCards.bytes,
|
||||
telemetryCards.temperature,
|
||||
telemetryCards.latency,
|
||||
telemetryCards.unavailable,
|
||||
telemetryCards.stale,
|
||||
telemetryCards.danger,
|
||||
telemetryCards.loading,
|
||||
];
|
||||
|
||||
export const sixteenTelemetryCards = Array.from({ length: 16 }, (_, index) => {
|
||||
const card = eightTelemetryCards[index % eightTelemetryCards.length];
|
||||
return {
|
||||
...card,
|
||||
id: `${card.id}-${index}`,
|
||||
label: `${card.label} ${index + 1}`,
|
||||
};
|
||||
});
|
||||
|
||||
export const mixedStatusStrip = [
|
||||
statusItems.ok,
|
||||
statusItems.degraded,
|
||||
statusItems.incident,
|
||||
statusItems.syncing,
|
||||
statusItems.stale,
|
||||
];
|
||||
|
||||
export const fullCompositionDashboard: UiDashboardPreview = {
|
||||
...primaryDashboard,
|
||||
telemetry: eightTelemetryCards,
|
||||
modules: [moduleBlocks.compact, moduleBlocks.unavailable],
|
||||
serviceGroups: [serviceGroups.mixed, serviceGroups.short, serviceGroups.long],
|
||||
statusItems: mixedStatusStrip,
|
||||
};
|
||||
|
||||
function row(
|
||||
id: string,
|
||||
label: string,
|
||||
description: string,
|
||||
icon: string,
|
||||
severity: UiSeverity,
|
||||
detail: string,
|
||||
link?: UiServiceRow["link"],
|
||||
): UiServiceRow {
|
||||
return { id, label, description, icon, severity, detail, link };
|
||||
}
|
||||
309
packages/ui/src/storybook.test.ts
Normal file
309
packages/ui/src/storybook.test.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { basename, join, relative } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
const root = process.cwd();
|
||||
const packageRoot = existsSync(join(root, "packages/ui/package.json"))
|
||||
? join(root, "packages/ui")
|
||||
: root;
|
||||
const componentsDir = join(packageRoot, "src/components");
|
||||
const storiesDir = join(packageRoot, "src/stories");
|
||||
|
||||
const allowedComponentDomains = [
|
||||
"foundation",
|
||||
"frames",
|
||||
"operations",
|
||||
"telemetry",
|
||||
] as const;
|
||||
const compositionStoryFiles = ["DashboardOnePager.stories.tsx"] as const;
|
||||
|
||||
const forbiddenStoryContent = [
|
||||
"dimension lab",
|
||||
"dimensionlab",
|
||||
"vince",
|
||||
"homepage",
|
||||
] as const;
|
||||
|
||||
describe("Storybook inventory", () => {
|
||||
test("exposes scripts for local and static Storybook review", () => {
|
||||
const packageJson = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")) as {
|
||||
scripts?: Record<string, string>;
|
||||
};
|
||||
|
||||
expect(packageJson.scripts?.storybook).toBeTypeOf("string");
|
||||
expect(packageJson.scripts?.storybook).toContain("storybook dev");
|
||||
expect(packageJson.scripts?.["build-storybook"]).toBe("storybook build");
|
||||
});
|
||||
|
||||
test("has a story for every reusable dashboard UI component", () => {
|
||||
for (const component of componentInventory()) {
|
||||
expect(
|
||||
existsSync(join(storiesDir, component.storyFile)),
|
||||
`${component.storyFile} is missing`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("uses only approved component domain folders", () => {
|
||||
const actualDomains = readdirSync(componentsDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
|
||||
expect(actualDomains).toEqual([...allowedComponentDomains].sort());
|
||||
});
|
||||
|
||||
test("loads dashboard component styles through the global app stylesheet", () => {
|
||||
const packageStyles = readFileSync(join(packageRoot, "src/styles.css"), "utf8");
|
||||
const dashboardFrame = readFileSync(requiredComponentPath("DashboardFrame"), "utf8");
|
||||
|
||||
expect(packageStyles).toContain('./components/styles.css');
|
||||
expect(dashboardFrame).not.toContain('./styles.css');
|
||||
});
|
||||
|
||||
test("configures Storybook theme switching for reusable components", () => {
|
||||
const previewSource = readFileSync(join(packageRoot, ".storybook/preview.ts"), "utf8");
|
||||
|
||||
expect(previewSource).toContain("globalTypes");
|
||||
expect(previewSource).toContain("data-ui-theme");
|
||||
expect(previewSource).toContain("../src/styles.css");
|
||||
});
|
||||
|
||||
test("keeps component and story files paired as the UI inventory changes", () => {
|
||||
const componentStoryFiles = new Set(
|
||||
componentInventory().map((component) => component.storyFile),
|
||||
);
|
||||
|
||||
for (const filename of componentStoryFiles) {
|
||||
expect(existsSync(join(storiesDir, filename)), `${filename} is missing`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
const unpairedStoryFiles = storyFiles().filter(
|
||||
(filename) =>
|
||||
!componentStoryFiles.has(filename) &&
|
||||
!compositionStoryFiles.includes(
|
||||
filename as (typeof compositionStoryFiles)[number],
|
||||
),
|
||||
);
|
||||
expect(unpairedStoryFiles).toEqual([]);
|
||||
});
|
||||
|
||||
test("exports every reusable component through the package barrel", () => {
|
||||
const indexSource = readFileSync(join(packageRoot, "src/index.ts"), "utf8");
|
||||
|
||||
for (const component of componentInventory()) {
|
||||
expect(indexSource).toContain(
|
||||
`export { ${component.name} } from "${component.relativeExportPath}";`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("exposes grouped package entrypoints for each component domain", () => {
|
||||
const packageJson = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")) as {
|
||||
exports?: Record<string, WorkspacePackageExport>;
|
||||
};
|
||||
const rootIndexSource = readFileSync(join(packageRoot, "src/index.ts"), "utf8");
|
||||
const componentsByDomain = new Map<(typeof allowedComponentDomains)[number], string[]>();
|
||||
|
||||
for (const domain of allowedComponentDomains) {
|
||||
componentsByDomain.set(domain, []);
|
||||
}
|
||||
|
||||
for (const component of componentInventory()) {
|
||||
componentsByDomain.get(component.domain)?.push(component.name);
|
||||
}
|
||||
|
||||
for (const domain of allowedComponentDomains) {
|
||||
const domainIndexPath = join(componentsDir, domain, "index.ts");
|
||||
const domainIndexSource = existsSync(domainIndexPath)
|
||||
? readFileSync(domainIndexPath, "utf8")
|
||||
: "";
|
||||
|
||||
expect(existsSync(domainIndexPath), `${domain} index is missing`).toBe(true);
|
||||
expect(rootIndexSource).toContain(`export * from "./components/${domain}";`);
|
||||
expect(packageJson.exports?.[`./${domain}`]).toMatchObject({
|
||||
types: `./dist/components/${domain}/index.d.ts`,
|
||||
development: `./src/components/${domain}/index.ts`,
|
||||
default: `./dist/components/${domain}/index.js`,
|
||||
});
|
||||
|
||||
for (const componentName of componentsByDomain.get(domain) ?? []) {
|
||||
expect(domainIndexSource).toContain(
|
||||
`export * from "./${componentName}";`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("exports component prop interfaces through grouped package entrypoints", () => {
|
||||
let propInterfaceCount = 0;
|
||||
|
||||
for (const component of componentInventory()) {
|
||||
const source = readFileSync(component.path, "utf8");
|
||||
const propTypeName = `${component.name}Props`;
|
||||
const propExportPattern = new RegExp(
|
||||
`export\\s+(interface|type)\\s+${propTypeName}\\b`,
|
||||
);
|
||||
|
||||
if (!propExportPattern.test(source)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
propInterfaceCount += 1;
|
||||
const domainIndexSource = readFileSync(
|
||||
join(componentsDir, component.domain, "index.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(domainIndexSource).toContain(
|
||||
`export * from "./${component.name}";`,
|
||||
);
|
||||
}
|
||||
|
||||
expect(propInterfaceCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("keeps Storybook fixtures generic and content-free", () => {
|
||||
const storyText = readdirSync(storiesDir)
|
||||
.filter((filename) => filename.endsWith(".tsx") || filename.endsWith(".ts"))
|
||||
.map((filename) => readFileSync(join(storiesDir, filename), "utf8").toLowerCase())
|
||||
.join("\n");
|
||||
|
||||
for (const forbidden of forbiddenStoryContent) {
|
||||
expect(storyText).not.toContain(forbidden);
|
||||
}
|
||||
});
|
||||
|
||||
test("includes style-only decorative primitives in the reusable UI inventory", () => {
|
||||
for (const component of [
|
||||
"CornerBracketFrame",
|
||||
"DiagonalStripeField",
|
||||
"ScanlineField",
|
||||
"SignalTrace",
|
||||
]) {
|
||||
expect(existsSync(requiredComponentPath(component)), `${component} is missing`).toBe(true);
|
||||
expect(
|
||||
existsSync(join(storiesDir, `${component}.stories.tsx`)),
|
||||
`${component}.stories.tsx is missing`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps decorative primitives hidden from assistive technology", () => {
|
||||
for (const component of [
|
||||
"CornerBracketFrame",
|
||||
"DiagonalStripeField",
|
||||
"ScanlineField",
|
||||
"SignalTrace",
|
||||
]) {
|
||||
const source = readFileSync(requiredComponentPath(component), "utf8");
|
||||
|
||||
expect(source).toContain('aria-hidden="true"');
|
||||
}
|
||||
});
|
||||
|
||||
test("does not add deferred form/navigation primitives", () => {
|
||||
const componentNames = new Set(
|
||||
componentInventory().map((component) => component.name),
|
||||
);
|
||||
|
||||
for (const component of ["Input", "ToggleGroup", "ScrollArea"]) {
|
||||
expect(componentNames.has(component)).toBe(false);
|
||||
expect(existsSync(join(componentsDir, `${component}.tsx`))).toBe(false);
|
||||
expect(existsSync(join(storiesDir, `${component}.stories.tsx`))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not keep legacy stories in the React Storybook inventory", () => {
|
||||
const legacyStoryExtension = [".stories", ".sve", "lte"].join("");
|
||||
const legacyStories = readdirSync(storiesDir).filter((filename) =>
|
||||
filename.endsWith(legacyStoryExtension),
|
||||
);
|
||||
|
||||
expect(legacyStories).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function requiredComponentPath(component: string): string {
|
||||
const componentPath = componentInventory().find(
|
||||
(entry) => entry.name === component,
|
||||
)?.path;
|
||||
|
||||
expect(componentPath, `${component} is missing from the UI component tree`).toBeTypeOf(
|
||||
"string",
|
||||
);
|
||||
|
||||
return componentPath as string;
|
||||
}
|
||||
|
||||
interface ComponentInventoryItem {
|
||||
domain: (typeof allowedComponentDomains)[number];
|
||||
name: string;
|
||||
path: string;
|
||||
relativeExportPath: string;
|
||||
storyFile: string;
|
||||
}
|
||||
|
||||
type WorkspacePackageExport =
|
||||
| string
|
||||
| {
|
||||
types?: string;
|
||||
development?: string;
|
||||
default?: string;
|
||||
};
|
||||
|
||||
function componentInventory(): ComponentInventoryItem[] {
|
||||
const components = allowedComponentDomains
|
||||
.flatMap((domain) => collectComponentFiles(join(componentsDir, domain)))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const names = components.map((component) => component.name);
|
||||
|
||||
expect(names).toEqual([...new Set(names)]);
|
||||
|
||||
return components;
|
||||
}
|
||||
|
||||
function collectComponentFiles(directory: string): ComponentInventoryItem[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const entryPath = join(directory, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
return collectComponentFiles(entryPath);
|
||||
}
|
||||
|
||||
if (
|
||||
!entry.isFile() ||
|
||||
!entry.name.endsWith(".tsx") ||
|
||||
entry.name.endsWith(".test.tsx")
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const name = basename(entry.name, ".tsx");
|
||||
const relativeComponentPath = relative(componentsDir, entryPath).replace(/\\/g, "/");
|
||||
const domain = relativeComponentPath.split(
|
||||
"/",
|
||||
)[0] as (typeof allowedComponentDomains)[number];
|
||||
const relativeExportPath = `./${relative(join(packageRoot, "src"), entryPath)
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/\.tsx$/, "")}`;
|
||||
|
||||
return [
|
||||
{
|
||||
domain,
|
||||
name,
|
||||
path: entryPath,
|
||||
relativeExportPath,
|
||||
storyFile: `${name}.stories.tsx`,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function storyFiles(): string[] {
|
||||
return readdirSync(storiesDir)
|
||||
.filter((filename) => filename.endsWith(".stories.tsx"))
|
||||
.sort();
|
||||
}
|
||||
4
packages/ui/src/styles.css
Normal file
4
packages/ui/src/styles.css
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
@import "@fontsource-variable/geist";
|
||||
@import "uplot/dist/uPlot.min.css";
|
||||
@import "./tokens.css";
|
||||
@import "./components/styles.css";
|
||||
56
packages/ui/src/theme.test.ts
Normal file
56
packages/ui/src/theme.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
getNextUiTheme,
|
||||
isUiTheme,
|
||||
persistUiTheme,
|
||||
resolveInitialUiTheme,
|
||||
UI_THEME_STORAGE_KEY,
|
||||
} from "./theme";
|
||||
|
||||
describe("UI theme preference", () => {
|
||||
test("defaults to dark when no stored preference exists", () => {
|
||||
const storage = new Map<string, string>();
|
||||
|
||||
expect(resolveInitialUiTheme(storage)).toBe("dark");
|
||||
});
|
||||
|
||||
test("restores a valid stored preference", () => {
|
||||
const storage = new Map<string, string>([[UI_THEME_STORAGE_KEY, "light"]]);
|
||||
|
||||
expect(resolveInitialUiTheme(storage)).toBe("light");
|
||||
});
|
||||
|
||||
test("ignores invalid stored preferences", () => {
|
||||
const storage = new Map<string, string>([[UI_THEME_STORAGE_KEY, "solarized"]]);
|
||||
|
||||
expect(resolveInitialUiTheme(storage)).toBe("dark");
|
||||
});
|
||||
|
||||
test("falls back when stored preferences cannot be read", () => {
|
||||
const storage = {
|
||||
getItem() {
|
||||
throw new Error("storage blocked");
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveInitialUiTheme(storage)).toBe("dark");
|
||||
});
|
||||
|
||||
test("ignores persistence failures", () => {
|
||||
const storage = {
|
||||
setItem() {
|
||||
throw new Error("quota exceeded");
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => persistUiTheme("light", storage)).not.toThrow();
|
||||
});
|
||||
|
||||
test("detects and toggles supported themes", () => {
|
||||
expect(isUiTheme("light")).toBe(true);
|
||||
expect(isUiTheme("dark")).toBe(true);
|
||||
expect(isUiTheme("contrast")).toBe(false);
|
||||
expect(getNextUiTheme("dark")).toBe("light");
|
||||
expect(getNextUiTheme("light")).toBe("dark");
|
||||
});
|
||||
});
|
||||
68
packages/ui/src/theme.ts
Normal file
68
packages/ui/src/theme.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
export const UI_THEME_STORAGE_KEY = "dashboard-ui-theme";
|
||||
|
||||
export type UiTheme = "dark" | "light";
|
||||
|
||||
type ReadableThemeStorage =
|
||||
| { getItem(key: string): string | null }
|
||||
| { get(key: string): string | undefined };
|
||||
|
||||
type WritableThemeStorage =
|
||||
| { setItem(key: string, value: string): void }
|
||||
| { set(key: string, value: string): unknown };
|
||||
|
||||
export function isUiTheme(value: unknown): value is UiTheme {
|
||||
return value === "dark" || value === "light";
|
||||
}
|
||||
|
||||
export function getNextUiTheme(theme: UiTheme): UiTheme {
|
||||
return theme === "dark" ? "light" : "dark";
|
||||
}
|
||||
|
||||
export function resolveInitialUiTheme(
|
||||
storage?: ReadableThemeStorage | null,
|
||||
fallback: UiTheme = "dark",
|
||||
): UiTheme {
|
||||
const stored = safeReadStoredTheme(storage);
|
||||
|
||||
return isUiTheme(stored) ? stored : fallback;
|
||||
}
|
||||
|
||||
export function persistUiTheme(
|
||||
theme: UiTheme,
|
||||
storage?: WritableThemeStorage | null,
|
||||
): void {
|
||||
if (!storage) return;
|
||||
|
||||
try {
|
||||
if ("setItem" in storage) {
|
||||
storage.setItem(UI_THEME_STORAGE_KEY, theme);
|
||||
return;
|
||||
}
|
||||
|
||||
storage.set(UI_THEME_STORAGE_KEY, theme);
|
||||
} catch {
|
||||
// Browser storage may be blocked or quota-constrained.
|
||||
}
|
||||
}
|
||||
|
||||
function safeReadStoredTheme(
|
||||
storage?: ReadableThemeStorage | null,
|
||||
): string | undefined {
|
||||
try {
|
||||
return readStoredTheme(storage);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredTheme(
|
||||
storage?: ReadableThemeStorage | null,
|
||||
): string | undefined {
|
||||
if (!storage) return undefined;
|
||||
|
||||
if ("getItem" in storage) {
|
||||
return storage.getItem(UI_THEME_STORAGE_KEY) ?? undefined;
|
||||
}
|
||||
|
||||
return storage.get(UI_THEME_STORAGE_KEY);
|
||||
}
|
||||
160
packages/ui/src/tokens.css
Normal file
160
packages/ui/src/tokens.css
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
:root,
|
||||
[data-ui-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--ui-color-background: #0b0f0d;
|
||||
--ui-color-backdrop-edge: #020403;
|
||||
--ui-color-canvas: #0b0f0d;
|
||||
--ui-color-surface: #111713;
|
||||
--ui-color-surface-raised: #151d18;
|
||||
--ui-color-surface-elevated: #19231d;
|
||||
--ui-color-surface-panel: rgba(15, 21, 17, 0.94);
|
||||
--ui-color-surface-module: rgba(18, 25, 21, 0.96);
|
||||
--ui-color-surface-footer: rgba(11, 15, 13, 0.98);
|
||||
--ui-color-surface-inset: #070b09;
|
||||
--ui-color-surface-icon: #101711;
|
||||
--ui-color-border-subtle: rgba(209, 222, 198, 0.13);
|
||||
--ui-color-border: rgba(211, 225, 199, 0.2);
|
||||
--ui-color-border-strong: rgba(219, 234, 207, 0.34);
|
||||
--ui-color-line: var(--ui-color-border);
|
||||
--ui-color-line-strong: var(--ui-color-border-strong);
|
||||
--ui-color-grid-line: rgba(222, 238, 211, 0.045);
|
||||
--ui-color-backdrop-vignette: rgba(0, 0, 0, 0.24);
|
||||
--ui-color-frame-spine: rgba(198, 230, 160, 0.1);
|
||||
--ui-color-frame-wash: rgba(10, 15, 12, 0.52);
|
||||
--ui-color-card-gradient-start: rgba(24, 33, 27, 0.94);
|
||||
--ui-color-card-gradient-end: rgba(10, 15, 12, 0.98);
|
||||
--ui-color-hover: rgba(190, 225, 146, 0.075);
|
||||
--ui-color-text-primary: #edf1e6;
|
||||
--ui-color-text-secondary: #b4bda9;
|
||||
--ui-color-text-muted: #7f8a7a;
|
||||
--ui-color-text: var(--ui-color-text-primary);
|
||||
--ui-color-muted: var(--ui-color-text-muted);
|
||||
--ui-color-dim: #4e594d;
|
||||
--ui-color-accent: #c7ef5f;
|
||||
--ui-color-accent-soft: rgba(199, 239, 95, 0.12);
|
||||
--ui-color-accent-contrast: #0a100b;
|
||||
--ui-color-success: #9fd85a;
|
||||
--ui-color-ok: var(--ui-color-success);
|
||||
--ui-color-warning: #d99a3a;
|
||||
--ui-color-critical: #f04463;
|
||||
--ui-color-danger: var(--ui-color-critical);
|
||||
--ui-color-info: #86a8bd;
|
||||
--ui-color-stale: #8a9a93;
|
||||
--ui-color-unavailable: #6f7a76;
|
||||
--ui-font-mono: "IBM Plex Mono", "Roboto Mono", "SFMono-Regular", Consolas, monospace;
|
||||
--ui-font-display: "Teko", "IBM Plex Mono", "Roboto Mono", monospace;
|
||||
--ui-space-1: 0.25rem;
|
||||
--ui-space-2: 0.5rem;
|
||||
--ui-space-3: 0.75rem;
|
||||
--ui-space-4: 1rem;
|
||||
--ui-space-5: 1.25rem;
|
||||
--ui-space-6: 1.5rem;
|
||||
--ui-radius-none: 0;
|
||||
--ui-border: 1px solid var(--ui-color-border);
|
||||
--ui-border-strong: 1px solid var(--ui-color-border-strong);
|
||||
--ui-shadow-hard: 0 0 0 1px rgba(199, 239, 95, 0.1) inset;
|
||||
--ui-shadow-inset-panel:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.32);
|
||||
--ui-z-base: 0;
|
||||
--ui-z-panel: 1;
|
||||
--ui-z-overlay: 10;
|
||||
--ui-density-card-min: 9.75rem;
|
||||
--ui-focus-ring: 0 0 0 2px var(--ui-color-canvas), 0 0 0 4px var(--ui-color-accent);
|
||||
}
|
||||
|
||||
[data-ui-theme="light"] {
|
||||
color-scheme: light;
|
||||
--ui-color-background: #eef2e7;
|
||||
--ui-color-backdrop-edge: #d7decf;
|
||||
--ui-color-canvas: #eef2e7;
|
||||
--ui-color-surface: #f9fbf3;
|
||||
--ui-color-surface-raised: #f1f5ea;
|
||||
--ui-color-surface-elevated: #ffffff;
|
||||
--ui-color-surface-panel: rgba(250, 252, 245, 0.94);
|
||||
--ui-color-surface-module: rgba(252, 254, 247, 0.96);
|
||||
--ui-color-surface-footer: rgba(239, 243, 233, 0.98);
|
||||
--ui-color-surface-inset: #e1e8d9;
|
||||
--ui-color-surface-icon: #edf3e6;
|
||||
--ui-color-border-subtle: rgba(32, 43, 29, 0.12);
|
||||
--ui-color-border: rgba(32, 43, 29, 0.22);
|
||||
--ui-color-border-strong: rgba(32, 43, 29, 0.38);
|
||||
--ui-color-line: var(--ui-color-border);
|
||||
--ui-color-line-strong: var(--ui-color-border-strong);
|
||||
--ui-color-grid-line: rgba(32, 43, 29, 0.055);
|
||||
--ui-color-backdrop-vignette: rgba(31, 44, 29, 0.08);
|
||||
--ui-color-frame-spine: rgba(32, 43, 29, 0.09);
|
||||
--ui-color-frame-wash: rgba(255, 255, 255, 0.54);
|
||||
--ui-color-card-gradient-start: rgba(255, 255, 255, 0.96);
|
||||
--ui-color-card-gradient-end: rgba(229, 235, 221, 0.92);
|
||||
--ui-color-hover: rgba(54, 81, 25, 0.065);
|
||||
--ui-color-text-primary: #11180f;
|
||||
--ui-color-text-secondary: #3f4b3b;
|
||||
--ui-color-text-muted: #667260;
|
||||
--ui-color-text: var(--ui-color-text-primary);
|
||||
--ui-color-muted: var(--ui-color-text-muted);
|
||||
--ui-color-dim: #7e8878;
|
||||
--ui-color-accent: #4f7200;
|
||||
--ui-color-accent-soft: rgba(79, 114, 0, 0.11);
|
||||
--ui-color-accent-contrast: #ffffff;
|
||||
--ui-color-success: #436f00;
|
||||
--ui-color-ok: var(--ui-color-success);
|
||||
--ui-color-warning: #915c00;
|
||||
--ui-color-critical: #b7173c;
|
||||
--ui-color-danger: var(--ui-color-critical);
|
||||
--ui-color-info: #476a7c;
|
||||
--ui-color-stale: #596b64;
|
||||
--ui-color-unavailable: #68726c;
|
||||
--ui-shadow-hard: 0 0 0 1px rgba(79, 114, 0, 0.14) inset;
|
||||
--ui-shadow-inset-panel:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.72),
|
||||
inset 0 -1px 0 rgba(31, 44, 29, 0.08);
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--ui-color-canvas);
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at 50% 12%, transparent 0 42%, var(--ui-color-backdrop-vignette) 100%),
|
||||
linear-gradient(var(--ui-color-grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--ui-color-grid-line) 1px, transparent 1px),
|
||||
var(--ui-color-canvas);
|
||||
background-size: auto, 40px 40px, 40px 40px, auto;
|
||||
color: var(--ui-color-text);
|
||||
font-family: var(--ui-font-mono);
|
||||
text-rendering: geometricPrecision;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
[tabindex]:focus-visible {
|
||||
outline: 0;
|
||||
box-shadow: var(--ui-focus-ring);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
88
packages/ui/src/types.ts
Normal file
88
packages/ui/src/types.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
export type UiSeverity =
|
||||
| "neutral"
|
||||
| "ok"
|
||||
| "warning"
|
||||
| "danger"
|
||||
| "stale"
|
||||
| "unavailable"
|
||||
| "loading";
|
||||
|
||||
export type UiMetricKind =
|
||||
| "bytes"
|
||||
| "latency"
|
||||
| "number"
|
||||
| "percent"
|
||||
| "temperature"
|
||||
| "text";
|
||||
|
||||
export interface UiMetricValue {
|
||||
kind: UiMetricKind;
|
||||
value: number | string;
|
||||
unit?: string;
|
||||
precision?: number;
|
||||
}
|
||||
|
||||
export interface UiLink {
|
||||
href: string;
|
||||
label?: string;
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
export interface UiTelemetryCard {
|
||||
id: string;
|
||||
label: string;
|
||||
value: UiMetricValue;
|
||||
detail?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
severity: UiSeverity;
|
||||
progress?: number;
|
||||
sparkline?: number[];
|
||||
}
|
||||
|
||||
export interface UiServiceRow {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
severity: UiSeverity;
|
||||
detail?: string;
|
||||
link?: UiLink;
|
||||
}
|
||||
|
||||
export interface UiServiceGroup {
|
||||
id: string;
|
||||
layout?: "grid" | "list";
|
||||
title: string;
|
||||
services: UiServiceRow[];
|
||||
summary?: UiStatusItem[];
|
||||
}
|
||||
|
||||
export interface UiStatusItem {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
severity?: UiSeverity;
|
||||
link?: UiLink;
|
||||
}
|
||||
|
||||
export interface UiModuleBlock {
|
||||
id: string;
|
||||
title?: string;
|
||||
label?: string;
|
||||
value?: string;
|
||||
detail?: string;
|
||||
icon?: string;
|
||||
severity?: UiSeverity;
|
||||
}
|
||||
|
||||
export interface UiDashboardPreview {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
eyebrow?: string;
|
||||
telemetry: UiTelemetryCard[];
|
||||
serviceGroups: UiServiceGroup[];
|
||||
modules: UiModuleBlock[];
|
||||
statusItems: UiStatusItem[];
|
||||
statusStripId?: string;
|
||||
}
|
||||
6
packages/ui/src/utils.ts
Normal file
6
packages/ui/src/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
19
packages/ui/tsconfig.build.json
Normal file
19
packages/ui/tsconfig.build.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": false,
|
||||
"noEmit": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules",
|
||||
"storybook-static",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/stories/**"
|
||||
]
|
||||
}
|
||||
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