+
{/* Billing and the Sealos Desktop Entry live inside the account
- popover; the account row is the whole footer. */}
+ popover. */}
{/* Account-row exception: the avatar disc is visually heavier
than the row glyphs, so in both states it takes this extra
margin on top of the footer gap. */}
diff --git a/bun.lock b/bun.lock
index 936ce383..cd28f15f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -213,6 +213,7 @@
"katex": "^0.16.45",
"lucide-react": "^1.12.0",
"mermaid": "^11.14.0",
+ "motion": "^13.1.1",
"nanoid": "^5.1.11",
"next-themes": "^0.4.6",
"react": "^19.2.4",
@@ -2465,6 +2466,8 @@
"@vercel/sandbox/zod": ["zod@3.24.4", "https://registry.npmmirror.com/zod/-/zod-3.24.4.tgz", {}, "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg=="],
+ "@workspace/ui/motion": ["motion@13.1.1", "", { "dependencies": { "framer-motion": "^13.1.1", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-WNZoK6xiF+kkTqkZ5K7FDDh6A8BG4i5Hc7KXtW8gtTxkpJFds+hIOrDaQGKjQj/AE/i4hJqAaUHEqp/Qo02y6Q=="],
+
"@workspace/ui/recharts": ["recharts@3.8.0", "https://registry.npmmirror.com/recharts/-/recharts-3.8.0.tgz", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ=="],
"accepts/mime-types": ["mime-types@3.0.2", "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
@@ -2667,6 +2670,8 @@
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
+ "@workspace/ui/motion/framer-motion": ["framer-motion@13.1.1", "", { "dependencies": { "motion-dom": "^13.1.1", "motion-utils": "^13.0.0", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-B/xn2TPS4f61cEBLFjiYlQFnBZUW1YVj/LM+C+N4OP8Rs95VLEI2ot/RlfBg111la/EiyECFaJJi/A3FWA8MUA=="],
+
"@workspace/ui/recharts/eventemitter3": ["eventemitter3@5.0.4", "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"@workspace/ui/recharts/victory-vendor": ["victory-vendor@37.3.6", "https://registry.npmmirror.com/victory-vendor/-/victory-vendor-37.3.6.tgz", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
@@ -2759,6 +2764,10 @@
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
+ "@workspace/ui/motion/framer-motion/motion-dom": ["motion-dom@13.1.1", "", { "dependencies": { "motion-utils": "^13.0.0" } }, "sha512-XSf8VYWSB6G/0IY3rWVbyLcxWXtAVHkN1PQE2agTaCv3u8RGvbwu56TyyR/MNzBqqNavEBTZzErcxI1TxBrjcA=="],
+
+ "@workspace/ui/motion/framer-motion/motion-utils": ["motion-utils@13.0.0", "", {}, "sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ=="],
+
"glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"just-bash/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 075b3d5a..dfaaa0ee 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -26,6 +26,7 @@
"katex": "^0.16.45",
"lucide-react": "^1.12.0",
"mermaid": "^11.14.0",
+ "motion": "^13.1.1",
"nanoid": "^5.1.11",
"next-themes": "^0.4.6",
"react": "^19.2.4",
diff --git a/packages/ui/src/components/animated-theme-toggler.test.tsx b/packages/ui/src/components/animated-theme-toggler.test.tsx
new file mode 100644
index 00000000..1a262acc
--- /dev/null
+++ b/packages/ui/src/components/animated-theme-toggler.test.tsx
@@ -0,0 +1,266 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { GlobalRegistrator } from "@happy-dom/global-registrator";
+import { render } from "@testing-library/react/pure";
+import { act, type ReactElement } from "react";
+
+import { AnimatedThemeToggler } from "./animated-theme-toggler";
+
+const ICON_SETTLE_MS = 320; // Sun/Moon motion transitions run 250ms.
+
+function installTestDom() {
+ if (GlobalRegistrator.isRegistered) {
+ throw new Error("a test DOM is already registered");
+ }
+ GlobalRegistrator.register({ url: "https://animated-theme-toggler.test" });
+ return () => GlobalRegistrator.unregister();
+}
+
+async function withMounted(
+ element: ReactElement,
+ run: (rendered: ReturnType
) => Promise | void
+) {
+ const restoreDom = installTestDom();
+ const reactTestGlobals = globalThis as typeof globalThis & {
+ IS_REACT_ACT_ENVIRONMENT?: boolean;
+ };
+ const previousActEnvironment = reactTestGlobals.IS_REACT_ACT_ENVIRONMENT;
+ reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true;
+ let rendered: ReturnType | undefined;
+ try {
+ await act(() => {
+ rendered = render(element);
+ });
+ assert.ok(rendered);
+ await run(rendered);
+ } finally {
+ if (rendered) {
+ await act(async () => {
+ // Let motion icon animations finish before teardown: their timers
+ // must not fire against the unregistered DOM in a later test file.
+ await new Promise((resolve) => {
+ setTimeout(resolve, ICON_SETTLE_MS);
+ });
+ rendered?.unmount();
+ });
+ }
+ reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
+ await restoreDom();
+ }
+}
+
+test("controlled dark mode flips the class synchronously and reports only the requested theme (no VT)", async () => {
+ const changes: string[] = [];
+ await withMounted(
+ {
+ changes.push(next);
+ }}
+ theme="dark"
+ />,
+ async ({ container }) => {
+ document.documentElement.classList.add("dark");
+ const button = container.querySelector("button");
+ assert.ok(button, "button should render");
+
+ // happy-dom has no startViewTransition -> fallback path: class flips
+ // immediately, the owner is told the new theme.
+ await act(() => {
+ button.click();
+ });
+
+ assert.deepEqual(changes, ["light"]);
+ assert.equal(
+ document.documentElement.classList.contains("dark"),
+ false,
+ "controlled toggler paints the dark class itself so a later VT can snapshot it"
+ );
+ }
+ );
+});
+
+test("controlled mode never writes localStorage and keeps the parent-owned theme", async () => {
+ await withMounted(
+ {
+ // parent persists; component must not touch storage
+ }}
+ theme="dark"
+ />,
+ async ({ container }) => {
+ document.documentElement.classList.add("dark");
+ localStorage.clear();
+ const button = container.querySelector("button");
+ assert.ok(button);
+ await act(() => {
+ button.click();
+ });
+ assert.equal(
+ localStorage.getItem("theme"),
+ null,
+ "controlled mode must not persist; the owner (next-themes) does"
+ );
+ }
+ );
+});
+
+test("uncontrolled mode toggles the class both ways and persists to localStorage", async () => {
+ await withMounted(, async ({ container }) => {
+ const button = container.querySelector("button");
+ assert.ok(button);
+
+ // light -> dark (no dark class at mount, so the handler state is settled)
+ await act(async () => {
+ button.click();
+ await Promise.resolve();
+ });
+ assert.equal(document.documentElement.classList.contains("dark"), true);
+ assert.equal(localStorage.getItem("theme"), "dark");
+
+ // dark -> light (flush the MutationObserver mirror first so the handler
+ // closure observes the dark class)
+ await act(async () => {
+ button.click();
+ await Promise.resolve();
+ });
+ assert.equal(document.documentElement.classList.contains("dark"), false);
+ assert.equal(localStorage.getItem("theme"), "light");
+ });
+});
+
+test("an in-flight view transition suppresses repeat clicks until cleanup", async () => {
+ let vtCount = 0;
+ let resolveReady: () => void = () => undefined;
+ let resolveFinished: () => void = () => undefined;
+ const changes: string[] = [];
+ await withMounted(
+ {
+ changes.push(next);
+ }}
+ theme="dark"
+ />,
+ async ({ container }) => {
+ document.documentElement.classList.add("dark");
+ // Opaque slot for the stub: the lib.dom ViewTransition signature is
+ // narrower than what the component actually reads (.ready/.finished).
+ const doc = document as unknown as Record;
+ doc.startViewTransition = (cb: () => void) => {
+ vtCount += 1;
+ cb();
+ return {
+ ready: new Promise((resolve) => {
+ resolveReady = resolve;
+ }),
+ finished: new Promise((resolve) => {
+ resolveFinished = resolve;
+ }),
+ };
+ // Partial VT object — the component only reads .ready/.finished.
+ };
+ (Element.prototype as unknown as { animate?: unknown }).animate ??=
+ () => ({
+ cancel: () => undefined,
+ finished: Promise.resolve(),
+ });
+
+ const button = container.querySelector("button");
+ assert.ok(button);
+
+ await act(() => {
+ button.click();
+ });
+ // Still in flight: a second click must be a no-op.
+ await act(() => {
+ button.click();
+ });
+ assert.equal(vtCount, 1, "re-entrant clicks must not start a second VT");
+ assert.deepEqual(changes, ["light"]);
+ assert.equal(
+ document.documentElement.dataset.sealaiThemeVt,
+ "active",
+ "the scoped VT attrs stay pinned while in flight"
+ );
+
+ // Settle: resolve the pending VT promises inside act so the cleanup
+ // state update flushes.
+ await act(async () => {
+ resolveReady();
+ resolveFinished();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ assert.equal(
+ document.documentElement.dataset.sealaiThemeVt,
+ undefined,
+ "cleanup removes the scoped VT attrs"
+ );
+ assert.equal(
+ document.documentElement.style.getPropertyValue(
+ "--sealai-theme-toggle-vt-duration"
+ ),
+ ""
+ );
+ // happy-dom ships no native startViewTransition; restoring the
+ // pre-test (undefined) value keeps the stub from leaking.
+ doc.startViewTransition = undefined;
+ }
+ );
+});
+
+test("the button carries an accessible name even before the icon mounts", async () => {
+ await withMounted(, ({ container }) => {
+ const button = container.querySelector("button");
+ assert.ok(button);
+ assert.ok(
+ (button.textContent ?? "").includes("Toggle theme"),
+ "accessible sr-only name present"
+ );
+ });
+});
+
+// The icon is hidden during SSR/hydration and appears after mount; each theme
+// shows the opposite glyph (Sun invites "go light", Moon invites "go dark").
+// Sun is the only glyph containing a in lucide, which distinguishes
+// them without depending on generated class names.
+async function mountedIcon(container: HTMLElement) {
+ await act(async () => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 0);
+ });
+ });
+ const slot = container.querySelector(
+ '[data-slot="animated-theme-toggler-icon"]'
+ );
+ assert.ok(slot, "icon slot should render");
+ return slot.querySelector("svg");
+}
+
+test("dark theme renders the Sun glyph once mounted", async () => {
+ await withMounted(
+ ,
+ async ({ container }) => {
+ const icon = await mountedIcon(container);
+ assert.ok(icon, "one icon should show after mount");
+ assert.ok(
+ icon.querySelector("circle"),
+ "dark theme should show the Sun (rays circle)"
+ );
+ }
+ );
+});
+
+test("light theme renders the Moon glyph once mounted", async () => {
+ await withMounted(
+ ,
+ async ({ container }) => {
+ const icon = await mountedIcon(container);
+ assert.ok(icon, "one icon should show after mount");
+ assert.equal(
+ icon.querySelector("circle"),
+ null,
+ "light theme should show the Moon (path only)"
+ );
+ }
+ );
+});
diff --git a/packages/ui/src/components/animated-theme-toggler.tsx b/packages/ui/src/components/animated-theme-toggler.tsx
new file mode 100644
index 00000000..0c97972b
--- /dev/null
+++ b/packages/ui/src/components/animated-theme-toggler.tsx
@@ -0,0 +1,460 @@
+"use client";
+
+import { cn } from "@workspace/ui/lib/utils";
+import { Moon, Sun } from "lucide-react";
+import { AnimatePresence, motion } from "motion/react";
+import {
+ type ComponentPropsWithRef,
+ type MutableRefObject,
+ type Ref,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+
+export type TransitionVariant =
+ | "circle"
+ | "square"
+ | "triangle"
+ | "diamond"
+ | "hexagon"
+ | "rectangle"
+ | "star";
+
+export interface AnimatedThemeTogglerProps
+ extends ComponentPropsWithRef<"button"> {
+ /** Optional visible label rendered after the icon (accessible name stays
+ * the built-in "Toggle theme" sr-only text). */
+ children?: React.ReactNode;
+ /** Diffusion duration in milliseconds (View Transitions only). */
+ duration?: number;
+ /** Expand from the viewport center instead of the button center. */
+ fromCenter?: boolean;
+ /** Classes for the icon slot (e.g. to align it with a list-row icon column). */
+ iconClassName?: string;
+ /** Ref to the icon slot span (e.g. as a Tooltip anchor in list rows). */
+ iconRef?: Ref;
+ /** Called on toggle with the new theme. Pair with `theme` for controlled usage. */
+ onThemeChange?: (theme: "light" | "dark") => void;
+ /** React 19 ref-as-prop; merged with the internal button ref so callers
+ * (e.g. Tooltip anchors) can observe the element without stealing it. */
+ ref?: Ref;
+ /**
+ * Controlled theme value. When provided, the parent owns persistence
+ * (next-themes) and this component will not write to localStorage.
+ */
+ theme?: "light" | "dark";
+ /** Shape of the clip-path reveal. Defaults to a circle. */
+ variant?: TransitionVariant;
+}
+
+/**
+ * Animated light/dark theme toggle (ported from magicui's
+ * animated-theme-toggler, adapted for next-themes control).
+ *
+ * The page reveal is a native View Transition: `document.startViewTransition`
+ * snapshots the old page, the callback flips the `dark` class on
+ * synchronously (so the new snapshot already carries the new theme), and a
+ * clip-path keyframe animation on `::view-transition-new(root)` grows the new
+ * page out from the button. Browsers without View Transitions apply the theme
+ * change directly — no animation, no error. The globals.css rules scoped to
+ * `html[data-sealai-theme-vt="active"]` (duration sync + collapsed clip pin
+ * for Firefox) activate only while a toggle is in flight, so other view
+ * transitions in the app are untouched.
+ *
+ * In controlled mode the class flip is done here for snapshot correctness;
+ * the owner's `setTheme` runs afterwards and re-applies the same class
+ * (idempotent) while persisting the choice.
+ *
+ * The Sun/Moon micro-animation (motion) deliberately runs after the view
+ * transition settles: while a VT is active the live DOM is frozen, so icons
+ * crossfade in place right after the circular reveal completes.
+ */
+function polygonCollapsed(point: string, vertexCount: number): string {
+ const pairs = Array.from({ length: vertexCount }, () => point).join(", ");
+ return `polygon(${pairs})`;
+}
+
+// All coordinates are percentages of the snapshot reference box: Chrome
+// renders absolute px clip-path coordinates unscaled on fractional display
+// scales for the first transition after load, so px values can land at the
+// wrong position.
+function getThemeTransitionClipPaths(
+ variant: TransitionVariant,
+ cx: number,
+ cy: number,
+ maxRadius: number,
+ viewportWidth: number,
+ viewportHeight: number
+): [string, string] {
+ const toX = (x: number) => `${(x / viewportWidth) * 100}%`;
+ const toY = (y: number) => `${(y / viewportHeight) * 100}%`;
+ const point = (x: number, y: number) => `${toX(x)} ${toY(y)}`;
+ // circle() percentage radii resolve against hypot(w, h) / sqrt(2) of the
+ // reference box.
+ const toRadius = (r: number) =>
+ `${(r / (Math.hypot(viewportWidth, viewportHeight) / Math.SQRT2)) * 100}%`;
+
+ switch (variant) {
+ case "circle":
+ return [
+ `circle(0% at ${point(cx, cy)})`,
+ `circle(${toRadius(maxRadius)} at ${point(cx, cy)})`,
+ ];
+ case "square": {
+ const halfW = Math.max(cx, viewportWidth - cx);
+ const halfH = Math.max(cy, viewportHeight - cy);
+ const halfSide = Math.max(halfW, halfH) * 1.05;
+ const end = [
+ point(cx - halfSide, cy - halfSide),
+ point(cx + halfSide, cy - halfSide),
+ point(cx + halfSide, cy + halfSide),
+ point(cx - halfSide, cy + halfSide),
+ ].join(", ");
+ return [polygonCollapsed(point(cx, cy), 4), `polygon(${end})`];
+ }
+ case "triangle": {
+ const scale = maxRadius * 2.2;
+ const dx = (Math.sqrt(3) / 2) * scale;
+ const verts = [
+ point(cx, cy - scale),
+ point(cx + dx, cy + 0.5 * scale),
+ point(cx - dx, cy + 0.5 * scale),
+ ].join(", ");
+ return [polygonCollapsed(point(cx, cy), 3), `polygon(${verts})`];
+ }
+ case "diamond": {
+ // Slightly larger than the view-transition circle radius so
+ // axis-aligned coverage matches the circle reveal.
+ const R = maxRadius * Math.SQRT2;
+ const end = [
+ point(cx, cy - R),
+ point(cx + R, cy),
+ point(cx, cy + R),
+ point(cx - R, cy),
+ ].join(", ");
+ return [polygonCollapsed(point(cx, cy), 4), `polygon(${end})`];
+ }
+ case "hexagon": {
+ const R = maxRadius * Math.SQRT2;
+ const verts: string[] = [];
+ for (let i = 0; i < 6; i++) {
+ const a = -Math.PI / 2 + (i * Math.PI) / 3;
+ verts.push(point(cx + R * Math.cos(a), cy + R * Math.sin(a)));
+ }
+ return [
+ polygonCollapsed(point(cx, cy), 6),
+ `polygon(${verts.join(", ")})`,
+ ];
+ }
+ case "rectangle": {
+ const halfW = Math.max(cx, viewportWidth - cx);
+ const halfH = Math.max(cy, viewportHeight - cy);
+ const end = [
+ point(cx - halfW, cy - halfH),
+ point(cx + halfW, cy - halfH),
+ point(cx + halfW, cy + halfH),
+ point(cx - halfW, cy + halfH),
+ ].join(", ");
+ return [polygonCollapsed(point(cx, cy), 4), `polygon(${end})`];
+ }
+ case "star": {
+ // Small overscan so the last frames never leave a 1px seam before the
+ // transition group ends.
+ const R = maxRadius * Math.SQRT2 * 1.03;
+ const innerRatio = 0.42;
+ const starPolygon = (radius: number) => {
+ const verts: string[] = [];
+ for (let i = 0; i < 5; i++) {
+ const outerA = -Math.PI / 2 + (i * 2 * Math.PI) / 5;
+ verts.push(
+ point(
+ cx + radius * Math.cos(outerA),
+ cy + radius * Math.sin(outerA)
+ )
+ );
+ const innerA = outerA + Math.PI / 5;
+ verts.push(
+ point(
+ cx + radius * innerRatio * Math.cos(innerA),
+ cy + radius * innerRatio * Math.sin(innerA)
+ )
+ );
+ }
+ return `polygon(${verts.join(", ")})`;
+ };
+ const startR = Math.max(2, R * 0.025);
+ return [starPolygon(startR), starPolygon(R)];
+ }
+ default:
+ return [
+ `circle(0% at ${point(cx, cy)})`,
+ `circle(${toRadius(maxRadius)} at ${point(cx, cy)})`,
+ ];
+ }
+}
+
+export const AnimatedThemeToggler = ({
+ className,
+ duration = 400,
+ variant,
+ fromCenter = false,
+ theme,
+ onThemeChange,
+ iconClassName,
+ iconRef,
+ children,
+ onClick,
+ ref,
+ ...props
+}: AnimatedThemeTogglerProps) => {
+ const shape = variant ?? "circle";
+ const isControlled = theme !== undefined;
+ const [internalIsDark, setInternalIsDark] = useState(false);
+ const [mounted, setMounted] = useState(false);
+ // Ref gates re-entrant clicks synchronously; the paired state hides the
+ // icon while the transition is in flight (a render-time read of the ref
+ // would never re-show it — refs do not trigger renders).
+ const [isTransitioning, setIsTransitioning] = useState(false);
+ const isDark = isControlled ? theme === "dark" : internalIsDark;
+ const buttonRef = useRef(null);
+ const isTransitioningRef = useRef(false);
+ const activeAnimRef = useRef(null);
+
+ const cancelAnim = useCallback(() => {
+ activeAnimRef.current?.cancel();
+ activeAnimRef.current = null;
+ }, []);
+
+ const setButtonRef = useCallback(
+ (node: HTMLButtonElement | null) => {
+ buttonRef.current = node;
+ if (typeof ref === "function") {
+ ref(node);
+ } else if (ref) {
+ (ref as MutableRefObject).current = node;
+ }
+ },
+ [ref]
+ );
+
+ // No SSR truth for the theme; the icon appears once mounted so the Sun/Moon
+ // crossfade never has to resolve a hydration mismatch.
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ useEffect(() => {
+ return () => {
+ cancelAnim();
+ const root = document.documentElement;
+ if (root.dataset.sealaiThemeVt !== "active") {
+ return;
+ }
+ delete root.dataset.sealaiThemeVt;
+ root.style.removeProperty("--sealai-theme-toggle-vt-duration");
+ root.style.removeProperty("--sealai-theme-vt-clip-from");
+ };
+ }, [cancelAnim]);
+
+ // Uncontrolled mode mirrors the class so an external owner (e.g. a
+ // script that applies a stored theme) still drives the icon.
+ useEffect(() => {
+ if (isControlled) {
+ return;
+ }
+
+ const updateTheme = () => {
+ setInternalIsDark(document.documentElement.classList.contains("dark"));
+ };
+
+ updateTheme();
+
+ const observer = new MutationObserver(updateTheme);
+ observer.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ["class"],
+ });
+
+ return () => observer.disconnect();
+ }, [isControlled]);
+
+ const toggleTheme = useCallback(() => {
+ const button = buttonRef.current;
+ if (
+ !button ||
+ isTransitioningRef.current ||
+ document.documentElement.dataset.sealaiThemeVt === "active"
+ ) {
+ return;
+ }
+
+ // innerWidth/innerHeight (not visualViewport): percentages must resolve
+ // against the snapshot reference box, which includes classic scrollbars.
+ const viewportWidth = window.innerWidth;
+ const viewportHeight = window.innerHeight;
+
+ let x: number;
+ let y: number;
+ if (fromCenter) {
+ x = viewportWidth / 2;
+ y = viewportHeight / 2;
+ } else {
+ const { top, left, width, height } = button.getBoundingClientRect();
+ x = left + width / 2;
+ y = top + height / 2;
+ }
+
+ const maxRadius = Math.hypot(
+ Math.max(x, viewportWidth - x),
+ Math.max(y, viewportHeight - y)
+ );
+
+ const applyTheme = () => {
+ const newTheme = !isDark;
+ // Flip the class synchronously so the View Transitions API snapshots
+ // the new theme inside the startViewTransition callback. In controlled
+ // mode this is the authoritative paint; the owner's setTheme re-applies
+ // the same class afterwards (idempotent) and persists the choice.
+ const root = document.documentElement;
+ root.classList.toggle("dark");
+ root.style.colorScheme = newTheme ? "dark" : "light";
+ if (isControlled) {
+ onThemeChange?.(newTheme ? "dark" : "light");
+ } else {
+ setInternalIsDark(newTheme);
+ localStorage.setItem("theme", newTheme ? "dark" : "light");
+ }
+ };
+
+ if (typeof document.startViewTransition !== "function") {
+ applyTheme();
+ return;
+ }
+
+ const clipPath = getThemeTransitionClipPaths(
+ shape,
+ x,
+ y,
+ maxRadius,
+ viewportWidth,
+ viewportHeight
+ );
+
+ const root = document.documentElement;
+ root.dataset.sealaiThemeVt = "active";
+ root.style.setProperty(
+ "--sealai-theme-toggle-vt-duration",
+ `${duration}ms`
+ );
+ // Pin the collapsed clip-path via CSS so Firefox does not paint the new
+ // theme unclipped between snapshot and the ready.then() JS animation.
+ root.style.setProperty("--sealai-theme-vt-clip-from", clipPath[0]);
+ const cleanup = () => {
+ isTransitioningRef.current = false;
+ setIsTransitioning(false);
+ delete root.dataset.sealaiThemeVt;
+ root.style.removeProperty("--sealai-theme-toggle-vt-duration");
+ root.style.removeProperty("--sealai-theme-vt-clip-from");
+ cancelAnim();
+ };
+
+ isTransitioningRef.current = true;
+ setIsTransitioning(true);
+ const transition = document.startViewTransition(applyTheme);
+ if (typeof transition?.finished?.finally === "function") {
+ transition.finished.finally(cleanup).catch(() => undefined);
+ } else {
+ cleanup();
+ }
+
+ const ready = transition?.ready;
+ if (ready && typeof ready.then === "function") {
+ ready
+ .then(() => {
+ const anim = document.documentElement.animate(
+ { clipPath },
+ {
+ duration,
+ // Star: linear avoids easing overshoot that fights polygon
+ // interpolation at t→1; the VT group duration is synced above.
+ easing: shape === "star" ? "linear" : "ease-in-out",
+ fill: "forwards",
+ pseudoElement: "::view-transition-new(root)",
+ }
+ );
+ activeAnimRef.current = anim;
+ })
+ .catch(() => undefined);
+ }
+ }, [
+ shape,
+ fromCenter,
+ duration,
+ isDark,
+ isControlled,
+ onThemeChange,
+ cancelAnim,
+ ]);
+
+ return (
+
+ );
+};
diff --git a/packages/ui/src/components/theme-provider.tsx b/packages/ui/src/components/theme-provider.tsx
index 16d2eb3d..7c353e29 100644
--- a/packages/ui/src/components/theme-provider.tsx
+++ b/packages/ui/src/components/theme-provider.tsx
@@ -13,8 +13,7 @@ function ThemeProvider({
defaultTheme="dark"
disableTransitionOnChange
enableSystem={false}
- forcedTheme="dark"
- themes={["dark"]}
+ themes={["light", "dark"]}
{...props}
>
{children}
diff --git a/packages/ui/src/components/theme-toggle.tsx b/packages/ui/src/components/theme-toggle.tsx
new file mode 100644
index 00000000..ea0ab337
--- /dev/null
+++ b/packages/ui/src/components/theme-toggle.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import {
+ AnimatedThemeToggler,
+ type AnimatedThemeTogglerProps,
+} from "@workspace/ui/components/animated-theme-toggler";
+import { useTheme } from "next-themes";
+
+/**
+ * next-themes-backed {@link AnimatedThemeToggler}. The provider owns
+ * persistence; the toggler only reports the requested theme and paints the
+ * View Transition reveal. Kept in @workspace/ui because app packages do not
+ * resolve `next-themes` directly (same boundary as sonner.tsx).
+ */
+function ThemeToggle(
+ props: Omit
+) {
+ const { resolvedTheme, setTheme } = useTheme();
+
+ return (
+
+ );
+}
+
+export { ThemeToggle };
diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css
index 211ab9a6..44e95cdb 100644
--- a/packages/ui/src/styles/globals.css
+++ b/packages/ui/src/styles/globals.css
@@ -163,8 +163,9 @@
/** Shared glass-sheet blur radius; mirrors CANVAS_GLASS_BLUR_RADIUS in JS. */
--canvas-glass-blur-radius: 40px;
- /** Project chrome surface tokens. */
- --project-chrome-surface-base: #080a11;
+ /** Project chrome surface tokens. The dark base (#080a11) moved into
+ `.dark` so light mode follows the theme; dark resolves identically. */
+ --project-chrome-surface-base: oklch(0.985 0 0);
--project-chrome-surface: color-mix(
in oklab,
var(--input) 30%,
@@ -255,6 +256,10 @@
);
--sealai-brand-primary-hover: var(--color-blue-500);
--sealai-brand-primary-foreground: oklch(0.922 0 0);
+
+ /** Project chrome surface base (was :root-only dark; paired here so dark
+ resolves exactly as before while light follows the theme). */
+ --project-chrome-surface-base: #080a11;
}
@layer base {
@@ -746,6 +751,26 @@
}
}
+/* AnimatedThemeToggler View Transitions (ported from magicui): disable the
+ browser's default crossfade so the JS clip-path animation owns the reveal.
+ Scoped via the data-attribute the toggler sets only while a toggle is in
+ flight, so other view transitions in the app (e.g. Next.js navigation)
+ keep default behavior. The clip-from pin keeps Firefox from painting the
+ new theme unclipped between snapshot and the JS animation. */
+::view-transition-old(root),
+::view-transition-new(root) {
+ mix-blend-mode: normal;
+ animation: none;
+}
+
+html[data-sealai-theme-vt="active"]::view-transition-group(root) {
+ animation-duration: var(--sealai-theme-toggle-vt-duration);
+}
+
+html[data-sealai-theme-vt="active"]::view-transition-new(root) {
+ clip-path: var(--sealai-theme-vt-clip-from);
+}
+
/* Registered via @utility (not a plain class in @layer utilities) so it can
compose with variants; plain layered classes silently produce no CSS when
used behind a variant prefix. */
From f163515d4efb7783a44135996943616aa88a6a4b Mon Sep 17 00:00:00 2001
From: zjy365 <3161362058@qq.com>
Date: Wed, 2 Sep 2026 13:39:22 +0800
Subject: [PATCH 2/3] feat(theme): light-mode adaptation for DB Access /
DataBrowser (#140)
Drop the hard-coded `dark` wrappers from DataBrowserPane and the main
action surface frame so the DB Access experience follows the theme.
Pair the dark-only component tokens so dark resolves pixel-identical
while light gets readable values:
- --db-access-sticky-header-surface / new --db-access-row-selector-
surface: navy originals moved to .dark, light uses --muted.
- --main-action-surface-bg / --main-action-surface-glow-color: the
overlay paints the canvas navy + blue glow in dark, a plain sheet
with no glow in light.
- White-overlay chips and zinc text in export/backup dialogs get light
semantic bases with dark: originals.
Test contract updated: the surface no longer carries a literal
bg-neutral-950 class (background is token-driven now).
Refs: labring/sealos-private#138 #140
---
.../features/db-browser/DataBrowserPane.tsx | 2 +-
.../backups/BackupServiceSurface.tsx | 8 ++++---
.../shared/SingleObjectExportModal.tsx | 4 ++--
.../sql/TableView/TableView.DataGrid.tsx | 2 +-
.../actions/canvas-action-surface.test.tsx | 5 ++---
.../actions/canvas-action-surface.tsx | 4 ++--
packages/ui/src/styles/globals.css | 22 ++++++++++++++++---
7 files changed, 32 insertions(+), 15 deletions(-)
diff --git a/apps/ui/src/features/db-browser/DataBrowserPane.tsx b/apps/ui/src/features/db-browser/DataBrowserPane.tsx
index bf440a33..d8176df4 100644
--- a/apps/ui/src/features/db-browser/DataBrowserPane.tsx
+++ b/apps/ui/src/features/db-browser/DataBrowserPane.tsx
@@ -42,7 +42,7 @@ export function DataBrowserPane({
refreshProjectCanvas={refreshProjectCanvas}
selectedDatabaseData={selectedDatabaseData}
>
-
+
diff --git a/apps/ui/src/features/db-browser/backups/BackupServiceSurface.tsx b/apps/ui/src/features/db-browser/backups/BackupServiceSurface.tsx
index cc5bb6c0..c69ad12f 100644
--- a/apps/ui/src/features/db-browser/backups/BackupServiceSurface.tsx
+++ b/apps/ui/src/features/db-browser/backups/BackupServiceSurface.tsx
@@ -295,7 +295,7 @@ function BackupRowsList({
const canDelete = backup.deletable && !isDeleting;
return (
-
+
{"Type "}
- {backup.name}
+
+ {backup.name}
+
{" to confirm."}
orders-db<\/p>/;
const DB_ACCESS_TITLE_RE =
/DB Access<\/h2>/;
-const ACTION_SURFACE_BACKGROUND_RE = /bg-neutral-950/;
+// The surface background is theme-paired (light sheet / dark canvas navy)
+// via --main-action-surface-bg, so no literal bg-neutral-950 remains.
const MAIN_ACTION_SURFACE_BACKGROUND_RE = /main-action-surface-background/;
const MAIN_ACTION_BODY_BACKGROUND_RE = /main-action-surface-body-background/;
const DB_ACCESS_RE = /text-foreground/;
@@ -76,7 +77,6 @@ test("main action surface renders shared chrome and empty body slot", () => {
assert.match(html, DB_ACCESS_TITLE_RE);
assert.doesNotMatch(html, ENGINE_SUBTITLE_RE);
assert.match(html, CLOSE_LABEL_RE);
- assert.match(html, ACTION_SURFACE_BACKGROUND_RE);
assert.match(html, MAIN_ACTION_SURFACE_BACKGROUND_RE);
assert.match(html, MAIN_ACTION_BODY_BACKGROUND_RE);
assert.match(html, DB_ACCESS_RE);
@@ -101,7 +101,6 @@ test("main action surface frame renders custom surface content", () => {
assert.match(html, CUSTOM_TITLE_RE);
assert.match(html, CUSTOM_SUBTITLE_RE);
assert.match(html, CUSTOM_BODY_RE);
- assert.match(html, ACTION_SURFACE_BACKGROUND_RE);
assert.match(html, MAIN_ACTION_SURFACE_BACKGROUND_RE);
assert.match(html, MAIN_ACTION_BODY_BACKGROUND_RE);
});
diff --git a/apps/ui/src/features/project-canvas/actions/canvas-action-surface.tsx b/apps/ui/src/features/project-canvas/actions/canvas-action-surface.tsx
index 83067760..2c78ae4b 100644
--- a/apps/ui/src/features/project-canvas/actions/canvas-action-surface.tsx
+++ b/apps/ui/src/features/project-canvas/actions/canvas-action-surface.tsx
@@ -62,7 +62,7 @@ export function MainActionSurfaceFrame({
return (
|