diff --git a/.changeset/style-dial-defaults-subpath.md b/.changeset/style-dial-defaults-subpath.md new file mode 100644 index 00000000..042ef7d2 --- /dev/null +++ b/.changeset/style-dial-defaults-subpath.md @@ -0,0 +1,5 @@ +--- +'@camp-dev/shaders': minor +--- + +`@camp-dev/shaders/defaults` exports `STYLE_DIAL_DEFAULTS`, FractalNoise's contrast and balance for each style, without loading three. Import it from there where the root entry can't load, such as in a server render or a Node test. diff --git a/.fallowrc.json b/.fallowrc.json index 948e567e..8acedf85 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -74,7 +74,11 @@ ".claude/**", "apps/docs/content/**", "**/*.mdx", - "packages/shaders-cli/src/test-fixtures/**" + "packages/shaders-cli/src/test-fixtures/**", + // Each composition's copied file per preset, written by golden.test.ts + // for CI's typecheck, lint, and format check. Nothing imports them, and + // every preset's file repeats the stack, by design. + "apps/docs/src/compositions/*/generated/**" ], "ignoreDependencies": ["eslint-import-resolver-typescript", "react"] } diff --git a/AGENTS.md b/AGENTS.md index feb70d01..c9e79eb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Shaders is a React shader component library built on WebGPU and Three.js TSL. Th - **Two tiers.** Tier 1 is the components under `packages/shaders/src/components/`, such as ``, imported from the package root. Tier 2 is the TSL primitives, such as `fractalNoise` and `voronoi`. The package exports them for Mode 2, and they have no doc pages of their own. - **Compositions decide what to build.** The docs site's Gallery shows compositions, which are stacks of public Sources and Effects with set props that users copy. Add a Source, an Effect, a prop, or engine work when a composition needs it, not to match another library's catalog. `docs/adr/0005-compositions-stack-only-public-sources-and-effects.md` has the rules. - **Two rendering modes.** In Mode 1, every Tier 1 component needs an explicit `` wrap, and you compose by stacking children in one scene. In Mode 2, users call `useShaderMaterial` inside their own r3f ``. Nothing auto-detects `@react-three/fiber`. -- **The engine is framework-free.** `src/engine.ts` is the barrel for the primitives, renderer, scheduler, and inputs. `src/react/` and `src/components/` import it rather than the root index, so dependencies run from root to components to react to engine, and never back. A `no-restricted-imports` block in `eslint.config.js` rejects React, `src/react`, and `src/components` everywhere in `packages/shaders/src` except those two folders and the root entries `index.ts`, `gamut.ts`, and `poster.ts`. The rule keeps the engine extractable for a second framework binding. +- **The engine is framework-free.** `src/engine.ts` is the barrel for the primitives, renderer, scheduler, and inputs. `src/react/` and `src/components/` import it rather than the root index, so dependencies run from root to components to react to engine, and never back. A `no-restricted-imports` block in `eslint.config.js` rejects React, `src/react`, and `src/components` everywhere in `packages/shaders/src` except those two folders and the root entries `index.ts`, `defaults.ts`, `gamut.ts`, and `poster.ts`. The rule keeps the engine extractable for a second framework binding. ## Where things live diff --git a/apps/docs-tests/docs/gallery-index.spec.ts b/apps/docs-tests/docs/gallery-index.spec.ts new file mode 100644 index 00000000..d56011a5 --- /dev/null +++ b/apps/docs-tests/docs/gallery-index.spec.ts @@ -0,0 +1,99 @@ +import { expect, test } from '@playwright/test'; +import type { Page } from '@playwright/test'; + +/** + * The Gallery grid at /gallery: a card per composition, each with its + * poster, its title, and its description, linking to the composition's + * page. A composition whose poster scripts/build-posters.sh never wrote + * shows a broken image here and fails the poster test, the way + * components-index.spec.ts catches a missing thumbnail. + * + * Each test reads every card in one evaluate rather than stepping card by + * card, because the section banner's live scene slows every Playwright step + * on CI's software renderer. + */ + +test.use({ viewport: { width: 1280, height: 2000 } }); + +interface Card { + href: string; + title: string; + description: string; + posterLoaded: boolean; +} + +async function open(page: Page): Promise { + await page.goto('/gallery'); + await page.waitForLoadState('networkidle'); +} + +// A card is the only link in the page's article that carries a +// description. Its title and description are the elements its +// aria-labelledby and aria-describedby name. +async function readCards(page: Page): Promise { + return page.locator('article a[aria-describedby]').evaluateAll((links) => + links.map((link) => { + const referenced = (name: string) => document.getElementById(link.getAttribute(name) ?? ''); + const title = referenced('aria-labelledby'); + const description = referenced('aria-describedby'); + const poster = link.querySelector('img'); + + if (!title || !description || !poster) { + throw new Error(`a card is missing a part: ${link.outerHTML}`); + } + + return { + href: link.getAttribute('href') ?? '', + title: title.innerText, + description: description.innerText, + posterLoaded: poster.complete && poster.naturalWidth > 0, + }; + }), + ); +} + +test('each card links to its composition, titled and described from the registry', async ({ + page, +}) => { + await open(page); + + const cards = await readCards(page); + + expect(cards.length).toBeGreaterThan(0); + + // The composition pages are fetched inside the browser in one step, which + // shows each link leads to a page whose title and meta description match + // the card's. + const pages = await page.evaluate( + (hrefs) => + Promise.all( + hrefs.map(async (href) => { + const response = await fetch(href); + const html = new DOMParser().parseFromString(await response.text(), 'text/html'); + + return { + href, + status: response.status, + title: html.querySelector('h1')?.textContent ?? '', + description: + html.querySelector('meta[name="description"]')?.getAttribute('content') ?? '', + }; + }), + ), + cards.map((card) => card.href), + ); + + expect(pages).toEqual( + cards.map(({ href, title, description }) => ({ href, status: 200, title, description })), + ); +}); + +test('each card shows its poster', async ({ page }) => { + await open(page); + + await expect + .poll(async () => + (await readCards(page)).filter((card) => !card.posterLoaded).map((card) => card.href), + ) + .toEqual([]); +}); diff --git a/apps/docs-tests/visual/gallery-fixture.spec.ts b/apps/docs-tests/visual/gallery-fixture.spec.ts new file mode 100644 index 00000000..2270832a --- /dev/null +++ b/apps/docs-tests/visual/gallery-fixture.spec.ts @@ -0,0 +1,57 @@ +import { expect, test } from './fixtures'; +import { waitForShader } from './helpers'; + +/** + * The fixture composition, once per preset, Default included. Every + * composition renders on the WebGL2 fallback, as ADR 0005 decides, and CI's + * headless Chromium has no WebGPU, so the spec asserts the backend before any + * screenshot: a runner that gains WebGPU fails here instead of quietly + * dropping the fallback's coverage. The picker loads each preset, and + * VisualTestPause remounts on the load to capture a fresh frame, so the spec + * clears the ready flag first and waits for it to rise again. + */ + +const PRESETS = ['Tight', 'Coarse']; + +// A preset's name in its baseline's file name, by the kebab-case rule its +// golden file takes in apps/docs/src/compositions/generate.ts. +const fileName = (preset: string) => + preset + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((word) => word.length > 0) + .join('-'); + +// The stack ends in Grain, a noise shader whose SwiftShader precision drifts +// across arm64 and amd64 past the 0.02 default, as in grain.spec.ts. +const MAX_DIFF_PIXEL_RATIO = 0.05; + +test('Fixture composition — every preset, on WebGL2', async ({ page }) => { + await page.goto('/gallery/fixture?visualTest=1'); + await waitForShader(page); + + const backend = await page.evaluate( + () => (window as unknown as { __shadersBackend?: string }).__shadersBackend, + ); + + expect(backend).toBe('webgl2'); + + const canvas = page.locator('[data-shader-demo] canvas'); + + await expect(canvas).toHaveScreenshot('gallery-fixture-default.png', { + maxDiffPixelRatio: MAX_DIFF_PIXEL_RATIO, + }); + + for (const preset of PRESETS) { + await page.evaluate(() => { + (window as unknown as { __shadersTestReady?: boolean }).__shadersTestReady = false; + }); + await page.getByRole('combobox', { name: 'Preset' }).click(); + await page.getByRole('option', { name: preset }).click(); + await waitForShader(page); + + await expect(canvas).toHaveScreenshot(`gallery-fixture-${fileName(preset)}.png`, { + maxDiffPixelRatio: MAX_DIFF_PIXEL_RATIO, + }); + } +}); diff --git a/apps/docs-tests/visual/gallery-fixture.spec.ts-snapshots/gallery-fixture-coarse-chromium-darwin.png b/apps/docs-tests/visual/gallery-fixture.spec.ts-snapshots/gallery-fixture-coarse-chromium-darwin.png new file mode 100644 index 00000000..d6317dd2 Binary files /dev/null and b/apps/docs-tests/visual/gallery-fixture.spec.ts-snapshots/gallery-fixture-coarse-chromium-darwin.png differ diff --git a/apps/docs-tests/visual/gallery-fixture.spec.ts-snapshots/gallery-fixture-coarse-chromium-linux.png b/apps/docs-tests/visual/gallery-fixture.spec.ts-snapshots/gallery-fixture-coarse-chromium-linux.png new file mode 100644 index 00000000..f7703372 Binary files /dev/null and b/apps/docs-tests/visual/gallery-fixture.spec.ts-snapshots/gallery-fixture-coarse-chromium-linux.png differ diff --git a/apps/docs-tests/visual/gallery-fixture.spec.ts-snapshots/gallery-fixture-default-chromium-darwin.png b/apps/docs-tests/visual/gallery-fixture.spec.ts-snapshots/gallery-fixture-default-chromium-darwin.png new file mode 100644 index 00000000..dbd67dcd Binary files /dev/null and b/apps/docs-tests/visual/gallery-fixture.spec.ts-snapshots/gallery-fixture-default-chromium-darwin.png differ diff --git a/apps/docs-tests/visual/gallery-fixture.spec.ts-snapshots/gallery-fixture-default-chromium-linux.png b/apps/docs-tests/visual/gallery-fixture.spec.ts-snapshots/gallery-fixture-default-chromium-linux.png new file mode 100644 index 00000000..eb791ab3 Binary files /dev/null and b/apps/docs-tests/visual/gallery-fixture.spec.ts-snapshots/gallery-fixture-default-chromium-linux.png differ diff --git a/apps/docs-tests/visual/gallery-fixture.spec.ts-snapshots/gallery-fixture-tight-chromium-darwin.png b/apps/docs-tests/visual/gallery-fixture.spec.ts-snapshots/gallery-fixture-tight-chromium-darwin.png new file mode 100644 index 00000000..224350d2 Binary files /dev/null and b/apps/docs-tests/visual/gallery-fixture.spec.ts-snapshots/gallery-fixture-tight-chromium-darwin.png differ diff --git a/apps/docs-tests/visual/gallery-fixture.spec.ts-snapshots/gallery-fixture-tight-chromium-linux.png b/apps/docs-tests/visual/gallery-fixture.spec.ts-snapshots/gallery-fixture-tight-chromium-linux.png new file mode 100644 index 00000000..3036091f Binary files /dev/null and b/apps/docs-tests/visual/gallery-fixture.spec.ts-snapshots/gallery-fixture-tight-chromium-linux.png differ diff --git a/apps/docs/content/docs/react/guides/ssr-and-fallbacks.mdx b/apps/docs/content/docs/react/guides/ssr-and-fallbacks.mdx index c9e2ba01..b4b85262 100644 --- a/apps/docs/content/docs/react/guides/ssr-and-fallbacks.mdx +++ b/apps/docs/content/docs/react/guides/ssr-and-fallbacks.mdx @@ -25,6 +25,7 @@ Some of what Shaders ships is plain arithmetic that never touches the GPU: parsi | Import from | Instead of | For | | --- | --- | --- | | `@camp-dev/shaders/color` | `@camp-dev/shaders` | `parseColorString`, the OKLab/OKLCH conversions, the gamut helpers, the sRGB transfer functions | +| `@camp-dev/shaders/defaults` | `@camp-dev/shaders` | `STYLE_DIAL_DEFAULTS`, FractalNoise's contrast and balance per style | | `@camp-dev/shaders/gamut` | `@camp-dev/shaders` | `useDisplayGamut` | | `@camp-dev/shaders/poster` | `@camp-dev/shaders` | `` | diff --git a/apps/docs/next.config.ts b/apps/docs/next.config.ts index 4c8980b0..af547904 100644 --- a/apps/docs/next.config.ts +++ b/apps/docs/next.config.ts @@ -62,6 +62,7 @@ const nextConfig: NextConfig = { 'three/tsl$': webgpuBundle, '@camp-dev/shaders$': resolve(shadersSrc, 'index.ts'), '@camp-dev/shaders/color$': resolve(shadersSrc, 'color.ts'), + '@camp-dev/shaders/defaults$': resolve(shadersSrc, 'defaults.ts'), '@camp-dev/shaders/gamut$': resolve(shadersSrc, 'gamut.ts'), '@camp-dev/shaders/poster$': resolve(shadersSrc, 'poster.ts'), }; diff --git a/apps/docs/public/posters/gallery/fixture.jpg b/apps/docs/public/posters/gallery/fixture.jpg new file mode 100644 index 00000000..7cfa2d1e Binary files /dev/null and b/apps/docs/public/posters/gallery/fixture.jpg differ diff --git a/apps/docs/public/posters/gallery/fixture.thumb.webp b/apps/docs/public/posters/gallery/fixture.thumb.webp new file mode 100644 index 00000000..2c8ce237 Binary files /dev/null and b/apps/docs/public/posters/gallery/fixture.thumb.webp differ diff --git a/apps/docs/src/app/components/[slug]/page.tsx b/apps/docs/src/app/components/[slug]/page.tsx index b7549f9d..29f46d40 100644 --- a/apps/docs/src/app/components/[slug]/page.tsx +++ b/apps/docs/src/app/components/[slug]/page.tsx @@ -22,6 +22,8 @@ import { PageActions } from '@/components/page-actions/page-actions'; import { PageToc, type PageTocSection } from '@/components/page-toc/page-toc'; import { Pagination } from '@/components/pagination/pagination'; import { PropsTable } from '@/components/props-table/props-table'; +import { UsedIn } from '@/components/used-in/used-in'; +import { compositionsUsing } from '@/compositions'; import { getComponentsCatalog, getComponentsTree } from '@/content/catalog'; import { DOCS_TRAIL_ROOT } from '@/content/nav'; import { getComponentProps } from '@/content/props'; @@ -97,9 +99,12 @@ export default async function ComponentPage({ params }: PageProps) {

{record.description}

