mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user