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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .fallowrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
// Run by hand to regenerate blue-noise-tile.ts.
"scripts/generate-blue-noise.mjs",
// Run by build-posters.sh after each poster capture.
"scripts/build-thumbnail.mjs"
"scripts/build-thumbnail.mjs",
// Run by hand to recapture the Reactive feature card's still frame.
"scripts/capture-reactive-still.mjs"
],
// The poster command esbuilds the harness at runtime, from a directory it
// resolves by path in poster/bundle.ts.
Expand Down
2 changes: 1 addition & 1 deletion GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ A card in the homepage's features section that illustrates one quality of the li
_Avoid_: Benefit card, value prop, pillar, featured card

**Story**:
The animation a feature card plays when the pointer moves over it. Once started, a story always finishes, and the card returns to its still frame.
The animation a feature card plays when the pointer moves over it, or, on the Reactive card, when a scroll push runs over it. Once started, a story always finishes, and the card returns to its still frame.
_Avoid_: Intro, entrance, demo, hover effect

**Still frame**:
Expand Down
196 changes: 196 additions & 0 deletions apps/docs-tests/docs/features-reactive.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { expect, test } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';

/**
* The Reactive feature card's story (SHA-216). A hover sends the arrow off
* the window and back in to the pointer, which it follows until the pointer
* leaves. Then it swoops back along the J to the still frame. A hover during
* that ending lets it land, then plays again. A touch that scrolls the page
* over the card hides the arrow and stirs the smudge until the page stops.
* Under reduced motion the card shows its still image, with no live scene.
* Checks read where the story is and where the arrow is from the
* illustration's data attributes, never how long anything takes or its
* curve, so tuning the motion cannot break this.
*/

// The card's live scene needs a WebGPU adapter. Headless Chromium hands out
// none, so this flag turns on its SwiftShader adapter, as
// favorites-hover.spec.ts does. A launch option forces a new browser, so it
// has to be set for the whole file.
test.use({ launchOptions: { args: ['--enable-unsafe-webgpu'] } });

// SwiftShader draws on the CPU, so the scene's first frame and its smudge
// can take a while, and every wait on the story allows for that.
const SCENE_TIMEOUT = 60_000;
const STORY_TIMEOUT = 20_000;

const featuresList = (page: Page) =>
page.getByRole('list', { name: 'We’ll handle the complex stuff' });

const reactiveCard = (page: Page) =>
featuresList(page)
.getByRole('listitem')
.filter({ has: page.getByRole('heading', { name: 'Reactive' }) });

// The illustration, whose data attributes say where the story is.
const illustration = (card: Locator) => card.locator('[data-scene]');

const stage = (card: Locator) => illustration(card).getAttribute('data-stage');
const arrowLeg = (card: Locator) => illustration(card).getAttribute('data-arrow');

// Scrolls the features section to the middle of the viewport, twice, for the
// reason favorites-hover.spec.ts gives: the section rides in the hero's
// sticky pin, and the hero finishes its change a frame after the first
// scroll, which moves the section again. Off screen, the hero's scene
// pauses, which leaves SwiftShader to the card's.
async function open(page: Page): Promise<void> {
await page.goto('/');
await page.waitForLoadState('networkidle');
await featuresList(page).evaluate(async (list) => {
const section = list.closest('section')!;
const nextFrames = () =>
new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));

section.scrollIntoView({ block: 'center' });
await nextFrames();
section.scrollIntoView({ block: 'center' });
});
}

/** Opens the page and waits for the card's live scene to show the still frame. */
async function openLive(page: Page): Promise<Locator> {
await open(page);
const card = reactiveCard(page);

await expect(illustration(card)).toHaveAttribute('data-scene', 'laid', {
timeout: SCENE_TIMEOUT,
});

return card;
}

/** Moves the pointer onto the illustration's middle. */
async function hover(card: Locator): Promise<void> {
const box = (await illustration(card).boundingBox())!;

await card.page().mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 4 });
}

/** Moves the pointer off the card, onto the site header. */
async function leave(page: Page): Promise<void> {
await page.mouse.move(0, 0, { steps: 4 });
}

// Records the story's stage on every frame from the moment this is called.
async function recordStages(card: Locator): Promise<() => Promise<string[]>> {
await illustration(card).evaluate((element) => {
const stages: string[] = [];
const record = () => {
const current = element.getAttribute('data-stage') ?? '';

if (stages.at(-1) !== current) stages.push(current);
requestAnimationFrame(record);
};

(window as unknown as { stages: string[] }).stages = stages;
record();
});

return () => card.page().evaluate(() => (window as unknown as { stages: string[] }).stages);
}

// ---------------------------------------------
// Hover
// ---------------------------------------------

test('a hover hands the arrow to the pointer, and leaving brings it back to the still frame', async ({
page,
}) => {
const card = await openLive(page);

await hover(card);
await expect.poll(() => stage(card)).toBe('holding');
await expect.poll(() => arrowLeg(card), { timeout: STORY_TIMEOUT }).toBe('following');
// The arrow stands in for the cursor once it follows the pointer.
await expect(illustration(card)).toHaveCSS('cursor', 'none');

await leave(page);
await expect.poll(() => stage(card)).toBe('ending');
await expect.poll(() => stage(card), { timeout: STORY_TIMEOUT }).toBe('still');
expect(await arrowLeg(card)).toBe('resting');
});

test('a hover during the ending lets it land, then plays again', async ({ page }) => {
const card = await openLive(page);

await hover(card);
await expect.poll(() => arrowLeg(card), { timeout: STORY_TIMEOUT }).toBe('following');

const stages = await recordStages(card);

await leave(page);
await expect.poll(() => stage(card)).toBe('ending');
await hover(card);
// The pointer is back, but the ending carries on.
expect(await stage(card)).toBe('ending');

await expect.poll(stages, { timeout: STORY_TIMEOUT }).toEqual(['holding', 'ending', 'holding']);
});

// ---------------------------------------------
// Touch
// ---------------------------------------------

test.describe('on a touch device', () => {
test.use({ hasTouch: true, isMobile: true, viewport: { width: 412, height: 839 } });

test('a touch that scrolls the page over the card stirs it, then it settles on the still frame', async ({
page,
}) => {
const card = await openLive(page);
const box = (await illustration(card).boundingBox())!;
const client = await page.context().newCDPSession(page);
const touch = (
type: 'touchStart' | 'touchMove' | 'touchEnd',
points: Array<{ x: number; y: number; id: number }>,
) => client.send('Input.dispatchTouchEvent', { type, touchPoints: points });
const x = box.x + box.width / 2;
const y = box.y + box.height * 0.7;
const scrollBefore = await page.evaluate(() => window.scrollY);

await touch('touchStart', [{ x, y, id: 1 }]);
for (let step = 1; step <= 16; step += 1) {
await touch('touchMove', [{ x, y: y - step * 12, id: 1 }]);
await page.waitForTimeout(30);
}

await expect.poll(() => stage(card)).toBe('holding');
expect(await arrowLeg(card)).toBe('pushing');
await expect(illustration(card)).toHaveAttribute('data-arrow-hidden', 'true');

await touch('touchEnd', []);
await expect.poll(() => stage(card), { timeout: STORY_TIMEOUT }).toBe('still');
expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(scrollBefore + 50);
});
});

// ---------------------------------------------
// Reduced motion
// ---------------------------------------------

test.describe('under reduced motion', () => {
test('the card shows its still image, and a hover leaves it there', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await open(page);

const card = reactiveCard(page);

await expect(card.locator('img[src*="/features/reactive.png"]')).toBeVisible();
await hover(card);
await page.waitForTimeout(1_000);

expect(await stage(card)).toBe('still');
await expect(illustration(card)).toHaveAttribute('data-scene', 'still');
await expect(card.locator('canvas')).toHaveCount(0);
});
});
9 changes: 6 additions & 3 deletions apps/docs-tests/docs/homepage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,10 +281,13 @@ test('each feature card shows its illustration, hidden from screen readers', asy
continue;
}

const image = item.locator('img');
// An image, alone or under a live illustration's other parts, such as
// the Reactive card's arrow. Without WebGPU, as here, the Reactive card
// keeps showing its still image.
const image = item.locator(`img[src="${card.illustration.src}"]`);

