Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7784f6e
feat(docs): add the composition document, renderer, and /gallery/[slug]
hunterbecton Oct 30, 2026
6c848b4
feat(docs): add the Customize panel with presets and curated controls
hunterbecton Oct 30, 2026
88c64d4
feat(docs): generate the copied composition file and wire Copy React
hunterbecton Oct 30, 2026
f2feab6
feat(docs): add the All props section to the Customize panel
hunterbecton Oct 30, 2026
6699eb5
feat(docs): assert WebGL2 in composition visual specs and add the com…
hunterbecton Oct 30, 2026
a7c8166
feat(docs): add the Gallery grid with posters and live-on-hover
hunterbecton Oct 30, 2026
d93fccd
feat(docs): add a provisional Used in strip to reference pages
hunterbecton Oct 30, 2026
49b3449
fix(docs): show a note in All props for a position that follows the p…
hunterbecton Oct 30, 2026
9458c9e
fix(docs): keep All props fields and Copy React in step with the preview
hunterbecton Oct 30, 2026
ba58101
fix(docs): reject mixed selects and escape line breaks in Copy React
hunterbecton Oct 30, 2026
7d8f772
fix(docs): keep curated controls on their entry and their target
hunterbecton Oct 30, 2026
8db87c4
fix(docs): allow numeric controls on crossSection and avoid export na…
hunterbecton Oct 31, 2026
0157d15
feat(shaders): export STYLE_DIAL_DEFAULTS from a three-free defaults …
hunterbecton Nov 1, 2026
9326950
fix(docs): build the golden files with the page's seeds
hunterbecton Nov 1, 2026
cf772a1
docs: allow three-free subpaths for component defaults and fix stale …
hunterbecton Nov 1, 2026
68bb1ab
fix(docs): import STYLE_DIAL_DEFAULTS from the defaults subpath
hunterbecton Nov 1, 2026
e15f823
test(docs): fail when a composition title can't name a component
hunterbecton Nov 1, 2026
8978e94
fix(docs): escape strings in Copy React the way the generator does
hunterbecton Nov 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/style-dial-defaults-subpath.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 5 additions & 1 deletion .fallowrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<LinearGradient>`, 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 `<ShaderScene>` wrap, and you compose by stacking children in one scene. In Mode 2, users call `useShaderMaterial` inside their own r3f `<Canvas>`. 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

Expand Down
99 changes: 99 additions & 0 deletions apps/docs-tests/docs/gallery-index.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<Card[]> {
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([]);
});
57 changes: 57 additions & 0 deletions apps/docs-tests/visual/gallery-fixture.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/docs/content/docs/react/guides/ssr-and-fallbacks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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` | `<ShaderPoster>` |

Expand Down
1 change: 1 addition & 0 deletions apps/docs/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
};
Expand Down
Binary file added apps/docs/public/posters/gallery/fixture.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/docs/public/posters/gallery/fixture.thumb.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 10 additions & 2 deletions apps/docs/src/app/components/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -97,9 +99,12 @@ export default async function ComponentPage({ params }: PageProps) {
<p className={styles.description}>{record.description}</p>
</div>
<PageActions
componentName={record.componentName}
markdownUrl={`/md${record.url}.md`}
siblings={entry.copySiblings}
target={{
kind: 'component',
componentName: record.componentName,
siblings: entry.copySiblings,
}}
/>
</header>
<Island />
Expand All @@ -109,6 +114,9 @@ export default async function ComponentPage({ params }: PageProps) {
the shader's center; see .dock in page-toc.module.css. */}
<PageToc sections={sections} />
<div className={styles.sections}>
{/* Provisional: the compositions whose stack holds this component.
Renders nothing when none does. */}
<UsedIn compositions={compositionsUsing(record.componentName)} />
<section className={styles.section} id="usage">
<h2 className={styles.sectionTitle}>Usage</h2>
{entry.usageNotes === undefined ? null : (
Expand Down
17 changes: 15 additions & 2 deletions apps/docs/src/app/components/aurora/demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<ControlPanel>
<>
<Section title="Motion">
<SliderInput label="Speed" max={3} min={0} path="speed" step={0.01} />
<SliderInput label="Waviness" max={3} min={0} path="waviness" step={0.01} />
Expand Down Expand Up @@ -129,6 +134,14 @@ export function AuroraControls() {
</>
)}
</ListInput>
</>
);
}

export function AuroraControls() {
return (
<ControlPanel>
<AuroraFields />
</ControlPanel>
);
}
Expand Down
17 changes: 15 additions & 2 deletions apps/docs/src/app/components/blobs/demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<ControlPanel>
<>
<Section title="Blobs">
<SliderInput label="Count" max={20} min={1} path="count" step={0.1} />
<SliderInput label="Size" max={1} min={0} path="size" step={0.01} />
Expand Down Expand Up @@ -87,6 +92,14 @@ function BlobsControls() {
>
{() => <ColorInput label="Color" path="color" />}
</ListInput>
</>
);
}

function BlobsControls() {
return (
<ControlPanel>
<BlobsFields />
</ControlPanel>
);
}
Expand Down
17 changes: 15 additions & 2 deletions apps/docs/src/app/components/conic-gradient/demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<ControlPanel>
<>
<Section title="Motion">
<SliderInput label="Speed" max={2} min={-2} path="speed" step={0.01} />
</Section>
Expand Down Expand Up @@ -88,6 +93,14 @@ function ConicGradientControls() {
</>
)}
</ListInput>
</>
);
}

function ConicGradientControls() {
return (
<ControlPanel>
<ConicGradientFields />
</ControlPanel>
);
}
Expand Down
17 changes: 15 additions & 2 deletions apps/docs/src/app/components/cursor-ripple/demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<ControlPanel>
<>
<Section title="Water">
<SliderInput label="Refraction" max={0.5} min={0} path="refraction" step={0.01} />
<SliderInput label="Radius" max={0.15} min={0.005} path="radius" step={0.005} />
<SliderInput label="Decay" max={1} min={0} path="decay" step={0.01} />
<SliderInput label="Shine" max={2} min={0} path="shine" step={0.01} />
</Section>
</>
);
}

function CursorRippleControls() {
return (
<ControlPanel>
<CursorRippleFields />
</ControlPanel>
);
}
Expand Down
17 changes: 15 additions & 2 deletions apps/docs/src/app/components/cursor-smudge/demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<ControlPanel>
<>
<Section title="Smudge">
<SliderInput label="Strength" max={0.3} min={0} path="strength" step={0.01} />
<SliderInput label="Radius" max={0.5} min={0} path="radius" step={0.01} />
<SliderInput label="Decay" max={1} min={0} path="decay" step={0.01} />
</Section>
</>
);
}

function CursorSmudgeControls() {
return (
<ControlPanel>
<CursorSmudgeFields />
</ControlPanel>
);
}
Expand Down
Loading
Loading