From 1af788f6dcb800b5730d92591610165ba3f83a5c Mon Sep 17 00:00:00 2001 From: Kaloyan Stoyanov Date: Sun, 20 Sep 2026 10:14:55 +0300 Subject: [PATCH 01/12] fix(web): scope interface animations to state changes --- .../components/ProjectScriptsControl.test.tsx | 61 ++++++---- .../src/components/ProjectScriptsControl.tsx | 26 ++-- apps/web/src/components/chat/ChatHeader.tsx | 13 +- .../src/components/ui/changing-state.test.tsx | 17 +++ apps/web/src/components/ui/changing-state.tsx | 113 ++++++++++++++++++ apps/web/src/index.css | 45 +++++++ 6 files changed, 246 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/components/ui/changing-state.test.tsx create mode 100644 apps/web/src/components/ui/changing-state.tsx diff --git a/apps/web/src/components/ProjectScriptsControl.test.tsx b/apps/web/src/components/ProjectScriptsControl.test.tsx index 268da8ec71aed..8b92947d6f13c 100644 --- a/apps/web/src/components/ProjectScriptsControl.test.tsx +++ b/apps/web/src/components/ProjectScriptsControl.test.tsx @@ -29,6 +29,10 @@ vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); vi.mock("~/state/server", () => ({ serverEnvironment: { configValueAtom: () => null } })); vi.mock("~/state/session", () => ({ readEnvironmentScope: () => true })); +vi.mock("../panelAnimations", () => ({ + usePanelAnimationSettings: () => ({ active: true, durationMs: 180 }), +})); + vi.mock("./projectScriptEditor", () => ({ EMPTY_PROJECT_SCRIPT_INPUT: { name: "", @@ -125,28 +129,32 @@ const scripts = [ const action = async () => AsyncResult.success(undefined); -function renderControl( - input: { - startingScriptIds?: ReadonlySet; - runningScriptIds?: ReadonlySet; - } = {}, -) { +interface ControlState { + startingScriptIds?: ReadonlySet; + runningScriptIds?: ReadonlySet; +} + +function Control(input: ControlState) { + return ( + + ); +} + +function renderControl(input: ControlState = {}) { let nextRenderer!: ReactTestRenderer; act(() => { - nextRenderer = create( - , - ); + nextRenderer = create(); }); return nextRenderer; } @@ -203,4 +211,17 @@ describe("ProjectScriptsControl", () => { expect(renderer.root.findAllByProps({ "data-icon": "square" })).not.toHaveLength(0); expect(runButton.props["aria-busy"]).toBe(false); }); + + it("animates the primary icon only when its command state changes", () => { + renderer = renderControl(); + expect(renderer.root.findByProps({ "data-changing-state": "build:idle" })).toBeDefined(); + + act(() => { + renderer?.update(); + }); + + expect(renderer.root.findAllByProps({ "data-changing-state": "build:idle" })).toHaveLength(0); + expect(renderer.root.findByProps({ "data-changing-state": "build:starting" })).toBeDefined(); + expect(renderer.root.findAllByProps({ className: "changing-icon-content" })).toHaveLength(1); + }); }); diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 34aee3c352a6e..eb7604e1739ad 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -62,6 +62,7 @@ import { THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, } from "./chat/threadDetailsPanelStyles"; +import { AnimatedIcon, useAnimatedLayout } from "./ui/changing-state"; export type { NewProjectScriptInput, ProjectScriptActionResult }; @@ -224,6 +225,12 @@ export default function ProjectScriptsControl({ : primaryScriptIsStarting ? `Starting ${primaryScript.name}` : `Run ${primaryScript.name}`; + const primaryScriptState = primaryScriptIsRunning + ? "running" + : primaryScriptIsStarting + ? "starting" + : "idle"; + const animatedGroupRef = useAnimatedLayout(); const scriptItems = ( <> @@ -346,7 +353,7 @@ export default function ProjectScriptsControl({ aria-label="Project scripts" {...(isPanel ? { className: THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, ref: panelAnchorRef } - : {})} + : { ref: animatedGroupRef })} > } > - + + +

- {activeThreadTitle} +

} > - {activeThreadTitle} +
{activeThreadTitle}
diff --git a/apps/web/src/components/ui/changing-state.test.tsx b/apps/web/src/components/ui/changing-state.test.tsx new file mode 100644 index 0000000000000..3f0de39f9db52 --- /dev/null +++ b/apps/web/src/components/ui/changing-state.test.tsx @@ -0,0 +1,17 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { AnimatedText } from "./changing-state"; + +describe("changing-state motion", () => { + it("keeps changing text accessible as one label while staggering its visible words", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('aria-label="Improve shared history"'); + expect(markup).toContain('aria-hidden="true"'); + expect(markup).toContain("--changing-text-index:0"); + expect(markup).toContain("--changing-text-index:2"); + }); +}); diff --git a/apps/web/src/components/ui/changing-state.tsx b/apps/web/src/components/ui/changing-state.tsx new file mode 100644 index 0000000000000..eced9b5785ae8 --- /dev/null +++ b/apps/web/src/components/ui/changing-state.tsx @@ -0,0 +1,113 @@ +import autoAnimate from "@formkit/auto-animate"; +import { + memo, + useCallback, + useEffect, + useRef, + type CSSProperties, + type ElementType, + type ReactNode, + type RefCallback, +} from "react"; + +import { cn } from "~/lib/utils"; +import { usePanelAnimationSettings } from "../../panelAnimations"; + +type ChangingStateStyle = CSSProperties & { "--changing-state-duration": string }; +type ChangingTextStyle = CSSProperties & { "--changing-text-index": number }; + +const derivativeDuration = (durationMs: number) => Math.round(durationMs / 1.5); + +function textTokens(value: string) { + const occurrences = new Map(); + return value.split(/(\s+)/).map((text, position) => { + const occurrence = occurrences.get(text) ?? 0; + occurrences.set(text, occurrence + 1); + return { key: `${text}:${occurrence}`, position, text }; + }); +} + +export const AnimatedText = memo(function AnimatedText({ + value, + changeKey, + as: Component = "span", + className, +}: { + readonly value: string; + readonly changeKey: string; + readonly as?: ElementType; + readonly className?: string; +}) { + const { active, durationMs } = usePanelAnimationSettings(); + const tokens = textTokens(value); + const style = { + "--changing-state-duration": `${derivativeDuration(durationMs)}ms`, + } as ChangingStateStyle; + + return ( + + + + ); +}); + +export const AnimatedIcon = memo( + function AnimatedIcon({ + stateKey, + children, + className, + }: { + readonly stateKey: string; + readonly children: ReactNode; + readonly className?: string; + }) { + const { active, durationMs } = usePanelAnimationSettings(); + const style = { + "--changing-state-duration": `${derivativeDuration(durationMs)}ms`, + } as ChangingStateStyle; + + return ( + + + {children} + + + ); + }, + (previous, next) => previous.stateKey === next.stateKey && previous.className === next.className, +); + +export function useAnimatedLayout(): RefCallback { + const { active, durationMs } = usePanelAnimationSettings(); + const elementRef = useRef(null); + const setElement = useCallback((element: T | null) => { + elementRef.current = element; + }, []); + + useEffect(() => { + const element = elementRef.current; + if (!element || typeof HTMLElement === "undefined") return; + const controller = autoAnimate(element, { + duration: derivativeDuration(durationMs), + easing: "cubic-bezier(0.23, 1, 0.32, 1)", + }); + if (!active) controller.disable(); + return () => controller.destroy?.(); + }, [active, durationMs]); + + return setElement; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 815deb6bcfb87..7b933c5ac5122 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,5 +1,50 @@ @import "tailwindcss"; +@keyframes changing-text-enter { + from { + opacity: 0; + filter: blur(3px); + transform: translateY(0.22em); + } +} + +.changing-text, +.changing-text-content { + min-width: 0; +} + +.changing-text-word { + display: inline-block; + animation: changing-text-enter min(var(--changing-state-duration, 120ms), 180ms) + cubic-bezier(0.23, 1, 0.32, 1) both; + animation-delay: min(calc(var(--changing-text-index) * 12ms), 72ms); +} + +@keyframes changing-icon-enter { + from { + opacity: 0; + transform: scale(0.82) rotate(-8deg); + } +} + +.changing-icon { + display: inline-grid; + place-items: center; +} + +.changing-icon-content { + display: inline-flex; + animation: changing-icon-enter var(--changing-state-duration, 120ms) + cubic-bezier(0.23, 1, 0.32, 1) both; +} + +@media (prefers-reduced-motion: reduce) { + .changing-text-word, + .changing-icon-content { + animation: none; + } +} + @custom-variant dark (&:is(.dark, .dark *)); @custom-variant light (&:not(.dark, .dark *)); /* Window Controls Overlay: active when Electron exposes native titlebar control geometry. */ From 2247572cdfb15654ea7e04580ff99903be73bcb5 Mon Sep 17 00:00:00 2001 From: Kaloyan Stoyanov Date: Sun, 20 Sep 2026 10:24:00 +0300 Subject: [PATCH 02/12] feat(web): add UI animation duration setting --- .../components/settings/SettingsPanels.tsx | 60 +++++++++++++++++++ .../settings/settingsSearch.test.ts | 1 + .../src/components/settings/settingsSearch.ts | 6 ++ packages/contracts/src/settings.test.ts | 16 +++++ packages/contracts/src/settings.ts | 14 +++++ 5 files changed, 97 insertions(+) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 86cb4854eaa0c..4db9805d91b98 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -37,6 +37,7 @@ import { MAX_PROMPT_FONT_SIZE, MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, MAX_TERMINAL_FONT_SIZE, + MAX_UI_ANIMATION_DURATION_MS, MIN_CODE_FONT_SIZE, MIN_APPEARANCE_CONTRAST, MIN_GLASS_OPACITY, @@ -46,6 +47,7 @@ import { MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, type ResponseStreamingMode, MIN_TERMINAL_FONT_SIZE, + MIN_UI_ANIMATION_DURATION_MS, type QuitConfirmationMode, type UsageMonitorMode, SidebarProjectSortOrder, @@ -559,6 +561,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.panelAnimationDurationMs !== DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs ? ["Panel animations"] : []), + ...(settings.uiAnimationDurationMs !== DEFAULT_UNIFIED_SETTINGS.uiAnimationDurationMs + ? ["UI animations"] + : []), ...(settings.environmentIdentificationMode !== DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode ? ["Environment identification"] @@ -823,6 +828,7 @@ export function useSettingsRestore(onRestored?: () => void) { environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs, + uiAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.uiAnimationDurationMs, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, sidebarProjectSortOrder: DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder, @@ -1202,6 +1208,13 @@ export function AppearanceSettingsPanel() { "--settings-slider-progress": `${panelAnimationDurationRatio * 100}%`, "--settings-slider-fill-offset": `${0.5 - panelAnimationDurationRatio}rem`, } as CSSProperties; + const uiAnimationDurationRatio = + (settings.uiAnimationDurationMs - MIN_UI_ANIMATION_DURATION_MS) / + (MAX_UI_ANIMATION_DURATION_MS - MIN_UI_ANIMATION_DURATION_MS); + const uiAnimationDurationSliderStyle = { + "--settings-slider-progress": `${uiAnimationDurationRatio * 100}%`, + "--settings-slider-fill-offset": `${0.5 - uiAnimationDurationRatio}rem`, + } as CSSProperties; return ( @@ -1512,6 +1525,53 @@ export function AppearanceSettingsPanel() { + + + {settings.uiAnimationDurationMs} ms + + { + const uiAnimationDurationMs = Number(event.currentTarget.value); + if ( + Number.isInteger(uiAnimationDurationMs) && + uiAnimationDurationMs >= MIN_UI_ANIMATION_DURATION_MS && + uiAnimationDurationMs <= MAX_UI_ANIMATION_DURATION_MS + ) { + updateSettings({ uiAnimationDurationMs }); + } + }} + step={25} + style={uiAnimationDurationSliderStyle} + type="range" + value={settings.uiAnimationDurationMs} + /> + + } + resetAction={ + settings.uiAnimationDurationMs !== DEFAULT_UNIFIED_SETTINGS.uiAnimationDurationMs ? ( + + updateSettings({ + uiAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.uiAnimationDurationMs, + }) + } + /> + ) : null + } + /> { expect(searchSettings(" WORD WRAP ", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]); expect(searchSettings("glass").map((item) => item.id)).toEqual(["setting-glass-opacity"]); expect(searchSettings("panel animations").map((item) => item.id)).toEqual(["panel-animations"]); + expect(searchSettings("ui animations").map((item) => item.id)).toEqual(["ui-animations"]); expect(searchSettings("thè\u{1ab0}mes")[0]?.id).toBe("theme"); const localeLowerCase = vi.spyOn(String.prototype, "toLocaleLowerCase").mockReturnValue("gıt"); try { diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index b69c820f3f481..317fbc66fc82a 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -289,6 +289,12 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Panel animations", to: "/settings/appearance", }, + { + id: "ui-animations", + title: "UI animations", + to: "/settings/appearance", + searchTerms: ["motion duration milliseconds reduced motion"], + }, { id: "environment-identification", title: "Environment identification", diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index b39e0753ed13b..b561ea93117b6 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -677,6 +677,22 @@ describe("ClientSettings panel animations", () => { }); }); +describe("ClientSettings UI animations", () => { + it("defaults to 150 ms", () => { + expect(decodeClientSettings({}).uiAnimationDurationMs).toBe(150); + }); + + it.each([0, 400])("accepts a UI animation duration: %s", (value) => { + expect(decodeClientSettingsPatch({ uiAnimationDurationMs: value })).toEqual({ + uiAnimationDurationMs: value, + }); + }); + + it.each([-1, 401, 150.5])("rejects an invalid UI animation duration: %s", (value) => { + expect(() => decodeClientSettingsPatch({ uiAnimationDurationMs: value })).toThrow(); + }); +}); + describe("ClientSettings environment identification", () => { it("defaults to artwork and accepts each presentation mode", () => { expect(decodeClientSettings({}).environmentIdentificationMode).toBe("artwork"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index a06049d3c4551..810a49ccc732c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -121,6 +121,16 @@ export const PanelAnimationDurationMs = Schema.Int.check( ); export type PanelAnimationDurationMs = typeof PanelAnimationDurationMs.Type; const DEFAULT_PANEL_ANIMATION_DURATION_MS: PanelAnimationDurationMs = 0; +export const MIN_UI_ANIMATION_DURATION_MS = 0; +export const MAX_UI_ANIMATION_DURATION_MS = 400; +export const UiAnimationDurationMs = Schema.Int.check( + Schema.isBetween({ + minimum: MIN_UI_ANIMATION_DURATION_MS, + maximum: MAX_UI_ANIMATION_DURATION_MS, + }), +); +export type UiAnimationDurationMs = typeof UiAnimationDurationMs.Type; +const DEFAULT_UI_ANIMATION_DURATION_MS: UiAnimationDurationMs = 150; /** * Font size preferences, in CSS pixels. The ranges are deliberately narrow: * the interface size scales every rem-based dimension in the app, so the @@ -339,6 +349,9 @@ export const ClientSettingsSchema = Schema.Struct({ panelAnimationDurationMs: PanelAnimationDurationMs.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_PANEL_ANIMATION_DURATION_MS)), ), + uiAnimationDurationMs: UiAnimationDurationMs.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_UI_ANIMATION_DURATION_MS)), + ), browserDefaultViewport: PreviewViewportSetting.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_VIEWPORT)), ), @@ -2175,6 +2188,7 @@ export const ClientSettingsPatch = Schema.Struct({ loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights), appearanceContrast: Schema.optionalKey(AppearanceContrast), panelAnimationDurationMs: Schema.optionalKey(PanelAnimationDurationMs), + uiAnimationDurationMs: Schema.optionalKey(UiAnimationDurationMs), browserDefaultViewport: Schema.optionalKey(PreviewViewportSetting), browserDefaultZoomFactor: Schema.optionalKey(PreviewZoomFactor), browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference), From 1c849c16d43718f6f375e79194cb4759c3c7c6f9 Mon Sep 17 00:00:00 2001 From: Kaloyan Stoyanov Date: Sun, 20 Sep 2026 10:26:42 +0300 Subject: [PATCH 03/12] fix(web): smooth breadcrumb and dropdown motion --- .../components/ProjectScriptsControl.test.tsx | 4 +- apps/web/src/components/chat/ChatHeader.tsx | 8 +- .../components/settings/SettingsPanels.tsx | 1 + .../src/components/ui/changing-state.test.tsx | 32 +++++--- apps/web/src/components/ui/changing-state.tsx | 74 ++++++++++--------- apps/web/src/components/ui/menu.tsx | 2 +- apps/web/src/index.css | 35 +++++++-- apps/web/src/uiAnimations.test.ts | 11 +++ apps/web/src/uiAnimations.ts | 18 +++++ 9 files changed, 130 insertions(+), 55 deletions(-) create mode 100644 apps/web/src/uiAnimations.test.ts create mode 100644 apps/web/src/uiAnimations.ts diff --git a/apps/web/src/components/ProjectScriptsControl.test.tsx b/apps/web/src/components/ProjectScriptsControl.test.tsx index 8b92947d6f13c..7d4f26905ffcf 100644 --- a/apps/web/src/components/ProjectScriptsControl.test.tsx +++ b/apps/web/src/components/ProjectScriptsControl.test.tsx @@ -29,8 +29,8 @@ vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); vi.mock("~/state/server", () => ({ serverEnvironment: { configValueAtom: () => null } })); vi.mock("~/state/session", () => ({ readEnvironmentScope: () => true })); -vi.mock("../panelAnimations", () => ({ - usePanelAnimationSettings: () => ({ active: true, durationMs: 180 }), +vi.mock("../uiAnimations", () => ({ + useUiAnimationSettings: () => ({ active: true, durationMs: 180 }), })); vi.mock("./projectScriptEditor", () => ({ diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 41a069ae6675f..9beca08cfdab0 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -435,10 +435,10 @@ export const ChatHeader = memo(function ChatHeader({ render={

} > + as={WorkspaceBreadcrumbText} + value={activeThreadTitle} + changeKey={`${activeThreadEnvironmentId}:${activeThreadId}`} + /> {activeThreadTitle} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 4db9805d91b98..62706d87545b9 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -720,6 +720,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.fontSizeTerminal, settings.glassOpacity, settings.panelAnimationDurationMs, + settings.uiAnimationDurationMs, settings.responseStreamingMode, settings.persistComposerContextStrip, settings.enableProviderUpdateChecks, diff --git a/apps/web/src/components/ui/changing-state.test.tsx b/apps/web/src/components/ui/changing-state.test.tsx index 3f0de39f9db52..015583b62ca03 100644 --- a/apps/web/src/components/ui/changing-state.test.tsx +++ b/apps/web/src/components/ui/changing-state.test.tsx @@ -1,17 +1,29 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vite-plus/test"; +import { act } from "react"; +import { create } from "react-test-renderer"; +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("../../uiAnimations", () => ({ + useUiAnimationSettings: () => ({ active: true, durationMs: 150 }), +})); import { AnimatedText } from "./changing-state"; describe("changing-state motion", () => { - it("keeps changing text accessible as one label while staggering its visible words", () => { - const markup = renderToStaticMarkup( - , - ); + it("keeps the outgoing breadcrumb text until its exit animation finishes", () => { + let renderer!: ReturnType; + act(() => { + renderer = create(); + }); + act(() => { + renderer.update(); + }); + + const outgoing = renderer.root.findByProps({ "data-changing-text": "outgoing" }); + const incoming = renderer.root.findByProps({ "data-changing-text": "incoming" }); + expect(outgoing.children).toEqual(["First thread"]); + expect(incoming.children).toEqual(["Second thread"]); - expect(markup).toContain('aria-label="Improve shared history"'); - expect(markup).toContain('aria-hidden="true"'); - expect(markup).toContain("--changing-text-index:0"); - expect(markup).toContain("--changing-text-index:2"); + act(() => outgoing.props.onAnimationEnd()); + expect(renderer.root.findAllByProps({ "data-changing-text": "outgoing" })).toHaveLength(0); }); }); diff --git a/apps/web/src/components/ui/changing-state.tsx b/apps/web/src/components/ui/changing-state.tsx index eced9b5785ae8..01d0780ba0385 100644 --- a/apps/web/src/components/ui/changing-state.tsx +++ b/apps/web/src/components/ui/changing-state.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, + useState, type CSSProperties, type ElementType, type ReactNode, @@ -11,20 +12,12 @@ import { } from "react"; import { cn } from "~/lib/utils"; -import { usePanelAnimationSettings } from "../../panelAnimations"; +import { useUiAnimationSettings } from "../../uiAnimations"; type ChangingStateStyle = CSSProperties & { "--changing-state-duration": string }; -type ChangingTextStyle = CSSProperties & { "--changing-text-index": number }; - -const derivativeDuration = (durationMs: number) => Math.round(durationMs / 1.5); - -function textTokens(value: string) { - const occurrences = new Map(); - return value.split(/(\s+)/).map((text, position) => { - const occurrence = occurrences.get(text) ?? 0; - occurrences.set(text, occurrence + 1); - return { key: `${text}:${occurrence}`, position, text }; - }); +interface AnimatedTextValue { + readonly changeKey: string; + readonly value: string; } export const AnimatedText = memo(function AnimatedText({ @@ -38,28 +31,43 @@ export const AnimatedText = memo(function AnimatedText({ readonly as?: ElementType; readonly className?: string; }) { - const { active, durationMs } = usePanelAnimationSettings(); - const tokens = textTokens(value); + const { active, durationMs } = useUiAnimationSettings(); + const [transition, setTransition] = useState<{ + current: AnimatedTextValue; + previous: AnimatedTextValue | null; + }>({ current: { changeKey, value }, previous: null }); + if (transition.current.changeKey !== changeKey || transition.current.value !== value) { + setTransition({ + current: { changeKey, value }, + previous: active ? transition.current : null, + }); + } const style = { - "--changing-state-duration": `${derivativeDuration(durationMs)}ms`, + "--changing-state-duration": `${durationMs}ms`, } as ChangingStateStyle; return ( - ); @@ -75,9 +83,9 @@ export const AnimatedIcon = memo( readonly children: ReactNode; readonly className?: string; }) { - const { active, durationMs } = usePanelAnimationSettings(); + const { active, durationMs } = useUiAnimationSettings(); const style = { - "--changing-state-duration": `${derivativeDuration(durationMs)}ms`, + "--changing-state-duration": `${durationMs}ms`, } as ChangingStateStyle; return ( @@ -92,7 +100,7 @@ export const AnimatedIcon = memo( ); export function useAnimatedLayout(): RefCallback { - const { active, durationMs } = usePanelAnimationSettings(); + const { active, durationMs } = useUiAnimationSettings(); const elementRef = useRef(null); const setElement = useCallback((element: T | null) => { elementRef.current = element; @@ -102,7 +110,7 @@ export function useAnimatedLayout(): RefCallback { const element = elementRef.current; if (!element || typeof HTMLElement === "undefined") return; const controller = autoAnimate(element, { - duration: derivativeDuration(durationMs), + duration: durationMs, easing: "cubic-bezier(0.23, 1, 0.32, 1)", }); if (!active) controller.disable(); diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index 12e621963dc41..ddfe484c8c169 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -48,7 +48,7 @@ function MenuPopup({ > { + it("treats 0 ms and the system reduced-motion preference as reduced motion", () => { + expect(uiAnimationsActive(0, false)).toBe(false); + expect(uiAnimationsActive(150, true)).toBe(false); + expect(uiAnimationsActive(150, false)).toBe(true); + }); +}); diff --git a/apps/web/src/uiAnimations.ts b/apps/web/src/uiAnimations.ts new file mode 100644 index 0000000000000..edb30b3a006f4 --- /dev/null +++ b/apps/web/src/uiAnimations.ts @@ -0,0 +1,18 @@ +import { type UiAnimationDurationMs } from "@t3tools/contracts/settings"; + +import { useMediaQuery } from "./hooks/useMediaQuery"; +import { useClientSettings } from "./hooks/useSettings"; + +export const uiAnimationsActive = ( + durationMs: UiAnimationDurationMs, + prefersReducedMotion: boolean, +) => durationMs > 0 && !prefersReducedMotion; + +export function useUiAnimationSettings(): { + active: boolean; + durationMs: UiAnimationDurationMs; +} { + const durationMs = useClientSettings((settings) => settings.uiAnimationDurationMs); + const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); + return { active: uiAnimationsActive(durationMs, prefersReducedMotion), durationMs }; +} From 8188e1cca28e5f3cc978be3c1eb004c669dedaac Mon Sep 17 00:00:00 2001 From: Kaloyan Stoyanov Date: Mon, 28 Sep 2026 12:57:40 +0300 Subject: [PATCH 04/12] test(web): support menu labels after rebasing main From a358c84cd7f382f10951d0db4f2da522d13a467c Mon Sep 17 00:00:00 2001 From: Kaloyan Stoyanov Date: Mon, 28 Sep 2026 13:12:39 +0300 Subject: [PATCH 05/12] feat(web): add subtle blur transitions to Base UI overlays --- .../components/settings/SettingsPanels.tsx | 2 +- apps/web/src/components/ui/combobox.tsx | 16 ++--- apps/web/src/components/ui/dialog-styles.ts | 6 +- apps/web/src/components/ui/menu.tsx | 2 +- apps/web/src/components/ui/popover.tsx | 6 +- apps/web/src/components/ui/select.tsx | 2 +- apps/web/src/components/ui/sheet.tsx | 15 ++++- apps/web/src/components/ui/tooltip.tsx | 6 +- apps/web/src/index.css | 67 +++++++++++++++++++ apps/web/src/routes/__root.tsx | 18 +++++ 10 files changed, 116 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 62706d87545b9..269778168822d 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1528,7 +1528,7 @@ export function AppearanceSettingsPanel() { - - - {children} - - + {children} + ); diff --git a/apps/web/src/components/ui/dialog-styles.ts b/apps/web/src/components/ui/dialog-styles.ts index f2eeae928e995..8a540399e6c23 100644 --- a/apps/web/src/components/ui/dialog-styles.ts +++ b/apps/web/src/components/ui/dialog-styles.ts @@ -1,13 +1,15 @@ const DIALOG_BACKDROP_BASE_CLASS = "fixed inset-0 z-50 transition-all duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0"; -const DIALOG_BACKDROP_CLASS = `dialog-backdrop ${DIALOG_BACKDROP_BASE_CLASS}`; +const DIALOG_BACKDROP_CLASS = + "dialog-backdrop ui-overlay-transition fixed inset-0 z-50 transition-opacity data-ending-style:opacity-0 data-starting-style:opacity-0"; const DIALOG_MEDIA_BACKDROP_CLASS = `${DIALOG_BACKDROP_BASE_CLASS} bg-black/75 backdrop-blur-none`; const DIALOG_POPUP_BASE_CLASS = "-translate-y-[calc(1.25rem*var(--nested-dialogs))] relative flex min-h-0 w-full min-w-0 scale-[calc(1-0.1*var(--nested-dialogs))] flex-col opacity-[calc(1-0.1*var(--nested-dialogs))] outline-none transition-[scale,opacity,translate] duration-200 ease-in-out will-change-transform data-nested:data-ending-style:translate-y-8 data-nested:data-starting-style:translate-y-8 data-nested-dialog-open:origin-top data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0 [-webkit-app-region:no-drag]"; -const DIALOG_POPUP_CLASS = `dialog-glass ${DIALOG_POPUP_BASE_CLASS} rounded-2xl border`; +const DIALOG_POPUP_CLASS = + "dialog-glass ui-overlay-transition ui-popup-motion -translate-y-[calc(1.25rem*var(--nested-dialogs))] relative flex min-h-0 w-full min-w-0 scale-[calc(1-0.1*var(--nested-dialogs))] flex-col opacity-[calc(1-0.1*var(--nested-dialogs))] rounded-2xl border outline-none data-nested:data-ending-style:translate-y-8 data-nested:data-starting-style:translate-y-8 data-nested-dialog-open:origin-top [-webkit-app-region:no-drag]"; const DIALOG_MEDIA_POPUP_CLASS = `${DIALOG_POPUP_BASE_CLASS} max-h-[92vh] w-auto max-w-[92vw] overflow-visible rounded-none border border-transparent bg-transparent p-0 shadow-none`; const DIALOG_MOBILE_SHEET_CLASS = diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index ddfe484c8c169..2d6e6c680de32 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -48,7 +48,7 @@ function MenuPopup({ > diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx index f0b0017976cb1..5ea4a9d7fee4f 100644 --- a/apps/web/src/components/ui/sheet.tsx +++ b/apps/web/src/components/ui/sheet.tsx @@ -12,14 +12,18 @@ const SheetPortal = SheetPrimitive.Portal; // Sheets are docked panels, not dialogs: their layer (--z-sheet) sits under dialogs that open // from inside them and under anything the app floats above panels. -function SheetBackdrop({ className, ...props }: SheetPrimitive.Backdrop.Props) { +function SheetBackdrop({ className, style, ...props }: SheetPrimitive.Backdrop.Props) { return ( ); @@ -73,6 +77,7 @@ function SheetPopup({ ? undefined : ({ transitionDuration: `${transitionDurationMs}ms` } satisfies CSSProperties); const instant = transitionDurationMs === 0; + const useUiMotion = transitionDurationMs === undefined; return ( @@ -87,7 +92,7 @@ function SheetPopup({ {children} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 3b4239a45ec15..3deada466eb82 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,5 +1,72 @@ @import "tailwindcss"; +/* Body-portaled overlays inherit the user's timing from the document root. */ +@utility ui-overlay-transition { + --tw-duration: var(--ui-animation-duration, 150ms); + --tw-ease: cubic-bezier(0.23, 1, 0.32, 1); + transition-duration: var(--tw-duration); + transition-timing-function: var(--tw-ease); + + &[data-ending-style] { + --tw-duration: calc(var(--ui-animation-duration, 150ms) * 0.8); + --tw-ease: ease-in; + } + + &[data-instant], + [data-ui-animations="off"] & { + --tw-duration: 0s; + } + + @media (prefers-reduced-motion: reduce) { + && { + --tw-duration: 0s; + } + } +} + +@utility ui-popup-motion { + --ui-popup-x: 0px; + --ui-popup-y: 4px; + transition-property: opacity, filter, transform, translate, scale; + + &[data-side="bottom"] { + --ui-popup-y: -4px; + } + &[data-side="left"] { + --ui-popup-x: 4px; + --ui-popup-y: 0px; + } + &[data-side="right"] { + --ui-popup-x: -4px; + --ui-popup-y: 0px; + } + &[data-side="none"] { + --ui-popup-y: 0px; + } + + &:is([data-starting-style], [data-ending-style]) { + opacity: 0; + filter: blur(2px); + transform: translate(var(--ui-popup-x), var(--ui-popup-y)); + } + + &[data-slot="sheet-popup"]:is([data-starting-style], [data-ending-style]) { + transform: translate(calc(var(--ui-popup-x) * -1), calc(var(--ui-popup-y) * -1)); + } + + [data-ui-animations="off"] &:is([data-starting-style], [data-ending-style]) { + filter: none; + transform: none; + } + + @media (prefers-reduced-motion: reduce) { + &&:is([data-starting-style], [data-ending-style]) { + filter: none; + transform: none; + } + } +} + @keyframes changing-text-enter { from { opacity: 0; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 11e58cae692d3..e66aa42138637 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -57,6 +57,7 @@ import { cn } from "../lib/utils"; import { applyAppearanceFontVariables } from "~/appearanceFonts"; import { applyAppearanceContrast } from "~/appearanceContrast"; import { useClientSettings } from "../hooks/useSettings"; +import { useUiAnimationSettings } from "../uiAnimations"; import { deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKeyFromPath, @@ -224,6 +225,7 @@ function RootRouteView() { + { + const root = document.documentElement; + root.style.setProperty("--ui-animation-duration", `${active ? durationMs : 0}ms`); + root.dataset.uiAnimations = active ? "on" : "off"; + return () => { + root.style.removeProperty("--ui-animation-duration"); + delete root.dataset.uiAnimations; + }; + }, [active, durationMs]); + + return null; +} + function GlassAppearanceSync() { const glassOpacity = useClientSettings((settings) => settings.glassOpacity); From 830f1b30e150b4e18dcd2e6f783bcb08fb93854a Mon Sep 17 00:00:00 2001 From: Kaloyan Stoyanov Date: Mon, 28 Sep 2026 13:23:50 +0300 Subject: [PATCH 06/12] feat(web): smooth composer transitions with Motion --- apps/web/package.json | 1 + apps/web/src/components/chat/ChatComposer.tsx | 182 +++++++++++------- .../components/chat/ComposerBannerStack.tsx | 38 ++-- pnpm-lock.yaml | 54 ++++++ 4 files changed, 190 insertions(+), 85 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 4d10f89743b66..a20a224130ce5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -64,6 +64,7 @@ "lucide-react": "^0.564.0", "mermaid": "^11.17.2", "morphicons": "^1.7.1", + "motion": "^13.4.4", "react": "19.2.6", "react-dom": "19.2.6", "react-markdown": "^10.1.0", diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 586af52b14167..78d9b030116a2 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -83,6 +83,14 @@ import { useSyncExternalStore, } from "react"; import { createPortal, flushSync } from "react-dom"; +import { + animate, + AnimatePresence, + motion, + useIsPresent, + useMotionValue, + type AnimationPlaybackControls, +} from "motion/react"; import { clampCollapsedComposerCursor, type ComposerTrigger, @@ -460,7 +468,8 @@ function useComposerRestingTransition( controlsFromLeft: null, surfaceBottomInset: null, }); - const animationRef = useRef(null); + const animationRef = useRef(null); + const animatedHeight = useMotionValue(0); const animationTargetHeightRef = useRef(null); const contentAnimationsRef = useRef([]); const stateChangeAnimationsRef = useRef([]); @@ -487,6 +496,7 @@ function useComposerRestingTransition( element?.style.removeProperty("overflow"); element?.style.removeProperty("clip-path"); element?.style.removeProperty("overflow-clip-margin"); + element?.style.removeProperty("height"); element ?.querySelector('[data-chat-composer-surface="true"]') ?.style.removeProperty("height"); @@ -518,7 +528,7 @@ function useComposerRestingTransition( isCollapsedRef.current = isCollapsed; const transitionToCurrentGeometry = useCallback( - (stateChanged: boolean) => { + (stateChanged: boolean, contentChanged = false) => { const element = elementRef.current; const surface = element?.querySelector('[data-chat-composer-surface="true"]'); if (!element || !surface) return; @@ -562,16 +572,15 @@ function useComposerRestingTransition( ? element.getBoundingClientRect().height : null; const interruptedTargetHeight = animationTargetHeightRef.current; - const interruptedCurrentTime = - typeof interruptedAnimation?.currentTime === "number" - ? interruptedAnimation.currentTime - : null; - const interruptedDuration = interruptedAnimation?.effect?.getComputedTiming().duration; + const interruptedCurrentTime = interruptedAnimation ? interruptedAnimation.time * 1000 : null; + const interruptedDuration = interruptedAnimation + ? interruptedAnimation.duration * 1000 + : null; if (transitionCleanupTimeoutRef.current !== null) { window.clearTimeout(transitionCleanupTimeoutRef.current); transitionCleanupTimeoutRef.current = null; } - interruptedAnimation?.cancel(); + interruptedAnimation?.stop(); animationRef.current = null; for (const animation of contentAnimationsRef.current) animation.cancel(); contentAnimationsRef.current = []; @@ -609,7 +618,7 @@ function useComposerRestingTransition( interruptedTargetHeight === null || Math.abs(interruptedTargetHeight - nextHeight) >= 0.5; const shouldAnimate = shouldAnimateComposerRestingTransition({ hasCompletedInitialLayout: hasCompletedInitialLayoutRef.current, - stateChanged, + stateChanged: stateChanged || contentChanged, hasInterruptedAnimation: interruptedHeight !== null, }); @@ -695,14 +704,17 @@ function useComposerRestingTransition( ); } - const animation = element.animate( - [{ height: `${previousHeight}px` }, { height: `${nextHeight}px` }], - { - duration, - easing: COMPOSER_RESTING_TRANSITION_EASING, - fill: "both", + if (!interruptedAnimation) animatedHeight.jump(previousHeight); + element.style.height = `${String(animatedHeight.get())}px`; + const speed = 300 / Math.max(1, animationDurationMs); + const animation = animate(animatedHeight, nextHeight, { + type: "spring", + stiffness: 400 * speed * speed, + damping: 40 * speed, + onUpdate: (height) => { + element.style.height = `${String(height)}px`; }, - ); + }); animationRef.current = animation; animationTargetHeightRef.current = nextHeight; @@ -848,7 +860,7 @@ function useComposerRestingTransition( transitionCleanupTimeoutRef.current = null; } if (cancelAnimations) { - animation.cancel(); + animation.stop(); for (const contentAnimation of contentAnimationsRef.current) { contentAnimation.cancel(); } @@ -860,7 +872,6 @@ function useComposerRestingTransition( animationTargetHeightRef.current = null; contentAnimationsRef.current = []; stateChangeAnimationsRef.current = []; - animation.cancel(); stripAnimation?.cancel(); clearTransitionStyles(); }; @@ -870,7 +881,7 @@ function useComposerRestingTransition( // the natural layout the eventual source of truth in that case. transitionCleanupTimeoutRef.current = window.setTimeout( () => finishTransition(true), - duration + COMPOSER_RESTING_TRANSITION_CLEANUP_BUFFER_MS, + animation.duration * 1000 + COMPOSER_RESTING_TRANSITION_CLEANUP_BUFFER_MS, ); } else { animationTargetHeightRef.current = null; @@ -891,6 +902,7 @@ function useComposerRestingTransition( [ animationDurationMs, animationsActive, + animatedHeight, clearTransitionStyles, onOverlayHeightChange, restingControlsRef, @@ -933,12 +945,17 @@ function useComposerRestingTransition( const body = element.querySelector('[data-chat-composer-body="true"]'); return observeResize(body ? [element, body] : element, (entries) => { + const bodyChanged = body && entries.some((entry) => entry.target === body); if (animationRef.current) { - if (body && entries.some((entry) => entry.target === body)) { - transitionToCurrentGeometry(false); + if (bodyChanged) { + transitionToCurrentGeometry(false, true); } return; } + if (bodyChanged && hasCompletedInitialLayoutRef.current) { + transitionToCurrentGeometry(false, true); + return; + } const elementRect = element.getBoundingClientRect(); const visibleTransitionElement = (selector: string) => Array.from(element.querySelectorAll(selector)).find( @@ -981,7 +998,7 @@ function useComposerRestingTransition( window.clearTimeout(transitionCleanupTimeoutRef.current); transitionCleanupTimeoutRef.current = null; } - animationRef.current?.cancel(); + animationRef.current?.stop(); animationRef.current = null; animationTargetHeightRef.current = null; for (const animation of contentAnimationsRef.current) animation.cancel(); @@ -1006,6 +1023,8 @@ function composerCommandMenuPositionsEqual( function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children: ReactNode }) { const [position, setPosition] = useState(null); + const { active, durationMs } = usePanelAnimationSettings(); + const isPresent = useIsPresent(); useLayoutEffect(() => { const anchor = props.anchor; @@ -1065,9 +1084,14 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children: if (!position) return null; return createPortal( -
{props.children} -
, + , document.body, ); } @@ -6878,6 +6902,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const isBashMode = prompt.startsWith("!") && !activePendingUserInput && !isComposerApprovalState; + const composerTransition = !panelAnimationsActive + ? { duration: 0 } + : { type: "spring" as const, duration: panelAnimationDurationMs / 1000, bounce: 0 }; // Render // ------------------------------------------------------------------ @@ -7157,20 +7184,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) : null}
- {/* One-shot fade/scale on opacity+transform only, so the glow never repaints while idle. */} -
); @@ -217,7 +220,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ {...pointerFocusProps} disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable} > - {isConnecting || isSendBusy ? "Sending..." : "Refine"} + ); } @@ -230,7 +233,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ {...pointerFocusProps} disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable} > - {isConnecting || isSendBusy ? "Sending..." : "Implement"} + ) : null} - {isConnecting || isSendBusy ? ( -