feat(studio): a light and dark theme toggle in the header that remembers the choice (#4839)

* feat(studio): a light and dark theme toggle in the header that remembers the choice

The sun and moon button flips Studio between Graphite light and dark with a
circular reveal, saves the choice, and follows the system until one is made.

* fix(studio): studio opens light the first time, then keeps the saved theme

* fix(studio): drop the theme field's comment, the theme owner states the rule

* fix(studio): the theme toggle shows only in studio's own app and follows the theme on screen

A host that embeds StudioApp gets no toggle; the button reads the document's theme, the reveal drops the default crossfade, and reduced motion skips it.

* fix(studio): the theme reveal leaves other view transitions alone; one default for the toggle

The crossfade rule applies only while the toggle's own transition runs, StudioApp passes the header's single default through, and the reveal's reduced-motion and keyboard paths are tested.

* fix(studio): a second theme click mid-reveal keeps the crossfade off

Only the latest reveal clears the document marker; tests pin the marker while a reveal runs and the circle's radius.

* refactor(studio): the theme toggle shares its icon props and reads as plain functions

Simplify pass: one icon props object for the sun and moon, no useCallback around a handler nothing memoises, cannotReveal named for what it answers, the test reuses the theme reader.

* fix(studio): studio's own app turns the theme toggle on through context; tests parse index.html

App.tsx stays at main's size: main.tsx provides ShowThemeToggle and the header reads it, default off for hosts. The boot-script tests read index.html through DOMParser instead of an HTML regex.
This commit is contained in:
Miguel Ángel
2026-10-01 08:44:57 -07:00
committed by GitHub
parent b6c879c4e9
commit 9f4d9e64b2
9 changed files with 331 additions and 11 deletions
+9
View File
@@ -5,6 +5,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>HyperFrames Studio</title>
<script>
// Before the first paint: the theme the toggle saved, else light (owner: src/utils/studioTheme.ts).
try {
if (JSON.parse(localStorage.getItem("hf-studio-ui-preferences") || "{}").theme !== "dark")
document.documentElement.dataset.theme = "paper";
} catch (e) {
document.documentElement.dataset.theme = "paper";
}
</script>
</head>
<body>
<div data-hf-id="hf-aph5" id="root"></div>
@@ -42,6 +42,7 @@ vi.mock("../contexts/PanelLayoutContext", () => ({
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
const { StudioHeader } = await import("./StudioHeader");
const { ShowThemeToggle } = await import("./ThemeToggle");
let mounted: { root: Root; host: HTMLElement } | null = null;
@@ -54,6 +55,7 @@ beforeEach(() => {
});
afterEach(() => {
delete document.documentElement.dataset.theme;
if (!mounted) return;
const { root, host } = mounted;
mounted = null;
@@ -61,21 +63,30 @@ afterEach(() => {
host.remove();
});
function mount(props: { inspectorButtonActive?: boolean } = {}): HTMLElement {
function mount(
props: { inspectorButtonActive?: boolean; themeToggle?: boolean } = {},
): HTMLElement {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
mounted = { root, host };
const header = (
<StudioHeader
captureFrameHref="blob:frame"
captureFrameFilename="frame.png"
handleCaptureFrameClick={vi.fn()}
refreshCaptureFrameTime={vi.fn()}
inspectorButtonActive={props.inspectorButtonActive ?? false}
inspectorPanelActive={false}
/>
);
act(() =>
root.render(
<StudioHeader
captureFrameHref="blob:frame"
captureFrameFilename="frame.png"
handleCaptureFrameClick={vi.fn()}
refreshCaptureFrameTime={vi.fn()}
inspectorButtonActive={props.inspectorButtonActive ?? false}
inspectorPanelActive={false}
/>,
props.themeToggle ? (
<ShowThemeToggle.Provider value>{header}</ShowThemeToggle.Provider>
) : (
header
),
),
);
return host;
@@ -179,3 +190,14 @@ it("classifies the new header controls for the hotkey filters as the old ones we
expect(shouldIgnorePlaybackShortcutTarget(el), el.tagName).toBe(true);
}
});
it("shows no theme toggle and leaves the host's data-theme alone when embedded", () => {
document.documentElement.dataset.theme = "host";
const host = mount();
expect(host.querySelector('[aria-label^="Switch to"]')).toBeNull();
expect(document.documentElement.dataset.theme).toBe("host");
});
it("shows the theme toggle in Studio's own app", () => {
expect(mount({ themeToggle: true }).querySelector('[aria-label^="Switch to"]')).not.toBeNull();
});
@@ -1,4 +1,4 @@
import type { MouseEvent } from "react";
import { useContext, type MouseEvent } from "react";
import { Camera } from "../icons/SystemIcons";
import { useStudioShellContext } from "../contexts/StudioContext";
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
@@ -7,6 +7,7 @@ import { Button, buttonBase, buttonSizes, buttonVariants, cn, Tooltip } from "./
import { Dock } from "./dock/Dock";
import { InspectorIcon } from "./icons/InspectorIcon";
import { HyperframesLogo } from "./ui/HyperframesLogo";
import { ShowThemeToggle, ThemeToggle } from "./ThemeToggle";
export interface StudioHeaderProps {
captureFrameHref: string;
@@ -44,6 +45,7 @@ export function StudioHeader({
inspectorPanelActive,
onExport,
}: StudioHeaderProps) {
const showThemeToggle = useContext(ShowThemeToggle);
const { projectId, renderQueue } = useStudioShellContext();
const { rightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext();
const isRendering = renderQueue.isRendering;
@@ -147,6 +149,7 @@ export function StudioHeader({
</Button>
</Tooltip>
</div>
{showThemeToggle && <ThemeToggle />}
<Dock.WindowMenu />
<Tooltip
label={
@@ -0,0 +1,99 @@
import { createContext } from "react";
import { setStudioTheme, useShownStudioTheme } from "../utils/studioTheme";
import { IconButton, Tooltip } from "./ui";
// hyperframes.dev's theme toggle: the same sun and moon, revealing the new theme in a circle from
// the button. A page that paints no frame aborts the transition; the theme is applied by then.
let latest: ViewTransition | null = null;
function reveal(transition: ViewTransition, at: string, radiusPct: number) {
latest = transition;
const done = () => {
if (latest === transition) delete document.documentElement.dataset.themeReveal;
};
void transition.finished.then(done, done);
void transition.ready.then(
() => {
document.documentElement.animate(
{ clipPath: [`circle(0% ${at})`, `circle(${radiusPct}% ${at})`] },
{ duration: 500, easing: "ease-in-out", pseudoElement: "::view-transition-new(root)" },
);
},
() => {},
);
}
function origin(e: React.MouseEvent<HTMLButtonElement>): [number, number] {
if (e.detail > 0) return [e.clientX, e.clientY];
const box = e.currentTarget.getBoundingClientRect();
return [box.left + box.width / 2, box.top + box.height / 2];
}
/** On only in Studio's own app; a host that embeds Studio owns its theme. */
export const ShowThemeToggle = createContext(false);
function cannotReveal(): boolean {
return (
!document.startViewTransition ||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true
);
}
const iconProps = {
width: "14",
height: "14",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: "2",
strokeLinecap: "round",
strokeLinejoin: "round",
"aria-hidden": true,
} as const;
export function ThemeToggle() {
const isDark = useShownStudioTheme() === "dark";
const label = `Switch to ${isDark ? "light" : "dark"} theme`;
function toggle(e: React.MouseEvent<HTMLButtonElement>) {
const apply = () => setStudioTheme(isDark ? "light" : "dark");
if (cannotReveal()) return apply();
const [x, y] = origin(e);
const w = window.innerWidth;
const h = window.innerHeight;
const maxRadius = Math.hypot(Math.max(x, w - x), Math.max(y, h - y));
const at = `at ${(x / w) * 100}% ${(y / h) * 100}%`;
const radiusPct = (maxRadius / (Math.hypot(w, h) / Math.SQRT2)) * 100;
// The switch stays synchronous inside the callback: rendering is paused during it.
document.documentElement.dataset.themeReveal = "";
reveal(document.startViewTransition(apply), at, radiusPct);
}
return (
<Tooltip label={label} side="bottom">
<IconButton
onClick={toggle}
aria-label={label}
icon={
isDark ? (
<svg {...iconProps}>
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
) : (
<svg {...iconProps}>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
)
}
/>
</Tooltip>
);
}
+4 -1
View File
@@ -2,6 +2,7 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { IconContext } from "@phosphor-icons/react";
import { StudioApp } from "./App";
import { ShowThemeToggle } from "./components/ThemeToggle";
import { StudioErrorBoundary } from "./components/StudioErrorBoundary";
import { readIconTokens } from "./styles/iconTokens";
import { trackStudioEvent } from "./utils/studioTelemetry";
@@ -128,7 +129,9 @@ createRoot(document.getElementById("root")!).render(
Icons that pass their own size or weight still win. */}
<IconContext.Provider value={readIconTokens()}>
<StudioErrorBoundary>
<StudioApp />
<ShowThemeToggle.Provider value>
<StudioApp />
</ShowThemeToggle.Provider>
</StudioErrorBoundary>
</IconContext.Provider>
</StrictMode>,
+6
View File
@@ -16,6 +16,12 @@
}
}
[data-theme-reveal]::view-transition-old(root),
[data-theme-reveal]::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
body {
margin: 0;
padding: 0;
@@ -0,0 +1,141 @@
// @vitest-environment happy-dom
import { readFileSync } from "node:fs";
import path from "node:path";
import { act } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ThemeToggle } from "../components/ThemeToggle";
import { cleanupMounted, mountHost } from "../components/ui/mountHost.testHelpers";
import { savedStudioTheme, shownStudioTheme } from "./studioTheme";
const KEY = "hf-studio-ui-preferences";
const page = new DOMParser().parseFromString(
readFileSync(path.join(__dirname, "../../index.html"), "utf8"),
"text/html",
);
const scripts = [...page.querySelectorAll("script")];
const boot = scripts.find((script) => !script.type)?.textContent ?? "";
function store(theme: unknown) {
localStorage.setItem(KEY, JSON.stringify({ theme }));
}
function boots(): "light" | "dark" {
new Function(boot)();
return shownStudioTheme();
}
afterEach(() => {
cleanupMounted();
localStorage.clear();
delete document.documentElement.dataset.theme;
});
describe("Studio's theme", () => {
it.each([["light"], ["dark"], [undefined], ["neon"]])(
"boots to the theme the owner picks (saved %s)",
(saved) => {
store(saved);
expect(boots()).toBe(savedStudioTheme());
},
);
it.each([["null"], ["{not json"], ['"dark"']])("boots to the owner's pick from %s", (raw) => {
localStorage.setItem(KEY, raw);
expect(boots()).toBe(savedStudioTheme());
});
it("opens light the first time", () => {
expect(boots()).toBe("light");
expect(savedStudioTheme()).toBe("light");
});
it("runs the boot script before the app's module", () => {
expect(boot).toContain(`"${KEY}"`);
expect(scripts.findIndex((script) => !script.type)).toBeLessThan(
scripts.findIndex((script) => script.type === "module"),
);
});
it("flips the document between light and dark and keeps the choice", async () => {
store("dark");
const host = mountHost(<ThemeToggle />);
const button = () => host.querySelector("button")!;
expect(button().getAttribute("aria-label")).toBe("Switch to light theme");
await act(async () => button().click());
expect(document.documentElement.dataset.theme).toBe("paper");
expect(JSON.parse(localStorage.getItem(KEY)!).theme).toBe("light");
expect(button().getAttribute("aria-label")).toBe("Switch to dark theme");
await act(async () => button().click());
expect(document.documentElement.dataset.theme).toBeUndefined();
expect(JSON.parse(localStorage.getItem(KEY)!).theme).toBe("dark");
});
it("follows the theme on screen, not only its own clicks", async () => {
store("light");
const host = mountHost(<ThemeToggle />);
expect(host.querySelector("button")!.getAttribute("aria-label")).toBe("Switch to light theme");
await act(async () => {
document.documentElement.dataset.theme = "paper";
});
expect(host.querySelector("button")!.getAttribute("aria-label")).toBe("Switch to dark theme");
});
});
describe("the reveal", () => {
function stubTransition() {
const animate = vi.fn();
Object.assign(document.documentElement, { animate });
const markers: (string | undefined)[] = [];
const finish: (() => void)[] = [];
const start = vi.fn((update: () => void) => {
markers.push(document.documentElement.dataset.themeReveal);
update();
return { ready: Promise.resolve(), finished: new Promise<void>((done) => finish.push(done)) };
});
Object.assign(document, { startViewTransition: start });
return { animate, start, markers, finish };
}
afterEach(() => {
Reflect.deleteProperty(document, "startViewTransition");
Reflect.deleteProperty(document.documentElement, "animate");
vi.unstubAllGlobals();
});
it("grows from the button's centre when the keyboard presses it, marked while it runs", async () => {
const { animate, markers, finish } = stubTransition();
vi.stubGlobal("innerWidth", 1000);
vi.stubGlobal("innerHeight", 500);
const button = mountHost(<ThemeToggle />).querySelector("button")!;
button.getBoundingClientRect = () => ({ left: 890, top: 10, width: 20, height: 20 }) as DOMRect;
await act(async () => {
button.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 0 }));
});
expect(markers).toEqual([""]);
expect(animate.mock.calls[0]![0].clipPath[1]).toMatch(/^circle\(129\.0\d*% at 90% 4%\)$/);
await act(async () => finish[0]!());
expect(document.documentElement.dataset.themeReveal).toBeUndefined();
});
it("keeps the marker for a second reveal that starts before the first ends", async () => {
const { finish } = stubTransition();
const button = mountHost(<ThemeToggle />).querySelector("button")!;
await act(async () => button.click());
await act(async () => button.click());
await act(async () => finish[0]!());
expect(document.documentElement.dataset.themeReveal).toBe("");
await act(async () => finish[1]!());
expect(document.documentElement.dataset.themeReveal).toBeUndefined();
});
it("switches without the reveal under reduced motion", async () => {
const { start } = stubTransition();
vi.stubGlobal("matchMedia", (query: string) => ({ matches: query.includes("reduce") }));
const button = mountHost(<ThemeToggle />).querySelector("button")!;
await act(async () => button.click());
expect(start).not.toHaveBeenCalled();
expect(document.documentElement.dataset.theme).toBe("paper");
});
});
+34
View File
@@ -0,0 +1,34 @@
// Studio's own app opens on the toggle's saved choice, else light. Light is `data-theme="paper"`
// on the document element, the hook theme.css keys on; dark is no attribute. index.html applies
// `savedStudioTheme` before the first paint; studioTheme.test.tsx holds the two together.
import { useSyncExternalStore } from "react";
import {
readStudioUiPreferences,
writeStudioUiPreferences,
type StudioTheme,
} from "./studioUiPreferences";
export function savedStudioTheme(): StudioTheme {
return readStudioUiPreferences().theme ?? "light";
}
export function shownStudioTheme(): StudioTheme {
return document.documentElement.dataset.theme === "paper" ? "light" : "dark";
}
export function setStudioTheme(theme: StudioTheme): void {
writeStudioUiPreferences({ theme });
const root = document.documentElement;
if (theme === "light") root.dataset.theme = "paper";
else delete root.dataset.theme;
}
function subscribe(listener: () => void): () => void {
const observer = new MutationObserver(listener);
observer.observe(document.documentElement, { attributeFilter: ["data-theme"] });
return () => observer.disconnect();
}
export function useShownStudioTheme(): StudioTheme {
return useSyncExternalStore(subscribe, shownStudioTheme);
}
@@ -2,6 +2,7 @@ import type { SerializedDockview } from "dockview-react";
import { parseDockLayout } from "../components/dock/dockLayoutSchema";
export type TimelineTimeDisplayMode = "time" | "frame";
export type StudioTheme = "light" | "dark";
export interface StudioUiPreferences {
timelineVisible?: boolean;
@@ -41,6 +42,7 @@ export interface StudioUiPreferences {
* intentionally scoped to one mount.
*/
agentToolsEnabled?: boolean;
theme?: StudioTheme;
/** The dock's serialized panel tree; parsed by `parseDockLayout` on read. */
dockLayout?: SerializedDockview;
}
@@ -128,6 +130,7 @@ function readStorage(storage: Storage | null, key: string): StudioUiPreferences
if (typeof parsed.rippleEditEnabled === "boolean") {
preferences.rippleEditEnabled = parsed.rippleEditEnabled;
}
if (parsed.theme === "light" || parsed.theme === "dark") preferences.theme = parsed.theme;
if (parsed.timeDisplayMode === "time" || parsed.timeDisplayMode === "frame") {
preferences.timeDisplayMode = parsed.timeDisplayMode;
}