feat(ui): add light theme toggle
This commit is contained in:
parent
bbc81cea0d
commit
cdb269e505
17 changed files with 415 additions and 24 deletions
|
|
@ -14,12 +14,40 @@ if (typeof window !== "undefined") {
|
||||||
}
|
}
|
||||||
|
|
||||||
const preview: Preview = {
|
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: {
|
parameters: {
|
||||||
backgrounds: {
|
backgrounds: {
|
||||||
default: "canvas",
|
default: "canvas",
|
||||||
values: [
|
values: [
|
||||||
{ name: "canvas", value: "#020302" },
|
{ name: "canvas", value: "#020302" },
|
||||||
{ name: "raised", value: "#0b0d0c" },
|
{ name: "raised", value: "#0b0d0c" },
|
||||||
|
{ name: "light canvas", value: "#f3f5ed" },
|
||||||
|
{ name: "light raised", value: "#eef1e7" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
controls: {
|
controls: {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,15 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="Dimension Lab dashboard runtime" />
|
<meta name="description" content="Dimension Lab dashboard runtime" />
|
||||||
<title>Dimension Lab</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -18,4 +18,23 @@ describe("React app dashboard state view", () => {
|
||||||
expect(html).toContain("Loading Dashboard");
|
expect(html).toContain("Loading Dashboard");
|
||||||
expect(html).toContain("Fetching active model");
|
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).toContain("Dark");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
35
src/App.tsx
35
src/App.tsx
|
|
@ -3,7 +3,11 @@ import type { DashboardRuntimeState } from "$lib/server/dashboard";
|
||||||
import {
|
import {
|
||||||
DashboardFrame,
|
DashboardFrame,
|
||||||
SystemState,
|
SystemState,
|
||||||
|
ThemeToggle,
|
||||||
dashboardDocumentToUiDashboard,
|
dashboardDocumentToUiDashboard,
|
||||||
|
persistUiTheme,
|
||||||
|
resolveInitialUiTheme,
|
||||||
|
type UiTheme,
|
||||||
type UiSeverity,
|
type UiSeverity,
|
||||||
} from "$lib/ui";
|
} from "$lib/ui";
|
||||||
|
|
||||||
|
|
@ -16,13 +20,23 @@ const loadingDashboardState: DashboardRuntimeState = {
|
||||||
|
|
||||||
export function AppStateView({
|
export function AppStateView({
|
||||||
dashboard,
|
dashboard,
|
||||||
|
onThemeChange,
|
||||||
|
theme,
|
||||||
}: {
|
}: {
|
||||||
dashboard: DashboardRuntimeState;
|
dashboard: DashboardRuntimeState;
|
||||||
|
onThemeChange?: (theme: UiTheme) => void;
|
||||||
|
theme?: UiTheme;
|
||||||
}) {
|
}) {
|
||||||
|
const themeToggle =
|
||||||
|
theme && onThemeChange ? (
|
||||||
|
<ThemeToggle theme={theme} onThemeChange={onThemeChange} />
|
||||||
|
) : null;
|
||||||
|
|
||||||
if (dashboard.state === "ready") {
|
if (dashboard.state === "ready") {
|
||||||
return (
|
return (
|
||||||
<DashboardFrame
|
<DashboardFrame
|
||||||
dashboard={dashboardDocumentToUiDashboard(dashboard.document)}
|
dashboard={dashboardDocumentToUiDashboard(dashboard.document)}
|
||||||
|
actions={themeToggle}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -32,6 +46,9 @@ export function AppStateView({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="state-shell" data-dashboard-state={dashboard.state}>
|
<main className="state-shell" data-dashboard-state={dashboard.state}>
|
||||||
|
{themeToggle ? (
|
||||||
|
<div className="state-shell__actions">{themeToggle}</div>
|
||||||
|
) : null}
|
||||||
<SystemState
|
<SystemState
|
||||||
title={dashboard.title}
|
title={dashboard.title}
|
||||||
detail={detail}
|
detail={detail}
|
||||||
|
|
@ -73,6 +90,11 @@ export function resolveDocumentMetadata(dashboard: DashboardRuntimeState): {
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [dashboard, setDashboard] =
|
const [dashboard, setDashboard] =
|
||||||
useState<DashboardRuntimeState>(loadingDashboardState);
|
useState<DashboardRuntimeState>(loadingDashboardState);
|
||||||
|
const [theme, setTheme] = useState<UiTheme>(() => {
|
||||||
|
if (typeof window === "undefined") return "dark";
|
||||||
|
|
||||||
|
return resolveInitialUiTheme(window.localStorage);
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const metadata = resolveDocumentMetadata(dashboard);
|
const metadata = resolveDocumentMetadata(dashboard);
|
||||||
|
|
@ -89,6 +111,11 @@ export default function App() {
|
||||||
description.content = metadata.description;
|
description.content = metadata.description;
|
||||||
}, [dashboard]);
|
}, [dashboard]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.dataset.uiTheme = theme;
|
||||||
|
persistUiTheme(theme, window.localStorage);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let refreshTimer: number | undefined;
|
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 {
|
function stateSeverity(state: DashboardRuntimeState["state"]): UiSeverity {
|
||||||
|
|
|
||||||
10
src/app.css
10
src/app.css
|
|
@ -107,8 +107,8 @@ body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background:
|
background:
|
||||||
linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px),
|
linear-gradient(var(--ui-color-grid-line) 1px, transparent 1px),
|
||||||
linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px),
|
linear-gradient(90deg, var(--ui-color-grid-line) 1px, transparent 1px),
|
||||||
var(--ui-color-canvas);
|
var(--ui-color-canvas);
|
||||||
background-size: 48px 48px, 48px 48px, auto;
|
background-size: 48px 48px, 48px 48px, auto;
|
||||||
color: var(--ui-color-text);
|
color: var(--ui-color-text);
|
||||||
|
|
@ -142,13 +142,17 @@ a {
|
||||||
padding: var(--ui-space-4);
|
padding: var(--ui-space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.state-shell__actions {
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
.state-shell ul {
|
.state-shell ul {
|
||||||
display: grid;
|
display: grid;
|
||||||
max-width: 56rem;
|
max-width: 56rem;
|
||||||
gap: var(--ui-space-2);
|
gap: var(--ui-space-2);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border: var(--ui-border);
|
border: var(--ui-border);
|
||||||
background: rgba(6, 8, 7, 0.82);
|
background: var(--ui-color-surface-module);
|
||||||
color: var(--ui-color-muted);
|
color: var(--ui-color-muted);
|
||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
list-style-position: inside;
|
list-style-position: inside;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useId } from "react";
|
import { useId } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import type { UiDashboardPreview } from "../types";
|
import type { UiDashboardPreview } from "../types";
|
||||||
import { ModuleCard } from "./ModuleCard";
|
import { ModuleCard } from "./ModuleCard";
|
||||||
import { ServicePanel } from "./ServicePanel";
|
import { ServicePanel } from "./ServicePanel";
|
||||||
|
|
@ -6,22 +7,32 @@ import { StatusStrip } from "./StatusStrip";
|
||||||
import { TelemetryGrid } from "./TelemetryGrid";
|
import { TelemetryGrid } from "./TelemetryGrid";
|
||||||
|
|
||||||
export interface DashboardFrameProps {
|
export interface DashboardFrameProps {
|
||||||
|
actions?: ReactNode;
|
||||||
dashboard: UiDashboardPreview;
|
dashboard: UiDashboardPreview;
|
||||||
titleId?: string;
|
titleId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardFrame({ dashboard, titleId }: DashboardFrameProps) {
|
export function DashboardFrame({
|
||||||
|
actions,
|
||||||
|
dashboard,
|
||||||
|
titleId,
|
||||||
|
}: DashboardFrameProps) {
|
||||||
const generatedTitleId = useId();
|
const generatedTitleId = useId();
|
||||||
const resolvedTitleId = titleId || `${generatedTitleId}-title`;
|
const resolvedTitleId = titleId || `${generatedTitleId}-title`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="dashboard-frame" aria-labelledby={resolvedTitleId}>
|
<main className="dashboard-frame" aria-labelledby={resolvedTitleId}>
|
||||||
<header className="dashboard-frame__header">
|
<header className="dashboard-frame__header">
|
||||||
<div>
|
<div className="dashboard-frame__title">
|
||||||
|
<div className="dashboard-frame__title-copy">
|
||||||
{dashboard.eyebrow ? <p>{dashboard.eyebrow}</p> : null}
|
{dashboard.eyebrow ? <p>{dashboard.eyebrow}</p> : null}
|
||||||
<h1 id={resolvedTitleId}>{dashboard.title}</h1>
|
<h1 id={resolvedTitleId}>{dashboard.title}</h1>
|
||||||
{dashboard.subtitle ? <span>{dashboard.subtitle}</span> : null}
|
{dashboard.subtitle ? <span>{dashboard.subtitle}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
|
{actions ? (
|
||||||
|
<div className="dashboard-frame__actions">{actions}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
{dashboard.modules.length ? (
|
{dashboard.modules.length ? (
|
||||||
<div className="dashboard-frame__modules">
|
<div className="dashboard-frame__modules">
|
||||||
{dashboard.modules.map((module) => (
|
{dashboard.modules.map((module) => (
|
||||||
|
|
|
||||||
30
src/lib/ui/components/ThemeToggle.tsx
Normal file
30
src/lib/ui/components/ThemeToggle.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
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}
|
||||||
|
aria-pressed={theme === "dark"}
|
||||||
|
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;
|
overflow: hidden;
|
||||||
padding: clamp(0.42rem, 0.65vw, 0.62rem);
|
padding: clamp(0.42rem, 0.65vw, 0.62rem);
|
||||||
background:
|
background:
|
||||||
linear-gradient(90deg, transparent 0 49%, rgba(255, 255, 255, 0.08) 50%, transparent 51%),
|
linear-gradient(90deg, transparent 0 49%, var(--ui-color-frame-spine) 50%, transparent 51%),
|
||||||
rgba(0, 0, 0, 0.18);
|
var(--ui-color-frame-wash);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-frame__header,
|
.dashboard-frame__header,
|
||||||
|
|
@ -26,6 +26,22 @@
|
||||||
padding-bottom: 0.34rem;
|
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 {
|
.dashboard-header {
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
padding: var(--ui-space-3);
|
padding: var(--ui-space-3);
|
||||||
|
|
@ -99,7 +115,7 @@
|
||||||
.panel,
|
.panel,
|
||||||
.system-state {
|
.system-state {
|
||||||
border: var(--ui-border);
|
border: var(--ui-border);
|
||||||
background: rgba(3, 4, 3, 0.86);
|
background: var(--ui-color-surface-panel);
|
||||||
color: var(--ui-color-text);
|
color: var(--ui-color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,7 +123,11 @@
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 5.15rem;
|
min-height: 5.15rem;
|
||||||
gap: 0.25rem;
|
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;
|
padding: 0.45rem 0.55rem 0.42rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -212,7 +232,7 @@
|
||||||
.progress-meter {
|
.progress-meter {
|
||||||
height: 0.25rem;
|
height: 0.25rem;
|
||||||
border: var(--ui-border);
|
border: var(--ui-border);
|
||||||
background: #030403;
|
background: var(--ui-color-surface-inset);
|
||||||
}
|
}
|
||||||
|
|
||||||
.telemetry-card__bar span,
|
.telemetry-card__bar span,
|
||||||
|
|
@ -271,7 +291,7 @@
|
||||||
align-items: start;
|
align-items: start;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 4.15rem;
|
min-height: 4.15rem;
|
||||||
background: rgba(6, 8, 7, 0.82);
|
background: var(--ui-color-surface-module);
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -297,7 +317,7 @@
|
||||||
|
|
||||||
.module-card[data-severity="ok"] .icon-glyph {
|
.module-card[data-severity="ok"] .icon-glyph {
|
||||||
background: var(--ui-color-accent);
|
background: var(--ui-color-accent);
|
||||||
color: #050605;
|
color: var(--ui-color-accent-contrast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-card[data-severity="warning"] {
|
.module-card[data-severity="warning"] {
|
||||||
|
|
@ -348,7 +368,7 @@
|
||||||
|
|
||||||
.service-row:where(a):hover {
|
.service-row:where(a):hover {
|
||||||
border-bottom-color: var(--ui-color-accent);
|
border-bottom-color: var(--ui-color-accent);
|
||||||
background: rgba(244, 244, 244, 0.035);
|
background: var(--ui-color-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.service-row__main {
|
.service-row__main {
|
||||||
|
|
@ -398,7 +418,7 @@
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
min-height: 1.9rem;
|
min-height: 1.9rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: rgba(2, 3, 2, 0.92);
|
background: var(--ui-color-surface-footer);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
@ -426,7 +446,7 @@
|
||||||
|
|
||||||
.footer-cell[data-severity="ok"] strong {
|
.footer-cell[data-severity="ok"] strong {
|
||||||
background: var(--ui-color-accent);
|
background: var(--ui-color-accent);
|
||||||
color: #060706;
|
color: var(--ui-color-accent-contrast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-cell[data-severity="warning"] strong {
|
.footer-cell[data-severity="warning"] strong {
|
||||||
|
|
@ -539,18 +559,48 @@
|
||||||
width: 2.5rem;
|
width: 2.5rem;
|
||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
background: rgba(2, 3, 2, 0.92);
|
background: var(--ui-color-surface-footer);
|
||||||
color: var(--ui-color-muted);
|
color: var(--ui-color-muted);
|
||||||
padding: 0;
|
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 {
|
.icon-glyph {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
width: 1.55rem;
|
width: 1.55rem;
|
||||||
height: 1.55rem;
|
height: 1.55rem;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border: var(--ui-border);
|
border: var(--ui-border);
|
||||||
background: #050605;
|
background: var(--ui-color-surface-icon);
|
||||||
color: currentColor;
|
color: currentColor;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
@ -617,6 +667,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-frame__header,
|
.dashboard-frame__header,
|
||||||
|
.dashboard-frame__title,
|
||||||
.dashboard-header,
|
.dashboard-header,
|
||||||
.dashboard-frame__modules,
|
.dashboard-frame__modules,
|
||||||
.dashboard-frame__panels {
|
.dashboard-frame__panels {
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,17 @@ export { SystemState } from "./components/SystemState";
|
||||||
export { TelemetryCard } from "./components/TelemetryCard";
|
export { TelemetryCard } from "./components/TelemetryCard";
|
||||||
export { TelemetryGrid } from "./components/TelemetryGrid";
|
export { TelemetryGrid } from "./components/TelemetryGrid";
|
||||||
export { TelemetryStrip } from "./components/TelemetryStrip";
|
export { TelemetryStrip } from "./components/TelemetryStrip";
|
||||||
|
export { ThemeToggle } from "./components/ThemeToggle";
|
||||||
export { WeatherModule } from "./components/WeatherModule";
|
export { WeatherModule } from "./components/WeatherModule";
|
||||||
export { dashboardPreviewFixtures } from "./fixtures";
|
export { dashboardPreviewFixtures } from "./fixtures";
|
||||||
export { dashboardDocumentToUiDashboard } from "./model-renderer";
|
export { dashboardDocumentToUiDashboard } from "./model-renderer";
|
||||||
|
export {
|
||||||
|
getNextUiTheme,
|
||||||
|
isUiTheme,
|
||||||
|
persistUiTheme,
|
||||||
|
resolveInitialUiTheme,
|
||||||
|
UI_THEME_STORAGE_KEY,
|
||||||
|
} from "./theme";
|
||||||
export type {
|
export type {
|
||||||
UiDashboardPreview,
|
UiDashboardPreview,
|
||||||
UiLink,
|
UiLink,
|
||||||
|
|
@ -40,3 +48,4 @@ export type {
|
||||||
UiStatusItem,
|
UiStatusItem,
|
||||||
UiTelemetryCard,
|
UiTelemetryCard,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
export type { UiTheme } from "./theme";
|
||||||
|
|
|
||||||
23
src/lib/ui/stories/ThemeToggle.stories.tsx
Normal file
23
src/lib/ui/stories/ThemeToggle.stories.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -36,6 +36,7 @@ const requiredStoryFiles = [
|
||||||
"TelemetryCard.stories.tsx",
|
"TelemetryCard.stories.tsx",
|
||||||
"TelemetryGrid.stories.tsx",
|
"TelemetryGrid.stories.tsx",
|
||||||
"TelemetryStrip.stories.tsx",
|
"TelemetryStrip.stories.tsx",
|
||||||
|
"ThemeToggle.stories.tsx",
|
||||||
"WeatherModule.stories.tsx",
|
"WeatherModule.stories.tsx",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
@ -74,6 +75,13 @@ describe("Storybook inventory", () => {
|
||||||
expect(dashboardFrame).not.toContain('./styles.css');
|
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", () => {
|
test("keeps component and story files paired as the UI inventory changes", () => {
|
||||||
const componentStoryFiles = readdirSync(componentsDir)
|
const componentStoryFiles = readdirSync(componentsDir)
|
||||||
.filter((filename) => filename.endsWith(".tsx") && !filename.endsWith(".test.tsx"))
|
.filter((filename) => filename.endsWith(".tsx") && !filename.endsWith(".test.tsx"))
|
||||||
|
|
|
||||||
35
src/lib/ui/theme.test.ts
Normal file
35
src/lib/ui/theme.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import {
|
||||||
|
getNextUiTheme,
|
||||||
|
isUiTheme,
|
||||||
|
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("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");
|
||||||
|
});
|
||||||
|
});
|
||||||
54
src/lib/ui/theme.ts
Normal file
54
src/lib/ui/theme.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
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 = readStoredTheme(storage);
|
||||||
|
|
||||||
|
return isUiTheme(stored) ? stored : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistUiTheme(
|
||||||
|
theme: UiTheme,
|
||||||
|
storage?: WritableThemeStorage | null,
|
||||||
|
): void {
|
||||||
|
if (!storage) return;
|
||||||
|
|
||||||
|
if ("setItem" in storage) {
|
||||||
|
storage.setItem(UI_THEME_STORAGE_KEY, theme);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
storage.set(UI_THEME_STORAGE_KEY, theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
color-scheme: dark;
|
||||||
--ui-color-canvas: #020302;
|
--ui-color-canvas: #020302;
|
||||||
--ui-color-surface: #060706;
|
--ui-color-surface: #060706;
|
||||||
--ui-color-surface-raised: #0b0d0c;
|
--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: rgba(244, 244, 244, 0.16);
|
||||||
--ui-color-line-strong: rgba(244, 244, 244, 0.32);
|
--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-text: #f3f4ed;
|
||||||
--ui-color-muted: #8d948c;
|
--ui-color-muted: #8d948c;
|
||||||
--ui-color-dim: #555b55;
|
--ui-color-dim: #555b55;
|
||||||
--ui-color-accent: #d7ff00;
|
--ui-color-accent: #d7ff00;
|
||||||
|
--ui-color-accent-contrast: #050605;
|
||||||
--ui-color-ok: #bfff00;
|
--ui-color-ok: #bfff00;
|
||||||
--ui-color-warning: #ffb020;
|
--ui-color-warning: #ffb020;
|
||||||
--ui-color-danger: #ff1744;
|
--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);
|
--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 {
|
html {
|
||||||
background: var(--ui-color-canvas);
|
background: var(--ui-color-canvas);
|
||||||
}
|
}
|
||||||
|
|
@ -42,8 +86,8 @@ body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background:
|
background:
|
||||||
linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px),
|
linear-gradient(var(--ui-color-grid-line) 1px, transparent 1px),
|
||||||
linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px),
|
linear-gradient(90deg, var(--ui-color-grid-line) 1px, transparent 1px),
|
||||||
var(--ui-color-canvas);
|
var(--ui-color-canvas);
|
||||||
background-size: 48px 48px, 48px 48px, auto;
|
background-size: 48px 48px, 48px 48px, auto;
|
||||||
color: var(--ui-color-text);
|
color: var(--ui-color-text);
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,16 @@ test.describe("dashboard page QA gate", () => {
|
||||||
await waitForDashboardReady(page);
|
await waitForDashboardReady(page);
|
||||||
|
|
||||||
await expect(page.getByRole("main")).toHaveCount(1);
|
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) {
|
for (const serviceId of linkedServiceIds) {
|
||||||
await page.keyboard.press("Tab");
|
await page.keyboard.press("Tab");
|
||||||
|
|
||||||
|
|
@ -255,6 +265,29 @@ test.describe("dashboard page QA gate", () => {
|
||||||
0.01,
|
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> {
|
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.
|
Before Width: | Height: | Size: 273 KiB After Width: | Height: | Size: 274 KiB |
Loading…
Add table
Add a link
Reference in a new issue