From 4df465300b723d1f7537374d5e3240470028eeab Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 23:58:30 -0400 Subject: [PATCH 1/2] feat(studio): menus and popovers paint instantly Menus and popovers open and close with no fade. Hover colour is unchanged. --- packages/studio/src/styles/theme.css | 3 ++- packages/studio/src/styles/theme.test.ts | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/styles/theme.css b/packages/studio/src/styles/theme.css index 80434050ad..ac9fcba48a 100644 --- a/packages/studio/src/styles/theme.css +++ b/packages/studio/src/styles/theme.css @@ -208,7 +208,8 @@ --duration-press: 120ms; --duration-hover: 150ms; --duration-expand: 160ms; - --duration-open: 180ms; + /* Menus and popovers share this for open and close. Zero so they paint. */ + --duration-open: 0ms; /* ------------------------------------------------------------------ icon */ diff --git a/packages/studio/src/styles/theme.test.ts b/packages/studio/src/styles/theme.test.ts index 902d1ad810..f8d98efe08 100644 --- a/packages/studio/src/styles/theme.test.ts +++ b/packages/studio/src/styles/theme.test.ts @@ -109,6 +109,10 @@ describe("studio theme", () => { expect(vars.get("--text-lg")).toBe("1.125rem"); }); + it("reads a zero open duration so menus and popovers paint immediately", () => { + expect(declaredValue("--duration-open")).toBe("0ms"); + }); + it("compiles a motion-duration utility with a reduced-motion variant", async () => { const css = await build("studio.css", ["duration-press", "duration-open"]); From 6e69b065e781b246bf7559b3eb9c9a85ea616442 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 22 Sep 2026 00:51:15 -0400 Subject: [PATCH 2/2] fix(studio): fade menus in from a visible shape Menus, popovers, and dropdowns start at opacity 0.8 and scale 0.97, then settle in 120ms. They close in 90ms. Tooltips use the same shape in 100ms. --- .../studio/src/components/ui/Menu.test.tsx | 7 +++--- packages/studio/src/components/ui/Menu.tsx | 23 +++++++++++++++---- packages/studio/src/components/ui/Select.tsx | 8 ++++++- packages/studio/src/components/ui/Tooltip.tsx | 7 +++++- packages/studio/src/styles/theme.css | 10 ++++++-- packages/studio/src/styles/theme.test.ts | 9 ++++++-- 6 files changed, 49 insertions(+), 15 deletions(-) diff --git a/packages/studio/src/components/ui/Menu.test.tsx b/packages/studio/src/components/ui/Menu.test.tsx index 261ce83edf..ef75f8b558 100644 --- a/packages/studio/src/components/ui/Menu.test.tsx +++ b/packages/studio/src/components/ui/Menu.test.tsx @@ -360,10 +360,9 @@ describe("open motion", () => { // AE4. it("names a motion token that zeroes itself under reduced motion", async () => { - // The zero-duration case lives in the `duration-open` utility itself - // (theme.css), not in a `motion-reduce:` class beside it, so a caller - // cannot use the token and forget the reduced-motion half. The menu's job - // is to name the token; the stylesheet's job is the media query. + // Reduced motion lives in the `duration-open` utility itself (theme.css), + // not in a `motion-reduce:` class beside it, so a caller cannot use the + // token and forget that half. The menu's job is to name the token. await openActionMenu(); expect([...popup()!.classList]).toContain("duration-open"); diff --git a/packages/studio/src/components/ui/Menu.tsx b/packages/studio/src/components/ui/Menu.tsx index c3daf04153..a0b36d54b9 100644 --- a/packages/studio/src/components/ui/Menu.tsx +++ b/packages/studio/src/components/ui/Menu.tsx @@ -27,15 +27,28 @@ const SIDE_OFFSET = 6; const VIEWPORT_MARGIN = 8; /** - * Chrome shared by every floating panel (`Popover` too); callers add their own shadow token. + * Enter and exit shared by menus, popovers, dropdowns, and tooltips. + * The open duration class differs; the shape does not. * `data-starting-style`/`data-ending-style` are Base UI's transition attributes. */ +export function floatingMotion(openDuration: "duration-open" | "duration-tooltip") { + return cn( + "origin-[var(--transform-origin)] outline-hidden", + "transition-[opacity,transform] ease-out-quint", + openDuration, + "data-[ending-style]:duration-close data-[ending-style]:ease-in", + "data-[starting-style]:[opacity:var(--popup-enter-opacity)]", + "data-[starting-style]:[scale:var(--popup-enter-scale)]", + "data-[ending-style]:opacity-0", + ); +} + +/** + * Chrome shared by every floating panel (`Popover` too); callers add their own shadow token. + */ export const popupSurface = cn( "rounded-lg border border-border-input bg-surface", - "origin-[var(--transform-origin)] outline-hidden", - "transition-[opacity,transform] ease-out-quint duration-open", - "data-[starting-style]:opacity-0 data-[starting-style]:scale-95", - "data-[ending-style]:opacity-0 data-[ending-style]:scale-95", + floatingMotion("duration-open"), "data-[preview-state=open]:opacity-100 data-[preview-state=open]:scale-100", ); diff --git a/packages/studio/src/components/ui/Select.tsx b/packages/studio/src/components/ui/Select.tsx index 0c7acf6736..3a5e60bae4 100644 --- a/packages/studio/src/components/ui/Select.tsx +++ b/packages/studio/src/components/ui/Select.tsx @@ -6,6 +6,7 @@ import { Select as BaseSelect } from "@base-ui/react/select"; import { cn } from "./cn"; import { fieldBase } from "./Input"; +import { floatingMotion } from "./Menu"; import type { PreviewState } from "./Button"; export interface SelectOption { @@ -77,7 +78,12 @@ export function Select({ - + {options.map((option) => ( {label} diff --git a/packages/studio/src/styles/theme.css b/packages/studio/src/styles/theme.css index ac9fcba48a..8efd3cc419 100644 --- a/packages/studio/src/styles/theme.css +++ b/packages/studio/src/styles/theme.css @@ -208,8 +208,14 @@ --duration-press: 120ms; --duration-hover: 150ms; --duration-expand: 160ms; - /* Menus and popovers share this for open and close. Zero so they paint. */ - --duration-open: 0ms; + /* Menus, popovers, and dropdowns. Open is ease-out; close is shorter and ease-in. */ + --duration-open: 120ms; + --duration-close: 90ms; + /* Tooltips use the same enter shape on a shorter open. */ + --duration-tooltip: 100ms; + /* Visible on the first frame. Never enter from scale 0 or opacity 0. */ + --popup-enter-opacity: 0.8; + --popup-enter-scale: 0.97; /* ------------------------------------------------------------------ icon */ diff --git a/packages/studio/src/styles/theme.test.ts b/packages/studio/src/styles/theme.test.ts index f8d98efe08..039ceeae1a 100644 --- a/packages/studio/src/styles/theme.test.ts +++ b/packages/studio/src/styles/theme.test.ts @@ -109,8 +109,13 @@ describe("studio theme", () => { expect(vars.get("--text-lg")).toBe("1.125rem"); }); - it("reads a zero open duration so menus and popovers paint immediately", () => { - expect(declaredValue("--duration-open")).toBe("0ms"); + it("opens menus from a visible shape and closes them faster", () => { + expect(declaredValue("--duration-open")).toBe("120ms"); + expect(declaredValue("--duration-close")).toBe("90ms"); + expect(declaredValue("--duration-tooltip")).toBe("100ms"); + expect(declaredValue("--popup-enter-opacity")).toBe("0.8"); + expect(declaredValue("--popup-enter-scale")).toBe("0.97"); + expect(declaredValue("--duration-hover")).toBe("150ms"); }); it("compiles a motion-duration utility with a reduced-motion variant", async () => {