diff --git a/.fallowrc.json b/.fallowrc.json index 948e567e..515620af 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -8,7 +8,9 @@ // Run by hand to regenerate blue-noise-tile.ts. "scripts/generate-blue-noise.mjs", // Run by build-posters.sh after each poster capture. - "scripts/build-thumbnail.mjs" + "scripts/build-thumbnail.mjs", + // Run by hand to recapture the Reactive feature card's still frame. + "scripts/capture-reactive-still.mjs" ], // The poster command esbuilds the harness at runtime, from a directory it // resolves by path in poster/bundle.ts. diff --git a/GLOSSARY.md b/GLOSSARY.md index 267b9328..35903188 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -101,7 +101,7 @@ A card in the homepage's features section that illustrates one quality of the li _Avoid_: Benefit card, value prop, pillar, featured card **Story**: -The animation a feature card plays when the pointer moves over it. Once started, a story always finishes, and the card returns to its still frame. +The animation a feature card plays when the pointer moves over it, or, on the Reactive card, when a scroll push runs over it. Once started, a story always finishes, and the card returns to its still frame. _Avoid_: Intro, entrance, demo, hover effect **Still frame**: diff --git a/apps/docs-tests/docs/features-reactive.spec.ts b/apps/docs-tests/docs/features-reactive.spec.ts new file mode 100644 index 00000000..1b1063ca --- /dev/null +++ b/apps/docs-tests/docs/features-reactive.spec.ts @@ -0,0 +1,196 @@ +import { expect, test } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; + +/** + * The Reactive feature card's story (SHA-216). A hover sends the arrow off + * the window and back in to the pointer, which it follows until the pointer + * leaves. Then it swoops back along the J to the still frame. A hover during + * that ending lets it land, then plays again. A touch that scrolls the page + * over the card hides the arrow and stirs the smudge until the page stops. + * Under reduced motion the card shows its still image, with no live scene. + * Checks read where the story is and where the arrow is from the + * illustration's data attributes, never how long anything takes or its + * curve, so tuning the motion cannot break this. + */ + +// The card's live scene needs a WebGPU adapter. Headless Chromium hands out +// none, so this flag turns on its SwiftShader adapter, as +// favorites-hover.spec.ts does. A launch option forces a new browser, so it +// has to be set for the whole file. +test.use({ launchOptions: { args: ['--enable-unsafe-webgpu'] } }); + +// SwiftShader draws on the CPU, so the scene's first frame and its smudge +// can take a while, and every wait on the story allows for that. +const SCENE_TIMEOUT = 60_000; +const STORY_TIMEOUT = 20_000; + +const featuresList = (page: Page) => + page.getByRole('list', { name: 'We’ll handle the complex stuff' }); + +const reactiveCard = (page: Page) => + featuresList(page) + .getByRole('listitem') + .filter({ has: page.getByRole('heading', { name: 'Reactive' }) }); + +// The illustration, whose data attributes say where the story is. +const illustration = (card: Locator) => card.locator('[data-scene]'); + +const stage = (card: Locator) => illustration(card).getAttribute('data-stage'); +const arrowLeg = (card: Locator) => illustration(card).getAttribute('data-arrow'); + +// Scrolls the features section to the middle of the viewport, twice, for the +// reason favorites-hover.spec.ts gives: the section rides in the hero's +// sticky pin, and the hero finishes its change a frame after the first +// scroll, which moves the section again. Off screen, the hero's scene +// pauses, which leaves SwiftShader to the card's. +async function open(page: Page): Promise { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + await featuresList(page).evaluate(async (list) => { + const section = list.closest('section')!; + const nextFrames = () => + new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + + section.scrollIntoView({ block: 'center' }); + await nextFrames(); + section.scrollIntoView({ block: 'center' }); + }); +} + +/** Opens the page and waits for the card's live scene to show the still frame. */ +async function openLive(page: Page): Promise { + await open(page); + const card = reactiveCard(page); + + await expect(illustration(card)).toHaveAttribute('data-scene', 'laid', { + timeout: SCENE_TIMEOUT, + }); + + return card; +} + +/** Moves the pointer onto the illustration's middle. */ +async function hover(card: Locator): Promise { + const box = (await illustration(card).boundingBox())!; + + await card.page().mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 4 }); +} + +/** Moves the pointer off the card, onto the site header. */ +async function leave(page: Page): Promise { + await page.mouse.move(0, 0, { steps: 4 }); +} + +// Records the story's stage on every frame from the moment this is called. +async function recordStages(card: Locator): Promise<() => Promise> { + await illustration(card).evaluate((element) => { + const stages: string[] = []; + const record = () => { + const current = element.getAttribute('data-stage') ?? ''; + + if (stages.at(-1) !== current) stages.push(current); + requestAnimationFrame(record); + }; + + (window as unknown as { stages: string[] }).stages = stages; + record(); + }); + + return () => card.page().evaluate(() => (window as unknown as { stages: string[] }).stages); +} + +// --------------------------------------------- +// Hover +// --------------------------------------------- + +test('a hover hands the arrow to the pointer, and leaving brings it back to the still frame', async ({ + page, +}) => { + const card = await openLive(page); + + await hover(card); + await expect.poll(() => stage(card)).toBe('holding'); + await expect.poll(() => arrowLeg(card), { timeout: STORY_TIMEOUT }).toBe('following'); + // The arrow stands in for the cursor once it follows the pointer. + await expect(illustration(card)).toHaveCSS('cursor', 'none'); + + await leave(page); + await expect.poll(() => stage(card)).toBe('ending'); + await expect.poll(() => stage(card), { timeout: STORY_TIMEOUT }).toBe('still'); + expect(await arrowLeg(card)).toBe('resting'); +}); + +test('a hover during the ending lets it land, then plays again', async ({ page }) => { + const card = await openLive(page); + + await hover(card); + await expect.poll(() => arrowLeg(card), { timeout: STORY_TIMEOUT }).toBe('following'); + + const stages = await recordStages(card); + + await leave(page); + await expect.poll(() => stage(card)).toBe('ending'); + await hover(card); + // The pointer is back, but the ending carries on. + expect(await stage(card)).toBe('ending'); + + await expect.poll(stages, { timeout: STORY_TIMEOUT }).toEqual(['holding', 'ending', 'holding']); +}); + +// --------------------------------------------- +// Touch +// --------------------------------------------- + +test.describe('on a touch device', () => { + test.use({ hasTouch: true, isMobile: true, viewport: { width: 412, height: 839 } }); + + test('a touch that scrolls the page over the card stirs it, then it settles on the still frame', async ({ + page, + }) => { + const card = await openLive(page); + const box = (await illustration(card).boundingBox())!; + const client = await page.context().newCDPSession(page); + const touch = ( + type: 'touchStart' | 'touchMove' | 'touchEnd', + points: Array<{ x: number; y: number; id: number }>, + ) => client.send('Input.dispatchTouchEvent', { type, touchPoints: points }); + const x = box.x + box.width / 2; + const y = box.y + box.height * 0.7; + const scrollBefore = await page.evaluate(() => window.scrollY); + + await touch('touchStart', [{ x, y, id: 1 }]); + for (let step = 1; step <= 16; step += 1) { + await touch('touchMove', [{ x, y: y - step * 12, id: 1 }]); + await page.waitForTimeout(30); + } + + await expect.poll(() => stage(card)).toBe('holding'); + expect(await arrowLeg(card)).toBe('pushing'); + await expect(illustration(card)).toHaveAttribute('data-arrow-hidden', 'true'); + + await touch('touchEnd', []); + await expect.poll(() => stage(card), { timeout: STORY_TIMEOUT }).toBe('still'); + expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(scrollBefore + 50); + }); +}); + +// --------------------------------------------- +// Reduced motion +// --------------------------------------------- + +test.describe('under reduced motion', () => { + test('the card shows its still image, and a hover leaves it there', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await open(page); + + const card = reactiveCard(page); + + await expect(card.locator('img[src*="/features/reactive.png"]')).toBeVisible(); + await hover(card); + await page.waitForTimeout(1_000); + + expect(await stage(card)).toBe('still'); + await expect(illustration(card)).toHaveAttribute('data-scene', 'still'); + await expect(card.locator('canvas')).toHaveCount(0); + }); +}); diff --git a/apps/docs-tests/docs/homepage.spec.ts b/apps/docs-tests/docs/homepage.spec.ts index 29dd0544..c977c8ae 100644 --- a/apps/docs-tests/docs/homepage.spec.ts +++ b/apps/docs-tests/docs/homepage.spec.ts @@ -281,10 +281,13 @@ test('each feature card shows its illustration, hidden from screen readers', asy continue; } - const image = item.locator('img'); + // An image, alone or under a live illustration's other parts, such as + // the Reactive card's arrow. Without WebGPU, as here, the Reactive card + // keeps showing its still image. + const image = item.locator(`img[src="${card.illustration.src}"]`); - await expect(image).toHaveAttribute('aria-hidden', 'true'); - await expect(image).toHaveAttribute('src', card.illustration.src); + await expect(image).toBeVisible(); + expect(await image.evaluate((element) => !!element.closest('[aria-hidden="true"]'))).toBe(true); await expect .poll(() => image.evaluate((element: HTMLImageElement) => element.complete && element.naturalWidth > 0), diff --git a/apps/docs/public/features/reactive-pointer-halo.svg b/apps/docs/public/features/reactive-pointer-halo.svg new file mode 100644 index 00000000..9e45db11 --- /dev/null +++ b/apps/docs/public/features/reactive-pointer-halo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/docs/public/features/reactive-pointer.svg b/apps/docs/public/features/reactive-pointer.svg new file mode 100644 index 00000000..7c72c4ed --- /dev/null +++ b/apps/docs/public/features/reactive-pointer.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/docs/public/features/reactive.png b/apps/docs/public/features/reactive.png index 45a54029..96fc0671 100644 Binary files a/apps/docs/public/features/reactive.png and b/apps/docs/public/features/reactive.png differ diff --git a/apps/docs/src/components/favorites/favorites-list.tsx b/apps/docs/src/components/favorites/favorites-list.tsx index 4d1aac31..2ba374c3 100644 --- a/apps/docs/src/components/favorites/favorites-list.tsx +++ b/apps/docs/src/components/favorites/favorites-list.tsx @@ -14,9 +14,10 @@ import { type CSSProperties, type RefObject, useEffect, useRef, useState } from import { ShaderPoster } from '@camp-dev/shaders/poster'; -import { useHeroPause } from '@/components/home-hero/hero-pause'; +import { usePauseHeroWhile } from '@/components/home-hero/hero-pause'; import type { Favorite, FavoriteSlug } from '@/content/homepage'; import { useCanHover } from '@/lib/use-can-hover'; +import { useHasWebGpu } from '@/lib/use-has-webgpu'; import { CardTab } from './card-tab'; import { favoriteSceneBackdrop, preloadFavoriteScenes, useFavoriteScene } from './favorite-scenes'; @@ -72,7 +73,9 @@ export function FavoritesList({ : (hoveredSlug ?? focusedSlug); usePreloadScenesNearView(listRef, canGoLive); - usePauseHeroWhilePlaying(canGoLive && playingSlug !== null); + // A favorite plays only once the gate below opens, so with no WebGPU the + // hero keeps drawing. + usePauseHeroWhile(canGoLive && playingSlug !== null); const setEngaged = (slug: FavoriteSlug, engagement: Engagement, engaged: boolean) => { const setSlug = engagement === 'pointer' ? setHoveredSlug : setFocusedSlug; @@ -298,26 +301,6 @@ function usePreloadScenesNearView(listRef: RefObject, e }, [listRef, enabled]); } -// --------------------------------------------- -// Pausing the hero while a favorite plays -// --------------------------------------------- - -/** - * Pauses the homepage hero while a favorite plays, for the reason - * components/home-hero/hero-pause.ts gives. A favorite plays only once the - * gate below opens, so with no WebGPU the hero keeps drawing. - */ -function usePauseHeroWhilePlaying(playing: boolean) { - const heroPause = useHeroPause(); - - useEffect(() => { - if (!playing || !heroPause) return undefined; - heroPause.setPaused(true); - - return () => heroPause.setPaused(false); - }, [playing, heroPause]); -} - // --------------------------------------------- // The gate: a mouse or trackpad, and a WebGPU adapter // --------------------------------------------- @@ -325,34 +308,11 @@ function usePauseHeroWhilePlaying(playing: boolean) { /** * True where a favorite may mount its scene: the device passes the site's * hover gate (lib/use-can-hover.ts), and the browser hands out a WebGPU - * adapter. A phone or tablet fails the gate, so its favorites stay posters. - * Without an adapter, ShaderScene would fall back to WebGL2, and the - * favorites stay posters instead. Only a device that can hover asks for the - * adapter. + * adapter (lib/use-has-webgpu.ts). A phone or tablet fails the gate, so its + * favorites stay posters. Only a device that can hover asks for the adapter. */ function useCanGoLive(): boolean { const canHover = useCanHover(); - const [hasWebGpu, setHasWebGpu] = useState(false); - useEffect(() => { - // navigator.gpu is typed as always present, but it is missing in a - // browser without WebGPU and outside a secure context, so probe with `in`. - if (!canHover || !('gpu' in navigator)) return; - - let cancelled = false; - - void navigator.gpu - .requestAdapter() - // A browser that refuses the request has no WebGPU to offer either. - .catch(() => null) - .then((adapter) => { - if (!cancelled) setHasWebGpu(adapter !== null); - }); - - return () => { - cancelled = true; - }; - }, [canHover]); - - return canHover && hasWebGpu; + return useHasWebGpu(canHover); } diff --git a/apps/docs/src/components/features/features.tsx b/apps/docs/src/components/features/features.tsx index 14a70727..7662120b 100644 --- a/apps/docs/src/components/features/features.tsx +++ b/apps/docs/src/components/features/features.tsx @@ -12,6 +12,7 @@ import { FEATURE_CARDS, type FeatureCardId } from '@/content/homepage'; import { ExtensibleIllustration } from './extensible-illustration'; import styles from './features.module.css'; import { PerformantIllustration } from './performant-illustration'; +import { ReactiveIllustration } from './reactive-illustration'; import { StoryCard } from './story'; // The illustration's size in the mock, in CSS pixels. The exported files are @@ -36,14 +37,14 @@ function StaticIllustration({ src }: { src: string }) { ); } -// Each card's illustration. Extensible's and Performant's are built in code. -// The others are the images exported from the mock until each card's own -// issue rebuilds its illustration in code and gives the card its story. A -// complete record, so a card with no illustration fails the type check. +// Each card's illustration. Extensible's, Performant's, and Reactive's are +// built in code. Composable's is the image exported from the mock until its +// own issue rebuilds it in code and gives the card its story. A complete +// record, so a card with no illustration fails the type check. const ILLUSTRATIONS: Record = { composable: , performant: , - reactive: , + reactive: , extensible: , }; diff --git a/apps/docs/src/components/features/live-scene.ts b/apps/docs/src/components/features/live-scene.ts new file mode 100644 index 00000000..d6814b7a --- /dev/null +++ b/apps/docs/src/components/features/live-scene.ts @@ -0,0 +1,58 @@ +/** + * Loads a feature card's live scene where it may run, for an illustration + * whose held story waits for its scene (story.tsx). It lives apart from + * story.tsx so that file exports only components and hooks, which Fast + * Refresh needs to keep their state across an edit. + */ +import { type ComponentType, useEffect, useState } from 'react'; + +import { useReducedMotion } from 'motion/react'; + +import { useHasWebGpu } from '@/lib/use-has-webgpu'; + +/** + * A live scene for an illustration's story: `load` imports its module. The + * returned hook gives the scene's component where the scene may run, a + * browser with a WebGPU adapter and motion allowed, once its module has + * loaded, and null until then and anywhere else, where the card shows its + * still frame as an image. The module loads as the card mounts, so the + * scene mounts before the card scrolls into view. It loads through a plain + * import() from an effect rather than next/dynamic, for the reason + * favorite-scenes.tsx gives, which also keeps three out of the server + * render. + */ +export function createLiveScene( + load: () => Promise<{ default: ComponentType }>, +): () => ComponentType | null { + // The component once its module has loaded, so a later mount reads it + // straight away. + let loaded: ComponentType | null = null; + + return function useLiveScene() { + const hasWebGpu = useHasWebGpu(); + const reducedMotion = useReducedMotion() === true; + const enabled = hasWebGpu && !reducedMotion; + const [scene, setScene] = useState(() => loaded); + + useEffect(() => { + if (!enabled || scene) return undefined; + + let cancelled = false; + + load() + .then((module) => { + loaded = module.default; + if (!cancelled) setScene(() => module.default); + }) + .catch(() => { + // A module that fails to load leaves the card on its image. + }); + + return () => { + cancelled = true; + }; + }, [enabled, scene]); + + return enabled ? scene : null; + }; +} diff --git a/apps/docs/src/components/features/reactive-arrow.test.ts b/apps/docs/src/components/features/reactive-arrow.test.ts new file mode 100644 index 00000000..5048e947 --- /dev/null +++ b/apps/docs/src/components/features/reactive-arrow.test.ts @@ -0,0 +1,101 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; + +import { + angleForHeading, + arrowAtPointer, + arrowTip, + entryPoint, + exitDistance, + headingToward, + STILL_FRAME_ARROW, + swoopPose, +} from './reactive-arrow'; + +describe('the Reactive arrow', () => { + // The mock's export at 2x draws the arrow's tip at about (548, 148). + it('puts the still frame tip where the mock draws it', () => { + const [x, y] = arrowTip(STILL_FRAME_ARROW); + + expect(Math.abs(x - 274)).toBeLessThan(3); + expect(Math.abs(y - 74)).toBeLessThan(3); + }); + + it('keeps the tip on the pointer at any heading', () => { + for (const heading of [-150, -40, 0, 90, 200]) { + const [x, y] = arrowTip(arrowAtPointer([120, 200], angleForHeading(heading))); + + expect(x).toBeCloseTo(120); + expect(y).toBeCloseTo(200); + } + }); + + // In the mock the arrow points up and to the right, about 40 degrees + // above the horizontal, which is a heading of about -40 with y down. + it('turns the arrow to the mock angle for the still frame heading', () => { + expect(Math.abs(angleForHeading(-40) - STILL_FRAME_ARROW.angle)).toBeLessThan(2); + }); + + describe('turning toward a move', () => { + it('heads the way the move goes', () => { + expect(headingToward(0, 0, 10)).toBeCloseTo(90); + expect(headingToward(10, -10, 0)).toBeCloseTo(180); + }); + + it('turns the short way round, past a full turn when it has to', () => { + // At 170 degrees, a move toward -170 is 20 degrees on, not 340 back. + const [dx, dy] = [Math.cos((-170 * Math.PI) / 180), Math.sin((-170 * Math.PI) / 180)]; + + expect(headingToward(170, dx, dy)).toBeCloseTo(190); + expect(headingToward(-190, dx, dy)).toBeCloseTo(-170); + }); + }); + + // The window is 312 by 309 mock pixels. The arrow starts its flight back + // in 90 past the edge, out of sight. + describe('where the arrow flies back in from', () => { + it('comes in past the edge the pointer came in over', () => { + expect(entryPoint([100, 340])).toEqual([100, 399]); + expect(entryPoint([-10, 150])).toEqual([-90, 150]); + expect(entryPoint([200, -20])).toEqual([200, -90]); + expect(entryPoint([330, 50])).toEqual([402, 50]); + }); + + it('comes in past the nearest edge for a pointer already inside', () => { + expect(entryPoint([20, 150])).toEqual([-90, 150]); + expect(entryPoint([150, 300])).toEqual([150, 399]); + }); + + it('slides along the edge for a pointer that came in past a corner', () => { + expect(entryPoint([-40, -10])).toEqual([-90, 0]); + }); + }); + + describe('flying off the window', () => { + // From the middle of the 312 by 309 window, heading right, the arrow + // clears the right edge and the 90 pixels past it. + it('flies far enough along its heading to leave the window out of sight', () => { + expect(exitDistance([156, 150], 0)).toBeCloseTo(312 + 90 - 156); + expect(exitDistance([156, 150], 90)).toBeCloseTo(309 + 90 - 150); + expect(exitDistance([156, 150], 180)).toBeCloseTo(156 + 90); + }); + + it('is out of sight already from past the margin', () => { + expect(exitDistance([500, 150], 0)).toBe(0); + }); + }); + + describe('the swoop back in along the J', () => { + it('lands exactly on the still frame', () => { + const pose = swoopPose(1); + + expect(pose.x).toBeCloseTo(STILL_FRAME_ARROW.x); + expect(pose.y).toBeCloseTo(STILL_FRAME_ARROW.y); + expect(pose.angle).toBeCloseTo(STILL_FRAME_ARROW.angle); + }); + + it('starts with its tip below the window, out of sight', () => { + expect(arrowTip(swoopPose(0))[1]).toBeGreaterThan(309); + }); + }); +}); diff --git a/apps/docs/src/components/features/reactive-arrow.ts b/apps/docs/src/components/features/reactive-arrow.ts new file mode 100644 index 00000000..67b3b38c --- /dev/null +++ b/apps/docs/src/components/features/reactive-arrow.ts @@ -0,0 +1,204 @@ +/** + * The Reactive card's arrow (SHA-216): its pose in the still frame, its + * pose while it follows the pointer, pointing the way the pointer moves, + * its flights off and back on the window, its swoop back along the J, and + * where its tip is in any pose. Lengths are mock pixels in the 312 by 309 + * illustration, angles are degrees clockwise, as CSS turns them, and + * reactive-illustration.module.css draws the arrow from a pose. + */ +import { headingOnJ, MOCK_HEIGHT, MOCK_WIDTH, pointOnJ } from './reactive-smudge'; + +// How far past the window's edge the arrow starts its flight back in: far +// enough that none of it shows. +const ENTRY_MARGIN = 90; + +/** Where the arrow is: the top-left of its box before it turns, and how far it turns, in degrees. */ +export interface ArrowPose { + x: number; + y: number; + angle: number; +} + +// The arrow's box: its halo's full size in the mock. CSS turns the box +// about its center. +const BOX = [71.595, 87.924] as const; + +// The tip, a cursor's hotspot, in the unturned box: the body's top corner, +// inset by the halo's padding. +const TIP = [8.6, 8.09] as const; + +// The notch at the arrow's tail, in the unturned box. The arrow points +// along the line from the notch to the tip. +const NOTCH = [35.38, 62.16] as const; + +// The heading the unturned arrow points along, in degrees with y down: +// up and a little to the left. +const UNTURNED_HEADING = (Math.atan2(TIP[1] - NOTCH[1], TIP[0] - NOTCH[0]) * 180) / Math.PI; + +const toRadians = (degrees: number) => (degrees * Math.PI) / 180; + +// The offset (`x`, `y`) turned clockwise by `angle`, as CSS turns it. +function rotate(x: number, y: number, angle: number): [number, number] { + const turn = toRadians(angle); + + return [x * Math.cos(turn) - y * Math.sin(turn), x * Math.sin(turn) + y * Math.cos(turn)]; +} + +/** The mock's pose: at the upper right, turned to point up and to the right. */ +export const STILL_FRAME_ARROW: ArrowPose = { x: 208.44, y: 65.7, angle: 76.89 }; + +/** How far to turn the arrow so it points along `heading`. */ +export function angleForHeading(heading: number): number { + return heading - UNTURNED_HEADING; +} + +/** The way an arrow turned by `angle` points. */ +export function headingForAngle(angle: number): number { + return angle + UNTURNED_HEADING; +} + +/** The still frame's heading: up and to the right, as the mock draws it. */ +export const STILL_FRAME_HEADING = headingForAngle(STILL_FRAME_ARROW.angle); + +// Where a point of the unturned box sits from the box's center once the +// box turns by `angle`. +function fromCenter([pointX, pointY]: readonly [number, number], angle: number): [number, number] { + return rotate(pointX - BOX[0] / 2, pointY - BOX[1] / 2, angle); +} + +// The pose that turns the arrow by `angle` with its point `local`, in the +// unturned box, on `at`. +function arrowWithPointAt( + local: readonly [number, number], + [atX, atY]: readonly [number, number], + angle: number, +): ArrowPose { + const [offsetX, offsetY] = fromCenter(local, angle); + + return { x: atX - BOX[0] / 2 - offsetX, y: atY - BOX[1] / 2 - offsetY, angle }; +} + +/** The pose that turns the arrow by `angle` with its tip on `pointer`. */ +export function arrowAtPointer(pointer: readonly [number, number], angle: number): ArrowPose { + return arrowWithPointAt(TIP, pointer, angle); +} + +/** Where the tip is in `pose`. */ +export function arrowTip({ x, y, angle }: ArrowPose): [number, number] { + const [tipX, tipY] = fromCenter(TIP, angle); + + return [x + BOX[0] / 2 + tipX, y + BOX[1] / 2 + tipY]; +} + +/** + * The heading of a move by (`dx`, `dy`), counted on from `current` so the + * turn to it is never more than half a turn. A heading can run past 180, + * so a spring that follows it always turns the short way round. + */ +export function headingToward(current: number, dx: number, dy: number): number { + return current + shortestTurn(current, (Math.atan2(dy, dx) * 180) / Math.PI); +} + +/** + * Where the arrow starts its flight back in to `pointer`: past the edge of + * the window nearest to it, out of sight. A pointer outside the window is + * nearest the edge it came in over, and one past a corner slides along the + * edge to the corner. + */ +export function entryPoint([x, y]: readonly [number, number]): [number, number] { + const alongX = Math.min(Math.max(x, 0), MOCK_WIDTH); + const alongY = Math.min(Math.max(y, 0), MOCK_HEIGHT); + // How far inside each edge the pointer is, negative past it. + const edges = [ + { inside: x, point: [-ENTRY_MARGIN, alongY] }, + { inside: MOCK_WIDTH - x, point: [MOCK_WIDTH + ENTRY_MARGIN, alongY] }, + { inside: y, point: [alongX, -ENTRY_MARGIN] }, + { inside: MOCK_HEIGHT - y, point: [alongX, MOCK_HEIGHT + ENTRY_MARGIN] }, + ] as const; + const nearest = edges.reduce((best, edge) => (edge.inside < best.inside ? edge : best)); + + return [nearest.point[0], nearest.point[1]]; +} + +/** + * How far along `heading` the arrow flies from `point` to leave the window + * and ENTRY_MARGIN past it, out of sight, or 0 from there already. + */ +export function exitDistance([x, y]: readonly [number, number], heading: number): number { + const turn = toRadians(heading); + // How far along the heading the arrow crosses the far side of one axis. + // A heading straight along the other axis never crosses it. Its step + // along this axis comes out of cos or sin as a rounding error, about + // 1e-16 rather than 0, so anything under STRAIGHT counts as 0. + const across = (from: number, step: number, size: number) => { + if (step > STRAIGHT) return (size + ENTRY_MARGIN - from) / step; + if (step < -STRAIGHT) return (-ENTRY_MARGIN - from) / step; + + return Infinity; + }; + + return Math.max( + 0, + Math.min(across(x, Math.cos(turn), MOCK_WIDTH), across(y, Math.sin(turn), MOCK_HEIGHT)), + ); +} + +// A step along an axis small enough to be a rounding error (exitDistance). +const STRAIGHT = 1e-9; + +/** The point `distance` on from `from` along `heading`. */ +export function pointAlong( + [x, y]: readonly [number, number], + heading: number, + distance: number, +): [number, number] { + const turn = toRadians(heading); + + return [x + Math.cos(turn) * distance, y + Math.sin(turn) * distance]; +} + +/** Where the tip sits in the still frame. */ +export const STILL_FRAME_TIP = arrowTip(STILL_FRAME_ARROW); + +/** The turn from `from` to `to`, in degrees, the short way round. */ +export function shortestTurn(from: number, to: number): number { + return ((((to - from) % 360) + 540) % 360) - 180; +} + +// The J's end, in mock pixels: where the still frame's smudge meets the +// arrow. +const J_END: readonly [number, number] = [ + pointOnJ(1)[0] * MOCK_WIDTH, + pointOnJ(1)[1] * MOCK_HEIGHT, +]; + +// The point of the unturned box the J's end meets in the still frame, so +// the swoop can carry that point along the J and land on the still frame. +const TRAIL = (() => { + const { x, y, angle } = STILL_FRAME_ARROW; + // Turn the J's end back by the still frame's angle about the box's center. + const [offsetX, offsetY] = rotate( + J_END[0] - (x + BOX[0] / 2), + J_END[1] - (y + BOX[1] / 2), + -angle, + ); + + return [BOX[0] / 2 + offsetX, BOX[1] / 2 + offsetY] as const; +})(); + +/** + * The arrow `t` of the way along its swoop back in along the J: from below + * the window's bottom-left, pointing the way the J runs, easing round to + * the still frame's heading as it lands on the still frame. + */ +export function swoopPose(t: number): ArrowPose { + const [x, y] = pointOnJ(t); + const pathHeading = headingOnJ(t); + // Smoothstep from 0.55 to 1, so the turn to the still frame's heading + // starts past halfway and settles as the arrow lands. + const settle = Math.min(Math.max((t - 0.55) / 0.45, 0), 1); + const blend = settle * settle * (3 - 2 * settle); + const heading = pathHeading + shortestTurn(pathHeading, STILL_FRAME_HEADING) * blend; + + return arrowWithPointAt(TRAIL, [x * MOCK_WIDTH, y * MOCK_HEIGHT], angleForHeading(heading)); +} diff --git a/apps/docs/src/components/features/reactive-gradient.ts b/apps/docs/src/components/features/reactive-gradient.ts new file mode 100644 index 00000000..c2c48847 --- /dev/null +++ b/apps/docs/src/components/features/reactive-gradient.ts @@ -0,0 +1,38 @@ +/** + * The Reactive card's LiquidGradient settings (SHA-216). The bands run from + * a deep violet in the upper right, where the arrow sits on a dark ground as + * it does in the mock, through violet and purple to a pale magenta in the + * lower left. The held smudge drags the bright corner up toward the arrow. + * The flow never drifts, so reactive-scene.tsx holds `speed` at a literal 0 + * and these settings leave it out. + */ +import { paletteOklch } from '@/lib/palette'; +import type { PositionedStop } from '@/lib/stops'; + +interface ReactiveGradient { + angle: number; + repeat: number; + softness: number; + swirl: number; + scale: number; + seed: number; + stops: PositionedStop[]; +} + +// 50 degrees of hue across the stops, with 0.14 to 0.26 of lightness +// between each pair. The darkest stop sits at the window's own lightness. +export const REACTIVE_GRADIENT: ReactiveGradient = { + // 225 runs the stops from the upper right to the lower left. + angle: 225, + repeat: 1, + softness: 1, + swirl: 0.9, + scale: 1.5, + seed: 0, + stops: [ + { color: paletteOklch.violet[2], position: 0 }, + { color: paletteOklch.violet[6], position: 0.55 }, + { color: paletteOklch.purple[9], position: 0.8 }, + { color: paletteOklch.magenta[10], position: 1 }, + ], +}; diff --git a/apps/docs/src/components/features/reactive-illustration.module.css b/apps/docs/src/components/features/reactive-illustration.module.css new file mode 100644 index 00000000..3f75a59d --- /dev/null +++ b/apps/docs/src/components/features/reactive-illustration.module.css @@ -0,0 +1,73 @@ +/* The Reactive illustration, after the Figma mock's node 939:964: a pointer + arrow over a live gradient. Every length is in the mock's pixels times + --mock-px, one mock pixel at the card window's current width. The window + is 312 mock pixels wide, and features.module.css makes it the container + that `cqw` measures, so the illustration scales like the image it + replaced at every column width. */ +.illustration { + --mock-px: calc(100cqw / 312); + + position: relative; + aspect-ratio: 312 / 309; +} + +/* The still frame, captured from the live scene at 2x with its arrow. It + covers the scene until the scene has laid its smudge, and stands in for + it without WebGPU and under reduced motion. */ +.still { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +/* Once the arrow lands on the pointer and follows it, it stands in for the + cursor, so the real one hides over the illustration. While the arrow is + still flying in, the real one stays, so the visitor never loses it. */ +.illustration[data-arrow='following'] { + cursor: none; +} + +/* ---- The arrow ---- + The mock's pointer, 71.6 by 87.9. --arrow-x and --arrow-y place the top + left of its box, and --arrow-angle turns the box about its center, in + degrees: 76.89 in the still frame points it up and to the right, and + while it follows the pointer, it turns to point the way the pointer + moves. reactive-arrow.ts works out the pose. + + Both parts carry the whole placement themselves. A transform on a shared + parent would make that parent a group of its own, and the halo's overlay + blend would then have nothing under it to blend with. + + Touches pass through to the canvas, whose own touchstart starts a scroll + push, even while a push hides the arrow. */ +.pointer { + position: absolute; + pointer-events: none; + transition: opacity var(--fade-sm) var(--ease-out); + top: 0; + left: 0; + box-sizing: border-box; + width: calc(71.595 * var(--mock-px)); + height: calc(87.924 * var(--mock-px)); + rotate: calc(var(--arrow-angle) * 1deg); + translate: calc(var(--arrow-x) * var(--mock-px)) calc(var(--arrow-y) * var(--mock-px)); +} + +/* A scroll push hides the arrow while the touch stirs the smudge. It shows + again as it swoops back in from off the window. */ +.illustration[data-arrow-hidden] .pointer { + opacity: 0; +} + +/* The halo, the pointer's full box, in light gray with an overlay blend, + which lightens it over bright color and keeps it dim over dark. */ +.pointer[data-part='halo'] { + mix-blend-mode: overlay; +} + +/* The body, 58.9 by 75.2, inset in the halo's box as the mock draws it. */ +.pointer[data-part='body'] { + padding: calc(6.489 * var(--mock-px)) calc(6.49 * var(--mock-px)) calc(6.198 * var(--mock-px)) + calc(6.198 * var(--mock-px)); +} diff --git a/apps/docs/src/components/features/reactive-illustration.tsx b/apps/docs/src/components/features/reactive-illustration.tsx new file mode 100644 index 00000000..21e3847f --- /dev/null +++ b/apps/docs/src/components/features/reactive-illustration.tsx @@ -0,0 +1,632 @@ +'use client'; + +/** + * The Reactive feature card's illustration (SHA-216): the mock's pointer + * arrow over a live LiquidGradient with a smudge held along the J path + * (reactive-scene.tsx), where the browser has WebGPU and motion is allowed. + * Anywhere else, and until the scene has laid the smudge, the card shows an + * image captured from that still frame. A hover sends the arrow off the + * window and back in to the visitor's pointer, which it then follows, + * pointing the way the pointer moves, while its tip smudges the gradient. + * When the pointer leaves, the arrow follows it off, then swoops back along + * the J and lays the still frame's smudge again. A touch that scrolls the + * page over the card hides the arrow and stirs the smudge until the page + * stops (story.tsx's held story). + */ +import Image from 'next/image'; +import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { + animate, + domMin, + LazyMotion, + m, + type MotionStyle, + type MotionValue, + useMotionValue, + useMotionValueEvent, + useSpring, + useTransform, +} from 'motion/react'; + +import { EASE_OUT } from '@/lib/easing'; + +import { createLiveScene } from './live-scene'; +import { + angleForHeading, + arrowAtPointer, + type ArrowPose, + arrowTip, + entryPoint, + exitDistance, + headingForAngle, + headingToward, + pointAlong, + shortestTurn, + STILL_FRAME_ARROW, + STILL_FRAME_HEADING, + STILL_FRAME_TIP, + swoopPose, +} from './reactive-arrow'; +import styles from './reactive-illustration.module.css'; +import { MOCK_HEIGHT, MOCK_WIDTH, pointOnJ, REACTIVE_SMUDGE } from './reactive-smudge'; +import { createScenePause, type ScenePause } from './scene-pause'; +import { type HeldBy, type HeldStage, type HeldStory, useHeldStory } from './story'; + +// --------------------------------------------- +// The scene +// --------------------------------------------- + +// The live scene, where it may run (live-scene.ts). +const useReactiveScene = createLiveScene(() => import('./reactive-scene')); + +// --------------------------------------------- +// The arrow +// --------------------------------------------- + +// The pose's CSS variables, which reactive-illustration.module.css reads. +type ArrowStyle = MotionStyle & { + '--arrow-x': MotionValue; + '--arrow-y': MotionValue; + '--arrow-angle': MotionValue; +}; + +// next/image, able to bind motion values to its style. +const MotionImage = m.create(Image); + +// The mock's arrow: its body, and the light halo around it, which an +// overlay blend tints with whatever lies under it. Two elements side by +// side rather than one group, because a transform on a shared parent would +// cut the halo's blend off from the canvas beneath. Each carries the pose's +// variables itself, so a frame restyles the two images and nothing else. +function Arrow({ style }: { style: ArrowStyle }) { + return ( + <> + + + + ); +} + +// --------------------------------------------- +// The story +// --------------------------------------------- + +// How long the hover's flights take, in seconds: the arrow flicks off the +// window along its heading, then flies back in to the pointer. Leaving +// sends it off the same way. Longer reads as a lazier handoff, shorter as a +// snap. +const OUT_SECONDS = 0.25; +const IN_SECONDS = 0.35; + +// How far the arrow flies out along its heading when a hover starts, in +// mock pixels: past the window's edge from the still frame's spot. +const OUT_DISTANCE = 260; + +// How long the visitor's smudges clear at decay 1 once the arrow is off the +// window, before it swoops back in, in seconds. A hover's flight off adds +// OUT_SECONDS first, and a push, whose arrow is already hidden, waits both, +// so a full pull always has about 0.6 s to clear to nothing. Shorter +// leaves a faint smudge for the swoop's decay 0 to hold. +const CLEAR_SECONDS = 0.35; + +// How quickly the heading follows the way the pointer moves: the time +// constant of a smoothing filter on the pointer's velocity, in seconds. +// Longer steadies the heading against a trembling mouse and turns later. +const TURN_SMOOTHING = 0.025; + +// The shortest gap between two pointer events the velocity counts, in +// seconds. A browser can deliver two events at the same timestamp, and the +// velocity would then divide by zero. +const MIN_EVENT_GAP = 1 / 240; + +// How the arrow turns to the heading: a spring with no bounce, so the +// turning speed builds and settles rather than starting at full speed. The +// filtered heading changes only when a pointer event lands, and a real +// mouse's events are uneven, so the spring also carries the turn smoothly +// through every frame between. +// +// Stiffness and damping rather than visualDuration, which would stall the +// turn on every pointer event (the time-defined-spring gotcha in +// docs/agents/docs-site.md). Critical damping keeps the turn bounce-free. +// A turn from rest covers about 95% in 240 ms, then eases through its last +// few degrees. Lower stiffness stretches that settle; damping is twice +// the square root of stiffness at mass 1 to keep the same response. +const TURN_SPRING = { stiffness: 400, damping: 40, mass: 1 }; + +// Smooth the pointer's position between mouse events. At mass 1, damping +// 50 critically damps stiffness 625: about 95% of a move in 190 ms, with +// no bounce. Position catches up a little sooner than the arrow's turn. +const FOLLOW_SPRING = { stiffness: 625, damping: 50, mass: 1 }; + +// The slowest the pointer can move, in mock pixels a second, and still +// turn the arrow. Slower is a tremble, not a direction. Higher holds the +// heading through slow drags too. +const MIN_TURN_SPEED = 40; + +/** The eased pointer over the card, in mock pixels, and the way the mouse heads. */ +interface CardPointer { + x: MotionValue; + y: MotionValue; + /** In degrees with y down, unwrapped, so following it always turns the short way. */ + heading: MotionValue; + /** Where the pointer was when the hover started, or null before it has. */ + enteredAt: RefObject<[number, number] | null>; +} + +/** + * Eases the pointer's position toward each mouse move without restarting + * its velocity. The heading follows the raw mouse velocity through a + * smoothing filter, and a still or trembling mouse keeps its heading. + */ +function useEasedPointer(illustrationRef: RefObject) { + const targetX = useMotionValue(0); + const targetY = useMotionValue(0); + const x = useSpring(targetX, FOLLOW_SPRING); + const y = useSpring(targetY, FOLLOW_SPRING); + const heading = useMotionValue(STILL_FRAME_HEADING); + const enteredAt = useRef<[number, number] | null>(null); + // Stable, so a re-render never restarts a leg that lists it in its deps. + const pointer = useMemo(() => ({ x, y, heading, enteredAt }), [x, y, heading]); + // The last move, in mock pixels and seconds, and the filtered velocity, + // in mock pixels a second. Null before the first move of a hover. + const lastMoveRef = useRef<{ x: number; y: number; time: number } | null>(null); + const velocityRef = useRef<[number, number]>([0, 0]); + + const onPointerMove = (event: PointerEvent) => { + const rect = illustrationRef.current?.getBoundingClientRect(); + + if (!rect || rect.width === 0 || rect.height === 0) return; + const nextX = ((event.clientX - rect.left) / rect.width) * MOCK_WIDTH; + const nextY = ((event.clientY - rect.top) / rect.height) * MOCK_HEIGHT; + const time = event.timeStamp / 1000; + const last = lastMoveRef.current; + + targetX.set(nextX); + targetY.set(nextY); + lastMoveRef.current = { x: nextX, y: nextY, time }; + if (!last) { + // Start each hover at its entry point, without carrying motion from + // the previous hover into the arrow's flight back in. + x.jump(nextX); + y.jump(nextY); + enteredAt.current = [nextX, nextY]; + velocityRef.current = [0, 0]; + + return; + } + const elapsed = Math.max(time - last.time, MIN_EVENT_GAP); + const keep = Math.exp(-elapsed / TURN_SMOOTHING); + const [velocityX, velocityY] = velocityRef.current; + const nextVelocity: [number, number] = [ + velocityX * keep + ((nextX - last.x) / elapsed) * (1 - keep), + velocityY * keep + ((nextY - last.y) / elapsed) * (1 - keep), + ]; + + velocityRef.current = nextVelocity; + if (Math.hypot(...nextVelocity) < MIN_TURN_SPEED) return; + heading.set(headingToward(heading.get(), ...nextVelocity)); + }; + + // Ready for the next hover. Stable, so the story's ending can list it in + // an effect's deps. + const reset = useCallback(() => { + enteredAt.current = null; + lastMoveRef.current = null; + velocityRef.current = [0, 0]; + heading.jump(STILL_FRAME_HEADING); + }, [heading]); + + return { pointer, onPointerMove, reset }; +} + +/** + * Where the arrow is in the story. It rests in the still frame. A hover + * sends it out, back in, and following the pointer. A scroll push hides it + * where it rests while the touch stirs the smudge. Once the hover or the + * push ends, it leaves the way the pointer did, if it was following, waits + * off the window while the smudges clear, then swoops back along the J. + */ +type Leg = 'resting' | 'out' | 'in' | 'following' | 'pushing' | 'leaving' | 'clearing' | 'swooping'; + +/** A leg that ends when a flight or a wait does, which moves the play to the next leg. */ +type TimedLeg = 'out' | 'in' | 'leaving' | 'clearing'; + +function legOf(stage: HeldStage, heldBy: HeldBy, finished: TimedLeg | null): Leg { + if (stage === 'still') return 'resting'; + if (stage === 'holding') { + if (heldBy === 'push') return 'pushing'; + if (finished === null) return 'out'; + + return finished === 'out' ? 'in' : 'following'; + } + if (finished === 'clearing') return 'swooping'; + // A push ending skips the flight off: the arrow is already hidden. + if (finished === 'leaving' || heldBy === 'push') return 'clearing'; + + return 'leaving'; +} + +// CursorSmudge's decay on each leg. 0 holds the J, at rest and as the +// swoop lays it again. While the visitor smudges, 0.4 takes 90% of a pull +// away in about 1.2 s, so the J ages out and each stroke eases back. Once +// they leave, 1 clears their smudges fast. +const DECAY: Record = { + resting: 0, + out: 0.4, + in: 0.4, + following: 0.4, + pushing: 0.4, + leaving: 1, + clearing: 1, + swooping: 0, +}; + +// The tip of `pose`, in 0..1 canvas units, for a scripted stroke. +const canvasTip = (pose: ArrowPose): [number, number] => { + const [x, y] = arrowTip(pose); + + return [x / MOCK_WIDTH, y / MOCK_HEIGHT]; +}; + +/** The progress of each leg's flight, from 0 to 1, and the points the flights start from. */ +interface Flights { + out: MotionValue; + flightIn: MotionValue; + leave: MotionValue; + swoop: MotionValue; + entryX: MotionValue; + entryY: MotionValue; + /** Where the arrow's tip is and the way it points, while the story holds. */ + leftX: MotionValue; + leftY: MotionValue; + leftHeading: MotionValue; +} + +// Stable, so a re-render never restarts a leg that lists it in its deps. +function useFlights(): Flights { + const out = useMotionValue(0); + const flightIn = useMotionValue(0); + const leave = useMotionValue(0); + const swoop = useMotionValue(0); + const entryX = useMotionValue(0); + const entryY = useMotionValue(0); + const leftX = useMotionValue(STILL_FRAME_TIP[0]); + const leftY = useMotionValue(STILL_FRAME_TIP[1]); + const leftHeading = useMotionValue(STILL_FRAME_HEADING); + + return useMemo( + () => ({ out, flightIn, leave, swoop, entryX, entryY, leftX, leftY, leftHeading }), + [out, flightIn, leave, swoop, entryX, entryY, leftX, leftY, leftHeading], + ); +} + +/** + * The arrow's pose on each leg that moves it, and the point each stroking + * leg's scripted stroke follows. `turn` is the heading the arrow shows. + */ +function useArrowPoses(pointer: CardPointer, turn: MotionValue, flights: Flights) { + const { out, flightIn, leave, swoop, entryX, entryY, leftX, leftY, leftHeading } = flights; + const outPose = useTransform(out, (progress) => + arrowAtPointer( + pointAlong(STILL_FRAME_TIP, STILL_FRAME_HEADING, progress * OUT_DISTANCE), + STILL_FRAME_ARROW.angle, + ), + ); + // Flying in, the arrow points the way it flies, and comes round to the + // pointer's own heading as it lands, so following picks up where it ends. + const inPose = useTransform(() => { + const progress = flightIn.get(); + const fromX = entryX.get(); + const fromY = entryY.get(); + const pointerX = pointer.x.get(); + const pointerY = pointer.y.get(); + const flying = (Math.atan2(pointerY - fromY, pointerX - fromX) * 180) / Math.PI; + const heading = flying + shortestTurn(flying, turn.get()) * progress * progress; + + return arrowAtPointer( + [fromX + (pointerX - fromX) * progress, fromY + (pointerY - fromY) * progress], + angleForHeading(heading), + ); + }); + const followPose = useTransform(() => + arrowAtPointer([pointer.x.get(), pointer.y.get()], angleForHeading(turn.get())), + ); + const leavePose = useTransform(() => { + const from: [number, number] = [leftX.get(), leftY.get()]; + const heading = leftHeading.get(); + + return arrowAtPointer( + pointAlong(from, heading, leave.get() * exitDistance(from, heading)), + angleForHeading(heading), + ); + }); + const swoopingPose = useTransform(swoop, swoopPose); + + return { + poses: { outPose, inPose, followPose, leavePose, swoopingPose }, + // One point per stroking leg, so the scene starts a fresh stroke on each + // rather than drawing one across the window from where the last left + // off. The smudge follows the eased tip while the arrow follows the + // pointer, rather than the raw mouse ahead of it. + tips: { + outTip: useTransform(outPose, canvasTip), + inTip: useTransform(inPose, canvasTip), + followTip: useTransform(followPose, canvasTip), + jPoint: useTransform(swoop, pointOnJ), + }, + }; +} + +/** + * Runs each timed leg's flight or wait, then moves the play on, and ends + * the story once the swoop lands. The scene attaches a stroking leg's point + * in an effect of its own, in the render where the leg starts, reading + * where the point is as the stroke's start. A child's effects run before + * its parent's, so a flight never runs ahead of the attach, and each leg's + * point is set up before its leg starts. Every flight and wait pauses with + * the scene (scene-pause.ts), so the strokes it lays and the smudges it + * waits on to clear keep the scene's time. + */ +function useLegTimeline( + leg: Leg, + { plays, heldBy, onStoryEnd }: HeldStory, + finish: (timed: TimedLeg) => void, + { + pointer, + flights, + turn, + resetPointer, + scenePause, + }: { + pointer: CardPointer; + flights: Flights; + turn: MotionValue; + resetPointer: () => void; + scenePause: ScenePause; + }, +) { + useEffect(() => { + const { out, flightIn, leave, swoop, entryX, entryY } = flights; + const run = (value: MotionValue, duration: number, then: () => void) => { + const controls = animate(value, 1, { duration, ease: EASE_OUT }); + + void controls.then(then); + + return scenePause.hold(controls); + }; + const steps: Partial () => void>> = { + out: () => + run(out, OUT_SECONDS, () => { + const [fromX, fromY] = entryPoint( + pointer.enteredAt.current ?? [pointer.x.get(), pointer.y.get()], + ); + + entryX.jump(fromX); + entryY.jump(fromY); + flightIn.jump(0); + finish('out'); + }), + in: () => run(flightIn, IN_SECONDS, () => finish('in')), + leaving: () => run(leave, OUT_SECONDS, () => finish('leaving')), + clearing: () => { + const seconds = heldBy === 'push' ? OUT_SECONDS + CLEAR_SECONDS : CLEAR_SECONDS; + // A wait rather than a flight, as an animation of nothing, so it + // pauses with the scene while the smudges clear. + const wait = animate(0, 1, { duration: seconds }); + + void wait.then(() => { + swoop.jump(0); + finish('clearing'); + }); + + return scenePause.hold(wait); + }, + swooping: () => + run(swoop, REACTIVE_SMUDGE.laySeconds, () => { + out.jump(0); + leave.jump(0); + resetPointer(); + turn.jump(STILL_FRAME_HEADING); + onStoryEnd(); + }), + }; + + return steps[leg]?.(); + }, [leg, plays, heldBy, finish, pointer, flights, turn, resetPointer, scenePause, onStoryEnd]); +} + +// After a push the arrow waits hidden in its spot while the smudges clear, +// so it shows again only as it swoops back in. +const isHidden = (leg: Leg, heldBy: HeldBy) => + leg === 'pushing' || (leg === 'clearing' && heldBy === 'push'); + +/** The arrow's pose on the leg it is on, or the still frame's on a leg that doesn't move it. */ +function useLegPose( + leg: Leg, + heldBy: HeldBy, + poses: ReturnType['poses'], +): MotionValue { + // After a hover the arrow waits where it left the window. After a push it + // waits hidden in its spot. + const clearingPose = heldBy === 'push' ? null : poses.leavePose; + const poseOn: Record | null> = { + resting: null, + out: poses.outPose, + in: poses.inPose, + following: poses.followPose, + pushing: null, + leaving: poses.leavePose, + clearing: clearingPose, + swooping: poses.swoopingPose, + }; + const legPose = poseOn[leg]; + + // A computed motion value collects what it reads on each render, so this + // follows only the leg the arrow is on. + return useTransform((): ArrowPose => legPose?.get() ?? STILL_FRAME_ARROW); +} + +/** + * While the story holds, keeps where the arrow's tip is and the way it + * points, so it leaves from there whichever leg it was on. + */ +function useHeldTip(pose: MotionValue, stage: HeldStage, flights: Flights) { + const holdingRef = useRef(false); + + useEffect(() => { + holdingRef.current = stage === 'holding'; + }, [stage]); + useMotionValueEvent(pose, 'change', (current) => { + if (!holdingRef.current) return; + const [tipX, tipY] = arrowTip(current); + + flights.leftX.set(tipX); + flights.leftY.set(tipY); + flights.leftHeading.set(headingForAngle(current.angle)); + }); +} + +/** + * The arrow's part in the held story, leg by leg (Leg says what each does). + * The tip lays a scripted stroke on the flights in and out of a hover and + * while the arrow follows the pointer, and the swoop lays the J again. + * Returns the leg, whether a push hides the arrow, the smudge's decay, the + * pose's CSS variables, and the point a scripted stroke follows, if one + * strokes. + */ +function useArrow( + story: HeldStory, + pointer: CardPointer, + resetPointer: () => void, + scenePause: ScenePause, +) { + const { stage, plays, heldBy } = story; + // The last timed leg this play finished. + const [finished, setFinished] = useState<{ play: number; leg: TimedLeg } | null>(null); + const leg = legOf(stage, heldBy, finished?.play === plays ? finished.leg : null); + const finish = useCallback( + (timed: TimedLeg) => setFinished({ play: plays, leg: timed }), + [plays], + ); + const flights = useFlights(); + // The heading the arrow shows, which turns toward the pointer's. + const turn = useSpring(pointer.heading, TURN_SPRING); + const { poses, tips } = useArrowPoses(pointer, turn, flights); + const pose = useLegPose(leg, heldBy, poses); + // A point that never moves, attached while the card rests: a scripted + // stroke beats the mouse and the push, so a pointer on the card before + // the story starts, or a mouse that fails the hover gate, can't smudge + // the still frame its decay 0 holds. The ending attaches it too while + // the smudges clear, so a pointer or a push that comes back then can't + // lay a stroke for the swoop's decay 0 to keep. + const stillPoint = useMotionValue<[number, number]>([0.5, 0.5]); + + useHeldTip(pose, stage, flights); + useLegTimeline(leg, story, finish, { pointer, flights, turn, resetPointer, scenePause }); + + const strokeOn: Record | null> = { + resting: stillPoint, + out: tips.outTip, + in: tips.inTip, + following: tips.followTip, + pushing: null, + leaving: stillPoint, + clearing: stillPoint, + swooping: tips.jPoint, + }; + const style: ArrowStyle = { + '--arrow-x': useTransform(pose, ({ x }) => x), + '--arrow-y': useTransform(pose, ({ y }) => y), + '--arrow-angle': useTransform(pose, ({ angle }) => angle), + }; + + return { + leg, + hidden: isHidden(leg, heldBy), + decay: DECAY[leg], + style, + strokeAt: strokeOn[leg], + }; +} + +// --------------------------------------------- +// The illustration +// --------------------------------------------- + +// Hidden from screen readers: the card's title and description carry its +// message. `data-scene` says whether the live scene shows the still frame, +// `data-stage` where the story is, `data-arrow` where the arrow is in it, +// and `data-arrow-hidden` whether a push has hidden it. +// +// `m` under LazyMotion rather than `motion`, as in the other feature cards: +// the illustration only binds motion values to style, which domMin covers. +export function ReactiveIllustration() { + const Scene = useReactiveScene(); + // Whether the live scene shows the still frame. It doesn't until the J is + // laid, nor while a new renderer or a resize lays it again. + const [stillFrameReady, setStillFrameReady] = useState(false); + const live = Scene !== null && stillFrameReady; + // The story's timed legs pause with the scene's loop. + const [scenePause] = useState(createScenePause); + const illustrationRef = useRef(null); + const { pointer, onPointerMove, reset } = useEasedPointer(illustrationRef); + // The story waits for the live still frame, so a card showing the image, + // as it does without WebGPU and under reduced motion, stays still. + const story = useHeldStory({ enabled: live, onPointerMove, pausesHero: true }); + const arrow = useArrow(story, pointer, reset, scenePause); + + return ( + +
+ {/* ShaderScene fills the illustration by itself. The arrow follows + it in the DOM, so it paints on top. */} + {Scene && ( + + )} + + {!live && ( + + )} +
+
+ ); +} diff --git a/apps/docs/src/components/features/reactive-scene.tsx b/apps/docs/src/components/features/reactive-scene.tsx new file mode 100644 index 00000000..88ea31b5 --- /dev/null +++ b/apps/docs/src/components/features/reactive-scene.tsx @@ -0,0 +1,240 @@ +'use client'; + +/** + * The Reactive card's live scene: its LiquidGradient, held still, and a + * CursorSmudge that holds a smudge along the J path (reactive-smudge.ts). + * After the scene's first frame, a scripted stroke lays the J, and the + * still frame is ready once it lands. The illustration sets the smudge's + * decay for its story, and can stroke it too, as the arrow flies. With + * scroll push on, a touch that scrolls the page over the card stirs the + * smudge, and the scene reports each push's start and end. A resize while + * the card rests lays the J again. The J's stroke pauses with the scene's + * loop (scene-pause.ts), and the scene reports each pause, so the + * illustration's own timed legs can pause with it too. + * reactive-illustration.tsx loads this module with import() from an + * effect, which keeps three out of the server render (the SSR gotcha in + * docs/agents/docs-site.md). + */ +import { useEffect, useRef, useState } from 'react'; + +import { CursorSmudge, LiquidGradient, ShaderScene, useShaderContext } from '@camp-dev/shaders'; +import { animate, type MotionValue, motionValue } from 'motion/react'; + +import { EASE_OUT } from '@/lib/easing'; + +import { REACTIVE_GRADIENT } from './reactive-gradient'; +import { pointOnJ, REACTIVE_SMUDGE } from './reactive-smudge'; +import { createScenePause } from './scene-pause'; + +export interface ReactiveSceneProps { + /** + * Fires with true once the J's smudge is laid and the scene shows the + * still frame, and with false whenever it lays the J again: after a new + * renderer's first frame, or a resize while the card rests. Fires with + * false as the scene unmounts, too. + */ + onStillFrameChange: (ready: boolean) => void; + /** + * Fires with true when the scene pauses its loop, off screen or in a + * hidden tab, and with false when it resumes. Fires with false as the + * scene unmounts, so a pause never outlives it. + */ + onPausedChange: (paused: boolean) => void; + /** CursorSmudge's decay. 0 holds the smudge, as the still frame does. */ + decay: number; + /** + * A point that strokes the smudge, in 0..1 canvas units, or null while + * nothing scripted strokes. The J's own stroke takes over while it lays. + */ + strokeAt: MotionValue<[number, number]> | null; + /** Whether the card rests on its still frame, so a resize lays the J again. */ + resting: boolean; + /** + * Fire when a touch that scrolls the page over the card first pushes the + * smudge, and when its fling settles. A tap that never scrolls fires + * neither. + */ + onPushStart: () => void; + onPushEnd: () => void; +} + +/** + * Passes the scene's scroll pushes on, from a push's first stroke to its + * end. A child of the scene, because only the scene's own context reaches + * its cursor input. A push under way when the scene goes, as a renderer + * rebuild takes it, ends with it. + */ +function PushEvents({ onStart, onEnd }: { onStart: () => void; onEnd: () => void }) { + const context = useShaderContext(); + const callbacksRef = useRef({ onStart, onEnd }); + + useEffect(() => { + callbacksRef.current = { onStart, onEnd }; + }); + + useEffect(() => { + if (!context) return undefined; + const { scheduler } = context; + const input = context.getCursorInput(); + let contact = false; + let pushing = false; + const end = () => { + contact = false; + if (!pushing) return; + pushing = false; + callbacksRef.current.onEnd(); + }; + // Runs after the input's own tick, which works out the frame's push. + const watch = () => { + if (!contact || pushing || !input.getPush()) return; + pushing = true; + callbacksRef.current.onStart(); + }; + const stopWatchingStart = input.onPushStart(() => { + contact = true; + }); + const stopWatchingEnd = input.onPushEnd(end); + + scheduler.add(watch); + + return () => { + stopWatchingStart(); + stopWatchingEnd(); + scheduler.remove(watch); + end(); + }; + }, [context]); + + return null; +} + +/** + * Passes the scene's pauses on. A child of the scene, because only the + * scene's own context reaches its scheduler. + */ +function PauseEvents({ onChange }: { onChange: (paused: boolean) => void }) { + const context = useShaderContext(); + const onChangeRef = useRef(onChange); + + useEffect(() => { + onChangeRef.current = onChange; + }); + + useEffect(() => { + if (!context) return undefined; + const report = (paused: boolean) => onChangeRef.current(paused); + const stopWatching = context.scheduler.onPauseChange(report); + + return () => { + stopWatching(); + report(false); + }; + }, [context]); + + return null; +} + +/** + * Lays the J again when the canvas resizes while the card rests: a resize + * clears CursorSmudge's field, and with it the still frame's smudge. That + * includes a resize while the J lays, which clears what it has laid so far. + */ +function RelayOnResize({ resting, relay }: { resting: boolean; relay: () => void }) { + const context = useShaderContext(); + const latestRef = useRef({ resting, relay }); + + useEffect(() => { + latestRef.current = { resting, relay }; + }); + + useEffect(() => { + if (!context) return undefined; + + return context.canvasSize.on('change', () => { + if (latestRef.current.resting) latestRef.current.relay(); + }); + }, [context]); + + return null; +} + +export default function ReactiveScene({ + onStillFrameChange, + onPausedChange, + decay, + strokeAt, + resting, + onPushStart, + onPushEnd, +}: ReactiveSceneProps) { + // The point of the J's stroke under way, or null while none lays. Each + // lay has a point of its own, so a lay that starts over attaches a fresh + // stroke at the J's foot rather than going on from where the last left + // off. + const [layPoint, setLayPoint] = useState | null>(null); + const [scenePause] = useState(createScenePause); + // The latest callbacks, so a new one from the parent doesn't restart the stroke. + const callbacksRef = useRef({ onStillFrameChange, onPausedChange }); + + useEffect(() => { + callbacksRef.current = { onStillFrameChange, onPausedChange }; + }); + + // The scene attaches the point in an effect of its own when the lay + // starts, reading the J's foot as the stroke's start. A child's effects + // run before its parent's, so the stroke never runs ahead of the attach. + useEffect(() => { + if (!layPoint) return undefined; + const controls = animate(0, 1, { + duration: REACTIVE_SMUDGE.laySeconds, + ease: EASE_OUT, + onUpdate: (progress) => layPoint.set(pointOnJ(progress)), + }); + + void controls.then(() => { + setLayPoint(null); + callbacksRef.current.onStillFrameChange(true); + }); + + return scenePause.hold(controls); + }, [layPoint, scenePause]); + + // A scene that unmounts takes its still frame with it, so a scene that + // mounts later keeps the image up until it lays its own J. + useEffect(() => { + const dropStillFrame = () => callbacksRef.current.onStillFrameChange(false); + + return dropStillFrame; + }, []); + + // The stroke starts on the first frame, once the scene and its cursor + // input exist, so none of it is lost to the renderer starting up. A + // rebuilt renderer draws a first frame of its own, and lays the J again, + // as a resize does while the card rests. + const layJ = () => { + callbacksRef.current.onStillFrameChange(false); + setLayPoint(motionValue(pointOnJ(0))); + }; + + // The J's stroke pauses with the scene, and the illustration hears of it. + const reportPause = (paused: boolean) => { + scenePause.setPaused(paused); + callbacksRef.current.onPausedChange(paused); + }; + + return ( + + {/* A literal 0 holds the flow still, so the scene parks whenever + nothing strokes. */} + + + + + + + ); +} diff --git a/apps/docs/src/components/features/reactive-smudge.test.ts b/apps/docs/src/components/features/reactive-smudge.test.ts new file mode 100644 index 00000000..934cee31 --- /dev/null +++ b/apps/docs/src/components/features/reactive-smudge.test.ts @@ -0,0 +1,13 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; + +import { headingOnJ } from './reactive-smudge'; + +describe('the J', () => { + // Degrees with y down: -90 is straight up and 0 is to the right. + it('rises from its foot and bends over to run right at the arrow', () => { + expect(headingOnJ(0)).toBeLessThan(-80); + expect(headingOnJ(0)).toBeGreaterThan(-95); + expect(Math.abs(headingOnJ(1))).toBeLessThan(10); + }); +}); diff --git a/apps/docs/src/components/features/reactive-smudge.ts b/apps/docs/src/components/features/reactive-smudge.ts new file mode 100644 index 00000000..935d1713 --- /dev/null +++ b/apps/docs/src/components/features/reactive-smudge.ts @@ -0,0 +1,66 @@ +/** + * The Reactive card's held smudge (SHA-216): the J path its scripted stroke + * follows, and CursorSmudge's settings. The stroke drags the gradient's + * bright lower left up along the J to the arrow, and decay 0 holds it as the + * still frame. + */ + +/** The illustration's size in mock pixels, which the arrow and the J measure in. */ +export const MOCK_WIDTH = 312; +export const MOCK_HEIGHT = 309; + +interface ReactiveSmudge { + /** CursorSmudge's strength: the furthest the stroke pulls, in canvas heights. */ + strength: number; + /** CursorSmudge's radius: how wide the stroke pulls, in canvas heights. */ + radius: number; + /** How long the stroke takes to run the J, in seconds. */ + laySeconds: number; +} + +// About twice CursorSmudge's default strength and radius, so the streak +// reads as the mock's trail. 0.85 s is the ending's swoop, which lays the +// same smudge. +export const REACTIVE_SMUDGE: ReactiveSmudge = { + strength: 0.25, + radius: 0.24, + laySeconds: 0.85, +}; + +// The J, as a cubic Bézier in 0..1 canvas units from the top-left: up from +// below the bottom edge near the left corner, bending over to the arrow at +// (240, 101) in mock pixels. +const J_PATH = [ + [0.12, 1.15], + [0.15, 0.55], + [0.45, 0.35], + [240 / MOCK_WIDTH, 101 / MOCK_HEIGHT], +] as const; + +/** The point `t` of the way along the J, from 0 at its foot to 1 at the arrow. */ +export function pointOnJ(t: number): [number, number] { + const [p0, p1, p2, p3] = J_PATH; + const u = 1 - t; + const weights = [u * u * u, 3 * u * u * t, 3 * u * t * t, t * t * t] as const; + + return [ + weights[0] * p0[0] + weights[1] * p1[0] + weights[2] * p2[0] + weights[3] * p3[0], + weights[0] * p0[1] + weights[1] * p1[1] + weights[2] * p2[1] + weights[3] * p3[1], + ]; +} + +/** + * The way the J runs at `t`, in degrees with y down, measured in mock + * pixels: straight up at its foot, and to the right at the arrow. + */ +export function headingOnJ(t: number): number { + const [p0, p1, p2, p3] = J_PATH; + const u = 1 - t; + const weights = [3 * u * u, 6 * u * t, 3 * t * t] as const; + const dx = + weights[0] * (p1[0] - p0[0]) + weights[1] * (p2[0] - p1[0]) + weights[2] * (p3[0] - p2[0]); + const dy = + weights[0] * (p1[1] - p0[1]) + weights[1] * (p2[1] - p1[1]) + weights[2] * (p3[1] - p2[1]); + + return (Math.atan2(dy * MOCK_HEIGHT, dx * MOCK_WIDTH) * 180) / Math.PI; +} diff --git a/apps/docs/src/components/features/scene-pause.test.ts b/apps/docs/src/components/features/scene-pause.test.ts new file mode 100644 index 00000000..fc78bc33 --- /dev/null +++ b/apps/docs/src/components/features/scene-pause.test.ts @@ -0,0 +1,81 @@ +// @vitest-environment node +import type { AnimationPlaybackControlsWithThen } from 'motion/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { createScenePause } from './scene-pause'; + +// Stand-in controls that track the play state the way Motion's do, and +// finish when the test says so. +function fakeControls() { + let finish = () => {}; + const finished = new Promise((resolve) => { + finish = resolve; + }); + const controls = { + state: 'running', + pause: vi.fn(() => { + controls.state = 'paused'; + }), + play: vi.fn(() => { + controls.state = 'running'; + }), + stop: vi.fn(() => { + controls.state = 'idle'; + }), + then: (resolve: () => void) => finished.then(resolve), + }; + + return { + controls, + held: controls as unknown as AnimationPlaybackControlsWithThen, + finish: async () => { + controls.state = 'finished'; + finish(); + await finished; + }, + }; +} + +describe('a scene pause', () => { + it('pauses what it holds while the scene is paused, and plays it on as the scene resumes', () => { + const scenePause = createScenePause(); + const { controls, held } = fakeControls(); + + scenePause.hold(held); + scenePause.setPaused(true); + expect(controls.state).toBe('paused'); + scenePause.setPaused(false); + expect(controls.state).toBe('running'); + }); + + it('starts an animation paused when the scene already is', () => { + const scenePause = createScenePause(); + const { controls, held } = fakeControls(); + + scenePause.setPaused(true); + scenePause.hold(held); + expect(controls.state).toBe('paused'); + }); + + it('never plays a finished animation again, which would restart it', async () => { + const scenePause = createScenePause(); + const { controls, held, finish } = fakeControls(); + + scenePause.hold(held); + await finish(); + scenePause.setPaused(true); + scenePause.setPaused(false); + expect(controls.play).not.toHaveBeenCalled(); + }); + + it('stops an animation and lets it go when its effect cleans up', () => { + const scenePause = createScenePause(); + const { controls, held } = fakeControls(); + const stop = scenePause.hold(held); + + stop(); + expect(controls.stop).toHaveBeenCalledOnce(); + scenePause.setPaused(true); + expect(controls.pause).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/docs/src/components/features/scene-pause.ts b/apps/docs/src/components/features/scene-pause.ts new file mode 100644 index 00000000..32e0ed4c --- /dev/null +++ b/apps/docs/src/components/features/scene-pause.ts @@ -0,0 +1,49 @@ +/** + * Keeps a feature card's timed animations on its live scene's time. The + * scene pauses its loop off screen and in a hidden tab, and a scripted + * stroke and a smudge's decay advance only while it runs, but a Motion + * animation runs on regardless. One that strokes the scene, or waits for + * its smudge to clear, pauses with the scene instead, so a card that + * scrolls away mid-stroke finishes the stroke on its return. + * reactive-scene.tsx reports the pauses, and both it and + * reactive-illustration.tsx run their timed animations through one. + */ +import type { AnimationPlaybackControlsWithThen } from 'motion/react'; + +/** Pauses the animations it holds while the scene is paused. */ +export interface ScenePause { + /** Called with true when the scene pauses, and with false when it resumes. */ + setPaused: (paused: boolean) => void; + /** + * Holds `controls` until it finishes, paused whenever the scene is, + * starting now. Returns its stop, for the cleanup of the effect that + * started it. + */ + hold: (controls: AnimationPlaybackControlsWithThen) => () => void; +} + +export function createScenePause(): ScenePause { + let paused = false; + const held = new Set(); + + return { + setPaused(nowPaused) { + paused = nowPaused; + for (const controls of held) { + // Only a paused animation plays on: play() restarts a finished one. + if (nowPaused) controls.pause(); + else if (controls.state === 'paused') controls.play(); + } + }, + hold(controls) { + held.add(controls); + if (paused) controls.pause(); + void controls.then(() => held.delete(controls)); + + return () => { + held.delete(controls); + controls.stop(); + }; + }, + }; +} diff --git a/apps/docs/src/components/features/story-state.test.ts b/apps/docs/src/components/features/story-state.test.ts new file mode 100644 index 00000000..17302c61 --- /dev/null +++ b/apps/docs/src/components/features/story-state.test.ts @@ -0,0 +1,71 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; + +import { type HeldStoryEvent, heldStoryReducer, type HeldStoryState, STILL } from './story-state'; + +const play = (...events: HeldStoryEvent[]): HeldStoryState => + events.reduce(heldStoryReducer, STILL); + +const landed = (pointerOnCard = false, pushing = false): HeldStoryEvent => ({ + type: 'ending-landed', + pointerOnCard, + pushing, +}); + +describe('a held story', () => { + it('holds from a hover, ends when the pointer leaves, and rests once the ending lands', () => { + expect(play({ type: 'pointer-enter' })).toEqual({ + stage: 'holding', + plays: 1, + heldBy: 'pointer', + }); + expect(play({ type: 'pointer-enter' }, { type: 'pointer-leave' })).toMatchObject({ + stage: 'ending', + }); + expect(play({ type: 'pointer-enter' }, { type: 'pointer-leave' }, landed())).toMatchObject({ + stage: 'still', + plays: 1, + }); + }); + + it('lets the ending land before a hover during it starts a fresh hold', () => { + const ending = play( + { type: 'pointer-enter' }, + { type: 'pointer-leave' }, + { type: 'pointer-enter' }, + ); + + expect(ending).toMatchObject({ stage: 'ending', plays: 1 }); + expect(heldStoryReducer(ending, landed(true))).toEqual({ + stage: 'holding', + plays: 2, + heldBy: 'pointer', + }); + }); + + it('holds from a scroll push, which only the push ending ends', () => { + const pushed = play({ type: 'push-start' }); + + expect(pushed).toEqual({ stage: 'holding', plays: 1, heldBy: 'push' }); + expect(heldStoryReducer(pushed, { type: 'pointer-leave' })).toBe(pushed); + expect(heldStoryReducer(pushed, { type: 'push-end' })).toMatchObject({ stage: 'ending' }); + }); + + it('keeps a hover hold when a push starts or ends', () => { + const hovered = play({ type: 'pointer-enter' }); + + expect(heldStoryReducer(hovered, { type: 'push-start' })).toBe(hovered); + expect(heldStoryReducer(hovered, { type: 'push-end' })).toBe(hovered); + }); + + it('starts a fresh push hold when a push is still going as the ending lands', () => { + const ending = play({ type: 'push-start' }, { type: 'push-end' }, { type: 'push-start' }); + + expect(ending).toMatchObject({ stage: 'ending', plays: 1 }); + expect(heldStoryReducer(ending, landed(false, true))).toEqual({ + stage: 'holding', + plays: 2, + heldBy: 'push', + }); + }); +}); diff --git a/apps/docs/src/components/features/story-state.ts b/apps/docs/src/components/features/story-state.ts new file mode 100644 index 00000000..39b02e9c --- /dev/null +++ b/apps/docs/src/components/features/story-state.ts @@ -0,0 +1,76 @@ +/** + * Where a feature card's held story is, and how each event moves it + * (story.tsx). A pure reducer, so the rules are testable without a DOM. + */ + +/** + * Where a held story is. It rests on the still frame, holds while the + * pointer stays or a scroll push runs, and plays its ending back to the + * still frame once the hold ends. + */ +export type HeldStage = 'still' | 'holding' | 'ending'; + +/** + * What holds the story: a hovering pointer, or a touch that scrolls the + * page over the card, a scroll push, from its first push to its fling's + * end. + */ +export type HeldBy = 'pointer' | 'push'; + +export interface HeldStoryState { + stage: HeldStage; + /** + * Counts the story's plays. An illustration keys its animation by it, so + * every play starts from the still frame. + */ + plays: number; + /** What holds the current play, or held the last one. */ + heldBy: HeldBy; +} + +export type HeldStoryEvent = + | { type: 'pointer-enter' } + | { type: 'pointer-leave' } + | { type: 'push-start' } + | { type: 'push-end' } + /** The ending landed on the still frame, with what is on the card now. */ + | { type: 'ending-landed'; pointerOnCard: boolean; pushing: boolean }; + +export const STILL: HeldStoryState = { stage: 'still', plays: 0, heldBy: 'pointer' }; + +const hold = (state: HeldStoryState, heldBy: HeldBy): HeldStoryState => ({ + stage: 'holding', + plays: state.plays + 1, + heldBy, +}); + +// What each start and end event is about. +const STARTED_BY = { 'pointer-enter': 'pointer', 'push-start': 'push' } as const; +const ENDED_BY = { 'pointer-leave': 'pointer', 'push-end': 'push' } as const; + +// An ending that lands with the pointer still on the card, or a push still +// running, starts a fresh hold, the pointer's first. +function land(state: HeldStoryState, pointerOnCard: boolean, pushing: boolean): HeldStoryState { + if (pointerOnCard) return hold(state, 'pointer'); + if (pushing) return hold(state, 'push'); + + return { ...state, stage: 'still' }; +} + +/** + * Only the still frame starts a hold, so an event during a hold or an + * ending waits. Only what started a hold ends it, and only a landing ends + * an ending. + */ +export function heldStoryReducer(state: HeldStoryState, event: HeldStoryEvent): HeldStoryState { + if (event.type === 'ending-landed') { + return state.stage === 'ending' ? land(state, event.pointerOnCard, event.pushing) : state; + } + if (event.type === 'pointer-enter' || event.type === 'push-start') { + return state.stage === 'still' ? hold(state, STARTED_BY[event.type]) : state; + } + + return state.stage === 'holding' && state.heldBy === ENDED_BY[event.type] + ? { ...state, stage: 'ending' } + : state; +} diff --git a/apps/docs/src/components/features/story.tsx b/apps/docs/src/components/features/story.tsx index fa713741..5b7b3165 100644 --- a/apps/docs/src/components/features/story.tsx +++ b/apps/docs/src/components/features/story.tsx @@ -3,10 +3,12 @@ /** * The lifecycle every feature card's story shares (SHA-178): a hover plays * the story, and a story under way always finishes after the pointer leaves. - * Touch, keyboard, and reduced motion leave the card on its still frame. + * Keyboard and reduced motion leave the card on its still frame, and so does + * touch, unless the illustration passes a scroll push to a held story. * StoryCard is the card. An illustration takes one of two story shapes: * useStory repeats a play while the pointer stays, and useHeldStory holds - * while the pointer stays, then plays its ending. + * while the pointer stays, then plays its ending. A held story can follow + * the pointer, and can wait for its live scene (live-scene.ts). */ import { createContext, @@ -15,14 +17,18 @@ import { useCallback, useContext, useEffect, + useReducer, useRef, useState, } from 'react'; import { useReducedMotion } from 'motion/react'; +import { usePauseHeroWhile } from '@/components/home-hero/hero-pause'; import { useCanHover } from '@/lib/use-can-hover'; +import { type HeldBy, type HeldStage, heldStoryReducer, STILL } from './story-state'; + // --------------------------------------------- // The card // --------------------------------------------- @@ -52,23 +58,30 @@ export function StoryCard({ className, children }: { className?: string; childre /** * Follows the pointer on the illustration's StoryCard, behind the site's - * hover gate, and calls `onEnter` and any `onLeave` as it comes and goes. Only a - * pointer that passes the gate counts, and a touch never does, even on a - * touch-screen laptop that passes the gate. Under reduced motion nothing - * counts. Returns whether the pointer is on the card now, which only the - * ends of a story's parts read, so it lives in a ref rather than in state. + * hover gate, and calls `onEnter` and any `onLeave` as it comes and goes, + * and any `onMove` with each move over the card, starting with the move + * the pointer enters on, so `onEnter` never comes before the pointer's + * first position. Only a pointer that passes the gate counts, and a + * touch never does, even on a touch-screen laptop that passes the gate. + * Under reduced motion, or while `enabled` is false, nothing counts. + * Returns whether the pointer is on the card now, which only the ends of a + * story's parts read, so it lives in a ref rather than in state. */ -function useCardPointer(onEnter: () => void, onLeave?: () => void): RefObject { +function useCardPointer( + onEnter: () => void, + onLeave?: () => void, + { enabled = true, onMove }: { enabled?: boolean; onMove?: (event: PointerEvent) => void } = {}, +): RefObject { const cardRef = useContext(CardContext); const canHover = useCanHover(); const reducedMotion = useReducedMotion() === true; const pointerOnCardRef = useRef(false); // The latest callbacks, so the listeners below call the current ones // without being added again on every render. - const callbacksRef = useRef({ onEnter, onLeave }); + const callbacksRef = useRef({ onEnter, onLeave, onMove }); useEffect(() => { - callbacksRef.current = { onEnter, onLeave }; + callbacksRef.current = { onEnter, onLeave, onMove }; }); // Listens to the card itself, so the whole card is the hover target, not @@ -76,26 +89,50 @@ function useCardPointer(onEnter: () => void, onLeave?: () => void): RefObject { const card = cardRef?.current; - if (!card || !canHover || reducedMotion) return undefined; + if (!card || !canHover || reducedMotion || !enabled) return undefined; + // Whether a pointer that was on the card as the gate opened still waits + // for its first move to enter (below). + let entersOnMove = false; const onPointerEnter = (event: PointerEvent) => { if (event.pointerType === 'touch') return; pointerOnCardRef.current = true; + callbacksRef.current.onMove?.(event); + callbacksRef.current.onEnter(); + }; + const onPointerMove = (event: PointerEvent) => { + if (event.pointerType === 'touch') return; + callbacksRef.current.onMove?.(event); + if (!entersOnMove) return; + entersOnMove = false; callbacksRef.current.onEnter(); }; const onPointerLeave = (event: PointerEvent) => { if (event.pointerType === 'touch') return; + entersOnMove = false; pointerOnCardRef.current = false; callbacksRef.current.onLeave?.(); }; card.addEventListener('pointerenter', onPointerEnter); + card.addEventListener('pointermove', onPointerMove); card.addEventListener('pointerleave', onPointerLeave); + // A pointer already on the card as the gate opens sends no + // pointerenter, so it counts as entering now. A story that follows the + // pointer has no position for it until it moves, so there it enters on + // its first move instead. + if (card.matches(':hover')) { + pointerOnCardRef.current = true; + if (callbacksRef.current.onMove) entersOnMove = true; + else callbacksRef.current.onEnter(); + } + // The gate closing counts as the pointer leaving, so a held story ends // rather than holding with no pointer to release it. return () => { card.removeEventListener('pointerenter', onPointerEnter); + card.removeEventListener('pointermove', onPointerMove); card.removeEventListener('pointerleave', onPointerLeave); if (pointerOnCardRef.current) { @@ -103,7 +140,7 @@ function useCardPointer(onEnter: () => void, onLeave?: () => void): RefObject void; + /** + * For an illustration whose scene has scroll push on: holds the story + * from the touch's first push, as a hover does, and ends it once the + * fling settles. Stable. Under reduced motion, or while the story isn't + * enabled, a push holds nothing. + */ + onPushStart: () => void; + onPushEnd: () => void; +} + +/** How an illustration with a held story takes part in it. */ +interface HeldStoryOptions { + /** + * False keeps the card on its still frame, ignoring the pointer and any + * push, as a card does until its live scene shows the still frame. + * Defaults to true. + */ + enabled?: boolean; + /** + * Called with each pointer move over the card, starting with the move the + * pointer enters on, for a story that follows the pointer. + */ + onPointerMove?: (event: PointerEvent) => void; + /** + * Pauses the homepage hero while the story plays and the card is in view, + * for a story with a live scene, as a playing favorite does. Off screen + * the card's scene pauses, so the hero can draw. Defaults to false. + */ + pausesHero?: boolean; +} + +/** Whether the illustration's StoryCard is on screen, which only a live story's hero pause reads. */ +function useCardInView(watching: boolean): boolean { + const cardRef = useContext(CardContext); + const [inView, setInView] = useState(false); + + useEffect(() => { + const card = cardRef?.current; + + if (!watching || !card) return undefined; + const observer = new IntersectionObserver(([entry]) => + setInView(entry?.isIntersecting ?? false), + ); + + observer.observe(card); + + return () => observer.disconnect(); + }, [cardRef, watching]); + + return watching && inView; } /** * The held story of the illustration that calls it, played by a hover on - * its StoryCard. Its ending runs to the still frame once it starts. A - * pointer that comes back during the ending lets it land, then a fresh play - * starts. + * its StoryCard or by a scroll push the illustration passes on. Its ending + * runs to the still frame once it starts. A pointer or a push that comes + * back during the ending lets it land, then a fresh play starts + * (story-state.ts has the rules). */ -export function useHeldStory(): HeldStory { - const [story, setStory] = useState<{ stage: HeldStage; plays: number }>({ - stage: 'still', - plays: 0, - }); +export function useHeldStory({ + enabled = true, + onPointerMove, + pausesHero = false, +}: HeldStoryOptions = {}): HeldStory { + const [story, dispatch] = useReducer(heldStoryReducer, STILL); + const reducedMotion = useReducedMotion() === true; + // Whether a push runs now, which only the ending's landing reads. + const pushingRef = useRef(false); const pointerOnCardRef = useCardPointer( - // Only the still frame starts the story. A hover during the ending waits - // for it to land. - () => - setStory((current) => - current.stage === 'still' ? { stage: 'holding', plays: current.plays + 1 } : current, - ), - // Leaving during the hold ends the story. - () => - setStory((current) => - current.stage === 'holding' ? { ...current, stage: 'ending' } : current, - ), + () => dispatch({ type: 'pointer-enter' }), + () => dispatch({ type: 'pointer-leave' }), + { enabled, onMove: onPointerMove }, ); + const canPush = enabled && !reducedMotion; + + const onPushStart = useCallback(() => { + if (!canPush) return; + pushingRef.current = true; + dispatch({ type: 'push-start' }); + }, [canPush]); + + const onPushEnd = useCallback(() => { + pushingRef.current = false; + dispatch({ type: 'push-end' }); + }, []); + + // Pushes stopping counts as the push ending, as the hover gate closing + // counts as the pointer leaving, so a push hold never outlives them. + useEffect( + () => () => { + if (!pushingRef.current) return; + pushingRef.current = false; + dispatch({ type: 'push-end' }); + }, + [canPush], + ); + + usePauseHeroWhile(useCardInView(pausesHero) && story.stage !== 'still'); // Stable, so an illustration can list it in an effect's deps without // restarting the play under way on every render. const onStoryEnd = useCallback(() => { - const replaying = pointerOnCardRef.current; - - setStory((current) => { - if (current.stage !== 'ending') return current; - - return replaying - ? { stage: 'holding', plays: current.plays + 1 } - : { ...current, stage: 'still' }; + dispatch({ + type: 'ending-landed', + pointerOnCard: pointerOnCardRef.current, + pushing: pushingRef.current, }); }, [pointerOnCardRef]); - return { ...story, onStoryEnd }; + return { ...story, onStoryEnd, onPushStart, onPushEnd }; } diff --git a/apps/docs/src/components/home-hero/hero-pause.test.ts b/apps/docs/src/components/home-hero/hero-pause.test.ts index 732b003f..b2e9ae5a 100644 --- a/apps/docs/src/components/home-hero/hero-pause.test.ts +++ b/apps/docs/src/components/home-hero/hero-pause.test.ts @@ -9,22 +9,48 @@ describe('createHeroPause', () => { it('pauses the hero, then lets it play again', () => { const heroPause = createHeroPause(); + const release = heroPause.pause(); - heroPause.setPaused(true); expect(heroPause.isPaused()).toBe(true); - heroPause.setPaused(false); + release(); expect(heroPause.isPaused()).toBe(false); }); + // A playing favorite and a feature card's story can overlap, and the + // first to stop must not resume the hero under the other. + it('keeps the hero paused until every pause is released', () => { + const heroPause = createHeroPause(); + const releaseFavorite = heroPause.pause(); + const releaseCard = heroPause.pause(); + + releaseFavorite(); + expect(heroPause.isPaused()).toBe(true); + releaseCard(); + expect(heroPause.isPaused()).toBe(false); + }); + + it('counts a release only once', () => { + const heroPause = createHeroPause(); + const releaseFavorite = heroPause.pause(); + + heroPause.pause(); + releaseFavorite(); + releaseFavorite(); + expect(heroPause.isPaused()).toBe(true); + }); + it('tells subscribers only when the hero pauses or resumes', () => { const heroPause = createHeroPause(); const onChange = vi.fn(); heroPause.subscribe(onChange); - heroPause.setPaused(true); - heroPause.setPaused(true); + const releaseFirst = heroPause.pause(); + const releaseSecond = heroPause.pause(); + + expect(onChange).toHaveBeenCalledTimes(1); + releaseFirst(); expect(onChange).toHaveBeenCalledTimes(1); - heroPause.setPaused(false); + releaseSecond(); expect(onChange).toHaveBeenCalledTimes(2); }); @@ -34,7 +60,7 @@ describe('createHeroPause', () => { const unsubscribe = heroPause.subscribe(onChange); unsubscribe(); - heroPause.setPaused(true); + heroPause.pause(); expect(onChange).not.toHaveBeenCalled(); }); }); diff --git a/apps/docs/src/components/home-hero/hero-pause.ts b/apps/docs/src/components/home-hero/hero-pause.ts index 82b9f9b0..68bfb772 100644 --- a/apps/docs/src/components/home-hero/hero-pause.ts +++ b/apps/docs/src/components/home-hero/hero-pause.ts @@ -2,13 +2,18 @@ * Lets what rides in the homepage hero's pin pause the hero's scene. A * playing favorite pauses it (components/favorites/favorites-list.tsx): at * 2x on an M1 Max, the hero and Voronoi, the most expensive favorite, took - * about 7 ms of GPU time a frame together, over SHA-203's 4 ms limit. + * about 7 ms of GPU time a frame together, over SHA-203's 4 ms limit. A + * feature card's live story pauses it too (components/features). */ -import { createContext, useContext, useSyncExternalStore } from 'react'; +import { createContext, useContext, useEffect, useSyncExternalStore } from 'react'; export interface HeroPause { - /** Pauses the hero's scene, or lets it play again. */ - setPaused: (paused: boolean) => void; + /** + * Pauses the hero's scene, and returns the release. The hero stays paused + * until every pause is released, so a favorite and a feature card's story + * that overlap never resume it under each other. A release counts once. + */ + pause: () => () => void; /** Calls `onChange` whenever the hero pauses or resumes, and returns the unsubscribe. */ subscribe: (onChange: () => void) => () => void; isPaused: () => boolean; @@ -16,21 +21,32 @@ export interface HeroPause { /** The hero creates one and reads it through useIsHeroPaused, so a pause re-renders only its scene. */ export function createHeroPause(): HeroPause { - let paused = false; + let pauses = 0; const listeners = new Set<() => void>(); + const notify = () => { + for (const listener of listeners) listener(); + }; return { - setPaused(next) { - if (next === paused) return; - paused = next; - for (const listener of listeners) listener(); + pause() { + let released = false; + + pauses += 1; + if (pauses === 1) notify(); + + return () => { + if (released) return; + released = true; + pauses -= 1; + if (pauses === 0) notify(); + }; }, subscribe(onChange) { listeners.add(onChange); return () => listeners.delete(onChange); }, - isPaused: () => paused, + isPaused: () => pauses > 0, }; } @@ -45,6 +61,13 @@ export function useIsHeroPaused(heroPause: HeroPause): boolean { /** The hero's pause, for what rides in its pin. Null outside the hero, where there is nothing to pause. */ export const HeroPauseContext = createContext(null); -export function useHeroPause() { +function useHeroPause() { return useContext(HeroPauseContext); } + +/** Pauses the hero while `paused` holds, from inside the hero's pin. */ +export function usePauseHeroWhile(paused: boolean) { + const heroPause = useHeroPause(); + + useEffect(() => (paused && heroPause ? heroPause.pause() : undefined), [paused, heroPause]); +} diff --git a/apps/docs/src/lib/use-has-webgpu.ts b/apps/docs/src/lib/use-has-webgpu.ts new file mode 100644 index 00000000..e17ffc26 --- /dev/null +++ b/apps/docs/src/lib/use-has-webgpu.ts @@ -0,0 +1,38 @@ +/** + * Whether the browser hands out a WebGPU adapter. The favorites and the + * Reactive feature card mount a live scene only where it does. Without an + * adapter, ShaderScene would fall back to WebGL2, and they show an image + * instead. + */ +import { useEffect, useState } from 'react'; + +/** + * True once the browser has handed out a WebGPU adapter. False on the + * server, until the adapter arrives, and while `enabled` is false, which + * skips the request. + */ +export function useHasWebGpu(enabled = true): boolean { + const [hasWebGpu, setHasWebGpu] = useState(false); + + useEffect(() => { + // navigator.gpu is typed as always present, but it is missing in a + // browser without WebGPU and outside a secure context, so probe with `in`. + if (!enabled || !('gpu' in navigator)) return; + + let cancelled = false; + + void navigator.gpu + .requestAdapter() + // A browser that refuses the request has no WebGPU to offer either. + .catch(() => null) + .then((adapter) => { + if (!cancelled) setHasWebGpu(adapter !== null); + }); + + return () => { + cancelled = true; + }; + }, [enabled]); + + return enabled && hasWebGpu; +} diff --git a/docs/agents/docs-site.md b/docs/agents/docs-site.md index 645ffd05..61ca66e4 100644 --- a/docs/agents/docs-site.md +++ b/docs/agents/docs-site.md @@ -45,6 +45,14 @@ These rules cover component defaults, demo palettes, and any gradient on the sit - Build depth from lightness, not hue. Keep at least 0.10 of OKLCH lightness between stops. - Keep backgrounds subtle and accents bold. Check text contrast at the lightest point and at the darkest point. +## Motion + +For motion and interaction polish, load the `design-engineering` skill first. The gotcha below is about Motion's API, not taste. + +### The time-defined-spring gotcha: a spring that follows a live value needs stiffness and damping + +`useSpring(source, options)` starts a fresh spring each time `source` changes. Motion drops the current speed of a spring defined by `visualDuration` or `duration`, so each new target starts the spring from rest. A spring that follows a value changing on every pointer event then stalls and restarts on each one, and the motion lags and stutters. With `visualDuration` 0.12, the Reactive card's arrow turned a sharp corner in about 220 ms, against 135 ms for the same spring set as `stiffness` 1900 and `damping` 87, which keeps its speed. A critically damped spring with `visualDuration` v works out to `stiffness` (2π / 1.2v)² and `damping` 2√stiffness, at `mass` 1. A spring that only runs to a fixed target can keep `visualDuration`. + ## Dev-only routes Name a debugging or probe page `page.dev.tsx`. The site is a static export, so every page the router sees lands in `out/`, and pagefind then indexes it for site search. `next.config.ts` adds `dev.tsx` and `dev.ts` to `pageExtensions` only when `INCLUDE_DEV_ROUTES=1`, so a plain `next build` never sees these pages. diff --git a/scripts/capture-reactive-still.mjs b/scripts/capture-reactive-still.mjs new file mode 100644 index 00000000..644fbd55 --- /dev/null +++ b/scripts/capture-reactive-still.mjs @@ -0,0 +1,62 @@ +// Captures the Reactive feature card's still frame (SHA-216) from its live +// scene, arrow and all, into the image the card shows until the scene lays +// its smudge, without WebGPU, and under reduced motion. Rerun it whenever the +// still frame changes: the gradient, the J, the smudge, or the arrow. +// +// pnpm dev:docs # in another terminal +// node scripts/capture-reactive-still.mjs +// +// It needs the docs dev server on http://localhost:3000, and a Mac, where +// Chromium draws WebGPU on the Metal adapter. SwiftShader's software WebGPU +// on Linux draws slightly different pixels. +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import sharp from 'sharp'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +// Playwright is a dependency of the docs tests, not of the repo root. +const require = createRequire(`${root}apps/docs-tests/package.json`); +const { chromium } = require('@playwright/test'); + +const OUTPUT = `${root}apps/docs/public/features/reactive.png`; +// The mock's 312 by 309 window at 2x, the size the image it replaces had. +const WIDTH = 624; +const HEIGHT = 618; + +const browser = await chromium.launch({ + channel: 'chromium', + args: ['--enable-unsafe-webgpu', '--ignore-gpu-blocklist', '--enable-gpu'], +}); + +try { + // Measure the window at 2x, then capture at a density that gives at least + // WIDTH pixels across it, so the image only ever scales down. + const measure = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + + await measure.goto('http://localhost:3000/', { waitUntil: 'networkidle' }); + const cssWidth = await measure + .locator('[data-scene]') + .first() + .evaluate((el) => el.clientWidth); + + await measure.close(); + + const page = await browser.newPage({ + viewport: { width: 1440, height: 900 }, + deviceScaleFactor: Math.max(2, WIDTH / cssWidth), + }); + + await page.goto('http://localhost:3000/', { waitUntil: 'networkidle' }); + const illustration = page.locator('[data-scene]').first(); + + await illustration.scrollIntoViewIfNeeded(); + await page.waitForSelector('[data-scene="laid"]', { timeout: 60_000 }); + // Let the frame after the smudge lands draw. + await page.waitForTimeout(300); + const capture = await illustration.screenshot(); + + await sharp(capture).resize(WIDTH, HEIGHT, { fit: 'fill' }).png().toFile(OUTPUT); + console.log(`Wrote ${OUTPUT}`); +} finally { + await browser.close(); +}