diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx index d0da17c02..f50f9d560 100644 --- a/src/components/admin/admin-shell.tsx +++ b/src/components/admin/admin-shell.tsx @@ -35,6 +35,7 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; +import { scrollBehaviorForUser } from "@/lib/motion"; import { useTranslations } from "@/lib/i18n/context"; import { isAdminSectionSlug, type AdminSectionSlug } from "./section-slugs"; @@ -183,7 +184,8 @@ export function AdminShell({ active, children }: AdminShellProps) { // and the first one or two chips were unreachable on narrow // viewports. inline: "start", - behavior: "smooth", + // v1.4.43 W5-H5 — respect `prefers-reduced-motion`. + behavior: scrollBehaviorForUser(), }); }, [activeSlug]); diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index 87cb1150a..6e779206b 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -12,6 +12,7 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; +import { scrollBehaviorForUser } from "@/lib/motion"; import { useTranslations } from "@/lib/i18n/context"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; import { useAuth } from "@/hooks/use-auth"; @@ -194,7 +195,8 @@ export function MessageThread({ const el = scrollerRef.current; if (!el) return; if (wasPinnedRef.current) { - el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); + // v1.4.43 W5-H5 — respect `prefers-reduced-motion`. + el.scrollTo({ top: el.scrollHeight, behavior: scrollBehaviorForUser() }); } }, [messages.length, streaming?.content, optimisticUser?.localId]); diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index bd03d5bcd..f8c4a8f59 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -31,6 +31,7 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; +import { scrollBehaviorForUser } from "@/lib/motion"; import { useTranslations } from "@/lib/i18n/context"; import { SETTINGS_SECTION_SLUGS, @@ -169,7 +170,8 @@ export function SettingsShell({ active, children }: SettingsShellProps) { // and the first one or two chips were unreachable on narrow // viewports. inline: "start", - behavior: "smooth", + // v1.4.43 W5-H5 — respect `prefers-reduced-motion`. + behavior: scrollBehaviorForUser(), }); }, [activeSlug]); diff --git a/src/lib/__tests__/motion.test.ts b/src/lib/__tests__/motion.test.ts new file mode 100644 index 000000000..4e78b54df --- /dev/null +++ b/src/lib/__tests__/motion.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { scrollBehaviorForUser } from "@/lib/motion"; + +// The vitest config runs unit tests under Node (no jsdom). The helper +// only touches `window.matchMedia`, so we mount a minimal stub via +// `vi.stubGlobal` and tear it down after each case. + +function mountWindow(matchesReducedMotion: boolean) { + vi.stubGlobal("window", { + matchMedia: (query: string) => ({ + matches: + matchesReducedMotion && query === "(prefers-reduced-motion: reduce)", + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), + }); +} + +describe("scrollBehaviorForUser", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns `auto` when window is undefined (SSR)", () => { + vi.stubGlobal("window", undefined); + expect(scrollBehaviorForUser()).toBe("auto"); + }); + + it("returns `auto` when prefers-reduced-motion matches", () => { + mountWindow(true); + expect(scrollBehaviorForUser()).toBe("auto"); + }); + + it("returns `smooth` when prefers-reduced-motion does not match", () => { + mountWindow(false); + expect(scrollBehaviorForUser()).toBe("smooth"); + }); +}); diff --git a/src/lib/motion.ts b/src/lib/motion.ts new file mode 100644 index 000000000..bb2932914 --- /dev/null +++ b/src/lib/motion.ts @@ -0,0 +1,13 @@ +// v1.4.43 W5-H5 — single source of truth for `behavior` on the four +// programmatic `scrollIntoView` / `scrollTo` call-sites in the app. +// `prefers-reduced-motion: reduce` users were previously animated against +// their OS-level preference, which is a WCAG 2.3.3 violation. The helper +// returns `"auto"` whenever a window is unavailable (SSR) or when the +// reduced-motion media query matches. + +export function scrollBehaviorForUser(): ScrollBehavior { + if (typeof window === "undefined") return "auto"; + return window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +}