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; }