From 69f937ab4ba02da02d0fc6fec6c411dfe4f9903c Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Sat, 10 Oct 2026 06:44:14 -0400 Subject: [PATCH 1/8] feat(docs): build the Extensible card's still frame in code Replace the exported Extensible illustration with the code window built in the DOM, so its story can move the lines (SHA-214). Lengths are in mock pixels scaled by the card's window, so it scales like the image did. The code repeats every seven lines, so lines 8 to 10 copy lines 1 to 3 and the story can scroll seven lines back onto the still frame. Feature cards now pick their illustration by id, and the content module keeps only copy. --- apps/docs-tests/docs/homepage.spec.ts | 28 +++- apps/docs/public/features/extensible.png | Bin 16629 -> 0 bytes .../extensible-illustration.module.css | 99 ++++++++++++++ .../features/extensible-illustration.tsx | 123 ++++++++++++++++++ .../components/features/features.module.css | 5 +- .../docs/src/components/features/features.tsx | 53 +++++--- apps/docs/src/content/homepage.ts | 26 ++-- 7 files changed, 298 insertions(+), 36 deletions(-) delete mode 100644 apps/docs/public/features/extensible.png create mode 100644 apps/docs/src/components/features/extensible-illustration.module.css create mode 100644 apps/docs/src/components/features/extensible-illustration.tsx 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 018fb1aca4f0b9cd99b483e072b58bf1b702b742..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16629 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 z࿉(#7is%f1bS2`z!ZXc$E4~}k_v!J*u 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, with ten lines of colored bars standing in for + * code and a fade over its bottom edge. It is built in the DOM rather than + * exported as an image so its story can move the lines (SHA-214). This file + * draws the still frame, which features.tsx shows in the card's window. + */ +import type { CSSProperties } from 'react'; + +import styles from './extensible-illustration.module.css'; + +// --------------------------------------------- +// 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 ten lines the window shows at rest. The last three repeat the first +// three, where the mock drew three lines of its own. They sit at the bottom, +// mostly under the fade, which keeps the repeat from showing. +const STILL_FRAME_LINES = [...CODE, ...CODE.slice(0, 3)]; + +// 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 illustration +// --------------------------------------------- + +// Hidden from screen readers: the card's title and description carry its +// message. The lines never reorder, so their places serve as keys. +export function ExtensibleIllustration() { + return ( +

+
+
+

{''}

+
+ {STILL_FRAME_LINES.map((line, lineIndex) => ( +
+ {line.bars.map((bar, barIndex) => ( + + ))} +
+ ))} +
+
+
+
+
+ ); +} 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..9d051dc9 100644 --- a/apps/docs/src/components/features/features.tsx +++ b/apps/docs/src/components/features/features.tsx @@ -1,15 +1,18 @@ /** * 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. + * in the order content/homepage.ts lists them. Extensible's illustration is + * built in code (extensible-illustration.tsx). The others are static images + * exported from the mock until each card's own issue rebuilds its + * illustration in code and gives the card its story. 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'; // The illustration's size in the mock, in CSS pixels. The exported files are @@ -18,6 +21,31 @@ 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. 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,19 +59,8 @@ 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/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', }, ]; From d12bf9bdd12f8b85b63066e5eeef67323d63b980 Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Sat, 10 Oct 2026 07:05:49 -0400 Subject: [PATCH 2/8] refactor(docs): share the site's hover gate Move the favorites' hover query into lib/use-can-hover.ts, so the feature cards can play their stories behind the same gate. --- .../components/favorites/favorites-list.tsx | 44 ++++--------------- apps/docs/src/lib/use-can-hover.ts | 28 ++++++++++++ 2 files changed, 37 insertions(+), 35 deletions(-) create mode 100644 apps/docs/src/lib/use-can-hover.ts 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/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); +} From 34db4157832ec2a1316e812a0d76554a64558ffe Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Sat, 10 Oct 2026 07:54:53 -0400 Subject: [PATCH 3/8] refactor(docs): share the site's --ease-out with Motion code Move the card tab's copy of the --ease-out curve into lib/easing.ts, so the feature cards' stories ease on the same curve without a second copy. --- apps/docs/src/components/favorites/card-tab.tsx | 8 ++++---- apps/docs/src/lib/easing.ts | 7 +++++++ 2 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 apps/docs/src/lib/easing.ts 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/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; From 078216fbfa3951058ac3ae847d1a73d8890efba3 Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Sat, 10 Oct 2026 07:05:49 -0400 Subject: [PATCH 4/8] feat(docs): play the Extensible card's story on hover Add the lifecycle every feature card's story shares, in story.tsx. A hover behind the site's hover gate plays a card's story, and it keeps playing while the pointer stays. Once the pointer leaves, the play under way finishes and the card rests on its still frame. A hover during a play doesn't restart it. Touch pointers and reduced motion leave the card on its still frame. The Extensible story steps the code up one line at a time and types each new line in the ninth slot, where the bottom fade has barely begun: its bars grow in from their baseline, left to right. The still frame leaves the tenth slot empty under the fade, so seven steps land back on it. Each play, and the end of the story, mounts a fresh column at the still frame, so the swap shows no seam. features-extensible.spec.ts covers the hover, the finish after the pointer leaves, no restart, the story continuing while the pointer stays, touch, and reduced motion. --- .../docs/features-extensible.spec.ts | 259 ++++++++++++++++++ .../extensible-illustration.module.css | 62 +++-- .../features/extensible-illustration.tsx | 192 +++++++++++-- .../docs/src/components/features/features.tsx | 20 +- apps/docs/src/components/features/story.tsx | 119 ++++++++ 5 files changed, 604 insertions(+), 48 deletions(-) create mode 100644 apps/docs-tests/docs/features-extensible.spec.ts create mode 100644 apps/docs/src/components/features/story.tsx 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/src/components/features/extensible-illustration.module.css b/apps/docs/src/components/features/extensible-illustration.module.css index d3d44861..b54126e4 100644 --- a/apps/docs/src/components/features/extensible-illustration.module.css +++ b/apps/docs/src/components/features/extensible-illustration.module.css @@ -33,8 +33,10 @@ mix-blend-mode: overlay; } -/* The code block clips its lines. The mock's ten lines run past its bottom - padding, so the last one ends just inside the bottom edge. */ +/* 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; @@ -42,7 +44,7 @@ flex-direction: column; gap: calc(10 * var(--mock-px)); height: calc(133 * var(--mock-px)); - padding: calc(10 * 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); @@ -62,38 +64,56 @@ white-space: nowrap; } -/* Lines 5px tall, 6px apart, in a 190px column. */ -.lines { - display: flex; - flex: none; - flex-direction: column; - gap: calc(6 * var(--mock-px)); +/* 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. */ +.viewport { + position: relative; + flex: 1; + min-height: 0; + overflow: clip; +} + +/* The mock's fade over the bottom edge: 17px from clear to the code block's + black. It all but hides the empty last slot and dims the line above it a + little. */ +.viewport::after { + position: absolute; + inset-inline: 0; + bottom: 0; + height: calc(17 * var(--mock-px)); + background: linear-gradient(transparent, var(--black)); + content: ''; +} + +/* Every line the story passes through, in a 190px column. The still + frame's nine and its empty slot fill the viewport, and the story's seven + 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; - flex: none; gap: calc(8 * var(--mock-px)); - height: calc(5 * 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); -} - -/* The mock's fade over the bottom edge: 17px from clear to the code block's - black. It hides most of the last line and touches the one above. */ -.fade { - position: absolute; - inset-inline: 0; - bottom: 0; - height: calc(17 * var(--mock-px)); - background: linear-gradient(transparent, var(--black)); + transform-origin: bottom; } diff --git a/apps/docs/src/components/features/extensible-illustration.tsx b/apps/docs/src/components/features/extensible-illustration.tsx index 1ca8fa2e..e59f02fb 100644 --- a/apps/docs/src/components/features/extensible-illustration.tsx +++ b/apps/docs/src/components/features/extensible-illustration.tsx @@ -1,13 +1,19 @@ +'use client'; + /** * The Extensible feature card's illustration, after the Figma mock: a code - * window headed ``, with ten lines of colored bars standing in for - * code and a fade over its bottom edge. It is built in the DOM rather than - * exported as an image so its story can move the lines (SHA-214). This file - * draws the still frame, which features.tsx shows in the card's window. + * 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 @@ -82,10 +88,23 @@ const CODE: readonly CodeLine[] = [ }, ]; -// The ten lines the window shows at rest. The last three repeat the first -// three, where the mock drew three lines of its own. They sit at the bottom, -// mostly under the fade, which keeps the repeat from showing. -const STILL_FRAME_LINES = [...CODE, ...CODE.slice(0, 3)]; +// The window has room for ten lines, and the still frame writes nine. The +// tenth, mostly under the fade, waits empty: the story steps each new line +// up out of it and types the line in the ninth, where the fade has barely +// begun. The mock drew gray bars in the tenth that the fade all but hides, +// so the empty slot looks the same. +const WRITTEN_LINES = 9; + +// Every line the story passes through, top to bottom: the still frame's +// nine, then the seven the story writes below them. The still frame's last +// two repeat its first two, where the mock drew lines of its own. They sit +// at the bottom, by the fade, which keeps the repeat from showing. 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 writes, 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 }; @@ -94,28 +113,167 @@ 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. + */ +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, + }, + }; +} + +// The column shows the still frame until a play scrolls it. Every play, and +// the end of the story, mounts a fresh column (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. The lines never reorder, so their places serve as keys. +// 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 (

    {''}

    -
    - {STILL_FRAME_LINES.map((line, lineIndex) => ( -
    - {line.bars.map((bar, barIndex) => ( - +
    + + { + 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.tsx b/apps/docs/src/components/features/features.tsx index 9d051dc9..0f13af7d 100644 --- a/apps/docs/src/components/features/features.tsx +++ b/apps/docs/src/components/features/features.tsx @@ -1,11 +1,8 @@ /** * 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. Extensible's illustration is - * built in code (extensible-illustration.tsx). The others are static images - * exported from the mock until each card's own issue rebuilds its - * illustration in code and gives the card its story. Nothing here 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 { type ReactNode, useId } from 'react'; @@ -14,6 +11,7 @@ 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 @@ -37,8 +35,10 @@ function StaticIllustration({ src }: { src: string }) { ); } -// Each card's illustration. A complete record, so a card with no -// illustration fails the type check. +// 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: , @@ -59,13 +59,13 @@ export function Features() {
      {FEATURE_CARDS.map((card) => ( -
    • +
      {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 }; +} From de2eb87d0aa577b640de68ba83ad170fde1fe85c Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Sat, 10 Oct 2026 10:09:15 -0400 Subject: [PATCH 5/8] feat(docs): show the Extensible card's faint tenth line at rest The still frame left the tenth slot empty, so the bottom fade covered plain black and didn't show. The slot now holds the next line again, faint under the fade as in the mock. A play starts with that line blank, and the first step lifts it into the ninth slot to type, as before. The line blanks when a play starts and returns when the story stops, both under the darkest part of the fade. --- .../extensible-illustration.module.css | 7 +- .../features/extensible-illustration.tsx | 82 ++++++++++++------- 2 files changed, 57 insertions(+), 32 deletions(-) diff --git a/apps/docs/src/components/features/extensible-illustration.module.css b/apps/docs/src/components/features/extensible-illustration.module.css index b54126e4..c312c09b 100644 --- a/apps/docs/src/components/features/extensible-illustration.module.css +++ b/apps/docs/src/components/features/extensible-illustration.module.css @@ -78,8 +78,7 @@ } /* The mock's fade over the bottom edge: 17px from clear to the code block's - black. It all but hides the empty last slot and dims the line above it a - little. */ + black. It all but hides the last line and dims the one above it a little. */ .viewport::after { position: absolute; inset-inline: 0; @@ -90,8 +89,8 @@ } /* Every line the story passes through, in a 190px column. The still - frame's nine and its empty slot fill the viewport, and the story's seven - wait below. */ + frame's ten fill the viewport, and the rest of the story's lines wait + below. */ .column { width: calc(190 * var(--mock-px)); } diff --git a/apps/docs/src/components/features/extensible-illustration.tsx b/apps/docs/src/components/features/extensible-illustration.tsx index e59f02fb..1ea69ec2 100644 --- a/apps/docs/src/components/features/extensible-illustration.tsx +++ b/apps/docs/src/components/features/extensible-illustration.tsx @@ -88,23 +88,26 @@ const CODE: readonly CodeLine[] = [ }, ]; -// The window has room for ten lines, and the still frame writes nine. The -// tenth, mostly under the fade, waits empty: the story steps each new line -// up out of it and types the line in the ninth, where the fade has barely -// begun. The mock drew gray bars in the tenth that the fade all but hides, -// so the empty slot looks the same. -const WRITTEN_LINES = 9; +// How many lines the window shows. The still frame writes all ten, the +// last one faint under the fade, as the mock draws it. +const WINDOW_LINES = 10; + +// The first line the story types, counting from 0: the still frame's faint +// tenth. The story types each line in the ninth slot, where the fade has +// barely begun, so a play starts with that tenth line blank, and the first +// step lifts it into the ninth slot to type. +const FIRST_STORY_LINE = 9; // Every line the story passes through, top to bottom: the still frame's -// nine, then the seven the story writes below them. The still frame's last -// two repeat its first two, where the mock drew lines of its own. They sit -// at the bottom, by the fade, which keeps the repeat from showing. 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)]; +// first nine, then the seven the story types, starting with the still +// frame's tenth. The still frame's last three repeat its first three, where +// the mock drew lines of its own. They sit at the bottom, by the fade, which +// keeps the repeat from showing. After seven lines of scrolling, the window +// shows lines 8 to 16, which are the still frame's first nine again. +const LINES = [...CODE, ...CODE, ...CODE.slice(0, FIRST_STORY_LINE - CODE.length)]; -// The lines the story writes, below the still frame. -const STORY_LINES = LINES.slice(WRITTEN_LINES); +// The lines the story types. +const STORY_LINES = LINES.slice(FIRST_STORY_LINE); // A bar's width and color, which extensible-illustration.module.css reads. type BarStyle = CSSProperties & { '--bar-width': number; '--bar-color': string }; @@ -145,7 +148,8 @@ 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. + * 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; @@ -199,24 +203,43 @@ function scrollAnimation() { }; } -// The column shows the still frame until a play scrolls it. Every play, and -// the end of the story, mounts a fresh column (see the illustration below), -// so nothing ever animates back to `still`: a column starts there. +// Every play, and the end of the story, mounts a fresh column (see the +// illustration below), so nothing ever animates back to `still` or `start`: +// a column starts at one of them. `still` is the still frame. `start` is +// where a play begins: the still frame with the lines the story types blank, +// which leaves the tenth slot empty. const COLUMN_VARIANTS: Variants = { still: { y: '0%' }, + start: { y: '0%' }, story: scrollAnimation(), }; +/** What a story line's bar needs from Motion's `custom`. */ +interface StoryBar { + /** Whether the still frame shows the bar: true for the faint tenth line. */ + inStillFrame: boolean; + /** When the bar starts to grow in a play, from growDelay. */ + delay: number; +} + // A story line's bars sit flat on their baseline until the story types -// them. Each takes its delay through Motion's `custom`. +// them, except the still frame's tenth line, whose bars are up at rest. const BAR_VARIANTS: Variants = { - still: { scaleY: 0 }, - story: (delay: number) => ({ + still: ({ inStillFrame }: StoryBar) => ({ scaleY: inStillFrame ? 1 : 0 }), + start: { scaleY: 0 }, + story: ({ delay }: StoryBar) => ({ scaleY: 1, transition: { delay, duration: GROW_SECONDS, ease: EASE_OUT }, }), }; +function storyBar(lineIndex: number, barIndex: number): StoryBar { + return { + inStillFrame: lineIndex < WINDOW_LINES, + delay: growDelay(lineIndex - FIRST_STORY_LINE, barIndex), + }; +} + // --------------------------------------------- // The illustration // --------------------------------------------- @@ -225,10 +248,13 @@ const BAR_VARIANTS: Variants = { // 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. +// Each play mounts a fresh column at `start`, keyed by the play's count, and +// the end of the story mounts one at `still`, keyed `still`. A finished +// play's column shows the still frame's first nine lines, because its last +// seven lines repeat the seven above them, over an empty tenth slot. So the +// swap to the next play's `start` shows no seam. The swap at either end of +// the story only blanks or restores the faint tenth line, under the darkest +// part of the fade. // // `m` under LazyMotion rather than `motion`, as in the favorites' card tab: // the column and the bars only animate to their variants, which domMin @@ -247,7 +273,7 @@ export function ExtensibleIllustration() { animate={playing ? 'story' : 'still'} className={styles.column} data-lines - initial="still" + initial={playing ? 'start' : 'still'} key={playing ? plays : 'still'} onAnimationComplete={(definition) => { if (definition === 'story') onStoryEnd(); @@ -257,12 +283,12 @@ export function ExtensibleIllustration() { {LINES.map((line, lineIndex) => (
    {line.bars.map((bar, barIndex) => - lineIndex < WRITTEN_LINES ? ( + lineIndex < FIRST_STORY_LINE ? ( ) : ( Date: Sat, 10 Oct 2026 10:24:48 -0400 Subject: [PATCH 6/8] feat(docs): hint at the next line under the Extensible card's fade During a play the tenth slot was empty, so the bottom fade covered plain black. The slot now always holds the next line, faint under the fade, as a hint of the code to come. Each step drops that line flat as it starts to rise, while the fade still hides most of it, and the line types in once it lands in the ninth slot. A play now ends exactly on the still frame, so the start and end of the story no longer blank or restore anything. --- .../extensible-illustration.module.css | 2 +- .../features/extensible-illustration.tsx | 133 ++++++++++-------- 2 files changed, 73 insertions(+), 62 deletions(-) diff --git a/apps/docs/src/components/features/extensible-illustration.module.css b/apps/docs/src/components/features/extensible-illustration.module.css index c312c09b..473f4020 100644 --- a/apps/docs/src/components/features/extensible-illustration.module.css +++ b/apps/docs/src/components/features/extensible-illustration.module.css @@ -89,7 +89,7 @@ } /* Every line the story passes through, in a 190px column. The still - frame's ten fill the viewport, and the rest of the story's lines wait + frame's ten fill the viewport, and the seven a play brings up wait below. */ .column { width: calc(190 * var(--mock-px)); diff --git a/apps/docs/src/components/features/extensible-illustration.tsx b/apps/docs/src/components/features/extensible-illustration.tsx index 1ea69ec2..d0c06e98 100644 --- a/apps/docs/src/components/features/extensible-illustration.tsx +++ b/apps/docs/src/components/features/extensible-illustration.tsx @@ -93,21 +93,22 @@ const CODE: readonly CodeLine[] = [ const WINDOW_LINES = 10; // The first line the story types, counting from 0: the still frame's faint -// tenth. The story types each line in the ninth slot, where the fade has -// barely begun, so a play starts with that tenth line blank, and the first -// step lifts it into the ninth slot to type. +// tenth. The tenth slot always holds the next line, faint under the fade, as +// a hint of the code to come. The story types each line once a step lifts +// it into the ninth slot, where the fade has barely begun. const FIRST_STORY_LINE = 9; -// Every line the story passes through, top to bottom: the still frame's -// first nine, then the seven the story types, starting with the still -// frame's tenth. The still frame's last three repeat its first three, where -// the mock drew lines of its own. They sit at the bottom, by the fade, which -// keeps the repeat from showing. After seven lines of scrolling, the window -// shows lines 8 to 16, which are the still frame's first nine again. -const LINES = [...CODE, ...CODE, ...CODE.slice(0, FIRST_STORY_LINE - CODE.length)]; +// Every line the story passes through, top to bottom: the still frame's ten, +// then the seven that rise into the tenth slot during a play. The still +// frame's last three repeat its first three, where the mock drew lines of +// its own. They sit at the bottom, by the fade, which keeps the repeat from +// showing. After seven lines of scrolling, the window shows lines 8 to 17, +// which are the still frame's ten again. +const LINES = [...CODE, ...CODE, ...CODE.slice(0, WINDOW_LINES - CODE.length)]; -// The lines the story types. -const STORY_LINES = LINES.slice(FIRST_STORY_LINE); +// The lines the story types: the still frame's tenth, and the six below it +// that pass through the ninth slot. The last line only reaches the tenth. +const STORY_LINES = LINES.slice(FIRST_STORY_LINE, FIRST_STORY_LINE + CODE.length); // A bar's width and color, which extensible-illustration.module.css reads. type BarStyle = CSSProperties & { '--bar-width': number; '--bar-color': string }; @@ -203,43 +204,51 @@ function scrollAnimation() { }; } -// Every play, and the end of the story, mounts a fresh column (see the -// illustration below), so nothing ever animates back to `still` or `start`: -// a column starts at one of them. `still` is the still frame. `start` is -// where a play begins: the still frame with the lines the story types blank, -// which leaves the tenth slot empty. +// 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%' }, - start: { y: '0%' }, story: scrollAnimation(), }; -/** What a story line's bar needs from Motion's `custom`. */ +/** Which story line a bar is on, counting from 0, and its place in the line. */ interface StoryBar { - /** Whether the still frame shows the bar: true for the faint tenth line. */ - inStillFrame: boolean; - /** When the bar starts to grow in a play, from growDelay. */ - delay: number; + storyLine: number; + barIndex: number; } -// A story line's bars sit flat on their baseline until the story types -// them, except the still frame's tenth line, whose bars are up at rest. -const BAR_VARIANTS: Variants = { - still: ({ inStillFrame }: StoryBar) => ({ scaleY: inStillFrame ? 1 : 0 }), - start: { scaleY: 0 }, - story: ({ delay }: StoryBar) => ({ - scaleY: 1, - transition: { delay, duration: GROW_SECONDS, ease: EASE_OUT }, - }), -}; +/** + * A story line's bar across one play. It stays up, faint under the fade, + * until its line starts to rise out of the tenth slot. It drops flat at + * that moment, while the fade still hides most of it, and grows back once + * the line lands in the ninth slot. A drop and a hold that share a moment + * make a jump, with no motion between them. + */ +function typingAnimation({ storyLine, barIndex }: StoryBar) { + const rises = storyLine * LINE_SECONDS; + const grows = growDelay(storyLine, barIndex); + const grown = grows + GROW_SECONDS; + // Only the growth eases. The hold, the drop, and the wait have no motion. + const ease: Easing[] = ['linear', 'linear', 'linear', EASE_OUT]; -function storyBar(lineIndex: number, barIndex: number): StoryBar { return { - inStillFrame: lineIndex < WINDOW_LINES, - delay: growDelay(lineIndex - FIRST_STORY_LINE, barIndex), + scaleY: [1, 1, 0, 0, 1], + transition: { + duration: grown, + times: [0, rises, rises, grows, grown].map((seconds) => seconds / grown), + ease, + }, }; } +// Every bar is up at rest. A story line's bars take their place through +// Motion's `custom`. +const BAR_VARIANTS: Variants = { + still: { scaleY: 1 }, + story: (storyBar: StoryBar) => typingAnimation(storyBar), +}; + // --------------------------------------------- // The illustration // --------------------------------------------- @@ -248,13 +257,10 @@ function storyBar(lineIndex: number, barIndex: number): StoryBar { // 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 at `start`, keyed by the play's count, and -// the end of the story mounts one at `still`, keyed `still`. A finished -// play's column shows the still frame's first nine lines, because its last -// seven lines repeat the seven above them, over an empty tenth slot. So the -// swap to the next play's `start` shows no seam. The swap at either end of -// the story only blanks or restores the faint tenth line, under the darkest -// part of the fade. +// 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 @@ -273,30 +279,35 @@ export function ExtensibleIllustration() { animate={playing ? 'story' : 'still'} className={styles.column} data-lines - initial={playing ? 'start' : 'still'} + initial="still" key={playing ? plays : 'still'} onAnimationComplete={(definition) => { if (definition === 'story') onStoryEnd(); }} variants={COLUMN_VARIANTS} > - {LINES.map((line, lineIndex) => ( -
    - {line.bars.map((bar, barIndex) => - lineIndex < FIRST_STORY_LINE ? ( - - ) : ( - - ), - )} -
    - ))} + {LINES.map((line, lineIndex) => { + const storyLine = lineIndex - FIRST_STORY_LINE; + const typed = storyLine >= 0 && storyLine < STORY_LINES.length; + + return ( +
    + {line.bars.map((bar, barIndex) => + typed ? ( + + ) : ( + + ), + )} +
    + ); + })}
    From d63d58ca9b5d8eb9485ffc59084739a936790ef6 Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Sat, 10 Oct 2026 10:30:12 -0400 Subject: [PATCH 7/8] feat(docs): drop the fade from the Extensible card A line animating under the bottom fade looked odd, so the fade goes, at the author's call. With nothing to hide the bottom slot, the story types each new line there, in full view, rather than in the ninth slot, and the still frame shows all ten lines. A play takes 1.95 seconds. --- .../extensible-illustration.module.css | 17 +-- .../features/extensible-illustration.tsx | 109 ++++++------------ 2 files changed, 37 insertions(+), 89 deletions(-) diff --git a/apps/docs/src/components/features/extensible-illustration.module.css b/apps/docs/src/components/features/extensible-illustration.module.css index 473f4020..91e9db7a 100644 --- a/apps/docs/src/components/features/extensible-illustration.module.css +++ b/apps/docs/src/components/features/extensible-illustration.module.css @@ -69,27 +69,16 @@ 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. */ + automatic minimum. The mock's fade over the bottom edge is left out, at + the author's call. */ .viewport { - position: relative; flex: 1; min-height: 0; overflow: clip; } -/* The mock's fade over the bottom edge: 17px from clear to the code block's - black. It all but hides the last line and dims the one above it a little. */ -.viewport::after { - position: absolute; - inset-inline: 0; - bottom: 0; - height: calc(17 * var(--mock-px)); - background: linear-gradient(transparent, var(--black)); - content: ''; -} - /* Every line the story passes through, in a 190px column. The still - frame's ten fill the viewport, and the seven a play brings up wait + frame's ten fill the viewport, and the seven the story types wait below. */ .column { width: calc(190 * var(--mock-px)); diff --git a/apps/docs/src/components/features/extensible-illustration.tsx b/apps/docs/src/components/features/extensible-illustration.tsx index d0c06e98..0974b507 100644 --- a/apps/docs/src/components/features/extensible-illustration.tsx +++ b/apps/docs/src/components/features/extensible-illustration.tsx @@ -88,27 +88,18 @@ const CODE: readonly CodeLine[] = [ }, ]; -// How many lines the window shows. The still frame writes all ten, the -// last one faint under the fade, as the mock draws it. +// How many lines the window shows, all of them written in the still frame. const WINDOW_LINES = 10; -// The first line the story types, counting from 0: the still frame's faint -// tenth. The tenth slot always holds the next line, faint under the fade, as -// a hint of the code to come. The story types each line once a step lifts -// it into the ninth slot, where the fade has barely begun. -const FIRST_STORY_LINE = 9; - // Every line the story passes through, top to bottom: the still frame's ten, -// then the seven that rise into the tenth slot during a play. The still -// frame's last three repeat its first three, where the mock drew lines of -// its own. They sit at the bottom, by the fade, which keeps the repeat from -// showing. After seven lines of scrolling, the window shows lines 8 to 17, -// which are the still frame's ten again. +// then the seven the story types into the bottom slot. The still frame's +// last three repeat its first three, where the mock drew lines of its own. +// After seven lines of scrolling, the window shows lines 8 to 17, which are +// the still frame's ten again. const LINES = [...CODE, ...CODE, ...CODE.slice(0, WINDOW_LINES - CODE.length)]; -// The lines the story types: the still frame's tenth, and the six below it -// that pass through the ninth slot. The last line only reaches the tenth. -const STORY_LINES = LINES.slice(FIRST_STORY_LINE, FIRST_STORY_LINE + CODE.length); +// The lines the story types, below the still frame. +const STORY_LINES = LINES.slice(WINDOW_LINES); // A bar's width and color, which extensible-illustration.module.css reads. type BarStyle = CSSProperties & { '--bar-width': number; '--bar-color': string }; @@ -121,9 +112,9 @@ function barStyle({ width, color }: Bar): BarStyle { // 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. +// From the motion brief on SHA-214. The code steps up one line, then the new +// line in the bottom 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 @@ -212,41 +203,14 @@ const COLUMN_VARIANTS: Variants = { story: scrollAnimation(), }; -/** Which story line a bar is on, counting from 0, and its place in the line. */ -interface StoryBar { - storyLine: number; - barIndex: number; -} - -/** - * A story line's bar across one play. It stays up, faint under the fade, - * until its line starts to rise out of the tenth slot. It drops flat at - * that moment, while the fade still hides most of it, and grows back once - * the line lands in the ninth slot. A drop and a hold that share a moment - * make a jump, with no motion between them. - */ -function typingAnimation({ storyLine, barIndex }: StoryBar) { - const rises = storyLine * LINE_SECONDS; - const grows = growDelay(storyLine, barIndex); - const grown = grows + GROW_SECONDS; - // Only the growth eases. The hold, the drop, and the wait have no motion. - const ease: Easing[] = ['linear', 'linear', 'linear', EASE_OUT]; - - return { - scaleY: [1, 1, 0, 0, 1], - transition: { - duration: grown, - times: [0, rises, rises, grows, grown].map((seconds) => seconds / grown), - ease, - }, - }; -} - -// Every bar is up at rest. A story line's bars take their place through -// Motion's `custom`. +// A story line's bars sit flat on their baseline, below the window, until +// the story types them. Each takes its delay through Motion's `custom`. const BAR_VARIANTS: Variants = { - still: { scaleY: 1 }, - story: (storyBar: StoryBar) => typingAnimation(storyBar), + still: { scaleY: 0 }, + story: (delay: number) => ({ + scaleY: 1, + transition: { delay, duration: GROW_SECONDS, ease: EASE_OUT }, + }), }; // --------------------------------------------- @@ -286,28 +250,23 @@ export function ExtensibleIllustration() { }} variants={COLUMN_VARIANTS} > - {LINES.map((line, lineIndex) => { - const storyLine = lineIndex - FIRST_STORY_LINE; - const typed = storyLine >= 0 && storyLine < STORY_LINES.length; - - return ( -
    - {line.bars.map((bar, barIndex) => - typed ? ( - - ) : ( - - ), - )} -
    - ); - })} + {LINES.map((line, lineIndex) => ( +
    + {line.bars.map((bar, barIndex) => + lineIndex < WINDOW_LINES ? ( + + ) : ( + + ), + )} +
    + ))}
From da59b1c1a8f4f119f91a39f32a9ed5fbb6efcc01 Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Sat, 10 Oct 2026 10:32:38 -0400 Subject: [PATCH 8/8] feat(docs): leave the Extensible card's tenth line empty Without the fade, the tenth line sat against the code block's bottom edge. The still frame writes nine lines again and leaves the tenth slot empty, so the code stops short of the edge. Each step lifts a blank line out of that slot, and it types in as the ninth. A play takes 1.99 seconds. --- .../extensible-illustration.module.css | 4 +- .../features/extensible-illustration.tsx | 37 ++++++++++--------- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/apps/docs/src/components/features/extensible-illustration.module.css b/apps/docs/src/components/features/extensible-illustration.module.css index 91e9db7a..e2688c68 100644 --- a/apps/docs/src/components/features/extensible-illustration.module.css +++ b/apps/docs/src/components/features/extensible-illustration.module.css @@ -78,8 +78,8 @@ } /* Every line the story passes through, in a 190px column. The still - frame's ten fill the viewport, and the seven the story types wait - below. */ + 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)); } diff --git a/apps/docs/src/components/features/extensible-illustration.tsx b/apps/docs/src/components/features/extensible-illustration.tsx index 0974b507..f93568f8 100644 --- a/apps/docs/src/components/features/extensible-illustration.tsx +++ b/apps/docs/src/components/features/extensible-illustration.tsx @@ -88,18 +88,21 @@ const CODE: readonly CodeLine[] = [ }, ]; -// How many lines the window shows, all of them written in the still frame. -const WINDOW_LINES = 10; - -// Every line the story passes through, top to bottom: the still frame's ten, -// then the seven the story types into the bottom slot. The still frame's -// last three repeat its first three, where the mock drew lines of its own. -// After seven lines of scrolling, the window shows lines 8 to 17, which are -// the still frame's ten again. -const LINES = [...CODE, ...CODE, ...CODE.slice(0, WINDOW_LINES - CODE.length)]; +// 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(WINDOW_LINES); +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 }; @@ -112,9 +115,9 @@ function barStyle({ width, color }: Bar): BarStyle { // The story's timing // --------------------------------------------- -// From the motion brief on SHA-214. The code steps up one line, then the new -// line in the bottom slot grows in: its bars grow up from their baseline, -// one after another, left to right, the way someone types a line. +// 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 @@ -203,8 +206,8 @@ const COLUMN_VARIANTS: Variants = { story: scrollAnimation(), }; -// A story line's bars sit flat on their baseline, below the window, until -// the story types them. Each takes its delay through Motion's `custom`. +// 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) => ({ @@ -253,12 +256,12 @@ export function ExtensibleIllustration() { {LINES.map((line, lineIndex) => (
{line.bars.map((bar, barIndex) => - lineIndex < WINDOW_LINES ? ( + lineIndex < WRITTEN_LINES ? ( ) : (