diff --git a/apps/docs-tests/docs/features-performant.spec.ts b/apps/docs-tests/docs/features-performant.spec.ts new file mode 100644 index 00000000..b34d9d04 --- /dev/null +++ b/apps/docs-tests/docs/features-performant.spec.ts @@ -0,0 +1,305 @@ +import { expect, test } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; + +/** + * The Performant feature card's story (SHA-215): a hover streams a band of + * work in under the chip and brings its glow up to full. The band keeps + * flowing while the pointer stays and runs dry once it leaves, back to the + * still frame with no work showing. A hover during that ending doesn't cut + * it short. Touch and reduced motion leave the still frame. Checks count the + * particles showing, watch them move, and read where the glow ends up, never + * how long anything takes or its curve, so tuning the motion cannot break + * this. + */ + +// No check here needs a scene, so this file turns WebGL off, as +// favorites-card-tab.spec.ts does. The hero keeps its poster rather than +// drawing full-width Aurora on CI's CPU renderer. A launch option forces a +// new browser, so it has to be set for the whole file. +test.use({ launchOptions: { args: ['--disable-webgl'] } }); + +const featuresList = (page: Page) => + page.getByRole('list', { name: 'We’ll handle the complex stuff' }); + +const performantCard = (page: Page) => + featuresList(page) + .getByRole('listitem') + .filter({ has: page.getByRole('heading', { name: 'Performant' }) }); + +// The illustration around the band of work the story streams. +const illustration = (card: Locator) => + card.locator('[aria-hidden="true"]').filter({ has: card.page().locator('[data-band]') }); + +// 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. +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' }); + }); +} + +/** Moves the pointer off the card, onto the site header. */ +async function leave(page: Page): Promise { + await page.mouse.move(0, 0); +} + +/** The band on one frame. */ +interface BandFrame { + /** How many particles show. */ + showing: number; + /** The band's particles' places, summed, which changes whenever one moves. */ + places: number; +} + +interface BandRecord { + /** Every frame so far, oldest first. */ + all: () => Promise; + /** The latest frame. */ + now: () => Promise; +} + +// Records the band on every frame from the moment this is called: how many +// of its particles show, and where they all are. +async function recordBand(card: Locator): Promise { + await card.evaluate((cardElement) => { + const frames: BandFrame[] = []; + const lanes = [...cardElement.querySelector('[data-band]')!.children]; + const measure = () => { + const styles = lanes.map((lane) => getComputedStyle(lane)); + + frames.push({ + showing: styles.filter((style) => Number(style.opacity) > 0).length, + places: styles.reduce((sum, style) => sum + new DOMMatrix(style.transform).m42, 0), + }); + requestAnimationFrame(measure); + }; + + (window as unknown as { bandFrames: BandFrame[] }).bandFrames = frames; + measure(); + }); + + const all = () => + card.page().evaluate(() => (window as unknown as { bandFrames: BandFrame[] }).bandFrames); + + return { all, now: async () => (await all()).at(-1)! }; +} + +/** How many particles the band holds once it has filled. */ +const bandSize = (card: Locator) => card.locator('[data-band] > *').count(); + +/** The chip's glow's strength, from 0, none, to 1, the mock's full glow. */ +const glowStrength = (card: Locator) => + card.locator('[data-halo]').evaluate((halo) => Number(getComputedStyle(halo).opacity)); + +/** + * A picture of the illustration, a pixel inside its box, so a box that falls + * between device pixels never pulls in the card around the window. + */ +async function screenshotIllustration(card: Locator): Promise { + const box = (await illustration(card).boundingBox())!; + + return card.page().screenshot({ + clip: { x: box.x + 1, y: box.y + 1, width: box.width - 2, height: box.height - 2 }, + }); +} + +/** Expects no particle to have shown the whole time. */ +function expectStillThroughout(frames: BandFrame[]): void { + expect(frames.length).toBeGreaterThan(0); + expect(frames.filter((frame) => frame.showing > 0)).toEqual([]); +} + +// --------------------------------------------- +// Hover +// --------------------------------------------- + +test('a hover plays the story, which runs dry after the pointer leaves', async ({ page }) => { + await open(page); + + const card = performantCard(page); + const stillFrame = await screenshotIllustration(card); + const band = await recordBand(card); + + expect((await band.now()).showing).toBe(0); + + await card.hover(); + await expect.poll(async () => (await band.now()).showing).toBeGreaterThan(0); + + // The band carries on after the pointer leaves, its particles still + // moving, then runs dry and stays empty. + const framesBeforeLeaving = (await band.all()).length; + + await leave(page); + await expect.poll(async () => (await band.now()).showing).toBe(0); + + const ending = (await band.all()).slice(framesBeforeLeaving); + const stillShowing = ending.filter((frame) => frame.showing > 0); + + expect(new Set(stillShowing.map((frame) => frame.places)).size).toBeGreaterThan(1); + + const afterTheStory = await recordBand(card); + + await page.waitForTimeout(1_000); + expectStillThroughout(await afterTheStory.all()); + expect(await screenshotIllustration(card)).toEqual(stillFrame); +}); + +// A restart would cut the drain short, emptying the band at once, or bring +// particles back into it while it drains. The check fills the band, leaves +// and comes straight back, then reads every frame from the leave: the band +// empties a few particles at a time and never refills until it has run +// dry, and the story plays again after that, because the pointer is on the +// card. +test('a hover during the ending lets the band run dry before it plays again', async ({ page }) => { + await open(page); + + const card = performantCard(page); + const band = await recordBand(card); + const filled = await bandSize(card); + + await card.hover(); + await expect.poll(async () => (await band.now()).showing).toBe(filled); + + const framesBeforeLeaving = (await band.all()).length; + + await leave(page); + await card.hover(); + // The pointer is back while the band still drains. + expect((await band.now()).showing).toBeGreaterThan(0); + + const ranDryThenPlayed = async () => { + const since = (await band.all()).slice(framesBeforeLeaving); + const dry = since.findIndex((frame) => frame.showing === 0); + + return dry >= 0 && since.slice(dry).some((frame) => frame.showing > 0); + }; + + await expect.poll(ranDryThenPlayed).toBe(true); + + const since = (await band.all()).slice(framesBeforeLeaving); + const draining = since.slice( + 0, + since.findIndex((frame) => frame.showing === 0), + ); + + expect(draining.some((frame) => frame.showing > 0 && frame.showing < filled)).toBe(true); + draining.forEach((frame, index) => { + expect(frame.showing).toBeLessThanOrEqual(draining[index - 1]?.showing ?? filled); + }); +}); + +test('the story keeps going while the pointer stays', async ({ page }) => { + await open(page); + + const card = performantCard(page); + const band = await recordBand(card); + + await card.hover(); + await expect.poll(async () => (await band.now()).showing).toBeGreaterThan(0); + await page.waitForTimeout(3_000); + + const before = await band.now(); + + await expect.poll(async () => (await band.now()).places).not.toBe(before.places); + expect((await band.now()).showing).toBeGreaterThan(0); +}); + +// The first test's final screenshot covers the glow settling back to the +// still frame's, so this one checks only that it comes up. +test('a hover brings the glow up to full while the pointer stays', async ({ page }) => { + await open(page); + + const card = performantCard(page); + + expect(await glowStrength(card)).toBeLessThan(1); + + await card.hover(); + await expect.poll(() => glowStrength(card)).toBe(1); +}); + +// --------------------------------------------- +// Touch +// --------------------------------------------- + +// A touch browser still fires mouse events around a tap, so the spec tries +// a hover as well as the tap itself. Nothing should happen, so there is +// nothing to wait on, and the spec gives the page a second, as +// favorites-card-tab.spec.ts does. +test.describe('on a touch device', () => { + test.use({ hasTouch: true, isMobile: true }); + + test('a hover or a tap leaves the card on its still frame', async ({ page }) => { + await open(page); + + const card = performantCard(page); + const stillFrame = await screenshotIllustration(card); + const band = await recordBand(card); + + await card.hover(); + await card.tap(); + await page.waitForTimeout(1_000); + + expectStillThroughout(await band.all()); + expect(await screenshotIllustration(card)).toEqual(stillFrame); + }); +}); + +// A touch-screen laptop passes the hover gate, because its primary pointer +// is the trackpad, so only the story's own check keeps a tap from playing +// it. Chromium's touch emulation fails the gate's query, so the spec answers +// it for the page. A mouse hover at the end proves the gate was open all +// along. +test.describe('on a touch-screen laptop', () => { + test.use({ hasTouch: true }); + + test('a tap leaves the card on its still frame, and a mouse hover plays it', async ({ page }) => { + await page.addInitScript(() => { + const matchMedia = window.matchMedia.bind(window); + + window.matchMedia = (query) => + matchMedia(query === '(hover: hover) and (pointer: fine)' ? 'all' : query); + }); + await open(page); + + const card = performantCard(page); + const band = await recordBand(card); + + await card.tap(); + await page.waitForTimeout(1_000); + expectStillThroughout(await band.all()); + + await card.hover(); + await expect.poll(async () => (await band.now()).showing).toBeGreaterThan(0); + }); +}); + +// --------------------------------------------- +// Reduced motion +// --------------------------------------------- + +test.describe('under reduced motion', () => { + test('a hover leaves the card on its still frame', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await open(page); + + const card = performantCard(page); + const stillFrame = await screenshotIllustration(card); + const band = await recordBand(card); + + await card.hover(); + await page.waitForTimeout(1_000); + + expectStillThroughout(await band.all()); + expect(await screenshotIllustration(card)).toEqual(stillFrame); + }); +}); diff --git a/apps/docs-tests/docs/homepage.spec.ts b/apps/docs-tests/docs/homepage.spec.ts index 52afa0c9..29dd0544 100644 --- a/apps/docs-tests/docs/homepage.spec.ts +++ b/apps/docs-tests/docs/homepage.spec.ts @@ -18,34 +18,35 @@ const FAVORITES = [ 'led-wall', ]; -// Each feature card's title, its description, and its illustration's URL, -// or null for an illustration built in code. +// Each feature card's title, its description, and its illustration: the +// URL of an image exported from the mock, or, for an illustration built in +// code, a piece of text it shows. interface FeatureCard { title: string; description: string; - illustration: string | null; + illustration: { src: string } | { text: string }; } const FEATURE_CARDS: FeatureCard[] = [ { title: 'Composable', description: 'Stack shaders in a single scene to build the exact effect you want.', - illustration: '/features/composable.png', + illustration: { src: '/features/composable.png' }, }, { title: 'Performant', description: 'Reduce browser overhead for your users with WebGPU rendering.', - illustration: '/features/performant.png', + illustration: { text: 'GPU' }, }, { title: 'Reactive', description: 'Bring shaders to life with animation, cursor, and scroll inputs.', - illustration: '/features/reactive.png', + illustration: { src: '/features/reactive.png' }, }, { title: 'Extensible', description: 'Write your own shaders with the same TSL primitives our components use.', - illustration: null, + illustration: { text: '' }, }, ]; @@ -269,12 +270,12 @@ test('each feature card shows its illustration, hidden from screen readers', asy await expect(item.getByRole('img')).toHaveCount(0); - // Extensible's code window: its header shows, inside a hidden subtree. - if (card.illustration === null) { - const header = item.getByText('', { exact: true }); + // An illustration built in code: its text shows, inside a hidden subtree. + if ('text' in card.illustration) { + const text = item.getByText(card.illustration.text, { exact: true }); - await expect(header).toBeVisible(); - expect(await header.evaluate((element) => !!element.closest('[aria-hidden="true"]'))).toBe( + await expect(text).toBeVisible(); + expect(await text.evaluate((element) => !!element.closest('[aria-hidden="true"]'))).toBe( true, ); continue; @@ -283,7 +284,7 @@ test('each feature card shows its illustration, hidden from screen readers', asy const image = item.locator('img'); await expect(image).toHaveAttribute('aria-hidden', 'true'); - await expect(image).toHaveAttribute('src', card.illustration); + await expect(image).toHaveAttribute('src', card.illustration.src); await expect .poll(() => image.evaluate((element: HTMLImageElement) => element.complete && element.naturalWidth > 0), diff --git a/apps/docs/public/features/performant.png b/apps/docs/public/features/performant.png deleted file mode 100644 index 92c7161d..00000000 Binary files a/apps/docs/public/features/performant.png and /dev/null differ diff --git a/apps/docs/src/components/features/features.tsx b/apps/docs/src/components/features/features.tsx index 0f13af7d..14a70727 100644 --- a/apps/docs/src/components/features/features.tsx +++ b/apps/docs/src/components/features/features.tsx @@ -11,6 +11,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 { StoryCard } from './story'; // The illustration's size in the mock, in CSS pixels. The exported files are @@ -35,13 +36,13 @@ function StaticIllustration({ src }: { src: string }) { ); } -// Each card's illustration. Extensible's is 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 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. const ILLUSTRATIONS: Record = { composable: , - performant: , + performant: , reactive: , extensible: , }; diff --git a/apps/docs/src/components/features/performant-illustration.module.css b/apps/docs/src/components/features/performant-illustration.module.css new file mode 100644 index 00000000..f2b6970e --- /dev/null +++ b/apps/docs/src/components/features/performant-illustration.module.css @@ -0,0 +1,181 @@ +/* The Performant illustration, after the Figma mock's node 939:962: its + GPU chip, alone and half again its size, centered in the window. 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. + The chip's own lengths are the mock's chip pixels times --chip-px, 1.5 + mock pixels, so the mock's 100px chip draws 150px wide. + + The pinks are the mock's: --glow lights the chip and the large + particles, and --glow-text, a deeper pink, the chip's name and the small + particles. */ +.illustration { + --mock-px: calc(100cqw / 312); + --chip-px: calc(1.5 * var(--mock-px)); + --glow: #fd73d5; + --glow-text: #fc40c6; + + position: relative; + display: grid; + place-items: center; + aspect-ratio: 312 / 309; +} + +/* ---- The chip ---- + 100 chip pixels wide: a 100px body with a row of pins above and below + it. The mock draws its CPU chip lit and its GPU chip gray, and this GPU + chip takes the lit look. + + Several layers blend with what lies beneath them, so nothing from the + chip up to the card's window may set opacity, a transform, or anything + else that would isolate them from the glow and the window's gray. */ +.chip { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + width: calc(100 * var(--chip-px)); +} + +/* Four pins, 6.67 by 8.89 with 8.89 between them, their outer ends + rounded: full white laid over the halo with an overlay blend, which + picks up its pink. */ +.pins { + display: flex; + gap: calc(8.889 * var(--chip-px)); +} + +.pin { + width: calc(6.667 * var(--chip-px)); + height: calc(8.889 * var(--chip-px)); + border-radius: calc(8.889 * var(--chip-px)) calc(8.889 * var(--chip-px)) 0 0; + background: var(--white); + mix-blend-mode: overlay; +} + +.pins[data-edge='bottom'] .pin { + border-radius: 0 0 calc(8.889 * var(--chip-px)) calc(8.889 * var(--chip-px)); +} + +/* The glow, in two layers on the body's square, under the top pins' + 8.89px. Both paint first, under the pins and the body. The fill is the + pink under the body, with the body's outer square, 20% white, over it. + The halo is the mock's pink glow around the chip, white close to the + edge. Its --halo, which the story sets, runs from a soft glow on the + still frame to the mock's full glow while the band of work flows. */ +.fill, +.halo { + position: absolute; + top: calc(8.889 * var(--chip-px)); + left: 0; + width: 100%; + aspect-ratio: 1; + border-radius: calc(8.889 * var(--chip-px)); +} + +.fill { + background: color-mix(in srgb, var(--glow), var(--white) 20%); +} + +.halo { + box-shadow: + 0 0 calc(50.4 * var(--chip-px)) color-mix(in srgb, var(--glow) 50%, transparent), + 0 0 calc(28.8 * var(--chip-px)) var(--glow), + 0 0 calc(16.8 * var(--chip-px)) var(--glow), + 0 0 calc(8.4 * var(--chip-px)) var(--white), + 0 0 calc(2.4 * var(--chip-px)) var(--white), + 0 0 calc(1.2 * var(--chip-px)) var(--white); + opacity: var(--halo, 1); +} + +/* The body, its outer square drawn by the fill beneath it. */ +.body { + position: relative; + width: 100%; + aspect-ratio: 1; + padding: calc(3.333 * var(--chip-px)); +} + +/* The middle square, 3.33px inside the outer one, at 20% white. */ +.ring { + height: 100%; + padding: calc(3.333 * var(--chip-px)); + border-radius: calc(5.556 * var(--chip-px)); + background: var(--white-a20); +} + +/* The center square, 3.33px inside the middle one, with the chip's name + centered in it: 40% white with a screen blend, which lightens the pink + under it toward white. The blend is a layer of its own, so it leaves the + name alone. */ +.core { + position: relative; + display: grid; + place-items: center; + height: 100%; +} + +.core::before { + position: absolute; + inset: 0; + border-radius: calc(2.222 * var(--chip-px)); + background: var(--white-a40); + content: ''; + mix-blend-mode: screen; +} + +/* Positioned, so it paints over the center square's blend layer. */ +.name { + position: relative; + color: var(--glow-text); + font-family: var(--font-mono); + font-size: calc(21.756 * var(--chip-px)); + font-weight: var(--font-weight-semibold); + line-height: 1; +} + +/* ---- The band of work ---- + A column of particles under the chip, as wide as the chip's middle + square, 56 chip pixels, from the tips of its bottom pins to the window's + bottom edge. The chip sits centered, so its pins end half its 117.78 + chip pixels of height below the window's middle. The band clips its own + particles, so one waiting at either end stays hidden without leaning on + the window's rounded clip, which some browsers skip for moving content. + The mask fades a particle out over the 16 mock pixels under the pins, so + it dissolves into the chip rather than stopping at a hard edge. */ +.band { + position: absolute; + top: calc(50% + 58.889 * var(--chip-px)); + bottom: 0; + left: calc(50% - 28 * var(--chip-px)); + width: calc(56 * var(--chip-px)); + overflow: clip; + mask-image: linear-gradient(to bottom, transparent, black calc(16 * var(--mock-px))); +} + +/* A particle's lane: a strip a particle's height taller than the band, + starting that height above it, so a particle at either end of its trip + sits wholly outside the band. The story moves it down by a share of the + lane's height. */ +.lane { + position: absolute; + top: calc(-8 * var(--mock-px)); + height: calc(100% + 8 * var(--mock-px)); +} + +.particle { + display: block; +} + +.particle[data-size='large'] { + width: calc(6 * var(--mock-px)); + height: calc(8 * var(--mock-px)); + background: var(--glow); +} + +.particle[data-size='small'] { + width: calc(4 * var(--mock-px)); + height: calc(5 * var(--mock-px)); + background: var(--glow-text); +} diff --git a/apps/docs/src/components/features/performant-illustration.tsx b/apps/docs/src/components/features/performant-illustration.tsx new file mode 100644 index 00000000..09f063f3 --- /dev/null +++ b/apps/docs/src/components/features/performant-illustration.tsx @@ -0,0 +1,376 @@ +'use client'; + +/** + * The Performant feature card's illustration: the Figma mock's GPU chip, + * alone, half again its size, and lit. The still frame shows the chip + * softly lit, with no work under it. Its story (SHA-215) streams a band of + * work into and out of the chip's pins and brings the glow up while the + * pointer stays, then lets the band run dry and the glow settle. story.tsx + * says when each part plays. + */ +import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'; + +import { + cancelFrame, + cubicBezier, + domMin, + frame, + LazyMotion, + m, + type MotionStyle, + type MotionValue, + motionValue, + useMotionValue, + useTransform, +} from 'motion/react'; + +import { EASE_OUT } from '@/lib/easing'; + +import styles from './performant-illustration.module.css'; +import { type HeldStage, useHeldStory } from './story'; + +// --------------------------------------------- +// The chip +// --------------------------------------------- + +// The four pins along the chip's top or bottom edge. +function Pins({ edge }: { edge: 'top' | 'bottom' }) { + return ( +
+ + + + +
+ ); +} + +// The halo's strength, from 0, none, to 1, the mock's full glow. +// performant-illustration.module.css fades the halo by it. +type HaloStyle = MotionStyle & { '--halo': MotionValue }; + +// The chip: its body, three nested squares that step lighter toward the +// center, with its name in the middle and four pins above and below. The +// glow's two layers come first, so both rows of pins paint over the halo +// and pick up its color. +function Chip({ halo }: { halo: MotionValue }) { + const style: HaloStyle = { '--halo': halo }; + + return ( + + + + +
+
+
+ GPU +
+
+
+ +
+ ); +} + +// --------------------------------------------- +// The work +// --------------------------------------------- + +/** A particle in the band of work under the chip. */ +interface Particle { + /** Names the particle, for its React key. */ + id: number; + /** The particle's left edge, in percent of the band's width. */ + left: number; + /** + * A large particle is the mock's 6 by 8 in the glow's pink, and a small + * one the mock's 4 by 5 in the deeper pink. + */ + size: 'large' | 'small'; + /** How strongly the particle shows, from 0 to 1. */ + opacity: number; + /** An `up` particle rises into the chip, and a `down` one falls out of it. */ + direction: 'up' | 'down'; + /** Trips across the band per second. */ + speed: number; + /** Seconds from the hover until the particle first enters the band. */ + delay: number; +} + +// How many particles the band holds once it has filled. More crowd it. +const PARTICLE_COUNT = 30; + +// How long the band takes to fill, in seconds: the last particle enters by +// then. Longer streams the work in more gradually. +const FILL_SECONDS = 0.8; + +// The share of particles that rise into the chip. The rest fall out of it. +const RISING_SHARE = 0.6; + +// The share of particles that are the mock's large ones, in the paler pink. +// Higher makes the band heavier and paler, lower finer and deeper pink. +const LARGE_SHARE = 0.4; + +// The strengths a particle shows at, picked at random, so the band layers +// like the reference: some bright, some faint. Fainter strengths give the +// band more depth and less punch. +const OPACITIES = [1, 0.6, 0.35] as const; + +// The range of particle speeds, in trips per second. A trip runs the +// particle's lane, from one end to the other: the band's height plus a +// particle's, about 74 mock pixels. Faster speeds make the band busier and +// shorten the drain, which takes one trip of the slowest particle at most. +const SLOWEST_SPEED = 0.9; +const FASTEST_SPEED = 1.8; + +// The band is 84 mock pixels wide and a particle at most 6, so a particle's +// left edge stays within the first 78. +const LANE_PERCENT = (78 / 84) * 100; + +/** + * A seeded random number generator (mulberry32), so the particles land in + * the same places on every render, on the server and in the browser. + */ +function seededRandom(seed: number): () => number { + let state = seed; + + return () => { + state = (state + 0x6d2b79f5) | 0; + let mixed = Math.imul(state ^ (state >>> 15), 1 | state); + + mixed = (mixed + Math.imul(mixed ^ (mixed >>> 7), 61 | mixed)) ^ mixed; + + return ((mixed ^ (mixed >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * The band's particles, scattered at random from `seed`. Their entries + * spread evenly across the fill, each at a random moment within its share, + * so the band fills steadily rather than in bursts. + */ +function scatterParticles(seed: number): readonly Particle[] { + const random = seededRandom(seed); + + return Array.from({ length: PARTICLE_COUNT }, (_, id) => ({ + id, + left: random() * LANE_PERCENT, + size: random() < LARGE_SHARE ? 'large' : 'small', + opacity: OPACITIES[Math.floor(random() * OPACITIES.length)] ?? 1, + direction: random() < RISING_SHARE ? 'up' : 'down', + speed: SLOWEST_SPEED + random() * (FASTEST_SPEED - SLOWEST_SPEED), + delay: (FILL_SECONDS * (id + random())) / PARTICLE_COUNT, + })); +} + +// Any fixed seed works, and 215, the ticket's number, is as good as any. +// Another seed reshuffles the band. +const PARTICLES = scatterParticles(215); + +/** + * Where a particle is down its lane, from 0 at the top to 1 at the bottom, + * once it has run `trips` trips. A trip starts at the particle's entry end, + * just outside the band: under the window's edge for a particle that rises, + * and above the band, under the chip's pins, for one that falls. A trip + * that runs off one end starts again at the other, so a whole number of + * trips puts the particle back at its entry end. + */ +function bandPosition(direction: Particle['direction'], trips: number): number { + const progress = trips - Math.floor(trips); + + return direction === 'up' ? 1 - progress : progress; +} + +/** + * A particle on its lane: a strip a little taller than the band, which the + * particle's trips move down by a share of its own height, so a step is the + * same share of the band at every card width. The particle shows only while + * it is on a trip. Before its first, and once the ending stops it at the end + * of one, it waits at its entry end: just outside the band, and transparent + * besides, because a clip at the band's edge can let a pixel's sliver + * through. + */ +function WorkParticle({ particle, trips }: { particle: Particle; trips: MotionValue }) { + const y = useTransform(trips, (run) => `${bandPosition(particle.direction, run) * 100}%`); + const opacity = useTransform(trips, (run) => (run > 0 && run % 1 !== 0 ? particle.opacity : 0)); + const laneStyle: CSSProperties = { left: `${particle.left}%` }; + + return ( + + + + ); +} + +// --------------------------------------------- +// The story +// --------------------------------------------- + +// From the motion brief on SHA-215. Every particle moves at a constant +// speed, so only the glow eases: with the site's --ease-out, as anything +// warming or cooling does. + +// The halo's strength on the still frame, from 0, none, to 1, the mock's +// full glow, which it rises to as the band fills. Lower makes the hover's +// lift bigger. +const STILL_FRAME_HALO = 0.4; + +// The shortest the halo takes to settle back once the pointer leaves, in +// seconds. It settles over the band's drain, so it lands as the last +// particle leaves, but takes at least this long when the drain is quicker. +const SHORTEST_SETTLE_SECONDS = 0.4; + +const easeOut = cubicBezier(...EASE_OUT); + +/** A particle and how many trips it has run this play. */ +interface BandParticle { + particle: Particle; + trips: MotionValue; +} + +/** + * Runs the band's flow and the glow for one play, from the hover until the + * band runs dry. Each particle enters after its delay and loops at its own + * speed, while the halo rises to full over the band's fill. Once the + * story's ending starts, nothing new enters: each particle in the band + * finishes the trip it is on, one still waiting never enters, and the halo + * settles back over the time the last trip takes. When both are done, the + * band and the glow are back on the still frame and the story ends. + */ +function useBandFlow( + band: readonly BandParticle[], + halo: MotionValue, + stage: HeldStage, + plays: number, + onStoryEnd: () => void, +) { + // The stage as the frame loop sees it, kept current without restarting + // the loop. A layout effect updates it before the next frame runs, so the + // ending starts on the first frame after the leave renders, before a + // particle still waiting can enter. + const stageRef = useRef(stage); + + useLayoutEffect(() => { + stageRef.current = stage; + }); + + const playing = stage !== 'still'; + + // One loop per play, keyed by the play's count, so a play that follows + // straight on from another's ending starts the band from empty again. + useEffect(() => { + if (!playing) return undefined; + + let seconds = 0; + // Set once the ending starts: where each particle stops, in trips, and + // how the halo settles from where it was. + let ending: + | { stops: number[]; startSeconds: number; fromHalo: number; settleSeconds: number } + | undefined; + const advance = ({ delta }: { delta: number }) => { + seconds += delta / 1000; + + const runs = band.map(({ particle }) => + Math.max(0, particle.speed * (seconds - particle.delay)), + ); + + if (stageRef.current === 'ending' && !ending) { + // Each stop comes from where its particle last showed, before this + // frame's advance, so one whose delay ran out since then never enters. + const stops = band.map(({ trips }) => { + const shown = trips.get(); + + return shown > 0 ? Math.floor(shown) + 1 : 0; + }); + const drainSeconds = Math.max( + 0, + ...band.map( + ({ particle }, index) => ((stops[index] ?? 0) - (runs[index] ?? 0)) / particle.speed, + ), + ); + + ending = { + stops, + startSeconds: seconds, + fromHalo: halo.get(), + settleSeconds: Math.max(drainSeconds, SHORTEST_SETTLE_SECONDS), + }; + } + + // Whether any particle is still on the move, which only the ending's + // stops can end. + let moving = false; + + for (const [index, { trips }] of band.entries()) { + const run = runs[index] ?? 0; + const stop = ending?.stops[index] ?? Infinity; + + trips.set(Math.min(run, stop)); + if (run < stop) moving = true; + } + + if (!ending) { + halo.set( + STILL_FRAME_HALO + (1 - STILL_FRAME_HALO) * easeOut(Math.min(1, seconds / FILL_SECONDS)), + ); + + return; + } + + const settled = Math.min(1, (seconds - ending.startSeconds) / ending.settleSeconds); + + halo.set(ending.fromHalo + (STILL_FRAME_HALO - ending.fromHalo) * easeOut(settled)); + + if (!moving && settled === 1) { + cancelFrame(advance); + onStoryEnd(); + } + }; + + frame.update(advance, true); + + return () => { + cancelFrame(advance); + band.forEach(({ trips }) => trips.set(0)); + halo.set(STILL_FRAME_HALO); + }; + }, [playing, plays, band, halo, onStoryEnd]); +} + +// --------------------------------------------- +// The illustration +// --------------------------------------------- + +// Hidden from screen readers: the card's title and description carry its +// message. `data-band` marks the band of work, and `data-halo` the chip's +// glow, for the Playwright spec. +// +// `m` under LazyMotion rather than `motion`, as in the Extensible card: the +// particles and the chip only bind motion values to style, which domMin +// covers. +export function PerformantIllustration() { + const { stage, plays, onStoryEnd } = useHeldStory(); + // How many trips each particle has run this play. At 0 it waits at its + // entry end, out of sight, which is where every particle sits on the + // still frame. + const [band] = useState(() => + PARTICLES.map((particle) => ({ particle, trips: motionValue(0) })), + ); + const halo = useMotionValue(STILL_FRAME_HALO); + + useBandFlow(band, halo, stage, plays, onStoryEnd); + + return ( +
+ +
+ {band.map(({ particle, trips }) => ( + + ))} +
+ +
+
+ ); +} diff --git a/apps/docs/src/components/features/story.tsx b/apps/docs/src/components/features/story.tsx index b08ec519..fa713741 100644 --- a/apps/docs/src/components/features/story.tsx +++ b/apps/docs/src/components/features/story.tsx @@ -2,14 +2,17 @@ /** * The lifecycle every feature card's story shares (SHA-178): a hover plays - * the story, which keeps going while the pointer stays and finishes the play - * under way once it leaves. Touch, keyboard, and reduced motion leave the card - * on its still frame. StoryCard is the card, and useStory the illustration's side. + * 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. + * 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. */ import { createContext, type ReactNode, type RefObject, + useCallback, useContext, useEffect, useRef, @@ -44,37 +47,29 @@ export function StoryCard({ className, children }: { className?: string; childre } // --------------------------------------------- -// The story +// The pointer // --------------------------------------------- -/** Where an illustration's story is: playing or not, which play, and how to end one. */ -export interface Story { - /** True from the hover that starts the story until a play ends with the pointer off the card. */ - playing: boolean; - /** - * Counts the story's plays: one more each time a play starts, including a - * play that follows another because the pointer stayed. An illustration - * keys its animation by it, so every play starts from the still frame. - */ - plays: number; - /** Called by the illustration each time a play lands back on the still frame. */ - onStoryEnd: () => void; -} - /** - * The story of the illustration that calls it, played by a hover on its - * StoryCard. Only a pointer that passes the site's hover gate starts it, - * and a touch never does, even on a touch-screen laptop that passes the - * gate. Under reduced motion nothing starts it. + * 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. */ -export function useStory(): Story { +function useCardPointer(onEnter: () => void, onLeave?: () => void): RefObject { const cardRef = useContext(CardContext); const canHover = useCanHover(); const reducedMotion = useReducedMotion() === true; - const [story, setStory] = useState({ playing: false, plays: 0 }); - // Whether the pointer is on the card. Only the end of a play reads it, so - // it lives in a ref rather than in state. 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 }); + + useEffect(() => { + callbacksRef.current = { onEnter, onLeave }; + }); // Listens to the card itself, so the whole card is the hover target, not // just the illustration. @@ -86,25 +81,66 @@ export function useStory(): Story { const onPointerEnter = (event: PointerEvent) => { if (event.pointerType === 'touch') return; pointerOnCardRef.current = true; - // A play under way carries on, so a hover during it doesn't restart it. - setStory((current) => - current.playing ? current : { playing: true, plays: current.plays + 1 }, - ); + callbacksRef.current.onEnter(); }; const onPointerLeave = (event: PointerEvent) => { - if (event.pointerType !== 'touch') pointerOnCardRef.current = false; + if (event.pointerType === 'touch') return; + pointerOnCardRef.current = false; + callbacksRef.current.onLeave?.(); }; card.addEventListener('pointerenter', onPointerEnter); card.addEventListener('pointerleave', onPointerLeave); + // The gate closing counts as the pointer leaving, so a held story ends + // rather than holding with no pointer to release it. return () => { - pointerOnCardRef.current = false; card.removeEventListener('pointerenter', onPointerEnter); card.removeEventListener('pointerleave', onPointerLeave); + + if (pointerOnCardRef.current) { + pointerOnCardRef.current = false; + callbacksRef.current.onLeave?.(); + } }; }, [cardRef, canHover, reducedMotion]); + return pointerOnCardRef; +} + +// --------------------------------------------- +// A story that repeats +// --------------------------------------------- + +/** Where an illustration's story is: playing or not, which play, and how to end one. */ +export interface Story { + /** True from the hover that starts the story until a play ends with the pointer off the card. */ + playing: boolean; + /** + * Counts the story's plays: one more each time a play starts, including a + * play that follows another because the pointer stayed. An illustration + * keys its animation by it, so every play starts from the still frame. + */ + plays: number; + /** Called by the illustration each time a play lands back on the still frame. */ + onStoryEnd: () => void; +} + +/** + * The story of the illustration that calls it, played by a hover on its + * StoryCard. A play that ends with the pointer still on the card starts the + * next one, so the story keeps going while the pointer stays. + */ +export function useStory(): Story { + const [story, setStory] = useState({ playing: false, plays: 0 }); + const pointerOnCardRef = useCardPointer( + // A play under way carries on, so a hover during it doesn't restart it. + () => + setStory((current) => + current.playing ? current : { playing: true, plays: current.plays + 1 }, + ), + ); + // A play that ends with the pointer still on the card starts the next one. // Otherwise the card rests on its still frame. const onStoryEnd = () => { @@ -117,3 +153,68 @@ export function useStory(): Story { return { ...story, onStoryEnd }; } + +// --------------------------------------------- +// A story that holds +// --------------------------------------------- + +/** + * Where a held story is. It rests on the still frame, holds from a hover + * for as long as the pointer stays, and plays its ending back to the still + * frame once the pointer leaves. + */ +export type HeldStage = 'still' | 'holding' | 'ending'; + +/** Where an illustration's held story is, which play, and how to end it. */ +export interface HeldStory { + stage: HeldStage; + /** + * Counts the story's plays. An illustration keys its animation by it, so + * every play starts from the still frame. + */ + plays: number; + /** Called by the illustration when its ending lands on the still frame. */ + onStoryEnd: () => void; +} + +/** + * 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. + */ +export function useHeldStory(): HeldStory { + const [story, setStory] = useState<{ stage: HeldStage; plays: number }>({ + stage: 'still', + plays: 0, + }); + 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, + ), + ); + + // 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' }; + }); + }, [pointerOnCardRef]); + + return { ...story, onStoryEnd }; +}