- {dashboard.eyebrow ?
{dashboard.eyebrow}
: null}
-
{dashboard.title}
- {dashboard.subtitle ?
{dashboard.subtitle} : null}
+
+
+ {dashboard.eyebrow ?
{dashboard.eyebrow}
: null}
+
{dashboard.title}
+ {dashboard.subtitle ?
{dashboard.subtitle} : null}
+
+ {actions ? (
+
{actions}
+ ) : null}
{dashboard.modules.length ? (
diff --git a/src/lib/ui/components/ThemeToggle.tsx b/src/lib/ui/components/ThemeToggle.tsx
new file mode 100644
index 0000000..1c8fb1a
--- /dev/null
+++ b/src/lib/ui/components/ThemeToggle.tsx
@@ -0,0 +1,29 @@
+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);
+ const label = `Switch to ${nextTheme} theme`;
+
+ return (
+
+ );
+}
diff --git a/src/lib/ui/components/styles.css b/src/lib/ui/components/styles.css
index d8111f4..086c0c4 100644
--- a/src/lib/ui/components/styles.css
+++ b/src/lib/ui/components/styles.css
@@ -8,8 +8,8 @@
overflow: hidden;
padding: clamp(0.42rem, 0.65vw, 0.62rem);
background:
- linear-gradient(90deg, transparent 0 49%, rgba(255, 255, 255, 0.08) 50%, transparent 51%),
- rgba(0, 0, 0, 0.18);
+ linear-gradient(90deg, transparent 0 49%, var(--ui-color-frame-spine) 50%, transparent 51%),
+ var(--ui-color-frame-wash);
}
.dashboard-frame__header,
@@ -26,6 +26,22 @@
padding-bottom: 0.34rem;
}
+.dashboard-frame__title {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: var(--ui-space-3);
+ align-items: start;
+ min-width: 0;
+}
+
+.dashboard-frame__title-copy {
+ min-width: 0;
+}
+
+.dashboard-frame__actions {
+ justify-self: end;
+}
+
.dashboard-header {
grid-template-columns: minmax(0, 1fr) auto;
padding: var(--ui-space-3);
@@ -99,7 +115,7 @@
.panel,
.system-state {
border: var(--ui-border);
- background: rgba(3, 4, 3, 0.86);
+ background: var(--ui-color-surface-panel);
color: var(--ui-color-text);
}
@@ -107,7 +123,11 @@
display: grid;
min-height: 5.15rem;
gap: 0.25rem;
- background: linear-gradient(180deg, rgba(13, 15, 14, 0.82), rgba(2, 3, 2, 0.92));
+ background: linear-gradient(
+ 180deg,
+ var(--ui-color-card-gradient-start),
+ var(--ui-color-card-gradient-end)
+ );
padding: 0.45rem 0.55rem 0.42rem;
}
@@ -212,7 +232,7 @@
.progress-meter {
height: 0.25rem;
border: var(--ui-border);
- background: #030403;
+ background: var(--ui-color-surface-inset);
}
.telemetry-card__bar span,
@@ -271,7 +291,7 @@
align-items: start;
min-width: 0;
min-height: 4.15rem;
- background: rgba(6, 8, 7, 0.82);
+ background: var(--ui-color-surface-module);
padding: 0.5rem;
}
@@ -297,7 +317,7 @@
.module-card[data-severity="ok"] .icon-glyph {
background: var(--ui-color-accent);
- color: #050605;
+ color: var(--ui-color-accent-contrast);
}
.module-card[data-severity="warning"] {
@@ -348,7 +368,7 @@
.service-row:where(a):hover {
border-bottom-color: var(--ui-color-accent);
- background: rgba(244, 244, 244, 0.035);
+ background: var(--ui-color-hover);
}
.service-row__main {
@@ -398,7 +418,7 @@
grid-template-columns: auto minmax(0, 1fr);
min-height: 1.9rem;
align-items: center;
- background: rgba(2, 3, 2, 0.92);
+ background: var(--ui-color-surface-footer);
color: inherit;
text-decoration: none;
}
@@ -426,7 +446,7 @@
.footer-cell[data-severity="ok"] strong {
background: var(--ui-color-accent);
- color: #060706;
+ color: var(--ui-color-accent-contrast);
}
.footer-cell[data-severity="warning"] strong {
@@ -539,18 +559,48 @@
width: 2.5rem;
height: 2.5rem;
place-items: center;
- background: rgba(2, 3, 2, 0.92);
+ background: var(--ui-color-surface-footer);
color: var(--ui-color-muted);
padding: 0;
}
+.theme-toggle {
+ display: inline-grid;
+ grid-auto-flow: column;
+ gap: var(--ui-space-2);
+ align-items: center;
+ justify-content: center;
+ min-width: 6.5rem;
+ min-height: 2.15rem;
+ border: var(--ui-border);
+ background: var(--ui-color-surface-raised);
+ color: var(--ui-color-text);
+ cursor: pointer;
+ font: inherit;
+ font-size: 0.62rem;
+ font-weight: 850;
+ letter-spacing: 0;
+ padding: 0 var(--ui-space-3);
+ text-transform: uppercase;
+}
+
+.theme-toggle:hover {
+ border-color: var(--ui-color-accent);
+ color: var(--ui-color-accent);
+}
+
+.theme-toggle span {
+ line-height: 1;
+ white-space: nowrap;
+}
+
.icon-glyph {
display: inline-grid;
width: 1.55rem;
height: 1.55rem;
place-items: center;
border: var(--ui-border);
- background: #050605;
+ background: var(--ui-color-surface-icon);
color: currentColor;
line-height: 1;
}
@@ -617,6 +667,7 @@
}
.dashboard-frame__header,
+ .dashboard-frame__title,
.dashboard-header,
.dashboard-frame__modules,
.dashboard-frame__panels {
diff --git a/src/lib/ui/index.ts b/src/lib/ui/index.ts
index 864306c..9eb5448 100644
--- a/src/lib/ui/index.ts
+++ b/src/lib/ui/index.ts
@@ -26,9 +26,17 @@ export { SystemState } from "./components/SystemState";
export { TelemetryCard } from "./components/TelemetryCard";
export { TelemetryGrid } from "./components/TelemetryGrid";
export { TelemetryStrip } from "./components/TelemetryStrip";
+export { ThemeToggle } from "./components/ThemeToggle";
export { WeatherModule } from "./components/WeatherModule";
export { dashboardPreviewFixtures } from "./fixtures";
export { dashboardDocumentToUiDashboard } from "./model-renderer";
+export {
+ getNextUiTheme,
+ isUiTheme,
+ persistUiTheme,
+ resolveInitialUiTheme,
+ UI_THEME_STORAGE_KEY,
+} from "./theme";
export type {
UiDashboardPreview,
UiLink,
@@ -40,3 +48,4 @@ export type {
UiStatusItem,
UiTelemetryCard,
} from "./types";
+export type { UiTheme } from "./theme";
diff --git a/src/lib/ui/stories/ThemeToggle.stories.tsx b/src/lib/ui/stories/ThemeToggle.stories.tsx
new file mode 100644
index 0000000..33e494a
--- /dev/null
+++ b/src/lib/ui/stories/ThemeToggle.stories.tsx
@@ -0,0 +1,26 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { ThemeToggle } from "../components/ThemeToggle";
+
+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/src/lib/ui/storybook.test.ts b/src/lib/ui/storybook.test.ts
index f1303c7..aa76cad 100644
--- a/src/lib/ui/storybook.test.ts
+++ b/src/lib/ui/storybook.test.ts
@@ -36,6 +36,7 @@ const requiredStoryFiles = [
"TelemetryCard.stories.tsx",
"TelemetryGrid.stories.tsx",
"TelemetryStrip.stories.tsx",
+ "ThemeToggle.stories.tsx",
"WeatherModule.stories.tsx",
] as const;
@@ -74,6 +75,13 @@ describe("Storybook inventory", () => {
expect(dashboardFrame).not.toContain('./styles.css');
});
+ test("configures Storybook theme switching for reusable components", () => {
+ const previewSource = readFileSync(join(root, ".storybook/preview.ts"), "utf8");
+
+ expect(previewSource).toContain("globalTypes");
+ expect(previewSource).toContain("data-ui-theme");
+ });
+
test("keeps component and story files paired as the UI inventory changes", () => {
const componentStoryFiles = readdirSync(componentsDir)
.filter((filename) => filename.endsWith(".tsx") && !filename.endsWith(".test.tsx"))
diff --git a/src/lib/ui/theme.test.ts b/src/lib/ui/theme.test.ts
new file mode 100644
index 0000000..8ed3f04
--- /dev/null
+++ b/src/lib/ui/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/src/lib/ui/theme.ts b/src/lib/ui/theme.ts
new file mode 100644
index 0000000..9f48df5
--- /dev/null
+++ b/src/lib/ui/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/src/lib/ui/tokens.css b/src/lib/ui/tokens.css
index 56c2f7e..3e70132 100644
--- a/src/lib/ui/tokens.css
+++ b/src/lib/ui/tokens.css
@@ -1,14 +1,27 @@
-:root {
+:root,
+[data-ui-theme="dark"] {
color-scheme: dark;
--ui-color-canvas: #020302;
--ui-color-surface: #060706;
--ui-color-surface-raised: #0b0d0c;
+ --ui-color-surface-panel: rgba(3, 4, 3, 0.86);
+ --ui-color-surface-module: rgba(6, 8, 7, 0.82);
+ --ui-color-surface-footer: rgba(2, 3, 2, 0.92);
+ --ui-color-surface-inset: #030403;
+ --ui-color-surface-icon: #050605;
--ui-color-line: rgba(244, 244, 244, 0.16);
--ui-color-line-strong: rgba(244, 244, 244, 0.32);
+ --ui-color-grid-line: rgba(255, 255, 255, 0.035);
+ --ui-color-frame-spine: rgba(255, 255, 255, 0.08);
+ --ui-color-frame-wash: rgba(0, 0, 0, 0.18);
+ --ui-color-card-gradient-start: rgba(13, 15, 14, 0.82);
+ --ui-color-card-gradient-end: rgba(2, 3, 2, 0.92);
+ --ui-color-hover: rgba(244, 244, 244, 0.035);
--ui-color-text: #f3f4ed;
--ui-color-muted: #8d948c;
--ui-color-dim: #555b55;
--ui-color-accent: #d7ff00;
+ --ui-color-accent-contrast: #050605;
--ui-color-ok: #bfff00;
--ui-color-warning: #ffb020;
--ui-color-danger: #ff1744;
@@ -33,6 +46,37 @@
--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-canvas: #f3f5ed;
+ --ui-color-surface: #ffffff;
+ --ui-color-surface-raised: #eef1e7;
+ --ui-color-surface-panel: rgba(255, 255, 255, 0.9);
+ --ui-color-surface-module: rgba(255, 255, 255, 0.84);
+ --ui-color-surface-footer: rgba(248, 250, 241, 0.94);
+ --ui-color-surface-inset: #e4e8db;
+ --ui-color-surface-icon: #f8faf1;
+ --ui-color-line: rgba(19, 25, 19, 0.18);
+ --ui-color-line-strong: rgba(19, 25, 19, 0.34);
+ --ui-color-grid-line: rgba(19, 25, 19, 0.055);
+ --ui-color-frame-spine: rgba(19, 25, 19, 0.1);
+ --ui-color-frame-wash: rgba(255, 255, 255, 0.48);
+ --ui-color-card-gradient-start: rgba(255, 255, 255, 0.92);
+ --ui-color-card-gradient-end: rgba(230, 235, 221, 0.88);
+ --ui-color-hover: rgba(19, 25, 19, 0.055);
+ --ui-color-text: #11170f;
+ --ui-color-muted: #5d665a;
+ --ui-color-dim: #7d8779;
+ --ui-color-accent: #516f00;
+ --ui-color-accent-contrast: #ffffff;
+ --ui-color-ok: #4c7400;
+ --ui-color-warning: #a45b00;
+ --ui-color-danger: #bd173f;
+ --ui-color-stale: #596975;
+ --ui-color-unavailable: #6c7479;
+ --ui-shadow-hard: 0 0 0 1px rgba(81, 111, 0, 0.18) inset;
+}
+
html {
background: var(--ui-color-canvas);
}
@@ -42,8 +86,8 @@ body {
min-height: 100vh;
margin: 0;
background:
- linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px),
- linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px),
+ 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: 48px 48px, 48px 48px, auto;
color: var(--ui-color-text);
diff --git a/tests/e2e/dashboard.spec.ts b/tests/e2e/dashboard.spec.ts
index b432d14..7110900 100644
--- a/tests/e2e/dashboard.spec.ts
+++ b/tests/e2e/dashboard.spec.ts
@@ -209,6 +209,16 @@ test.describe("dashboard page QA gate", () => {
await waitForDashboardReady(page);
await expect(page.getByRole("main")).toHaveCount(1);
+ await page.keyboard.press("Tab");
+ const themeToggle = page.getByRole("button", {
+ name: "Switch to light theme",
+ });
+ await expect(themeToggle).toBeFocused();
+ const themeFocusBoxShadow = await themeToggle.evaluate((element) => {
+ return window.getComputedStyle(element).boxShadow;
+ });
+ expect(themeFocusBoxShadow).not.toBe("none");
+
for (const serviceId of linkedServiceIds) {
await page.keyboard.press("Tab");
@@ -230,6 +240,24 @@ test.describe("dashboard page QA gate", () => {
expect(results.violations).toEqual([]);
});
+ test("passes automated accessibility checks in light mode", async ({
+ page,
+ }, testInfo) => {
+ test.skip(testInfo.project.name !== "chromium-desktop");
+
+ await page.goto("/");
+ await waitForDashboardReady(page);
+ await page.getByRole("button", { name: "Switch to light theme" }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-ui-theme", "light");
+
+ const results = await new AxeBuilder({ page }).analyze();
+ expect(results.violations).toEqual([]);
+
+ await expect(page).toHaveScreenshot("dashboard-light-desktop.png", {
+ fullPage: true,
+ });
+ });
+
test("honors reduced-motion preferences", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
@@ -255,6 +283,29 @@ test.describe("dashboard page QA gate", () => {
0.01,
);
});
+
+ test("toggles the dashboard between dark and light themes", async ({ page }) => {
+ await page.goto("/");
+ await waitForDashboardReady(page);
+
+ await expect(page.locator("html")).toHaveAttribute("data-ui-theme", "dark");
+
+ const switchToLight = page.getByRole("button", {
+ name: "Switch to light theme",
+ });
+ await expect(switchToLight).toBeVisible();
+ await switchToLight.click();
+
+ await expect(page.locator("html")).toHaveAttribute("data-ui-theme", "light");
+ await expect(
+ page.getByRole("button", { name: "Switch to dark theme" }),
+ ).toBeVisible();
+
+ await page.reload();
+ await waitForDashboardReady(page);
+
+ await expect(page.locator("html")).toHaveAttribute("data-ui-theme", "light");
+ });
});
async function waitForDashboardReady(page: Page): Promise {
diff --git a/tests/e2e/dashboard.spec.ts-snapshots/dashboard-desktop-chromium-desktop-linux.png b/tests/e2e/dashboard.spec.ts-snapshots/dashboard-desktop-chromium-desktop-linux.png
index fa3b3bf..848033e 100644
Binary files a/tests/e2e/dashboard.spec.ts-snapshots/dashboard-desktop-chromium-desktop-linux.png and b/tests/e2e/dashboard.spec.ts-snapshots/dashboard-desktop-chromium-desktop-linux.png differ
diff --git a/tests/e2e/dashboard.spec.ts-snapshots/dashboard-light-desktop-chromium-desktop-linux.png b/tests/e2e/dashboard.spec.ts-snapshots/dashboard-light-desktop-chromium-desktop-linux.png
new file mode 100644
index 0000000..5b04c30
Binary files /dev/null and b/tests/e2e/dashboard.spec.ts-snapshots/dashboard-light-desktop-chromium-desktop-linux.png differ
diff --git a/tests/e2e/dashboard.spec.ts-snapshots/dashboard-mobile-chromium-mobile-linux.png b/tests/e2e/dashboard.spec.ts-snapshots/dashboard-mobile-chromium-mobile-linux.png
index a199b15..1f6baf4 100644
Binary files a/tests/e2e/dashboard.spec.ts-snapshots/dashboard-mobile-chromium-mobile-linux.png and b/tests/e2e/dashboard.spec.ts-snapshots/dashboard-mobile-chromium-mobile-linux.png differ
diff --git a/tests/e2e/storybook-server.ts b/tests/e2e/storybook-server.ts
new file mode 100644
index 0000000..e6e3f63
--- /dev/null
+++ b/tests/e2e/storybook-server.ts
@@ -0,0 +1,33 @@
+import { existsSync } from "node:fs";
+import { resolve, sep } from "node:path";
+
+const root = resolve(process.cwd(), "storybook-static");
+const port = Number(process.env.STORYBOOK_STATIC_PORT || 6007);
+
+if (!existsSync(resolve(root, "iframe.html"))) {
+ throw new Error("storybook-static is missing. Run bun run build-storybook first.");
+}
+
+Bun.serve({
+ hostname: "127.0.0.1",
+ port,
+ async fetch(request) {
+ const url = new URL(request.url);
+ const pathname = decodeURIComponent(url.pathname);
+ const relativePath = pathname === "/" ? "/index.html" : pathname;
+ const filePath = resolve(root, `.${relativePath}`);
+
+ if (!filePath.startsWith(`${root}${sep}`)) {
+ return new Response("Forbidden", { status: 403 });
+ }
+
+ const file = Bun.file(filePath);
+ if (!(await file.exists())) {
+ return new Response("Not found", { status: 404 });
+ }
+
+ return new Response(file);
+ },
+});
+
+console.log(`Storybook static listening on http://127.0.0.1:${port}`);
diff --git a/tests/e2e/storybook.spec.ts b/tests/e2e/storybook.spec.ts
new file mode 100644
index 0000000..7873a02
--- /dev/null
+++ b/tests/e2e/storybook.spec.ts
@@ -0,0 +1,36 @@
+import { expect, test } from "@playwright/test";
+
+const storybookPort = Number(process.env.PLAYWRIGHT_STORYBOOK_PORT || 6007);
+const storybookURL = `http://127.0.0.1:${storybookPort}`;
+
+test.describe("Storybook theme QA", () => {
+ test("renders the ThemeToggle light story on the light canvas", async ({
+ page,
+ }, testInfo) => {
+ test.skip(testInfo.project.name !== "chromium-desktop");
+
+ const consoleMessages: string[] = [];
+ page.on("console", (message) => {
+ if (["error", "warning"].includes(message.type())) {
+ consoleMessages.push(message.text());
+ }
+ });
+
+ const url = `${storybookURL}/iframe.html?id=ui-themetoggle--light&viewMode=story`;
+ await page.goto(url, { waitUntil: "networkidle" });
+
+ await expect(page.locator("html")).toHaveAttribute("data-ui-theme", "light");
+ await expect(
+ page.getByRole("button", { name: "Switch to dark theme" }),
+ ).toBeVisible();
+ await expect(page.locator("body")).toHaveCSS(
+ "background-color",
+ "rgb(243, 245, 237)",
+ );
+ expect(
+ consoleMessages.filter((message) =>
+ message.includes("Global args/argTypes can only be set globally"),
+ ),
+ ).toEqual([]);
+ });
+});