diff --git a/apps/docs-tests/docs/features-extensible.spec.ts b/apps/docs-tests/docs/features-extensible.spec.ts new file mode 100644 index 00000000..4f884317 --- /dev/null +++ b/apps/docs-tests/docs/features-extensible.spec.ts @@ -0,0 +1,259 @@ +import { expect, test } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; + +/** + * The Extensible feature card's story (SHA-214): a hover plays it, it keeps + * going while the pointer stays, the play under way finishes after the pointer + * leaves, and a second hover doesn't restart it. Touch and reduced motion + * leave the still frame. Checks read how far the code has scrolled, never + * how long a step 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 extensibleCard = (page: Page) => + featuresList(page) + .getByRole('listitem') + .filter({ has: page.getByRole('heading', { name: 'Extensible' }) }); + +// The illustration around the column of code the story scrolls. +const illustration = (card: Locator) => + card.locator('[aria-hidden="true"]').filter({ has: card.page().locator('[data-lines]') }); + +// 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); +} + +interface ScrollRecord { + /** Every measurement so far, oldest first. */ + all: () => Promise; + /** The latest measurement. */ + now: () => Promise; +} + +// Measures how far the code has scrolled on every frame from the moment this +// is called, in lines: 0 at the still frame, and one more for each step up. +// Each play mounts a fresh column, so every frame looks the column up again. +async function recordScroll(card: Locator): Promise { + await card.evaluate((cardElement) => { + const lines: number[] = []; + const measure = () => { + const column = cardElement.querySelector('[data-lines]')!; + const lineHeight = column.firstElementChild!.getBoundingClientRect().height; + + lines.push(-new DOMMatrix(getComputedStyle(column).transform).m42 / lineHeight); + requestAnimationFrame(measure); + }; + + (window as unknown as { scrolledLines: number[] }).scrolledLines = lines; + measure(); + }); + + const all = () => + card.page().evaluate(() => (window as unknown as { scrolledLines: number[] }).scrolledLines); + + return { all, now: async () => (await all()).at(-1)! }; +} + +/** + * 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 the code to have stayed on the still frame the whole time. */ +function expectStillThroughout(lines: number[]): void { + expect(lines.length).toBeGreaterThan(0); + expect(lines.filter((scrolled) => scrolled !== 0)).toEqual([]); +} + +// --------------------------------------------- +// Hover +// --------------------------------------------- + +test('a hover plays the story, which finishes after the pointer leaves', async ({ page }) => { + await open(page); + + const card = extensibleCard(page); + const stillFrame = await screenshotIllustration(card); + const scroll = await recordScroll(card); + + await card.hover(); + await expect.poll(scroll.now).toBeGreaterThan(0.5); + + // The play under way carries on after the pointer leaves, then lands back + // on the still frame and stays there. + await leave(page); + + const whenLeft = await scroll.now(); + + await expect.poll(scroll.now).toBeGreaterThan(whenLeft); + await expect.poll(scroll.now).toBeCloseTo(0); + + const afterTheStory = await recordScroll(card); + + await page.waitForTimeout(1_000); + expectStillThroughout(await afterTheStory.all()); + expect(await screenshotIllustration(card)).toEqual(stillFrame); +}); + +// A restart would put the code back at the still frame mid-play. The check +// takes the scroll just before the pointer leaves, then watches every frame +// for a moment after it comes back, all well inside one play, for any frame +// that has gone back toward the still frame. +test('a hover during a play does not restart it', async ({ page }) => { + await open(page); + + const card = extensibleCard(page); + const scroll = await recordScroll(card); + + await card.hover(); + await expect.poll(scroll.now, { intervals: [50] }).toBeGreaterThan(1.5); + + const beforeLeaving = await scroll.now(); + const framesBeforeLeaving = (await scroll.all()).length; + + await leave(page); + await card.hover(); + // A few frames for a restart to show. + await page.waitForTimeout(200); + + const framesSince = (await scroll.all()).slice(framesBeforeLeaving); + + expect(Math.min(...framesSince)).toBeGreaterThanOrEqual(beforeLeaving); +}); + +// A second play shows as the code back at the still frame after it had +// scrolled, then scrolling again, all with the pointer still on the card. +test('the story keeps going while the pointer stays', async ({ page }) => { + await open(page); + + const card = extensibleCard(page); + const scroll = await recordScroll(card); + + await card.hover(); + + await expect + .poll( + async () => { + const lines = await scroll.all(); + const scrolled = lines.findIndex((scrolledLines) => scrolledLines > 0.5); + const backAtStillFrame = lines.findIndex( + (scrolledLines, index) => index > scrolled && scrolledLines < 0.05, + ); + + return ( + scrolled >= 0 && + backAtStillFrame >= 0 && + lines.some((scrolledLines, index) => index > backAtStillFrame && scrolledLines > 0.5) + ); + }, + { timeout: 10_000 }, + ) + .toBe(true); +}); + +// --------------------------------------------- +// 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 = extensibleCard(page); + const scroll = await recordScroll(card); + + await card.hover(); + await card.tap(); + await page.waitForTimeout(1_000); + + expectStillThroughout(await scroll.all()); + }); +}); + +// 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 = extensibleCard(page); + const scroll = await recordScroll(card); + + await card.tap(); + await page.waitForTimeout(1_000); + expectStillThroughout(await scroll.all()); + + await card.hover(); + await expect.poll(scroll.now).toBeGreaterThan(0.5); + }); +}); + +// --------------------------------------------- +// 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 = extensibleCard(page); + const scroll = await recordScroll(card); + + await card.hover(); + await page.waitForTimeout(1_000); + + expectStillThroughout(await scroll.all()); + }); +}); diff --git a/apps/docs-tests/docs/homepage.spec.ts b/apps/docs-tests/docs/homepage.spec.ts index 25b79d91..52afa0c9 100644 --- a/apps/docs-tests/docs/homepage.spec.ts +++ b/apps/docs-tests/docs/homepage.spec.ts @@ -18,8 +18,15 @@ const FAVORITES = [ 'led-wall', ]; -// Each feature card's title, its description, and its illustration's URL. -const FEATURE_CARDS = [ +// Each feature card's title, its description, and its illustration's URL, +// or null for an illustration built in code. +interface FeatureCard { + title: string; + description: string; + illustration: string | null; +} + +const FEATURE_CARDS: FeatureCard[] = [ { title: 'Composable', description: 'Stack shaders in a single scene to build the exact effect you want.', @@ -38,7 +45,7 @@ const FEATURE_CARDS = [ { title: 'Extensible', description: 'Write your own shaders with the same TSL primitives our components use.', - illustration: '/features/extensible.png', + illustration: null, }, ]; @@ -259,9 +266,22 @@ test('each feature card shows its illustration, hidden from screen readers', asy for (const [index, card] of FEATURE_CARDS.entries()) { const item = featureCards(page).nth(index); - const image = item.locator('img'); 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 }); + + await expect(header).toBeVisible(); + expect(await header.evaluate((element) => !!element.closest('[aria-hidden="true"]'))).toBe( + true, + ); + continue; + } + + const image = item.locator('img'); + await expect(image).toHaveAttribute('aria-hidden', 'true'); await expect(image).toHaveAttribute('src', card.illustration); await expect diff --git a/apps/docs/public/features/extensible.png b/apps/docs/public/features/extensible.png deleted file mode 100644 index 018fb1ac..00000000 Binary files a/apps/docs/public/features/extensible.png and /dev/null differ diff --git a/apps/docs/src/components/favorites/card-tab.tsx b/apps/docs/src/components/favorites/card-tab.tsx index 474f4731..445892f1 100644 --- a/apps/docs/src/components/favorites/card-tab.tsx +++ b/apps/docs/src/components/favorites/card-tab.tsx @@ -12,6 +12,8 @@ // focus. import { domMin, LazyMotion, m, useReducedMotion, type Variants } from 'motion/react'; +import { EASE_OUT } from '@/lib/easing'; + import styles from './favorites.module.css'; // --------------------------------------------- @@ -41,10 +43,8 @@ const CARD_TAB_PATH = 'M24 168C24 145.417 0 123.022 0 83.98C0 44.937 24 22.542 2 // back rather than finish its slide first. // // Every curve is the site's --ease-out (tokens.css): fast off the mark, -// with a long settle. Durations are in seconds, as Motion takes them. - -/** The site's --ease-out, as Motion takes a cubic-bezier. */ -const EASE_OUT = [0.32, 0.72, 0, 1] as const; +// with a long settle (lib/easing.ts). Durations are in seconds, as Motion +// takes them. // How long the strip takes to slide in, as --duration-md does. Longer reads // heavier. The curve's steep start shows most of the strip within the diff --git a/apps/docs/src/components/favorites/favorites-list.tsx b/apps/docs/src/components/favorites/favorites-list.tsx index caa823db..4d1aac31 100644 --- a/apps/docs/src/components/favorites/favorites-list.tsx +++ b/apps/docs/src/components/favorites/favorites-list.tsx @@ -10,19 +10,13 @@ */ import Image from 'next/image'; import Link from 'next/link'; -import { - type CSSProperties, - type RefObject, - useEffect, - useRef, - useState, - useSyncExternalStore, -} from 'react'; +import { type CSSProperties, type RefObject, useEffect, useRef, useState } from 'react'; import { ShaderPoster } from '@camp-dev/shaders/poster'; import { useHeroPause } from '@/components/home-hero/hero-pause'; import type { Favorite, FavoriteSlug } from '@/content/homepage'; +import { useCanHover } from '@/lib/use-can-hover'; import { CardTab } from './card-tab'; import { favoriteSceneBackdrop, preloadFavoriteScenes, useFavoriteScene } from './favorite-scenes'; @@ -328,36 +322,16 @@ function usePauseHeroWhilePlaying(playing: boolean) { // The gate: a mouse or trackpad, and a WebGPU adapter // --------------------------------------------- -// The site's hover gate, the same query the stylesheets put hover styles -// behind. A phone or tablet fails it, so its favorites stay posters. -const HOVER_QUERY = '(hover: hover) and (pointer: fine)'; - -function subscribeToHoverQuery(onChange: () => void): () => void { - const query = window.matchMedia(HOVER_QUERY); - - query.addEventListener('change', onChange); - - return () => query.removeEventListener('change', onChange); -} - -const readHoverQuery = () => window.matchMedia(HOVER_QUERY).matches; - -// The server has no pointer to ask about, and the posters are the safe -// answer, so it renders every favorite at rest. -const readServerHoverQuery = () => false; - /** - * True where a favorite may mount its scene: the device can hover, and the - * browser hands out a WebGPU adapter. Without one, ShaderScene would fall - * back to WebGL2, and the favorites stay posters instead. Only a device that - * can hover asks for the adapter. + * 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. */ function useCanGoLive(): boolean { - const canHover = useSyncExternalStore( - subscribeToHoverQuery, - readHoverQuery, - readServerHoverQuery, - ); + const canHover = useCanHover(); const [hasWebGpu, setHasWebGpu] = useState(false); useEffect(() => { diff --git a/apps/docs/src/components/features/extensible-illustration.module.css b/apps/docs/src/components/features/extensible-illustration.module.css new file mode 100644 index 00000000..e2688c68 --- /dev/null +++ b/apps/docs/src/components/features/extensible-illustration.module.css @@ -0,0 +1,107 @@ +/* The Extensible illustration, after the Figma mock's node 939:966. 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); + + display: grid; + place-items: center; + aspect-ratio: 312 / 309; +} + +/* The code window's frame: the mock's 220 by 143.7 outline, with the code + block 5px inside it on each side and centered top to bottom. */ +.codeWindow { + position: relative; + display: flex; + align-items: center; + width: calc(220 * var(--mock-px)); + height: calc(143.673 * var(--mock-px)); + padding-inline: calc(5 * var(--mock-px)); +} + +/* The frame's color is the mock's: 90% white laid over the window's gray + with an overlay blend, which lifts the gray a few steps. */ +.codeWindow::before { + position: absolute; + inset: 0; + border-radius: calc(10.776 * var(--mock-px)); + background: var(--white-a90); + content: ''; + mix-blend-mode: overlay; +} + +/* The code block, 10px inside on three sides. The mock's ten lines run + past its bottom padding, so the last one ends just inside the bottom + edge, and the block has none. Positioned, so it paints over the frame's + ::before. */ +.code { + position: relative; + display: flex; + flex: 1; + flex-direction: column; + gap: calc(10 * var(--mock-px)); + height: calc(133 * var(--mock-px)); + padding: calc(10 * var(--mock-px)) calc(10 * var(--mock-px)) 0; + overflow: clip; + border-radius: calc(5.388 * var(--mock-px)); + background: var(--black); +} + +/* The mock trims the header's line box to its capitals, which leaves it 8px + tall, so the lines start 10px below the capitals' baseline. A box that + tall, with the line height to match, puts the text there without + text-box-trim, which Firefox lacks. */ +.header { + flex: none; + height: calc(8 * var(--mock-px)); + color: var(--white); + font-family: var(--font-mono); + font-size: calc(12 * var(--mock-px)); + line-height: calc(8 * var(--mock-px)); + white-space: nowrap; +} + +/* The window the lines scroll through, from 10px under the header to the + code block's bottom edge. A line the story scrolls up past its top edge + is cut off there, before it reaches the header. Its min-height lets it + end at the code block's edge rather than grow to hold the whole column: + `overflow: clip`, unlike a scroll container, keeps a flex item's + automatic minimum. The mock's fade over the bottom edge is left out, at + the author's call. */ +.viewport { + flex: 1; + min-height: 0; + overflow: clip; +} + +/* Every line the story passes through, in a 190px column. The still + frame's nine and an empty tenth slot fill the viewport, and the seven the + story types wait below. */ +.column { + width: calc(190 * var(--mock-px)); +} + +/* Each line takes 11px: its 5px bars, then the mock's 6px gap below them, + so the story's step of one line is one line's height. */ +.line { + display: flex; + gap: calc(8 * var(--mock-px)); + height: calc(11 * var(--mock-px)); +} + +.line[data-align='end'] { + justify-content: flex-end; +} + +/* A bar grows up from its baseline in the story, so it scales from its + bottom edge. */ +.bar { + flex: none; + width: calc(var(--bar-width) * var(--mock-px)); + height: calc(5 * var(--mock-px)); + background: var(--bar-color); + transform-origin: bottom; +} diff --git a/apps/docs/src/components/features/extensible-illustration.tsx b/apps/docs/src/components/features/extensible-illustration.tsx new file mode 100644 index 00000000..f93568f8 --- /dev/null +++ b/apps/docs/src/components/features/extensible-illustration.tsx @@ -0,0 +1,280 @@ +'use client'; + +/** + * The Extensible feature card's illustration, after the Figma mock: a code + * window headed ``, with colored bars standing in for code. Its story + * (SHA-214) steps the code up a line at a time and types each new line in, + * until the window lands back on its still frame. story.tsx says when it plays. + */ +import type { CSSProperties } from 'react'; + +import { domMin, type Easing, LazyMotion, m, type Variants } from 'motion/react'; + +import { EASE_OUT } from '@/lib/easing'; + +import styles from './extensible-illustration.module.css'; +import { useStory } from './story'; + +// --------------------------------------------- +// The code +// --------------------------------------------- + +// The mock's bar colors, standing in for syntax highlighting. +const MAGENTA = '#cf4ef7'; +const VIOLET = '#5e00c7'; +const PINK = '#fd73d5'; +const GRAY = 'var(--white-a30)'; + +/** A bar standing in for a word of code. */ +interface Bar { + /** In mock pixels. */ + width: number; + color: string; +} + +interface CodeLine { + /** The side the line's bars sit against. A line set to the end reads as indented code. */ + align: 'start' | 'end'; + /** The line's bars, left to right. */ + bars: readonly Bar[]; +} + +// The mock's first seven lines. The code repeats every seven lines, so the +// story can scroll seven lines and land back on the still frame. +const CODE: readonly CodeLine[] = [ + { + align: 'end', + bars: [ + { width: 111, color: MAGENTA }, + { width: 41, color: VIOLET }, + ], + }, + { + align: 'start', + bars: [ + { width: 49, color: PINK }, + { width: 120, color: GRAY }, + ], + }, + { align: 'end', bars: [{ width: 139, color: MAGENTA }] }, + { + align: 'start', + bars: [ + { width: 21, color: VIOLET }, + { width: 102, color: GRAY }, + ], + }, + { + align: 'end', + bars: [ + { width: 124, color: MAGENTA }, + { width: 39, color: VIOLET }, + ], + }, + { + align: 'start', + bars: [ + { width: 32, color: VIOLET }, + { width: 88, color: GRAY }, + { width: 42, color: PINK }, + ], + }, + { + align: 'end', + bars: [ + { width: 47, color: VIOLET }, + { width: 58, color: GRAY }, + ], + }, +]; + +// The window has room for ten lines, and the still frame writes nine. The +// tenth slot stays empty, so the code stops short of the code block's +// bottom edge. The story steps each new line up out of it, blank, and types +// the line in the ninth. +const WRITTEN_LINES = 9; + +// Every line the story passes through, top to bottom: the still frame's +// nine, then the seven the story types. The still frame's last two repeat +// its first two, where the mock drew lines of its own. After seven lines of +// scrolling, the window shows lines 8 to 16, which are the still frame's +// nine again, over the same empty slot. +const LINES = [...CODE, ...CODE, ...CODE.slice(0, WRITTEN_LINES - CODE.length)]; + +// The lines the story types, below the still frame. +const STORY_LINES = LINES.slice(WRITTEN_LINES); + +// A bar's width and color, which extensible-illustration.module.css reads. +type BarStyle = CSSProperties & { '--bar-width': number; '--bar-color': string }; + +function barStyle({ width, color }: Bar): BarStyle { + return { '--bar-width': width, '--bar-color': color }; +} + +// --------------------------------------------- +// The story's timing +// --------------------------------------------- + +// From the motion brief on SHA-214. The code steps up one line, then the +// line that rose into the ninth slot grows in: its bars grow up from their +// baseline, one after another, left to right, the way someone types a line. +// After seven lines the window shows the still frame again, where the next +// play picks up without a seam if the pointer is still on the card. Every +// curve is the site's --ease-out (lib/easing.ts): fast off the mark, with a +// long settle. Durations are in seconds, as Motion takes them. + +// How long the code takes to step up one line. Longer makes each step glide +// rather than tick. +const STEP_SECONDS = 0.15; + +// How long from the start of one step to the start of the next. Longer +// slows the whole story. It leaves room for the step and most of a line's +// typing, and a line's last bar may still be growing as the next step +// starts. +const LINE_SECONDS = 0.28; + +// How long a bar takes to grow to its full height. Longer makes a bar swell +// into place rather than pop. +const GROW_SECONDS = 0.12; + +// How far behind the bar to its left each bar starts to grow. Longer reads +// as slower typing. +const BAR_STAGGER_SECONDS = 0.04; + +/** + * When a story line's bar starts to grow: after its line's step lands, and + * a stagger behind the bar to its left. `storyLine` counts the story's lines + * from 0. + */ +function growDelay(storyLine: number, bar: number): number { + return storyLine * LINE_SECONDS + STEP_SECONDS + bar * BAR_STAGGER_SECONDS; +} + +// The whole story, from the first step to the last bar's full height. It has +// to stay under the 2-second ceiling SHA-178 sets for every feature card's +// story, so check it after any change to the timing above. +const STORY_SECONDS = Math.max( + ...STORY_LINES.map( + (line, storyLine) => growDelay(storyLine, line.bars.length - 1) + GROW_SECONDS, + ), +); + +// --------------------------------------------- +// The story's motion +// --------------------------------------------- + +// One line's share of the lines' column, in percent. A `y` in percent moves +// the column by a share of its own height, so a step is one line at every +// card width. +const LINE_PERCENT = 100 / LINES.length; + +/** + * The column's scroll across the whole story, as one keyframed animation: + * for each story line, hold where the last step left the column, then step + * up one line. It then holds on the still frame until the last bar has + * grown, so the column's animation ends when the story does. + */ +function scrollAnimation() { + const keyframes = STORY_LINES.flatMap((_, storyLine) => [ + { line: storyLine, seconds: storyLine * LINE_SECONDS }, + { line: storyLine + 1, seconds: storyLine * LINE_SECONDS + STEP_SECONDS }, + ]); + + keyframes.push({ line: STORY_LINES.length, seconds: STORY_SECONDS }); + + // Each segment between two keyframes is either a step, which eases out, or + // a hold, which has nothing to ease. + const ease: Easing[] = keyframes + .slice(1) + .map(({ line }, index) => (line === keyframes[index]?.line ? 'linear' : EASE_OUT)); + + return { + y: keyframes.map(({ line }) => `${-line * LINE_PERCENT}%`), + transition: { + duration: STORY_SECONDS, + times: keyframes.map(({ seconds }) => seconds / STORY_SECONDS), + ease, + }, + }; +} + +// Every play, and the end of the story, mounts a fresh column at the still +// frame (see the illustration below), so nothing ever animates back to +// `still`: a column starts there. +const COLUMN_VARIANTS: Variants = { + still: { y: '0%' }, + story: scrollAnimation(), +}; + +// A story line's bars sit flat on their baseline until the story types +// them. Each takes its delay through Motion's `custom`. +const BAR_VARIANTS: Variants = { + still: { scaleY: 0 }, + story: (delay: number) => ({ + scaleY: 1, + transition: { delay, duration: GROW_SECONDS, ease: EASE_OUT }, + }), +}; + +// --------------------------------------------- +// The illustration +// --------------------------------------------- + +// Hidden from screen readers: the card's title and description carry its +// message. `data-lines` marks the column the story scrolls, for the +// Playwright spec. The lines never reorder, so their places serve as keys. +// +// Each play mounts a fresh column, keyed by the play's count, and the end of +// the story mounts one keyed `still`. A fresh column starts at the still +// frame. A finished play's column shows the still frame too, because its +// last seven lines repeat the seven above them, so the swap shows no seam. +// +// `m` under LazyMotion rather than `motion`, as in the favorites' card tab: +// the column and the bars only animate to their variants, which domMin +// covers. +export function ExtensibleIllustration() { + const { playing, plays, onStoryEnd } = useStory(); + + return ( +
+
+
+

