diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 14beefd6d178..ebd4a9d794ec 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, @@ -737,14 +738,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 ( @@ -842,10 +844,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 db13c8f21c80..f3fec9ac2c51 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -366,6 +366,7 @@ import { } from "../state/entities"; import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; +import { prefersReducedMotion as readReducedMotion } from "../lib/reducedMotion"; import { createPageScrollController, type PageScrollKey } from "./chat/pageScrollController"; import { isTimelineScrollTarget } from "./chat/timelineScrollTarget"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; @@ -5406,7 +5407,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(() => { @@ -5619,7 +5620,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 001342e48262..e41357b9b158 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"; @@ -861,7 +862,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.test.ts b/apps/web/src/components/chat/AssistantCitationSource.test.ts index 8692a7ffdf66..de6b543eba08 100644 --- a/apps/web/src/components/chat/AssistantCitationSource.test.ts +++ b/apps/web/src/components/chat/AssistantCitationSource.test.ts @@ -440,20 +440,31 @@ describe("assistant citation source lifecycle", () => { source.cleanup(); }); - it("uses instant navigation and removes the temporary quote highlight for reduced motion", async () => { - const source = createSource({ reducedMotion: true }); - source.mount(); - source.flushFrame(); - expect(source.scrollToOffset).toHaveBeenCalledWith({ offset: 80, animated: false }); - expect(source.highlight()).toBeUndefined(); - await source.finishScroll(); - expect(source.target.activationRef.current.pulse?.reducedMotion).toBe(true); - expect(source.highlight()?.toString()).toBe("quote"); - await source.finishPulse(); - expect(source.highlight()).toBeUndefined(); - expect(source.root.focus).not.toHaveBeenCalled(); - source.cleanup(); - }); + it.each(["reduced motion", "missing matchMedia"])( + "uses instant navigation and preserves selection and acknowledgement with %s", + async (preference) => { + const source = createSource({ reducedMotion: true }); + if (preference === "missing matchMedia") vi.stubGlobal("window", {}); + const selection = new TestSelection(); + const selectedRange = new TestRange(source.text, () => rect(0)); + selection.addRange(selectedRange); + source.root.ownerDocument.getSelection = () => selection; + source.mount(); + source.flushFrame(); + expect(source.scrollToOffset).toHaveBeenCalledWith({ offset: 80, animated: false }); + expect(source.highlight()).toBeUndefined(); + await source.finishScroll(); + expect(source.scrollNode.scrollTop).toBe(80); + expect(source.target.onComplete).toHaveBeenCalledOnce(); + expect(selection.ranges).toEqual([selectedRange]); + expect(source.target.activationRef.current.pulse?.reducedMotion).toBe(true); + expect(source.highlight()?.toString()).toBe("quote"); + await source.finishPulse(); + expect(source.highlight()).toBeUndefined(); + expect(source.root.focus).not.toHaveBeenCalled(); + source.cleanup(); + }, + ); it("does not revive a quote when remounting after its original pulse deadline", async () => { const source = createSource(); 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 8ac769f33e7f..b9c4a50aa56c 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"; @@ -1354,7 +1355,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..1b1415e3da02 100644 --- a/apps/web/src/components/chat/pageScrollController.test.ts +++ b/apps/web/src/components/chat/pageScrollController.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vite-plus/test"; +import { afterEach, describe, expect, test, vi } from "vite-plus/test"; import { createPageScrollController, @@ -80,6 +80,78 @@ class TestClock { } } +afterEach(() => vi.unstubAllGlobals()); + +describe("page scroll motion preference", () => { + test.each(["missing window", "missing matchMedia"])( + "reaches the same destination and acknowledges the action with %s", + (unavailable) => { + const clock = new TestClock(); + const container = { + clientHeight: 600, + scrollHeight: 4_000, + scrollTop: 0, + getBoundingClientRect: () => ({ height: 600 }), + }; + vi.stubGlobal("window", { matchMedia: () => ({ matches: false }) }); + const started: string[] = []; + const controller = createPageScrollController({ + getContainer: () => container, + getScrollPaddingBottomPx: () => 24, + onScrollStart: (key) => started.push(key), + 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; + vi.stubGlobal("window", unavailable === "missing window" ? undefined : {}); + controller.handleKeyDown("PageDown"); + controller.handleKeyUp("PageDown"); + expect(container.scrollTop).toBe(destination); + expect(started).toEqual(["PageDown", "PageDown"]); + clock.advanceBy(PAGE_SCROLL_ANIMATION_MS); + expect(container.scrollTop).toBe(destination); + controller.dispose(); + }, + ); + + 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]> = {}, @@ -185,11 +257,13 @@ describe("createPageScrollController", () => { const controller = createPageScrollController({ getContainer: () => container, getScrollPaddingBottomPx: () => 24, + isReducedMotion: () => false, env: clock.env, }); controller.handleKeyDown("PageDown"); controller.handleKeyUp("PageDown"); + expect(container.scrollTop).toBe(0); clock.advanceBy(PAGE_SCROLL_ANIMATION_MS); expect(container.scrollTop).toBeCloseTo( @@ -201,33 +275,37 @@ describe("createPageScrollController", () => { ); }); - test("continues scrolling on hold without repeated keydown events and stops on keyup", () => { - const clock = new TestClock(); - const container = { - clientHeight: 600, - scrollHeight: 4_000, - scrollTop: 0, - getBoundingClientRect: () => ({ height: 600 }), - }; - const controller = createPageScrollController({ - getContainer: () => container, - getScrollPaddingBottomPx: () => 24, - env: clock.env, - }); - controller.handleKeyDown("PageDown"); - clock.advanceBy(PAGE_SCROLL_ANIMATION_MS + 50); - - const afterHoldStarts = container.scrollTop; - clock.advanceBy(200); - - expect(container.scrollTop).toBeGreaterThan(afterHoldStarts); - - const stoppedAt = container.scrollTop; - controller.handleKeyUp("PageDown"); - clock.advanceBy(250); - - expect(container.scrollTop).toBe(stoppedAt); - }); + test.each([false, true])( + "continues held scrolling and stops on keyup (reduced motion: %s)", + (reducedMotion) => { + const clock = new TestClock(); + const container = { + clientHeight: 600, + scrollHeight: 4_000, + scrollTop: 0, + getBoundingClientRect: () => ({ height: 600 }), + }; + const controller = createPageScrollController({ + getContainer: () => container, + getScrollPaddingBottomPx: () => 24, + isReducedMotion: () => reducedMotion, + env: clock.env, + }); + controller.handleKeyDown("PageDown"); + clock.advanceBy(PAGE_SCROLL_ANIMATION_MS + 50); + + const afterHoldStarts = container.scrollTop; + clock.advanceBy(200); + + expect(container.scrollTop).toBeGreaterThan(afterHoldStarts); + + const stoppedAt = container.scrollTop; + controller.handleKeyUp("PageDown"); + clock.advanceBy(250); + + expect(container.scrollTop).toBe(stoppedAt); + }, + ); test("notifies once when a page scroll starts", () => { const clock = new TestClock(); 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..829a679b01c1 --- /dev/null +++ b/apps/web/src/components/settings/ThemeEditorPanel.test.tsx @@ -0,0 +1,108 @@ +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: "auto", block: "nearest" }); + expect(findFields(renderPanel()).find((field) => field.props.selected)?.props.role).toBe( + "surface", + ); + + window.matchMedia = vi.fn(() => ({ matches: false }) as MediaQueryList); + 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 339fc4b0c72b..c0c818fb9186 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..7488d5a2a5cc 100644 --- a/apps/web/src/components/settings/settingsLayout.test.tsx +++ b/apps/web/src/components/settings/settingsLayout.test.tsx @@ -87,6 +87,46 @@ 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 classList = { remove: vi.fn(), add: vi.fn() }; + const target = { + tagName: "DIV", + scrollIntoView, + focus, + classList, + 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" }); + classList.add.mockClear(); + vi.stubGlobal("window", {}); + expect(scrollToSettingsTarget("word-wrap")).toBe(true); + expect(scrollIntoView).toHaveBeenLastCalledWith({ behavior: "auto", block: "center" }); + expect(classList.remove).toHaveBeenLastCalledWith("settings-search-target-pulse"); + expect(classList.add).not.toHaveBeenCalled(); + vi.stubGlobal("window", { matchMedia: () => ({ matches: false }) }); + expect(scrollToSettingsTarget("word-wrap")).toBe(true); + expect(scrollIntoView).toHaveBeenLastCalledWith({ behavior: "smooth", block: "center" }); + expect(getElementById.mock.calls).toEqual([ + ["word-wrap"], + ["word-wrap"], + ["word-wrap"], + ["word-wrap"], + ]); + expect(focus).toHaveBeenCalledTimes(4); + 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 0794553cb8fb..6190c07c26f1 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"; @@ -77,7 +78,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..71640c50b062 --- /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 + : true; +} diff --git a/docs/internals/consistency-reduced-motion.md b/docs/internals/consistency-reduced-motion.md new file mode 100644 index 000000000000..dc4d04b6344d --- /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. If the preference API is unavailable, custom programmatic navigation uses an instant reveal. 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. Input still receives immediate acknowledgement, such as pressed styling or a destination indicator, without restoring animated travel. +- Changing the preference while the client remains open applies to the next action. +- If the preference API is unavailable, navigation still reaches the same destination through an instant reveal, with focus, selection and input acknowledgement preserved. + +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.