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
9 changes: 9 additions & 0 deletions packages/studio/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
40 changes: 31 additions & 9 deletions packages/studio/src/components/StudioHeader.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -54,28 +55,38 @@ beforeEach(() => {
});

afterEach(() => {
delete document.documentElement.dataset.theme;
if (!mounted) return;
const { root, host } = mounted;
mounted = null;
act(() => root.unmount());
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;
Expand Down Expand Up @@ -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();
});
5 changes: 4 additions & 1 deletion packages/studio/src/components/StudioHeader.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -147,6 +149,7 @@ export function StudioHeader({
</Button>
</Tooltip>
</div>
{showThemeToggle && <ThemeToggle />}
<Dock.WindowMenu />
<Tooltip
label={
Expand Down
99 changes: 99 additions & 0 deletions packages/studio/src/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
5 changes: 4 additions & 1 deletion packages/studio/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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>,
Expand Down
6 changes: 6 additions & 0 deletions packages/studio/src/styles/studio.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
141 changes: 141 additions & 0 deletions packages/studio/src/utils/studioTheme.test.tsx
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading