From e1ee8242143f685497a8f447b3ac6804b6940bb0 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 1 Oct 2026 00:44:36 -0700 Subject: [PATCH 1/8] 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. --- packages/studio/index.html | 9 ++ .../studio/src/components/StudioHeader.tsx | 2 + .../studio/src/components/ThemeToggle.tsx | 83 +++++++++++++++++++ .../studio/src/utils/studioTheme.test.tsx | 62 ++++++++++++++ packages/studio/src/utils/studioTheme.ts | 40 +++++++++ .../studio/src/utils/studioUiPreferences.ts | 4 + 6 files changed, 200 insertions(+) create mode 100644 packages/studio/src/components/ThemeToggle.tsx create mode 100644 packages/studio/src/utils/studioTheme.test.tsx create mode 100644 packages/studio/src/utils/studioTheme.ts diff --git a/packages/studio/index.html b/packages/studio/index.html index b3659e0763..56074986aa 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.tsx b/packages/studio/src/components/StudioHeader.tsx index a08acc5a49..b24209d5d3 100644 --- a/packages/studio/src/components/StudioHeader.tsx +++ b/packages/studio/src/components/StudioHeader.tsx @@ -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 { ThemeToggle } from "./ThemeToggle"; export interface StudioHeaderProps { captureFrameHref: string; @@ -147,6 +148,7 @@ export function StudioHeader({ + { + document.documentElement.animate( + { clipPath: [`circle(0% ${at})`, `circle(${radiusPct}% ${at})`] }, + { duration: 500, easing: "ease-in-out", pseudoElement: "::view-transition-new(root)" }, + ); + }, + () => {}, + ); +} + +export function ThemeToggle() { + const isDark = useShownStudioTheme() === "dark"; + + const toggle = useCallback( + (e: React.MouseEvent) => { + const apply = () => setStudioTheme(isDark ? "light" : "dark"); + if (!document.startViewTransition) return apply(); + // The switch stays synchronous inside the callback: rendering is paused during it. + const { clientX: x, clientY: y } = 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; + reveal(document.startViewTransition(apply), at, radiusPct); + }, + [isDark], + ); + + return ( + + ); +} diff --git a/packages/studio/src/utils/studioTheme.test.tsx b/packages/studio/src/utils/studioTheme.test.tsx new file mode 100644 index 0000000000..b69c9e1887 --- /dev/null +++ b/packages/studio/src/utils/studioTheme.test.tsx @@ -0,0 +1,62 @@ +// @vitest-environment happy-dom +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { act } from "react"; +import { afterEach, describe, expect, it } from "vitest"; +import { ThemeToggle } from "../components/ThemeToggle"; +import { cleanupMounted, mountHost } from "../components/ui/mountHost.testHelpers"; +import { shownStudioTheme } from "./studioTheme"; + +const KEY = "hf-studio-ui-preferences"; +const html = readFileSync(path.join(__dirname, "../../index.html"), "utf8"); +const boot = html.match(/ diff --git a/packages/studio/src/utils/studioTheme.test.tsx b/packages/studio/src/utils/studioTheme.test.tsx index b69c9e1887..23bf1bc864 100644 --- a/packages/studio/src/utils/studioTheme.test.tsx +++ b/packages/studio/src/utils/studioTheme.test.tsx @@ -11,10 +11,6 @@ const KEY = "hf-studio-ui-preferences"; const html = readFileSync(path.join(__dirname, "../../index.html"), "utf8"); const boot = html.match(/