diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 59d663d..964f47b 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -14,12 +14,40 @@ if (typeof window !== "undefined") { } 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: "#020302" }, { name: "raised", value: "#0b0d0c" }, + { name: "light canvas", value: "#f3f5ed" }, + { name: "light raised", value: "#eef1e7" }, ], }, controls: { diff --git a/index.html b/index.html index 5e47684..75dae71 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,15 @@ Dimension Lab +
diff --git a/playwright.config.ts b/playwright.config.ts index 2785c33..3593452 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,6 +2,8 @@ import { defineConfig, devices } from "@playwright/test"; const port = Number(process.env.PLAYWRIGHT_PORT || 4173); const baseURL = `http://127.0.0.1:${port}`; +const storybookPort = Number(process.env.PLAYWRIGHT_STORYBOOK_PORT || 6007); +const storybookURL = `http://127.0.0.1:${storybookPort}`; const databaseUrl = process.env.PLAYWRIGHT_DATABASE_URL || `file:./data/playwright-${process.pid}-${Date.now()}.sqlite`; @@ -17,12 +19,20 @@ export default defineConfig({ trace: "retain-on-failure", screenshot: "only-on-failure", }, - webServer: { - command: `DISABLE_LIVE_DATASOURCES=1 bun run build && DISABLE_LIVE_DATASOURCES=1 DATABASE_URL=${databaseUrl} HOST=127.0.0.1 PORT=${port} bun build/index.js`, - url: baseURL, - reuseExistingServer: false, - timeout: 120_000, - }, + webServer: [ + { + command: `DISABLE_LIVE_DATASOURCES=1 bun run build && DISABLE_LIVE_DATASOURCES=1 DATABASE_URL=${databaseUrl} HOST=127.0.0.1 PORT=${port} bun build/index.js`, + url: baseURL, + reuseExistingServer: false, + timeout: 120_000, + }, + { + command: `bun run build-storybook && STORYBOOK_STATIC_PORT=${storybookPort} bun tests/e2e/storybook-server.ts`, + url: storybookURL, + reuseExistingServer: false, + timeout: 120_000, + }, + ], projects: [ { name: "chromium-desktop", diff --git a/src/App.test.tsx b/src/App.test.tsx index c394ee9..b7abbc7 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -18,4 +18,24 @@ describe("React app dashboard state view", () => { expect(html).toContain("Loading Dashboard"); expect(html).toContain("Fetching active model"); }); + + test("renders an accessible theme toggle with the active theme", () => { + const html = renderToString( + undefined} + />, + ); + + expect(html).toContain('data-ui-theme-toggle="true"'); + expect(html).toContain('aria-label="Switch to light theme"'); + expect(html).not.toContain("aria-pressed"); + expect(html).toContain("Dark"); + }); }); diff --git a/src/App.tsx b/src/App.tsx index 81ec0c3..99cdc5e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,7 +3,11 @@ import type { DashboardRuntimeState } from "$lib/server/dashboard"; import { DashboardFrame, SystemState, + ThemeToggle, dashboardDocumentToUiDashboard, + persistUiTheme, + resolveInitialUiTheme, + type UiTheme, type UiSeverity, } from "$lib/ui"; @@ -16,13 +20,23 @@ const loadingDashboardState: DashboardRuntimeState = { export function AppStateView({ dashboard, + onThemeChange, + theme, }: { dashboard: DashboardRuntimeState; + onThemeChange?: (theme: UiTheme) => void; + theme?: UiTheme; }) { + const themeToggle = + theme && onThemeChange ? ( + + ) : null; + if (dashboard.state === "ready") { return ( ); } @@ -32,6 +46,9 @@ export function AppStateView({ return (
+ {themeToggle ? ( +
{themeToggle}
+ ) : null} (loadingDashboardState); + const [theme, setTheme] = useState(() => { + if (typeof window === "undefined") return "dark"; + + return resolveInitialUiTheme(getThemeStorage()); + }); useEffect(() => { const metadata = resolveDocumentMetadata(dashboard); @@ -89,6 +111,11 @@ export default function App() { description.content = metadata.description; }, [dashboard]); + useEffect(() => { + document.documentElement.dataset.uiTheme = theme; + persistUiTheme(theme, getThemeStorage()); + }, [theme]); + useEffect(() => { let cancelled = false; let refreshTimer: number | undefined; @@ -126,7 +153,13 @@ export default function App() { }; }, []); - return ; + return ( + + ); } function stateSeverity(state: DashboardRuntimeState["state"]): UiSeverity { @@ -140,3 +173,11 @@ function stateIcon(state: DashboardRuntimeState["state"]): string { if (state === "loading") return "mdi:progress-clock"; return "mdi:tray"; } + +function getThemeStorage(): Storage | undefined { + try { + return window.localStorage; + } catch { + return undefined; + } +} diff --git a/src/app.css b/src/app.css index 10a9ee9..e4fe069 100644 --- a/src/app.css +++ b/src/app.css @@ -107,8 +107,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); @@ -142,13 +142,17 @@ a { padding: var(--ui-space-4); } +.state-shell__actions { + justify-self: center; +} + .state-shell ul { display: grid; max-width: 56rem; gap: var(--ui-space-2); margin: 0; border: var(--ui-border); - background: rgba(6, 8, 7, 0.82); + background: var(--ui-color-surface-module); color: var(--ui-color-muted); font-size: 0.76rem; list-style-position: inside; diff --git a/src/lib/ui/components/DashboardFrame.tsx b/src/lib/ui/components/DashboardFrame.tsx index e8b8fe2..6a3a35b 100644 --- a/src/lib/ui/components/DashboardFrame.tsx +++ b/src/lib/ui/components/DashboardFrame.tsx @@ -1,4 +1,5 @@ import { useId } from "react"; +import type { ReactNode } from "react"; import type { UiDashboardPreview } from "../types"; import { ModuleCard } from "./ModuleCard"; import { ServicePanel } from "./ServicePanel"; @@ -6,21 +7,31 @@ import { StatusStrip } from "./StatusStrip"; import { TelemetryGrid } from "./TelemetryGrid"; export interface DashboardFrameProps { + actions?: ReactNode; dashboard: UiDashboardPreview; titleId?: string; } -export function DashboardFrame({ dashboard, titleId }: DashboardFrameProps) { +export function DashboardFrame({ + actions, + dashboard, + titleId, +}: DashboardFrameProps) { const generatedTitleId = useId(); const resolvedTitleId = titleId || `${generatedTitleId}-title`; return (
-
- {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([]); + }); +});