Merge pull request #29 from codex/light-mode-toggle
feat(ui): add light theme toggle
This commit is contained in:
commit
c84de9aaa9
21 changed files with 564 additions and 30 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,15 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Dimension Lab dashboard runtime" />
|
||||
<title>Dimension Lab</title>
|
||||
<script>
|
||||
try {
|
||||
const theme = localStorage.getItem("dashboard-ui-theme");
|
||||
document.documentElement.dataset.uiTheme =
|
||||
theme === "light" ? "light" : "dark";
|
||||
} catch {
|
||||
document.documentElement.dataset.uiTheme = "dark";
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<AppStateView
|
||||
dashboard={{
|
||||
state: "loading",
|
||||
title: "Loading Dashboard",
|
||||
subtitle: "Fetching active model",
|
||||
message: "Waiting for the active dashboard document.",
|
||||
}}
|
||||
theme="dark"
|
||||
onThemeChange={() => 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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
43
src/App.tsx
43
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 ? (
|
||||
<ThemeToggle theme={theme} onThemeChange={onThemeChange} />
|
||||
) : null;
|
||||
|
||||
if (dashboard.state === "ready") {
|
||||
return (
|
||||
<DashboardFrame
|
||||
dashboard={dashboardDocumentToUiDashboard(dashboard.document)}
|
||||
actions={themeToggle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -32,6 +46,9 @@ export function AppStateView({
|
|||
|
||||
return (
|
||||
<main className="state-shell" data-dashboard-state={dashboard.state}>
|
||||
{themeToggle ? (
|
||||
<div className="state-shell__actions">{themeToggle}</div>
|
||||
) : null}
|
||||
<SystemState
|
||||
title={dashboard.title}
|
||||
detail={detail}
|
||||
|
|
@ -73,6 +90,11 @@ export function resolveDocumentMetadata(dashboard: DashboardRuntimeState): {
|
|||
export default function App() {
|
||||
const [dashboard, setDashboard] =
|
||||
useState<DashboardRuntimeState>(loadingDashboardState);
|
||||
const [theme, setTheme] = useState<UiTheme>(() => {
|
||||
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 <AppStateView dashboard={dashboard} />;
|
||||
return (
|
||||
<AppStateView
|
||||
dashboard={dashboard}
|
||||
theme={theme}
|
||||
onThemeChange={setTheme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
10
src/app.css
10
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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<main className="dashboard-frame" aria-labelledby={resolvedTitleId}>
|
||||
<header className="dashboard-frame__header">
|
||||
<div>
|
||||
{dashboard.eyebrow ? <p>{dashboard.eyebrow}</p> : null}
|
||||
<h1 id={resolvedTitleId}>{dashboard.title}</h1>
|
||||
{dashboard.subtitle ? <span>{dashboard.subtitle}</span> : null}
|
||||
<div className="dashboard-frame__title">
|
||||
<div className="dashboard-frame__title-copy">
|
||||
{dashboard.eyebrow ? <p>{dashboard.eyebrow}</p> : null}
|
||||
<h1 id={resolvedTitleId}>{dashboard.title}</h1>
|
||||
{dashboard.subtitle ? <span>{dashboard.subtitle}</span> : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="dashboard-frame__actions">{actions}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{dashboard.modules.length ? (
|
||||
<div className="dashboard-frame__modules">
|
||||
|
|
|
|||
29
src/lib/ui/components/ThemeToggle.tsx
Normal file
29
src/lib/ui/components/ThemeToggle.tsx
Normal file
|
|
@ -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 (
|
||||
<button
|
||||
aria-label={label}
|
||||
className="theme-toggle"
|
||||
data-ui-theme-toggle="true"
|
||||
onClick={() => onThemeChange(nextTheme)}
|
||||
type="button"
|
||||
>
|
||||
<IconGlyph
|
||||
name={theme === "dark" ? "mdi:weather-night" : "mdi:white-balance-sunny"}
|
||||
size="sm"
|
||||
/>
|
||||
<span>{theme === "dark" ? "Dark" : "Light"}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
26
src/lib/ui/stories/ThemeToggle.stories.tsx
Normal file
26
src/lib/ui/stories/ThemeToggle.stories.tsx
Normal file
|
|
@ -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<typeof ThemeToggle>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Dark: Story = {};
|
||||
|
||||
export const Light: Story = {
|
||||
args: {
|
||||
theme: "light",
|
||||
},
|
||||
globals: {
|
||||
theme: "light",
|
||||
},
|
||||
};
|
||||
|
|
@ -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"))
|
||||
|
|
|
|||
56
src/lib/ui/theme.test.ts
Normal file
56
src/lib/ui/theme.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
getNextUiTheme,
|
||||
isUiTheme,
|
||||
persistUiTheme,
|
||||
resolveInitialUiTheme,
|
||||
UI_THEME_STORAGE_KEY,
|
||||
} from "./theme";
|
||||
|
||||
describe("UI theme preference", () => {
|
||||
test("defaults to dark when no stored preference exists", () => {
|
||||
const storage = new Map<string, string>();
|
||||
|
||||
expect(resolveInitialUiTheme(storage)).toBe("dark");
|
||||
});
|
||||
|
||||
test("restores a valid stored preference", () => {
|
||||
const storage = new Map<string, string>([[UI_THEME_STORAGE_KEY, "light"]]);
|
||||
|
||||
expect(resolveInitialUiTheme(storage)).toBe("light");
|
||||
});
|
||||
|
||||
test("ignores invalid stored preferences", () => {
|
||||
const storage = new Map<string, string>([[UI_THEME_STORAGE_KEY, "solarized"]]);
|
||||
|
||||
expect(resolveInitialUiTheme(storage)).toBe("dark");
|
||||
});
|
||||
|
||||
test("falls back when stored preferences cannot be read", () => {
|
||||
const storage = {
|
||||
getItem() {
|
||||
throw new Error("storage blocked");
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveInitialUiTheme(storage)).toBe("dark");
|
||||
});
|
||||
|
||||
test("ignores persistence failures", () => {
|
||||
const storage = {
|
||||
setItem() {
|
||||
throw new Error("quota exceeded");
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => persistUiTheme("light", storage)).not.toThrow();
|
||||
});
|
||||
|
||||
test("detects and toggles supported themes", () => {
|
||||
expect(isUiTheme("light")).toBe(true);
|
||||
expect(isUiTheme("dark")).toBe(true);
|
||||
expect(isUiTheme("contrast")).toBe(false);
|
||||
expect(getNextUiTheme("dark")).toBe("light");
|
||||
expect(getNextUiTheme("light")).toBe("dark");
|
||||
});
|
||||
});
|
||||
68
src/lib/ui/theme.ts
Normal file
68
src/lib/ui/theme.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
export const UI_THEME_STORAGE_KEY = "dashboard-ui-theme";
|
||||
|
||||
export type UiTheme = "dark" | "light";
|
||||
|
||||
type ReadableThemeStorage =
|
||||
| { getItem(key: string): string | null }
|
||||
| { get(key: string): string | undefined };
|
||||
|
||||
type WritableThemeStorage =
|
||||
| { setItem(key: string, value: string): void }
|
||||
| { set(key: string, value: string): unknown };
|
||||
|
||||
export function isUiTheme(value: unknown): value is UiTheme {
|
||||
return value === "dark" || value === "light";
|
||||
}
|
||||
|
||||
export function getNextUiTheme(theme: UiTheme): UiTheme {
|
||||
return theme === "dark" ? "light" : "dark";
|
||||
}
|
||||
|
||||
export function resolveInitialUiTheme(
|
||||
storage?: ReadableThemeStorage | null,
|
||||
fallback: UiTheme = "dark",
|
||||
): UiTheme {
|
||||
const stored = safeReadStoredTheme(storage);
|
||||
|
||||
return isUiTheme(stored) ? stored : fallback;
|
||||
}
|
||||
|
||||
export function persistUiTheme(
|
||||
theme: UiTheme,
|
||||
storage?: WritableThemeStorage | null,
|
||||
): void {
|
||||
if (!storage) return;
|
||||
|
||||
try {
|
||||
if ("setItem" in storage) {
|
||||
storage.setItem(UI_THEME_STORAGE_KEY, theme);
|
||||
return;
|
||||
}
|
||||
|
||||
storage.set(UI_THEME_STORAGE_KEY, theme);
|
||||
} catch {
|
||||
// Browser storage may be blocked or quota-constrained.
|
||||
}
|
||||
}
|
||||
|
||||
function safeReadStoredTheme(
|
||||
storage?: ReadableThemeStorage | null,
|
||||
): string | undefined {
|
||||
try {
|
||||
return readStoredTheme(storage);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredTheme(
|
||||
storage?: ReadableThemeStorage | null,
|
||||
): string | undefined {
|
||||
if (!storage) return undefined;
|
||||
|
||||
if ("getItem" in storage) {
|
||||
return storage.getItem(UI_THEME_STORAGE_KEY) ?? undefined;
|
||||
}
|
||||
|
||||
return storage.get(UI_THEME_STORAGE_KEY);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 253 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 274 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 273 KiB After Width: | Height: | Size: 274 KiB |
33
tests/e2e/storybook-server.ts
Normal file
33
tests/e2e/storybook-server.ts
Normal file
|
|
@ -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}`);
|
||||
36
tests/e2e/storybook.spec.ts
Normal file
36
tests/e2e/storybook.spec.ts
Normal file
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue