Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 224 additions & 0 deletions src/components/home/scroll-story/act-gravity.test.ts
Original file line number Diff line number Diff line change
@@ -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<number> }) {
const scrollerRef = React.useRef<HTMLDivElement>(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<string, unknown>).onscrollend
delete (Window.prototype as unknown as Record<string, unknown>).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<string, unknown>)
.offsetHeight
}
}

function createProgress(value: number) {
return {
get: vi.fn(() => value),
} as unknown as MotionValue<number>
}

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()
}
})
})
114 changes: 114 additions & 0 deletions src/components/home/scroll-story/act-gravity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
'use client'

import * as React from 'react'
import { animate, type MotionValue } from 'motion/react'
Comment thread
wcpos-bot[bot] marked this conversation as resolved.

/**
* 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<HTMLElement | null>,
progress: MotionValue<number>
) {
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<typeof animate> | null = null
let settling = false
let debounce: ReturnType<typeof setTimeout> | undefined

const cancel = () => {
clearTimeout(debounce)
debounce = undefined
animation?.stop()
animation = null
settling = false
}
Comment thread
wcpos-bot[bot] marked this conversation as resolved.

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])
}
9 changes: 9 additions & 0 deletions src/components/home/scroll-story/scroll-story.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -19,6 +20,7 @@ vi.mock('motion/react', async () => {
div: ({ children, ...props }: React.HTMLAttributes<HTMLElement>) =>
React.createElement('div', props, children),
},
animate: motionMock.animate,
useMotionValueEvent: (_value: unknown, eventName: string, handler: ProgressHandler) => {
if (eventName === 'change') motionMock.progressHandlers.push(handler)
},
Expand All @@ -31,6 +33,7 @@ vi.mock('motion/react', async () => {
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
motionMock.animate.mockClear()
motionMock.progressHandlers.length = 0
})

Expand Down Expand Up @@ -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(<ScrollStory />)
Expand Down
2 changes: 2 additions & 0 deletions src/components/home/scroll-story/scroll-story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
Loading