diff --git a/packages/studio/index.html b/packages/studio/index.html
index b3659e0763..321a34e105 100644
--- a/packages/studio/index.html
+++ b/packages/studio/index.html
@@ -5,6 +5,15 @@
HyperFrames Studio
+
diff --git a/packages/studio/src/components/StudioHeader.dom.test.tsx b/packages/studio/src/components/StudioHeader.dom.test.tsx
index fe4d01f8b7..263433c7a9 100644
--- a/packages/studio/src/components/StudioHeader.dom.test.tsx
+++ b/packages/studio/src/components/StudioHeader.dom.test.tsx
@@ -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 = (
+
+ );
act(() =>
root.render(
- ,
+ props.themeToggle ? (
+ {header}
+ ) : (
+ 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();
+});
diff --git a/packages/studio/src/components/StudioHeader.tsx b/packages/studio/src/components/StudioHeader.tsx
index a08acc5a49..8eff9c4e94 100644
--- a/packages/studio/src/components/StudioHeader.tsx
+++ b/packages/studio/src/components/StudioHeader.tsx
@@ -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({
+ {showThemeToggle && }
{
+ 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): [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) {
+ 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 (
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
+ )
+ }
+ />
+
+ );
+}
diff --git a/packages/studio/src/main.tsx b/packages/studio/src/main.tsx
index 17d97def97..7ee9e3953f 100644
--- a/packages/studio/src/main.tsx
+++ b/packages/studio/src/main.tsx
@@ -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. */}
-
+
+
+
,
diff --git a/packages/studio/src/styles/studio.css b/packages/studio/src/styles/studio.css
index d6ea40bb99..f4abd78d97 100644
--- a/packages/studio/src/styles/studio.css
+++ b/packages/studio/src/styles/studio.css
@@ -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;
diff --git a/packages/studio/src/utils/studioTheme.test.tsx b/packages/studio/src/utils/studioTheme.test.tsx
new file mode 100644
index 0000000000..37dbca772e
--- /dev/null
+++ b/packages/studio/src/utils/studioTheme.test.tsx
@@ -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();
+ 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();
+ 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((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().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().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().querySelector("button")!;
+ await act(async () => button.click());
+ expect(start).not.toHaveBeenCalled();
+ expect(document.documentElement.dataset.theme).toBe("paper");
+ });
+});
diff --git a/packages/studio/src/utils/studioTheme.ts b/packages/studio/src/utils/studioTheme.ts
new file mode 100644
index 0000000000..7fabb66d14
--- /dev/null
+++ b/packages/studio/src/utils/studioTheme.ts
@@ -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);
+}
diff --git a/packages/studio/src/utils/studioUiPreferences.ts b/packages/studio/src/utils/studioUiPreferences.ts
index 44a49faf6c..4e1fdc9f99 100644
--- a/packages/studio/src/utils/studioUiPreferences.ts
+++ b/packages/studio/src/utils/studioUiPreferences.ts
@@ -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;
}