await expect(image).toHaveAttribute('aria-hidden', 'true');
await expect(image).toHaveAttribute('src', card.illustration.src);
await expect(image).toBeVisible();
expect(await image.evaluate((element) => !!element.closest('[aria-hidden="true"]'))).toBe(true);
await expect
.poll(() =>
image.evaluate((element: HTMLImageElement) => element.complete && element.naturalWidth > 0),
Expand Down
5 changes: 5 additions & 0 deletions apps/docs/public/features/reactive-pointer-halo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions apps/docs/public/features/reactive-pointer.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/docs/public/features/reactive.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
56 changes: 8 additions & 48 deletions apps/docs/src/components/favorites/favorites-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ import { type CSSProperties, type RefObject, useEffect, useRef, useState } from

import { ShaderPoster } from '@camp-dev/shaders/poster';

import { useHeroPause } from '@/components/home-hero/hero-pause';
import { usePauseHeroWhile } from '@/components/home-hero/hero-pause';
import type { Favorite, FavoriteSlug } from '@/content/homepage';
import { useCanHover } from '@/lib/use-can-hover';
import { useHasWebGpu } from '@/lib/use-has-webgpu';

import { CardTab } from './card-tab';
import { favoriteSceneBackdrop, preloadFavoriteScenes, useFavoriteScene } from './favorite-scenes';
Expand Down Expand Up @@ -72,7 +73,9 @@ export function FavoritesList({
: (hoveredSlug ?? focusedSlug);

usePreloadScenesNearView(listRef, canGoLive);
usePauseHeroWhilePlaying(canGoLive && playingSlug !== null);
// A favorite plays only once the gate below opens, so with no WebGPU the
// hero keeps drawing.
usePauseHeroWhile(canGoLive && playingSlug !== null);

const setEngaged = (slug: FavoriteSlug, engagement: Engagement, engaged: boolean) => {
const setSlug = engagement === 'pointer' ? setHoveredSlug : setFocusedSlug;
Expand Down Expand Up @@ -298,61 +301,18 @@ function usePreloadScenesNearView(listRef: RefObject<HTMLUListElement | null>, e
}, [listRef, enabled]);
}

// ---------------------------------------------
// Pausing the hero while a favorite plays
// ---------------------------------------------

/**
* Pauses the homepage hero while a favorite plays, for the reason
* components/home-hero/hero-pause.ts gives. A favorite plays only once the
* gate below opens, so with no WebGPU the hero keeps drawing.
*/
function usePauseHeroWhilePlaying(playing: boolean) {
const heroPause = useHeroPause();

useEffect(() => {
if (!playing || !heroPause) return undefined;
heroPause.setPaused(true);

return () => heroPause.setPaused(false);
}, [playing, heroPause]);
}

// ---------------------------------------------
// The gate: a mouse or trackpad, and a WebGPU adapter
// ---------------------------------------------

/**
* True where a favorite may mount its scene: the device passes the site's
* hover gate (lib/use-can-hover.ts), and the browser hands out a WebGPU
* adapter. A phone or tablet fails the gate, so its favorites stay posters.
* Without an adapter, ShaderScene would fall back to WebGL2, and the
* favorites stay posters instead. Only a device that can hover asks for the
* adapter.
* adapter (lib/use-has-webgpu.ts). A phone or tablet fails the gate, so its
* favorites stay posters. Only a device that can hover asks for the adapter.
*/
function useCanGoLive(): boolean {
const canHover = useCanHover();
const [hasWebGpu, setHasWebGpu] = useState(false);

useEffect(() => {
// navigator.gpu is typed as always present, but it is missing in a
// browser without WebGPU and outside a secure context, so probe with `in`.
if (!canHover || !('gpu' in navigator)) return;

let cancelled = false;

void navigator.gpu
.requestAdapter()
// A browser that refuses the request has no WebGPU to offer either.
.catch(() => null)
.then((adapter) => {
if (!cancelled) setHasWebGpu(adapter !== null);
});

return () => {
cancelled = true;
};
}, [canHover]);

return canHover && hasWebGpu;
return useHasWebGpu(canHover);
}
11 changes: 6 additions & 5 deletions apps/docs/src/components/features/features.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { FEATURE_CARDS, type FeatureCardId } from '@/content/homepage';
import { ExtensibleIllustration } from './extensible-illustration';
import styles from './features.module.css';
import { PerformantIllustration } from './performant-illustration';
import { ReactiveIllustration } from './reactive-illustration';
import { StoryCard } from './story';

// The illustration's size in the mock, in CSS pixels. The exported files are
Expand All @@ -36,14 +37,14 @@ function StaticIllustration({ src }: { src: string }) {
);
}

// Each card's illustration. Extensible's and Performant's are built in code.
// The others are the images exported from the mock until each card's own
// issue rebuilds its illustration in code and gives the card its story. A
// complete record, so a card with no illustration fails the type check.
// Each card's illustration. Extensible's, Performant's, and Reactive's are
// built in code. Composable's is the image exported from the mock until its
// own issue rebuilds it in code and gives the card its story. A complete
// record, so a card with no illustration fails the type check.
const ILLUSTRATIONS: Record<FeatureCardId, ReactNode> = {
composable: <StaticIllustration src="/features/composable.png" />,
performant: <PerformantIllustration />,
reactive: <StaticIllustration src="/features/reactive.png" />,
reactive: <ReactiveIllustration />,
extensible: <ExtensibleIllustration />,
};

Expand Down
Loading
Loading