Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/theme-system-default.md
Original file line number Diff line number Diff line change
@@ -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).
2 changes: 1 addition & 1 deletion packages/studio/src/lib/remote-studio-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,7 @@ export function RemoteStudioApp({
};

return (
<ThemeProvider attribute="class" defaultTheme="light" enableSystem>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<StudioNavigationProvider
value={{
pathname,
Expand Down
23 changes: 23 additions & 0 deletions packages/studio/src/lib/runtime-ui/adapters/next-themes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,26 @@ test("resolveThemePreference falls back to system when enabled", () => {
);
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");
});
Original file line number Diff line number Diff line change
@@ -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>/);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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";
Expand Down Expand Up @@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-foreground-muted relative"
aria-label="Theme"
data-testid="mdcms-theme-picker-trigger"
>
<Sun className="h-5 w-5 rotate-0 scale-100 transition-transform dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-transform dark:rotate-0 dark:scale-100" />
<span className="sr-only">Theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
{THEME_OPTIONS.map(({ value, label, Icon }) => {
const selected = theme === value;
return (
<DropdownMenuItem
key={value}
onSelect={() => setTheme(value)}
data-testid={`mdcms-theme-option-${value}`}
aria-checked={selected}
role="menuitemradio"
>
<Icon className="mr-2 h-4 w-4" />
<span className="flex-1">{label}</span>
{selected ? <Check className="h-4 w-4" /> : null}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}

function deriveInitials(email: string): string {
const local = email.split("@")[0] ?? "";
const parts = local.split(/[._-]/).filter(Boolean);
Expand All @@ -144,7 +191,6 @@ function deriveInitials(email: string): string {
}

export function AppHeader({ breadcrumbs = [] }: AppHeaderProps) {
const { theme, setTheme } = useTheme();
const sessionState = useStudioSession();
const mountInfo = useStudioMountInfo();

Expand Down Expand Up @@ -204,22 +250,8 @@ export function AppHeader({ breadcrumbs = [] }: AppHeaderProps) {
</div>
)}

{/* Dark mode toggle */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="text-foreground-muted"
>
<Sun className="h-5 w-5 rotate-0 scale-100 transition-transform dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-transform dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</TooltipTrigger>
<TooltipContent>Toggle theme</TooltipContent>
</Tooltip>
{/* Theme picker */}
<ThemePickerMenu />

{/* User menu */}
{sessionState.status === "authenticated" ? (
Expand Down
101 changes: 101 additions & 0 deletions packages/studio/src/lib/studio-component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<HTMLDivElement | null>;
shellTheme?: ShellAppliedTheme;
};

export type StudioStartupErrorMetadataRow = {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<ShellAppliedTheme>(() => {
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"
Expand All @@ -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"}
>
<div
Expand Down Expand Up @@ -805,6 +904,7 @@ export function Studio({
const [startupState, setStartupState] =
useState<StudioStartupState>("loading");
const [startupError, setStartupError] = useState<unknown>();
const shellTheme = useResolvedShellTheme();

useEffect(() => {
const container = containerRef.current;
Expand Down Expand Up @@ -859,6 +959,7 @@ export function Studio({
startupState={startupState}
startupError={startupError}
containerRef={containerRef}
shellTheme={shellTheme}
/>
);
}
Loading
Loading