{''}

+
+ + { + if (definition === 'story') onStoryEnd(); + }} + variants={COLUMN_VARIANTS} + > + {LINES.map((line, lineIndex) => ( +
+ {line.bars.map((bar, barIndex) => + lineIndex < WRITTEN_LINES ? ( + + ) : ( + + ), + )} +
+ ))} +
+
+
+
+
+
+ ); +} diff --git a/apps/docs/src/components/features/features.module.css b/apps/docs/src/components/features/features.module.css index bb0e4adf..7930f1bc 100644 --- a/apps/docs/src/components/features/features.module.css +++ b/apps/docs/src/components/features/features.module.css @@ -65,11 +65,14 @@ } /* The window's gray is the mock's, and the exported image carries it too, - so the window reads as finished while the image loads. */ + so the window reads as finished while the image loads. The window is the + container an illustration built in code measures with `cqw`, so it scales + with the column the way an image does. */ .window { overflow: hidden; border-radius: var(--radius-lg); background: var(--gray-800); + container-type: inline-size; } /* The width and height attributes set the 312 by 309 shape, and this scales diff --git a/apps/docs/src/components/features/features.tsx b/apps/docs/src/components/features/features.tsx index 3c7297a8..0f13af7d 100644 --- a/apps/docs/src/components/features/features.tsx +++ b/apps/docs/src/components/features/features.tsx @@ -1,16 +1,17 @@ /** * The homepage's features section, after the Figma mock: the heading over - * four feature cards, each an illustration above its title and description, - * in the order content/homepage.ts lists them. Each illustration is a static - * image exported from the mock until its card's own issue rebuilds it in code - * and gives the card its story. Nothing here moves, links, or takes focus. + * the feature cards in content/homepage.ts, each an illustration above its + * title and description. A hover plays a card's story (story.tsx). Nothing + * here links or takes focus. */ import Image from 'next/image'; -import { useId } from 'react'; +import { type ReactNode, useId } from 'react'; -import { FEATURE_CARDS } from '@/content/homepage'; +import { FEATURE_CARDS, type FeatureCardId } from '@/content/homepage'; +import { ExtensibleIllustration } from './extensible-illustration'; import styles from './features.module.css'; +import { StoryCard } from './story'; // The illustration's size in the mock, in CSS pixels. The exported files are // twice this. The attributes give the image its shape before it loads, and @@ -18,6 +19,33 @@ import styles from './features.module.css'; const ILLUSTRATION_WIDTH = 312; const ILLUSTRATION_HEIGHT = 309; +// An illustration exported from the mock at 2x into public/features. +// Decoration: the title and description carry the card's message, so screen +// readers skip the picture. +function StaticIllustration({ src }: { src: string }) { + return ( + + ); +} + +// 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. +const ILLUSTRATIONS: Record = { + composable: , + performant: , + reactive: , + extensible: , +}; + export function Features() { const headingId = useId(); @@ -31,24 +59,13 @@ export function Features() {
    {FEATURE_CARDS.map((card) => ( -
  • -
    - {/* Decoration: the title and description carry the card's - message, so screen readers skip the picture. */} - -
    + +
    {ILLUSTRATIONS[card.id]}

    {card.title}

    {card.description}

    -
  • + ))}