@@ -109,6 +114,9 @@ export default async function ComponentPage({ params }: PageProps) { the shader's center; see .dock in page-toc.module.css. */}
+ {/* Provisional: the compositions whose stack holds this component. + Renders nothing when none does. */} +

Usage

{entry.usageNotes === undefined ? null : ( diff --git a/apps/docs/src/app/components/aurora/demo.tsx b/apps/docs/src/app/components/aurora/demo.tsx index 5ef12057..e86b94b9 100644 --- a/apps/docs/src/app/components/aurora/demo.tsx +++ b/apps/docs/src/app/components/aurora/demo.tsx @@ -90,9 +90,14 @@ function AuroraDemo() { ); } -export function AuroraControls() { +/** + * The panel's controls without the panel around them. A composition's All + * props section mounts them under a stack entry's id, so each path lands in + * that entry's props. + */ +export function AuroraFields() { return ( - + <>
@@ -129,6 +134,14 @@ export function AuroraControls() { )} + + ); +} + +export function AuroraControls() { + return ( + + ); } diff --git a/apps/docs/src/app/components/blobs/demo.tsx b/apps/docs/src/app/components/blobs/demo.tsx index 7be702f2..a7c497d6 100644 --- a/apps/docs/src/app/components/blobs/demo.tsx +++ b/apps/docs/src/app/components/blobs/demo.tsx @@ -52,9 +52,14 @@ function BlobsDemo() { ); } -function BlobsControls() { +/** + * The panel's controls without the panel around them. A composition's All + * props section mounts them under a stack entry's id, so each path lands in + * that entry's props. + */ +export function BlobsFields() { return ( - + <>
@@ -87,6 +92,14 @@ function BlobsControls() { > {() => } + + ); +} + +function BlobsControls() { + return ( + + ); } diff --git a/apps/docs/src/app/components/conic-gradient/demo.tsx b/apps/docs/src/app/components/conic-gradient/demo.tsx index f91f18a3..da34d8a7 100644 --- a/apps/docs/src/app/components/conic-gradient/demo.tsx +++ b/apps/docs/src/app/components/conic-gradient/demo.tsx @@ -48,9 +48,14 @@ function ConicGradientDemo() { ); } -function ConicGradientControls() { +/** + * The panel's controls without the panel around them. A composition's All + * props section mounts them under a stack entry's id, so each path lands in + * that entry's props. + */ +export function ConicGradientFields() { return ( - + <>
@@ -88,6 +93,14 @@ function ConicGradientControls() { )} + + ); +} + +function ConicGradientControls() { + return ( + + ); } diff --git a/apps/docs/src/app/components/cursor-ripple/demo.tsx b/apps/docs/src/app/components/cursor-ripple/demo.tsx index aac4b6f7..04b02d61 100644 --- a/apps/docs/src/app/components/cursor-ripple/demo.tsx +++ b/apps/docs/src/app/components/cursor-ripple/demo.tsx @@ -45,15 +45,28 @@ function CursorRippleDemo() { ); } -function CursorRippleControls() { +/** + * The panel's controls without the panel around them. A composition's All + * props section mounts them under a stack entry's id, so each path lands in + * that entry's props. + */ +export function CursorRippleFields() { return ( - + <>
+ + ); +} + +function CursorRippleControls() { + return ( + + ); } diff --git a/apps/docs/src/app/components/cursor-smudge/demo.tsx b/apps/docs/src/app/components/cursor-smudge/demo.tsx index 6a79a277..1e1f47e7 100644 --- a/apps/docs/src/app/components/cursor-smudge/demo.tsx +++ b/apps/docs/src/app/components/cursor-smudge/demo.tsx @@ -39,14 +39,27 @@ function CursorSmudgeDemo() { } // ---- Controls and page layout -function CursorSmudgeControls() { +/** + * The panel's controls without the panel around them. A composition's All + * props section mounts them under a stack entry's id, so each path lands in + * that entry's props. + */ +export function CursorSmudgeFields() { return ( - + <>
+ + ); +} + +function CursorSmudgeControls() { + return ( + + ); } diff --git a/apps/docs/src/app/components/cursor-spotlight/demo.tsx b/apps/docs/src/app/components/cursor-spotlight/demo.tsx index 56c4a838..d537f80d 100644 --- a/apps/docs/src/app/components/cursor-spotlight/demo.tsx +++ b/apps/docs/src/app/components/cursor-spotlight/demo.tsx @@ -45,13 +45,26 @@ function CursorSpotlightDemo() { ); } -function CursorSpotlightControls() { +/** + * The panel's controls without the panel around them. A composition's All + * props section mounts them under a stack entry's id, so each path lands in + * that entry's props. + */ +export function CursorSpotlightFields() { return ( - + <>
+ + ); +} + +function CursorSpotlightControls() { + return ( + + ); } diff --git a/apps/docs/src/app/components/demo-fields.test.ts b/apps/docs/src/app/components/demo-fields.test.ts new file mode 100644 index 00000000..98cd5487 --- /dev/null +++ b/apps/docs/src/app/components/demo-fields.test.ts @@ -0,0 +1,99 @@ +/** + * A composition's All props section mounts each demo's fields over the + * component's real props, so every path they write must start with a prop + * the component has. The demos load three, which this Node vitest can't, so + * it reads their source with the TypeScript compiler, as props.ts does. + */ +import { readFile } from 'node:fs/promises'; +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; + +import { POSITIONED_STOP_COMPONENTS } from '@/compositions/store'; +import { getComponentsCatalog } from '@/content/catalog'; +import { COMPONENTS } from '@/content/components'; +import { getComponentProps } from '@/content/props'; + +/** + * Every `path` attribute and every `setProp('…')` call in a demo's source, + * skipping a ListInput's rows, whose paths are relative to a row. + */ +function collectWrittenPaths(source: string): string[] { + const file = ts.createSourceFile( + 'demo.tsx', + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + const paths: string[] = []; + + const visit = (node: ts.Node) => { + if (ts.isJsxAttribute(node) && node.name.getText(file) === 'path') { + const { initializer } = node; + + if (initializer !== undefined && ts.isStringLiteral(initializer)) { + paths.push(initializer.text); + } + } + + if ( + ts.isCallExpression(node) && + node.expression.getText(file) === 'setProp' && + node.arguments[0] !== undefined && + ts.isStringLiteral(node.arguments[0]) + ) { + paths.push(node.arguments[0].text); + } + + // A ListInput's own path attribute is top-level, but its children render + // its rows, where paths are relative to the row. + if (ts.isJsxElement(node) && node.openingElement.tagName.getText(file) === 'ListInput') { + node.openingElement.attributes.properties.forEach(visit); + + return; + } + + ts.forEachChild(node, visit); + }; + + visit(file); + + return paths; +} + +describe('demo fields', () => { + it.each(Object.keys(COMPONENTS))('%s writes only its own props', async (slug) => { + const source = await readFile(new URL(`./${slug}/demo.tsx`, import.meta.url), 'utf8'); + const props = new Set((await getComponentProps(slug)).map(({ name }) => name)); + const paths = collectWrittenPaths(source); + + expect(paths.length, `${slug} has no fields`).toBeGreaterThan(0); + + for (const path of paths) { + const [prop = ''] = path.split('.'); + + expect(props, `${slug} writes "${path}"`).toContain(prop); + } + }); +}); + +// A composition's store writes out the stop positions the library spaces +// evenly, but only for the components in POSITIONED_STOP_COMPONENTS, because +// only their fields show a position. A demo that gains or drops a position +// field has to change that list too. +describe('stop positions', () => { + it('lists exactly the components whose fields edit a stop position', async () => { + const editing = await Promise.all( + (await getComponentsCatalog()).map(async ({ url, componentName }) => { + const source = await readFile( + new URL(`.${url.replace('/components', '')}/demo.tsx`, import.meta.url), + 'utf8', + ); + + return source.includes('path="position"') ? [componentName] : []; + }), + ); + + expect(editing.flat().sort()).toEqual([...POSITIONED_STOP_COMPONENTS].sort()); + }); +}); diff --git a/apps/docs/src/app/components/demo-registry.tsx b/apps/docs/src/app/components/demo-registry.tsx index f315a6db..078d6e55 100644 --- a/apps/docs/src/app/components/demo-registry.tsx +++ b/apps/docs/src/app/components/demo-registry.tsx @@ -1,36 +1,40 @@ /** * Maps a component slug to the pieces the shared components/[slug] template * cannot derive from the catalog (content/components.ts): the client demo island and - * the Usage content. The template renders usageNotes above the snippet, so + * the Usage content. COMPONENT_FIELDS at the end maps each component's + * export name to its demo's fields, which a composition's All props section + * mounts. The template renders usageNotes above the snippet, so * notes read as guidance introducing the code. Paragraphs that merely * restated the catalog description were dropped when pages converted — the * template's header already shows it. */ import type { ComponentType, ReactNode } from 'react'; -import { AuroraIsland } from './aurora/demo'; -import { BlobsIsland } from './blobs/demo'; -import { ConicGradientIsland } from './conic-gradient/demo'; -import { CursorRippleIsland } from './cursor-ripple/demo'; -import { CursorSmudgeIsland } from './cursor-smudge/demo'; -import { CursorSpotlightIsland } from './cursor-spotlight/demo'; -import { DissolveIsland } from './dissolve/demo'; -import { DitherIsland } from './dither/demo'; -import { DotFieldIsland } from './dot-field/demo'; -import { FlutedGlassIsland } from './fluted-glass/demo'; -import { FractalNoiseIsland } from './fractal-noise/demo'; -import { GodRaysIsland } from './god-rays/demo'; -import { GrainIsland } from './grain/demo'; -import { LedWallIsland } from './led-wall/demo'; -import { LinearGradientIsland } from './linear-gradient/demo'; -import { LiquidGradientIsland } from './liquid-gradient/demo'; -import { MeshGradientIsland } from './mesh-gradient/demo'; -import { RadialGradientIsland } from './radial-gradient/demo'; -import { RadialWipeIsland } from './radial-wipe/demo'; -import { SimplexNoiseIsland } from './simplex-noise/demo'; -import { VignetteIsland } from './vignette/demo'; -import { VoronoiIsland } from './voronoi/demo'; -import { WaveLinesIsland } from './wave-lines/demo'; +import type { ComponentName } from '@/compositions/define'; + +import { AuroraFields, AuroraIsland } from './aurora/demo'; +import { BlobsFields, BlobsIsland } from './blobs/demo'; +import { ConicGradientFields, ConicGradientIsland } from './conic-gradient/demo'; +import { CursorRippleFields, CursorRippleIsland } from './cursor-ripple/demo'; +import { CursorSmudgeFields, CursorSmudgeIsland } from './cursor-smudge/demo'; +import { CursorSpotlightFields, CursorSpotlightIsland } from './cursor-spotlight/demo'; +import { DissolveFields, DissolveIsland } from './dissolve/demo'; +import { DitherFields, DitherIsland } from './dither/demo'; +import { DotFieldFields, DotFieldIsland } from './dot-field/demo'; +import { FlutedGlassFields, FlutedGlassIsland } from './fluted-glass/demo'; +import { FractalNoiseFields, FractalNoiseIsland } from './fractal-noise/demo'; +import { GodRaysFields, GodRaysIsland } from './god-rays/demo'; +import { GrainFields, GrainIsland } from './grain/demo'; +import { LedWallFields, LedWallIsland } from './led-wall/demo'; +import { LinearGradientFields, LinearGradientIsland } from './linear-gradient/demo'; +import { LiquidGradientFields, LiquidGradientIsland } from './liquid-gradient/demo'; +import { MeshGradientFields, MeshGradientIsland } from './mesh-gradient/demo'; +import { RadialGradientFields, RadialGradientIsland } from './radial-gradient/demo'; +import { RadialWipeFields, RadialWipeIsland } from './radial-wipe/demo'; +import { SimplexNoiseFields, SimplexNoiseIsland } from './simplex-noise/demo'; +import { VignetteFields, VignetteIsland } from './vignette/demo'; +import { VoronoiFields, VoronoiIsland } from './voronoi/demo'; +import { WaveLinesFields, WaveLinesIsland } from './wave-lines/demo'; export interface ComponentPageEntry { /** Client island rendering the control store, shader demo, and control panel. */ @@ -384,3 +388,39 @@ export const COMPONENT_PAGES: Record = { `, }, }; + +/** One component's demo fields, as a composition's All props section mounts them. */ +export interface ComponentFields { + /** + * The demo's controls without its panel. The Customize panel mounts them + * under a stack entry's id, so `path="speed"` writes `.speed`. + */ + Fields: ComponentType; +} + +/** Every component's fields, keyed by export name, so a stack entry's `component` finds them. */ +export const COMPONENT_FIELDS: Record = { + Aurora: { Fields: AuroraFields }, + Blobs: { Fields: BlobsFields }, + ConicGradient: { Fields: ConicGradientFields }, + CursorRipple: { Fields: CursorRippleFields }, + CursorSmudge: { Fields: CursorSmudgeFields }, + CursorSpotlight: { Fields: CursorSpotlightFields }, + Dissolve: { Fields: DissolveFields }, + Dither: { Fields: DitherFields }, + DotField: { Fields: DotFieldFields }, + FlutedGlass: { Fields: FlutedGlassFields }, + FractalNoise: { Fields: FractalNoiseFields }, + GodRays: { Fields: GodRaysFields }, + Grain: { Fields: GrainFields }, + LedWall: { Fields: LedWallFields }, + LinearGradient: { Fields: LinearGradientFields }, + LiquidGradient: { Fields: LiquidGradientFields }, + MeshGradient: { Fields: MeshGradientFields }, + RadialGradient: { Fields: RadialGradientFields }, + RadialWipe: { Fields: RadialWipeFields }, + SimplexNoise: { Fields: SimplexNoiseFields }, + Vignette: { Fields: VignetteFields }, + Voronoi: { Fields: VoronoiFields }, + WaveLines: { Fields: WaveLinesFields }, +}; diff --git a/apps/docs/src/app/components/dissolve/demo.tsx b/apps/docs/src/app/components/dissolve/demo.tsx index 6412fdae..e498e80c 100644 --- a/apps/docs/src/app/components/dissolve/demo.tsx +++ b/apps/docs/src/app/components/dissolve/demo.tsx @@ -45,13 +45,26 @@ function DissolveDemo() { ); } -function DissolveControls() { +/** + * The panel's controls without the panel around them. A composition's All + * props section mounts them under a stack entry's id, so each path lands in + * that entry's props. + */ +export function DissolveFields() { return ( - + <>
+ + ); +} + +function DissolveControls() { + return ( + + ); } diff --git a/apps/docs/src/app/components/dither/demo.tsx b/apps/docs/src/app/components/dither/demo.tsx index ece713e6..fb62f798 100644 --- a/apps/docs/src/app/components/dither/demo.tsx +++ b/apps/docs/src/app/components/dither/demo.tsx @@ -57,9 +57,14 @@ function DitherDemo() { ); } -function DitherControls() { +/** + * The panel's controls without the panel around them. A composition's All + * props section mounts them under a stack entry's id, so each path lands in + * that entry's props. + */ +export function DitherFields() { return ( - + <>
@@ -67,6 +72,14 @@ function DitherControls() {
+ + ); +} + +function DitherControls() { + return ( + + ); } diff --git a/apps/docs/src/app/components/dot-field/demo.tsx b/apps/docs/src/app/components/dot-field/demo.tsx index 15ecc9f7..f59e3dad 100644 --- a/apps/docs/src/app/components/dot-field/demo.tsx +++ b/apps/docs/src/app/components/dot-field/demo.tsx @@ -49,9 +49,14 @@ function DotFieldDemo() { ); } -function DotFieldControls() { +/** + * The panel's controls without the panel around them. A composition's All + * props section mounts them under a stack entry's id, so each path lands in + * that entry's props. + */ +export function DotFieldFields() { return ( - + <>
@@ -68,6 +73,14 @@ function DotFieldControls() {
+ + ); +} + +function DotFieldControls() { + return ( + + ); } diff --git a/apps/docs/src/app/components/fluted-glass/cross-section.ts b/apps/docs/src/app/components/fluted-glass/cross-section.ts new file mode 100644 index 00000000..bc56e2ed --- /dev/null +++ b/apps/docs/src/app/components/fluted-glass/cross-section.ts @@ -0,0 +1,26 @@ +/** + * FlutedGlass's cross-section for a composition's All props section. The + * Cross-section slider edits the dial as a number, and the library default + * is the name `'round'`, so the store needs the number the name stands for. + */ +import type { CrossSectionName } from '@camp-dev/shaders'; + +// Where fluted-glass.tsx puts each name on the dial. The package doesn't +// export its table; the type keeps these names in step with it. +const CROSS_SECTION_DIAL: Record = { + prism: 0, + round: 0.5, + flat: 1, +}; + +const isCrossSectionName = (value: unknown): value is CrossSectionName => + typeof value === 'string' && Object.hasOwn(CROSS_SECTION_DIAL, value); + +/** Turns a named cross-section into the dial value the component renders it at. */ +export function seedCrossSection(props: Record): Record { + const { crossSection } = props; + + if (!isCrossSectionName(crossSection)) return props; + + return { ...props, crossSection: CROSS_SECTION_DIAL[crossSection] }; +} diff --git a/apps/docs/src/app/components/fluted-glass/demo.tsx b/apps/docs/src/app/components/fluted-glass/demo.tsx index ff51e5dc..b45dac49 100644 --- a/apps/docs/src/app/components/fluted-glass/demo.tsx +++ b/apps/docs/src/app/components/fluted-glass/demo.tsx @@ -48,9 +48,14 @@ function FlutedGlassDemo() { ); } -function FlutedGlassControls() { +/** + * The panel's controls without the panel around them. A composition's All + * props section mounts them under a stack entry's id, so each path lands in + * that entry's props. + */ +export function FlutedGlassFields() { return ( - + <>
@@ -64,6 +69,14 @@ function FlutedGlassControls() {
+ + ); +} + +function FlutedGlassControls() { + return ( + + ); } diff --git a/apps/docs/src/app/components/fractal-noise/demo.tsx b/apps/docs/src/app/components/fractal-noise/demo.tsx index 9cf5abfe..08934159 100644 --- a/apps/docs/src/app/components/fractal-noise/demo.tsx +++ b/apps/docs/src/app/components/fractal-noise/demo.tsx @@ -7,9 +7,9 @@ * between its static header and prose sections. */ import dynamic from 'next/dynamic'; -import { useEffect, useMemo, useRef } from 'react'; +import { useMemo } from 'react'; -import { type FractalNoiseStyle, STYLE_DIAL_DEFAULTS } from '@camp-dev/shaders'; +import { type FractalNoiseStyle, STYLE_DIAL_DEFAULTS } from '@camp-dev/shaders/defaults'; import { COLOR_SPACE_OPTIONS, @@ -27,36 +27,49 @@ import { useSetProp, useSnapshot, } from '@/components/controls'; +import { SelectField } from '@/components/controls/SelectInput'; import { DemoPoster } from '@/components/DemoPoster'; import { VisualTestPause } from '@/lib/visualTestHooks'; import { INITIAL, MAX_STOPS, MIN_STOPS, type Params, type PlainColorStop } from './params'; +import { isFractalNoiseStyle } from './style-dials'; const FractalNoiseScene = dynamic(() => import('./scene'), { ssr: false }); +const STYLE_OPTIONS = [ + { label: 'Clouds', value: 'clouds' }, + { label: 'Smoke', value: 'smoke' }, + { label: 'Veins', value: 'veins' }, +]; + /** - * Keeps the Contrast/Balance sliders in step with the component's per-style - * defaults: switching Style writes that style's dial defaults into the store, - * so the panel shows what an uncontrolled renders. + * The Style select. Picking a style also writes that style's dial defaults + * into Contrast and Balance, so the panel shows what an uncontrolled + * renders. Only a pick does: a preset load or a + * Reset sets the style and the dials together, and keeps the dials it sets. * Subscribes to the style leaf only (writing a container path re-renders * everything under it — see the demo-store gotcha in docs/agents/docs-site.md). */ -function StyleDialSync() { +function StyleSelect() { const style = usePropValue('style'); const setProp = useSetProp(); - const previousStyle = useRef(style); - - useEffect(() => { - if (previousStyle.current === style) return; - previousStyle.current = style; - - const dials = STYLE_DIAL_DEFAULTS[style]; - - setProp('contrast', dials.contrast); - setProp('balance', dials.balance); - }, [style, setProp]); - return null; + return ( + { + if (!isFractalNoiseStyle(next)) return; + + const dials = STYLE_DIAL_DEFAULTS[next]; + + setProp('style', next); + setProp('contrast', dials.contrast); + setProp('balance', dials.balance); + }} + options={STYLE_OPTIONS} + value={style} + /> + ); } /** A new palette color clones the last one so the addition is visible. */ @@ -81,19 +94,16 @@ function FractalNoiseDemo() { ); } -function FractalNoiseControls() { +/** + * The panel's controls without the panel around them. A composition's All + * props section mounts them under a stack entry's id, so each path lands in + * that entry's props. + */ +export function FractalNoiseFields() { return ( - + <>
- + @@ -119,6 +129,14 @@ function FractalNoiseControls() { > {() => } + + ); +} + +function FractalNoiseControls() { + return ( + + ); } @@ -128,7 +146,6 @@ export function FractalNoiseIsland() { return ( - }>
diff --git a/apps/docs/src/app/components/fractal-noise/params.ts b/apps/docs/src/app/components/fractal-noise/params.ts index 463f5ab6..001bcab5 100644 --- a/apps/docs/src/app/components/fractal-noise/params.ts +++ b/apps/docs/src/app/components/fractal-noise/params.ts @@ -1,9 +1,5 @@ -import { - type ColorSpace, - type FractalNoiseStyle, - type HueInterpolation, - STYLE_DIAL_DEFAULTS, -} from '@camp-dev/shaders'; +import { type ColorSpace, type FractalNoiseStyle, type HueInterpolation } from '@camp-dev/shaders'; +import { STYLE_DIAL_DEFAULTS } from '@camp-dev/shaders/defaults'; import { paletteOklch } from '../../../lib/palette'; diff --git a/apps/docs/src/app/components/fractal-noise/style-dials.ts b/apps/docs/src/app/components/fractal-noise/style-dials.ts new file mode 100644 index 00000000..f6a308f1 --- /dev/null +++ b/apps/docs/src/app/components/fractal-noise/style-dials.ts @@ -0,0 +1,21 @@ +/** + * FractalNoise's style dials for a composition's All props section. The dial + * table comes from the package's three-free subpath, so the golden test can + * run this seed in Node. + */ +import { type FractalNoiseStyle, STYLE_DIAL_DEFAULTS } from '@camp-dev/shaders/defaults'; + +export const isFractalNoiseStyle = (value: unknown): value is FractalNoiseStyle => + typeof value === 'string' && value in STYLE_DIAL_DEFAULTS; + +/** + * Fills in contrast and balance from the style's dial defaults where the + * props leave them out. A composition's store starts from the library + * defaults, which have none for these two, because the component picks them + * by style; the Contrast and Balance sliders need a value to show. + */ +export function seedStyleDials(props: Record): Record { + const dials = STYLE_DIAL_DEFAULTS[isFractalNoiseStyle(props.style) ? props.style : 'clouds']; + + return { contrast: dials.contrast, balance: dials.balance, ...props }; +} diff --git a/apps/docs/src/app/components/god-rays/demo.tsx b/apps/docs/src/app/components/god-rays/demo.tsx index aab64e18..90be3826 100644 --- a/apps/docs/src/app/components/god-rays/demo.tsx +++ b/apps/docs/src/app/components/god-rays/demo.tsx @@ -47,12 +47,17 @@ function GodRaysDemo() { ); } -function GodRaysControls() { +/** + * The panel's controls without the panel around them. A composition's All + * props section mounts them under a stack entry's id, so each path lands in + * that entry's props. + */ +export function GodRaysFields() { return ( - + <>
- - + +
@@ -78,6 +83,14 @@ function GodRaysControls() { > {() => } + + ); +} + +function GodRaysControls() { + return ( + + ); } diff --git a/apps/docs/src/app/components/god-rays/params.ts b/apps/docs/src/app/components/god-rays/params.ts index bef6b614..56fcd175 100644 --- a/apps/docs/src/app/components/god-rays/params.ts +++ b/apps/docs/src/app/components/god-rays/params.ts @@ -1,7 +1,7 @@ export interface GodRaysParams { colors: string[]; - centerX: number; - centerY: number; + /** The rays' origin, as the component's `center` prop takes it. */ + center: [number, number]; angle: number; spread: number; radius: number; @@ -21,8 +21,7 @@ export const MAX_COLORS = 5; // GodRays defaults. export const INITIAL: GodRaysParams = { colors: ['oklch(0.80 0.12 250)', 'oklch(0.70 0.16 300)', 'oklch(0.75 0.14 345)'], - centerX: 0.5, - centerY: -0.05, + center: [0.5, -0.05], angle: 270, spread: 360, radius: 2, diff --git a/apps/docs/src/app/components/god-rays/scene.tsx b/apps/docs/src/app/components/god-rays/scene.tsx index b8ed6672..cad8fd79 100644 --- a/apps/docs/src/app/components/god-rays/scene.tsx +++ b/apps/docs/src/app/components/god-rays/scene.tsx @@ -24,7 +24,7 @@ export default function GodRaysScene({ + <>
+ + ); +} + +function GrainControls() { + return ( + + ); } diff --git a/apps/docs/src/app/components/led-wall/demo.tsx b/apps/docs/src/app/components/led-wall/demo.tsx index 65afda1f..2687e047 100644 --- a/apps/docs/src/app/components/led-wall/demo.tsx +++ b/apps/docs/src/app/components/led-wall/demo.tsx @@ -45,9 +45,14 @@ function LedWallDemo() { ); } -function LedWallControls() { +/** + * The panel's controls without the panel around them. A composition's All + * props section mounts them under a stack entry's id, so each path lands in + * that entry's props. + */ +export function LedWallFields() { return ( - + <>
@@ -61,6 +66,14 @@ function LedWallControls() {
+ + ); +} + +function LedWallControls() { + return ( + + ); } diff --git a/apps/docs/src/app/components/linear-gradient/demo.tsx b/apps/docs/src/app/components/linear-gradient/demo.tsx index 941b2043..490b7b81 100644 --- a/apps/docs/src/app/components/linear-gradient/demo.tsx +++ b/apps/docs/src/app/components/linear-gradient/demo.tsx @@ -49,9 +49,14 @@ function LinearGradientDemo() { ); } -function LinearGradientControls() { +/** + * The panel's controls without the panel around them. A composition's All + * props section mounts them under a stack entry's id, so each path lands in + * that entry's props. + */ +export function LinearGradientFields() { return ( - + <>
@@ -87,6 +92,14 @@ function LinearGradientControls() { )} + + ); +} + +function LinearGradientControls() { + return ( + + ); } diff --git a/apps/docs/src/app/components/liquid-gradient/demo.tsx b/apps/docs/src/app/components/liquid-gradient/demo.tsx index da90272c..a1d2e623 100644 --- a/apps/docs/src/app/components/liquid-gradient/demo.tsx +++ b/apps/docs/src/app/components/liquid-gradient/demo.tsx @@ -49,9 +49,14 @@ function LiquidGradientDemo() { ); } -function LiquidGradientControls() { +/** + * The panel's controls without the panel around them. A composition's All + * props section mounts them under a stack entry's id, so each path lands in + * that entry's props. + */ +export function LiquidGradientFields() { return ( - + <>
@@ -91,6 +96,14 @@ function LiquidGradientControls() { )} + + ); +} + +function LiquidGradientControls() { + return ( + + ); } diff --git a/apps/docs/src/app/components/mesh-gradient/demo.tsx b/apps/docs/src/app/components/mesh-gradient/demo.tsx index d1d974ee..5021cd3a 100644 --- a/apps/docs/src/app/components/mesh-gradient/demo.tsx +++ b/apps/docs/src/app/components/mesh-gradient/demo.tsx @@ -50,9 +50,14 @@ function MeshGradientDemo() { ); } -function MeshGradientControls() { +/** + * The panel's controls without the panel around them. A composition's All + * props section mounts them under a stack entry's id, so each path lands in + * that entry's props. + */ +export function MeshGradientFields() { return ( - + <>
@@ -86,6 +91,14 @@ function MeshGradientControls() { > {() => } + + ); +} + +function MeshGradientControls() { + return ( + + ); } diff --git a/apps/docs/src/app/components/radial-gradient/demo.tsx b/apps/docs/src/app/components/radial-gradient/demo.tsx index a351bb49..539439bb 100644 --- a/apps/docs/src/app/components/radial-gradient/demo.tsx +++ b/apps/docs/src/app/components/radial-gradient/demo.tsx @@ -48,9 +48,14 @@ function RadialGradientDemo() { ); } -function RadialGradientControls() { +/** + * The panel's controls without the panel around them. A composition's All + * props section mounts them under a stack entry's id, so each path lands in + * that entry's props. + */ +export function RadialGradientFields() { return ( - + <>
@@ -90,6 +95,14 @@ function RadialGradientControls() { )} + + ); +} + +function RadialGradientControls() { + return ( + + ); } diff --git a/apps/docs/src/app/components/radial-wipe/demo.tsx b/apps/docs/src/app/components/radial-wipe/demo.tsx index e935f4d6..4d973b04 100644 --- a/apps/docs/src/app/components/radial-wipe/demo.tsx +++ b/apps/docs/src/app/components/radial-wipe/demo.tsx @@ -45,15 +45,28 @@ function RadialWipeDemo() { ); } -function RadialWipeControls() { +/** + * The panel's controls without the panel around them. A composition's All + * props section mounts them under a stack entry's id, so each path lands in + * that entry's props. + */ +export function RadialWipeFields() { return ( - + <>
- - + +
+ + ); +} + +function RadialWipeControls() { + return ( + + ); } diff --git a/apps/docs/src/app/components/radial-wipe/params.ts b/apps/docs/src/app/components/radial-wipe/params.ts index 2f9b7e83..20fa14db 100644 --- a/apps/docs/src/app/components/radial-wipe/params.ts +++ b/apps/docs/src/app/components/radial-wipe/params.ts @@ -1,13 +1,12 @@ export interface RadialWipeParams { progress: number; - centerX: number; - centerY: number; + /** The wipe's origin, as the component's `center` prop takes it. */ + center: [number, number]; feather: number; } export const INITIAL: RadialWipeParams = { progress: 0.5, - centerX: 0.5, - centerY: 1, + center: [0.5, 1], feather: 0.15, }; diff --git a/apps/docs/src/app/components/radial-wipe/scene.tsx b/apps/docs/src/app/components/radial-wipe/scene.tsx index 7b71e681..0ceb98c5 100644 --- a/apps/docs/src/app/components/radial-wipe/scene.tsx +++ b/apps/docs/src/app/components/radial-wipe/scene.tsx @@ -16,11 +16,7 @@ export default function RadialWipeScene({ return ( - + {children} ); diff --git a/apps/docs/src/app/components/seed-fields.test.ts b/apps/docs/src/app/components/seed-fields.test.ts new file mode 100644 index 00000000..dedf197a --- /dev/null +++ b/apps/docs/src/app/components/seed-fields.test.ts @@ -0,0 +1,64 @@ +/** + * The seeds that put a prop the All props fields can't edit as written into + * a shape they can, without changing what the component renders. + */ +import { describe, expect, it } from 'vitest'; + +import { seedFields } from '@/compositions/seed-fields'; + +import { seedCrossSection } from './fluted-glass/cross-section'; +import { seedStyleDials } from './fractal-noise/style-dials'; +import { seedLineColors } from './wave-lines/line-colors'; + +describe('seedCrossSection', () => { + it('turns a named cross-section into its dial value', () => { + expect(seedCrossSection({ crossSection: 'round' })).toEqual({ crossSection: 0.5 }); + expect(seedCrossSection({ crossSection: 'flat' })).toEqual({ crossSection: 1 }); + }); + + it('leaves a dial value alone', () => { + expect(seedCrossSection({ crossSection: 0.3 })).toEqual({ crossSection: 0.3 }); + }); +}); + +describe('seedStyleDials', () => { + it("fills in the style's contrast and balance", () => { + expect(seedStyleDials({ style: 'veins' })).toEqual({ + style: 'veins', + contrast: 1, + balance: 0.8, + }); + }); + + it('keeps a contrast the props set', () => { + expect(seedStyleDials({ style: 'veins', contrast: 3 })).toMatchObject({ contrast: 3 }); + }); +}); + +describe('seedLineColors', () => { + it('turns a single or omitted line color into a one-stop list', () => { + expect(seedLineColors({ lines: [{ color: '#ffffff' }, {}] })).toEqual({ + lines: [{ color: ['#ffffff'] }, { color: ['#ff6f6a'] }], + }); + }); + + it('leaves a list of stops alone', () => { + const lines = [{ color: ['#000000', '#ffffff'] }]; + + expect(seedLineColors({ lines })).toEqual({ lines }); + }); +}); + +describe('seedFields', () => { + it("runs the seed for the entry's component", () => { + expect( + seedFields({ id: 'glass', component: 'FlutedGlass', props: {} }, { crossSection: 'flat' }), + ).toEqual({ crossSection: 1 }); + }); + + it('leaves a component with no seed alone', () => { + expect(seedFields({ id: 'base', component: 'MeshGradient', props: {} }, { speed: 1 })).toEqual({ + speed: 1, + }); + }); +}); diff --git a/apps/docs/src/app/components/simplex-noise/demo.tsx b/apps/docs/src/app/components/simplex-noise/demo.tsx index 7b852477..7a13623f 100644 --- a/apps/docs/src/app/components/simplex-noise/demo.tsx +++ b/apps/docs/src/app/components/simplex-noise/demo.tsx @@ -52,9 +52,14 @@ function SimplexNoiseDemo() { ); } -function SimplexNoiseControls() { +/** + * The panel's controls without the panel around them. A composition's All + * props section mounts them under a stack entry's id, so each path lands in + * that entry's props. + */ +export function SimplexNoiseFields() { return ( - + <>
@@ -77,6 +82,14 @@ function SimplexNoiseControls() { > {() => } + + ); +} + +function SimplexNoiseControls() { + return ( + + ); } diff --git a/apps/docs/src/app/components/vignette/demo.tsx b/apps/docs/src/app/components/vignette/demo.tsx index b8244e32..d1c433df 100644 --- a/apps/docs/src/app/components/vignette/demo.tsx +++ b/apps/docs/src/app/components/vignette/demo.tsx @@ -44,9 +44,14 @@ function VignetteDemo() { ); } -function VignetteControls() { +/** + * The panel's controls without the panel around them. A composition's All + * props section mounts them under a stack entry's id, so each path lands in + * that entry's props. + */ +export function VignetteFields() { return ( - + <>
@@ -59,6 +64,14 @@ function VignetteControls() {
+ + ); +} + +function VignetteControls() { + return ( + + ); } diff --git a/apps/docs/src/app/components/voronoi/demo.tsx b/apps/docs/src/app/components/voronoi/demo.tsx index a89274d5..0ac744e5 100644 --- a/apps/docs/src/app/components/voronoi/demo.tsx +++ b/apps/docs/src/app/components/voronoi/demo.tsx @@ -52,9 +52,14 @@ function VoronoiDemo() { ); } -function VoronoiControls() { +/** + * The panel's controls without the panel around them. A composition's All + * props section mounts them under a stack entry's id, so each path lands in + * that entry's props. + */ +export function VoronoiFields() { return ( - + <>
@@ -88,6 +93,14 @@ function VoronoiControls() { > {() => } + + ); +} + +function VoronoiControls() { + return ( + + ); } diff --git a/apps/docs/src/app/components/wave-lines/demo.tsx b/apps/docs/src/app/components/wave-lines/demo.tsx index 4f508572..fd0716ac 100644 --- a/apps/docs/src/app/components/wave-lines/demo.tsx +++ b/apps/docs/src/app/components/wave-lines/demo.tsx @@ -72,9 +72,14 @@ function WaveLinesDemo() { ); } -function WaveLinesControls() { +/** + * The panel's controls without the panel around them. A composition's All + * props section mounts them under a stack entry's id, so each path lands in + * that entry's props. + */ +export function WaveLinesFields() { return ( - + <>
@@ -117,6 +122,14 @@ function WaveLinesControls() { )} + + ); +} + +function WaveLinesControls() { + return ( + + ); } diff --git a/apps/docs/src/app/components/wave-lines/line-colors.ts b/apps/docs/src/app/components/wave-lines/line-colors.ts new file mode 100644 index 00000000..fc91d896 --- /dev/null +++ b/apps/docs/src/app/components/wave-lines/line-colors.ts @@ -0,0 +1,29 @@ +/** + * WaveLines' line colors for a composition's All props section. A line's + * color can be one string, a list of stops, or left out, and the Colors list + * edits only a list, so the store needs every line's color as one. + */ +import { isRecord } from '@/compositions/store'; + +// The color shader.tsx draws a line in when its color is left out. The +// package doesn't export it. +const OMITTED_LINE_COLOR = '#ff6f6a'; + +/** Turns each line's single or omitted color into the one-stop list the component draws the same. */ +export function seedLineColors(props: Record): Record { + const { lines } = props; + + if (!Array.isArray(lines)) return props; + + return { + ...props, + lines: lines.map((line: unknown) => { + if (!isRecord(line) || Array.isArray(line.color)) return line; + + return { + ...line, + color: [typeof line.color === 'string' ? line.color : OMITTED_LINE_COLOR], + }; + }), + }; +} diff --git a/apps/docs/src/app/dev/composition-benchmark/benchmark-scene.tsx b/apps/docs/src/app/dev/composition-benchmark/benchmark-scene.tsx new file mode 100644 index 00000000..049b1479 --- /dev/null +++ b/apps/docs/src/app/dev/composition-benchmark/benchmark-scene.tsx @@ -0,0 +1,103 @@ +'use client'; + +// Benchmark for any composition and preset with ShaderMonitor's GPU readout, +// at the homepage hero's 1636 by 696 CSS pixels, where every composition must +// hold to the Gallery's GPU budget: 4 ms per frame at most and 2 ms as the +// goal, at 2x on the M1 Max. Read every preset, and keep the most expensive +// under the limit. `?composition=&preset=` picks what to draw, +// with the preset as its golden file's name, such as `default`, and `?dpr=2` +// caps the canvas at two pixels per CSS pixel, anything else at one. +import { useState } from 'react'; + +import { ShaderMonitor, useStaticSceneHint } from '@camp-dev/shaders'; + +import { COMPOSITIONS } from '@/compositions'; +import { CompositionScene } from '@/compositions/composition-scene'; +import { kebabCase } from '@/compositions/generate'; +import { presetNames, presetProps } from '@/compositions/store'; + +const WIDTH = 1636; +const HEIGHT = 696; + +interface Settings { + slug: string; + preset: string; + pixelRatio: number; +} + +const readSettings = (): Settings => { + const search = new URLSearchParams(window.location.search); + + return { + slug: search.get('composition') ?? COMPOSITIONS[0]?.slug ?? '', + preset: search.get('preset') ?? 'default', + pixelRatio: search.get('dpr') === '2' ? 2 : 1, + }; +}; + +/** + * Votes the scene animated, so a still scene keeps drawing frames and the GPU + * readout averages many frames instead of settling on one. + */ +function KeepDrawing() { + useStaticSceneHint(false); + + return null; +} + +const href = ({ slug, preset, pixelRatio }: Settings) => + `?composition=${slug}&preset=${preset}&dpr=${pixelRatio}`; + +// Time each reading with no other scene drawing, since a second scene +// inflates it (the skewed-readout gotcha in docs/agents/tsl.md). The links +// are full page loads, so each measurement starts from a fresh renderer. +export default function BenchmarkScene() { + const [settings] = useState(readSettings); + const composition = COMPOSITIONS.find(({ slug }) => slug === settings.slug); + const presetName = + composition === undefined + ? undefined + : presetNames(composition).find((name) => kebabCase(name) === settings.preset); + + return ( +
+
+ {COMPOSITIONS.map((candidate) => ( +

+ {candidate.title}:{' '} + {presetNames(candidate).map((name) => ( + + + {name} + {' '} + + ))} +

+ ))} +

+ {composition?.title ?? settings.slug} · {presetName ?? settings.preset} at {WIDTH} ×{' '} + {HEIGHT} CSS px, pixel ratio capped at {settings.pixelRatio}x.{' '} + 1x ·{' '} + 2x +

+
+ {composition === undefined || presetName === undefined ? ( +

No composition or preset by that name.

+ ) : ( +
+ {/* No library defaults here: the document's props and the + preset's override are what the copied file sets, and the + components fill in the rest themselves. */} + + + + +
+ )} +
+ ); +} diff --git a/apps/docs/src/app/dev/composition-benchmark/page.dev.tsx b/apps/docs/src/app/dev/composition-benchmark/page.dev.tsx new file mode 100644 index 00000000..0844673c --- /dev/null +++ b/apps/docs/src/app/dev/composition-benchmark/page.dev.tsx @@ -0,0 +1,12 @@ +'use client'; + +import dynamic from 'next/dynamic'; + +// three/webgpu references `self` at module load and cannot SSR, so the +// benchmark scene is loaded client-only. Dev route: invisible to production +// builds. +const BenchmarkScene = dynamic(() => import('./benchmark-scene'), { ssr: false }); + +export default function CompositionBenchmarkPage() { + return ; +} diff --git a/apps/docs/src/app/gallery/[slug]/composition-island.tsx b/apps/docs/src/app/gallery/[slug]/composition-island.tsx new file mode 100644 index 00000000..b0d80e08 --- /dev/null +++ b/apps/docs/src/app/gallery/[slug]/composition-island.tsx @@ -0,0 +1,91 @@ +'use client'; + +/** + * The interactive half of a composition page: the scene, behind its poster + * until the first frame, beside the Customize panel, sharing one store. The store starts from the library + * defaults the server page read for each entry, with the stack's props on + * top. The scene loads through next/dynamic with ssr: false, because the + * component map behind it reaches three/webgpu, which a server render can't + * load (the SSR gotcha in docs/agents/docs-site.md). + */ +import dynamic from 'next/dynamic'; +import { useMemo } from 'react'; + +import { ControlsProvider, DemoLayout, useSnapshot } from '@/components/controls'; +import { DemoPoster } from '@/components/DemoPoster'; +import type { Composition } from '@/compositions/define'; +import { compositionPoster } from '@/compositions/posters'; +import { seedFields } from '@/compositions/seed-fields'; +import { + type CompositionDefaults, + type CompositionState, + type CompositionStore, + createCompositionStore, +} from '@/compositions/store'; +import { VisualTestPause } from '@/lib/visualTestHooks'; + +import { CustomizePanel } from './customize-panel'; +import { useLoadedPreset } from './use-loaded-preset'; + +const CompositionScene = dynamic( + () => import('@/compositions/composition-scene').then((module) => module.CompositionScene), + { ssr: false }, +); + +/** + * Reads the whole store and draws the scene with it. The one full-snapshot + * subscription on the page, split out so a drag re-renders only this and the + * moved control, as controls/README.md describes. VisualTestPause remounts + * on every preset the picker loads, so the visual spec can capture each + * preset as a fresh, deterministic frame. + */ +function LiveScene({ composition, store }: { composition: Composition; store: CompositionStore }) { + const props = useSnapshot(); + const preset = useLoadedPreset(store); + + return ( + + + + + + ); +} + +export function CompositionIsland({ + composition, + defaults, + labels, +}: { + composition: Composition; + defaults: CompositionDefaults; + /** Each entry's component label, keyed by entry id, for All props. */ + labels: Readonly>; +}) { + const store = useMemo( + () => createCompositionStore(composition, defaults, seedFields), + [composition, defaults], + ); + + return ( + + + } + > +
+ +
+
+
+ ); +} diff --git a/apps/docs/src/app/gallery/[slug]/customize-panel.module.css b/apps/docs/src/app/gallery/[slug]/customize-panel.module.css new file mode 100644 index 00000000..6a3c7e8b --- /dev/null +++ b/apps/docs/src/app/gallery/[slug]/customize-panel.module.css @@ -0,0 +1,31 @@ +/* The Customize panel's own pieces. Everything else comes from the demo + panels' styles (components/controls/controls.module.css). */ + +/* An entry's fields under its name in All props: the panel's 16px gap + between their groups, as in a demo's own panel, with 8px of air under the + name. */ +.entryFields { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding: var(--spacing-2) 0 var(--spacing-2); +} + +/* In place of an entry's fields while one of its props follows the pointer. */ +.entryNote { + margin: 0; + color: var(--fg-muted); +} + +/* Copy React closes the panel: a hairline and 12px of air, as every group + opens with, then the site's text button stretched across the panel so it + reads as the panel's one action rather than one more control. */ +.copyRow { + padding-top: var(--spacing-3); + border-top: var(--border-width-1) solid var(--white-a5); +} + +.copyButton { + justify-content: center; + width: 100%; +} diff --git a/apps/docs/src/app/gallery/[slug]/customize-panel.tsx b/apps/docs/src/app/gallery/[slug]/customize-panel.tsx new file mode 100644 index 00000000..bc5acaee --- /dev/null +++ b/apps/docs/src/app/gallery/[slug]/customize-panel.tsx @@ -0,0 +1,282 @@ +'use client'; + +/** + * A composition's Customize panel: the preset picker, the curated controls, + * All props, and Copy React. Every control writes `.` in the + * composition's store and subscribes to its own leaf, per the demo-store + * gotcha in docs/agents/docs-site.md. + */ +import { useMemo, useSyncExternalStore } from 'react'; + +import { Collapsible } from '@base-ui/react/collapsible'; + +import { COMPONENT_FIELDS } from '@/app/components/demo-registry'; +import { + ColorInput, + ControlPanel, + NumberInput, + Section, + SelectInput, + SliderInput, +} from '@/components/controls'; +import { PathPrefixProvider } from '@/components/controls/context'; +import controlStyles from '@/components/controls/controls.module.css'; +import { SelectField } from '@/components/controls/SelectInput'; +import { normalizePath } from '@/components/controls/store'; +import { CaretRightIcon } from '@/components/icons/caret-right'; +import { CheckIcon } from '@/components/icons/check'; +import { ChevronDownIcon } from '@/components/icons/chevron-down'; +import { CopyIcon } from '@/components/icons/copy'; +import textButtonStyles from '@/components/text-button/text-button.module.css'; +import visuallyHiddenStyles from '@/components/visually-hidden/visually-hidden.module.css'; +import type { Composition, CompositionControl, StackEntry } from '@/compositions/define'; +import { generateFromStore } from '@/compositions/generate'; +import { type CompositionDefaults, type CompositionStore, presetNames } from '@/compositions/store'; +import { COPY_ANNOUNCEMENTS, useClipboardCopy } from '@/lib/use-clipboard-copy'; + +import styles from './customize-panel.module.css'; +import { useLoadedPreset } from './use-loaded-preset'; + +export function CustomizePanel({ + composition, + defaults, + labels, + store, +}: { + composition: Composition; + defaults: CompositionDefaults; + /** Each entry's component label, such as "Mesh Gradient", keyed by entry id. */ + labels: Readonly>; + store: CompositionStore; +}) { + return ( + + + {composition.controls.length === 0 ? null : ( +
+ {composition.controls.map((control) => ( + + ))} +
+ )} +
+ {composition.stack.map((entry) => ( + + ))} +
+ +
+ ); +} + +// ---------------------------------------------------------------------------- +// The preset picker +// ---------------------------------------------------------------------------- +// It shows the loaded preset, not whether the props still match it: after an +// edit it keeps the preset's name, and Reset goes back to that preset. + +function PresetPicker({ + composition, + store, +}: { + composition: Composition; + store: CompositionStore; +}) { + const preset = useLoadedPreset(store); + const options = presetNames(composition).map((name) => ({ + label: name, + value: name, + })); + + return ( + + ); +} + +// ---------------------------------------------------------------------------- +// Curated controls +// ---------------------------------------------------------------------------- + +/** + * One curated control, under its entry's id as a single path segment, so an + * id with a dot in it stays one key. It mounts only while its target exists: + * All props can remove the stop a control edits, a preset can load fewer, + * and a control that reads a missing path throws. + */ +function CuratedControl({ + control, + store, +}: { + control: CompositionControl; + store: CompositionStore; +}) { + const target = useMemo( + () => [control.entry, ...normalizePath(control.path)], + [control.entry, control.path], + ); + const read = () => { + try { + store.getAtPath(target); + + return true; + } catch { + return false; + } + }; + const present = useSyncExternalStore(store.subscribe, read, read); + + if (!present) return null; + + return ( + + + + ); +} + +function CuratedInput({ control }: { control: CompositionControl }) { + const { path } = control; + + switch (control.kind) { + case 'slider': + return ( + + ); + case 'number': + return ( + + ); + case 'select': + return ; + case 'color': + return ; + } +} + +// ---------------------------------------------------------------------------- +// All props +// ---------------------------------------------------------------------------- +// One collapsed disclosure per stack entry, in ListInput's collapsible-row +// style, holding that component's demo fields under the entry's id. The +// fields mount when the entry opens, as ListInput's rows do: a slider that +// mounts hidden measures a zero-width track and draws nothing. + +function EntryFields({ + entry, + label, + store, +}: { + entry: StackEntry; + label: string; + store: CompositionStore; +}) { + const { Fields } = COMPONENT_FIELDS[entry.component]; + const pointerProps = usePointerProps(store, entry.id); + + return ( + +
+ {/* Both glyphs render and CSS shows one, keyed off Base UI's + data-panel-open, as ListInput's rows do. */} + + + + {label} + +
+ +
+ {pointerProps === '' ? ( + + + + ) : ( +

+ Follows the pointer through {pointerProps}, which these fields edit as a point. Set it + to a point in the composition to edit {label} here. +

+ )} +
+
+
+ ); +} + +/** + * The entry's props set to `'cursor'`, the shorthand that follows the + * pointer, joined into one string so the subscription compares a primitive. + * A demo's fields edit a position as a point, such as `center.0`, and reading + * a coordinate out of the string would throw, so the entry shows a note in + * their place while any prop follows the pointer. + */ +function usePointerProps(store: CompositionStore, entryId: string): string { + const read = () => + Object.entries(store.getSnapshot()[entryId] ?? {}) + .filter(([, value]) => value === 'cursor') + .map(([name]) => name) + .join(', '); + + return useSyncExternalStore(store.subscribe, read, read); +} + +// ---------------------------------------------------------------------------- +// Copy React +// ---------------------------------------------------------------------------- +// Reads the store at click time rather than subscribing, the way the page +// header's Copy React does, so a drag never re-renders the button. + +// The copy glyph's 16px box, the size the header's button draws it at. +const COPY_ICON_SIZE = 16; + +function CopyReact({ + composition, + defaults, + store, +}: { + composition: Composition; + defaults: CompositionDefaults; + store: CompositionStore; +}) { + const { status, copy } = useClipboardCopy(); + + return ( +
+ + + {COPY_ANNOUNCEMENTS[status]} + +
+ ); +} diff --git a/apps/docs/src/app/gallery/[slug]/page.tsx b/apps/docs/src/app/gallery/[slug]/page.tsx new file mode 100644 index 00000000..5facc392 --- /dev/null +++ b/apps/docs/src/app/gallery/[slug]/page.tsx @@ -0,0 +1,74 @@ +/** + * One composition's page at /gallery/: its title, description, and + * Copy React over the composition and its Customize panel in the demo + * layout. The registry (compositions/index.ts) supplies the static params + * and every word on the page. The library defaults for each entry's + * component are read here, at build, because props.ts reads the component + * sources from disk, and the island seeds its store from them. + */ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; + +import { CopySourceProvider } from '@/components/controls'; +import { PageActions } from '@/components/page-actions/page-actions'; +import { COMPOSITIONS, findComposition } from '@/compositions'; +import { readCompositionDefaults } from '@/compositions/read-defaults'; +import { getComponentsCatalog } from '@/content/catalog'; + +// The component pages' header styles, so the two kinds of page share one +// header until the author designs the Gallery. +import styles from '../../components/[slug]/page.module.css'; +import { CompositionIsland } from './composition-island'; + +export const dynamicParams = false; + +interface PageProps { + params: Promise<{ slug: string }>; +} + +export function generateStaticParams() { + return COMPOSITIONS.map(({ slug }) => ({ slug })); +} + +export async function generateMetadata({ params }: PageProps): Promise { + const composition = findComposition((await params).slug); + + if (!composition) return {}; + + return { title: composition.title, description: composition.description }; +} + +export default async function CompositionPage({ params }: PageProps) { + const composition = findComposition((await params).slug); + + if (!composition) notFound(); + + const [defaults, catalog] = await Promise.all([ + readCompositionDefaults(composition), + getComponentsCatalog(), + ]); + // All props titles each entry with its component's label from the catalog. + const labels = Object.fromEntries( + composition.stack.map(({ id, component }) => [ + id, + catalog.find((record) => record.componentName === component)?.label ?? component, + ]), + ); + + return ( +
+ {/* The header's Copy React reads the island's store through this + provider; see the copy source in controls/context.tsx. */} + +
+
+

{composition.title}

+

{composition.description}

+
+ +
+ +
+
+ ); +} diff --git a/apps/docs/src/app/gallery/[slug]/use-loaded-preset.ts b/apps/docs/src/app/gallery/[slug]/use-loaded-preset.ts new file mode 100644 index 00000000..d535ca74 --- /dev/null +++ b/apps/docs/src/app/gallery/[slug]/use-loaded-preset.ts @@ -0,0 +1,14 @@ +'use client'; + +/** + * The loaded preset's name, from a composition's store. The picker shows it, + * and the scene remounts VisualTestPause on it, so each read subscribes to + * the name alone rather than to the props. + */ +import { useSyncExternalStore } from 'react'; + +import type { CompositionStore } from '@/compositions/store'; + +export function useLoadedPreset(store: CompositionStore): string { + return useSyncExternalStore(store.subscribe, store.getPreset, store.getPreset); +} diff --git a/apps/docs/src/app/gallery/gallery-grid.tsx b/apps/docs/src/app/gallery/gallery-grid.tsx new file mode 100644 index 00000000..6b597b28 --- /dev/null +++ b/apps/docs/src/app/gallery/gallery-grid.tsx @@ -0,0 +1,145 @@ +'use client'; + +/** + * The Gallery's plain grid, until the author designs it: a card per + * composition with its poster, title, and description, linking to its page. + * A card plays live under the pointer or keyboard focus, through the hover + * logic the homepage favorites share (lib/use-live-on-hover.ts). + */ +import Image from 'next/image'; +import Link from 'next/link'; +import { useId, useRef } from 'react'; + +import { ShaderPoster } from '@camp-dev/shaders/poster'; + +import type { CompositionScene as CompositionSceneComponent } from '@/compositions/composition-scene'; +import type { Composition } from '@/compositions/define'; +import { compositionPoster } from '@/compositions/posters'; +import { + engagementHandlers, + type OnEngagedChange, + useLiveOnHover, + useLoadedScene, +} from '@/lib/use-live-on-hover'; + +import styles from './page.module.css'; + +export function GalleryGrid({ compositions }: { compositions: readonly Composition[] }) { + const listRef = useRef(null); + const { canGoLive, mountedSlugs, playingSlug, setEngaged } = useLiveOnHover( + listRef, + preloadCompositionScene, + ); + + return ( +
    + {compositions.map((composition) => ( +
  • + + setEngaged(composition.slug, engagement, engaged) + } + playing={composition.slug === playingSlug} + sceneMounted={canGoLive && mountedSlugs.has(composition.slug)} + /> +
  • + ))} +
+ ); +} + +// The whole card is the link. Its name is the title alone and the +// description is attached as the link's description, as on the components +// index, so a screen reader says the title and then the description. The +// poster repeats the title in pictures, so its alt is empty. ShaderPoster +// keeps it over the window until the live scene's first frame. +function GalleryCard({ + composition, + sceneMounted, + playing, + onEngagedChange, +}: { + composition: Composition; + sceneMounted: boolean; + playing: boolean; + onEngagedChange: OnEngagedChange; +}) { + const id = useId(); + const titleId = `${id}-title`; + const descriptionId = `${id}-description`; + + return ( + +
+ + } + > + {sceneMounted && } + +
+ + {composition.title} + + + {composition.description} + + + ); +} + +// `data-paused` mirrors the scene's pause for the Playwright spec, as the +// favorites' scene layer does. +function LiveScene({ composition, paused }: { composition: Composition; paused: boolean }) { + const Scene = useCompositionScene(); + + return ( +
+ {Scene && } +
+ ); +} + +// --------------------------------------------- +// Loading the renderer, once +// --------------------------------------------- +// Every composition shares the one renderer module. It loads through +// import(), from the preload or a hover's effect, so three stays out of the +// server render (the SSR gotcha in docs/agents/docs-site.md); +// useLoadedScene says why not next/dynamic. + +type Scene = typeof CompositionSceneComponent; + +let loadedScene: Scene | null = null; + +function loadCompositionScene(): Promise { + return import('@/compositions/composition-scene').then((module) => { + loadedScene = module.CompositionScene; + + return module.CompositionScene; + }); +} + +/** Loads the renderer ahead of the first hover. A failure leaves it for the hover. */ +function preloadCompositionScene(): void { + loadCompositionScene().catch(() => undefined); +} + +/** The renderer, or null until its module has loaded. */ +const useCompositionScene = () => useLoadedScene(loadedScene, loadCompositionScene); diff --git a/apps/docs/src/app/gallery/layout.tsx b/apps/docs/src/app/gallery/layout.tsx new file mode 100644 index 00000000..98582226 --- /dev/null +++ b/apps/docs/src/app/gallery/layout.tsx @@ -0,0 +1,19 @@ +/** + * The Gallery's frame: the section banner and the docs shell that the + * components pages use, with no design of its own until the author designs + * the Gallery. The sidebar lists the components, which a composition is + * built from. + */ +import type { ReactNode } from 'react'; + +import { DocsShell } from '@/components/docs-shell/docs-shell'; +import { SectionBanner } from '@/components/section-banner/section-banner'; + +export default function GalleryLayout({ children }: { children: ReactNode }) { + return ( + <> + + {children} + + ); +} diff --git a/apps/docs/src/app/gallery/page.module.css b/apps/docs/src/app/gallery/page.module.css new file mode 100644 index 00000000..cd13f78b --- /dev/null +++ b/apps/docs/src/app/gallery/page.module.css @@ -0,0 +1,62 @@ +/* The Gallery's plain grid, until the author designs it: a card per + composition, its 3:2 poster over its title and description, as many + across as fit at 18rem or wider, capped at the 4xl column the composition + pages share. */ +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 18rem), 1fr)); + gap: var(--spacing-6); + max-width: var(--container-4xl); + margin: var(--spacing-6) 0 var(--spacing-16); + padding: 0; + list-style: none; +} + +.card { + display: flex; + flex-direction: column; + gap: var(--spacing-1); + color: var(--fg); +} + +/* The site underlines a link on hover (globals.css); a card is a block, not + a line of text, so it does not, as on the components index. */ +.card:hover { + text-decoration: none; +} + +.card:focus-visible { + outline: var(--border-width-2) solid var(--accent); + outline-offset: var(--border-width-2); + border-radius: var(--radius-xl); +} + +/* The window the poster and the live scene share, at the posters' 3:2. */ +.window { + position: relative; + aspect-ratio: 3 / 2; + margin-bottom: var(--spacing-2); + overflow: hidden; + border-radius: var(--radius-xl); + background: var(--bg-muted); +} + +.poster { + object-fit: cover; +} + +/* The live scene fills the window under the poster. ShaderScene's canvas is + absolutely positioned, so it needs a positioned box with a size. */ +.scene { + position: absolute; + inset: 0; +} + +.title { + font-weight: var(--font-weight-semibold); +} + +.description { + color: var(--fg-muted); + line-height: var(--leading-6); +} diff --git a/apps/docs/src/app/gallery/page.tsx b/apps/docs/src/app/gallery/page.tsx new file mode 100644 index 00000000..73abeb44 --- /dev/null +++ b/apps/docs/src/app/gallery/page.tsx @@ -0,0 +1,31 @@ +/** + * The Gallery at /gallery: every composition as a card in the registry's + * order (compositions/index.ts), linking to its page. The grid is plain until + * the author designs the Gallery. The registry is three-free, so this page + * reads it on the server and hands the documents to the client grid, which + * plays a card live on hover. + */ +import type { Metadata } from 'next'; + +import visuallyHiddenStyles from '@/components/visually-hidden/visually-hidden.module.css'; +import { COMPOSITIONS } from '@/compositions'; + +import { GalleryGrid } from './gallery-grid'; + +export const metadata: Metadata = { + title: 'Gallery', + description: 'Finished compositions of Shaders components, ready to tweak and copy.', +}; + +export default function GalleryPage() { + return ( +
+ {/* The section banner prints "Gallery" as a paragraph, because it also + sits over every composition page, where the composition's title is + the h1. This gives the grid its h1 without a second visible title, + as on the components index. */} +

