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 (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/packages/ui/src/primitives/progress.tsx b/packages/ui/src/primitives/progress.tsx
new file mode 100644
index 0000000..59fac6c
--- /dev/null
+++ b/packages/ui/src/primitives/progress.tsx
@@ -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
) {
+ return (
+
+
+
+ )
+}
+
+export { Progress }
diff --git a/packages/ui/src/primitives/separator.tsx b/packages/ui/src/primitives/separator.tsx
new file mode 100644
index 0000000..ee9f6cb
--- /dev/null
+++ b/packages/ui/src/primitives/separator.tsx
@@ -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) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/packages/ui/src/primitives/skeleton.tsx b/packages/ui/src/primitives/skeleton.tsx
new file mode 100644
index 0000000..a1df193
--- /dev/null
+++ b/packages/ui/src/primitives/skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from "../utils"
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Skeleton }
diff --git a/packages/ui/src/stories/Badge.stories.tsx b/packages/ui/src/stories/Badge.stories.tsx
new file mode 100644
index 0000000..26e3ea8
--- /dev/null
+++ b/packages/ui/src/stories/Badge.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/packages/ui/src/stories/Button.stories.tsx b/packages/ui/src/stories/Button.stories.tsx
new file mode 100644
index 0000000..084caa8
--- /dev/null
+++ b/packages/ui/src/stories/Button.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+export const Primary: Story = {};
+export const Secondary: Story = {
+ args: {
+ variant: "secondary",
+ },
+};
diff --git a/packages/ui/src/stories/CornerBracketFrame.stories.tsx b/packages/ui/src/stories/CornerBracketFrame.stories.tsx
new file mode 100644
index 0000000..d5c0110
--- /dev/null
+++ b/packages/ui/src/stories/CornerBracketFrame.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/packages/ui/src/stories/DashboardFrame.stories.tsx b/packages/ui/src/stories/DashboardFrame.stories.tsx
new file mode 100644
index 0000000..894219e
--- /dev/null
+++ b/packages/ui/src/stories/DashboardFrame.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+export const FullComposition: Story = {};
+export const Secondary: Story = {
+ args: {
+ dashboard: secondaryDashboard,
+ },
+};
diff --git a/packages/ui/src/stories/DashboardHeader.stories.tsx b/packages/ui/src/stories/DashboardHeader.stories.tsx
new file mode 100644
index 0000000..c337158
--- /dev/null
+++ b/packages/ui/src/stories/DashboardHeader.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+export const WithModule: Story = {};
diff --git a/packages/ui/src/stories/DashboardOnePager.stories.tsx b/packages/ui/src/stories/DashboardOnePager.stories.tsx
new file mode 100644
index 0000000..3bd800f
--- /dev/null
+++ b/packages/ui/src/stories/DashboardOnePager.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+export const OnePager: Story = {};
diff --git a/packages/ui/src/stories/DiagonalStripeField.stories.tsx b/packages/ui/src/stories/DiagonalStripeField.stories.tsx
new file mode 100644
index 0000000..1f094ee
--- /dev/null
+++ b/packages/ui/src/stories/DiagonalStripeField.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/packages/ui/src/stories/FooterCell.stories.tsx b/packages/ui/src/stories/FooterCell.stories.tsx
new file mode 100644
index 0000000..7aa06d8
--- /dev/null
+++ b/packages/ui/src/stories/FooterCell.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+export const Operational: Story = {};
+export const Linked: Story = {
+ args: {
+ item: statusItems.action,
+ },
+};
diff --git a/packages/ui/src/stories/FooterStatusCell.stories.tsx b/packages/ui/src/stories/FooterStatusCell.stories.tsx
new file mode 100644
index 0000000..bd985a1
--- /dev/null
+++ b/packages/ui/src/stories/FooterStatusCell.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+export const Warning: Story = {};
diff --git a/packages/ui/src/stories/GridFrame.stories.tsx b/packages/ui/src/stories/GridFrame.stories.tsx
new file mode 100644
index 0000000..191bf6c
--- /dev/null
+++ b/packages/ui/src/stories/GridFrame.stories.tsx
@@ -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) => (
+
+
+
+
+
+ ),
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Dense: Story = {
+ args: {
+ density: "dense",
+ },
+};
diff --git a/packages/ui/src/stories/IconButton.stories.tsx b/packages/ui/src/stories/IconButton.stories.tsx
new file mode 100644
index 0000000..612bf2a
--- /dev/null
+++ b/packages/ui/src/stories/IconButton.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/packages/ui/src/stories/IconGlyph.stories.tsx b/packages/ui/src/stories/IconGlyph.stories.tsx
new file mode 100644
index 0000000..d832951
--- /dev/null
+++ b/packages/ui/src/stories/IconGlyph.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/packages/ui/src/stories/LineChart.stories.tsx b/packages/ui/src/stories/LineChart.stories.tsx
new file mode 100644
index 0000000..ccbd502
--- /dev/null
+++ b/packages/ui/src/stories/LineChart.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+export const Trend: Story = {};
diff --git a/packages/ui/src/stories/ModuleCard.stories.tsx b/packages/ui/src/stories/ModuleCard.stories.tsx
new file mode 100644
index 0000000..ea0305b
--- /dev/null
+++ b/packages/ui/src/stories/ModuleCard.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+export const Compact: Story = {};
+export const Long: Story = {
+ args: {
+ module: moduleBlocks.long,
+ },
+};
diff --git a/packages/ui/src/stories/Panel.stories.tsx b/packages/ui/src/stories/Panel.stories.tsx
new file mode 100644
index 0000000..91eb61e
--- /dev/null
+++ b/packages/ui/src/stories/Panel.stories.tsx
@@ -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) => (
+
+
+
+
+ ),
+ args: {
+ title: "Generic Panel",
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Dense: Story = {};
diff --git a/packages/ui/src/stories/ProgressMeter.stories.tsx b/packages/ui/src/stories/ProgressMeter.stories.tsx
new file mode 100644
index 0000000..eec35c2
--- /dev/null
+++ b/packages/ui/src/stories/ProgressMeter.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+export const Filled: Story = {};
diff --git a/packages/ui/src/stories/ScanlineField.stories.tsx b/packages/ui/src/stories/ScanlineField.stories.tsx
new file mode 100644
index 0000000..fbe53f4
--- /dev/null
+++ b/packages/ui/src/stories/ScanlineField.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/packages/ui/src/stories/Separator.stories.tsx b/packages/ui/src/stories/Separator.stories.tsx
new file mode 100644
index 0000000..986ed23
--- /dev/null
+++ b/packages/ui/src/stories/Separator.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+export const Horizontal: Story = {};
diff --git a/packages/ui/src/stories/ServiceGroupPanel.stories.tsx b/packages/ui/src/stories/ServiceGroupPanel.stories.tsx
new file mode 100644
index 0000000..f5ebdce
--- /dev/null
+++ b/packages/ui/src/stories/ServiceGroupPanel.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+export const Mixed: Story = {};
diff --git a/packages/ui/src/stories/ServicePanel.stories.tsx b/packages/ui/src/stories/ServicePanel.stories.tsx
new file mode 100644
index 0000000..d29b7ad
--- /dev/null
+++ b/packages/ui/src/stories/ServicePanel.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+export const LongList: Story = {};
diff --git a/packages/ui/src/stories/ServiceRow.stories.tsx b/packages/ui/src/stories/ServiceRow.stories.tsx
new file mode 100644
index 0000000..56ba076
--- /dev/null
+++ b/packages/ui/src/stories/ServiceRow.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+export const Normal: Story = {};
+export const Warning: Story = {
+ args: {
+ service: serviceRows.degraded,
+ },
+};
diff --git a/packages/ui/src/stories/SignalTrace.stories.tsx b/packages/ui/src/stories/SignalTrace.stories.tsx
new file mode 100644
index 0000000..4089df8
--- /dev/null
+++ b/packages/ui/src/stories/SignalTrace.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+export const Horizontal: Story = {};
diff --git a/packages/ui/src/stories/Sparkline.stories.tsx b/packages/ui/src/stories/Sparkline.stories.tsx
new file mode 100644
index 0000000..af3e6e5
--- /dev/null
+++ b/packages/ui/src/stories/Sparkline.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/packages/ui/src/stories/StatusBadge.stories.tsx b/packages/ui/src/stories/StatusBadge.stories.tsx
new file mode 100644
index 0000000..92a444d
--- /dev/null
+++ b/packages/ui/src/stories/StatusBadge.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+export const Ready: Story = {};
diff --git a/packages/ui/src/stories/StatusStrip.stories.tsx b/packages/ui/src/stories/StatusStrip.stories.tsx
new file mode 100644
index 0000000..2fc236d
--- /dev/null
+++ b/packages/ui/src/stories/StatusStrip.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+export const Mixed: Story = {};
diff --git a/packages/ui/src/stories/SystemState.stories.tsx b/packages/ui/src/stories/SystemState.stories.tsx
new file mode 100644
index 0000000..9b3f6e5
--- /dev/null
+++ b/packages/ui/src/stories/SystemState.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+export const Loading: Story = {};
diff --git a/packages/ui/src/stories/TelemetryCard.stories.tsx b/packages/ui/src/stories/TelemetryCard.stories.tsx
new file mode 100644
index 0000000..3ac0e1e
--- /dev/null
+++ b/packages/ui/src/stories/TelemetryCard.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+export const Percent: Story = {};
+export const Danger: Story = {
+ args: {
+ card: telemetryCards.danger,
+ },
+};
diff --git a/packages/ui/src/stories/TelemetryGrid.stories.tsx b/packages/ui/src/stories/TelemetryGrid.stories.tsx
new file mode 100644
index 0000000..34493c5
--- /dev/null
+++ b/packages/ui/src/stories/TelemetryGrid.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+export const EightCards: Story = {};
+export const SixteenCards: Story = {
+ args: {
+ cards: sixteenTelemetryCards,
+ },
+};
diff --git a/packages/ui/src/stories/TelemetryStrip.stories.tsx b/packages/ui/src/stories/TelemetryStrip.stories.tsx
new file mode 100644
index 0000000..f7c9d13
--- /dev/null
+++ b/packages/ui/src/stories/TelemetryStrip.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/packages/ui/src/stories/ThemeToggle.stories.tsx b/packages/ui/src/stories/ThemeToggle.stories.tsx
new file mode 100644
index 0000000..f2edeea
--- /dev/null
+++ b/packages/ui/src/stories/ThemeToggle.stories.tsx
@@ -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;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Dark: Story = {};
+
+export const Light: Story = {
+ args: {
+ theme: "light",
+ },
+ globals: {
+ theme: "light",
+ },
+};
diff --git a/packages/ui/src/stories/WeatherModule.stories.tsx b/packages/ui/src/stories/WeatherModule.stories.tsx
new file mode 100644
index 0000000..a376448
--- /dev/null
+++ b/packages/ui/src/stories/WeatherModule.stories.tsx
@@ -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;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/packages/ui/src/stories/story-data.ts b/packages/ui/src/stories/story-data.ts
new file mode 100644
index 0000000..4085dac
--- /dev/null
+++ b/packages/ui/src/stories/story-data.ts
@@ -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 = {
+ 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 = {
+ 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 = {
+ 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 = {
+ 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 = {
+ 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 };
+}
diff --git a/packages/ui/src/storybook.test.ts b/packages/ui/src/storybook.test.ts
new file mode 100644
index 0000000..9a18ecd
--- /dev/null
+++ b/packages/ui/src/storybook.test.ts
@@ -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;
+ };
+
+ 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;
+ };
+ 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();
+}
diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css
new file mode 100644
index 0000000..fd6f4a2
--- /dev/null
+++ b/packages/ui/src/styles.css
@@ -0,0 +1,4 @@
+@import "@fontsource-variable/geist";
+@import "uplot/dist/uPlot.min.css";
+@import "./tokens.css";
+@import "./components/styles.css";
diff --git a/packages/ui/src/theme.test.ts b/packages/ui/src/theme.test.ts
new file mode 100644
index 0000000..8ed3f04
--- /dev/null
+++ b/packages/ui/src/theme.test.ts
@@ -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();
+
+ expect(resolveInitialUiTheme(storage)).toBe("dark");
+ });
+
+ test("restores a valid stored preference", () => {
+ const storage = new Map([[UI_THEME_STORAGE_KEY, "light"]]);
+
+ expect(resolveInitialUiTheme(storage)).toBe("light");
+ });
+
+ test("ignores invalid stored preferences", () => {
+ const storage = new Map([[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");
+ });
+});
diff --git a/packages/ui/src/theme.ts b/packages/ui/src/theme.ts
new file mode 100644
index 0000000..9f48df5
--- /dev/null
+++ b/packages/ui/src/theme.ts
@@ -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);
+}
diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css
new file mode 100644
index 0000000..26b20bd
--- /dev/null
+++ b/packages/ui/src/tokens.css
@@ -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;
+ }
+}
diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts
new file mode 100644
index 0000000..792a24b
--- /dev/null
+++ b/packages/ui/src/types.ts
@@ -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;
+}
diff --git a/packages/ui/src/utils.ts b/packages/ui/src/utils.ts
new file mode 100644
index 0000000..bd0c391
--- /dev/null
+++ b/packages/ui/src/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/packages/ui/tsconfig.build.json b/packages/ui/tsconfig.build.json
new file mode 100644
index 0000000..bd56366
--- /dev/null
+++ b/packages/ui/tsconfig.build.json
@@ -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/**"
+ ]
+}
diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json
new file mode 100644
index 0000000..2ad97bc
--- /dev/null
+++ b/packages/ui/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "baseUrl": "."
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx", ".storybook/**/*.ts"],
+ "exclude": ["dist", "node_modules", "storybook-static"]
+}
diff --git a/scripts/deploy-dimensionlab-website.sh b/scripts/deploy-dimensionlab-website.sh
deleted file mode 100755
index 4fac4cd..0000000
--- a/scripts/deploy-dimensionlab-website.sh
+++ /dev/null
@@ -1,362 +0,0 @@
-#!/usr/bin/env bash
-set -Eeuo pipefail
-
-APP_NAME="${APP_NAME:-dimensionlab-website}"
-SERVICE_NAME="${SERVICE_NAME:-dimensionlab-website.service}"
-CONTAINER_NAME="${CONTAINER_NAME:-dimensionlab-website}"
-IMAGE_REPO="${IMAGE_REPO:-localhost/dimensionlab-website}"
-CONTAINERFILE="${CONTAINERFILE:-apps/web/Containerfile}"
-PUBLIC_URL="${PUBLIC_URL:-https://dimensionlab.net/}"
-TILE_BATCH_URL="${TILE_BATCH_URL:-https://dimensionlab.net/api/dashboard/tiles}"
-DEPLOY_RESTART_STRATEGY="${DEPLOY_RESTART_STRATEGY:-auto}"
-DEPLOY_SMOKE_TIMEOUT_SECONDS="${DEPLOY_SMOKE_TIMEOUT_SECONDS:-120}"
-DEPLOY_CONTAINER_START_TIMEOUT_SECONDS="${DEPLOY_CONTAINER_START_TIMEOUT_SECONDS:-90}"
-
-dry_run=false
-rollback_tag=""
-release_tag=""
-latest_tag="${IMAGE_REPO}:latest"
-container_cli=""
-deployment_started=false
-rollback_done=false
-rollback_in_progress=false
-
-usage() {
- cat <&2
- if [ "${deployment_started:-false}" = "true" ] && [ "${rollback_in_progress:-false}" != "true" ]; then
- rollback || true
- fi
- exit 1
-}
-
-run() {
- if "$dry_run"; then
- printf '[deploy:%s] DRY-RUN:' "$APP_NAME"
- printf ' %q' "$@"
- printf '\n'
- return 0
- fi
-
- "$@"
-}
-
-for arg in "$@"; do
- case "$arg" in
- --dry-run)
- dry_run=true
- ;;
- -h|--help)
- usage
- exit 0
- ;;
- *)
- usage >&2
- fail "unknown argument: $arg"
- ;;
- esac
-done
-
-deployment_ref() {
- printf '%s' "${DEPLOY_REF:-${GITHUB_REF:-${FORGEJO_REF:-}}}"
-}
-
-deployment_event() {
- printf '%s' "${DEPLOY_EVENT_NAME:-${GITHUB_EVENT_NAME:-${FORGEJO_EVENT_NAME:-}}}"
-}
-
-require_main_push() {
- local event
- local ref
-
- event="$(deployment_event)"
- ref="$(deployment_ref)"
-
- if [ -n "$event" ] && [ "$event" != "push" ]; then
- fail "refusing to deploy for event '$event'; production deploys only run for push"
- fi
-
- if [ -n "$ref" ]; then
- [ "$ref" = "refs/heads/main" ] || fail "refusing to deploy ref '$ref'; expected refs/heads/main"
- return 0
- fi
-
- local branch
- branch="$(git branch --show-current 2>/dev/null || true)"
- [ "$branch" = "main" ] || fail "refusing to deploy branch '$branch'; expected main"
-}
-
-select_container_cli() {
- if [ -n "${DEPLOY_CONTAINER_CLI:-}" ]; then
- command -v "$DEPLOY_CONTAINER_CLI" >/dev/null 2>&1 || fail "container CLI not found: $DEPLOY_CONTAINER_CLI"
- container_cli="$DEPLOY_CONTAINER_CLI"
- return 0
- fi
-
- if command -v podman >/dev/null 2>&1; then
- container_cli="podman"
- return 0
- fi
-
- if command -v docker >/dev/null 2>&1; then
- container_cli="docker"
- return 0
- fi
-
- fail "podman or docker is required"
-}
-
-current_sha() {
- if [ -n "${DEPLOY_SHA:-${GITHUB_SHA:-}}" ]; then
- printf '%s' "${DEPLOY_SHA:-${GITHUB_SHA:-}}"
- return 0
- fi
-
- git rev-parse HEAD
-}
-
-tag_existing_latest_for_rollback() {
- rollback_tag="${IMAGE_REPO}:rollback-$(date -u +%Y%m%d%H%M%S)"
- if "$container_cli" image inspect "$latest_tag" >/dev/null 2>&1; then
- log "tagging current latest image as $rollback_tag"
- run "$container_cli" tag "$latest_tag" "$rollback_tag"
- else
- log "no existing $latest_tag image found; rollback image tag will not be created"
- rollback_tag=""
- fi
-}
-
-container_systemd_unit() {
- "$container_cli" inspect "$CONTAINER_NAME" \
- --format '{{ index .Config.Labels "PODMAN_SYSTEMD_UNIT" }}' 2>/dev/null || true
-}
-
-require_container_managed_by_service() {
- local unit
-
- if "$dry_run"; then
- log "DRY-RUN: would require $CONTAINER_NAME to be managed by $SERVICE_NAME"
- return 0
- fi
-
- unit="$(container_systemd_unit)"
- [ "$unit" = "$SERVICE_NAME" ] || fail "refusing to stop $CONTAINER_NAME; expected PODMAN_SYSTEMD_UNIT=$SERVICE_NAME, got '${unit:-unset}'"
-}
-
-validate_restart_strategy() {
- case "$DEPLOY_RESTART_STRATEGY" in
- systemctl)
- if ! "$dry_run" && ! systemctl --user show "$SERVICE_NAME" >/dev/null 2>&1; then
- fail "systemctl --user cannot access $SERVICE_NAME"
- fi
- ;;
- quadlet-container|kill-container)
- require_container_managed_by_service
- ;;
- auto)
- if "$dry_run"; then
- log "DRY-RUN: would validate automatic restart strategy"
- elif ! command -v systemctl >/dev/null 2>&1 || ! systemctl --user show "$SERVICE_NAME" >/dev/null 2>&1; then
- require_container_managed_by_service
- fi
- ;;
- *)
- fail "unknown DEPLOY_RESTART_STRATEGY: $DEPLOY_RESTART_STRATEGY"
- ;;
- esac
-}
-
-initialize_submodules() {
- log "initializing submodules"
- run git config --global url."https://git.dimensionlab.net/".insteadOf "ssh://git@git.dimensionlab.net/"
- run git submodule update --init --recursive
-}
-
-build_image() {
- local sha
- local short_sha
-
- sha="$(current_sha)"
- short_sha="${sha:0:12}"
- release_tag="${IMAGE_REPO}:${short_sha}"
-
- [ -f "$CONTAINERFILE" ] || fail "containerfile not found: $CONTAINERFILE"
-
- log "building $release_tag and $latest_tag from $CONTAINERFILE"
- run "$container_cli" build -f "$CONTAINERFILE" -t "$release_tag" -t "$latest_tag" .
-}
-
-restart_service() {
- log "restarting $SERVICE_NAME with strategy $DEPLOY_RESTART_STRATEGY"
-
- case "$DEPLOY_RESTART_STRATEGY" in
- systemctl)
- run systemctl --user restart "$SERVICE_NAME"
- ;;
- quadlet-container|kill-container)
- require_container_managed_by_service
- run "$container_cli" stop "$CONTAINER_NAME"
- ;;
- auto)
- if command -v systemctl >/dev/null 2>&1 && systemctl --user is-active "$SERVICE_NAME" >/dev/null 2>&1; then
- run systemctl --user restart "$SERVICE_NAME"
- else
- require_container_managed_by_service
- run "$container_cli" stop "$CONTAINER_NAME"
- fi
- ;;
- *)
- fail "unknown DEPLOY_RESTART_STRATEGY: $DEPLOY_RESTART_STRATEGY"
- ;;
- esac
-}
-
-latest_image_id() {
- "$container_cli" image inspect "$latest_tag" --format '{{.Id}}' 2>/dev/null || true
-}
-
-container_image_id() {
- "$container_cli" inspect "$CONTAINER_NAME" --format '{{.Image}}' 2>/dev/null || true
-}
-
-container_running() {
- local running
- running="$("$container_cli" inspect "$CONTAINER_NAME" --format '{{.State.Running}}' 2>/dev/null || true)"
- [ "$running" = "true" ]
-}
-
-wait_for_container_restart() {
- local expected_image
-
- if "$dry_run"; then
- log "DRY-RUN: would wait for $CONTAINER_NAME to run $latest_tag"
- return 0
- fi
-
- expected_image="$(latest_image_id)"
- [ -n "$expected_image" ] || fail "could not resolve image id for $latest_tag"
-
- wait_for_container_image "$expected_image" "new image" || fail "$CONTAINER_NAME did not restart on $latest_tag within ${DEPLOY_CONTAINER_START_TIMEOUT_SECONDS}s"
-}
-
-wait_for_container_image() {
- local expected_image="$1"
- local label="$2"
- local deadline
-
- deadline=$((SECONDS + DEPLOY_CONTAINER_START_TIMEOUT_SECONDS))
-
- while [ "$SECONDS" -lt "$deadline" ]; do
- if container_running && [ "$(container_image_id)" = "$expected_image" ]; then
- log "$CONTAINER_NAME is running the $label"
- return 0
- fi
-
- sleep 2
- done
-
- return 1
-}
-
-smoke_get() {
- local url="$1"
- curl -fsS --max-time 10 -o /dev/null "$url"
-}
-
-smoke_tiles() {
- local response
-
- response="$(
- curl -fsS --max-time 20 \
- -H "content-type: application/json" \
- --data '{"tiles":[{"kind":"status","stripId":"footer-status","id":"system-status"}]}' \
- "$TILE_BATCH_URL"
- )"
-
- [[ "$response" == *'"state":"ready"'* ]]
-}
-
-wait_for_smoke() {
- local deadline
-
- if "$dry_run"; then
- log "DRY-RUN: would smoke check $PUBLIC_URL and $TILE_BATCH_URL"
- return 0
- fi
-
- deadline=$((SECONDS + DEPLOY_SMOKE_TIMEOUT_SECONDS))
- until smoke_get "$PUBLIC_URL" && smoke_tiles; do
- if [ "$SECONDS" -ge "$deadline" ]; then
- fail "smoke checks failed for $PUBLIC_URL and $TILE_BATCH_URL"
- fi
-
- sleep 3
- done
-
- log "smoke checks passed"
-}
-
-rollback() {
- local rollback_image
-
- if [ "$deployment_started" != "true" ] || [ -z "$rollback_tag" ] || [ "$rollback_done" = "true" ]; then
- return 0
- fi
-
- rollback_done=true
- rollback_in_progress=true
- printf '[deploy:%s] rolling back to %s\n' "$APP_NAME" "$rollback_tag" >&2
- rollback_image="$("$container_cli" image inspect "$rollback_tag" --format '{{.Id}}' 2>/dev/null || true)"
- "$container_cli" tag "$rollback_tag" "$latest_tag" || true
- if container_running; then
- restart_service || true
- else
- printf '[deploy:%s] waiting for %s to recover with rollback image\n' "$APP_NAME" "$SERVICE_NAME" >&2
- fi
- if [ -n "$rollback_image" ] && wait_for_container_image "$rollback_image" "rollback image"; then
- printf '[deploy:%s] rollback image is running\n' "$APP_NAME" >&2
- else
- printf '[deploy:%s] ERROR: rollback image did not become healthy\n' "$APP_NAME" >&2
- fi
- rollback_in_progress=false
-}
-
-on_error() {
- local status=$?
- rollback
- exit "$status"
-}
-
-trap on_error ERR
-
-require_main_push
-select_container_cli
-validate_restart_strategy
-
-log "using container CLI: $container_cli"
-log "target image: $latest_tag"
-log "target service: $SERVICE_NAME"
-
-initialize_submodules
-tag_existing_latest_for_rollback
-build_image
-deployment_started=true
-restart_service
-wait_for_container_restart
-wait_for_smoke
-
-log "deployment finished"