diff --git a/apps/web/package.json b/apps/web/package.json index 4d10f89743b66..02b66e54dd104 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,6 +22,7 @@ "@dnd-kit/utilities": "^3.2.2", "@effect/atom-react": "catalog:", "@formkit/auto-animate": "^0.9.0", + "@kitlangton/rolling-number": "^0.4.1", "@legendapp/list": "catalog:", "@noble/hashes": "catalog:", "@pierre/diffs": "catalog:", @@ -64,6 +65,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/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 6419ce2a779c3..f9be8f8d03d14 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1,4 +1,5 @@ import { ThreadDetailsControl } from "./chat/ThreadDetailsControl"; +import { AnimatedNumber, AnimatedText, AnimatedIcon } from "./ui/changing-state"; import { useAtomValue } from "@effect/atom-react"; import { AuthOrchestrationOperateScope, @@ -320,18 +321,30 @@ function GitQuickActionIcon({ className?: string; SourceControlIcon: ReturnType["Icon"]; }) { - if (quickAction.kind === "open_publish") return ; - if (quickAction.kind === "run_pull") return ; - if (quickAction.kind === "run_action") { - if (quickAction.action === "commit") return ; - if (quickAction.action === "push" || quickAction.action === "commit_push") { - return ; + const iconKind = (() => { + if (quickAction.kind === "open_publish") return "upload"; + if (quickAction.kind === "run_pull") return "download"; + if (quickAction.kind === "run_action") { + if (quickAction.action === "commit") return "commit"; + if (quickAction.action === "push" || quickAction.action === "commit_push") return "upload"; + return "source"; } - return ; - } - if (quickAction.label === "Commit") return ; - if (quickAction.label === "Push") return ; - return ; + if (quickAction.label === "Commit") return "commit"; + if (quickAction.label === "Push") return "upload"; + return "info"; + })(); + const Icon = { + source: SourceControlIcon, + upload: CloudUploadIcon, + download: CloudDownloadIcon, + commit: GitCommitIcon, + info: InfoIcon, + }[iconKind]; + return ( + + + + ); } function GitActionElapsedTime({ @@ -1708,7 +1721,7 @@ export default function GitActionsControl({ > - {initAction.isPending ? "Initializing..." : "Initialize Git"} + ) : ( @@ -1724,7 +1737,9 @@ export default function GitActionsControl({ quickAction={quickAction} SourceControlIcon={SourceControlIcon} /> - {quickAction.label} + + + {quickActionDisabledReason && (

@@ -1755,7 +1770,7 @@ export default function GitActionsControl({ > - {initAction.isPending ? "Initializing..." : "Initialize Git"} + ) : compact && !gitActionProgress && !visibleInlineSuccess ? null : ( @@ -1815,7 +1830,7 @@ export default function GitActionsControl({ isPanel && cn("not-sr-only ml-0 truncate", THREAD_DETAILS_PANEL_LABEL_CLASS), )} > - {quickAction.label} + @@ -1842,7 +1857,7 @@ export default function GitActionsControl({ isPanel && cn("not-sr-only ml-0 truncate", THREAD_DETAILS_PANEL_LABEL_CLASS), )} > - {quickAction.label} + )} @@ -1911,9 +1926,11 @@ export default function GitActionsControl({ Changes - +{formatDiffCount(changesTotals?.insertions ?? 0)} + + + + - -{formatDiffCount(changesTotals?.deletions ?? 0)} + - diff --git a/apps/web/src/components/ProjectScriptsControl.test.tsx b/apps/web/src/components/ProjectScriptsControl.test.tsx index 268da8ec71aed..e23ed702b4455 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("../uiAnimations", () => ({ + useUiAnimationSettings: () => ({ 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,22 @@ 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 + .findByProps({ "data-changing-state": "build:starting" }) + .findAllByType("span") + .filter((node) => node.props["data-icon"] === "loader"), + ).toHaveLength(1); + }); }); diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 34aee3c352a6e..231caf4a39be2 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 { AnimatedText, 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 })} > } > - + + + - {primaryScript.name} + diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 586af52b14167..893bc8a020511 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1,5 +1,6 @@ import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; import { useComposerTypingGuard } from "./useComposerTypingGuard"; +import { AnimatedText, AnimatedIcon } from "../ui/changing-state"; import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText"; import { runtimeModeConfig, runtimeModeOptions as runtimeModes } from "./runtimeModeConfig"; import { isLocalEnvironmentDisabled } from "../../localEnvironment"; @@ -83,6 +84,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 +469,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 +497,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 +529,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 +573,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 +619,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 +705,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 +861,7 @@ function useComposerRestingTransition( transitionCleanupTimeoutRef.current = null; } if (cancelAnimations) { - animation.cancel(); + animation.stop(); for (const contentAnimation of contentAnimationsRef.current) { contentAnimation.cancel(); } @@ -860,7 +873,6 @@ function useComposerRestingTransition( animationTargetHeightRef.current = null; contentAnimationsRef.current = []; stateChangeAnimationsRef.current = []; - animation.cancel(); stripAnimation?.cancel(); clearTransitionStyles(); }; @@ -870,7 +882,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 +903,7 @@ function useComposerRestingTransition( [ animationDurationMs, animationsActive, + animatedHeight, clearTransitionStyles, onOverlayHeightChange, restingControlsRef, @@ -933,12 +946,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 +999,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 +1024,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 +1085,14 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children: if (!position) return null; return createPortal( -

{props.children} -
, + , document.body, ); } @@ -1287,21 +1312,23 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop /> } > - {props.interactionMode === "plan" ? ( - - ) : ( - - )} + + {props.interactionMode === "plan" ? ( + + ) : ( + + )} + - {props.interactionMode === "plan" ? "Plan" : "Build"} + {interactionModeTooltip} @@ -1329,8 +1356,12 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop /> } > - - {runtimeModeOption.label} + + + + + + {props.runtimeModeOptions.map((option) => { @@ -6878,6 +6909,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 +7191,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 ? ( - - {isCopied ? "Copied!" : "Copy to clipboard"} + Download as markdown selection.label).join(", ") || "Choose models" : undefined; + const visibleLabel = props.triggerLabel ?? multipleLabel ?? triggerTitle; const triggerTooltipContent = shortcutLabel ? `${props.triggerLabel ?? allModelNames ?? triggerLabel} · ${shortcutLabel}` : (props.triggerLabel ?? allModelNames ?? triggerLabel); @@ -240,44 +242,57 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { {props.triggerIcon} ) : null} - {selectedEntries && props.triggerLabel === undefined ? ( - ) : ( <> - {triggerLabel} + + + )} diff --git a/apps/web/src/components/settings/CaptureShortcutConfig.tsx b/apps/web/src/components/settings/CaptureShortcutConfig.tsx index 892bf13c7c195..8ff7309e1e53e 100644 --- a/apps/web/src/components/settings/CaptureShortcutConfig.tsx +++ b/apps/web/src/components/settings/CaptureShortcutConfig.tsx @@ -1,3 +1,4 @@ +import { AnimatedText } from "../ui/changing-state"; import { isModifierPairShortcut, type DesktopCaptureConfigApplied, @@ -320,7 +321,7 @@ export function CaptureShortcutConfig({ if (state.shortcutBinding) copyToClipboard(state.shortcutBinding); }} > - {isCopied ? "Copied" : "Copy shortcut"} +

Turn capture off in T3 Code to stop it. Remove the shortcut from {desktop} to free up diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 86cb4854eaa0c..269778168822d 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"] @@ -715,6 +720,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.fontSizeTerminal, settings.glassOpacity, settings.panelAnimationDurationMs, + settings.uiAnimationDurationMs, settings.responseStreamingMode, settings.persistComposerContextStrip, settings.enableProviderUpdateChecks, @@ -823,6 +829,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 +1209,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 +1526,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/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..1a2da39ffe67d --- /dev/null +++ b/apps/web/src/components/ui/changing-state.test.tsx @@ -0,0 +1,113 @@ +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const settings = vi.hoisted(() => ({ active: true, durationMs: 150 })); +vi.mock("../../uiAnimations", () => ({ useUiAnimationSettings: () => settings })); + +import { AnimatedIcon, AnimatedText } from "./changing-state"; + +let renderer: ReactTestRenderer; +beforeEach(() => { + settings.active = true; +}); +afterEach(() => { + act(() => renderer?.unmount()); +}); + +function renderText(value: string, changeKey = "thread") { + act(() => { + const element = ; + if (renderer?.toJSON()) renderer.update(element); + else renderer = create(element); + }); +} +function finishExit(outgoing = renderer.root.findByProps({ "data-changing-text": "outgoing" })) { + act(() => outgoing.props.onAnimationComplete()); +} +function visibleLetters() { + return renderer.root + .findAllByType("span") + .filter((node) => node.props.className === "changing-text-letter") + .map((node) => node.children.join("")); +} + +describe("changing-state motion", () => { + it("animates a rename within the same thread and releases letters after the transition", () => { + renderText("First thread"); + expect(visibleLetters()).toEqual([]); + renderText("Second thread"); + expect(renderer.root.findByProps({ "data-changing-text": "outgoing" }).props.children).toBe( + "First thread", + ); + expect(visibleLetters().join("")).toBe("Second thread"); + finishExit(); + expect(renderer.root.findAllByProps({ "data-changing-text": "outgoing" })).toHaveLength(0); + expect(visibleLetters()).toEqual([]); + expect(renderer.root.findByProps({ "data-changing-text": "incoming" }).children).toEqual([ + "Second thread", + ]); + }); + + it("keeps only the latest transition when thread changes interrupt one another", () => { + renderText("First", "1"); + renderText("Second", "2"); + const staleExit = renderer.root.findByProps({ "data-changing-text": "outgoing" }).props + .onAnimationComplete; + renderText("Third", "3"); + act(() => staleExit()); + expect(renderer.root.findByProps({ "data-changing-text": "outgoing" }).props.children).toBe( + "Second", + ); + expect(visibleLetters().join("")).toBe("Third"); + finishExit(); + expect(renderer.root.findAllByProps({ "data-changing-text": "outgoing" })).toHaveLength(0); + }); + + it("keeps combining characters and emoji intact", () => { + renderText("First"); + renderText("e\u0301👨‍👩‍👧‍👦!"); + expect(visibleLetters()).toEqual(["e\u0301", "👨‍👩‍👧‍👦", "!"]); + }); + + it.each(["مرحبا", "a".repeat(121)])( + "keeps joined scripts and long labels in one run", + (value) => { + renderText("First"); + renderText(value); + expect(visibleLetters()).toEqual([value]); + }, + ); + + it("immediately settles an active transition when animation is disabled", () => { + renderText("First"); + renderText("Second"); + settings.active = false; + renderText("Third"); + expect(renderer.root.findAllByProps({ "data-changing-text": "outgoing" })).toHaveLength(0); + expect(visibleLetters()).toEqual([]); + expect(renderer.root.findByProps({ "data-changing-text": "incoming" }).children).toEqual([ + "Third", + ]); + }); + + it("updates icon contents even when the state key remains the same", () => { + act(() => { + renderer = create( + + First + , + ); + }); + act(() => { + renderer.update( + + Updated + , + ); + }); + expect( + renderer.root.findAllByType("span").some((node) => node.children.includes("Updated")), + ).toBe(true); + }); +}); 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..f70931d4a4fff --- /dev/null +++ b/apps/web/src/components/ui/changing-state.tsx @@ -0,0 +1,193 @@ +import { motion } from "motion/react"; +import autoAnimate from "@formkit/auto-animate"; +import { RollingNumber } from "@kitlangton/rolling-number/react"; +import "@kitlangton/rolling-number/styles.css"; +import { + memo, + useCallback, + useEffect, + useRef, + useState, + type ElementType, + type ReactNode, + type RefCallback, +} from "react"; + +import { cn } from "~/lib/utils"; +import { useUiAnimationSettings } from "../../uiAnimations"; + +const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + +interface AnimatedTextValue { + readonly changeKey: string; + readonly value: string; +} + +export const AnimatedText = memo(function AnimatedText({ + value, + changeKey = value, + as: Component = "span", + className, +}: { + readonly value: string; + readonly changeKey?: string; + readonly as?: ElementType; + readonly className?: string; +}) { + 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 || + (!active && transition.previous !== null) + ) { + setTransition({ + current: { changeKey, value }, + previous: active ? transition.current : null, + }); + } + const letters = active && transition.previous ? Array.from(graphemes.segment(value)) : []; + // Keep joined scripts shaped as a run and bound the number of animated elements. + const parts = + letters.length > 120 || + /[^\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}\p{Script=Common}\p{Script=Inherited}]/u.test( + value, + ) + ? [{ segment: value, index: 0 }] + : letters; + const staggerMs = Math.min(60, durationMs * 0.3); + + return ( + + {value} + {active && transition.previous ? ( + + ) : null} + + + ); +}); + +export const AnimatedIcon = memo(function AnimatedIcon({ + stateKey, + children, + className, +}: { + readonly stateKey: string; + readonly children: ReactNode; + readonly className?: string; +}) { + const { active, durationMs } = useUiAnimationSettings(); + const [state, setState] = useState({ key: stateKey, changed: false }); + if (state.key !== stateKey || (!active && state.changed)) { + setState({ key: stateKey, changed: active }); + } + + return ( + + {active && state.changed ? ( + + {children} + + ) : ( + {children} + )} + + ); +}); + +/** Rolls changed digits in place; settles immediately when interface animations are off. */ +export const AnimatedNumber = memo(function AnimatedNumber({ + value, + format, + className, +}: { + readonly value: number; + readonly format?: Intl.NumberFormatOptions; + readonly className?: string; +}) { + const { active, durationMs } = useUiAnimationSettings(); + return ( + + ); +}); + +export function useAnimatedLayout(): RefCallback { + const { active, durationMs } = useUiAnimationSettings(); + 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: 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/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index 4020df1653cc6..64179b889cfe2 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -164,20 +164,16 @@ function ComboboxPopup({ side={side} sideOffset={sideOffset} > - - - {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 12e621963dc41..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/components/usage/SidebarUsageCard.tsx b/apps/web/src/components/usage/SidebarUsageCard.tsx index f36eab80784c1..347759c2c9757 100644 --- a/apps/web/src/components/usage/SidebarUsageCard.tsx +++ b/apps/web/src/components/usage/SidebarUsageCard.tsx @@ -29,6 +29,7 @@ import { resolveThreadRouteTarget } from "../../threadRoutes"; import { formatUpcomingTimestamp } from "../../timestampFormat"; import { providerClients } from "../settings/providerDriverMeta"; import { Button } from "../ui/button"; +import { AnimatedNumber } from "../ui/changing-state"; import { Collapsible, CollapsiblePanel } from "../ui/collapsible"; import { CollapsibleSectionHeader } from "../ui/collapsible-section-header"; import { cn } from "../../lib/utils"; @@ -159,7 +160,6 @@ export function SidebarUsageCard() { lastConnectionKey.current = connectionKey; }, [connectionKey, expanded]); - const percentLabel = remaining === null ? "Unknown" : `${remaining}%`; return ( @@ -186,7 +186,9 @@ export function SidebarUsageCard() {

{window.label} {window.columns.length > 1 ? ( - {window.remainingPercent}% left + + % left + ) : null}
@@ -233,7 +235,13 @@ export function SidebarUsageCard() { onClick={() => setExpanded((value) => !value)} accessory={ - {percentLabel} + {remaining === null ? ( + "Unknown" + ) : ( + <> + % + + )} } > diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 815deb6bcfb87..eb91a995c8dd2 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,5 +1,138 @@ @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; + } + } +} + +.changing-text, +.changing-text-content { + min-width: 0; +} + +.changing-text { + position: relative; + display: inline-block; + max-width: 100%; + vertical-align: bottom; +} + +.changing-text-letter { + display: inline-block; + white-space: pre; +} + +.changing-text-content { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.changing-text-exit { + position: absolute; + inset: 0; + z-index: 1; +} + +.changing-icon { + display: inline-grid; + place-items: center; +} + +.changing-icon-content { + display: inline-flex; +} + +/* One-shot rise for messages that arrive while the thread is open. */ +.timeline-row-enter { + animation: timeline-row-enter calc(var(--ui-animation-duration, 150ms) * 1.5) + cubic-bezier(0.23, 1, 0.32, 1) both; +} + +/* No blur: a filter here would flatten the card's backdrop glass. */ +.thread-details-card-enter { + transform-origin: top right; + animation: thread-details-card-enter var(--ui-animation-duration, 150ms) + cubic-bezier(0.23, 1, 0.32, 1); +} + +@keyframes thread-details-card-enter { + from { + opacity: 0; + transform: translateY(-4px) scale(0.98); + } +} + +@keyframes timeline-row-enter { + from { + opacity: 0; + transform: translateY(6px); + } +} + @custom-variant dark (&:is(.dark, .dark *)); @custom-variant light (&:not(.dark, .dark *)); /* Window Controls Overlay: active when Electron exposes native titlebar control geometry. */ diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 11e58cae692d3..fcb9a1bb7fcfc 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -57,6 +57,8 @@ import { cn } from "../lib/utils"; import { applyAppearanceFontVariables } from "~/appearanceFonts"; import { applyAppearanceContrast } from "~/appearanceContrast"; import { useClientSettings } from "../hooks/useSettings"; +import { useMediaQuery } from "../hooks/useMediaQuery"; +import { publishUiAnimationSettings, uiAnimationsActive } from "../uiAnimations"; import { deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKeyFromPath, @@ -224,6 +226,7 @@ function RootRouteView() { + settings.uiAnimationDurationMs); + const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); + const active = uiAnimationsActive(durationMs, prefersReducedMotion); + + useEffect(() => { + publishUiAnimationSettings({ active, durationMs }); + 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); diff --git a/apps/web/src/uiAnimations.test.ts b/apps/web/src/uiAnimations.test.ts new file mode 100644 index 0000000000000..e95953399ed01 --- /dev/null +++ b/apps/web/src/uiAnimations.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { uiAnimationsActive } from "./uiAnimations"; + +describe("UI animation settings", () => { + 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..d743ac56d59aa --- /dev/null +++ b/apps/web/src/uiAnimations.ts @@ -0,0 +1,35 @@ +import { type UiAnimationDurationMs } from "@t3tools/contracts/settings"; +import { useSyncExternalStore } from "react"; + +export const uiAnimationsActive = ( + durationMs: UiAnimationDurationMs, + prefersReducedMotion: boolean, +) => durationMs > 0 && !prefersReducedMotion; + +interface UiAnimationSettings { + readonly active: boolean; + readonly durationMs: UiAnimationDurationMs; +} + +// The root publishes the resolved setting here. Animated primitives read this small +// store instead of the settings hooks, which pull in connection and session state: +// a low-level UI import must not create a module cycle back into app startup. +let current: UiAnimationSettings = { active: false, durationMs: 0 }; +const listeners = new Set<() => void>(); + +export function publishUiAnimationSettings(next: UiAnimationSettings) { + if (next.active === current.active && next.durationMs === current.durationMs) return; + current = next; + for (const listener of listeners) listener(); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +const getSnapshot = () => current; + +export function useUiAnimationSettings(): UiAnimationSettings { + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} 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), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e6efe075030e..e7054e1711be7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -775,6 +775,9 @@ importers: '@formkit/auto-animate': specifier: ^0.9.0 version: 0.9.0 + '@kitlangton/rolling-number': + specifier: ^0.4.1 + version: 0.4.1(react@19.2.6) '@legendapp/list': specifier: 'catalog:' version: 3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.6(react@19.2.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) @@ -901,6 +904,9 @@ importers: morphicons: specifier: ^1.7.1 version: 1.7.1(react-native-svg@15.15.5(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + motion: + specifier: ^13.4.4 + version: 13.5.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: specifier: 19.2.6 version: 19.2.6 @@ -4142,6 +4148,17 @@ packages: resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==} engines: {node: '>=12'} + '@kitlangton/rolling-number@0.4.1': + resolution: {integrity: sha512-wvtmA/iz2rZ8QKrHt32dsxWFq4AJk0RfeXUsk/eXHvto7m3bRQxJ5Nv3KzWZmE7FNB1Ysz567Oj/hILlvpCqLQ==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + solid-js: ^1.9.0 + peerDependenciesMeta: + react: + optional: true + solid-js: + optional: true + '@legendapp/list@3.3.5': resolution: {integrity: sha512-XTsLYtpg41SVb5uLBYA+YcDSA3w0tgoPq/W8ZggQ2tx+3lrC/rf+ehTP9KYHea9oFaZIuePAgzACs5/auVMJlQ==} peerDependencies: @@ -8971,6 +8988,17 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + framer-motion@13.5.1: + resolution: {integrity: sha512-HeWOaeHD3kyTSIEMT6+nw7fBn4saIfxxabZyAITbycAl4xr1kzGJKIvGt/OWghSlvIA9qTnZ1zTGelHAe6S8DA==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} @@ -10092,6 +10120,23 @@ packages: vue: optional: true + motion-dom@13.5.1: + resolution: {integrity: sha512-/7CVCD20Mz1KobktorpdgvowV0V41J7UxqE+qWS3GkSJqrRr5ft+2RloOYtEKv+wvBp3JaSFBclhxt4ja8ovHg==} + + motion-utils@13.5.0: + resolution: {integrity: sha512-Hqe/sqjIKliPZg8sVjWAkdySDPJNIeKH+euVkDOG8ri3DUUU7ZjndmHsGqEPz2iHpiyVaV5I22iguCMHU2he+Q==} + + motion@13.5.1: + resolution: {integrity: sha512-eV9SUem6KnzEa9+eT+dVBeDlhy5TTdn09YWR1WhUsKoZCJPw/n4TcYhjbxq2Gy5PTwDr//4H3r5vxkcZnNnO1w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -14989,6 +15034,10 @@ snapshots: dependencies: jsbi: 4.3.2 + '@kitlangton/rolling-number@0.4.1(react@19.2.6)': + optionalDependencies: + react: 19.2.6 + '@legendapp/list@3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.6(react@19.2.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)': dependencies: react: 19.2.6 @@ -19624,6 +19673,15 @@ snapshots: forwarded@0.2.0: {} + framer-motion@13.5.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + motion-dom: 13.5.1 + motion-utils: 13.5.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + fresh@0.5.2: {} fresh@2.0.0: {} @@ -21086,6 +21144,20 @@ snapshots: react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) react-native-svg: 15.15.5(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + motion-dom@13.5.1: + dependencies: + motion-utils: 13.5.0 + + motion-utils@13.5.0: {} + + motion@13.5.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + framer-motion: 13.5.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + mrmime@2.0.1: {} ms@2.0.0: {}