Gallery

+ +
+ ); +} diff --git a/apps/docs/src/components/controls/ControlPanel.tsx b/apps/docs/src/components/controls/ControlPanel.tsx index fdcbb92b..a94972c4 100644 --- a/apps/docs/src/components/controls/ControlPanel.tsx +++ b/apps/docs/src/components/controls/ControlPanel.tsx @@ -1,12 +1,12 @@ 'use client'; /** - * The panel shell: the "Shader Controls" title row with its Reset button, - * then whatever controls the page puts inside it. The Copy JSX and Copy - * params buttons that used to live here are moving to the page header's - * copy menu (SHA-115); copy.ts keeps the formatters ready for that. + * The panel shell: the title row with its Reset button, then whatever + * controls the page puts inside it. Demo pages keep the "Shader Controls" + * title, and a composition's page titles its panel "Customize". The title + * also names the panel's group for a screen reader. */ -import type { ReactNode } from 'react'; +import { type ReactNode, useId } from 'react'; import { ResetIcon } from '@/components/icons/reset'; import textButtonStyles from '@/components/text-button/text-button.module.css'; @@ -14,13 +14,22 @@ import textButtonStyles from '@/components/text-button/text-button.module.css'; import styles from './controls.module.css'; import { useResetControls } from './useControl'; -export function ControlPanel({ children }: { children: ReactNode }) { +export function ControlPanel({ + title = 'Shader Controls', + children, +}: { + title?: string; + children: ReactNode; +}) { const reset = useResetControls(); + const titleId = useId(); return ( -
+
-

Shader Controls

+

+ {title} +

); } diff --git a/apps/docs/src/components/used-in/used-in.module.css b/apps/docs/src/components/used-in/used-in.module.css new file mode 100644 index 00000000..66e95b6b --- /dev/null +++ b/apps/docs/src/components/used-in/used-in.module.css @@ -0,0 +1,61 @@ +/* The provisional "Used in" strip: a small heading over one row of + thumbnail-and-title links that wraps on a narrow column. A first pass for + the author to judge, in the site's existing type and chip styles. */ +.strip { + display: flex; + flex-direction: column; + gap: var(--spacing-3); +} + +.heading { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--fg-muted); +} + +.list { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-2); + margin: 0; + padding: 0; + list-style: none; +} + +/* A chip: the thumbnail and the title in one bordered box, the components + index's card at a smaller size. */ +.item { + display: flex; + align-items: center; + gap: var(--spacing-2); + padding: var(--spacing-1) var(--spacing-3) var(--spacing-1) var(--spacing-1); + border: var(--border-width-1) solid var(--white-a5); + border-radius: var(--radius-lg); + background: var(--bg-muted); + color: var(--fg); + font-size: var(--font-size-sm); + transition: + background-color var(--fade-xs) var(--ease-hover), + border-color var(--fade-xs) var(--ease-hover); +} + +.item:hover { + text-decoration: none; +} + +@media (hover: hover) and (pointer: fine) { + .item:hover { + border-color: var(--white-a10); + background: var(--gray-800); + } +} + +.item:focus-visible { + outline: var(--border-width-2) solid var(--accent); + outline-offset: var(--border-width-2); +} + +.thumbnail { + flex: none; + border-radius: var(--radius-sm); +} diff --git a/apps/docs/src/components/used-in/used-in.tsx b/apps/docs/src/components/used-in/used-in.tsx new file mode 100644 index 00000000..19d48416 --- /dev/null +++ b/apps/docs/src/components/used-in/used-in.tsx @@ -0,0 +1,51 @@ +/** + * The "Used in" strip on a component's reference page: one compact row of + * the compositions whose stack includes the component, each a thumbnail and + * a title linking to its Gallery page. Provisional: the author judges it on + * the dev server, then keeps it, redesigns it, or drops it, so it stays one + * small server component with no client code. It renders nothing when no + * composition uses the component. + */ +import Image from 'next/image'; +import Link from 'next/link'; +import { useId } from 'react'; + +import type { Composition } from '@/compositions/define'; +import { compositionThumbnail } from '@/compositions/posters'; + +import styles from './used-in.module.css'; + +// The thumbnail's edge in CSS pixels. scripts/build-thumbnail.mjs cuts every +// thumbnail at 124px for the components index's 62px cards, so this smaller +// chip stays sharp up to a 3x screen. +const THUMBNAIL_SIZE = 40; + +export function UsedIn({ compositions }: { compositions: readonly Composition[] }) { + const headingId = useId(); + + if (compositions.length === 0) return null; + + return ( +
+

