From c6f32e281d9fec4b93224342f29caf32ad59607a Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sun, 20 Sep 2026 12:40:39 +1000 Subject: [PATCH 1/2] docs: define consistent reduced motion behavior --- docs/internals/consistency-reduced-motion.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 docs/internals/consistency-reduced-motion.md diff --git a/docs/internals/consistency-reduced-motion.md b/docs/internals/consistency-reduced-motion.md new file mode 100644 index 000000000000..6c00722855d0 --- /dev/null +++ b/docs/internals/consistency-reduced-motion.md @@ -0,0 +1,18 @@ +# Motion preferences across entry points + +Proposed invariant: a request for reduced motion changes how an action reaches its destination, not the destination, available content, focus or selection. Apply it to programmatic navigation and reveal actions whether they begin in search, settings, a preview, a menu or a keyboard shortcut. + +Apple’s [Motion](https://developer.apple.com/design/human-interface-guidelines/motion) guidance calls for purposeful, optional motion and feedback that preserves information. Its [Accessibility](https://developer.apple.com/design/human-interface-guidelines/accessibility) guidance supports adapting motion to people’s preferences. The cross-entry-point constraint here is a proposed application of those principles, not a requirement for identical native animations. + +Web and Electron read the browser’s reduced-motion preference; React Native uses the device accessibility preference. Read the current preference when starting an action, or subscribe to changes. Do not cache it for the lifetime of a screen. A missing preference API must not prevent navigation. Existing instant reveals and animation APIs that already honor the platform setting need no replacement. + +Reduce unnecessary spatial animation while retaining an immediate destination indicator, focus, selection and meaningful input acknowledgement. Direct manipulation, including dragging and continuous scrolling while a key is held, still follows the person’s input. Native system transitions may adapt differently from web transitions. Neither exception permits a custom programmatic jump to ignore the preference. + +Acceptance cases: + +- Revealing the same target through search, preview inspection or a keyboard/menu route reaches the same content with normal and reduced motion. +- Reduced motion removes the custom animated travel without hiding content or suppressing the action’s focus or selection changes. +- Changing the preference while the client remains open applies to the next action. +- If the preference API is unavailable, navigation still completes using a safe fallback. + +These are behavioral constraints across clients and connection modes; the preference belongs to the client displaying the action, independently of the provider or remote environment. From d71e51a66dab23e0e6d763da1108e6eb1fafcd01 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sun, 20 Sep 2026 12:51:49 +1000 Subject: [PATCH 2/2] fix: respect reduced motion in programmatic scrolling --- .../features/threads/ThreadDetailScreen.tsx | 21 ++-- apps/web/src/components/ChatView.tsx | 5 +- apps/web/src/components/RightPanelTabs.tsx | 3 +- .../chat/AssistantCitationSource.tsx | 3 +- .../src/components/chat/MessagesTimeline.tsx | 3 +- .../chat/pageScrollController.test.ts | 33 +++++++ .../components/chat/pageScrollController.ts | 9 ++ .../settings/ThemeEditorPanel.test.tsx | 99 +++++++++++++++++++ .../components/settings/ThemeEditorPanel.tsx | 3 +- .../settings/settingsLayout.test.tsx | 28 ++++++ .../components/settings/settingsLayout.tsx | 3 +- apps/web/src/lib/reducedMotion.ts | 6 ++ 12 files changed, 203 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/components/settings/ThemeEditorPanel.test.tsx create mode 100644 apps/web/src/lib/reducedMotion.ts diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index ad0872ea9116..65dd14aa670b 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -40,6 +40,7 @@ import { useState, } from "react"; import { + AccessibilityInfo, Alert, AppState, Keyboard, @@ -730,14 +731,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // leaves the keyboard padding permanently applied — overshooting the // anchor and leaving a phantom bottom inset once the reply streams in. void KeyboardController.dismiss() - .then(() => { + .then(() => AccessibilityInfo.isReduceMotionEnabled().catch(() => true)) + .then((reducedMotion) => { if ( selectedThreadKeyRef.current !== targetThreadKey || lastScrolledSubmittedMessageIdRef.current !== submittedMessageId ) { return; } - return scrollMessageToEnd({ animated: true, closeKeyboard: false }); + return scrollMessageToEnd({ animated: !reducedMotion, closeKeyboard: false }); }) .catch(() => { if ( @@ -835,10 +837,17 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const handleScrollToEnd = useCallback(() => { void Haptics.selectionAsync(); - void scrollMessageToEnd({ animated: true, closeKeyboard: false }).catch(() => { - freeze.set(false); - }); - }, [freeze, scrollMessageToEnd]); + const targetThreadKey = selectedThreadKey; + void AccessibilityInfo.isReduceMotionEnabled() + .catch(() => true) + .then((reducedMotion) => { + if (selectedThreadKeyRef.current !== targetThreadKey) return; + return scrollMessageToEnd({ animated: !reducedMotion, closeKeyboard: false }); + }) + .catch(() => { + if (selectedThreadKeyRef.current === targetThreadKey) freeze.set(false); + }); + }, [freeze, scrollMessageToEnd, selectedThreadKey]); const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled; const { themeAppearance } = useAppearancePreferences(); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 667eb0e11f6c..b5388f1d1cd6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,3 +1,4 @@ +import { prefersReducedMotion as readReducedMotion } from "../lib/reducedMotion"; import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import type { UsageLimitSourceSnapshots } from "@t3tools/contracts"; @@ -5401,7 +5402,7 @@ export default function ChatView(props: ChatViewProps) { setShowScrollToBottom(false); setTimelineAnchor(releaseChatTimelineAnchor); requestAnimationFrame(() => { - void legendListRef.current?.scrollToEnd?.({ animated }); + void legendListRef.current?.scrollToEnd?.({ animated: animated && !readReducedMotion() }); }); }, []); useLayoutEffect(() => { @@ -5608,7 +5609,7 @@ export default function ChatView(props: ChatViewProps) { void list .scrollToIndex({ index: anchorIndex, - animated: true, + animated: !readReducedMotion(), viewPosition: 0, viewOffset: CHAT_TIMELINE_ANCHOR_OFFSET, }) diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index f234efff093e..24947610f3bb 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,3 +1,4 @@ +import { prefersReducedMotion as readReducedMotion } from "../lib/reducedMotion"; import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { useProjects, useServerConfigs, useThreadShells } from "~/state/entities"; @@ -853,7 +854,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { const scrollTabs = useCallback((direction: -1 | 1) => { const viewport = tabScrollViewport(tabListRef.current); if (!viewport) return; - const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const reduceMotion = readReducedMotion(); viewport.scrollBy({ left: direction * Math.max(120, viewport.clientWidth * 0.75), behavior: reduceMotion ? "auto" : "smooth", diff --git a/apps/web/src/components/chat/AssistantCitationSource.tsx b/apps/web/src/components/chat/AssistantCitationSource.tsx index c0b5ad3a1933..f6a8f3ea81cd 100644 --- a/apps/web/src/components/chat/AssistantCitationSource.tsx +++ b/apps/web/src/components/chat/AssistantCitationSource.tsx @@ -1,3 +1,4 @@ +import { prefersReducedMotion as readReducedMotion } from "../../lib/reducedMotion"; import type { LegendListRef } from "@legendapp/list/react"; import type { AssistantCitation, MessageId, ScopedThreadRef } from "@t3tools/contracts"; import { useEffect, useRef, type ReactNode, type RefObject } from "react"; @@ -117,7 +118,7 @@ export function observeAssistantCitationSource({ if (activation.dismissed) return; const scrollNode = list.getScrollableNode(); if (!(scrollNode instanceof HTMLElement)) return; - const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const reducedMotion = readReducedMotion(); let highlighted: Highlight | null = null; let ownedRange: Range | null = null; let selected: { range: Range; snapshot: Range } | null = null; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 42a45b991028..31961fe09df5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,3 +1,4 @@ +import { prefersReducedMotion as readReducedMotion } from "../../lib/reducedMotion"; import { ArrowUpIcon, ClockIcon } from "lucide-react"; import { ReadOnlySourcePreview } from "../files/AttachmentFilePreview"; import { useRightPanelStore } from "~/rightPanelStore"; @@ -1340,7 +1341,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onManualNavigation(); void listRef.current?.scrollToIndex({ index: item.rowIndex, - animated: true, + animated: !readReducedMotion(), viewOffset: 24, }); }} diff --git a/apps/web/src/components/chat/pageScrollController.test.ts b/apps/web/src/components/chat/pageScrollController.test.ts index 9fe399f50d3e..e72327afd1cd 100644 --- a/apps/web/src/components/chat/pageScrollController.test.ts +++ b/apps/web/src/components/chat/pageScrollController.test.ts @@ -80,6 +80,39 @@ class TestClock { } } +describe("page scroll motion preference", () => { + test("reaches the same page immediately after the preference changes", () => { + const clock = new TestClock(); + const container = { + clientHeight: 600, + scrollHeight: 4_000, + scrollTop: 0, + getBoundingClientRect: () => ({ height: 600 }), + }; + let reducedMotion = false; + const controller = createPageScrollController({ + getContainer: () => container, + getScrollPaddingBottomPx: () => 24, + isReducedMotion: () => reducedMotion, + env: clock.env, + }); + controller.handleKeyDown("PageDown"); + controller.handleKeyUp("PageDown"); + expect(container.scrollTop).toBe(0); + clock.advanceBy(PAGE_SCROLL_ANIMATION_MS); + const destination = container.scrollTop; + expect(destination).toBeGreaterThan(0); + container.scrollTop = 0; + reducedMotion = true; + controller.handleKeyDown("PageDown"); + controller.handleKeyUp("PageDown"); + expect(container.scrollTop).toBe(destination); + clock.advanceBy(PAGE_SCROLL_ANIMATION_MS); + expect(container.scrollTop).toBe(destination); + controller.dispose(); + }); +}); + describe("page scroll helpers", () => { const composerPageScrollEvent = ( overrides: Partial[0]> = {}, diff --git a/apps/web/src/components/chat/pageScrollController.ts b/apps/web/src/components/chat/pageScrollController.ts index 4c3999b1efc1..85bbfbae6cb1 100644 --- a/apps/web/src/components/chat/pageScrollController.ts +++ b/apps/web/src/components/chat/pageScrollController.ts @@ -1,3 +1,5 @@ +import { prefersReducedMotion } from "../../lib/reducedMotion"; + export const PAGE_SCROLL_ANIMATION_MS = 150; export const PAGE_SCROLL_ACCELERATION_MS = 400; export const PAGE_SCROLL_MAX_MULTIPLIER = 2; @@ -149,11 +151,13 @@ export function createPageScrollController({ getContainer, getScrollPaddingBottomPx, onScrollStart, + isReducedMotion = prefersReducedMotion, env = getDefaultEnv(), }: { getContainer: () => PageScrollContainer | null; getScrollPaddingBottomPx: () => number; onScrollStart?: (key: PageScrollKey) => void; + isReducedMotion?: () => boolean; env?: PageScrollEnv; }) { const state = { @@ -209,6 +213,11 @@ export function createPageScrollController({ const smoothScrollBy = (container: PageScrollContainer, deltaY: number) => { cancelDiscreteAnimation(); + if (isReducedMotion()) { + container.scrollTop += deltaY; + return; + } + const startScrollTop = container.scrollTop; const startTime = env.now(); diff --git a/apps/web/src/components/settings/ThemeEditorPanel.test.tsx b/apps/web/src/components/settings/ThemeEditorPanel.test.tsx new file mode 100644 index 000000000000..c8953012122a --- /dev/null +++ b/apps/web/src/components/settings/ThemeEditorPanel.test.tsx @@ -0,0 +1,99 @@ +import { isValidElement, type ReactElement, type ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { reactHookHarness as hooks } from "../../test/reactHookHarness"; +import type { ThemeColorRole } from "../../themePalette"; + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { + ...actual, + useCallback: reactHookHarness.useCallback, + useMemo: reactHookHarness.useMemo, + useRef: reactHookHarness.useRef, + useState: reactHookHarness.useState, + useEffect: () => {}, + }; +}); +vi.mock("react/compiler-runtime", async () => { + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { c: reactHookHarness.useMemoCache }; +}); + +import { ThemeEditorPanel } from "./ThemeEditorPanel"; +import { ThemeColorField } from "./ThemeColorPicker"; + +type FieldProps = { + role: ThemeColorRole; + selected: boolean; + onSelect: (role: ThemeColorRole, reveal?: boolean) => void; +}; + +function findFields(node: ReactNode): ReactElement[] { + if (Array.isArray(node)) return node.flatMap(findFields); + if (!isValidElement<{ children?: ReactNode }>(node)) return []; + if (node.type === ThemeColorField) return [node as ReactElement]; + return findFields(node.props.children); +} + +function renderPanel() { + hooks.beginRender(); + return ThemeEditorPanel({ + open: true, + onOpenChange: vi.fn(), + onSaved: () => true, + editingTheme: null, + initialAppearance: "dark", + restoreTheme: vi.fn(), + }) as ReactElement<{ ref: { current: unknown }; children: ReactNode }>; +} + +describe("theme role reveal", () => { + beforeEach(() => { + hooks.reset(); + vi.stubGlobal("window", { localStorage: { getItem: () => null } }); + }); + afterEach(() => vi.unstubAllGlobals()); + + it("keeps role selection and reveal targets when motion changes or matchMedia is missing", () => { + let reducedMotion = false; + window.matchMedia = vi.fn(() => ({ matches: reducedMotion }) as MediaQueryList); + const frames: FrameRequestCallback[] = []; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => + frames.push(callback), + ); + const scrollIntoView = vi.fn(); + const querySelector = vi.fn(() => ({ scrollIntoView })); + const panel = renderPanel(); + panel.props.ref.current = { querySelector }; + const select = findFields(panel)[0]!.props.onSelect; + + select("toolbar", true); + frames.shift()!(0); + expect(querySelector).toHaveBeenLastCalledWith('[data-theme-color-role="canvas"]'); + expect(scrollIntoView).toHaveBeenLastCalledWith({ behavior: "smooth", block: "nearest" }); + expect(findFields(renderPanel()).find((field) => field.props.selected)?.props.role).toBe( + "canvas", + ); + + select("surface", true); + // Read at reveal time, including a preference change after the input event. + reducedMotion = true; + frames.shift()!(0); + expect(querySelector).toHaveBeenLastCalledWith('[data-theme-color-role="surface"]'); + expect(scrollIntoView).toHaveBeenLastCalledWith({ behavior: "auto", block: "nearest" }); + expect(findFields(renderPanel()).find((field) => field.props.selected)?.props.role).toBe( + "surface", + ); + + vi.stubGlobal("window", { localStorage: { getItem: () => null } }); + select("surface", true); + frames.shift()!(0); + expect(querySelector).toHaveBeenLastCalledWith('[data-theme-color-role="surface"]'); + expect(scrollIntoView).toHaveBeenLastCalledWith({ behavior: "smooth", block: "nearest" }); + expect(findFields(renderPanel()).find((field) => field.props.selected)?.props.role).toBe( + "surface", + ); + }); +}); diff --git a/apps/web/src/components/settings/ThemeEditorPanel.tsx b/apps/web/src/components/settings/ThemeEditorPanel.tsx index 02d1dad458f1..c9a65a7b2838 100644 --- a/apps/web/src/components/settings/ThemeEditorPanel.tsx +++ b/apps/web/src/components/settings/ThemeEditorPanel.tsx @@ -1,3 +1,4 @@ +import { prefersReducedMotion as readReducedMotion } from "../../lib/reducedMotion"; import { ChevronDownIcon, ChevronUpIcon, @@ -520,7 +521,7 @@ export function ThemeEditorPanel({ requestAnimationFrame(() => { panelRef.current ?.querySelector(`[data-theme-color-role="${visibleRole}"]`) - ?.scrollIntoView({ behavior: "smooth", block: "nearest" }); + ?.scrollIntoView({ behavior: readReducedMotion() ? "auto" : "smooth", block: "nearest" }); }); }, []); diff --git a/apps/web/src/components/settings/settingsLayout.test.tsx b/apps/web/src/components/settings/settingsLayout.test.tsx index 4195a359d1b4..6b1fc32b25ee 100644 --- a/apps/web/src/components/settings/settingsLayout.test.tsx +++ b/apps/web/src/components/settings/settingsLayout.test.tsx @@ -87,6 +87,34 @@ describe("settings search targets", () => { expect(add).not.toHaveBeenCalled(); }); + it("keeps the destination and focus when the preference changes or is unavailable", () => { + const scrollIntoView = vi.fn(); + const focus = vi.fn(); + const target = { + tagName: "DIV", + scrollIntoView, + focus, + classList: { remove: vi.fn(), add: vi.fn() }, + addEventListener: vi.fn(), + offsetWidth: 100, + } as unknown as HTMLElement; + const getElementById = vi.fn(() => target); + vi.stubGlobal("document", { getElementById }); + let reducedMotion = false; + vi.stubGlobal("window", { matchMedia: () => ({ matches: reducedMotion }) }); + expect(scrollToSettingsTarget("word-wrap")).toBe(true); + expect(scrollIntoView).toHaveBeenLastCalledWith({ behavior: "smooth", block: "center" }); + reducedMotion = true; + expect(scrollToSettingsTarget("word-wrap")).toBe(true); + expect(scrollIntoView).toHaveBeenLastCalledWith({ behavior: "auto", block: "center" }); + vi.stubGlobal("window", {}); + expect(scrollToSettingsTarget("word-wrap")).toBe(true); + expect(scrollIntoView).toHaveBeenLastCalledWith({ behavior: "smooth", block: "center" }); + expect(getElementById.mock.calls).toEqual([["word-wrap"], ["word-wrap"], ["word-wrap"]]); + expect(focus).toHaveBeenCalledTimes(3); + expect(focus).toHaveBeenLastCalledWith({ preventScroll: true }); + }); + it("leaves not-yet-mounted destinations to their mount lifecycle", () => { vi.stubGlobal("document", { getElementById: vi.fn(() => null), diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index c805e37f1bbd..6ba997f7ae01 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -1,3 +1,4 @@ +import { prefersReducedMotion as readReducedMotion } from "../../lib/reducedMotion"; import { SettingsGroup } from "./SettingsGroup"; import { InfoIcon, Undo2Icon } from "lucide-react"; import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts"; @@ -76,7 +77,7 @@ function SettingsSearchTargetProvider({ } function scrollAndFocusSettingsTarget(target: HTMLElement, highlight = true): void { - const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const prefersReducedMotion = readReducedMotion(); const markedScrollTarget = typeof target.querySelector === "function" ? target.querySelector(":scope > [data-settings-scroll-target]") diff --git a/apps/web/src/lib/reducedMotion.ts b/apps/web/src/lib/reducedMotion.ts new file mode 100644 index 000000000000..13ba01dc9435 --- /dev/null +++ b/apps/web/src/lib/reducedMotion.ts @@ -0,0 +1,6 @@ +/** Read at the action boundary so a changed system preference applies immediately. */ +export function prefersReducedMotion(): boolean { + return typeof window !== "undefined" && typeof window.matchMedia === "function" + ? window.matchMedia("(prefers-reduced-motion: reduce)").matches + : false; +}