diff --git a/apps/docs/src/components/features/story.tsx b/apps/docs/src/components/features/story.tsx new file mode 100644 index 00000000..b08ec519 --- /dev/null +++ b/apps/docs/src/components/features/story.tsx @@ -0,0 +1,119 @@ +'use client'; + +/** + * 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. + */ +import { + createContext, + type ReactNode, + type RefObject, + useContext, + useEffect, + useRef, + useState, +} from 'react'; + +import { useReducedMotion } from 'motion/react'; + +import { useCanHover } from '@/lib/use-can-hover'; + +// --------------------------------------------- +// The card +// --------------------------------------------- + +// The card's element, which an illustration's story listens to for the +// pointer. Null outside a StoryCard. +const CardContext = createContext | null>(null); + +/** + * A feature card: its list item, which hovering plays the story of the + * illustration inside it. A card whose illustration has no story yet does + * nothing on hover, because nothing inside it listens. + */ +export function StoryCard({ className, children }: { className?: string; children: ReactNode }) { + const cardRef = useRef(null); + + return ( +
  • + {children} +
  • + ); +} + +// --------------------------------------------- +// The story +// --------------------------------------------- + +/** 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. + */ +export function useStory(): Story { + 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); + + // Listens to the card itself, so the whole card is the hover target, not + // just the illustration. + useEffect(() => { + const card = cardRef?.current; + + if (!card || !canHover || reducedMotion) return undefined; + + 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 }, + ); + }; + const onPointerLeave = (event: PointerEvent) => { + if (event.pointerType !== 'touch') pointerOnCardRef.current = false; + }; + + card.addEventListener('pointerenter', onPointerEnter); + card.addEventListener('pointerleave', onPointerLeave); + + return () => { + pointerOnCardRef.current = false; + card.removeEventListener('pointerenter', onPointerEnter); + card.removeEventListener('pointerleave', onPointerLeave); + }; + }, [cardRef, canHover, reducedMotion]); + + // 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 = () => { + const keepGoing = pointerOnCardRef.current; + + setStory((current) => + keepGoing ? { playing: true, plays: current.plays + 1 } : { ...current, playing: false }, + ); + }; + + return { ...story, onStoryEnd }; +} diff --git a/apps/docs/src/content/homepage.ts b/apps/docs/src/content/homepage.ts index 8065cf35..31a2990f 100644 --- a/apps/docs/src/content/homepage.ts +++ b/apps/docs/src/content/homepage.ts @@ -1,8 +1,9 @@ /** * The homepage's content: the favorites in the Figma mock's order, read row - * by row after the heading cell, then the feature cards. The favorites are - * typed against the slugs in components.ts, so a renamed or removed - * component fails the type check here. The catalog carries no favorite flag. + * by row after the heading cell, then the feature cards' copy. The + * favorites are typed against the slugs in components.ts, so a renamed or + * removed component fails the type check here. The catalog carries no + * favorite flag. */ import { type ComponentCatalogRecord, getComponentsCatalog } from './catalog'; import type { ComponentSlug } from './components'; @@ -79,37 +80,36 @@ export async function getFavorites(): Promise { // ---- Feature cards -/** A feature card's copy and its illustration. */ +/** Names a feature card. components/features picks its illustration by it. */ +export type FeatureCardId = 'composable' | 'performant' | 'reactive' | 'extensible'; + +/** A feature card's copy. */ export interface FeatureCard { + id: FeatureCardId; title: string; description: string; - /** - * The URL of the card's illustration, exported from the Figma mock at 2x - * into public/features. Each card's own issue rebuilds it in code. - */ - illustration: string; } /** The feature cards, in the mock's order, with the mock's copy. */ export const FEATURE_CARDS: readonly FeatureCard[] = [ { + id: 'composable', title: 'Composable', description: 'Stack shaders in a single scene to build the exact effect you want.', - illustration: '/features/composable.png', }, { + id: 'performant', title: 'Performant', description: 'Reduce browser overhead for your users with WebGPU rendering.', - illustration: '/features/performant.png', }, { + id: 'reactive', title: 'Reactive', description: 'Bring shaders to life with animation, cursor, and scroll inputs.', - illustration: '/features/reactive.png', }, { + id: 'extensible', title: 'Extensible', description: 'Write your own shaders with the same TSL primitives our components use.', - illustration: '/features/extensible.png', }, ]; diff --git a/apps/docs/src/lib/easing.ts b/apps/docs/src/lib/easing.ts new file mode 100644 index 00000000..f3f50983 --- /dev/null +++ b/apps/docs/src/lib/easing.ts @@ -0,0 +1,7 @@ +/** + * The site's easing tokens from tokens.css, in the form Motion takes, for + * the homepage's Motion animations. Stylesheets read the tokens themselves. + */ + +/** The site's --ease-out, as Motion takes a cubic-bezier: fast off the mark, with a long settle. */ +export const EASE_OUT = [0.32, 0.72, 0, 1] as const; diff --git a/apps/docs/src/lib/use-can-hover.ts b/apps/docs/src/lib/use-can-hover.ts new file mode 100644 index 00000000..e8337253 --- /dev/null +++ b/apps/docs/src/lib/use-can-hover.ts @@ -0,0 +1,28 @@ +/** + * The site's hover gate: whether the visitor's main pointer can hover, as a + * mouse or trackpad can. The favorites go live behind it, and the feature + * cards play their stories behind it. A phone or tablet fails it. + */ +import { useSyncExternalStore } from 'react'; + +// The same query the stylesheets put hover styles behind. +const HOVER_QUERY = '(hover: hover) and (pointer: fine)'; + +function subscribeToHoverQuery(onChange: () => void): () => void { + const query = window.matchMedia(HOVER_QUERY); + + query.addEventListener('change', onChange); + + return () => query.removeEventListener('change', onChange); +} + +const readHoverQuery = () => window.matchMedia(HOVER_QUERY).matches; + +// The server has no pointer to ask about, so it answers no, which renders +// every favorite as its poster and every feature card on its still frame. +const readServerHoverQuery = () => false; + +/** True while the visitor's main pointer can hover. */ +export function useCanHover(): boolean { + return useSyncExternalStore(subscribeToHoverQuery, readHoverQuery, readServerHoverQuery); +}