+ Used in +

+
    + {compositions.map(({ slug, title }) => ( +
  • + + + {title} + +
  • + ))} +
+
+ ); +} diff --git a/apps/docs/src/compositions/component-map.test.ts b/apps/docs/src/compositions/component-map.test.ts new file mode 100644 index 00000000..9ed7dd14 --- /dev/null +++ b/apps/docs/src/compositions/component-map.test.ts @@ -0,0 +1,50 @@ +/** + * Keeps the component map in step with the docs' own list of components. + * The map imports every component, which loads three/webgpu, and the docs' + * vitest runs in plain Node, where three throws at module load. So the test + * reads the map's source with the TypeScript compiler, the way props.ts + * reads the component wrappers, and compares its keys with each catalog + * record's componentName, which content/catalog.ts derives from the slug. + */ +import { readFile } from 'node:fs/promises'; +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; + +import { getComponentsCatalog } from '@/content/catalog'; + +async function readComponentMapKeys(): Promise { + const source = await readFile(new URL('./component-map.ts', import.meta.url), 'utf8'); + const file = ts.createSourceFile('component-map.ts', source, ts.ScriptTarget.Latest, true); + + for (const statement of file.statements) { + if (!ts.isVariableStatement(statement)) continue; + + for (const declaration of statement.declarationList.declarations) { + const { name, initializer } = declaration; + + if (ts.isIdentifier(name) && name.text === 'COMPONENT_MAP' && initializer !== undefined) { + if (!ts.isObjectLiteralExpression(initializer)) { + throw new Error('COMPONENT_MAP is no longer an object literal'); + } + + return initializer.properties.map((property) => { + if (property.name === undefined || !ts.isIdentifier(property.name)) { + throw new Error(`COMPONENT_MAP has a key this test can't read: ${property.getText()}`); + } + + return property.name.text; + }); + } + } + } + + throw new Error('component-map.ts no longer declares COMPONENT_MAP'); +} + +describe('COMPONENT_MAP', () => { + it('maps every component in COMPONENTS by its export name, and nothing else', async () => { + const catalogNames = (await getComponentsCatalog()).map((record) => record.componentName); + + expect((await readComponentMapKeys()).sort()).toEqual(catalogNames.sort()); + }); +}); diff --git a/apps/docs/src/compositions/component-map.ts b/apps/docs/src/compositions/component-map.ts new file mode 100644 index 00000000..41dea474 --- /dev/null +++ b/apps/docs/src/compositions/component-map.ts @@ -0,0 +1,59 @@ +'use client'; + +/** + * Every public Source and Effect, keyed by export name, which CompositionScene + * draws each stack entry's component from. Documents reach it only through + * `import type` (define.ts), because it loads three/webgpu. A test keeps its + * keys in step with COMPONENTS in content/components.ts. + */ +import { + Aurora, + Blobs, + ConicGradient, + CursorRipple, + CursorSmudge, + CursorSpotlight, + Dissolve, + Dither, + DotField, + FlutedGlass, + FractalNoise, + GodRays, + Grain, + LedWall, + LinearGradient, + LiquidGradient, + MeshGradient, + RadialGradient, + RadialWipe, + SimplexNoise, + Vignette, + Voronoi, + WaveLines, +} from '@camp-dev/shaders'; + +export const COMPONENT_MAP = { + Aurora, + Blobs, + ConicGradient, + CursorRipple, + CursorSmudge, + CursorSpotlight, + Dissolve, + Dither, + DotField, + FlutedGlass, + FractalNoise, + GodRays, + Grain, + LedWall, + LinearGradient, + LiquidGradient, + MeshGradient, + RadialGradient, + RadialWipe, + SimplexNoise, + Vignette, + Voronoi, + WaveLines, +}; diff --git a/apps/docs/src/compositions/composition-scene.tsx b/apps/docs/src/compositions/composition-scene.tsx new file mode 100644 index 00000000..59405b13 --- /dev/null +++ b/apps/docs/src/compositions/composition-scene.tsx @@ -0,0 +1,59 @@ +'use client'; + +/** + * The renderer every composition shares: a ShaderScene with the document's + * scene props, then each stack entry's component in stack order, keyed by + * its id. It reaches three through the component map, so callers load it + * through next/dynamic with ssr: false, or through import() for a scene that + * mounts on hover, per the SSR gotcha in docs/agents/docs-site.md. + */ +import type { ComponentType, ReactNode } from 'react'; + +import { ShaderScene } from '@camp-dev/shaders'; + +import { COMPONENT_MAP } from './component-map'; +import type { Composition, EntryProps } from './define'; +import type { CompositionState } from './store'; + +export interface CompositionSceneProps { + composition: Composition; + /** + * The props to draw each entry with, keyed by entry id. Absent, each entry + * draws with the document's own props, which is the Default preset. + */ + props?: CompositionState; + /** Freezes the scene on its current frame, passed to ShaderScene. */ + paused?: boolean; + /** Caps the canvas's pixels per CSS pixel, passed to ShaderScene. The benchmark sets it. */ + maxDPR?: number; + /** Mounted after the stack, such as VisualTestPause or a benchmark's hint. */ + children?: ReactNode; +} + +export function CompositionScene({ + composition, + props, + paused, + maxDPR, + children, +}: CompositionSceneProps) { + const { scene } = composition; + + return ( + + {composition.stack.map((entry) => { + // Widened to take any entry's props. defineComposition already + // checked each entry's props against its own component. + const Component = COMPONENT_MAP[entry.component] as ComponentType; + + return ; + })} + {children} + + ); +} diff --git a/apps/docs/src/compositions/define.test.ts b/apps/docs/src/compositions/define.test.ts new file mode 100644 index 00000000..a030e6b0 --- /dev/null +++ b/apps/docs/src/compositions/define.test.ts @@ -0,0 +1,283 @@ +// @vitest-environment node +/** + * Type tests for defineComposition. Each @ts-expect-error below is one of the + * mistakes the document's types exist to catch, so `pnpm typecheck` fails if + * any of them starts compiling. The runtime half only checks that a valid + * document comes back unchanged, since defineComposition does nothing else. + */ +import type { AnimatableProp, PositionProp } from '@camp-dev/shaders'; +import { describe, expect, expectTypeOf, it } from 'vitest'; + +import { defineComposition, type LiteralPropsOf } from './define'; + +const base = { slug: 'test', title: 'Test', description: 'A test composition.' }; + +describe('LiteralPropsOf', () => { + // No public component takes a callback today, so a props shape of its own + // stands in for one that will. + it('drops callback props and the signal half of an animatable prop', () => { + interface Props { + speed?: AnimatableProp; + center?: PositionProp; + stops?: Array<{ color: string; position?: number }>; + onDone?: () => void; + } + + expectTypeOf>().toEqualTypeOf<{ + readonly speed?: number; + readonly center?: readonly [number, number] | 'cursor'; + readonly stops?: ReadonlyArray<{ readonly color: string; readonly position?: number }>; + }>(); + }); +}); + +describe('defineComposition', () => { + it('returns a valid document unchanged', () => { + const document = { + ...base, + stack: [ + { + id: 'base', + component: 'MeshGradient', + props: { + speed: 0.4, + palettes: [ + ['#000', '#111', '#222', '#333'], + ['#444', '#555', '#666', '#777'], + ], + }, + }, + { id: 'grain', component: 'Grain', props: { intensity: 0.08, blend: 'subtractive' } }, + { id: 'wipe', component: 'RadialWipe', props: { center: 'cursor' } }, + ], + scene: { scrollPush: true, maxFPS: 30 }, + presets: [{ name: 'Still', props: { base: { speed: 0 }, grain: { intensity: 0 } } }], + controls: [ + { + kind: 'slider', + entry: 'base', + path: 'speed', + label: 'Drift', + min: 0, + max: 2, + step: 0.01, + }, + { + kind: 'number', + entry: 'grain', + path: 'intensity', + label: 'Grain', + min: 0, + max: 1, + step: 0.01, + }, + { kind: 'color', entry: 'base', path: 'palettes.0.2', label: 'Accent' }, + { + kind: 'select', + entry: 'grain', + path: 'blend', + label: 'Blend', + options: [ + { label: 'Additive', value: 'additive' }, + { label: 'Subtractive', value: 'subtractive' }, + ], + }, + ], + } as const; + + expect(defineComposition(document)).toBe(document); + }); + + it('rejects a component name the component map lacks', () => { + defineComposition({ + ...base, + // @ts-expect-error -- 'MeshGradiant' is not a public Source or Effect + stack: [{ id: 'base', component: 'MeshGradiant', props: {} }], + presets: [], + controls: [], + }); + }); + + it('rejects a prop the component does not have', () => { + defineComposition({ + ...base, + // @ts-expect-error -- MeshGradient has no `sped` prop + stack: [{ id: 'base', component: 'MeshGradient', props: { speed: 1, sped: 2 } }], + presets: [], + controls: [], + }); + }); + + it('rejects a value of the wrong type', () => { + defineComposition({ + ...base, + // @ts-expect-error -- speed is a number + stack: [{ id: 'base', component: 'MeshGradient', props: { speed: 'fast' } }], + presets: [], + controls: [], + }); + }); + + it('rejects a signal where a literal belongs', () => { + const signal = { get: () => 1, on: () => () => undefined }; + + defineComposition({ + ...base, + // @ts-expect-error -- a signal is not a literal + stack: [{ id: 'base', component: 'MeshGradient', props: { speed: signal } }], + presets: [], + controls: [], + }); + }); + + it('rejects a preset that names an entry the stack lacks', () => { + defineComposition({ + ...base, + stack: [{ id: 'base', component: 'MeshGradient', props: {} }], + // @ts-expect-error -- the stack has no `grain` entry + presets: [{ name: 'Loud', props: { grain: { intensity: 1 } } }], + controls: [], + }); + }); + + it('rejects a preset prop the entry does not have', () => { + defineComposition({ + ...base, + stack: [{ id: 'base', component: 'MeshGradient', props: {} }], + // @ts-expect-error -- MeshGradient has no `intensity` prop + presets: [{ name: 'Loud', props: { base: { intensity: 1 } } }], + controls: [], + }); + }); + + it('rejects a control that names an entry the stack lacks', () => { + defineComposition({ + ...base, + stack: [{ id: 'base', component: 'MeshGradient', props: {} }], + presets: [], + controls: [ + { + kind: 'slider', + // @ts-expect-error -- the stack has no `grain` entry + entry: 'grain', + path: 'speed', + label: 'Grain', + min: 0, + max: 1, + step: 0.1, + }, + ], + }); + }); + + it('rejects a control path the entry does not have', () => { + defineComposition({ + ...base, + stack: [{ id: 'base', component: 'MeshGradient', props: {} }], + presets: [], + controls: [ + // @ts-expect-error -- MeshGradient has no `sped` prop + { kind: 'slider', entry: 'base', path: 'sped', label: 'Drift', min: 0, max: 2, step: 0.1 }, + ], + }); + }); + + it('rejects a slider on a prop that is not a number', () => { + defineComposition({ + ...base, + stack: [{ id: 'base', component: 'MeshGradient', props: {} }], + presets: [], + controls: [ + // @ts-expect-error -- colorSpace is a string union + { + kind: 'slider', + entry: 'base', + path: 'colorSpace', + label: 'Space', + min: 0, + max: 2, + step: 0.1, + }, + ], + }); + }); + + it('rejects a color control on a prop that is not a string', () => { + defineComposition({ + ...base, + stack: [{ id: 'base', component: 'MeshGradient', props: {} }], + presets: [], + // @ts-expect-error -- speed is a number + controls: [{ kind: 'color', entry: 'base', path: 'speed', label: 'Speed' }], + }); + }); + + it('rejects a color control on a string union', () => { + defineComposition({ + ...base, + stack: [{ id: 'grain', component: 'Grain', props: {} }], + presets: [], + // @ts-expect-error -- blend is a GrainBlend, which a select edits + controls: [{ kind: 'color', entry: 'grain', path: 'blend', label: 'Blend' }], + }); + }); + + it('rejects a select option outside the prop union', () => { + defineComposition({ + ...base, + stack: [{ id: 'grain', component: 'Grain', props: {} }], + presets: [], + controls: [ + { + kind: 'select', + entry: 'grain', + path: 'blend', + label: 'Blend', + // @ts-expect-error -- 'overlay' is not a GrainBlend + options: [{ label: 'Overlay', value: 'overlay' }], + }, + ], + }); + }); + + it('rejects a select on a prop that can also be a number', () => { + defineComposition({ + ...base, + stack: [{ id: 'glass', component: 'FlutedGlass', props: {} }], + presets: [], + controls: [ + // @ts-expect-error -- crossSection is a dial value as well as a name + { kind: 'select', entry: 'glass', path: 'crossSection', label: 'Cut', options: [] }, + ], + }); + }); + + it('accepts a slider on a prop that can also be a name', () => { + defineComposition({ + ...base, + stack: [{ id: 'glass', component: 'FlutedGlass', props: {} }], + presets: [], + controls: [ + { + kind: 'slider', + entry: 'glass', + path: 'crossSection', + label: 'Cut', + min: 0, + max: 1, + step: 0.01, + }, + ], + }); + }); + + it('rejects a slider without its range', () => { + defineComposition({ + ...base, + stack: [{ id: 'base', component: 'MeshGradient', props: {} }], + presets: [], + // @ts-expect-error -- a slider needs min, max, and step + controls: [{ kind: 'slider', entry: 'base', path: 'speed', label: 'Drift' }], + }); + }); +}); diff --git a/apps/docs/src/compositions/define.ts b/apps/docs/src/compositions/define.ts new file mode 100644 index 00000000..5d6246a4 --- /dev/null +++ b/apps/docs/src/compositions/define.ts @@ -0,0 +1,321 @@ +/** + * The composition document, as ADR 0005 lays it out: a named stack of public + * Sources and Effects with set props, which each `/composition.ts` + * passes to defineComposition. The types make a wrong name, prop, value, or + * control path fail to compile, and nothing here reaches three at runtime. + */ +import type { ComponentProps } from 'react'; + +import type { AnimatableSignal } from '@camp-dev/shaders'; + +import type { COMPONENT_MAP } from './component-map'; + +type ComponentMap = typeof COMPONENT_MAP; + +/** A public Source or Effect, by its export name, such as `'MeshGradient'`. */ +export type ComponentName = keyof ComponentMap; + +// ---------------------------------------------------------------------------- +// Literal props +// ---------------------------------------------------------------------------- + +/** + * A prop type cut down to what a document can write as a literal: numbers, + * strings, booleans, and arrays or plain objects of those. A callback or an + * animation signal becomes `never`, so `AnimatableProp` keeps only its + * `number` half and a callback prop drops out of LiteralProps entirely. The + * `'cursor'` shorthand on a position prop is a string, so it stays. Arrays + * come out readonly, because `defineComposition` infers its stack as `const`, + * and a readonly literal array is not assignable to a mutable one. + */ +type LiteralOf = T extends (...args: never[]) => unknown + ? never + : T extends AnimatableSignal + ? never + : T extends readonly unknown[] + ? { readonly [K in keyof T]: LiteralOf } + : T extends object + ? { readonly [K in keyof T]: LiteralOf } + : T; + +/** A props type with each prop cut to its literal half, and callback props dropped. */ +export type LiteralPropsOf = { + readonly [K in keyof Props as [LiteralOf>] extends [never] + ? never + : K]?: LiteralOf>; +}; + +/** Every prop of a component that a document can set, each one optional. */ +export type LiteralProps = LiteralPropsOf>; + +// ---------------------------------------------------------------------------- +// The stack +// ---------------------------------------------------------------------------- + +/** + * One Source or Effect in the stack. `id` names the entry for presets, + * controls, and the store, and is unique in the composition. The copied + * file never prints it. Never call an entry a layer: the glossary term for + * the whole list is Stack. + */ +export type StackEntry = { + [N in ComponentName]: { + readonly id: string; + readonly component: N; + readonly props: LiteralProps; + }; +}[ComponentName]; + +/** + * The stack with every prop its component doesn't have typed as `never`. + * The stack is inferred from the document itself, so ordinary excess-property + * checking never runs on it, and a typo such as `sped` would otherwise pass. + * CompositionDocument intersects the inferred stack with this, which puts the + * error on the typo. NoInfer keeps this half out of inference, so the + * stack's ids stay literal. + */ +type ExactStack = { + readonly [I in keyof TStack]: ExactEntry; +}; + +type ExactEntry = Entry extends StackEntry + ? { + readonly id: Entry['id']; + readonly component: Entry['component']; + readonly props: LiteralProps & + Readonly< + Record>, never> + >; + } + : Entry; + +/** `` props the document sets. No control or preset can change them. */ +export interface SceneProps { + readonly scrollPush?: boolean; + readonly maxFPS?: number; +} + +// ---------------------------------------------------------------------------- +// Presets +// ---------------------------------------------------------------------------- + +/** + * A partial override per entry id. Loading a preset resets to the stack's + * props and then applies the override, so a loaded preset is always + * complete. A preset can't add, remove, or reorder entries. + */ +interface PresetOf { + readonly name: string; + readonly props: { + readonly [Entry in TStack[number] as Entry['id']]?: LiteralProps; + }; +} + +// ---------------------------------------------------------------------------- +// Control paths +// ---------------------------------------------------------------------------- +// A control's `path` is dotted, such as 'palettes.0.2', and each segment walks +// one level into the entry's props. PathEntries lists every path a props +// type has as a union of [path, value] pairs, so a control kind can keep the +// paths whose value it can edit. Tuples list their own indices, and plain +// arrays accept any index through `${number}`. Four levels covers the +// deepest prop today, a WaveLines line's color list. + +type MaxPathDepth = 4; + +type PathEntries = Depth['length'] extends MaxPathDepth + ? never + : T extends readonly unknown[] + ? number extends T['length'] + ? PathEntriesUnder<`${number}`, T[number], Depth> + : { [K in keyof T & `${number}`]: PathEntriesUnder }[keyof T & `${number}`] + : T extends object + ? { [K in keyof T & string]-?: PathEntriesUnder, Depth> }[keyof T & + string] + : never; + +type PathEntriesUnder = + | [Key, Value] + | (PathEntries extends infer Sub + ? Sub extends [infer SubPath extends string, infer SubValue] + ? [`${Key}.${SubPath}`, SubValue] + : never + : never); + +/** + * The paths into `Props` whose value can be a number, which is what a slider + * or a number input can edit. FlutedGlass's `crossSection` counts, because + * the store seeds its names to dial values, so the panel only holds numbers. + */ +type NumberPaths = + PathEntries extends infer Entry + ? Entry extends [infer Path, infer Value] + ? [Extract] extends [never] + ? never + : Path + : never + : never; + +/** + * The paths into `Props` whose value is a union of string literals, which is + * what a select can offer. A plain `string`, such as a color, has no options, + * and a value that can also be a number, such as FlutedGlass's + * `crossSection`, belongs to a slider. + */ +type SelectPaths = + PathEntries extends infer Entry + ? Entry extends [infer Path, infer Value] + ? [Value] extends [string] + ? string extends Value + ? never + : Path + : never + : never + : never; + +/** + * The paths into `Props` whose value is a plain `string`, which is what a + * color control can edit. A union of string literals, such as Grain's + * `blend`, belongs to a select. + */ +type ColorPaths = + PathEntries extends infer Entry + ? Entry extends [infer Path, infer Value] + ? [Value] extends [string] + ? string extends Value + ? Path + : never + : never + : never + : never; + +type ValueAt = Extract, [Path, unknown]>[1]; + +// ---------------------------------------------------------------------------- +// Controls +// ---------------------------------------------------------------------------- +// A control sets exactly one prop, the first segment of its path, which is +// the glossary's rule for a composition's controls. + +interface ControlBase { + /** The stack entry the control edits, by id. */ + readonly entry: Id; + /** A dotted path into that entry's props, such as `'speed'` or `'palettes.0.2'`. */ + readonly path: Path; + readonly label: string; +} + +interface RangeControl extends ControlBase { + readonly min: number; + readonly max: number; + readonly step: number; +} + +export interface SliderControl< + Id extends string = string, + Path extends string = string, +> extends RangeControl { + readonly kind: 'slider'; +} + +export interface NumberControl< + Id extends string = string, + Path extends string = string, +> extends RangeControl { + readonly kind: 'number'; +} + +export interface SelectControl< + Id extends string = string, + Path extends string = string, + Value extends string = string, +> extends ControlBase { + readonly kind: 'select'; + readonly options: ReadonlyArray<{ readonly label: string; readonly value: Value }>; +} + +export interface ColorControl< + Id extends string = string, + Path extends string = string, +> extends ControlBase { + readonly kind: 'color'; +} + +/** Any control, with its entry and path widened to strings, as the panel reads it. */ +export type CompositionControl = SliderControl | NumberControl | SelectControl | ColorControl; + +type SelectControlsOf = { + [Path in SelectPaths & string]: SelectControl< + Id, + Path, + Extract, string> + >; +}[SelectPaths & string]; + +type ControlsOfEntry = + | SliderControl & string> + | NumberControl & string> + | ColorControl & string> + | SelectControlsOf; + +type ControlOf = TStack[number] extends infer Entry + ? Entry extends StackEntry + ? ControlsOfEntry> + : never + : never; + +// ---------------------------------------------------------------------------- +// The document +// ---------------------------------------------------------------------------- + +interface CompositionMetadata { + /** Sets the URL, `/gallery/`, and the file names. */ + readonly slug: string; + /** The heading, the card label, and, in PascalCase, the copied component's name. */ + readonly title: string; + /** One sentence for the card, the page lede, and the meta description. */ + readonly description: string; +} + +/** What a composition file passes to defineComposition. */ +interface CompositionDocument extends CompositionMetadata { + /** First entry at the bottom. Each Effect acts on what the entries before it drew. */ + readonly stack: TStack & NoInfer>; + readonly scene?: SceneProps; + /** The picker lists "Default", the stack's own props, before these. */ + readonly presets: NoInfer>>; + /** The curated controls, in panel order. */ + readonly controls: NoInfer>>; +} + +/** One entry's props as the renderer, the store, and the generator read them. */ +export type EntryProps = Readonly>; + +/** A preset with its entry ids widened to strings. */ +export interface CompositionPreset { + readonly name: string; + readonly props: Readonly>; +} + +/** + * A composition as everything downstream of its own file reads it: the + * registry, the renderer, the panel, and the generator. Ids and paths are + * plain strings here, because defineComposition has already checked them. + */ +export interface Composition extends CompositionMetadata { + readonly stack: readonly StackEntry[]; + readonly scene?: SceneProps; + readonly presets: readonly CompositionPreset[]; + readonly controls: readonly CompositionControl[]; +} + +/** + * Checks a composition document against its components and returns it. + * `const` keeps each entry's id as a literal, so presets and controls can be + * checked against the ids the stack actually has. + */ +export function defineComposition( + document: CompositionDocument, +): Composition { + return document; +} diff --git a/apps/docs/src/compositions/fixture/composition.ts b/apps/docs/src/compositions/fixture/composition.ts new file mode 100644 index 00000000..8328e63c --- /dev/null +++ b/apps/docs/src/compositions/fixture/composition.ts @@ -0,0 +1,24 @@ +/** + * The fixture composition: a MeshGradient under Grain, there so the + * composition page, the Gallery grid, the golden files, and the visual spec + * have something real to draw while the pipeline is built. Composition 1's + * assembly ticket deletes it, with its golden files, poster, and baselines. + */ +import { defineComposition } from '../define'; + +export const fixture = defineComposition({ + slug: 'fixture', + title: 'Fixture', + description: 'A MeshGradient under Grain, for building the pipeline.', + stack: [ + { id: 'base', component: 'MeshGradient', props: { speed: 0.4 } }, + { id: 'grain', component: 'Grain', props: { intensity: 0.08 } }, + ], + presets: [ + { name: 'Tight', props: { base: { frequency: 12, amplitude: 15 } } }, + { name: 'Coarse', props: { grain: { intensity: 0.3 } } }, + ], + controls: [ + { kind: 'slider', entry: 'base', path: 'speed', label: 'Drift', min: 0, max: 2, step: 0.01 }, + ], +}); diff --git a/apps/docs/src/compositions/fixture/generated/coarse.tsx b/apps/docs/src/compositions/fixture/generated/coarse.tsx new file mode 100644 index 00000000..e45ef772 --- /dev/null +++ b/apps/docs/src/compositions/fixture/generated/coarse.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { Grain, MeshGradient, ShaderScene, type ShaderSceneProps } from '@camp-dev/shaders'; + +export function Fixture(props: Omit) { + return ( + + + + + ); +} diff --git a/apps/docs/src/compositions/fixture/generated/default.tsx b/apps/docs/src/compositions/fixture/generated/default.tsx new file mode 100644 index 00000000..2d3f9b19 --- /dev/null +++ b/apps/docs/src/compositions/fixture/generated/default.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { Grain, MeshGradient, ShaderScene, type ShaderSceneProps } from '@camp-dev/shaders'; + +export function Fixture(props: Omit) { + return ( + + + + + ); +} diff --git a/apps/docs/src/compositions/fixture/generated/tight.tsx b/apps/docs/src/compositions/fixture/generated/tight.tsx new file mode 100644 index 00000000..b405c0df --- /dev/null +++ b/apps/docs/src/compositions/fixture/generated/tight.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { Grain, MeshGradient, ShaderScene, type ShaderSceneProps } from '@camp-dev/shaders'; + +export function Fixture(props: Omit) { + return ( + + + + + ); +} diff --git a/apps/docs/src/compositions/generate.test.ts b/apps/docs/src/compositions/generate.test.ts new file mode 100644 index 00000000..99670cc1 --- /dev/null +++ b/apps/docs/src/compositions/generate.test.ts @@ -0,0 +1,387 @@ +// @vitest-environment node +/** + * The code generator on a fixture of its own, so art changes to a real + * composition never break these. The last suite holds the generator's line + * breaking to Prettier's: it prints every component with every default set + * and checks that the repo's Prettier config leaves the file as it is. + */ +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { format, resolveConfig } from 'prettier'; +import { describe, expect, it } from 'vitest'; + +import { slugForComponentName } from '@/content/catalog'; +import { getComponentDefaults } from '@/content/props'; + +import type { ComponentName, Composition } from './define'; +import { defineComposition } from './define'; +import { generateComposition, kebabCase, pascalCase } from './generate'; +import { presetProps, type SeedFields } from './store'; + +const composition = defineComposition({ + slug: 'generator-test', + title: 'Generator test', + description: 'A stack for the generator tests.', + stack: [ + { id: 'base', component: 'MeshGradient', props: { speed: 0.4 } }, + { id: 'grain', component: 'Grain', props: { intensity: 0.08 } }, + ], + presets: [{ name: 'Coarse', props: { grain: { intensity: 0.08, blend: 'additive' } } }], + controls: [], +}); + +const defaults = { + base: { speed: 2, frequency: 5, palettes: [['#000', '#111', '#222', '#333']] }, + grain: { intensity: 0.15, speed: 0.3, blend: 'additive' }, +}; + +const generate = ( + preset: string, + edit?: (state: Record>) => void, +) => { + const state = presetProps(composition, defaults, preset); + + edit?.(state); + + return generateComposition({ composition, preset, state, defaults }); +}; + +describe('generateComposition', () => { + it('prints the file around the stack', () => { + expect(generate('Default')).toBe(`'use client'; + +import { Grain, MeshGradient, ShaderScene, type ShaderSceneProps } from '@camp-dev/shaders'; + +export function GeneratorTest(props: Omit) { + return ( + + + + + ); +} +`); + }); + + it('prints a prop the loaded preset sets, even at its library default', () => { + expect(generate('Coarse')).toContain(''); + }); + + it('prints a prop that has moved off its library default', () => { + const output = generate('Default', (state) => { + state.base = { ...state.base, frequency: 7 }; + }); + + expect(output).toContain(''); + }); + + it('leaves out a prop that sits at its library default', () => { + const output = generate('Default', (state) => { + state.grain = { ...state.grain, speed: 0.3 }; + }); + + expect(output).not.toContain('speed={0.3}'); + }); + + it('rounds slider noise and compares numbers after rounding', () => { + const output = generate('Default', (state) => { + state.base = { ...state.base, speed: 0.1 + 0.2, frequency: 5.000000001 }; + }); + + expect(output).toContain(''); + }); + + it('prints strings as JSX strings and arrays in braces', () => { + const output = generate('Default', (state) => { + state.grain = { ...state.grain, blend: 'subtractive' }; + state.base = { ...state.base, palettes: [['#fff', '#eee', '#ddd', '#ccc']] }; + }); + + expect(output).toContain( + ``, + ); + expect(output).toContain(''); + }); + + it("puts the document's scene props before the spread", () => { + const output = generateComposition({ + composition: { ...composition, scene: { scrollPush: true, maxFPS: 30 } }, + preset: 'Default', + state: presetProps(composition, defaults, 'Default'), + defaults, + }); + + expect(output).toContain(''); + }); + + it('imports each component once, sorted with ShaderScene', () => { + const output = generateComposition({ + composition: { + ...composition, + stack: [ + { id: 'one', component: 'Vignette', props: {} }, + { id: 'two', component: 'Dither', props: {} }, + { id: 'three', component: 'Vignette', props: {} }, + ], + }, + preset: 'Default', + state: { one: {}, two: {}, three: {} }, + defaults: {}, + }); + + expect(output).toContain( + "import { Dither, ShaderScene, type ShaderSceneProps, Vignette } from '@camp-dev/shaders';", + ); + expect(output).toContain('\n \n '); + }); +}); + +describe('props with no library default', () => { + // FractalNoise's contrast follows its style, so the library has no + // default for it. The panel seeds one from the style, and the file prints + // it once it differs from the style's own. + const noise = defineComposition({ + slug: 'no-default-test', + title: 'No default', + description: 'A FractalNoise for the no-default rule.', + stack: [{ id: 'noise', component: 'FractalNoise', props: {} }], + presets: [], + controls: [], + }); + const noiseDefaults = { noise: { style: 'clouds', scale: 3 } }; + const seed: SeedFields = (_entry, props) => ({ + contrast: props.style === 'veins' ? 1 : 1.75, + ...props, + }); + const loaded = { noise: { style: 'clouds', scale: 3, contrast: 1.75 } }; + + it("leaves the prop out while it holds the style's value", () => { + const output = generateComposition({ + composition: noise, + preset: 'Default', + state: loaded, + defaults: noiseDefaults, + seed, + }); + + expect(output).toContain(''); + }); + + it('prints the prop once it moves', () => { + const output = generateComposition({ + composition: noise, + preset: 'Default', + state: { noise: { ...loaded.noise, contrast: 2 } }, + defaults: noiseDefaults, + seed, + }); + + expect(output).toContain(''); + }); + + it('prints the loaded value once the style it followed changes', () => { + const output = generateComposition({ + composition: noise, + preset: 'Default', + state: { noise: { ...loaded.noise, style: 'veins' } }, + defaults: noiseDefaults, + seed, + }); + + expect(output).toContain(''); + }); +}); + +describe('seeded library defaults', () => { + // FlutedGlass's crossSection defaults to the name 'round', which the panel + // seeds as its dial value so the slider can edit it. + it('leaves the seeded default out', () => { + const glass = defineComposition({ + slug: 'seeded-default-test', + title: 'Seeded default', + description: 'A FlutedGlass for the seeded-default rule.', + stack: [{ id: 'glass', component: 'FlutedGlass', props: {} }], + presets: [], + controls: [], + }); + + const output = generateComposition({ + composition: glass, + preset: 'Default', + state: { glass: { crossSection: 0.5 } }, + defaults: { glass: { crossSection: 'round' } }, + seed: (_entry, props) => + props.crossSection === 'round' ? { ...props, crossSection: 0.5 } : props, + }); + + expect(output).toContain(''); + }); +}); + +describe('stop positions', () => { + // The store writes out the positions the library would space a gradient's + // stops at, so its fields can show them. Untouched, they still match the + // library default. + it('leaves untouched gradient stops out', () => { + const gradient = defineComposition({ + slug: 'stops-test', + title: 'Stops', + description: 'A LinearGradient for the stop-position rule.', + stack: [{ id: 'base', component: 'LinearGradient', props: {} }], + presets: [], + controls: [], + }); + const gradientDefaults = { base: { stops: [{ color: '#000' }, { color: '#fff' }] } }; + + const output = generateComposition({ + composition: gradient, + preset: 'Default', + state: presetProps(gradient, gradientDefaults, 'Default'), + defaults: gradientDefaults, + }); + + expect(output).toContain(''); + }); +}); + +describe('strings', () => { + it('escapes a line break and a backslash inside a nested string', () => { + const gradient = defineComposition({ + slug: 'strings-test', + title: 'Strings', + description: 'A LinearGradient for the string rule.', + stack: [{ id: 'base', component: 'LinearGradient', props: {} }], + presets: [], + controls: [], + }); + + const output = generateComposition({ + composition: gradient, + preset: 'Default', + state: { base: { stops: [{ color: 'oklch(0.5\n 0.1 250)' }, { color: "it's \\" }] } }, + defaults: {}, + }); + + expect(output).toContain("{ color: 'oklch(0.5\\n 0.1 250)' }"); + expect(output).toContain(`{ color: "it's \\\\" }`); + }); +}); + +describe('names', () => { + it('turns the title into the component name and a preset into a file name', () => { + expect(pascalCase('Late summer haze')).toBe('LateSummerHaze'); + expect(kebabCase('Late Summer, Again')).toBe('late-summer-again'); + }); + + it('renames the component when the title repeats a name the file imports', () => { + const output = generateComposition({ + composition: { ...composition, title: 'Mesh Gradient' }, + preset: 'Default', + state: presetProps(composition, defaults, 'Default'), + defaults, + }); + + expect(output).toContain('export function MeshGradientComposition('); + }); +}); + +// ---------------------------------------------------------------------------- +// Prettier's line breaking +// ---------------------------------------------------------------------------- + +const COMPONENT_NAMES: ComponentName[] = [ + 'Aurora', + 'Blobs', + 'ConicGradient', + 'CursorRipple', + 'CursorSmudge', + 'CursorSpotlight', + 'Dissolve', + 'Dither', + 'DotField', + 'FlutedGlass', + 'FractalNoise', + 'GodRays', + 'Grain', + 'LedWall', + 'LinearGradient', + 'LiquidGradient', + 'MeshGradient', + 'RadialGradient', + 'RadialWipe', + 'SimplexNoise', + 'Vignette', + 'Voronoi', + 'WaveLines', +]; + +async function prettierFormat(source: string): Promise { + // A file name beside this one, so Prettier resolves the repo's config and + // parses as TSX. Nothing is written there. + const filepath = join(fileURLToPath(new URL('.', import.meta.url)), 'prettier-check.tsx'); + const options = await resolveConfig(filepath); + + return format(source, { ...options, filepath }); +} + +describe('Prettier style', () => { + // Setting every default prints each component's longest props: stop + // lists, palettes, WaveLines' nested color pairs, and position tuples. + it.each(COMPONENT_NAMES)('leaves %s with every default set as it is', async (name) => { + const slug = slugForComponentName(name); + + if (slug === undefined) throw new Error(`No slug for ${name}`); + + const props = await getComponentDefaults(slug); + const document = { + slug: 'prettier-check', + title: 'Prettier check', + description: 'Every default of one component.', + stack: [{ id: 'entry', component: name, props }], + presets: [], + controls: [], + } as unknown as Composition; + const output = generateComposition({ + composition: document, + preset: 'Default', + state: { entry: props }, + defaults: { entry: props }, + }); + + expect(output).toBe(await prettierFormat(output)); + }); + + it('leaves a long import line and a long single attribute as they are', async () => { + const stack = COMPONENT_NAMES.slice(0, 6).map((name, index) => ({ + id: `entry${index}`, + component: name, + props: {}, + })); + const output = generateComposition({ + composition: { + ...composition, + stack: [ + ...stack, + { + id: 'wide', + component: 'LinearGradient', + props: { stops: [{ color: 'oklch(0.5 0.1 250)', position: 0.25 }] }, + }, + ], + } as unknown as Composition, + preset: 'Default', + state: { + wide: { + stops: [ + { color: 'oklch(0.5 0.1 250)', position: 0.25 }, + { color: 'oklch(0.6 0.12 260)', position: 0.5 }, + { color: 'oklch(0.7 0.14 270)', position: 1 }, + ], + }, + }, + defaults: {}, + }); + + expect(output).toBe(await prettierFormat(output)); + }); +}); diff --git a/apps/docs/src/compositions/generate.ts b/apps/docs/src/compositions/generate.ts new file mode 100644 index 00000000..0f6a409a --- /dev/null +++ b/apps/docs/src/compositions/generate.ts @@ -0,0 +1,376 @@ +/** + * Turns a composition and its Customize panel's state into the component + * file Copy React hands out, already in the repo's Prettier style. Pure and + * three-free, so golden.test.ts commits its output for every preset, and its + * own tests hold its line breaking to Prettier's. Grew out of controls/copy.ts. + */ +import { printString, roundNumber } from '@/lib/print-literal'; + +import type { Composition, EntryProps, StackEntry } from './define'; +import { + type CompositionDefaults, + type CompositionState, + type CompositionStore, + isRecord, + type SeedFields, + withStopPositions, +} from './store'; + +/** What the generator reads from the panel: the loaded preset and the props as they stand. */ +export interface GenerateInput { + composition: Composition; + /** The picker's loaded preset. Its props print even where they match a library default. */ + preset: string; + /** Each entry's props, keyed by entry id, as the panel's store holds them. */ + state: CompositionState; + /** The library defaults for each entry's component, keyed by entry id. */ + defaults: CompositionDefaults; + /** + * What the panel's store runs on each entry's props when it loads them. + * The file runs it too, so a prop prints only where it differs from what + * the component renders without it. Defaults to leaving props as they are. + */ + seed?: SeedFields; +} + +// Prettier's printWidth in the repo's .prettierrc. +const PRINT_WIDTH = 100; +const INDENT = ' '; + +// ---------------------------------------------------------------------------- +// The file +// ---------------------------------------------------------------------------- + +export function generateComposition({ + composition, + preset, + state, + defaults, + seed = (_entry, props) => props, +}: GenerateInput): string { + const presetOverride = composition.presets.find(({ name }) => name === preset)?.props; + const componentNames = [...new Set(composition.stack.map(({ component }) => component))]; + const exportedName = exportName(composition.title, [ + ...componentNames, + 'ShaderScene', + 'ShaderSceneProps', + ]); + const sceneAttributes = Object.entries(composition.scene ?? {}) + .filter(([, value]) => value !== undefined) + .sort(([a], [b]) => (a < b ? -1 : 1)); + + // The stack sits two levels inside the function: under `return (` and + // then under . + const elementIndent = INDENT.repeat(3); + const elements = composition.stack.map((entry) => { + const props: EntryProps = state[entry.id] ?? entry.props; + const setBy = { ...entry.props, ...presetOverride?.[entry.id] }; + // The defaults in the shape the store holds, so stops the library spaces + // evenly compare equal to the positions the store wrote out for them. + const entryDefaults = withStopPositions(entry.component, defaults[entry.id] ?? {}); + const baseline = renderedWithout(entry, props, entryDefaults, seed); + // Sorted, as the repo's jsx-sort-props rule sorts hand-written JSX. + const printed = Object.keys(props) + .filter((name) => shouldPrint(name, props, setBy, baseline)) + .sort(); + + return printElement( + entry.component, + printed.map((name) => [name, props[name]] as const), + elementIndent, + ); + }); + + return [ + "'use client';", + '', + printImport([...componentNames, 'ShaderScene', 'type ShaderSceneProps']), + '', + `export function ${exportedName}(props: Omit) {`, + `${INDENT}return (`, + printOpeningTag( + 'ShaderScene', + [...sceneAttributes.map(([name, value]) => printAttribute(name, value)), '{...props}'], + INDENT.repeat(2), + ), + ...elements, + `${INDENT.repeat(2)}`, + `${INDENT});`, + '}', + '', + ].join('\n'); +} + +/** The file for the panel as it stands: its loaded preset and its current props. */ +export function generateFromStore( + composition: Composition, + defaults: CompositionDefaults, + store: CompositionStore, +): string { + return generateComposition({ + composition, + preset: store.getPreset(), + state: store.getSnapshot(), + defaults, + seed: store.seed, + }); +} + +/** + * What the component renders for each prop the file leaves out. A prop with + * a library default gets that default, seeded into the shape the store holds, + * so FlutedGlass's `'round'` reads as its dial value. A prop with none, such + * as FractalNoise's contrast, gets what the seed picks for the props that + * have one, so a contrast that matches the old style prints once the style + * changes. + */ +function renderedWithout( + entry: StackEntry, + props: EntryProps, + defaults: EntryProps, + seed: SeedFields, +): EntryProps { + const defaulted = Object.keys(defaults); + const seededDefaults = seed(entry, { ...defaults }); + const current = Object.fromEntries( + defaulted.filter((name) => name in props).map((name) => [name, props[name]]), + ); + + return { + ...seed(entry, current), + ...Object.fromEntries(defaulted.map((name) => [name, seededDefaults[name]])), + }; +} + +/** + * Whether a prop belongs in the file: the document or the loaded preset sets + * it, or its value differs from what the component renders without it. + */ +function shouldPrint( + name: string, + props: EntryProps, + setBy: EntryProps, + baseline: EntryProps, +): boolean { + if (props[name] === undefined) return false; + if (name in setBy) return true; + + return !sameValue(props[name], baseline[name]); +} + +function sameValue(a: unknown, b: unknown): boolean { + if (typeof a === 'number' && typeof b === 'number') return roundNumber(a) === roundNumber(b); + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((item, index) => sameValue(item, b[index])); + } + + if (isRecord(a) && isRecord(b)) { + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + + return [...keys].every((key) => sameValue(a[key], b[key])); + } + + return a === b; +} + +/** "Fixture" → Fixture, "Late summer haze" → LateSummerHaze. */ +export function pascalCase(title: string): string { + return title + .split(/[^A-Za-z0-9]+/) + .filter((word) => word.length > 0) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(''); +} + +/** + * The copied component's name: the title in PascalCase, with `Composition` + * after it when that would repeat a name the file imports, such as a + * "Mesh Gradient" title over a MeshGradient entry. + */ +function exportName(title: string, imported: readonly string[]): string { + const name = pascalCase(title); + + return imported.includes(name) ? `${name}Composition` : name; +} + +/** The file name a preset's golden file takes: "Late Summer" → late-summer. */ +export function kebabCase(name: string): string { + return name + .split(/[^A-Za-z0-9]+/) + .filter((word) => word.length > 0) + .map((word) => word.toLowerCase()) + .join('-'); +} + +// ---------------------------------------------------------------------------- +// The import line +// ---------------------------------------------------------------------------- +// The repo's import-sorting Prettier plugin orders specifiers +// case-insensitively, ignoring `type`, and Prettier breaks the braces one +// specifier per line once the line passes the print width. + +function printImport(specifiers: readonly string[]): string { + const sorted = [...specifiers].sort((a, b) => + stripType(a).toLowerCase().localeCompare(stripType(b).toLowerCase()), + ); + const flat = `import { ${sorted.join(', ')} } from '@camp-dev/shaders';`; + + if (flat.length <= PRINT_WIDTH) return flat; + + return [ + 'import {', + ...sorted.map((name) => `${INDENT}${name},`), + "} from '@camp-dev/shaders';", + ].join('\n'); +} + +const stripType = (specifier: string) => specifier.replace(/^type /, ''); + +// ---------------------------------------------------------------------------- +// Elements +// ---------------------------------------------------------------------------- +// Prettier keeps a JSX element on one line when it fits. Otherwise every +// attribute takes its own line, and each attribute's value then gets the +// same choice at its own indent. + +function printElement( + name: string, + props: ReadonlyArray, + indent: string, +): string { + if (props.length === 0) return `${indent}<${name} />`; + + const flat = `${indent}<${name} ${props.map(([key, value]) => printAttribute(key, value)).join(' ')} />`; + + if (fits(flat)) return flat; + + const attributeIndent = indent + INDENT; + + return [ + `${indent}<${name}`, + ...props.map(([key, value]) => printAttribute(key, value, attributeIndent)), + `${indent}/>`, + ].join('\n'); +} + +function printOpeningTag(name: string, attributes: readonly string[], indent: string): string { + const flat = `${indent}<${name} ${attributes.join(' ')}>`; + + if (fits(flat)) return flat; + + return [ + `${indent}<${name}`, + ...attributes.map((attribute) => `${indent}${INDENT}${attribute}`), + `${indent}>`, + ].join('\n'); +} + +/** + * One attribute. Strings print as JSX strings and everything else in braces. + * With an indent, the attribute starts its own line, and its value breaks + * when that line would pass the print width. Without one, it prints flat for + * a one-line element. + */ +function printAttribute(name: string, value: unknown, indent?: string): string { + if (typeof value === 'string' && !value.includes('"')) return `${indent ?? ''}${name}="${value}"`; + + if (indent === undefined) return `${name}={${printFlat(value)}}`; + + return `${indent}${name}={${printValue(value, indent, `${name}={`.length, '}'.length)}}`; +} + +// ---------------------------------------------------------------------------- +// Values +// ---------------------------------------------------------------------------- + +/** + * A value at a line's `indent`, after `before` characters and ahead of + * `after` characters on the same line. It prints flat when the whole line + * fits, and breaks otherwise. + */ +function printValue(value: unknown, indent: string, before: number, after: number): string { + const flat = printFlat(value); + + if (!mustBreak(value) && indent.length + before + flat.length + after <= PRINT_WIDTH) return flat; + if (Array.isArray(value)) return printBrokenArray(value, indent); + if (isRecord(value)) return printBrokenObject(value, indent); + + return flat; +} + +function printBrokenArray(items: readonly unknown[], indent: string): string { + const inner = indent + INDENT; + + // Prettier fills an array of plain numbers, packing as many per line as fit. + if (items.length > 0 && items.every((item) => typeof item === 'number')) { + const lines: string[] = []; + let line = ''; + + for (const item of items) { + const next = `${printFlat(item)},`; + + if (line !== '' && inner.length + line.length + 1 + next.length > PRINT_WIDTH) { + lines.push(line); + line = next; + } else { + line = line === '' ? next : `${line} ${next}`; + } + } + + lines.push(line); + + return `[\n${lines.map((text) => inner + text).join('\n')}\n${indent}]`; + } + + const lines = items.map((item) => `${inner}${printValue(item, inner, 0, ','.length)},`); + + return `[\n${lines.join('\n')}\n${indent}]`; +} + +function printBrokenObject(record: Readonly>, indent: string): string { + const inner = indent + INDENT; + const lines = Object.entries(record) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => { + const printedKey = printKey(key); + + return `${inner}${printedKey}: ${printValue(value, inner, printedKey.length + 2, ','.length)},`; + }); + + return `{\n${lines.join('\n')}\n${indent}}`; +} + +/** + * Prettier always breaks an array of two or more arrays, or of two or more + * objects, when each of them holds more than one item. MeshGradient's two + * palettes are the case in point. + */ +function mustBreak(value: unknown): boolean { + if (!Array.isArray(value) || value.length < 2) return false; + + const allArrays = value.every((item) => Array.isArray(item) && item.length > 1); + const allObjects = value.every((item) => isRecord(item) && Object.keys(item).length > 1); + + return allArrays || allObjects; +} + +function printFlat(value: unknown): string { + if (typeof value === 'number') return String(roundNumber(value)); + if (typeof value === 'boolean') return String(value); + if (typeof value === 'string') return printString(value); + if (Array.isArray(value)) return `[${value.map(printFlat).join(', ')}]`; + + if (isRecord(value)) { + const entries = Object.entries(value) + .filter(([, entryValue]) => entryValue !== undefined) + .map(([key, entryValue]) => `${printKey(key)}: ${printFlat(entryValue)}`); + + return entries.length === 0 ? '{}' : `{ ${entries.join(', ')} }`; + } + + throw new Error(`A composition prop can only hold literals, not ${String(value)}`); +} + +const printKey = (key: string) => (/^[A-Za-z_$][\w$]*$/.test(key) ? key : printString(key)); + +const fits = (line: string) => line.length <= PRINT_WIDTH; diff --git a/apps/docs/src/compositions/golden.test.ts b/apps/docs/src/compositions/golden.test.ts new file mode 100644 index 00000000..f6f23546 --- /dev/null +++ b/apps/docs/src/compositions/golden.test.ts @@ -0,0 +1,33 @@ +// @vitest-environment node +/** + * Commits the file Copy React hands out for every composition and every + * preset, Default included, as `/generated/.tsx`. Each file + * comes from a store built with the page's seeds, as the Customize panel + * builds it. Those files sit under the repo's typecheck, lint, and format + * check, so CI compiles the exact code a visitor copies, and every PR shows + * the output's diff. After a change to a composition or the generator, + * rewrite them with `pnpm --filter @shaders/docs test -u`. + */ +import { describe, expect, it } from 'vitest'; + +import { generateFromStore, kebabCase } from './generate'; +import { COMPOSITIONS } from './index'; +import { readCompositionDefaults } from './read-defaults'; +import { seedFields } from './seed-fields'; +import { createCompositionStore, presetNames } from './store'; + +describe.each(COMPOSITIONS.map((composition) => [composition.slug, composition] as const))( + '%s', + (slug, composition) => { + it.each(presetNames(composition))('matches the golden file for %s', async (preset) => { + const defaults = await readCompositionDefaults(composition); + const store = createCompositionStore(composition, defaults, seedFields); + + store.loadPreset(preset); + + await expect(generateFromStore(composition, defaults, store)).toMatchFileSnapshot( + `./${slug}/generated/${kebabCase(preset)}.tsx`, + ); + }); + }, +); diff --git a/apps/docs/src/compositions/index.test.ts b/apps/docs/src/compositions/index.test.ts new file mode 100644 index 00000000..e68e0e24 --- /dev/null +++ b/apps/docs/src/compositions/index.test.ts @@ -0,0 +1,94 @@ +// @vitest-environment node +/** + * Imports the registry in plain Node, where three/webgpu throws at module + * load because it reads `self`. The import succeeding is the check that no + * document reaches three, so server code can read every composition. The + * rest covers what the document's types can't: uniqueness across entries, + * and titles that make a component name. + */ +import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import { defineComposition } from './define'; +import { pascalCase } from './generate'; +import { COMPOSITIONS, compositionsUsing } from './index'; +import { compositionPoster, compositionThumbnail } from './posters'; + +const PUBLIC_DIR = fileURLToPath(new URL('../../public', import.meta.url)); + +describe('COMPOSITIONS', () => { + it('gives every composition its own slug', () => { + const slugs = COMPOSITIONS.map(({ slug }) => slug); + + expect(new Set(slugs).size).toBe(slugs.length); + }); + + it('gives every stack entry an id of its own within its composition', () => { + for (const composition of COMPOSITIONS) { + const ids = composition.stack.map(({ id }) => id); + + expect(new Set(ids).size, composition.slug).toBe(ids.length); + } + }); + + // The copied file exports a component named after the title, so a title + // such as "3D Haze" would print `function 3DHaze`, which doesn't compile. + it('gives every composition a title that makes a component name', () => { + for (const composition of COMPOSITIONS) { + expect(pascalCase(composition.title), composition.slug).toMatch(/^[A-Z][A-Za-z0-9]*$/); + } + }); + + // The picker lists "Default" ahead of the document's presets, so a + // preset by that name would show twice. + it('names every preset once, and never "Default"', () => { + for (const composition of COMPOSITIONS) { + const names = composition.presets.map(({ name }) => name.toLowerCase()); + + expect(names, composition.slug).not.toContain('default'); + expect(new Set(names).size, composition.slug).toBe(names.length); + } + }); + + // The composition page, the Gallery card, and the "Used in" strip show + // these before a frame. scripts/build-posters.sh writes them, and a + // composition missing its entry there would ship broken images. + it('has a poster and a thumbnail for every composition', () => { + const missing = COMPOSITIONS.flatMap(({ slug }) => + [compositionPoster(slug), compositionThumbnail(slug)].filter( + (url) => !existsSync(`${PUBLIC_DIR}${url}`), + ), + ); + + expect(missing).toEqual([]); + }); +}); + +describe('compositionsUsing', () => { + // A list of its own, so the lookup's test outlives any one composition. + const stackOf = (slug: string, ...components: Array<'Grain' | 'MeshGradient' | 'Vignette'>) => + defineComposition({ + slug, + title: slug, + description: 'A stack for the lookup test.', + stack: components.map((component, index) => ({ id: `entry${index}`, component, props: {} })), + presets: [], + controls: [], + }); + const compositions = [ + stackOf('one', 'MeshGradient', 'Grain'), + stackOf('two', 'MeshGradient', 'Vignette'), + ]; + const slugsUsing = (componentName: string) => + compositionsUsing(componentName, compositions).map(({ slug }) => slug); + + it('finds every composition whose stack holds the component, in order', () => { + expect(slugsUsing('MeshGradient')).toEqual(['one', 'two']); + expect(slugsUsing('Vignette')).toEqual(['two']); + }); + + it('finds none for a component no stack holds', () => { + expect(slugsUsing('Aurora')).toEqual([]); + }); +}); diff --git a/apps/docs/src/compositions/index.ts b/apps/docs/src/compositions/index.ts new file mode 100644 index 00000000..2b7c9150 --- /dev/null +++ b/apps/docs/src/compositions/index.ts @@ -0,0 +1,29 @@ +/** + * The composition registry. Its order is the Gallery's order, and the + * composition pages build their static params from it. Every document here + * is three-free, so server code reads the registry directly: the page's + * metadata, the Gallery grid, and the "Used in" strip on reference pages. + */ +import type { Composition } from './define'; +import { fixture } from './fixture/composition'; + +export const COMPOSITIONS: readonly Composition[] = [fixture]; + +/** + * The compositions whose stack includes the component exported as + * `componentName`, in Gallery order. A reference page's "Used in" strip + * lists them. + */ +export function compositionsUsing( + componentName: string, + compositions: readonly Composition[] = COMPOSITIONS, +): Composition[] { + return compositions.filter(({ stack }) => + stack.some(({ component }) => component === componentName), + ); +} + +/** The composition with this slug, or undefined when there is none. */ +export function findComposition(slug: string): Composition | undefined { + return COMPOSITIONS.find((composition) => composition.slug === slug); +} diff --git a/apps/docs/src/compositions/posters.ts b/apps/docs/src/compositions/posters.ts new file mode 100644 index 00000000..733f4928 --- /dev/null +++ b/apps/docs/src/compositions/posters.ts @@ -0,0 +1,12 @@ +/** + * Where each composition's poster and thumbnail live under public/. + * scripts/build-posters.sh captures the poster from the composition's + * Default golden file and cuts the thumbnail from it, so both paths derive + * from the slug alone. + */ + +/** The 3:2 poster the composition page and the Gallery card show before a frame. */ +export const compositionPoster = (slug: string) => `/posters/gallery/${slug}.jpg`; + +/** The square thumbnail the "Used in" strip shows. */ +export const compositionThumbnail = (slug: string) => `/posters/gallery/${slug}.thumb.webp`; diff --git a/apps/docs/src/compositions/read-defaults.ts b/apps/docs/src/compositions/read-defaults.ts new file mode 100644 index 00000000..45bbc03b --- /dev/null +++ b/apps/docs/src/compositions/read-defaults.ts @@ -0,0 +1,27 @@ +/** + * Reads the library defaults for each stack entry's component, keyed by + * entry id. props.ts reads the component sources from disk, so this runs only + * at build, in the composition page and in the golden-file test, and the + * page hands the result to its island. + */ +import { slugForComponentName } from '@/content/catalog'; +import { getComponentDefaults } from '@/content/props'; + +import type { Composition } from './define'; +import type { CompositionDefaults } from './store'; + +export async function readCompositionDefaults( + composition: Composition, +): Promise { + return Object.fromEntries( + await Promise.all( + composition.stack.map(async ({ id, component }) => { + const slug = slugForComponentName(component); + + if (slug === undefined) throw new Error(`No component page for ${component}`); + + return [id, await getComponentDefaults(slug)] as const; + }), + ), + ); +} diff --git a/apps/docs/src/compositions/seed-fields.ts b/apps/docs/src/compositions/seed-fields.ts new file mode 100644 index 00000000..ad9d8268 --- /dev/null +++ b/apps/docs/src/compositions/seed-fields.ts @@ -0,0 +1,22 @@ +/** + * The seed a composition's store runs on each entry's props, keyed by the + * entry's component. The Customize panel and golden.test.ts both use it, so + * the golden files match what Copy React hands out. Three-free, so the + * docs' Node vitest can load it. + */ +import { seedCrossSection } from '@/app/components/fluted-glass/cross-section'; +import { seedStyleDials } from '@/app/components/fractal-noise/style-dials'; +import { seedLineColors } from '@/app/components/wave-lines/line-colors'; + +import type { ComponentName } from './define'; +import type { SeedFields } from './store'; + +const SEEDS: Partial< + Record) => Record> +> = { + FlutedGlass: seedCrossSection, + FractalNoise: seedStyleDials, + WaveLines: seedLineColors, +}; + +export const seedFields: SeedFields = (entry, props) => SEEDS[entry.component]?.(props) ?? props; diff --git a/apps/docs/src/compositions/store.test.ts b/apps/docs/src/compositions/store.test.ts new file mode 100644 index 00000000..4ca57fcb --- /dev/null +++ b/apps/docs/src/compositions/store.test.ts @@ -0,0 +1,144 @@ +// @vitest-environment node +/** + * The Customize panel's store: library defaults, then the stack's props, then + * the loaded preset. These cover what a visitor sees through the panel: + * loading a preset starts from the defaults rather than from earlier edits, + * the picker keeps naming the loaded preset after an edit, and Reset goes + * back to that preset rather than to Default. + */ +import { describe, expect, it } from 'vitest'; + +import { defineComposition } from './define'; +import { createCompositionStore, DEFAULT_PRESET, presetProps, withStopPositions } from './store'; + +const composition = defineComposition({ + slug: 'store-test', + title: 'Store Test', + description: 'A stack for the store tests.', + stack: [ + { id: 'base', component: 'MeshGradient', props: { speed: 0.4 } }, + { id: 'grain', component: 'Grain', props: { intensity: 0.08 } }, + ], + presets: [ + { name: 'Still', props: { base: { speed: 0 } } }, + { name: 'Coarse', props: { grain: { intensity: 0.3, blend: 'subtractive' } } }, + ], + controls: [], +}); + +const defaults = { + base: { speed: 2, frequency: 5 }, + grain: { intensity: 0.15, speed: 0.3, blend: 'additive' }, +}; + +describe('presetProps', () => { + it('puts the stack props over the library defaults for Default', () => { + expect(presetProps(composition, defaults, DEFAULT_PRESET)).toEqual({ + base: { speed: 0.4, frequency: 5 }, + grain: { intensity: 0.08, speed: 0.3, blend: 'additive' }, + }); + }); + + it("puts a preset's override over the stack props", () => { + expect(presetProps(composition, defaults, 'Coarse')).toEqual({ + base: { speed: 0.4, frequency: 5 }, + grain: { intensity: 0.3, speed: 0.3, blend: 'subtractive' }, + }); + }); + + it('throws on a preset the composition lacks', () => { + expect(() => presetProps(composition, defaults, 'Loud')).toThrow(/Loud/); + }); +}); + +describe('createCompositionStore', () => { + it('starts on Default', () => { + const store = createCompositionStore(composition, defaults); + + expect(store.getPreset()).toBe(DEFAULT_PRESET); + expect(store.getSnapshot()).toEqual(presetProps(composition, defaults, DEFAULT_PRESET)); + }); + + it('resets to the defaults before it applies a preset', () => { + const store = createCompositionStore(composition, defaults); + + store.setAtPath(['grain', 'intensity'], 0.9); + store.loadPreset('Still'); + + expect(store.getSnapshot()).toEqual({ + base: { speed: 0, frequency: 5 }, + grain: { intensity: 0.08, speed: 0.3, blend: 'additive' }, + }); + }); + + it('keeps the loaded preset after an edit', () => { + const store = createCompositionStore(composition, defaults); + + store.loadPreset('Coarse'); + store.setAtPath(['base', 'speed'], 1.5); + + expect(store.getPreset()).toBe('Coarse'); + }); + + it('resets to the loaded preset, not to Default', () => { + const store = createCompositionStore(composition, defaults); + + store.loadPreset('Coarse'); + store.setAtPath(['grain', 'intensity'], 0.9); + store.reset(); + + expect(store.getSnapshot()).toEqual(presetProps(composition, defaults, 'Coarse')); + }); + + it('tells subscribers when a preset loads', () => { + const store = createCompositionStore(composition, defaults); + let calls = 0; + + store.subscribe(() => { + calls += 1; + }); + store.loadPreset('Still'); + + expect(calls).toBe(1); + }); +}); + +describe('withStopPositions', () => { + it('writes out the positions the library spaces evenly, for a gradient', () => { + const stops = [{ color: '#000' }, { color: '#888', position: 0.2 }, { color: '#fff' }]; + + expect(withStopPositions('LinearGradient', { stops })).toEqual({ + stops: [ + { color: '#000', position: 0 }, + { color: '#888', position: 0.2 }, + { color: '#fff', position: 1 }, + ], + }); + }); + + // Blobs' fields edit colors only, so its stops keep the shape they came in. + it('leaves a component whose fields edit no stop position alone', () => { + const stops = [{ color: '#000' }, { color: '#fff' }]; + + expect(withStopPositions('Blobs', { stops })).toEqual({ stops }); + }); +}); + +describe('seeding the fields', () => { + const seeded = createCompositionStore(composition, defaults, (entry, props) => + entry.component === 'Grain' ? { grit: 1, ...props } : props, + ); + + it('seeds every preset it loads, and Reset returns to the seeded props', () => { + expect(seeded.getSnapshot().grain).toMatchObject({ grit: 1, intensity: 0.08 }); + + seeded.loadPreset('Coarse'); + + expect(seeded.getSnapshot().grain).toMatchObject({ grit: 1, intensity: 0.3 }); + + seeded.setAtPath(['grain', 'grit'], 2); + seeded.reset(); + + expect(seeded.getSnapshot().grain).toMatchObject({ grit: 1, intensity: 0.3 }); + }); +}); diff --git a/apps/docs/src/compositions/store.ts b/apps/docs/src/compositions/store.ts new file mode 100644 index 00000000..9ecdafe4 --- /dev/null +++ b/apps/docs/src/compositions/store.ts @@ -0,0 +1,171 @@ +/** + * The state behind a composition's Customize panel. It is the demo pages' + * control store, shaped `{ [entryId]: props }`, plus the name of the loaded + * preset, which the picker shows. Each entry's props start from the library + * defaults that props.ts reads, with the stack's props on top, and a preset + * adds its override on top of those. Pure and three-free, so the docs' Node + * vitest covers it. + */ +import { type ControlStore, createControlStore } from '@/components/controls/store'; + +import type { ComponentName, Composition, EntryProps, StackEntry } from './define'; + +/** The picker's first choice: the stack's own props, with no preset applied. */ +export const DEFAULT_PRESET = 'Default'; + +/** Every preset the picker offers, Default first. Each also gets a golden file. */ +export function presetNames(composition: Composition): string[] { + return [DEFAULT_PRESET, ...composition.presets.map(({ name }) => name)]; +} + +/** Each entry's props, keyed by entry id. */ +export type CompositionState = Record>; + +/** The library defaults for each entry's component, keyed by entry id. */ +export type CompositionDefaults = Readonly>; + +// ---------------------------------------------------------------------------- +// The shape the fields edit +// ---------------------------------------------------------------------------- +// The All props section mounts each component's demo fields, which edit every +// color stop's position. The library's default stops for the gradients leave +// positions out and space them evenly, so those stops get the positions the +// library would give them: index / (count - 1), as toColorRampStops in +// packages/shaders/src/components/shared/color.ts spaces them. The picture +// doesn't change, and the generator compares against the same shape. + +/** The components whose demo fields edit a stop's position. demo-fields.test.ts keeps it current. */ +export const POSITIONED_STOP_COMPONENTS: ReadonlySet = new Set([ + 'Aurora', + 'ConicGradient', + 'LinearGradient', + 'LiquidGradient', + 'RadialGradient', +]); + +/** A component's props with each color stop's position written out. */ +export function withStopPositions( + component: ComponentName, + props: EntryProps, +): Record { + const { stops } = props; + + if (!POSITIONED_STOP_COMPONENTS.has(component) || !Array.isArray(stops)) return { ...props }; + + const lastIndex = Math.max(stops.length - 1, 1); + + return { + ...props, + stops: stops.map((stop: unknown, index) => + isRecord(stop) && typeof stop.position !== 'number' + ? { ...stop, position: index / lastIndex } + : stop, + ), + }; +} + +/** Whether a value is a plain object, as a literal prop's objects are. */ +export function isRecord(value: unknown): value is Readonly> { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +// ---------------------------------------------------------------------------- +// Presets +// ---------------------------------------------------------------------------- + +/** + * Every entry's props under one preset: the library defaults, then the + * stack's props, then the preset's override, with stop positions written + * out. Built from scratch each time, so loading a preset never keeps an + * earlier edit. + */ +export function presetProps( + composition: Composition, + defaults: CompositionDefaults, + presetName: string, +): CompositionState { + const preset = + presetName === DEFAULT_PRESET + ? undefined + : composition.presets.find(({ name }) => name === presetName); + + if (presetName !== DEFAULT_PRESET && preset === undefined) { + throw new Error(`${composition.title} has no preset named "${presetName}".`); + } + + return Object.fromEntries( + composition.stack.map((entry) => [ + entry.id, + withStopPositions(entry.component, { + ...defaults[entry.id], + ...entry.props, + ...preset?.props[entry.id], + }), + ]), + ); +} + +// ---------------------------------------------------------------------------- +// The store +// ---------------------------------------------------------------------------- + +export interface CompositionStore extends ControlStore { + /** The name of the loaded preset. An edit doesn't change it. */ + getPreset: () => string; + /** What the store runs on each entry's props when it loads them. The generator runs it too. */ + seed: SeedFields; + /** Loads a preset, which also makes it what Reset returns to. */ + loadPreset: (presetName: string) => void; +} + +/** + * Whether a published control store is a composition's. The page header + * reads the island's store through the copy source, which only knows it as a + * control store. + */ +export function isCompositionStore(store: ControlStore): store is CompositionStore { + return 'getPreset' in store && 'loadPreset' in store; +} + +/** + * Fills in what an entry's fields need beyond the library defaults, such as + * FractalNoise's contrast and balance, whose defaults follow its style, and + * puts a prop the fields can't edit as written, such as FlutedGlass's named + * cross-section, into a shape they can. seed-fields.ts holds the one the page + * and the golden test share. + */ +export type SeedFields = ( + entry: StackEntry, + props: Record, +) => Record; + +export function createCompositionStore( + composition: Composition, + defaults: CompositionDefaults, + seed: SeedFields = (_entry, props) => props, +): CompositionStore { + const loadedProps = (presetName: string): CompositionState => { + const state = presetProps(composition, defaults, presetName); + + return Object.fromEntries( + composition.stack.map((entry) => [entry.id, seed(entry, state[entry.id] ?? {})]), + ); + }; + + let preset = DEFAULT_PRESET; + const store = createControlStore(loadedProps(DEFAULT_PRESET)); + + return { + ...store, + getPreset: () => preset, + seed, + loadPreset: (presetName) => { + const loaded = loadedProps(presetName); + + // Set before load() notifies, so a subscriber reading it sees it agree + // with the snapshot. + preset = presetName; + store.load(loaded); + }, + }; +} diff --git a/apps/docs/src/content/catalog.test.ts b/apps/docs/src/content/catalog.test.ts index 4c6482a1..2d2dd907 100644 --- a/apps/docs/src/content/catalog.test.ts +++ b/apps/docs/src/content/catalog.test.ts @@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { getComponentsCatalog } from './catalog'; +import { getComponentsCatalog, slugForComponentName } from './catalog'; const POSTERS_DIR = fileURLToPath(new URL('../../public/posters', import.meta.url)); @@ -35,3 +35,13 @@ describe('getComponentsCatalog', () => { expect(missing).toEqual([]); }); }); + +// A composition names its components by export name, and the pages that read +// a component's props or poster need its slug. +describe('slugForComponentName', () => { + it('runs the PascalCase rule backwards', () => { + expect(slugForComponentName('LedWall')).toBe('led-wall'); + expect(slugForComponentName('Grain')).toBe('grain'); + expect(slugForComponentName('LEDWall')).toBeUndefined(); + }); +}); diff --git a/apps/docs/src/content/catalog.ts b/apps/docs/src/content/catalog.ts index 1091e0c6..1cc64353 100644 --- a/apps/docs/src/content/catalog.ts +++ b/apps/docs/src/content/catalog.ts @@ -38,6 +38,15 @@ function pascalizeSlug(slug: string): string { return slug.split('-').map(capitalize).join(''); } +/** + * The slug of the component exported under `componentName`, such as + * 'mesh-gradient' for 'MeshGradient': the PascalCase rule above, run + * backwards over the known slugs. Undefined for a name no component has. + */ +export function slugForComponentName(componentName: string): string | undefined { + return Object.keys(COMPONENTS).find((slug) => pascalizeSlug(slug) === componentName); +} + // scripts/build-posters.sh cuts every component's thumbnail from its poster // and writes it beside the poster under the slug, whatever format the poster // itself is, so the URL derives from the slug alone. diff --git a/apps/docs/src/content/props.test.ts b/apps/docs/src/content/props.test.ts index e86fe24e..0f99c54a 100644 --- a/apps/docs/src/content/props.test.ts +++ b/apps/docs/src/content/props.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { extractProps, getComponentProps } from './props'; +import { COMPONENTS } from './components'; +import { extractProps, getComponentDefaults, getComponentProps, parseLiteral } from './props'; describe('extractProps', () => { it('extracts name, type text, and description for each interface member', async () => { @@ -173,3 +174,41 @@ describe('getComponentProps', () => { expect(rows.length).toBeGreaterThan(0); }); }); + +describe('parseLiteral', () => { + it('reads numbers, strings, booleans, and nested arrays and objects', () => { + expect(parseLiteral("[\n { color: 'oklch(0.5 0.1 250)', position: 0.35 },\n]")).toEqual([ + { color: 'oklch(0.5 0.1 250)', position: 0.35 }, + ]); + expect(parseLiteral('[0.5, -0.05]')).toEqual([0.5, -0.05]); + expect(parseLiteral('true')).toBe(true); + }); + + it('throws on anything that is not a literal', () => { + expect(() => parseLiteral('DEFAULT_STOPS')).toThrow(/not a literal/); + expect(() => parseLiteral('[...rest]')).toThrow(/not a literal/); + }); +}); + +// A composition's store starts from these values, so a default that isn't a +// literal would leave its control with nothing to show. +describe('getComponentDefaults', () => { + const isLiteral = (value: unknown): boolean => { + if (['number', 'string', 'boolean'].includes(typeof value)) return true; + if (Array.isArray(value)) return value.every(isLiteral); + if (value !== null && typeof value === 'object') return Object.values(value).every(isLiteral); + + return false; + }; + + it('turns every component default into a literal value', async () => { + for (const slug of Object.keys(COMPONENTS)) { + const rows = await getComponentProps(slug); + const defaults = await getComponentDefaults(slug); + + for (const row of rows.filter((candidate) => candidate.defaultValue !== undefined)) { + expect(isLiteral(defaults[row.name]), `${slug}.${row.name}`).toBe(true); + } + } + }); +}); diff --git a/apps/docs/src/content/props.ts b/apps/docs/src/content/props.ts index 6e4beee1..8886652a 100644 --- a/apps/docs/src/content/props.ts +++ b/apps/docs/src/content/props.ts @@ -60,6 +60,21 @@ export const getComponentProps = cache(async (slug: string): Promise return extractProps(await readFile(wrapperPath, 'utf8'), componentName); }); +/** + * Each prop's default as a value rather than source text, keyed by prop name, + * for the props that have one. A composition's Customize panel starts its + * store from these, so a control shows the library's default before anyone + * moves it. + */ +export const getComponentDefaults = cache( + async (slug: string): Promise> => + Object.fromEntries( + (await getComponentProps(slug)).flatMap((row) => + row.defaultValue === undefined ? [] : [[row.name, parseLiteral(row.defaultValue)]], + ), + ), +); + export async function extractProps(source: string, componentName: string): Promise { const file = ts.createSourceFile('component.tsx', source, ts.ScriptTarget.Latest, true); @@ -194,3 +209,58 @@ function cleanDescription(raw: string): string { .replace(/\s*Defaults to .*?\.(?=\s+[A-Z`]|\s*$)/, '') .trim(); } + +// --------------------------------------------- +// Default text to values +// --------------------------------------------- +// formatDefault prints each default as literal source, so turning it back +// into a value is a walk over a literal's syntax: numbers, strings, booleans, +// and arrays or plain objects of those. Anything else throws, because a +// default that isn't a literal can't seed a control or print in a copied file. + +/** Parses a default's source text, such as `[0.5, 0.5]`, into its value. */ +export function parseLiteral(source: string): unknown { + const file = ts.createSourceFile('default.ts', `(${source});`, ts.ScriptTarget.Latest, true); + const [statement] = file.statements; + + if (statement === undefined || !ts.isExpressionStatement(statement)) { + throw new Error(`Default is not an expression: ${source}`); + } + + return literalValue(statement.expression); +} + +function literalValue(node: ts.Expression): unknown { + if (ts.isParenthesizedExpression(node)) return literalValue(node.expression); + if (ts.isNumericLiteral(node)) return Number(node.text); + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text; + if (node.kind === ts.SyntaxKind.TrueKeyword) return true; + if (node.kind === ts.SyntaxKind.FalseKeyword) return false; + + if ( + ts.isPrefixUnaryExpression(node) && + node.operator === ts.SyntaxKind.MinusToken && + ts.isNumericLiteral(node.operand) + ) { + return -Number(node.operand.text); + } + + if (ts.isArrayLiteralExpression(node)) return node.elements.map(literalValue); + + if (ts.isObjectLiteralExpression(node)) { + return Object.fromEntries( + node.properties.map((property) => { + if ( + !ts.isPropertyAssignment(property) || + !(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) + ) { + throw new Error(`Default has a property that isn't a literal: ${property.getText()}`); + } + + return [property.name.text, literalValue(property.initializer)]; + }), + ); + } + + throw new Error(`Default is not a literal: ${node.getText()}`); +} diff --git a/apps/docs/src/lib/VisualTestPause.tsx b/apps/docs/src/lib/VisualTestPause.tsx index 7c87c995..1cbba2cd 100644 --- a/apps/docs/src/lib/VisualTestPause.tsx +++ b/apps/docs/src/lib/VisualTestPause.tsx @@ -3,6 +3,7 @@ import { useEffect } from 'react'; import { + type GpuBackend, type ReducedMotionPolicy, resetRendererClock, type SchedulerTick, @@ -33,6 +34,13 @@ const isReducedMotionPolicy = (policyName: string): policyName is ReducedMotionP declare global { interface Window { __shadersTestReady?: boolean; + /** + * The backend the scene's renderer initialized with, set beside + * `__shadersTestReady`. A composition's visual spec asserts 'webgl2', + * so a runner that gains WebGPU can't drop the fallback's coverage + * without a failure. + */ + __shadersBackend?: GpuBackend; /** * Set under `?pointer=1` once the scene's cursor listeners are attached, * so a spec knows its pointer move will be heard. @@ -57,6 +65,12 @@ function useVisualTestPause(): void { setReducedMotionPolicy(policy); + // A composition page remounts this on every preset it loads, so a spec + // can capture each one. An earlier capture left the scheduler paused, + // and this capture needs it running again. The first mount finds it + // running, and resume() then changes nothing. + ctx.scheduler.resume(); + const releaseAnimated = ctx.scheduler.setIdle(false); // Pointer mode, for cursor effects: the capture waits for the spec to @@ -110,6 +124,7 @@ function useVisualTestPause(): void { if (frame > TARGET_FRAME && settled(now)) { ctx.scheduler.remove(client); ctx.scheduler.pause(); + window.__shadersBackend = ctx.renderer.backend; window.__shadersTestReady = true; } }; diff --git a/apps/docs/src/lib/print-literal.ts b/apps/docs/src/lib/print-literal.ts new file mode 100644 index 00000000..199456e0 --- /dev/null +++ b/apps/docs/src/lib/print-literal.ts @@ -0,0 +1,30 @@ +/** + * How a literal prop value prints in copied code. The page header's Copy + * React (components/controls/copy.ts) and the composition generator + * (compositions/generate.ts) share these, so both escape strings alike. + */ + +// Sliders produce float noise such as 0.30000000000000004. Four places is +// finer than any control's step. +const DECIMAL_PLACES = 4; + +export const roundNumber = (value: number) => + Math.round(value * 10 ** DECIMAL_PLACES) / 10 ** DECIMAL_PLACES; + +// Single quotes, as the repo's Prettier config asks, unless the string holds +// more single quotes than double ones, where Prettier switches. A line break +// can't sit inside a quoted string, so it prints as its escape. +export function printString(value: string): string { + const singles = value.split("'").length - 1; + const doubles = value.split('"').length - 1; + const quote = singles > doubles ? '"' : "'"; + const escaped = value + .replaceAll('\\', '\\\\') + .replaceAll('\n', '\\n') + .replaceAll('\r', '\\r') + .replaceAll('
', '\\u2028') + .replaceAll('
', '\\u2029') + .replaceAll(quote, `\\${quote}`); + + return `${quote}${escaped}${quote}`; +} diff --git a/apps/docs/src/lib/use-live-on-hover.ts b/apps/docs/src/lib/use-live-on-hover.ts new file mode 100644 index 00000000..de42786c --- /dev/null +++ b/apps/docs/src/lib/use-live-on-hover.ts @@ -0,0 +1,224 @@ +'use client'; + +/** + * Live-on-hover for a grid of shader cards, shared by the homepage favorites + * and the Gallery grid. Hovering or focusing a card mounts its scene, which + * stays mounted and paused once let go, and one plays at a time. Without a + * hovering pointer or a WebGPU adapter, every card stays a poster. + */ +import { type FocusEvent, type PointerEvent, type RefObject, useEffect, useState } from 'react'; + +import { useCanHover } from './use-can-hover'; + +/** What brought a visitor to a card: the pointer, or keyboard focus. */ +export type Engagement = 'pointer' | 'focus'; + +/** Hears a card gain or lose the pointer or keyboard focus. */ +export type OnEngagedChange = (engagement: Engagement, engaged: boolean) => void; + +export interface LiveOnHover { + /** Whether cards may mount scenes at all; see useCanGoLive below. */ + canGoLive: boolean; + /** Every card hovered or focused at least once. Its scene stays mounted. */ + mountedSlugs: ReadonlySet; + /** The card whose scene plays, or null. Every other mounted scene is paused. */ + playingSlug: Slug | null; + /** Whether the pointer or keyboard focus is on the card right now. */ + isEngaged: (slug: Slug) => boolean; + /** Reports a card gaining or losing the pointer or keyboard focus. */ + setEngaged: (slug: Slug, engagement: Engagement, engaged: boolean) => void; +} + +// --------------------------------------------- +// Which cards are mounted, and which one plays +// --------------------------------------------- + +// Every card a visitor has hovered or focused keeps its scene mounted, paused +// on the frame it stopped on, so it never falls back to its poster, and +// coming back resumes it at once. Each mounted scene holds a renderer, but a +// paused one does no GPU work. Only the most recently engaged card plays, and +// only while the pointer or keyboard focus is still on it. When that one is +// let go, a card the other input is still on plays instead. +// +// Engagement is tracked before the gate opens, so a card hovered or focused +// while the WebGPU adapter is still on its way goes live once it arrives. +export function useLiveOnHover( + listRef: RefObject, + preload: () => void, +): LiveOnHover { + const canGoLive = useCanGoLive(); + const [mountedSlugs, setMountedSlugs] = useState>(() => new Set()); + const [lastEngagedSlug, setLastEngagedSlug] = useState(null); + // The card under the pointer and the card with keyboard focus, each null + // when there is none. + const [hoveredSlug, setHoveredSlug] = useState(null); + const [focusedSlug, setFocusedSlug] = useState(null); + const playingSlug = + lastEngagedSlug !== null && (hoveredSlug === lastEngagedSlug || focusedSlug === lastEngagedSlug) + ? lastEngagedSlug + : (hoveredSlug ?? focusedSlug); + + usePreloadScenesNearView(listRef, canGoLive, preload); + + const setEngaged = (slug: Slug, engagement: Engagement, engaged: boolean) => { + const setSlug = engagement === 'pointer' ? setHoveredSlug : setFocusedSlug; + + if (engaged) { + setLastEngagedSlug(slug); + setMountedSlugs((current) => (current.has(slug) ? current : new Set(current).add(slug))); + setSlug(slug); + } else { + setSlug((current) => (current === slug ? null : current)); + } + }; + + return { + canGoLive, + mountedSlugs, + playingSlug, + isEngaged: (slug) => slug === hoveredSlug || slug === focusedSlug, + setEngaged, + }; +} + +/** + * The handlers a card's link takes to report its engagement. A tap never + * takes a card live, even on a touch-screen laptop that passes the hover + * gate, because the tap is on its way to the card's page. The pointer + * handlers skip a touch's pointerenter and pointerleave, and the focus + * handler skips the focus a tap or a click gives the link, keeping keyboard + * focus only. + */ +export function engagementHandlers(onEngagedChange: OnEngagedChange) { + return { + onBlur: () => onEngagedChange('focus', false), + onFocus: (event: FocusEvent) => { + if (event.currentTarget.matches(':focus-visible')) onEngagedChange('focus', true); + }, + onPointerEnter: (event: PointerEvent) => { + if (event.pointerType !== 'touch') onEngagedChange('pointer', true); + }, + onPointerLeave: (event: PointerEvent) => { + if (event.pointerType !== 'touch') onEngagedChange('pointer', false); + }, + }; +} + +// --------------------------------------------- +// Loading a scene's code +// --------------------------------------------- + +/** + * A scene component that loads through import(), or null until it has. + * `cached` is the component when its module has already loaded, from the + * preload or an earlier card, so the first render has it and a hover mounts + * its canvas in the same commit. A plain import() rather than next/dynamic, + * which is React.lazy underneath: a lazy component suspends on its first + * render even when its module has loaded, and React then holds the reveal + * back by 300ms. A module that fails to load leaves the card on its poster. + * `load` must keep its identity across renders. + */ +export function useLoadedScene( + cached: Scene | null, + load: () => Promise, +): Scene | null { + const [scene, setScene] = useState(() => cached); + + useEffect(() => { + if (scene !== null) return; + + let cancelled = false; + + void load() + .catch(() => null) + .then((loaded) => { + // The updater form, because a component is itself a function. + if (!cancelled && loaded !== null) setScene(() => loaded); + }); + + return () => { + cancelled = true; + }; + }, [load, scene]); + + return scene; +} + +// --------------------------------------------- +// Preloading: the scenes' code, before the first hover +// --------------------------------------------- + +// How far outside the viewport the cards are when their scenes start to +// load, in CSS pixels. Larger loads them sooner on the way down the page. +const PRELOAD_MARGIN = '200px'; + +/** + * Runs `preload` once the cards come within PRELOAD_MARGIN of the viewport, + * the way next/link prefetches a page in view, so a first hover mounts its + * canvas at once rather than waiting on the code. It loads code only: a + * renderer starts on a card's first hover, so a card never hovered costs + * nothing on the GPU. The first card stands in for the list, because the + * favorites' list is `display: contents` and has no box to observe. + */ +function usePreloadScenesNearView( + listRef: RefObject, + enabled: boolean, + preload: () => void, +) { + useEffect(() => { + const firstCard = listRef.current?.firstElementChild; + + if (!enabled || !firstCard) return; + + const observer = new IntersectionObserver( + (entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return; + observer.disconnect(); + preload(); + }, + { rootMargin: PRELOAD_MARGIN }, + ); + + observer.observe(firstCard); + + return () => observer.disconnect(); + }, [listRef, enabled, preload]); +} + +// --------------------------------------------- +// The gate: a mouse or trackpad, and a WebGPU adapter +// --------------------------------------------- + +/** + * True where a card 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 cards stay posters. Without an + * adapter, ShaderScene would fall back to WebGL2, and the cards stay posters + * instead. 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; +} diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json index 8588bccd..fcebd3fc 100644 --- a/apps/docs/tsconfig.json +++ b/apps/docs/tsconfig.json @@ -14,6 +14,7 @@ "@/*": ["./src/*"], "@camp-dev/shaders": ["../../packages/shaders/src/index.ts"], "@camp-dev/shaders/color": ["../../packages/shaders/src/color.ts"], + "@camp-dev/shaders/defaults": ["../../packages/shaders/src/defaults.ts"], "@camp-dev/shaders/gamut": ["../../packages/shaders/src/gamut.ts"], "@camp-dev/shaders/poster": ["../../packages/shaders/src/poster.ts"] }, diff --git a/docs/adr/0005-compositions-stack-only-public-sources-and-effects.md b/docs/adr/0005-compositions-stack-only-public-sources-and-effects.md index 1da9953d..7af3b9a6 100644 --- a/docs/adr/0005-compositions-stack-only-public-sources-and-effects.md +++ b/docs/adr/0005-compositions-stack-only-public-sources-and-effects.md @@ -18,4 +18,4 @@ We chose to lead with a Gallery of finished compositions, and to build a new Sou - Each control in a composition's control panel sets exactly one prop. A control that sets two props needs a mapping from one value to two, and the document format gains one only when a composition needs it. - The copied file holds literal props for the control panel's current state, so it doesn't pick up later improvements to the composition. - Every composition renders on the WebGL2 fallback. Shaders.com is WebGPU-only and draws nothing there. CI's headless Chromium already runs WebGL2. Each composition's visual spec asserts the backend, so a runner that gains WebGPU can't drop that coverage without a failure. -- Composition code lives in `apps/docs`. When an editor app needs it too, it moves to a private workspace package, never into `@camp-dev/shaders`. Compositions add no exports to the published package. +- Composition code lives in `apps/docs`. When an editor app needs it too, it moves to a private workspace package, never into `@camp-dev/shaders`. Compositions add no exports to the published package. When composition code needs a component's default values in a server render or a Node test, the package can publish those values on a three-free subpath, as `@camp-dev/shaders/defaults` does for `STYLE_DIAL_DEFAULTS`. Such a subpath carries a component's values, never a composition, and the root entry exports the same values. diff --git a/docs/agents/build-and-ci.md b/docs/agents/build-and-ci.md index f663bdd2..24c1bb76 100644 --- a/docs/agents/build-and-ci.md +++ b/docs/agents/build-and-ci.md @@ -20,7 +20,7 @@ In a client component, put `'use client'` on line 1 and any comment below it. Wi ## The docs site compiles the package from source -`apps/docs/next.config.ts` aliases `@camp-dev/shaders` and its `color`, `gamut`, and `poster` subpaths to files under `packages/shaders/src`, in its `webpack()` hook. A module rule scoped to that directory maps the package's `.js` import specifiers onto `.ts`, and `apps/docs/tsconfig.json` mirrors the aliases under `paths`. Shader edits hot-reload in the dev server with no build step. +`apps/docs/next.config.ts` aliases `@camp-dev/shaders` and its `color`, `defaults`, `gamut`, and `poster` subpaths to files under `packages/shaders/src`, in its `webpack()` hook. A module rule scoped to that directory maps the package's `.js` import specifiers onto `.ts`, and `apps/docs/tsconfig.json` mirrors the aliases under `paths`. Shader edits hot-reload in the dev server with no build step. The docs site's Vitest run still resolves the package through `dist`, which is why `turbo.json` builds before test. If a package edit shows in the browser but not in the docs tests, or the reverse, run `pnpm --filter @camp-dev/shaders build` to refresh the test side. diff --git a/docs/agents/docs-site.md b/docs/agents/docs-site.md index 5f7ee865..6878adf7 100644 --- a/docs/agents/docs-site.md +++ b/docs/agents/docs-site.md @@ -6,16 +6,29 @@ Read this before you work in `apps/docs`: a component page, a demo panel, a dev The package ships no manifest. The docs site keeps its own record of each component in `apps/docs/src/content/components.ts`: one description and one category per component, keyed by slug. The slug is also the URL segment and the folder name under `packages/shaders/src/components/`, and `props.ts` reads that folder to build the API table. -Adding a component takes seven edits: the folder in the package, an export from `packages/shaders/src/components/index.ts`, an entry in `components.ts`, a demo island at `apps/docs/src/app/components//demo.tsx`, an entry in `apps/docs/src/app/components/demo-registry.tsx`, an entry in `scripts/build-posters.sh`, and a visual regression spec at `apps/docs-tests/visual/.spec.ts`, whose baselines `pnpm snap ` generates, as `docs/development/visual-regression.md` describes. The posters script writes the poster the demo shows before its first frame, then cuts the thumbnail for the component's card on `/components` from it. A component with no thumbnail shows a broken image on its card and fails `components-index.spec.ts`. The registry is what generates the page: `[slug]/page.tsx` builds its static params from `COMPONENT_PAGES`, so a component without an entry gets no route. A new sidebar group is one more entry in `taxonomy.ts`. +Adding a component takes nine edits: the folder in the package, an export from `packages/shaders/src/components/index.ts`, an entry in `components.ts`, a demo island at `apps/docs/src/app/components//demo.tsx`, the demo's fields exported from that file, entries in `COMPONENT_PAGES` and `COMPONENT_FIELDS` in `apps/docs/src/app/components/demo-registry.tsx`, an entry in `apps/docs/src/compositions/component-map.ts`, an entry in `scripts/build-posters.sh`, and a visual regression spec at `apps/docs-tests/visual/.spec.ts`, whose baselines `pnpm snap ` generates, as `docs/development/visual-regression.md` describes. The posters script writes the poster the demo shows before its first frame, then cuts the thumbnail for the component's card on `/components` from it. A component with no thumbnail shows a broken image on its card and fails `components-index.spec.ts`. The registry is what generates the page: `[slug]/page.tsx` builds its static params from `COMPONENT_PAGES`, so a component without an entry gets no route. A new sidebar group is one more entry in `taxonomy.ts`. The demo's fields are its controls without the `ControlPanel` around them, exported as `Fields`, and its `*Controls` renders ``. A composition's All props section mounts them under a stack entry's id, so each field must write a real prop of the component, never a demo-only field such as `centerX`. `demo-fields.test.ts` checks that. A field that shows a prop with no library default needs a seed in the `SEEDS` map in `compositions/seed-fields.ts`, as FractalNoise's contrast and balance have. A demo whose fields edit a color stop's position belongs in `POSITIONED_STOP_COMPONENTS` in `compositions/store.ts`, and `demo-fields.test.ts` fails until it is. + +## Add a composition + +A composition is a typed document, as ADR 0005 decides. Write it at `apps/docs/src/compositions//composition.ts` with `defineComposition` from `compositions/define.ts`, then add it to the `COMPOSITIONS` array in `compositions/index.ts`. The array's order is the Gallery's order, and `/gallery/` builds its static params from it. + +- `stack` names each Source or Effect by its export name, under an `id` that is unique in the composition. Presets and controls address an entry by its `id`. Never call an entry a layer. +- Props are literals only. The types reject a component the component map lacks, a prop the component doesn't have, a callback or signal prop, a preset or control that names a missing entry, and a control path whose value doesn't fit its kind. +- A document imports only types. `compositions/component-map.ts` is the one file that imports the components, and `define.ts` reaches it through `import type`, so server code and the docs' Node vitest can read every document. A test fails when a component in `content/components.ts` is missing from the map. +- `presets` and `controls` fill the page's Customize panel. A preset is a partial override per entry id, and the picker lists "Default", the stack's own props, before them. Loading a preset starts again from the library defaults with the stack's props on top, then applies the override, and Reset returns to the loaded preset. A control writes `.` in the panel's store, and sets exactly one prop. The panel's All props section mounts each entry's demo fields under the entry's id, so it needs no work per composition either. An entry with a position prop set to `'cursor'` shows a note there instead of its fields, because they edit a position as a point. A curated control on such a prop's coordinates, such as `center.0`, throws, so keep the position a point wherever a control edits it. +- Copy React, in the panel and in the page header, hands out the file `compositions/generate.ts` prints from the panel's state. It sets a prop when the document or the loaded preset sets it, or when the prop has moved off its library default. `golden.test.ts` commits that file for every preset, Default included, as `compositions//generated/.tsx`, so CI's typecheck, lint, and format check run on the exact code a visitor copies. After you add or change a composition, or change the generator, rewrite the files with `pnpm --filter @shaders/docs test -u` and read their diff. +- Add a `slug:ExportName` entry to the composition loop in `scripts/build-posters.sh`, where `ExportName` is the `export function` name in `generated/default.tsx`, and run `pnpm posters`. The name is the title in PascalCase, with `Composition` after it when that name would repeat one the file imports, as in `MeshGradientComposition`. `pnpm posters` captures `compositions//generated/default.tsx` to `public/posters/gallery/.jpg` and cuts `.thumb.webp` from it. The composition page shows the poster before the scene's first frame, the Gallery card shows it at rest, and the "Used in" strip on reference pages shows the thumbnail. A test in `compositions/index.test.ts` fails when either file is missing. +- Give the composition a visual spec at `apps/docs-tests/visual/gallery-.spec.ts`, modeled on `gallery-fixture.spec.ts`. It asserts that `window.__shadersBackend` is `'webgl2'` before any screenshot, because every composition must draw on the WebGL2 fallback. It then takes one baseline per preset, Default included, choosing each preset through the picker. `pnpm snap gallery-` writes the baselines. +- Check the GPU budget by hand, because CI has no GPU. Open `/dev/composition-benchmark?composition=&preset=&dpr=2` for every preset, with no other scene drawing (the skewed-readout gotcha in `docs/agents/tsl.md`), and read ShaderMonitor's GPU time. At 2x on the M1 Max, the most expensive preset must stay under 4 ms per frame, and the goal is 2 ms. Record every reading in the PR body. ## The SSR gotcha: keep three out of server code `three/webgpu` reads `self` at module load, so any server render that imports it crashes. - Load anything that reaches the renderer through `next/dynamic` with `{ ssr: false }`. Every demo loads its `scene.tsx` that way. -- For a scene that mounts on a hover or a click, call `import()` from an effect or a preload instead, and render the loaded component. `next/dynamic` is `React.lazy` underneath. A lazy component suspends on its first render even when its module has already loaded, and React 19 then holds back the reveal by 300ms. `components/favorites/favorite-scenes.tsx` is the model. -- Scalar code has a three-free import path, so reach for it before `ssr: false`. CPU color math comes from `@camp-dev/shaders/color`, and `useDisplayGamut` from `@camp-dev/shaders/gamut`. A lint rule on `apps/docs/**` rejects those names from the package root, and each subpath has a `// @vitest-environment node` test that fails if three enters its import graph. -- The package's root entry, `gamut.ts`, and `poster.ts` open with `'use client'`. The directive lives in source because the docs site compiles the package from source. `generateMetadata` and other server-only logic import from `@camp-dev/shaders/color` only, which has no directive and no path to three. An RSC page can also render what `/gamut` and `/poster` export, because both are client boundaries that never import three. +- For a scene that mounts on a hover or a click, call `import()` from an effect or a preload instead, and render the loaded component. `next/dynamic` is `React.lazy` underneath. A lazy component suspends on its first render even when its module has already loaded, and React 19 then holds back the reveal by 300ms. `useLoadedScene` in `lib/use-live-on-hover.ts` does this, and `components/favorites/favorite-scenes.tsx` and `app/gallery/gallery-grid.tsx` use it. +- Scalar code has a three-free import path, so reach for it before `ssr: false`. CPU color math comes from `@camp-dev/shaders/color`, FractalNoise's `STYLE_DIAL_DEFAULTS` from `@camp-dev/shaders/defaults`, and `useDisplayGamut` from `@camp-dev/shaders/gamut`. A lint rule on `apps/docs/**` rejects those names from the package root, and each subpath has a `// @vitest-environment node` test that fails if three enters its import graph. +- The package's root entry, `gamut.ts`, and `poster.ts` open with `'use client'`. The directive lives in source because the docs site compiles the package from source. `generateMetadata` and other server-only logic import from `@camp-dev/shaders/color` and `@camp-dev/shaders/defaults` only, which have no directive and no path to three. An RSC page can also render what `/gamut` and `/poster` export, because both are client boundaries that never import three. - three ships two standalone bundles, `three.module.js` and `three.webgpu.js`. Importing both duplicates three's core, which shows up as `Cannot read properties of undefined (reading 'usedTimes')` on dispose. `next.config.ts` aliases `three`, `three/webgpu`, and `three/tsl` to the webgpu bundle. Keep all three aliases. ## Demo panels diff --git a/eslint.config.js b/eslint.config.js index b96b20bd..d57c4a77 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -172,6 +172,8 @@ export default defineConfig([ files: ['packages/shaders/src/**/*.{ts,tsx}'], ignores: [ 'packages/shaders/src/index.ts', + // Re-exports component defaults, with no React or three behind them. + 'packages/shaders/src/defaults.ts', 'packages/shaders/src/gamut.ts', 'packages/shaders/src/poster.ts', 'packages/shaders/src/react/**', @@ -206,8 +208,9 @@ export default defineConfig([ // The docs site server-renders, and the package's root entry reaches // three/webgpu through the renderer and ShaderScene. three/webgpu reads // `self` at module load, so the wrong import crashes at render time, - // which is far too late to notice. Two three-free subpaths carry the - // same code: @camp-dev/shaders/color and @camp-dev/shaders/gamut. + // which is far too late to notice. Three three-free subpaths carry the + // same code: @camp-dev/shaders/color, @camp-dev/shaders/defaults, and + // @camp-dev/shaders/gamut. // // Banning the names rather than the specifier is deliberate: it lets /dev // playgrounds keep importing colorRamp, ShaderScene and friends from the @@ -232,11 +235,12 @@ export default defineConfig([ 'parseColorString', 'parseOklchString', 'srgbChannelToLinear', + 'STYLE_DIAL_DEFAULTS', 'useDisplayGamut', ], allowTypeImports: true, message: - "Import CPU color math from '@camp-dev/shaders/color' and useDisplayGamut from '@camp-dev/shaders/gamut'. The root entry pulls in three/webgpu, which reads `self` at module load and crashes any server render.", + "Import CPU color math from '@camp-dev/shaders/color', STYLE_DIAL_DEFAULTS from '@camp-dev/shaders/defaults', and useDisplayGamut from '@camp-dev/shaders/gamut'. The root entry pulls in three/webgpu, which reads `self` at module load and crashes any server render.", }, ], }, diff --git a/packages/shaders/README.md b/packages/shaders/README.md index 84c06373..6aff3a56 100644 --- a/packages/shaders/README.md +++ b/packages/shaders/README.md @@ -45,13 +45,14 @@ const color = colorRamp(noise, [ ## Server-safe subpaths -The root entry reaches `three/webgpu`, which reads `self` at module load and so cannot run on a server. Three subpaths carry code that never touches three: - -| Import | For | -| -------------------------- | --------------------------------------------------------------- | -| `@camp-dev/shaders/color` | `parseColorString` and the OKLab, OKLCH, and gamut math | -| `@camp-dev/shaders/gamut` | `useDisplayGamut` | -| `@camp-dev/shaders/poster` | ``, a static stand-in shown until the first frame | +The root entry reaches `three/webgpu`, which reads `self` at module load and so cannot run on a server. Four subpaths carry code that never touches three: + +| Import | For | +| ---------------------------- | -------------------------------------------------------------------- | +| `@camp-dev/shaders/color` | `parseColorString` and the OKLab, OKLCH, and gamut math | +| `@camp-dev/shaders/defaults` | `STYLE_DIAL_DEFAULTS`, FractalNoise's contrast and balance per style | +| `@camp-dev/shaders/gamut` | `useDisplayGamut` | +| `@camp-dev/shaders/poster` | ``, a static stand-in shown until the first frame | ## Docs diff --git a/packages/shaders/package.json b/packages/shaders/package.json index a52f23df..00a59932 100644 --- a/packages/shaders/package.json +++ b/packages/shaders/package.json @@ -46,6 +46,11 @@ "import": "./dist/color.js", "require": "./dist/color.cjs" }, + "./defaults": { + "types": "./dist/defaults.d.ts", + "import": "./dist/defaults.js", + "require": "./dist/defaults.cjs" + }, "./gamut": { "types": "./dist/gamut.d.ts", "import": "./dist/gamut.js", diff --git a/packages/shaders/src/defaults.test.ts b/packages/shaders/src/defaults.test.ts new file mode 100644 index 00000000..929c8a99 --- /dev/null +++ b/packages/shaders/src/defaults.test.ts @@ -0,0 +1,20 @@ +// @vitest-environment node +// +// The subpath's contract is that it never reaches three. With no DOM +// environment `self` is undefined, and three/webgpu reads `self` at module +// load, so if anything below ./defaults.js drags three back in, this file +// throws while importing and the suite fails here. +import { describe, expect, it } from 'vitest'; + +import { STYLE_DIAL_DEFAULTS } from './defaults.js'; + +describe('@camp-dev/shaders/defaults', () => { + it('runs with no DOM globals present', () => { + // Guards the guard, as color.test.ts does. + expect(typeof self).toBe('undefined'); + }); + + it("exposes FractalNoise's style dial defaults", () => { + expect(STYLE_DIAL_DEFAULTS.veins).toEqual({ contrast: 1, balance: 0.8 }); + }); +}); diff --git a/packages/shaders/src/defaults.ts b/packages/shaders/src/defaults.ts new file mode 100644 index 00000000..68110803 --- /dev/null +++ b/packages/shaders/src/defaults.ts @@ -0,0 +1,13 @@ +// @camp-dev/shaders/defaults — component defaults that UI code reads without +// the renderer, published as their own entry point. A controls panel that +// shows a default needs the number, not the shader, and the root barrel +// reaches three/webgpu, which reads `self` at module load. Nothing here +// imports three, so a server render or a Node test can load it. +// defaults.test.ts proves that. The root barrel re-exports the same values. +// +// Each export comes straight from its leaf module, never through +// ./components/index.js, which re-exports the components themselves and +// would bring three back. + +export type { FractalNoiseStyle } from './components/fractal-noise/shader.js'; +export { STYLE_DIAL_DEFAULTS } from './components/fractal-noise/style-dial-defaults.js'; diff --git a/packages/shaders/tsup.config.ts b/packages/shaders/tsup.config.ts index dbaeb005..cad81d14 100644 --- a/packages/shaders/tsup.config.ts +++ b/packages/shaders/tsup.config.ts @@ -1,12 +1,12 @@ import { defineConfig } from 'tsup'; -// Four entries. The root, gamut, and poster sources carry a 'use client' +// Five entries. The root, gamut, and poster sources carry a 'use client' // directive on line 1, which esbuild keeps on the emitted entry chunk, so no -// banner is needed and none is set: a banner would also stamp ./color, whose -// function exports server code calls, and a 'use client' module turns those -// into client references that throw on the server. +// banner is needed and none is set: a banner would also stamp ./color and +// ./defaults, whose exports server code reads, and a 'use client' module +// turns those into client references that throw on the server. export default defineConfig({ - entry: ['src/index.ts', 'src/color.ts', 'src/gamut.ts', 'src/poster.ts'], + entry: ['src/index.ts', 'src/color.ts', 'src/defaults.ts', 'src/gamut.ts', 'src/poster.ts'], format: ['esm', 'cjs'], dts: true, sourcemap: true, diff --git a/scripts/build-posters.sh b/scripts/build-posters.sh index 0e2fc5a0..ab3445fc 100755 --- a/scripts/build-posters.sh +++ b/scripts/build-posters.sh @@ -114,6 +114,36 @@ for pair in \ node scripts/build-thumbnail.mjs "${thumbnail_args[@]}" done +# Each composition's poster is captured from the file Copy React hands out +# for its Default preset, compositions//generated/default.tsx, which +# golden.test.ts keeps current, so the poster cannot drift from what a +# visitor copies. The file's named export is the composition's title in +# PascalCase. The composition page, the Gallery card, and the "Used in" +# strip on reference pages read the poster and its thumbnail from +# posters/gallery/. Entries are slug:ExportName[:background]. +mkdir -p "${OUT_DIR}/gallery" +for entry in \ + "fixture:Fixture"; do + IFS=':' read -r slug export_name background <<< "$entry" + poster="${OUT_DIR}/gallery/${slug}.jpg" + if [ "$THUMBNAILS_ONLY" = 0 ]; then + echo "==> gallery/${slug} (jpg, ${WIDTH}x${HEIGHT})" + gallery_args=( + --source "apps/docs/src/compositions/${slug}/generated/default.tsx" + --export-name "$export_name" + --output "$poster" + --format jpg + --width "$WIDTH" + --height "$HEIGHT" + ) + if [ -n "${background:-}" ]; then + gallery_args+=(--background "$background") + fi + $CLI "${gallery_args[@]}" + fi + node scripts/build-thumbnail.mjs "$poster" +done + if [ "$THUMBNAILS_ONLY" = 1 ]; then echo "All thumbnails regenerated." exit 0