diff --git a/.changeset/theme-system-default.md b/.changeset/theme-system-default.md new file mode 100644 index 00000000..a1f83429 --- /dev/null +++ b/.changeset/theme-system-default.md @@ -0,0 +1,5 @@ +--- +"@mdcms/studio": patch +--- + +Add a System/Light/Dark theme picker in the admin header, default the Studio theme to System so it follows the OS preference, and theme the bootstrap loading shell (`Loading Studio`) so it resolves from `localStorage` + `prefers-color-scheme` instead of always rendering light. `StudioShellFrame` gains an optional `shellTheme` prop (defaulted to `"light"`, non-breaking). diff --git a/packages/studio/src/lib/remote-studio-app.tsx b/packages/studio/src/lib/remote-studio-app.tsx index 7966b222..7726a5ba 100644 --- a/packages/studio/src/lib/remote-studio-app.tsx +++ b/packages/studio/src/lib/remote-studio-app.tsx @@ -512,7 +512,7 @@ export function RemoteStudioApp({ }; return ( - + { ); assert.equal(resolveAppliedTheme("system", true), "dark"); }); + +test("resolveThemePreference honours a 'system' default even without enableSystem", () => { + assert.equal( + resolveThemePreference({ + storedTheme: null, + defaultTheme: "system", + enableSystem: false, + systemPrefersDark: false, + }), + "light", + ); +}); + +test("readStoredThemePreference returns 'system' when persisted", () => { + const storage = createStorage(); + storage.setItem(STUDIO_THEME_STORAGE_KEY, "system"); + + assert.equal(readStoredThemePreference(storage), "system"); +}); + +test("resolveAppliedTheme picks light when 'system' and OS prefers light", () => { + assert.equal(resolveAppliedTheme("system", false), "light"); +}); diff --git a/packages/studio/src/lib/runtime-ui/components/layout/page-header.test.tsx b/packages/studio/src/lib/runtime-ui/components/layout/page-header.test.tsx new file mode 100644 index 00000000..9e62ce0e --- /dev/null +++ b/packages/studio/src/lib/runtime-ui/components/layout/page-header.test.tsx @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { test } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { ThemeProvider } from "../../adapters/next-themes.js"; +import { ThemePickerMenu } from "./page-header.js"; + +function renderPicker(): string { + return renderToStaticMarkup( + createElement( + ThemeProvider, + { attribute: "class", defaultTheme: "system", enableSystem: true }, + createElement(ThemePickerMenu), + ), + ); +} + +test("ThemePickerMenu exposes a trigger with a theme label", () => { + const markup = renderPicker(); + + assert.match(markup, /data-testid="mdcms-theme-picker-trigger"/); + assert.match(markup, /aria-label="Theme"/); + assert.match(markup, /Theme<\/span>/); +}); diff --git a/packages/studio/src/lib/runtime-ui/components/layout/page-header.tsx b/packages/studio/src/lib/runtime-ui/components/layout/page-header.tsx index 95d32ec3..ee438e77 100644 --- a/packages/studio/src/lib/runtime-ui/components/layout/page-header.tsx +++ b/packages/studio/src/lib/runtime-ui/components/layout/page-header.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import { useTheme } from "../../adapters/next-themes.js"; import Link from "../../adapters/next-link.js"; -import { Sun, Moon, ChevronRight, LogOut } from "lucide-react"; +import { Sun, Moon, Monitor, ChevronRight, LogOut, Check } from "lucide-react"; import { Button } from "../ui/button.js"; import { Avatar, AvatarFallback } from "../ui/avatar.js"; import { @@ -21,12 +21,7 @@ import { SelectTrigger, SelectValue, } from "../ui/select.js"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "../ui/tooltip.js"; +import { TooltipProvider } from "../ui/tooltip.js"; import { cn } from "../../lib/utils.js"; import { useStudioSession } from "../../app/admin/session-context.js"; import { useStudioMountInfo } from "../../app/admin/mount-info-context.js"; @@ -134,6 +129,58 @@ export function BreadcrumbTrail({ ); } +type ThemeOption = { + value: "system" | "light" | "dark"; + label: string; + Icon: React.ComponentType<{ className?: string }>; +}; + +const THEME_OPTIONS: readonly ThemeOption[] = [ + { value: "system", label: "System", Icon: Monitor }, + { value: "light", label: "Light", Icon: Sun }, + { value: "dark", label: "Dark", Icon: Moon }, +] as const; + +export function ThemePickerMenu() { + const { theme, setTheme } = useTheme(); + + return ( + + + + + + {THEME_OPTIONS.map(({ value, label, Icon }) => { + const selected = theme === value; + return ( + setTheme(value)} + data-testid={`mdcms-theme-option-${value}`} + aria-checked={selected} + role="menuitemradio" + > + + {label} + {selected ? : null} + + ); + })} + + + ); +} + function deriveInitials(email: string): string { const local = email.split("@")[0] ?? ""; const parts = local.split(/[._-]/).filter(Boolean); @@ -144,7 +191,6 @@ function deriveInitials(email: string): string { } export function AppHeader({ breadcrumbs = [] }: AppHeaderProps) { - const { theme, setTheme } = useTheme(); const sessionState = useStudioSession(); const mountInfo = useStudioMountInfo(); @@ -204,22 +250,8 @@ export function AppHeader({ breadcrumbs = [] }: AppHeaderProps) { )} - {/* Dark mode toggle */} - - - - - Toggle theme - + {/* Theme picker */} + {/* User menu */} {sessionState.status === "authenticated" ? ( diff --git a/packages/studio/src/lib/studio-component.tsx b/packages/studio/src/lib/studio-component.tsx index f7f9123c..b16cf598 100644 --- a/packages/studio/src/lib/studio-component.tsx +++ b/packages/studio/src/lib/studio-component.tsx @@ -13,6 +13,10 @@ import { type MdcmsConfig, type StudioLoaderOptions, } from "./studio-loader.js"; +import { + readStoredThemePreference, + resolveAppliedTheme, +} from "./runtime-ui/adapters/next-themes.js"; export type { MdcmsConfig } from "./studio-loader.js"; @@ -27,12 +31,15 @@ export type StudioProps = { loadRemoteModule?: StudioLoaderOptions["loadRemoteModule"]; }; +export type ShellAppliedTheme = "light" | "dark"; + export type StudioShellFrameProps = { config: MdcmsConfig; basePath: string; startupState: StudioStartupState; startupError?: unknown; containerRef?: RefObject; + shellTheme?: ShellAppliedTheme; }; export type StudioStartupErrorMetadataRow = { @@ -85,6 +92,7 @@ const STUDIO_SHELL_STYLES = ` --s-glow: rgba(47, 73, 229, 0.04); --s-path-bg: rgba(197, 197, 216, 0.1); --s-path-border: rgba(197, 197, 216, 0.18); + color-scheme: light; position: fixed; inset: 0; @@ -99,6 +107,31 @@ const STUDIO_SHELL_STYLES = ` -moz-osx-font-smoothing: grayscale; } +.mdcms-studio-shell[data-mdcms-theme="dark"] { + --s-bg: #0C0C0E; + --s-surface: #151518; + --s-fg: #F5F4F4; + --s-fg-muted: #A0A2B5; + --s-primary: #5B72F5; + --s-border: rgba(197, 197, 216, 0.15); + --s-border-subtle: rgba(197, 197, 216, 0.08); + --s-surface-inner: rgba(40, 40, 48, 0.6); + --s-surface-inner-subtle: rgba(40, 40, 48, 0.4); + --s-skeleton-strong: rgba(197, 197, 216, 0.14); + --s-skeleton-mid: rgba(197, 197, 216, 0.09); + --s-skeleton-soft: rgba(197, 197, 216, 0.06); + --s-check-bg: rgba(91, 114, 245, 0.1); + --s-destructive: #f87171; + --s-destructive-border: rgba(248, 113, 113, 0.25); + --s-destructive-bg: rgba(248, 113, 113, 0.1); + --s-warning: #fbbf24; + --s-code-bg: #1A1A1E; + --s-glow: rgba(91, 114, 245, 0.08); + --s-path-bg: rgba(197, 197, 216, 0.06); + --s-path-border: rgba(197, 197, 216, 0.12); + color-scheme: dark; +} + .mdcms-studio-shell__backdrop { position: absolute; inset: 0; @@ -593,12 +626,77 @@ export function describeStudioStartupError( }; } +export function resolveShellAppliedTheme(input: { + storedThemeRaw: string | null; + systemPrefersDark: boolean; +}): ShellAppliedTheme { + const stored = + input.storedThemeRaw === "light" || + input.storedThemeRaw === "dark" || + input.storedThemeRaw === "system" + ? input.storedThemeRaw + : null; + + return resolveAppliedTheme(stored ?? "system", input.systemPrefersDark); +} + +function useResolvedShellTheme(): ShellAppliedTheme { + const [applied, setApplied] = useState(() => { + if (typeof window === "undefined") { + return "light"; + } + + const storage = window.localStorage ?? null; + const stored = readStoredThemePreference(storage); + const systemPrefersDark = + typeof window.matchMedia === "function" + ? window.matchMedia("(prefers-color-scheme: dark)").matches + : false; + + return resolveAppliedTheme(stored ?? "system", systemPrefersDark); + }); + + useEffect(() => { + if ( + typeof window === "undefined" || + typeof window.matchMedia !== "function" + ) { + return; + } + + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + const storage = window.localStorage ?? null; + + const recompute = () => { + const stored = readStoredThemePreference(storage); + setApplied(resolveAppliedTheme(stored ?? "system", mediaQuery.matches)); + }; + + recompute(); + + if (typeof mediaQuery.addEventListener === "function") { + mediaQuery.addEventListener("change", recompute); + return () => { + mediaQuery.removeEventListener("change", recompute); + }; + } + + mediaQuery.addListener(recompute); + return () => { + mediaQuery.removeListener(recompute); + }; + }, []); + + return applied; +} + export function StudioShellFrame({ config, basePath, startupState, startupError, containerRef, + shellTheme = "light", }: StudioShellFrameProps) { const describedError = startupState === "error" @@ -613,6 +711,7 @@ export function StudioShellFrame({ data-mdcms-base-path={basePath} data-mdcms-state={startupState} data-mdcms-brand="MDCMS" + data-mdcms-theme={shellTheme} className={startupState === "ready" ? undefined : "mdcms-studio-shell"} >
("loading"); const [startupError, setStartupError] = useState(); + const shellTheme = useResolvedShellTheme(); useEffect(() => { const container = containerRef.current; @@ -859,6 +959,7 @@ export function Studio({ startupState={startupState} startupError={startupError} containerRef={containerRef} + shellTheme={shellTheme} /> ); } diff --git a/packages/studio/src/lib/studio.test.ts b/packages/studio/src/lib/studio.test.ts index d2deb75a..efddf10e 100644 --- a/packages/studio/src/lib/studio.test.ts +++ b/packages/studio/src/lib/studio.test.ts @@ -20,6 +20,7 @@ import { createStudioActionCatalogAdapter } from "./action-catalog-adapter.js"; import { StudioShellFrame, describeStudioStartupError, + resolveShellAppliedTheme, } from "./studio-component.js"; import { loadStudioDocumentShell } from "./document-shell.js"; @@ -634,6 +635,93 @@ test("StudioShellFrame renders loading startup message", () => { assert.match(markup, /overflow-x:\s*hidden/); }); +test("StudioShellFrame defaults shell theme to light when no theme is provided", () => { + const node = StudioShellFrame({ + config: { + project: "marketing-site", + environment: "staging", + serverUrl: "http://localhost:4000", + }, + basePath: "/admin", + startupState: "loading", + }); + + assert.equal(node.props["data-mdcms-theme"], "light"); +}); + +test("StudioShellFrame applies the caller-provided shell theme", () => { + const node = StudioShellFrame({ + config: { + project: "marketing-site", + environment: "staging", + serverUrl: "http://localhost:4000", + }, + basePath: "/admin", + startupState: "loading", + shellTheme: "dark", + }); + + assert.equal(node.props["data-mdcms-theme"], "dark"); +}); + +test("StudioShellFrame loading markup embeds a dark-mode palette override", () => { + const markup = renderToStaticMarkup( + StudioShellFrame({ + config: { + project: "marketing-site", + environment: "staging", + serverUrl: "http://localhost:4000", + }, + basePath: "/admin", + startupState: "loading", + shellTheme: "dark", + }), + ); + + assert.match(markup, /\[data-mdcms-theme="dark"\]/); + assert.match(markup, /data-mdcms-theme="dark"/); +}); + +test("resolveShellAppliedTheme returns dark when system prefers dark and no theme is stored", () => { + assert.equal( + resolveShellAppliedTheme({ + storedThemeRaw: null, + systemPrefersDark: true, + }), + "dark", + ); +}); + +test("resolveShellAppliedTheme honours a stored light preference even when system prefers dark", () => { + assert.equal( + resolveShellAppliedTheme({ + storedThemeRaw: "light", + systemPrefersDark: true, + }), + "light", + ); +}); + +test("resolveShellAppliedTheme honours a stored dark preference when system prefers light", () => { + assert.equal( + resolveShellAppliedTheme({ + storedThemeRaw: "dark", + systemPrefersDark: false, + }), + "dark", + ); +}); + +test("resolveShellAppliedTheme treats unknown stored values as system default", () => { + assert.equal( + resolveShellAppliedTheme({ + storedThemeRaw: "garbage", + systemPrefersDark: true, + }), + "dark", + ); +}); + test("describeStudioStartupError keeps generic cross-origin load failures neutral", () => { const viewModel = describeStudioStartupError( new RuntimeError({