diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index a9df592..a4a1c5f 100644 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -50,6 +50,21 @@ try { 'top nav says List your repo'); assert.equal(await page.getByText(/\bwave\b/i).count(), 0, 'no wave wording left on the landing page'); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.locator('.bit-spark-host').evaluate(el => + el.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: 1, clientY: 1 })), + ); + assert.equal(await page.locator('.bit-spark').count(), 0, 'reduced motion suppresses click sparks'); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + await page.waitForTimeout(100); + await page.locator('.bit-spark-host').evaluate(el => + el.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: 1, clientY: 1 })), + ); + await page.waitForFunction(() => document.querySelectorAll('.bit-spark').length > 0); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.waitForFunction(() => document.querySelectorAll('.bit-spark').length === 0); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + await page.getByRole('link', { name: /Explore issues/ }).first().click(); await page.waitForURL('**/explore'); await page.getByRole('heading', { name: 'Explore', exact: true }).waitFor(); diff --git a/src/components/bits/interactive.tsx b/src/components/bits/interactive.tsx index 5f9df79..ecac0f4 100644 --- a/src/components/bits/interactive.tsx +++ b/src/components/bits/interactive.tsx @@ -3,12 +3,10 @@ import { } from 'react'; import { NavLink, useLocation } from 'react-router-dom'; import { AnimatePresence, motion, useAnimationFrame, useMotionValue } from 'motion/react'; +import { useReducedMotion } from './motion'; const EASE = [0.2, 0.8, 0.2, 1] as const; -const reduced = () => - typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches; - /* ========================================================================== GooeyNav A blob tracks the active item. An SVG goo filter merges the blob with the @@ -18,6 +16,7 @@ const reduced = () => interface GooItem { to: string; label: string; end?: boolean } export function GooeyNav({ items }: { items: GooItem[] }) { + const prefersReducedMotion = useReducedMotion(); const { pathname } = useLocation(); const filterId = useId().replace(/:/g, ''); const wrapRef = useRef(null); @@ -25,6 +24,10 @@ export function GooeyNav({ items }: { items: GooItem[] }) { const [bursts, setBursts] = useState<{ id: number; x: number }[]>([]); const seq = useRef(0); + useEffect(() => { + if (prefersReducedMotion) setBursts([]); + }, [prefersReducedMotion]); + // Only commit when the measurement actually changes — setting a fresh object // every render would re-trigger this effect and loop forever. const sync = useCallback(() => { @@ -47,7 +50,7 @@ export function GooeyNav({ items }: { items: GooItem[] }) { }, [sync, pathname]); const burst = (e: React.MouseEvent) => { - if (reduced()) return; + if (prefersReducedMotion) return; const wrap = wrapRef.current; if (!wrap) return; const wr = wrap.getBoundingClientRect(); @@ -127,6 +130,7 @@ export function GooeyNav({ items }: { items: GooItem[] }) { ========================================================================== */ export function Lanyard({ children, className = '' }: { children: ReactNode; className?: string }) { + const prefersReducedMotion = useReducedMotion(); const hostRef = useRef(null); const cordRef = useRef(null); const x = useMotionValue(0); @@ -141,7 +145,7 @@ export function Lanyard({ children, className = '' }: { children: ReactNode; cla if (!host) return; const onDown = (e: PointerEvent) => { const t = e.target as HTMLElement; - if (!t.closest('.lanyard-badge')) return; + if (!t.closest('.lanyard-badge') || prefersReducedMotion) return; sim.current.dragging = true; setHeld(true); host.setPointerCapture(e.pointerId); @@ -161,10 +165,20 @@ export function Lanyard({ children, className = '' }: { children: ReactNode; cla window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); }; - }, []); + }, [prefersReducedMotion]); useAnimationFrame((t, delta) => { const s = sim.current; + if (prefersReducedMotion) { + s.x = 0; + s.y = 0; + s.vx = 0; + s.vy = 0; + x.set(0); + y.set(0); + cordRef.current?.setAttribute('d', 'M 110 0 Q 110 62 110 76'); + return; + } const dt = Math.min(delta, 32) / 16.67; if (s.dragging) { @@ -172,7 +186,7 @@ export function Lanyard({ children, className = '' }: { children: ReactNode; cla s.vy += (s.ty - s.y) * 0.22 * dt; } else { // Gravity pulls the badge back to rest; a slow breeze keeps it alive. - const breeze = reduced() ? 0 : Math.sin(t / 1400) * 5; + const breeze = Math.sin(t / 1400) * 5; s.vx += (breeze - s.x) * 0.035 * dt; s.vy += (0 - s.y) * 0.05 * dt; } @@ -217,13 +231,14 @@ export function CardSwap({ interval?: number; className?: string; }) { + const prefersReducedMotion = useReducedMotion(); const [order, setOrder] = useState(() => cards.map((_, i) => i)); useEffect(() => { - if (reduced() || cards.length < 2) return; + if (prefersReducedMotion || cards.length < 2) return; const t = window.setInterval(() => setOrder(o => [...o.slice(1), o[0]]), interval); return () => window.clearInterval(t); - }, [cards.length, interval]); + }, [cards.length, interval, prefersReducedMotion]); return (
@@ -304,10 +319,11 @@ export function ProfileCard({ stat?: string; }) { const ref = useRef(null); + const prefersReducedMotion = useReducedMotion(); const onMove = (e: React.PointerEvent) => { const el = ref.current; - if (!el || reduced()) return; + if (!el || prefersReducedMotion) return; const r = el.getBoundingClientRect(); const px = (e.clientX - r.left) / r.width; const py = (e.clientY - r.top) / r.height; @@ -324,6 +340,10 @@ export function ProfileCard({ el.style.setProperty('--ry', '0deg'); }; + useEffect(() => { + if (prefersReducedMotion) reset(); + }, [prefersReducedMotion]); + return (