Skip to content
Merged
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
259 changes: 259 additions & 0 deletions apps/docs-tests/docs/features-extensible.spec.ts
Original file line number Diff line number Diff line change
@@ -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<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' });
});
}

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

interface ScrollRecord {
/** Every measurement so far, oldest first. */
all: () => Promise<number[]>;
/** The latest measurement. */
now: () => Promise<number>;
}

// 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<ScrollRecord> {
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);
Comment thread
hunterbecton marked this conversation as resolved.
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<Buffer> {
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());
});
});
28 changes: 24 additions & 4 deletions apps/docs-tests/docs/homepage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand All @@ -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,
},
];

Expand Down Expand Up @@ -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('<shaders>', { 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
Expand Down
Binary file removed apps/docs/public/features/extensible.png
Binary file not shown.
8 changes: 4 additions & 4 deletions apps/docs/src/components/favorites/card-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

// ---------------------------------------------
Expand Down Expand Up @@ -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
Expand Down
44 changes: 9 additions & 35 deletions apps/docs/src/components/favorites/favorites-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(() => {
Expand Down
Loading
Loading