diff --git a/src/components/home/scroll-story/act-gravity.test.ts b/src/components/home/scroll-story/act-gravity.test.ts new file mode 100644 index 00000000..9e9a3ed3 --- /dev/null +++ b/src/components/home/scroll-story/act-gravity.test.ts @@ -0,0 +1,224 @@ +import * as React from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, render } from '@testing-library/react' +import type { MotionValue } from 'motion/react' + +const animateMock = vi.hoisted(() => + vi.fn( + ( + _from: number, + to: number, + options?: { onUpdate?: (value: number) => void } + ) => { + options?.onUpdate?.(to) + return { stop: vi.fn() } + } + ) +) + +vi.mock('motion/react', () => ({ + animate: animateMock, +})) + +import { + ACT_HOLDS, + CATCH_RADIUS, + DEAD_ZONE, + nearestHold, + useActGravity, +} from './act-gravity' + +afterEach(() => { + cleanup() + vi.useRealTimers() + animateMock.mockClear() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('nearestHold', () => { + it('pulls toward a hold from inside the catch radius', () => { + expect(nearestHold(0.4 + CATCH_RADIUS * 0.6)).toBe(0.4) + expect(nearestHold(0.64 - CATCH_RADIUS * 0.6)).toBe(0.64) + }) + + it('lets transitions rest where they are outside the radius', () => { + expect(nearestHold(0.2)).toBeNull() + expect(nearestHold(0.52)).toBeNull() + expect(nearestHold(0.8)).toBeNull() + }) + + it('does not re-trigger once settled on a plateau (dead zone)', () => { + for (const hold of ACT_HOLDS) { + expect(nearestHold(hold)).toBeNull() + expect(nearestHold(hold + DEAD_ZONE / 2)).toBeNull() + } + }) + + it('picks the nearer hold when two are conceivable', () => { + expect(nearestHold(0.6)).toBe(0.64) + }) +}) + +function GravityHarness({ progress }: { progress: MotionValue }) { + const scrollerRef = React.useRef(null) + useActGravity(scrollerRef, progress) + + return React.createElement('div', { + ref: scrollerRef, + 'data-testid': 'gravity-scroller', + }) +} + +function removeScrollEndSupport() { + const windowDescriptor = Object.getOwnPropertyDescriptor(window, 'onscrollend') + const prototypeDescriptor = Object.getOwnPropertyDescriptor( + Window.prototype, + 'onscrollend' + ) + + delete (window as unknown as Record).onscrollend + delete (Window.prototype as unknown as Record).onscrollend + + return () => { + if (windowDescriptor) { + Object.defineProperty(window, 'onscrollend', windowDescriptor) + } + if (prototypeDescriptor) { + Object.defineProperty(Window.prototype, 'onscrollend', prototypeDescriptor) + } + } +} + +function stubReducedMotion(matches: boolean) { + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches }))) +} + +function stubScrollerGeometry(height: number) { + const offsetHeightDescriptor = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'offsetHeight' + ) + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + configurable: true, + get: () => height, + }) + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue( + new DOMRect(0, 0, 0, height) + ) + vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(1000) + vi.spyOn(window, 'scrollY', 'get').mockReturnValue(0) + + return () => { + if (offsetHeightDescriptor) { + Object.defineProperty( + HTMLElement.prototype, + 'offsetHeight', + offsetHeightDescriptor + ) + return + } + + delete (HTMLElement.prototype as unknown as Record) + .offsetHeight + } +} + +function createProgress(value: number) { + return { + get: vi.fn(() => value), + } as unknown as MotionValue +} + +describe('useActGravity', () => { + it('settles near a hold after fallback scrolling stops', () => { + vi.useFakeTimers() + const restoreScrollEnd = removeScrollEndSupport() + const restoreGeometry = stubScrollerGeometry(2000) + const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) + const progress = createProgress(ACT_HOLDS[0] + CATCH_RADIUS / 2) + + try { + render(React.createElement(GravityHarness, { progress })) + + act(() => { + window.dispatchEvent(new Event('scroll')) + vi.advanceTimersByTime(141) + }) + + const target = ACT_HOLDS[0] * 1000 + expect(animateMock).toHaveBeenCalledWith( + 0, + target, + expect.objectContaining({ duration: 0.55 }) + ) + expect(scrollTo).toHaveBeenCalledWith(0, target) + } finally { + restoreGeometry() + restoreScrollEnd() + } + }) + + it('settles through the scrollend-supported path', () => { + vi.stubGlobal('onscrollend', null) + const restoreGeometry = stubScrollerGeometry(2000) + const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) + const progress = createProgress(ACT_HOLDS[1] - CATCH_RADIUS / 2) + + try { + render(React.createElement(GravityHarness, { progress })) + + act(() => { + window.dispatchEvent(new Event('scrollend')) + }) + + const target = ACT_HOLDS[1] * 1000 + expect(animateMock).toHaveBeenCalledWith( + 0, + target, + expect.objectContaining({ duration: 0.55 }) + ) + expect(scrollTo).toHaveBeenCalledWith(0, target) + } finally { + restoreGeometry() + } + }) + + it('returns early when reduced motion is requested', () => { + vi.useFakeTimers() + stubReducedMotion(true) + vi.stubGlobal('onscrollend', null) + const progress = createProgress(ACT_HOLDS[0] + CATCH_RADIUS / 2) + + render(React.createElement(GravityHarness, { progress })) + + act(() => { + window.dispatchEvent(new Event('scrollend')) + window.dispatchEvent(new Event('scroll')) + vi.advanceTimersByTime(141) + }) + + expect(progress.get).not.toHaveBeenCalled() + expect(animateMock).not.toHaveBeenCalled() + }) + + it('cancels a pending fallback debounce when the user starts interacting', () => { + vi.useFakeTimers() + const restoreScrollEnd = removeScrollEndSupport() + const progress = createProgress(0.2) + + try { + render(React.createElement(GravityHarness, { progress })) + + act(() => { + window.dispatchEvent(new Event('scroll')) + window.dispatchEvent(new Event('touchstart')) + vi.advanceTimersByTime(141) + }) + + expect(progress.get).not.toHaveBeenCalled() + } finally { + restoreScrollEnd() + } + }) +}) diff --git a/src/components/home/scroll-story/act-gravity.ts b/src/components/home/scroll-story/act-gravity.ts new file mode 100644 index 00000000..d96c0376 --- /dev/null +++ b/src/components/home/scroll-story/act-gravity.ts @@ -0,0 +1,114 @@ +'use client' + +import * as React from 'react' +import { animate, type MotionValue } from 'motion/react' + +/** + * Scroll gravity for the pinned story (owner ask: completed acts should + * carry more weight than transitions). When scrolling comes to rest within + * the catch radius of an act's hold plateau, the last stretch eases to the + * plateau — the classic scrollytelling "settle assist" (GSAP ScrollTrigger + * calls it snap; CSS proximity snapping is the platform cousin, but its + * radius isn't tunable). Any user input cancels immediately; reduced motion + * disables it entirely. + */ + +/** Progress positions where each act sits fully composed (see keyframes.ts: + * act 2 holds 0.32–0.44, act 3 holds 0.58–0.7, act 4 settles ≥0.92). Act 1 + * is the top of the page, which has its own natural gravity. */ +export const ACT_HOLDS = [0.4, 0.64, 0.95] as const + +/** How far (in progress space) the pull reaches. 0.05 of a 560vh scroller + * ≈ a fifth of a viewport — noticeable, never grabby. */ +export const CATCH_RADIUS = 0.05 + +/** Dead zone so a settle that lands on the plateau doesn't re-trigger. */ +export const DEAD_ZONE = 0.004 + +export function nearestHold(progress: number): number | null { + let best: number | null = null + for (const hold of ACT_HOLDS) { + const distance = Math.abs(progress - hold) + if (distance >= CATCH_RADIUS || distance <= DEAD_ZONE) continue + if (best === null || distance < Math.abs(progress - best)) best = hold + } + return best +} + +export function useActGravity( + scrollerRef: React.RefObject, + progress: MotionValue +) { + React.useEffect(() => { + const scroller = scrollerRef.current + if (!scroller) return + if ( + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ) { + return + } + + let animation: ReturnType | null = null + let settling = false + let debounce: ReturnType | undefined + + const cancel = () => { + clearTimeout(debounce) + debounce = undefined + animation?.stop() + animation = null + settling = false + } + + const settle = () => { + if (settling) return + const hold = nearestHold(progress.get()) + if (hold === null) return + const top = scroller.getBoundingClientRect().top + window.scrollY + const target = top + hold * (scroller.offsetHeight - window.innerHeight) + if (Math.abs(target - window.scrollY) < 2) return + settling = true + animation = animate(window.scrollY, target, { + duration: 0.55, + ease: [0.22, 0.61, 0.36, 1], + onUpdate: (value) => window.scrollTo(0, value), + onComplete: () => { + // let the scroll events from our own settle drain before re-arming + requestAnimationFrame(() => { + settling = false + }) + }, + }) + } + + // scrollend where available; debounced scroll as the fallback + const hasScrollEnd = 'onscrollend' in window + const onScrollEnd = () => { + if (!settling) settle() + } + const onScroll = () => { + if (settling) return + clearTimeout(debounce) + debounce = setTimeout(settle, 140) + } + if (hasScrollEnd) window.addEventListener('scrollend', onScrollEnd) + else window.addEventListener('scroll', onScroll, { passive: true }) + + // the user always wins: any input releases the pull instantly + const onInput = () => cancel() + window.addEventListener('wheel', onInput, { passive: true }) + window.addEventListener('touchstart', onInput, { passive: true }) + window.addEventListener('keydown', onInput) + + return () => { + cancel() + clearTimeout(debounce) + window.removeEventListener('scrollend', onScrollEnd) + window.removeEventListener('scroll', onScroll) + window.removeEventListener('wheel', onInput) + window.removeEventListener('touchstart', onInput) + window.removeEventListener('keydown', onInput) + } + }, [scrollerRef, progress]) +} diff --git a/src/components/home/scroll-story/scroll-story.test.tsx b/src/components/home/scroll-story/scroll-story.test.tsx index e57c2596..f23fbea8 100644 --- a/src/components/home/scroll-story/scroll-story.test.tsx +++ b/src/components/home/scroll-story/scroll-story.test.tsx @@ -10,6 +10,7 @@ import { storyCopy } from './copy' type ProgressHandler = (value: number) => void const motionMock = vi.hoisted(() => ({ progressHandlers: [] as ProgressHandler[], + animate: vi.fn(() => ({ stop: vi.fn() })), })) vi.mock('motion/react', async () => { @@ -19,6 +20,7 @@ vi.mock('motion/react', async () => { div: ({ children, ...props }: React.HTMLAttributes) => React.createElement('div', props, children), }, + animate: motionMock.animate, useMotionValueEvent: (_value: unknown, eventName: string, handler: ProgressHandler) => { if (eventName === 'change') motionMock.progressHandlers.push(handler) }, @@ -31,6 +33,7 @@ vi.mock('motion/react', async () => { afterEach(() => { cleanup() vi.unstubAllGlobals() + motionMock.animate.mockClear() motionMock.progressHandlers.length = 0 }) @@ -124,6 +127,12 @@ describe('StoryStatic', () => { }) describe('ScrollStory', () => { + it('keeps the motion test mock compatible with act gravity', async () => { + const motionReact = await import('motion/react') + + expect(motionReact.animate).toEqual(expect.any(Function)) + }) + it('renders the pinned choreography with all act copy in the DOM', () => { stubMatchMedia({ reducedMotion: false }) render() diff --git a/src/components/home/scroll-story/scroll-story.tsx b/src/components/home/scroll-story/scroll-story.tsx index d7aeb38e..b6120248 100644 --- a/src/components/home/scroll-story/scroll-story.tsx +++ b/src/components/home/scroll-story/scroll-story.tsx @@ -9,6 +9,7 @@ import { type MotionValue, } from 'motion/react' import { cn } from '@/lib/utils' +import { useActGravity } from './act-gravity' import { CounterProps } from './acts/counter-props' import { CloudSync } from './acts/cloud-sync' import { CyclingDevice } from './acts/cycling-device' @@ -95,6 +96,7 @@ function PinnedStoryScroller() { target: scrollerRef, offset: ['start start', 'end end'], }) + useActGravity(scrollerRef, progress) const [act, setAct] = React.useState(0) const [copy1Interactive, setCopy1Interactive] = React.useState(true)