diff --git a/.github/workflows/generated-skills.yml b/.github/workflows/generated-skills.yml
new file mode 100644
index 000000000..cec08952b
--- /dev/null
+++ b/.github/workflows/generated-skills.yml
@@ -0,0 +1,39 @@
+name: Generated Skills
+
+on:
+ pull_request:
+ branches: [main]
+ paths:
+ - 'docs/src/shader-defs/**'
+ - 'packages/shaders/**'
+ - 'packages/shaders-react/**'
+ - 'packages/skill-generator/**'
+ - 'skills/**'
+ - '.github/workflows/generated-skills.yml'
+ - '.bun-version'
+ - 'bun.lock'
+ - 'package.json'
+ - 'publish.js'
+
+permissions:
+ contents: read
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Use Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version-file: .bun-version
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
+ - name: Type-check generator
+ run: bun run --cwd packages/skill-generator type-check
+
+ - name: Check generated skills
+ run: bun run check-skills
diff --git a/bun.lock b/bun.lock
index cfd0bf0b0..b204fa41e 100644
--- a/bun.lock
+++ b/bun.lock
@@ -66,6 +66,9 @@
"@types/react",
],
},
+ "packages/skill-generator": {
+ "name": "@paper-design/skill-generator",
+ },
},
"patchedDependencies": {
"leva@0.9.34": "patches/leva@0.9.34.patch",
@@ -319,6 +322,8 @@
"@paper-design/shaders-react": ["@paper-design/shaders-react@workspace:packages/shaders-react"],
+ "@paper-design/skill-generator": ["@paper-design/skill-generator@workspace:packages/skill-generator"],
+
"@radix-ui/popper": ["@radix-ui/popper@0.1.0", "", { "dependencies": { "@babel/runtime": "^7.13.10", "csstype": "^3.0.4" } }, "sha512-uzYeElL3w7SeNMuQpXiFlBhTT+JyaNMCwDfjKkrzugEcYrf5n52PHqncNdQPUtR42hJh8V9FsqyEDbDxkeNjJQ=="],
"@radix-ui/primitive": ["@radix-ui/primitive@0.1.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" } }, "sha512-tqxZKybwN5Fa3VzZry4G6mXAAb9aAqKmPtnVbZpL0vsBwvOHTBwsjHVPXylocYLwEtBY9SCe665bYnNB515uoA=="],
diff --git a/package.json b/package.json
index 456b083d8..491cc6550 100644
--- a/package.json
+++ b/package.json
@@ -5,6 +5,7 @@
"license": "Apache-2.0",
"workspaces": [
"docs",
+ "packages/skill-generator",
"packages/shaders",
"packages/shaders-react"
],
@@ -13,6 +14,8 @@
"publish-all": "run-s clean build && bun run publish.js",
"publish-canary": "run-s clean build && bun run publish.js --canary",
"publish-all-test": "run-s clean build && bun run publish.js --dry-run",
+ "generate-skills": "bun run --cwd packages/skill-generator generate",
+ "check-skills": "bun run --cwd packages/skill-generator check",
"dev:docs": "bun run --filter docs dev",
"dev:packages": "nodemon --watch 'packages/*/src/**/*' --ext ts,tsx --exec 'bun run build' --on-change-only",
"dev": "run-s clean build && run-p dev:*",
diff --git a/packages/skill-generator/content/fragments.json b/packages/skill-generator/content/fragments.json
new file mode 100644
index 000000000..accaec727
--- /dev/null
+++ b/packages/skill-generator/content/fragments.json
@@ -0,0 +1,13 @@
+{
+ "shaderLink": "- [{{name}}](references/shaders/{{slug}}.md)",
+ "note": "- {{note}}",
+ "default": "{{name}}={{value}}",
+ "inlineCode": "`{{value}}`",
+ "tableHeader": [
+ "| Prop | Type | Required | React default | Constraints | Effect |",
+ "| --- | --- | --- | --- | --- | --- |"
+ ],
+ "tableRow": "| `{{name}}` | `{{type}}` | {{required}} | {{defaultValue}} | {{constraints}} | {{description}} |",
+ "usageProperty": "- `{{name}}{{optional}}: {{type}}`",
+ "usageMotionProperty": "- `{{name}}{{optional}}: {{type}}`: {{description}}"
+}
diff --git a/packages/skill-generator/content/orders.json b/packages/skill-generator/content/orders.json
new file mode 100644
index 000000000..a04054343
--- /dev/null
+++ b/packages/skill-generator/content/orders.json
@@ -0,0 +1,16 @@
+{
+ "usageComponentProperties": ["ref", "width", "height", "minPixelRatio", "maxPixelCount", "webGlContextAttributes"],
+ "commonDefaults": [
+ "speed",
+ "frame",
+ "fit",
+ "scale",
+ "rotation",
+ "offsetX",
+ "offsetY",
+ "originX",
+ "originY",
+ "worldWidth",
+ "worldHeight"
+ ]
+}
diff --git a/packages/skill-generator/content/templates/shader.md b/packages/skill-generator/content/templates/shader.md
new file mode 100644
index 000000000..4088034bb
--- /dev/null
+++ b/packages/skill-generator/content/templates/shader.md
@@ -0,0 +1,12 @@
+# {{name}}
+
+Use the listed implementation types, options, capacities, exports, and defaults. Values labeled “editor range” come from the documentation UI definitions; the component does not clamp props to that range.
+
+{{description}}
+
+- React: `{{component}}` and `{{preset}}` from `@paper-design/shaders-react`.
+- Vanilla: `{{fragmentShader}}` and `{{paramsType}}` from `@paper-design/shaders`.
+- Common controls: {{controls}}. Defaults: {{defaults}}.{{notes}}
+- {{source}}
+
+{{table}}
diff --git a/packages/skill-generator/content/templates/skill.md b/packages/skill-generator/content/templates/skill.md
new file mode 100644
index 000000000..adfafff7d
--- /dev/null
+++ b/packages/skill-generator/content/templates/skill.md
@@ -0,0 +1,47 @@
+---
+name: paper-shaders
+description: Implement, convert, customize, debug, or review Paper Shaders effects in React or vanilla JavaScript using `@paper-design/shaders-react` and `@paper-design/shaders`.
+---
+
+# Paper Shaders
+
+Implement Paper Shaders from the package source contract. Do not infer prop names, defaults, enum values, uniform mappings, color limits, or image behavior.
+
+## Workflow
+
+1. Inspect the target project's package manager and installed Paper Shaders package/version. Preserve the existing framework and dependency style.
+2. Read [references/usage.md](references/usage.md) before writing integration code.
+3. Read only the relevant file under [Shader references](#shader-references). Use its exact component export, fragment-shader export, props, defaults, enum options, and implementation capacity.
+4. For React, prefer the named shader component. Use the low-level React `ShaderMount` only for a custom fragment shader or an explicitly requested uniform-level integration.
+5. For vanilla JavaScript or TypeScript, reproduce the named React component's uniform construction. Apply every conversion and special requirement listed in `usage.md` and the matching shader reference.
+6. Give the mount element an explicit rendered size. Keep shader props separate from layout styles and ordinary DOM props.
+7. Verify with the project's type-check/build and, when rendering is available, inspect the result at the intended dimensions.
+
+## Source authority
+
+- Treat `packages/shaders/src/shaders/*.ts` and `packages/shaders-react/src/shaders/*.tsx` as authoritative for runtime behavior, types, defaults, enum mappings, and uniforms.
+- Treat `packages/shaders/src/shader-mount.ts`, `packages/shaders/src/shader-sizing.ts`, and `packages/shaders-react/src/shader-mount.tsx` as authoritative for mounting, images, sizing, motion, and performance controls.
+- Treat numeric ranges and steps in `docs/src/shader-defs/*-def.ts` as editor guidance, not runtime validation. Components pass values through without clamping.
+- Resolve source contradictions in favor of the shader implementation and its exported types/constants. Never repeat a stale prose claim when an array size, type, constant, or component mapping contradicts it.
+- Recheck current source when working against a different repository revision. Do not assume this reference overrides changed code.
+
+## Implementation rules
+
+- Import only public exports from the package entry point.
+- Use `@paper-design/shaders-react` for named React components and presets.
+- Use `@paper-design/shaders` for `ShaderMount`, fragment shaders, uniform types, enum maps, color conversion, noise textures, and image preprocessors.
+- Supply all required initial vanilla uniforms. `ShaderMount` records uniform locations from the constructor's initial uniform object; later partial updates cannot introduce an unregistered uniform.
+- Dispose vanilla mounts during teardown.
+- Keep color arrays non-empty and do not exceed the shader reference's implementation capacity for colors or other fixed-size loops.
+- Do not pass CSS named colors. The source color parser accepts hex, `rgb`/`rgba`, and `hsl`/`hsla` syntax.
+- Do not use deprecated React aliases in new code.
+
+## References
+
+- [Usage and integration](references/usage.md): React, vanilla, common controls, uniform conversion, images, lifecycle, and special cases.
+
+## Shader references
+
+Read only the file for the shader being used:
+
+{{shaderLinks}}
diff --git a/packages/skill-generator/content/templates/usage.md b/packages/skill-generator/content/templates/usage.md
new file mode 100644
index 000000000..c943dab37
--- /dev/null
+++ b/packages/skill-generator/content/templates/usage.md
@@ -0,0 +1,271 @@
+# Paper Shaders usage
+
+This reference is derived from:
+
+- `packages/shaders/src/index.ts`
+- `packages/shaders/src/shader-mount.ts`
+- `packages/shaders/src/shader-sizing.ts`
+- `packages/shaders/src/get-shader-color-from-string.ts`
+- `packages/shaders-react/src/index.ts`
+- `packages/shaders-react/src/shader-mount.tsx`
+- `packages/shaders-react/src/shaders/*.tsx`
+
+Read the matching file under [Shader references](../SKILL.md#shader-references) for shader-specific props, defaults, ranges, enum options, exports, capacities, and special vanilla requirements.
+
+## Contents
+
+- [Packages](#packages)
+- [React](#react)
+ - [Presets](#presets)
+ - [Low-level React mount](#low-level-react-mount)
+- [Vanilla](#vanilla)
+ - [Convert React props to vanilla uniforms](#convert-react-props-to-vanilla-uniforms)
+ - [Noise textures](#noise-textures)
+ - [Images](#images)
+ - [Preprocessed image shaders](#preprocessed-image-shaders)
+ - [Color syntax](#color-syntax)
+- [Common sizing and motion](#common-sizing-and-motion)
+- [Verification](#verification)
+
+## Packages
+
+- React: `@paper-design/shaders-react`
+- Vanilla JavaScript or TypeScript: `@paper-design/shaders`
+
+The repository README asks consumers to pin the dependency because breaking changes may ship under `0.0.x` versioning. Match the project's existing package manager and version policy.
+
+## React
+
+Use the named component exported by `@paper-design/shaders-react`.
+
+```tsx
+import { MeshGradient } from '@paper-design/shaders-react';
+
+export function HeroShader() {
+ return (
+
+ );
+}
+```
+
+Every named shader component accepts its shader-specific params plus the common component controls:
+
+- ordinary `div` props except `color` and `ref`
+{{componentControls}}
+
+`width` and `height` become inline styles. Other layout styles can be passed through `style`. Ensure the element resolves to a non-zero width and height.
+
+### Presets
+
+Each named React component has a corresponding exported preset array, such as `meshGradientPresets`. Preset `params` contain the shader params plus all sizing defaults and, when the shader supports motion, `speed` and `frame`. Image presets intentionally omit `image`; React-only component controls are also outside preset `params`.
+
+```tsx
+import { MeshGradient, meshGradientPresets } from '@paper-design/shaders-react';
+
+const preset = meshGradientPresets[0].params;
+
+;
+```
+
+Use the catalog's exact preset export name.
+
+### Low-level React mount
+
+`ShaderMount` from `@paper-design/shaders-react` accepts:
+
+- `fragmentShader`
+- `uniforms`
+- `speed` and `frame`
+- `mipmaps`
+- `minPixelRatio` and `maxPixelCount`
+- `webGlContextAttributes`
+- `width`, `height`, and ordinary supported `div` props
+
+String uniform values are treated as image URLs, not arbitrary string uniforms. Prefer named components for package shaders because they construct and convert uniforms correctly.
+
+## Vanilla
+
+The vanilla package exports fragment shader source and `ShaderMount`; it does not provide named convenience mount functions. Construct the complete uniform object yourself.
+Create and dispose the mount in a browser/client lifecycle: the class requires DOM, `navigator`, canvas, and WebGL APIs.
+
+```ts
+import {
+ ShaderFitOptions,
+ ShaderMount,
+ getShaderColorFromString,
+ meshGradientFragmentShader,
+ type MeshGradientUniforms,
+} from '@paper-design/shaders';
+
+const host = document.querySelector('#shader');
+if (!host) throw new Error('Missing #shader element');
+
+const colors = ['#e0eaff', '#241d9a', '#f75092', '#9f50d3'];
+
+const uniforms: MeshGradientUniforms = {
+ u_colors: colors.map(getShaderColorFromString),
+ u_colorsCount: colors.length,
+ u_distortion: 0.8,
+ u_swirl: 0.1,
+ u_grainMixer: 0,
+ u_grainOverlay: 0,
+ u_fit: ShaderFitOptions.contain,
+ u_scale: 1,
+ u_rotation: 0,
+ u_offsetX: 0,
+ u_offsetY: 0,
+ u_originX: 0.5,
+ u_originY: 0.5,
+ u_worldWidth: 0,
+ u_worldHeight: 0,
+};
+
+const mount = new ShaderMount(
+ host,
+ meshGradientFragmentShader,
+ uniforms,
+ undefined,
+ 1,
+ 0
+);
+
+mount.setUniforms({ u_distortion: 0.5 });
+mount.setSpeed(0.5);
+
+// Call during teardown:
+mount.dispose();
+```
+
+The constructor arguments, in order, are:
+
+1. parent `HTMLElement`
+2. fragment shader string
+3. initial uniform object
+4. optional `WebGLContextAttributes`
+5. speed, default `0`
+6. frame, default `0`
+7. minimum pixel ratio, default `2`
+8. maximum pixel count, default `1920 * 1080 * 4`
+9. uniform names that require mipmaps, default `[]`
+
+The parent receives a prepended canvas, `data-paper-shader`, and `paperShaderMount`. `dispose()` removes the canvas and WebGL resources.
+
+`ShaderMount` supports partial updates with `setUniforms`, and also exposes `getCurrentFrame`, `setFrame`, `setSpeed`, `setMinPixelRatio`, and `setMaxPixelCount`.
+
+### Convert React props to vanilla uniforms
+
+Follow the named React component's `uniforms` object. Apply these source-defined rules:
+
+- Convert each color string with `getShaderColorFromString`.
+- Convert `colors` with `.map(getShaderColorFromString)` and also set `u_colorsCount`.
+- Convert `fit` with `ShaderFitOptions[fit]`.
+- Convert enum props with their exported mapping object, such as `WarpPatterns[shape]`.
+- Pass `speed` and `frame` to `ShaderMount`; they are not shader-specific uniforms.
+- Map common sizing props to `u_fit`, `u_scale`, `u_rotation`, `u_offsetX`, `u_offsetY`, `u_originX`, `u_originY`, `u_worldWidth`, and `u_worldHeight`.
+- Pass booleans as booleans. `ShaderMount` converts them to integer uniforms.
+- Pass textures as fully loaded `HTMLImageElement` instances.
+
+Do not assume every prop becomes `u_${prop}`. These package mappings differ:
+
+- `Dithering.size` and `ImageDithering.size` → `u_pxSize`
+- `DotGrid.size` → `u_dotSize`
+- `FlutedGlass.margin` and `PulsingBorder.margin` → defaults for all four side-specific margin uniforms; an explicitly supplied side value wins
+- `width`, `height`, `minPixelRatio`, `maxPixelCount`, `webGlContextAttributes`, and `ref` configure the mount/container and are not uniforms
+- `suspendWhenProcessingImage` and deprecated compatibility props are React-only
+
+Always inspect the catalog's source paths when constructing a vanilla shader. The uniform interfaces in `packages/shaders/src/shaders/*.ts` list the complete required uniform object.
+
+### Noise textures
+
+For shaders whose catalog says to set `u_noiseTexture`, import `getShaderNoiseTexture` and include:
+
+```ts
+const noiseTexture = getShaderNoiseTexture();
+if (!noiseTexture) throw new Error('Noise textures require a browser');
+await noiseTexture.decode();
+
+// Include in the initial uniforms:
+u_noiseTexture: noiseTexture
+```
+
+The source returns `undefined` outside the browser and a newly created `HTMLImageElement` in the browser. Wait for it to load before constructing the vanilla `ShaderMount`; the mount rejects incomplete images. The React mount performs this wait itself.
+
+### Images
+
+The low-level vanilla mount accepts `HTMLImageElement`, not URL strings. The image must be fully loaded and have a non-zero `naturalWidth`; otherwise mounting the texture throws.
+
+The React mount accepts an `HTMLImageElement` or a string that is either:
+
+- an absolute path beginning with `/`
+- a URL accepted by `new URL(value)`
+- an empty string, which becomes a transparent pixel
+
+For an external URL, the React loader sets `crossOrigin = "anonymous"`. When both natural dimensions are below 1024, it sets the image dimensions so the shorter side is 1024 before upload.
+
+When a texture is supplied as `u_image`, `ShaderMount` automatically looks up and fills `u_imageAspectRatio`.
+
+Pass `["u_image"]` as the final vanilla constructor argument when the catalog requires mipmaps.
+
+### Preprocessed image shaders
+
+`Heatmap`, `LiquidMetal`, and `GemSmoke` preprocess image inputs in their React components. Reproduce this in vanilla:
+
+- `toProcessedHeatmap(fileOrUrl)` returns `Promise<{ blob: Blob }>`
+- `toProcessedLiquidMetal(fileOrUrl)` returns `Promise<{ imageData: ImageData; pngBlob: Blob }>`
+- `toProcessedGemSmoke(fileOrUrl)` returns `Promise<{ imageData: ImageData; pngBlob: Blob }>`
+
+Each function accepts `File | string` and requires browser document/canvas APIs. Load the returned blob into an `HTMLImageElement`, pass that loaded image as `u_image`, and enable mipmaps for `u_image`.
+
+For `LiquidMetal` and `GemSmoke`, also set `u_isImage` from whether an original image was supplied and convert `shape` with `LiquidMetalShapes` or `GemSmokeShapes`. When no original image is supplied, load the exported `emptyPixel` into an `HTMLImageElement`, use that placeholder as the initial `u_image`, and set `u_isImage` to `false`; the shader still declares and samples `u_image`.
+
+### Color syntax
+
+`getShaderColorFromString` supports:
+
+- 3-, 4-, 6-, and 8-digit hex
+- comma-form `rgb(...)` and `rgba(...)`
+- comma-form `hsl(...)` and `hsla(...)`
+- already-normalized RGB or RGBA number tuples when calling the utility directly
+
+It does not parse CSS named colors. Invalid values fall back to `[0.5, 0.5, 0.5, 1]`.
+
+## Common sizing and motion
+
+All named shader components accept:
+
+{{sizingProperties}}
+
+Defaults come from one of two source objects, then may be overridden by the component's default preset:
+
+- `defaultObjectSizing`: `fit="contain"` and otherwise `scale=1`, `rotation=0`, offsets `0`, origins `0.5`, and world dimensions `0`
+- `defaultPatternSizing`: the same values except `fit="none"`
+
+The catalog lists the effective defaults for every shader.
+
+Shaders whose params extend `ShaderMotionParams` also accept:
+
+{{motionProperties}}
+
+The mount pauses animated rendering when the document is hidden. It also pauses when the element leaves the viewport when `IntersectionObserver` is available in the element's window.
+
+`minPixelRatio` defaults to `2`. `maxPixelCount` defaults to `1920 * 1080 * 4` physical pixels. The `Waves` React component overrides its `maxPixelCount` default to `6016 * 3384`.
+The `DotGrid` React component uses the same `6016 * 3384` override.
+
+## Verification
+
+- Type-check against the installed package version.
+- Confirm the mount has a non-zero layout size.
+- Confirm all initial vanilla uniforms are present.
+- Confirm color arrays are non-empty and do not exceed the implementation capacity in the catalog.
+- Confirm image inputs load and satisfy CORS rules.
+- Confirm preprocessors run only in a browser environment.
+- Call `dispose()` for vanilla mounts during teardown.
diff --git a/packages/skill-generator/content/text.json b/packages/skill-generator/content/text.json
new file mode 100644
index 000000000..8c85811c4
--- /dev/null
+++ b/packages/skill-generator/content/text.json
@@ -0,0 +1,14 @@
+{
+ "phrases": {
+ "noiseRequirement": "Vanilla requirements: create `u_noiseTexture` with `getShaderNoiseTexture()` and wait for the image to load before constructing `ShaderMount`.",
+ "mipmapRequirement": "Vanilla requirements: pass `[\"u_image\"]` as the `ShaderMount` mipmaps argument.",
+ "combinedNoiseAndMipmapRequirement": "Vanilla requirements: create `u_noiseTexture` with `getShaderNoiseTexture()` and wait for the image to load before constructing `ShaderMount`; pass `[\"u_image\"]` as the `ShaderMount` mipmaps argument.",
+ "preprocessorRequirement": "Vanilla requirements: preprocess image input with `{{preprocessor}}`; pass `[\"u_image\"]` as the `ShaderMount` mipmaps argument.",
+ "maxPixelCount": "React performance override: `maxPixelCount` defaults to `{{expression}}`.",
+ "enumMismatch": "Enum source note: the documentation UI definition contains stale {{documented}}; the exported `{{mapping}}` mapping and component type use {{source}}."
+ },
+ "usageMotionDescriptions": {
+ "speed": "`0` stops the animation loop; negative values play backward",
+ "frame": "animation position in milliseconds; the shader receives seconds as `u_time`"
+ }
+}
diff --git a/packages/skill-generator/package.json b/packages/skill-generator/package.json
new file mode 100644
index 000000000..9e43978bc
--- /dev/null
+++ b/packages/skill-generator/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "@paper-design/skill-generator",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "generate": "bun run src/generate-skills.ts",
+ "check": "bun run src/generate-skills.ts --check",
+ "type-check": "tsc --project tsconfig.json"
+ }
+}
diff --git a/packages/skill-generator/src/ast.ts b/packages/skill-generator/src/ast.ts
new file mode 100644
index 000000000..750a02b37
--- /dev/null
+++ b/packages/skill-generator/src/ast.ts
@@ -0,0 +1,196 @@
+import ts from 'typescript';
+import type { StaticRecord, StaticValue } from './model.js';
+
+export const unwrapExpression = (expression: ts.Expression): ts.Expression => {
+ if (
+ ts.isAsExpression(expression) ||
+ ts.isSatisfiesExpression(expression) ||
+ ts.isTypeAssertionExpression(expression) ||
+ ts.isNonNullExpression(expression) ||
+ ts.isParenthesizedExpression(expression)
+ ) {
+ return unwrapExpression(expression.expression);
+ }
+
+ return expression;
+};
+
+export const resolveSymbol = (checker: ts.TypeChecker, node: ts.Node): ts.Symbol | undefined => {
+ const symbol = checker.getSymbolAtLocation(node);
+ return symbol && symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol;
+};
+
+export const variableInitializer = (symbol: ts.Symbol | undefined): ts.Expression | undefined =>
+ symbol?.declarations
+ ?.filter(ts.isVariableDeclaration)
+ .map((declaration) => declaration.initializer)
+ .find((initializer): initializer is ts.Expression => Boolean(initializer));
+
+export const propertyName = (name: ts.PropertyName | ts.BindingName | undefined): string | undefined => {
+ if (!name) return undefined;
+ if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
+ return name.text;
+ }
+
+ return undefined;
+};
+
+export const objectProperty = (object: ts.ObjectLiteralExpression, name: string): ts.PropertyAssignment | undefined =>
+ object.properties.filter(ts.isPropertyAssignment).find((property) => propertyName(property.name) === name);
+
+export const exportedVariable = (sourceFile: ts.SourceFile, name: string): ts.VariableDeclaration | undefined =>
+ sourceFile.statements
+ .filter(ts.isVariableStatement)
+ .filter((statement) => statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword))
+ .flatMap((statement) => [...statement.declarationList.declarations])
+ .find((declaration) => propertyName(declaration.name) === name);
+
+export const interfaceDeclaration = (sourceFile: ts.SourceFile, name: string): ts.InterfaceDeclaration | undefined =>
+ sourceFile.statements.filter(ts.isInterfaceDeclaration).find((declaration) => declaration.name.text === name);
+
+export const typeAliasDeclaration = (sourceFile: ts.SourceFile, name: string): ts.TypeAliasDeclaration | undefined =>
+ sourceFile.statements.filter(ts.isTypeAliasDeclaration).find((declaration) => declaration.name.text === name);
+
+export const staticExpressionValue = (
+ checker: ts.TypeChecker,
+ input: ts.Expression,
+ visited: ReadonlySet = new Set()
+): StaticValue | undefined => {
+ const expression = unwrapExpression(input);
+ if (visited.has(expression)) return undefined;
+ const nextVisited = new Set(visited).add(expression);
+ const literal = literalExpressionValue(expression);
+ if (literal !== undefined) return literal;
+
+ if (ts.isPrefixUnaryExpression(expression)) return unaryExpressionValue(checker, expression, nextVisited);
+ if (ts.isArrayLiteralExpression(expression)) return arrayExpressionValue(checker, expression, nextVisited);
+ if (ts.isObjectLiteralExpression(expression)) return objectExpressionValue(checker, expression, nextVisited);
+ if (ts.isIdentifier(expression)) return identifierExpressionValue(checker, expression, nextVisited);
+ if (ts.isPropertyAccessExpression(expression)) return propertyAccessExpressionValue(checker, expression, nextVisited);
+ if (ts.isBinaryExpression(expression)) return binaryExpressionValue(checker, expression, nextVisited);
+
+ return undefined;
+};
+
+export const sourceStaticValue = (input: ts.Expression): StaticValue | undefined => {
+ const expression = unwrapExpression(input);
+ const literal = literalExpressionValue(expression);
+ if (literal !== undefined) return literal;
+ if (ts.isPrefixUnaryExpression(expression)) {
+ const value = sourceStaticValue(expression.operand);
+ return typeof value === 'number' && expression.operator === ts.SyntaxKind.MinusToken ? -value : value;
+ }
+ if (ts.isArrayLiteralExpression(expression)) {
+ const values = expression.elements.map((element) =>
+ ts.isExpression(element) ? sourceStaticValue(element) : undefined
+ );
+ return values.every((value) => value !== undefined) ? (values as StaticValue[]) : undefined;
+ }
+ return undefined;
+};
+
+export const isStaticRecord = (value: StaticValue | undefined): value is StaticRecord =>
+ typeof value === 'object' && value !== null && !Array.isArray(value);
+
+const literalExpressionValue = (expression: ts.Expression): StaticValue | undefined => {
+ if (ts.isStringLiteralLike(expression)) return expression.text;
+ if (ts.isNumericLiteral(expression)) return Number(expression.text);
+ if (expression.kind === ts.SyntaxKind.TrueKeyword) return true;
+ if (expression.kind === ts.SyntaxKind.FalseKeyword) return false;
+ if (expression.kind === ts.SyntaxKind.NullKeyword) return null;
+ return undefined;
+};
+
+const unaryExpressionValue = (
+ checker: ts.TypeChecker,
+ expression: ts.PrefixUnaryExpression,
+ visited: ReadonlySet
+): StaticValue | undefined => {
+ const value = staticExpressionValue(checker, expression.operand, visited);
+ if (typeof value !== 'number') return undefined;
+ if (expression.operator === ts.SyntaxKind.MinusToken) return -value;
+ if (expression.operator === ts.SyntaxKind.PlusToken) return value;
+ return undefined;
+};
+
+const arrayExpressionValue = (
+ checker: ts.TypeChecker,
+ expression: ts.ArrayLiteralExpression,
+ visited: ReadonlySet
+): StaticValue[] | undefined => {
+ const values = expression.elements.map((element) =>
+ ts.isSpreadElement(element) ? undefined : staticExpressionValue(checker, element, visited)
+ );
+ return values.every((value) => value !== undefined) ? (values as StaticValue[]) : undefined;
+};
+
+const objectExpressionValue = (
+ checker: ts.TypeChecker,
+ expression: ts.ObjectLiteralExpression,
+ visited: ReadonlySet
+): StaticRecord | undefined =>
+ expression.properties.reduce(
+ (result, property) => mergeObjectProperty(checker, result, property, visited),
+ {}
+ );
+
+const mergeObjectProperty = (
+ checker: ts.TypeChecker,
+ record: StaticRecord | undefined,
+ property: ts.ObjectLiteralElementLike,
+ visited: ReadonlySet
+): StaticRecord | undefined => {
+ if (!record) return undefined;
+ if (ts.isSpreadAssignment(property)) {
+ const spread = staticExpressionValue(checker, property.expression, visited);
+ return isStaticRecord(spread) ? { ...record, ...spread } : undefined;
+ }
+ if (ts.isPropertyAssignment(property)) {
+ const name = propertyName(property.name);
+ const value = staticExpressionValue(checker, property.initializer, visited);
+ return name && value !== undefined ? { ...record, [name]: value } : undefined;
+ }
+ if (ts.isShorthandPropertyAssignment(property)) {
+ const value = staticExpressionValue(checker, property.name, visited);
+ return value !== undefined ? { ...record, [property.name.text]: value } : undefined;
+ }
+ return record;
+};
+
+const identifierExpressionValue = (
+ checker: ts.TypeChecker,
+ expression: ts.Identifier,
+ visited: ReadonlySet
+): StaticValue | undefined => {
+ const initializer = variableInitializer(resolveSymbol(checker, expression));
+ return initializer ? staticExpressionValue(checker, initializer, visited) : undefined;
+};
+
+const propertyAccessExpressionValue = (
+ checker: ts.TypeChecker,
+ expression: ts.PropertyAccessExpression,
+ visited: ReadonlySet
+): StaticValue | undefined => {
+ const object = staticExpressionValue(checker, expression.expression, visited);
+ return isStaticRecord(object) ? object[expression.name.text] : undefined;
+};
+
+const binaryExpressionValue = (
+ checker: ts.TypeChecker,
+ expression: ts.BinaryExpression,
+ visited: ReadonlySet
+): number | undefined => {
+ const left = staticExpressionValue(checker, expression.left, visited);
+ const right = staticExpressionValue(checker, expression.right, visited);
+ return typeof left === 'number' && typeof right === 'number'
+ ? numericBinaryValue(expression.operatorToken.kind, left, right)
+ : undefined;
+};
+
+const numericBinaryValue = (operator: ts.SyntaxKind, left: number, right: number): number | undefined => {
+ if (operator === ts.SyntaxKind.PlusToken) return left + right;
+ if (operator === ts.SyntaxKind.MinusToken) return left - right;
+ if (operator === ts.SyntaxKind.AsteriskToken) return left * right;
+ if (operator === ts.SyntaxKind.SlashToken) return left / right;
+ return undefined;
+};
diff --git a/packages/skill-generator/src/catalog.test.ts b/packages/skill-generator/src/catalog.test.ts
new file mode 100644
index 000000000..e68c8a66f
--- /dev/null
+++ b/packages/skill-generator/src/catalog.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, test } from 'bun:test';
+import { propertyCapacity } from './catalog.js';
+
+describe('propertyCapacity', () => {
+ test('resolves a plural property from singular count metadata', () => {
+ expect(propertyCapacity('colors', { maxColorCount: 10 })).toBe(10);
+ });
+
+ test('preserves direct metadata matches', () => {
+ expect(propertyCapacity('spots', { maxSpots: 4 })).toBe(4);
+ });
+});
diff --git a/packages/skill-generator/src/catalog.ts b/packages/skill-generator/src/catalog.ts
new file mode 100644
index 000000000..6d0214ffd
--- /dev/null
+++ b/packages/skill-generator/src/catalog.ts
@@ -0,0 +1,97 @@
+import type {
+ DocumentationProperty,
+ DocumentationShader,
+ ShaderProperty,
+ ShaderComment,
+ ShaderCommentProperty,
+ SourceProperty,
+ StaticRecord,
+} from './model.js';
+
+export const combineProperties = (
+ source: SourceProperty[],
+ documentation: DocumentationShader,
+ sourceComment: ShaderComment,
+ uniformNames: Record,
+ defaults: StaticRecord,
+ metadata: StaticRecord
+): ShaderProperty[] =>
+ source.map((property) => {
+ const documented = documentation.properties.find((candidate) => candidate.name === property.name);
+ const commented = sourceComment.properties.find(
+ (candidate) => candidate.name === (uniformNames[property.name] ?? property.name)
+ );
+ return {
+ ...property,
+ ...(defaults[property.name] !== undefined ? { defaultValue: defaults[property.name] } : {}),
+ description: propertyDescription(property, commented, documented),
+ constraints: propertyConstraints(property, commented, documented, metadata),
+ };
+ });
+
+const propertyDescription = (
+ property: SourceProperty,
+ sourceComment: ShaderCommentProperty | undefined,
+ documentation: DocumentationProperty | undefined
+): string => {
+ if (property.deprecated) {
+ return deprecatedDescription(property.deprecated);
+ }
+ if (property.description) {
+ return reactOnlyDescription(property.description);
+ }
+ return sourceComment?.description ?? documentation?.description ?? reactOnlyDescription();
+};
+
+const propertyConstraints = (
+ property: SourceProperty,
+ sourceComment: ShaderCommentProperty | undefined,
+ documentation: DocumentationProperty | undefined,
+ metadata: StaticRecord
+): string[] => {
+ if (property.deprecated) return [];
+ const range =
+ typeof documentation?.min === 'number' && typeof documentation.max === 'number'
+ ? editorRange(documentation.min, documentation.max)
+ : undefined;
+ const step = typeof documentation?.step === 'number' ? constraintStep(documentation.step) : undefined;
+ const options = property.options.length > 0 ? constraintOptions(property.options) : undefined;
+ const sourceRange =
+ sourceComment?.min !== undefined &&
+ sourceComment.max !== undefined &&
+ (sourceComment.min !== documentation?.min || sourceComment.max !== documentation.max)
+ ? documentedSourceRange(sourceComment.min, sourceComment.max)
+ : undefined;
+ const capacity = propertyCapacity(property.name, metadata);
+ const implementationCapacity =
+ capacity !== undefined && capacity !== sourceComment?.max && capacity !== documentation?.max
+ ? capacityConstraint(capacity)
+ : undefined;
+ return [range, step, options, sourceRange, implementationCapacity].filter((value): value is string => Boolean(value));
+};
+
+export const deprecatedDescription = (comment: string): string => `React-only. @deprecated ${comment}`;
+
+export const reactOnlyDescription = (description?: string): string =>
+ description ? `React-only. ${description}` : 'React-only.';
+
+export const editorRange = (min: number, max: number): string => `editor range: ${min}…${max}`;
+
+export const constraintStep = (step: number): string => `step: ${step}`;
+
+export const constraintOptions = (options: string[]): string =>
+ `options: ${options.map((option) => JSON.stringify(option)).join(', ')}`;
+
+export const documentedSourceRange = (min: number, max: number): string => `shader source documents ${min}…${max}`;
+
+export const capacityConstraint = (capacity: number): string => `implementation capacity: ${capacity}`;
+
+export const propertyCapacity = (propertyName: string, metadata: StaticRecord): number | undefined => {
+ const property = `${propertyName.charAt(0).toUpperCase()}${propertyName.slice(1)}`;
+ const singularProperty = propertyName.endsWith('s') ? property.slice(0, -1) : property;
+ const names = [`max${property}`, `max${singularProperty}Count`];
+ return names.map((name) => metadata[name]).find((value): value is number => typeof value === 'number');
+};
+
+export const replaceTokens = (template: string, values: Record): string =>
+ Object.entries(values).reduce((result, [name, value]) => result.replaceAll(`{{${name}}}`, value), template);
diff --git a/packages/skill-generator/src/content.ts b/packages/skill-generator/src/content.ts
new file mode 100644
index 000000000..66886e9a3
--- /dev/null
+++ b/packages/skill-generator/src/content.ts
@@ -0,0 +1,46 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import type { ContentOrders, RenderFragments, SkillContent, SkillTemplates, WrittenText } from './model.js';
+
+const templateNames = ['skill', 'usage', 'shader'] as const satisfies readonly (keyof SkillTemplates)[];
+
+export const loadSkillContent = (directory: string): SkillContent => {
+ const writtenText = readJson(path.join(directory, 'text.json'));
+ return {
+ templates: requireKeys(loadMarkdownDirectory(path.join(directory, 'templates')), templateNames, 'templates'),
+ fragments: readJson(path.join(directory, 'fragments.json')),
+ orders: readJson(path.join(directory, 'orders.json')),
+ text: writtenText.phrases,
+ usageMotionDescriptions: writtenText.usageMotionDescriptions,
+ };
+};
+
+export const loadMarkdownDirectory = (directory: string): Record =>
+ Object.fromEntries(
+ filesWithExtension(directory, '.md').map((fileName) => [
+ path.basename(fileName, '.md'),
+ withoutFinalLineBreak(fs.readFileSync(path.join(directory, fileName), 'utf8')),
+ ])
+ );
+
+export const readJson = (filePath: string): Value => JSON.parse(fs.readFileSync(filePath, 'utf8')) as Value;
+
+export const filesWithExtension = (directory: string, extension: string): string[] =>
+ fs
+ .readdirSync(directory)
+ .filter((fileName) => path.extname(fileName) === extension)
+ .sort();
+
+export const withoutFinalLineBreak = (value: string): string => value.replace(/\r?\n$/, '');
+
+export const requireKeys = (
+ record: Record,
+ keys: readonly Key[],
+ group: string
+): Record => {
+ const missing = keys.filter((key) => !(key in record));
+ if (missing.length > 0) {
+ throw new Error(`Missing ${group}: ${missing.join(', ')}`);
+ }
+ return record as Record;
+};
diff --git a/packages/skill-generator/src/generate-skills.ts b/packages/skill-generator/src/generate-skills.ts
new file mode 100644
index 000000000..8368b36c2
--- /dev/null
+++ b/packages/skill-generator/src/generate-skills.ts
@@ -0,0 +1,176 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import ts from 'typescript';
+import { combineProperties } from './catalog.js';
+import { loadSkillContent } from './content.js';
+import type { Shader } from './model.js';
+import { renderShader, renderSkill, renderUsage } from './render.js';
+import { parseShaderComment } from './shader-comment.js';
+import {
+ bindingDefaultValues,
+ bindingDefaultExpression,
+ callsIdentifier,
+ calledIdentifierStartingWith,
+ defaultPresetParams,
+ discoverShaderSources,
+ enumMismatches,
+ interfaceExtends,
+ jsxArrayAttributeIncludes,
+ parseCommonDocumentationProperties,
+ parseDocumentationShader,
+ shaderMetadata,
+ shaderModulePaths,
+ sourceProperties,
+ uniformPropertyNames,
+} from './source-model.js';
+
+const root = path.resolve(import.meta.dir, '../../..');
+const content = loadSkillContent(path.join(root, 'packages/skill-generator/content'));
+const check = process.argv.includes('--check');
+const configPath = path.join(root, 'packages/shaders-react/tsconfig.json');
+const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
+if (configFile.error) {
+ throw new Error(ts.flattenDiagnosticMessageText(configFile.error.messageText, '\n'));
+}
+const parsedConfig = ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.dirname(configPath));
+const compilerOptions: ts.CompilerOptions = {
+ ...parsedConfig.options,
+ baseUrl: root,
+ paths: {
+ '@paper-design/shaders': ['packages/shaders/src/index.ts'],
+ '@paper-design/shaders/*': ['packages/shaders/src/*'],
+ },
+};
+const program = ts.createProgram(parsedConfig.fileNames, compilerOptions);
+const checker = program.getTypeChecker();
+const coreIndexPath = path.join(root, 'packages/shaders/src/index.ts');
+const reactIndexPath = path.join(root, 'packages/shaders-react/src/index.ts');
+const coreIndex = program.getSourceFile(coreIndexPath);
+const reactIndex = program.getSourceFile(reactIndexPath);
+if (!coreIndex || !reactIndex) {
+ throw new Error('Could not load Paper Shaders package entry points');
+}
+const commonDocumentationPath = path.join(root, 'docs/src/shader-defs/common-param-def.ts');
+const commonDocumentationFile = ts.createSourceFile(
+ commonDocumentationPath,
+ fs.readFileSync(commonDocumentationPath, 'utf8'),
+ ts.ScriptTarget.Latest,
+ true,
+ ts.ScriptKind.TS
+);
+const commonDocumentation = parseCommonDocumentationProperties(commonDocumentationFile);
+const sizingSource = program.getSourceFile(path.join(root, 'packages/shaders/src/shader-sizing.ts'));
+const motionSource = program.getSourceFile(path.join(root, 'packages/shaders/src/shader-mount.ts'));
+const componentSource = program.getSourceFile(path.join(root, 'packages/shaders-react/src/shader-mount.tsx'));
+if (!sizingSource || !motionSource || !componentSource) {
+ throw new Error('Could not load common Paper Shaders types');
+}
+const sizingProperties = sourceProperties(checker, sizingSource, 'ShaderSizingParams');
+const motionProperties = sourceProperties(checker, motionSource, 'ShaderMotionParams');
+const componentProperties = sourceProperties(checker, componentSource, 'ShaderComponentProps');
+
+const sources = discoverShaderSources(root, coreIndex, reactIndex);
+if (sources.length !== shaderModulePaths(reactIndex).length || sources.length === 0) {
+ throw new Error('Could not resolve every exported shader module');
+}
+const shaders = sources.map((source): Shader => {
+ const coreFile = program.getSourceFile(source.corePath);
+ const reactFile = program.getSourceFile(source.reactPath);
+ const documentationText = fs.readFileSync(source.documentationPath, 'utf8');
+ const documentationFile = ts.createSourceFile(
+ source.documentationPath,
+ documentationText,
+ ts.ScriptTarget.Latest,
+ true,
+ ts.ScriptKind.TS
+ );
+ const documentation = parseDocumentationShader(documentationFile);
+ if (!coreFile || !reactFile || !documentation) {
+ throw new Error(`Could not load source model for ${source.slug}`);
+ }
+ documentation.properties.push(...commonDocumentation);
+
+ const coreProperties = sourceProperties(checker, coreFile, source.exports.paramsType);
+ const reactProperties = sourceProperties(checker, reactFile, `${source.exports.component}Props`);
+ const sourceComment = parseShaderComment(coreFile.getFullText(), source.exports.fragmentShader);
+ if (coreProperties.length === 0) {
+ throw new Error(`Could not resolve properties for ${source.exports.paramsType}`);
+ }
+ if (!sourceComment) {
+ throw new Error(`Could not resolve shader comment for ${source.exports.paramsType}`);
+ }
+ const propertiesFromSource = [...coreProperties, ...reactProperties];
+ const defaults = {
+ ...defaultPresetParams(checker, reactFile),
+ ...bindingDefaultValues(checker, reactFile),
+ };
+ const metadata = shaderMetadata(checker, coreFile);
+ const properties = combineProperties(
+ propertiesFromSource,
+ documentation,
+ sourceComment,
+ uniformPropertyNames(
+ reactFile,
+ propertiesFromSource.map((property) => property.name)
+ ),
+ defaults,
+ metadata
+ );
+ const shader: Shader = {
+ ...source,
+ name: documentation.name,
+ description: sourceComment.description,
+ properties,
+ defaults,
+ hasMotion: interfaceExtends(coreFile, source.exports.paramsType, 'ShaderMotionParams'),
+ usesNoiseTexture: callsIdentifier(reactFile, 'getShaderNoiseTexture'),
+ usesImageMipmaps: jsxArrayAttributeIncludes(reactFile, 'mipmaps', 'u_image'),
+ imagePreprocessor: calledIdentifierStartingWith(reactFile, 'toProcessed'),
+ maxPixelCountExpression: bindingDefaultExpression(reactFile, 'maxPixelCount'),
+ enumMismatches: enumMismatches(checker, coreFile, coreProperties, documentation),
+ };
+ return shader;
+});
+const sortedShaders = [...shaders].sort((left, right) => left.name.localeCompare(right.name));
+const outputRoot = path.join(root, 'skills/paper-shaders');
+const files = new Map([
+ [path.join(outputRoot, 'SKILL.md'), renderSkill(content, sortedShaders)],
+ [
+ path.join(outputRoot, 'references/usage.md'),
+ renderUsage(content, componentProperties, sizingProperties, motionProperties),
+ ],
+ ...sortedShaders.map((shader): [string, string] => [
+ path.join(outputRoot, 'references/shaders', `${shader.slug}.md`),
+ renderShader(root, content, shader),
+ ]),
+]);
+const staleFiles = fs.existsSync(path.join(outputRoot, 'references/shaders'))
+ ? fs
+ .readdirSync(path.join(outputRoot, 'references/shaders'))
+ .filter((name) => name.endsWith('.md'))
+ .map((name) => path.join(outputRoot, 'references/shaders', name))
+ .filter((filePath) => !files.has(filePath))
+ : [];
+const changedFiles = [...files].filter(
+ ([filePath, value]) => !fs.existsSync(filePath) || fs.readFileSync(filePath, 'utf8') !== value
+);
+
+if (check && (changedFiles.length > 0 || staleFiles.length > 0)) {
+ const names = [
+ ...changedFiles.map(([filePath]) => path.relative(root, filePath)),
+ ...staleFiles.map((filePath) => path.relative(root, filePath)),
+ ];
+ throw new Error(`Generated skills are out of date:\n${names.join('\n')}`);
+}
+
+if (!check) {
+ for (const [filePath, value] of changedFiles) {
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
+ fs.writeFileSync(filePath, value);
+ }
+ for (const filePath of staleFiles) {
+ fs.unlinkSync(filePath);
+ }
+}
+
+console.log(check ? `Checked ${files.size} generated skill files` : `Generated ${files.size} skill files`);
diff --git a/packages/skill-generator/src/model.ts b/packages/skill-generator/src/model.ts
new file mode 100644
index 000000000..f166bb09a
--- /dev/null
+++ b/packages/skill-generator/src/model.ts
@@ -0,0 +1,124 @@
+export type StaticValue = string | number | boolean | null | StaticValue[] | { [key: string]: StaticValue };
+
+export type StaticRecord = { [key: string]: StaticValue };
+
+export type SourceProperty = {
+ name: string;
+ type: string;
+ required: boolean;
+ options: string[];
+ deprecated?: string;
+ description?: string;
+};
+
+export type DocumentationProperty = {
+ name: string;
+ description: string;
+ min?: number;
+ max?: number;
+ step?: number;
+ options: string[];
+};
+
+export type DocumentationShader = {
+ name: string;
+ description: string;
+ properties: DocumentationProperty[];
+};
+
+export type ShaderCommentProperty = {
+ name: string;
+ description: string;
+ min?: number;
+ max?: number;
+};
+
+export type ShaderComment = {
+ description: string;
+ properties: ShaderCommentProperty[];
+};
+
+export type ShaderExports = {
+ component: string;
+ paramsType: string;
+ preset: string;
+ fragmentShader: string;
+};
+
+export type ShaderSource = {
+ slug: string;
+ corePath: string;
+ reactPath: string;
+ documentationPath: string;
+ exports: ShaderExports;
+};
+
+export type ShaderProperty = SourceProperty & {
+ defaultValue?: StaticValue;
+ description: string;
+ constraints: string[];
+};
+
+export type Shader = ShaderSource & {
+ name: string;
+ description: string;
+ properties: ShaderProperty[];
+ defaults: StaticRecord;
+ hasMotion: boolean;
+ usesNoiseTexture: boolean;
+ usesImageMipmaps: boolean;
+ imagePreprocessor?: string;
+ maxPixelCountExpression?: string;
+ enumMismatches: EnumMismatch[];
+};
+
+export type EnumMismatch = {
+ prop: string;
+ sourceOptions: string[];
+ documentationOptions: string[];
+ mapping?: string;
+};
+
+export type SkillTemplates = {
+ skill: string;
+ usage: string;
+ shader: string;
+};
+
+export type RenderFragments = {
+ shaderLink: string;
+ note: string;
+ default: string;
+ inlineCode: string;
+ tableHeader: string[];
+ tableRow: string;
+ usageProperty: string;
+ usageMotionProperty: string;
+};
+
+export type ContentOrders = {
+ usageComponentProperties: string[];
+ commonDefaults: string[];
+};
+
+export type SkillText = {
+ noiseRequirement: string;
+ mipmapRequirement: string;
+ combinedNoiseAndMipmapRequirement: string;
+ preprocessorRequirement: string;
+ maxPixelCount: string;
+ enumMismatch: string;
+};
+
+export type WrittenText = {
+ phrases: SkillText;
+ usageMotionDescriptions: Record;
+};
+
+export type SkillContent = {
+ templates: SkillTemplates;
+ fragments: RenderFragments;
+ orders: ContentOrders;
+ usageMotionDescriptions: Record;
+ text: SkillText;
+};
diff --git a/packages/skill-generator/src/render.ts b/packages/skill-generator/src/render.ts
new file mode 100644
index 000000000..8c5d39d53
--- /dev/null
+++ b/packages/skill-generator/src/render.ts
@@ -0,0 +1,156 @@
+import path from 'node:path';
+import { replaceTokens } from './catalog.js';
+import type { EnumMismatch, Shader, ShaderProperty, SkillContent, SourceProperty, StaticValue } from './model.js';
+
+export const renderSkill = (content: SkillContent, shaders: Shader[]): string =>
+ renderTemplate(content.templates.skill, {
+ shaderLinks: shaders
+ .map((shader) =>
+ replaceTokens(content.fragments.shaderLink, {
+ name: shader.name,
+ slug: shader.slug,
+ })
+ )
+ .join('\n'),
+ });
+
+export const renderUsage = (
+ content: SkillContent,
+ componentProperties: SourceProperty[],
+ sizingProperties: SourceProperty[],
+ motionProperties: SourceProperty[]
+): string =>
+ renderTemplate(content.templates.usage, {
+ componentControls: orderedProperties(componentProperties, content.orders.usageComponentProperties)
+ .map((property) => renderUsageProperty(content, property))
+ .join('\n'),
+ sizingProperties: sizingProperties.map((property) => renderUsageProperty(content, property)).join('\n'),
+ motionProperties: motionProperties
+ .map((property) =>
+ replaceTokens(content.fragments.usageMotionProperty, {
+ name: property.name,
+ optional: optionalMarker(property.required),
+ type: displayType(property),
+ description: content.usageMotionDescriptions[property.name] ?? '',
+ })
+ )
+ .join('\n'),
+ });
+
+export const renderShader = (root: string, content: SkillContent, shader: Shader): string => {
+ const requirements = renderRequirements(content, shader);
+ const notes = [...requirements, ...shader.enumMismatches.map((mismatch) => renderEnumMismatch(content, mismatch))];
+ const defaults = content.orders.commonDefaults
+ .map((name) => [name, shader.defaults[name]] as const)
+ .filter((entry): entry is readonly [string, StaticValue] => entry[1] !== undefined)
+ .map(([name, value]) =>
+ replaceTokens(content.fragments.default, {
+ name,
+ value: formatValue(value),
+ })
+ )
+ .join(', ');
+ const table = [
+ ...content.fragments.tableHeader,
+ ...shader.properties.map((property) => renderProperty(content, property)),
+ ].join('\n');
+
+ return renderTemplate(content.templates.shader, {
+ name: shader.name,
+ description: shader.description,
+ component: shader.exports.component,
+ preset: shader.exports.preset,
+ fragmentShader: shader.exports.fragmentShader,
+ paramsType: shader.exports.paramsType,
+ controls: controlsLabel(shader.hasMotion),
+ defaults,
+ notes:
+ notes.length > 0 ? `\n${notes.map((note) => replaceTokens(content.fragments.note, { note })).join('\n')}` : '',
+ source: sourceList(
+ relativePath(root, shader.corePath),
+ relativePath(root, shader.reactPath),
+ relativePath(root, shader.documentationPath)
+ ),
+ table,
+ });
+};
+
+export const renderProperty = (content: SkillContent, property: ShaderProperty): string =>
+ replaceTokens(content.fragments.tableRow, {
+ name: property.name,
+ type: escapeTable(property.type),
+ required: requiredLabel(property.required),
+ defaultValue:
+ property.defaultValue === undefined
+ ? emptyTableCell()
+ : replaceTokens(content.fragments.inlineCode, {
+ value: escapeTable(formatValue(property.defaultValue)),
+ }),
+ constraints: property.constraints.length > 0 ? escapeTable(property.constraints.join('; ')) : emptyTableCell(),
+ description: escapeTable(property.description),
+ });
+
+export const renderTemplate = (template: string, values: Record): string =>
+ `${replaceTokens(template, values)}\n`;
+
+export const formatValue = (value: StaticValue): string => JSON.stringify(value);
+
+export const renderUsageProperty = (content: SkillContent, property: SourceProperty): string =>
+ replaceTokens(content.fragments.usageProperty, {
+ name: property.name,
+ optional: optionalMarker(property.required),
+ type: displayType(property),
+ });
+
+export const optionalMarker = (required: boolean): string => (required ? '' : '?');
+
+export const controlsLabel = (hasMotion: boolean): string => (hasMotion ? 'sizing and motion' : 'sizing');
+
+export const sourceList = (corePath: string, reactPath: string, documentationPath: string): string =>
+ `Source: \`${corePath}\`, \`${reactPath}\`, \`${documentationPath}\`.`;
+
+export const requiredLabel = (required: boolean): string => (required ? 'yes' : 'no');
+
+export const emptyTableCell = (): string => '—';
+
+const renderRequirements = (content: SkillContent, shader: Shader): string[] => {
+ if (shader.usesNoiseTexture && shader.usesImageMipmaps) {
+ return [content.text.combinedNoiseAndMipmapRequirement];
+ }
+ const requirements = [
+ shader.usesNoiseTexture ? content.text.noiseRequirement : undefined,
+ shader.usesImageMipmaps
+ ? shader.imagePreprocessor
+ ? replaceTokens(content.text.preprocessorRequirement, {
+ preprocessor: shader.imagePreprocessor,
+ })
+ : content.text.mipmapRequirement
+ : undefined,
+ shader.maxPixelCountExpression
+ ? replaceTokens(content.text.maxPixelCount, {
+ expression: shader.maxPixelCountExpression,
+ })
+ : undefined,
+ ];
+ return requirements.filter((value): value is string => Boolean(value));
+};
+
+const renderEnumMismatch = (content: SkillContent, mismatch: EnumMismatch): string =>
+ replaceTokens(content.text.enumMismatch, {
+ documented: mismatch.documentationOptions
+ .map((value) => replaceTokens(content.fragments.inlineCode, { value }))
+ .join(', '),
+ source: mismatch.sourceOptions.map((value) => replaceTokens(content.fragments.inlineCode, { value })).join(', '),
+ mapping: mismatch.mapping ?? '',
+ });
+
+const relativePath = (root: string, filePath: string): string =>
+ path.relative(root, filePath).split(path.sep).join('/');
+
+const escapeTable = (value: string): string => value.replaceAll('|', '\\|');
+
+const displayType = (property: SourceProperty): string =>
+ property.options.length > 0 ? property.options.map((option) => JSON.stringify(option)).join(' | ') : property.type;
+
+const orderedProperties = (properties: SourceProperty[], order: string[]): SourceProperty[] =>
+ [...properties].sort((left, right) => order.indexOf(left.name) - order.indexOf(right.name));
diff --git a/packages/skill-generator/src/shader-comment.test.ts b/packages/skill-generator/src/shader-comment.test.ts
new file mode 100644
index 000000000..e2526a18a
--- /dev/null
+++ b/packages/skill-generator/src/shader-comment.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, test } from 'bun:test';
+import { descriptionAndRange, parseShaderComment } from './shader-comment.js';
+
+describe('parseShaderComment', () => {
+ test('reads the shader description and uniform documentation', () => {
+ const source = `
+ /**
+ * Example shader description
+ * split over two lines.
+ *
+ * Fragment shader uniforms:
+ * - u_amount (float): Effect strength (0 to 1)
+ * - u_mode (float): Mode (0 = first, 1 = second)
+ */
+ export const exampleFragmentShader = '';
+ `;
+
+ expect(parseShaderComment(source, 'exampleFragmentShader')).toEqual({
+ description: 'Example shader description split over two lines.',
+ properties: [
+ { name: 'amount', description: 'Effect strength', min: 0, max: 1 },
+ { name: 'mode', description: 'Mode (0 = first, 1 = second)' },
+ ],
+ });
+ });
+});
+
+describe('descriptionAndRange', () => {
+ test('preserves descriptive parentheses before a trailing range', () => {
+ expect(descriptionAndRange('Proportional gain (enhances existing dots, -1 to 1)')).toEqual({
+ description: 'Proportional gain (enhances existing dots)',
+ min: -1,
+ max: 1,
+ });
+ });
+});
diff --git a/packages/skill-generator/src/shader-comment.ts b/packages/skill-generator/src/shader-comment.ts
new file mode 100644
index 000000000..7053470d1
--- /dev/null
+++ b/packages/skill-generator/src/shader-comment.ts
@@ -0,0 +1,74 @@
+import type { ShaderComment, ShaderCommentProperty } from './model.js';
+
+const jsDocPattern = /\/\*\*([\s\S]*?)\*\//g;
+const uniformPattern = /^- u_([A-Za-z0-9]+) \([^)]+\): (.+)$/;
+const numberPattern = '-?(?:\\d+(?:\\.\\d+)?|\\.\\d+)';
+const trailingRangePattern = new RegExp(`(${numberPattern}) to (${numberPattern})\\)$`);
+
+export const parseShaderComment = (source: string, fragmentShaderName: string): ShaderComment | undefined => {
+ const comment = precedingJsDoc(source, fragmentShaderName);
+ if (!comment) return undefined;
+ const lines = jsDocLines(comment);
+ const description = firstParagraph(lines);
+ return description
+ ? {
+ description,
+ properties: lines
+ .map(parseUniformLine)
+ .filter((property): property is ShaderCommentProperty => Boolean(property)),
+ }
+ : undefined;
+};
+
+export const precedingJsDoc = (source: string, declarationName: string): string | undefined => {
+ const declarationIndex = source.indexOf(declarationName);
+ if (declarationIndex < 0) return undefined;
+ return [...source.slice(0, declarationIndex).matchAll(jsDocPattern)].at(-1)?.[1];
+};
+
+export const jsDocLines = (comment: string): string[] =>
+ comment.split(/\r?\n/).map((line) => line.replace(/^\s*\*\s?/, '').trimEnd());
+
+export const firstParagraph = (lines: string[]): string => {
+ const start = lines.findIndex((line) => line.trim().length > 0);
+ if (start < 0) return '';
+ const paragraph = lines.slice(start);
+ const end = paragraph.findIndex((line) => line.trim().length === 0);
+ return paragraph
+ .slice(0, end < 0 ? paragraph.length : end)
+ .map((line) => line.trim())
+ .join(' ');
+};
+
+export const parseUniformLine = (line: string): ShaderCommentProperty | undefined => {
+ const match = line.match(uniformPattern);
+ if (!match?.[1] || !match[2]) return undefined;
+ const value = descriptionAndRange(match[2]);
+ return {
+ name: match[1],
+ description: value.description,
+ ...(value.min === undefined ? {} : { min: value.min }),
+ ...(value.max === undefined ? {} : { max: value.max }),
+ };
+};
+
+export const descriptionAndRange = (value: string): { description: string; min?: number; max?: number } => {
+ const match = value.match(trailingRangePattern);
+ if (!match?.[1] || !match[2] || match.index === undefined) {
+ return { description: value };
+ }
+ return {
+ description: closeUnbalancedParenthesis(removeRangeSeparator(value.slice(0, match.index))),
+ min: Number(match[1]),
+ max: Number(match[2]),
+ };
+};
+
+export const removeRangeSeparator = (value: string): string =>
+ value.trimEnd().replace(/\($/, '').replace(/,\s*$/, '').trimEnd();
+
+export const closeUnbalancedParenthesis = (value: string): string =>
+ characterCount(value, '(') > characterCount(value, ')') ? `${value})` : value;
+
+export const characterCount = (value: string, character: string): number =>
+ [...value].filter((candidate) => candidate === character).length;
diff --git a/packages/skill-generator/src/source-model.test.ts b/packages/skill-generator/src/source-model.test.ts
new file mode 100644
index 000000000..30fe6b989
--- /dev/null
+++ b/packages/skill-generator/src/source-model.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, test } from 'bun:test';
+import ts from 'typescript';
+import { uniformPropertyNames } from './source-model.js';
+
+describe('uniformPropertyNames', () => {
+ test('ignores identifiers nested inside uniform initializer expressions', () => {
+ const sourceFile = ts.createSourceFile(
+ 'shader.tsx',
+ `
+ const uniforms = {
+ u_isImage: Boolean(image),
+ u_image: processedImage,
+ u_direct: directProperty,
+ };
+ `,
+ ts.ScriptTarget.Latest,
+ true,
+ ts.ScriptKind.TSX
+ );
+
+ expect(uniformPropertyNames(sourceFile, ['image', 'directProperty'])).toEqual({ directProperty: 'direct' });
+ });
+});
diff --git a/packages/skill-generator/src/source-model.ts b/packages/skill-generator/src/source-model.ts
new file mode 100644
index 000000000..ecf79ce59
--- /dev/null
+++ b/packages/skill-generator/src/source-model.ts
@@ -0,0 +1,349 @@
+import path from 'node:path';
+import ts from 'typescript';
+import {
+ exportedVariable,
+ interfaceDeclaration,
+ isStaticRecord,
+ objectProperty,
+ propertyName,
+ resolveSymbol,
+ sourceStaticValue,
+ staticExpressionValue,
+ typeAliasDeclaration,
+ unwrapExpression,
+} from './ast.js';
+import type {
+ DocumentationProperty,
+ DocumentationShader,
+ EnumMismatch,
+ ShaderExports,
+ ShaderSource,
+ SourceProperty,
+ StaticRecord,
+} from './model.js';
+
+export const discoverShaderSources = (
+ root: string,
+ coreIndex: ts.SourceFile,
+ reactIndex: ts.SourceFile
+): ShaderSource[] =>
+ shaderModulePaths(reactIndex)
+ .map((modulePath) => {
+ const slug = path.basename(modulePath, '.js');
+ const exports = shaderExports(
+ exportNamesForModule(reactIndex, modulePath),
+ exportNamesForModule(coreIndex, modulePath)
+ );
+ return exports
+ ? {
+ slug,
+ corePath: path.join(root, 'packages/shaders/src/shaders', `${slug}.ts`),
+ reactPath: path.join(root, 'packages/shaders-react/src/shaders', `${slug}.tsx`),
+ documentationPath: path.join(root, 'docs/src/shader-defs', `${slug}-def.ts`),
+ exports,
+ }
+ : undefined;
+ })
+ .filter((source): source is ShaderSource => Boolean(source));
+
+export const shaderModulePaths = (sourceFile: ts.SourceFile): string[] => [
+ ...new Set(
+ sourceFile.statements
+ .filter(ts.isExportDeclaration)
+ .map((declaration) => shaderModulePath(declaration))
+ .filter((modulePath): modulePath is string => Boolean(modulePath))
+ ),
+];
+
+export const sourceProperties = (
+ checker: ts.TypeChecker,
+ sourceFile: ts.SourceFile,
+ interfaceName: string
+): SourceProperty[] => {
+ const declaration = interfaceDeclaration(sourceFile, interfaceName);
+ if (!declaration) return [];
+
+ return declaration.members
+ .filter(ts.isPropertySignature)
+ .map((member) => {
+ const name = propertyName(member.name);
+ return name && member.type
+ ? {
+ name,
+ type: member.type.getText(sourceFile),
+ required: !member.questionToken,
+ options: orderedStringOptions(checker, sourceFile, member.type),
+ ...(deprecatedText(member) ? { deprecated: deprecatedText(member) } : {}),
+ ...(jsDocText(member) ? { description: jsDocText(member) } : {}),
+ }
+ : undefined;
+ })
+ .filter((property): property is SourceProperty => Boolean(property));
+};
+
+export const interfaceExtends = (sourceFile: ts.SourceFile, interfaceName: string, baseName: string): boolean =>
+ Boolean(
+ interfaceDeclaration(sourceFile, interfaceName)?.heritageClauses?.some((clause) =>
+ clause.types.some((type) => type.expression.getText(sourceFile) === baseName)
+ )
+ );
+
+export const defaultPresetParams = (checker: ts.TypeChecker, sourceFile: ts.SourceFile): StaticRecord => {
+ const initializer = exportedVariable(sourceFile, 'defaultPreset')?.initializer;
+ const preset = initializer ? staticExpressionValue(checker, initializer) : undefined;
+ const params = isStaticRecord(preset) ? preset.params : undefined;
+ return isStaticRecord(params) ? params : {};
+};
+
+export const bindingDefaultValues = (checker: ts.TypeChecker, sourceFile: ts.SourceFile): StaticRecord =>
+ descendants(sourceFile)
+ .filter(ts.isBindingElement)
+ .reduce((defaults, element) => {
+ const name = propertyName(element.name);
+ const value = element.initializer ? staticExpressionValue(checker, element.initializer) : undefined;
+ return name && value !== undefined ? { ...defaults, [name]: value } : defaults;
+ }, {});
+
+export const shaderMetadata = (checker: ts.TypeChecker, sourceFile: ts.SourceFile): StaticRecord => {
+ const declaration = sourceFile.statements
+ .filter(ts.isVariableStatement)
+ .flatMap((statement) => [...statement.declarationList.declarations])
+ .find((item) => propertyName(item.name)?.endsWith('Meta'));
+ const value = declaration?.initializer ? staticExpressionValue(checker, declaration.initializer) : undefined;
+ return isStaticRecord(value) ? value : {};
+};
+
+export const parseDocumentationShader = (sourceFile: ts.SourceFile): DocumentationShader | undefined => {
+ const declaration = sourceFile.statements
+ .filter(ts.isVariableStatement)
+ .flatMap((statement) => [...statement.declarationList.declarations])
+ .find((item) => propertyName(item.name)?.endsWith('Def'));
+ const object = declaration?.initializer ? unwrapExpression(declaration.initializer) : undefined;
+ if (!object || !ts.isObjectLiteralExpression(object)) return undefined;
+
+ const name = staticPropertyValue(object, 'name');
+ const description = staticPropertyValue(object, 'description');
+ const params = objectProperty(object, 'params')?.initializer;
+ if (typeof name !== 'string' || typeof description !== 'string' || !params || !ts.isArrayLiteralExpression(params)) {
+ return undefined;
+ }
+
+ return {
+ name,
+ description,
+ properties: params.elements
+ .filter(ts.isObjectLiteralExpression)
+ .map(parseDocumentationProperty)
+ .filter((property): property is DocumentationProperty => Boolean(property)),
+ };
+};
+
+export const parseCommonDocumentationProperties = (sourceFile: ts.SourceFile): DocumentationProperty[] => {
+ const initializer = exportedVariable(sourceFile, 'commonParams')?.initializer;
+ const object = initializer ? unwrapExpression(initializer) : undefined;
+ if (!object || !ts.isObjectLiteralExpression(object)) return [];
+ return object.properties
+ .filter(ts.isPropertyAssignment)
+ .map((property) => unwrapExpression(property.initializer))
+ .filter(ts.isObjectLiteralExpression)
+ .map(parseDocumentationProperty)
+ .filter((property): property is DocumentationProperty => Boolean(property));
+};
+
+export const callsIdentifier = (sourceFile: ts.SourceFile, identifier: string): boolean =>
+ descendants(sourceFile)
+ .filter(ts.isCallExpression)
+ .some((call) => ts.isIdentifier(call.expression) && call.expression.text === identifier);
+
+export const calledIdentifierStartingWith = (sourceFile: ts.SourceFile, prefix: string): string | undefined =>
+ descendants(sourceFile)
+ .filter(ts.isCallExpression)
+ .map((call) => call.expression)
+ .filter(ts.isIdentifier)
+ .map((identifier) => identifier.text)
+ .find((name) => name.startsWith(prefix));
+
+export const jsxArrayAttributeIncludes = (
+ sourceFile: ts.SourceFile,
+ attributeName: string,
+ expectedValue: string
+): boolean =>
+ descendants(sourceFile)
+ .filter(ts.isJsxAttribute)
+ .filter((attribute) => attribute.name.getText(sourceFile) === attributeName)
+ .map((attribute) => attribute.initializer)
+ .filter((initializer): initializer is ts.JsxExpression => Boolean(initializer && ts.isJsxExpression(initializer)))
+ .map((expression) => expression.expression)
+ .filter((expression): expression is ts.ArrayLiteralExpression =>
+ Boolean(expression && ts.isArrayLiteralExpression(expression))
+ )
+ .some((array) =>
+ array.elements.some((element) => ts.isStringLiteralLike(element) && element.text === expectedValue)
+ );
+
+export const bindingDefaultExpression = (sourceFile: ts.SourceFile, bindingName: string): string | undefined =>
+ descendants(sourceFile)
+ .filter(ts.isBindingElement)
+ .find((element) => propertyName(element.name) === bindingName)
+ ?.initializer?.getText(sourceFile);
+
+export const uniformPropertyNames = (
+ sourceFile: ts.SourceFile,
+ propertyNames: readonly string[]
+): Record => {
+ const names = new Set(propertyNames);
+ const declaration = descendants(sourceFile)
+ .filter(ts.isVariableDeclaration)
+ .find((candidate) => propertyName(candidate.name) === 'uniforms');
+ const initializer = declaration?.initializer ? unwrapExpression(declaration.initializer) : undefined;
+ if (!initializer || !ts.isObjectLiteralExpression(initializer)) return {};
+
+ return initializer.properties.filter(ts.isPropertyAssignment).reduce>((result, property) => {
+ const uniform = propertyName(property.name);
+ const sourceProperty = referencedIdentifier(property.initializer, names);
+ return uniform?.startsWith('u_') && sourceProperty && !(sourceProperty in result)
+ ? { ...result, [sourceProperty]: uniform.slice(2) }
+ : result;
+ }, {});
+};
+
+export const enumMismatches = (
+ checker: ts.TypeChecker,
+ sourceFile: ts.SourceFile,
+ properties: SourceProperty[],
+ documentation: DocumentationShader
+): EnumMismatch[] =>
+ properties.flatMap((property) => {
+ const documented = documentation.properties.find((candidate) => candidate.name === property.name);
+ const differs =
+ documented &&
+ documented.options.length > 0 &&
+ property.options.length > 0 &&
+ (documented.options.some((option) => !property.options.includes(option)) ||
+ property.options.some((option) => !documented.options.includes(option)));
+ return differs
+ ? [
+ {
+ prop: property.name,
+ sourceOptions: property.options.filter((option) => !documented.options.includes(option)),
+ documentationOptions: documented.options.filter((option) => !property.options.includes(option)),
+ mapping: enumMappingName(sourceFile, checker, property.type),
+ },
+ ]
+ : [];
+ });
+
+const shaderModulePath = (declaration: ts.ExportDeclaration): string | undefined => {
+ const modulePath = declaration.moduleSpecifier;
+ return modulePath && ts.isStringLiteral(modulePath) && modulePath.text.startsWith('./shaders/')
+ ? modulePath.text
+ : undefined;
+};
+
+const exportNamesForModule = (sourceFile: ts.SourceFile, modulePath: string): string[] =>
+ sourceFile.statements
+ .filter(ts.isExportDeclaration)
+ .filter(
+ (declaration) =>
+ declaration.moduleSpecifier &&
+ ts.isStringLiteral(declaration.moduleSpecifier) &&
+ declaration.moduleSpecifier.text === modulePath
+ )
+ .flatMap(namedExports);
+
+const shaderExports = (reactNames: string[], coreNames: string[]): ShaderExports | undefined => {
+ const component = reactNames.find((name) => /^[A-Z]/.test(name));
+ const preset = reactNames.find((name) => name.endsWith('Presets'));
+ const paramsType = coreNames.find((name) => name.endsWith('Params'));
+ const fragmentShader = coreNames.find((name) => name.endsWith('FragmentShader'));
+ return component && preset && paramsType && fragmentShader
+ ? { component, preset, paramsType, fragmentShader }
+ : undefined;
+};
+
+const namedExports = (declaration: ts.ExportDeclaration): string[] =>
+ declaration?.exportClause && ts.isNamedExports(declaration.exportClause)
+ ? declaration.exportClause.elements.map((element) => element.name.text)
+ : [];
+
+const stringLiteralOptions = (type: ts.Type): string[] =>
+ type.isUnion()
+ ? type.types.filter((item): item is ts.StringLiteralType => item.isStringLiteral()).map((item) => item.value)
+ : type.isStringLiteral()
+ ? [type.value]
+ : [];
+
+const orderedStringOptions = (checker: ts.TypeChecker, sourceFile: ts.SourceFile, typeNode: ts.TypeNode): string[] => {
+ const mapping = mappingNameForType(sourceFile, typeNode.getText(sourceFile));
+ const initializer = mapping ? exportedVariable(sourceFile, mapping)?.initializer : undefined;
+ const object = initializer ? unwrapExpression(initializer) : undefined;
+ return object && ts.isObjectLiteralExpression(object)
+ ? object.properties
+ .map((property) => ('name' in property ? propertyName(property.name) : undefined))
+ .filter((name): name is string => Boolean(name))
+ : stringLiteralOptions(checker.getTypeAtLocation(typeNode));
+};
+
+const deprecatedText = (member: ts.PropertySignature): string | undefined =>
+ ts
+ .getJSDocTags(member)
+ .filter((tag) => tag.tagName.text === 'deprecated')
+ .map((tag) => (typeof tag.comment === 'string' ? tag.comment : tag.comment?.map((part) => part.text).join('')))
+ .find((comment): comment is string => Boolean(comment));
+
+const jsDocText = (member: ts.PropertySignature): string | undefined =>
+ ts
+ .getJSDocCommentsAndTags(member)
+ .filter(ts.isJSDoc)
+ .map((doc) => (typeof doc.comment === 'string' ? doc.comment : doc.comment?.map((part) => part.text).join('')))
+ .find((comment): comment is string => Boolean(comment));
+
+const parseDocumentationProperty = (object: ts.ObjectLiteralExpression): DocumentationProperty | undefined => {
+ const name = staticPropertyValue(object, 'name');
+ const description = staticPropertyValue(object, 'description');
+ if (typeof name !== 'string' || typeof description !== 'string') return undefined;
+ const min = staticPropertyValue(object, 'min');
+ const max = staticPropertyValue(object, 'max');
+ const step = staticPropertyValue(object, 'step');
+ const options = staticPropertyValue(object, 'options');
+ return {
+ name,
+ description,
+ ...(typeof min === 'number' ? { min } : {}),
+ ...(typeof max === 'number' ? { max } : {}),
+ ...(typeof step === 'number' ? { step } : {}),
+ options: Array.isArray(options) ? options.filter((option): option is string => typeof option === 'string') : [],
+ };
+};
+
+const staticPropertyValue = (object: ts.ObjectLiteralExpression, name: string) => {
+ const expression = objectProperty(object, name)?.initializer;
+ return expression ? sourceStaticValue(expression) : undefined;
+};
+
+const descendants = (root: ts.Node): ts.Node[] => {
+ const children: ts.Node[] = [];
+ root.forEachChild((child) => {
+ children.push(child, ...descendants(child));
+ });
+ return children;
+};
+
+const referencedIdentifier = (expression: ts.Expression, names: ReadonlySet): string | undefined => {
+ const identifier = unwrapExpression(expression);
+ return ts.isIdentifier(identifier) && names.has(identifier.text) ? identifier.text : undefined;
+};
+
+const enumMappingName = (sourceFile: ts.SourceFile, checker: ts.TypeChecker, typeName: string): string | undefined => {
+ const mapping = mappingNameForType(sourceFile, typeName);
+ if (mapping) return mapping;
+ const alias = typeAliasDeclaration(sourceFile, typeName);
+ const type = alias ? checker.getTypeAtLocation(alias.type) : undefined;
+ return type?.aliasSymbol?.name ?? (alias ? resolveSymbol(checker, alias.name)?.name : undefined);
+};
+
+const mappingNameForType = (sourceFile: ts.SourceFile, typeName: string): string | undefined => {
+ const alias = typeAliasDeclaration(sourceFile, typeName);
+ const match = alias?.type.getText(sourceFile).match(/keyof typeof (\w+)/);
+ return match?.[1];
+};
diff --git a/packages/skill-generator/tsconfig.json b/packages/skill-generator/tsconfig.json
new file mode 100644
index 000000000..9ae82d084
--- /dev/null
+++ b/packages/skill-generator/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "lib": ["ESNext", "DOM"],
+ "target": "ESNext",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "strict": true,
+ "skipLibCheck": true,
+ "noUncheckedIndexedAccess": true,
+ "resolveJsonModule": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/publish.js b/publish.js
index 24744a29e..a2a2df25c 100644
--- a/publish.js
+++ b/publish.js
@@ -3,12 +3,15 @@ import { spawn } from 'child_process';
import { readFileSync, writeFileSync } from 'fs';
import { $ } from 'bun';
+await $`bun run generate-skills`;
+
// This publish process automatically replaces workspace:* with the actual version number of other packages currently in the repo
// The packages to publish – this will run in order and await each package before moving on
const packages = ['shaders', 'shaders-react'];
const isDryRun = process.argv.includes('--dry-run');
+const publishedPackages = [];
// Extract the tag value from the command line arguments
const isCanaryRelease = process.argv.includes('--canary');
@@ -92,6 +95,7 @@ async function publish(pkg) {
console.log(`Skipping ${pkg}: Publication failed or package is already up to date`);
} else {
console.log(`Published ${pkg}`);
+ publishedPackages.push(pkg);
}
// Restore the original package.json to put back workspace:* dependencies
@@ -105,11 +109,31 @@ async function publish(pkg) {
});
}
+async function commitGeneratedSkills() {
+ const changes = await $`git status --porcelain -- skills`.text();
+ if (!changes.trim()) {
+ console.log('Generated skills are already up to date in Git');
+ return;
+ }
+
+ const publishedVersions = [
+ ...new Set(publishedPackages.map((pkg) => packageVersionMap[`@paper-design/${pkg}`])),
+ ].join(', ');
+
+ await $`git add skills`;
+ await $`git commit -m ${`Generate skills for ${publishedVersions}`}`;
+ await $`git push`;
+ console.log('Committed and pushed generated skills');
+}
+
async function publishAll() {
try {
for (const pkg of packages) {
await publish(pkg);
}
+ if (!isDryRun && publishedPackages.length > 0) {
+ await commitGeneratedSkills();
+ }
console.log('All packages processed!');
} catch (error) {
console.error('An unexpected error occurred:', error);
diff --git a/skills/paper-shaders/SKILL.md b/skills/paper-shaders/SKILL.md
new file mode 100644
index 000000000..d238fddbc
--- /dev/null
+++ b/skills/paper-shaders/SKILL.md
@@ -0,0 +1,75 @@
+---
+name: paper-shaders
+description: Implement, convert, customize, debug, or review Paper Shaders effects in React or vanilla JavaScript using `@paper-design/shaders-react` and `@paper-design/shaders`.
+---
+
+# Paper Shaders
+
+Implement Paper Shaders from the package source contract. Do not infer prop names, defaults, enum values, uniform mappings, color limits, or image behavior.
+
+## Workflow
+
+1. Inspect the target project's package manager and installed Paper Shaders package/version. Preserve the existing framework and dependency style.
+2. Read [references/usage.md](references/usage.md) before writing integration code.
+3. Read only the relevant file under [Shader references](#shader-references). Use its exact component export, fragment-shader export, props, defaults, enum options, and implementation capacity.
+4. For React, prefer the named shader component. Use the low-level React `ShaderMount` only for a custom fragment shader or an explicitly requested uniform-level integration.
+5. For vanilla JavaScript or TypeScript, reproduce the named React component's uniform construction. Apply every conversion and special requirement listed in `usage.md` and the matching shader reference.
+6. Give the mount element an explicit rendered size. Keep shader props separate from layout styles and ordinary DOM props.
+7. Verify with the project's type-check/build and, when rendering is available, inspect the result at the intended dimensions.
+
+## Source authority
+
+- Treat `packages/shaders/src/shaders/*.ts` and `packages/shaders-react/src/shaders/*.tsx` as authoritative for runtime behavior, types, defaults, enum mappings, and uniforms.
+- Treat `packages/shaders/src/shader-mount.ts`, `packages/shaders/src/shader-sizing.ts`, and `packages/shaders-react/src/shader-mount.tsx` as authoritative for mounting, images, sizing, motion, and performance controls.
+- Treat numeric ranges and steps in `docs/src/shader-defs/*-def.ts` as editor guidance, not runtime validation. Components pass values through without clamping.
+- Resolve source contradictions in favor of the shader implementation and its exported types/constants. Never repeat a stale prose claim when an array size, type, constant, or component mapping contradicts it.
+- Recheck current source when working against a different repository revision. Do not assume this reference overrides changed code.
+
+## Implementation rules
+
+- Import only public exports from the package entry point.
+- Use `@paper-design/shaders-react` for named React components and presets.
+- Use `@paper-design/shaders` for `ShaderMount`, fragment shaders, uniform types, enum maps, color conversion, noise textures, and image preprocessors.
+- Supply all required initial vanilla uniforms. `ShaderMount` records uniform locations from the constructor's initial uniform object; later partial updates cannot introduce an unregistered uniform.
+- Dispose vanilla mounts during teardown.
+- Keep color arrays non-empty and do not exceed the shader reference's implementation capacity for colors or other fixed-size loops.
+- Do not pass CSS named colors. The source color parser accepts hex, `rgb`/`rgba`, and `hsl`/`hsla` syntax.
+- Do not use deprecated React aliases in new code.
+
+## References
+
+- [Usage and integration](references/usage.md): React, vanilla, common controls, uniform conversion, images, lifecycle, and special cases.
+
+## Shader references
+
+Read only the file for the shader being used:
+
+- [Color Panels](references/shaders/color-panels.md)
+- [Dithering](references/shaders/dithering.md)
+- [Dot Grid](references/shaders/dot-grid.md)
+- [Dot Orbit](references/shaders/dot-orbit.md)
+- [Fluted Glass](references/shaders/fluted-glass.md)
+- [Gem Smoke](references/shaders/gem-smoke.md)
+- [God Rays](references/shaders/god-rays.md)
+- [Grain Gradient](references/shaders/grain-gradient.md)
+- [Halftone CMYK](references/shaders/halftone-cmyk.md)
+- [Halftone Dots](references/shaders/halftone-dots.md)
+- [Heatmap](references/shaders/heatmap.md)
+- [Image Dithering](references/shaders/image-dithering.md)
+- [Liquid Metal](references/shaders/liquid-metal.md)
+- [Mesh Gradient](references/shaders/mesh-gradient.md)
+- [Metaballs](references/shaders/metaballs.md)
+- [Neuro Noise](references/shaders/neuro-noise.md)
+- [Paper Texture](references/shaders/paper-texture.md)
+- [Perlin Noise](references/shaders/perlin-noise.md)
+- [Pulsing Border](references/shaders/pulsing-border.md)
+- [Simplex Noise](references/shaders/simplex-noise.md)
+- [Smoke Ring](references/shaders/smoke-ring.md)
+- [Spiral](references/shaders/spiral.md)
+- [Static Mesh Gradient](references/shaders/static-mesh-gradient.md)
+- [Static Radial Gradient](references/shaders/static-radial-gradient.md)
+- [Swirl](references/shaders/swirl.md)
+- [Voronoi](references/shaders/voronoi.md)
+- [Warp](references/shaders/warp.md)
+- [Water](references/shaders/water.md)
+- [Waves](references/shaders/waves.md)
diff --git a/skills/paper-shaders/references/shaders/color-panels.md b/skills/paper-shaders/references/shaders/color-panels.md
new file mode 100644
index 000000000..f209cfd1f
--- /dev/null
+++ b/skills/paper-shaders/references/shaders/color-panels.md
@@ -0,0 +1,24 @@
+# Color Panels
+
+Use the listed implementation types, options, capacities, exports, and defaults. Values labeled “editor range” come from the documentation UI definitions; the component does not clamp props to that range.
+
+Pseudo-3D semi-transparent panels rotating around a central axis
+
+- React: `ColorPanels` and `colorPanelsPresets` from `@paper-design/shaders-react`.
+- Vanilla: `colorPanelsFragmentShader` and `ColorPanelsParams` from `@paper-design/shaders`.
+- Common controls: sizing and motion. Defaults: speed=0.5, frame=0, fit="contain", scale=0.8, rotation=0, offsetX=0, offsetY=0, originX=0.5, originY=0.5, worldWidth=0, worldHeight=0.
+- Source: `packages/shaders/src/shaders/color-panels.ts`, `packages/shaders-react/src/shaders/color-panels.tsx`, `docs/src/shader-defs/color-panels-def.ts`.
+
+| Prop | Type | Required | React default | Constraints | Effect |
+| --- | --- | --- | --- | --- | --- |
+| `colors` | `string[]` | no | `["#ff9d00","#fd4f30","#809bff","#6d2eff","#333aff","#f15cff","#ffd557"]` | implementation capacity: 7 | Up to 7 RGBA colors used to color the panels |
+| `colorBack` | `string` | no | `"#000000"` | — | Background color in RGBA |
+| `angle1` | `number` | no | `0` | editor range: -1…1 | Skew angle applied to all panes |
+| `angle2` | `number` | no | `0` | editor range: -1…1 | Skew angle applied to all panes |
+| `length` | `number` | no | `1.1` | editor range: 0…3 | Panel length relative to total height |
+| `edges` | `boolean` | no | `false` | — | Color highlight on the panels edges |
+| `blur` | `number` | no | `0` | editor range: 0…0.5 | Side blur, 0 for sharp edges |
+| `fadeIn` | `number` | no | `1` | editor range: 0…1 | Transparency near central axis |
+| `fadeOut` | `number` | no | `0.3` | editor range: 0…1 | Transparency near viewer |
+| `density` | `number` | no | `3` | editor range: 0.25…7 | Angle between every 2 panels |
+| `gradient` | `number` | no | `0` | editor range: 0…1 | Color mixing within a panel, 0 = solid, 1 = gradient |
diff --git a/skills/paper-shaders/references/shaders/dithering.md b/skills/paper-shaders/references/shaders/dithering.md
new file mode 100644
index 000000000..270e2be65
--- /dev/null
+++ b/skills/paper-shaders/references/shaders/dithering.md
@@ -0,0 +1,19 @@
+# Dithering
+
+Use the listed implementation types, options, capacities, exports, and defaults. Values labeled “editor range” come from the documentation UI definitions; the component does not clamp props to that range.
+
+Animated 2-color dithering over multiple pattern sources (noise, warp, dots, waves, ripple, swirl, sphere).
+
+- React: `Dithering` and `ditheringPresets` from `@paper-design/shaders-react`.
+- Vanilla: `ditheringFragmentShader` and `DitheringParams` from `@paper-design/shaders`.
+- Common controls: sizing and motion. Defaults: speed=1, frame=0, fit="none", scale=0.6, rotation=0, offsetX=0, offsetY=0, originX=0.5, originY=0.5, worldWidth=0, worldHeight=0.
+- Source: `packages/shaders/src/shaders/dithering.ts`, `packages/shaders-react/src/shaders/dithering.tsx`, `docs/src/shader-defs/dithering-def.ts`.
+
+| Prop | Type | Required | React default | Constraints | Effect |
+| --- | --- | --- | --- | --- | --- |
+| `colorBack` | `string` | no | `"#000000"` | — | Background color in RGBA |
+| `colorFront` | `string` | no | `"#00b2ff"` | — | Foreground (ink) color in RGBA |
+| `shape` | `DitheringShape` | no | `"sphere"` | options: "simplex", "warp", "dots", "wave", "ripple", "swirl", "sphere" | Shape pattern type (1 = simplex, 2 = warp, 3 = dots, 4 = wave, 5 = ripple, 6 = swirl, 7 = sphere) |
+| `type` | `DitheringType` | no | `"4x4"` | options: "random", "2x2", "4x4", "8x8" | Dithering type (1 = random, 2 = 2x2 Bayer, 3 = 4x4 Bayer, 4 = 8x8 Bayer) |
+| `size` | `number` | no | `2` | editor range: 1…20; shader source documents 0.5…20 | Pixel size of dithering grid |
+| `pxSize` | `number` | no | — | — | React-only. @deprecated use `size` instead |
diff --git a/skills/paper-shaders/references/shaders/dot-grid.md b/skills/paper-shaders/references/shaders/dot-grid.md
new file mode 100644
index 000000000..4e259aa1d
--- /dev/null
+++ b/skills/paper-shaders/references/shaders/dot-grid.md
@@ -0,0 +1,24 @@
+# Dot Grid
+
+Use the listed implementation types, options, capacities, exports, and defaults. Values labeled “editor range” come from the documentation UI definitions; the component does not clamp props to that range.
+
+Static grid pattern made of circles, diamonds, squares or triangles.
+
+- React: `DotGrid` and `dotGridPresets` from `@paper-design/shaders-react`.
+- Vanilla: `dotGridFragmentShader` and `DotGridParams` from `@paper-design/shaders`.
+- Common controls: sizing. Defaults: fit="none", scale=1, rotation=0, offsetX=0, offsetY=0, originX=0.5, originY=0.5, worldWidth=0, worldHeight=0.
+- React performance override: `maxPixelCount` defaults to `6016 * 3384`.
+- Source: `packages/shaders/src/shaders/dot-grid.ts`, `packages/shaders-react/src/shaders/dot-grid.tsx`, `docs/src/shader-defs/dot-grid-def.ts`.
+
+| Prop | Type | Required | React default | Constraints | Effect |
+| --- | --- | --- | --- | --- | --- |
+| `colorBack` | `string` | no | `"#000000"` | — | Background color in RGBA |
+| `colorFill` | `string` | no | `"#ffffff"` | — | Shape fill color in RGBA |
+| `colorStroke` | `string` | no | `"#ffaa00"` | — | Shape stroke color in RGBA |
+| `size` | `number` | no | `2` | editor range: 1…100 | Base size of each shape in pixels |
+| `gapX` | `number` | no | `32` | editor range: 2…500 | Pattern horizontal spacing in pixels |
+| `gapY` | `number` | no | `32` | editor range: 2…500 | Pattern vertical spacing in pixels |
+| `strokeWidth` | `number` | no | `0` | editor range: 0…50 | Outline stroke width in pixels |
+| `sizeRange` | `number` | no | `0` | editor range: 0…1 | Random variation in shape size, 0 = uniform, higher = random up to base size |
+| `opacityRange` | `number` | no | `0` | editor range: 0…1 | Random variation in shape opacity, 0 = opaque, higher = semi-transparent |
+| `shape` | `DotGridShape` | no | `"circle"` | options: "circle", "diamond", "square", "triangle" | Shape type (0 = circle, 1 = diamond, 2 = square, 3 = triangle) |
diff --git a/skills/paper-shaders/references/shaders/dot-orbit.md b/skills/paper-shaders/references/shaders/dot-orbit.md
new file mode 100644
index 000000000..ee3ccf4f7
--- /dev/null
+++ b/skills/paper-shaders/references/shaders/dot-orbit.md
@@ -0,0 +1,20 @@
+# Dot Orbit
+
+Use the listed implementation types, options, capacities, exports, and defaults. Values labeled “editor range” come from the documentation UI definitions; the component does not clamp props to that range.
+
+Animated multi-color dots pattern with each dot orbiting around its cell center. Supports up to 10 colors and various shape and motion controls.
+
+- React: `DotOrbit` and `dotOrbitPresets` from `@paper-design/shaders-react`.
+- Vanilla: `dotOrbitFragmentShader` and `DotOrbitParams` from `@paper-design/shaders`.
+- Common controls: sizing and motion. Defaults: speed=1.5, frame=0, fit="none", scale=1, rotation=0, offsetX=0, offsetY=0, originX=0.5, originY=0.5, worldWidth=0, worldHeight=0.
+- Vanilla requirements: create `u_noiseTexture` with `getShaderNoiseTexture()` and wait for the image to load before constructing `ShaderMount`.
+- Source: `packages/shaders/src/shaders/dot-orbit.ts`, `packages/shaders-react/src/shaders/dot-orbit.tsx`, `docs/src/shader-defs/dot-orbit-def.ts`.
+
+| Prop | Type | Required | React default | Constraints | Effect |
+| --- | --- | --- | --- | --- | --- |
+| `colorBack` | `string` | no | `"#000000"` | — | Background color in RGBA |
+| `colors` | `string[]` | no | `["#ffc96b","#ff6200","#ff2f00","#421100","#1a0000"]` | implementation capacity: 10 | Up to 10 base colors in RGBA |
+| `size` | `number` | no | `1` | editor range: 0…1 | Dot radius relative to cell size |
+| `sizeRange` | `number` | no | `0` | editor range: 0…1 | Random variation in shape size, 0 = uniform, higher = random up to base size |
+| `spreading` | `number` | no | `1` | editor range: 0…1 | Maximum orbit distance around cell center |
+| `stepsPerColor` | `number` | no | `4` | editor range: 1…4; step: 1 | Number of extra colors between base colors, 1 = N colors, 2 = 2×N, etc. |
diff --git a/skills/paper-shaders/references/shaders/fluted-glass.md b/skills/paper-shaders/references/shaders/fluted-glass.md
new file mode 100644
index 000000000..dc5aa15cb
--- /dev/null
+++ b/skills/paper-shaders/references/shaders/fluted-glass.md
@@ -0,0 +1,38 @@
+# Fluted Glass
+
+Use the listed implementation types, options, capacities, exports, and defaults. Values labeled “editor range” come from the documentation UI definitions; the component does not clamp props to that range.
+
+Fluted glass image filter that transforms an image into streaked, ribbed distortions, giving a mix of clarity and obscurity.
+
+- React: `FlutedGlass` and `flutedGlassPresets` from `@paper-design/shaders-react`.
+- Vanilla: `flutedGlassFragmentShader` and `FlutedGlassParams` from `@paper-design/shaders`.
+- Common controls: sizing and motion. Defaults: speed=0, frame=0, fit="cover", scale=1, rotation=0, offsetX=0, offsetY=0, originX=0.5, originY=0.5, worldWidth=0, worldHeight=0.
+- Vanilla requirements: pass `["u_image"]` as the `ShaderMount` mipmaps argument.
+- Enum source note: the documentation UI definition contains stale `facete`; the exported `GlassDistortionShapes` mapping and component type use `flat`.
+- Source: `packages/shaders/src/shaders/fluted-glass.ts`, `packages/shaders-react/src/shaders/fluted-glass.tsx`, `docs/src/shader-defs/fluted-glass-def.ts`.
+
+| Prop | Type | Required | React default | Constraints | Effect |
+| --- | --- | --- | --- | --- | --- |
+| `image` | `HTMLImageElement \| string` | no | `""` | — | Source image texture |
+| `colorBack` | `string` | no | `"#00000000"` | — | Background color in RGBA |
+| `colorShadow` | `string` | no | `"#000000"` | — | Shadows color in RGBA |
+| `colorHighlight` | `string` | no | `"#ffffff"` | — | Highlights color in RGBA |
+| `shadows` | `number` | no | `0.25` | editor range: 0…1 | Color gradient added over image and background, following distortion shape |
+| `size` | `number` | no | `0.5` | editor range: 0…1; step: 0.001 | Size of the distortion shape grid |
+| `angle` | `number` | no | `0` | editor range: 0…180 | Direction of the grid relative to the image in degrees |
+| `distortion` | `number` | no | `0.5` | editor range: 0…1 | Power of distortion applied within each stripe |
+| `shift` | `number` | no | `0` | editor range: -1…1 | Texture shift in direction opposite to the grid |
+| `blur` | `number` | no | `0` | editor range: 0…1 | One-directional blur over the image and extra blur around edges |
+| `edges` | `number` | no | `0.25` | editor range: 0…1 | Glass distortion and softness on the image edges |
+| `margin` | `number` | no | `0` | editor range: 0…1 | Distance from image edges to the effect |
+| `marginLeft` | `number` | no | `0` | editor range: 0…1 | Distance from the left edge to the effect |
+| `marginRight` | `number` | no | `0` | editor range: 0…1 | Distance from the right edge to the effect |
+| `marginTop` | `number` | no | `0` | editor range: 0…1 | Distance from the top edge to the effect |
+| `marginBottom` | `number` | no | `0` | editor range: 0…1 | Distance from the bottom edge to the effect |
+| `stretch` | `number` | no | `0` | editor range: 0…1 | Extra distortion along the grid lines |
+| `distortionShape` | `GlassDistortionShape` | no | `"prism"` | options: "prism", "lens", "contour", "cascade", "flat" | Shape of distortion (1 = prism, 2 = lens, 3 = contour, 4 = cascade, 5 = flat) |
+| `highlights` | `number` | no | `0.1` | editor range: 0…1 | Thin strokes along distortion shape, useful for antialiasing on small grid |
+| `shape` | `GlassGridShape` | no | `"lines"` | options: "lines", "linesIrregular", "wave", "zigzag", "pattern" | Grid shape (1 = lines, 2 = linesIrregular, 3 = wave, 4 = zigzag, 5 = pattern) |
+| `grainMixer` | `number` | no | `0` | editor range: 0…1 | Strength of grain distortion applied to shape edges |
+| `grainOverlay` | `number` | no | `0` | editor range: 0…1 | Post-processing black/white grain overlay |
+| `count` | `number` | no | — | — | React-only. @deprecated use `size` instead |
diff --git a/skills/paper-shaders/references/shaders/gem-smoke.md b/skills/paper-shaders/references/shaders/gem-smoke.md
new file mode 100644
index 000000000..0db758773
--- /dev/null
+++ b/skills/paper-shaders/references/shaders/gem-smoke.md
@@ -0,0 +1,27 @@
+# Gem Smoke
+
+Use the listed implementation types, options, capacities, exports, and defaults. Values labeled “editor range” come from the documentation UI definitions; the component does not clamp props to that range.
+
+Animated color fields placed over uploaded logo shape; gives the illusion of smoky noise behind the glassy shape.
+
+- React: `GemSmoke` and `gemSmokePresets` from `@paper-design/shaders-react`.
+- Vanilla: `gemSmokeFragmentShader` and `GemSmokeParams` from `@paper-design/shaders`.
+- Common controls: sizing and motion. Defaults: speed=1, frame=0, fit="contain", scale=0.6, rotation=0, offsetX=0, offsetY=0, originX=0.5, originY=0.5, worldWidth=0, worldHeight=0.
+- Vanilla requirements: preprocess image input with `toProcessedGemSmoke`; pass `["u_image"]` as the `ShaderMount` mipmaps argument.
+- Source: `packages/shaders/src/shaders/gem-smoke.ts`, `packages/shaders-react/src/shaders/gem-smoke.tsx`, `docs/src/shader-defs/gem-smoke-def.ts`.
+
+| Prop | Type | Required | React default | Constraints | Effect |
+| --- | --- | --- | --- | --- | --- |
+| `colors` | `string[]` | no | `["#333333","#e7e6df"]` | implementation capacity: 6 | Up to 6 smoke colors in RGBA |
+| `colorBack` | `string` | no | `"#f0efea"` | — | Background color in RGBA |
+| `image` | `HTMLImageElement \| string \| undefined` | no | `""` | — | Pre-processed source image texture (R = edge gradient, G = alpha) |
+| `innerDistortion` | `number` | no | `0.8` | editor range: 0…1 | Power of smoke distortion inside the input shape |
+| `outerDistortion` | `number` | no | `0.6` | editor range: 0…1 | Power of smoke distortion outside the input shape |
+| `outerGlow` | `number` | no | `0.55` | editor range: 0…1 | Visibility of smoke shape outside the input shape |
+| `innerGlow` | `number` | no | `1` | editor range: 0…1 | Visibility of smoke shape inside the input shape |
+| `colorInner` | `string` | no | `"#fafaf5"` | — | Additional color inside the input shape, mixing with smoke (RGBA) |
+| `offset` | `number` | no | `0` | editor range: -1…1 | Vertical offset of smoke inside the shape |
+| `angle` | `number` | no | `0` | editor range: 0…360 | Smoke direction in degrees |
+| `size` | `number` | no | `0.8` | editor range: 0…1 | Size of smoke shape relative to the image box |
+| `shape` | `GemSmokeShape` | no | `"diamond"` | options: "none", "circle", "daisy", "diamond", "metaballs" | The predefined shape used as an effect mask when no image is provided. |
+| `suspendWhenProcessingImage` | `boolean` | no | `false` | — | React-only. Suspends the component when the image is being processed. |
diff --git a/skills/paper-shaders/references/shaders/god-rays.md b/skills/paper-shaders/references/shaders/god-rays.md
new file mode 100644
index 000000000..29a77b8f7
--- /dev/null
+++ b/skills/paper-shaders/references/shaders/god-rays.md
@@ -0,0 +1,23 @@
+# God Rays
+
+Use the listed implementation types, options, capacities, exports, and defaults. Values labeled “editor range” come from the documentation UI definitions; the component does not clamp props to that range.
+
+Animated rays of light radiating from the center, blended with up to 5 colors.
+
+- React: `GodRays` and `godRaysPresets` from `@paper-design/shaders-react`.
+- Vanilla: `godRaysFragmentShader` and `GodRaysParams` from `@paper-design/shaders`.
+- Common controls: sizing and motion. Defaults: speed=0.75, frame=0, fit="contain", scale=1, rotation=0, offsetX=0, offsetY=-0.55, originX=0.5, originY=0.5, worldWidth=0, worldHeight=0.
+- Vanilla requirements: create `u_noiseTexture` with `getShaderNoiseTexture()` and wait for the image to load before constructing `ShaderMount`.
+- Source: `packages/shaders/src/shaders/god-rays.ts`, `packages/shaders-react/src/shaders/god-rays.tsx`, `docs/src/shader-defs/god-rays-def.ts`.
+
+| Prop | Type | Required | React default | Constraints | Effect |
+| --- | --- | --- | --- | --- | --- |
+| `colorBack` | `string` | no | `"#000000"` | — | Background color in RGBA |
+| `colorBloom` | `string` | no | `"#0000ff"` | — | Color overlay blended with the rays in RGBA |
+| `colors` | `string[]` | no | `["#a600ff6e","#6200fff0","#ffffff","#33fff5"]` | implementation capacity: 5 | Up to 5 ray colors in RGBA |
+| `spotty` | `number` | no | `0.3` | editor range: 0…1 | The length of the rays, higher = more spots/shorter rays |
+| `midSize` | `number` | no | `0.2` | editor range: 0…1 | Size of the circular glow shape in the center |
+| `midIntensity` | `number` | no | `0.4` | editor range: 0…1 | Brightness/intensity of the central glow |
+| `density` | `number` | no | `0.3` | editor range: 0…1 | The number of rays |
+| `intensity` | `number` | no | `0.8` | editor range: 0…1 | Visibility/strength of the rays |
+| `bloom` | `number` | no | `0.4` | editor range: 0…1 | Strength of the bloom/overlay effect, 0 = alpha blend, 1 = additive blend |
diff --git a/skills/paper-shaders/references/shaders/grain-gradient.md b/skills/paper-shaders/references/shaders/grain-gradient.md
new file mode 100644
index 000000000..6bb32d58d
--- /dev/null
+++ b/skills/paper-shaders/references/shaders/grain-gradient.md
@@ -0,0 +1,20 @@
+# Grain Gradient
+
+Use the listed implementation types, options, capacities, exports, and defaults. Values labeled “editor range” come from the documentation UI definitions; the component does not clamp props to that range.
+
+Multi-color gradients with grainy, noise-textured distortion available in 7 animated abstract forms.
+
+- React: `GrainGradient` and `grainGradientPresets` from `@paper-design/shaders-react`.
+- Vanilla: `grainGradientFragmentShader` and `GrainGradientParams` from `@paper-design/shaders`.
+- Common controls: sizing and motion. Defaults: speed=1, frame=0, fit="contain", scale=1, rotation=0, offsetX=0, offsetY=0, originX=0.5, originY=0.5, worldWidth=0, worldHeight=0.
+- Vanilla requirements: create `u_noiseTexture` with `getShaderNoiseTexture()` and wait for the image to load before constructing `ShaderMount`.
+- Source: `packages/shaders/src/shaders/grain-gradient.ts`, `packages/shaders-react/src/shaders/grain-gradient.tsx`, `docs/src/shader-defs/grain-gradient-def.ts`.
+
+| Prop | Type | Required | React default | Constraints | Effect |
+| --- | --- | --- | --- | --- | --- |
+| `colorBack` | `string` | no | `"#000000"` | — | Background color in RGBA |
+| `colors` | `string[]` | no | `["#7300ff","#eba8ff","#00bfff","#2a00ff"]` | implementation capacity: 7 | Up to 7 gradient colors in RGBA |
+| `softness` | `number` | no | `0.5` | editor range: 0…1 | Color transition sharpness, 0 = hard edge, 1 = smooth gradient |
+| `intensity` | `number` | no | `0.5` | editor range: 0…1 | Distortion between color bands |
+| `noise` | `number` | no | `0.25` | editor range: 0…1 | Grainy noise overlay |
+| `shape` | `GrainGradientShape` | no | `"corners"` | options: "wave", "dots", "truchet", "corners", "ripple", "blob", "sphere" | Shape type (1 = wave, 2 = dots, 3 = truchet, 4 = corners, 5 = ripple, 6 = blob, 7 = sphere) |
diff --git a/skills/paper-shaders/references/shaders/halftone-cmyk.md b/skills/paper-shaders/references/shaders/halftone-cmyk.md
new file mode 100644
index 000000000..a799d7445
--- /dev/null
+++ b/skills/paper-shaders/references/shaders/halftone-cmyk.md
@@ -0,0 +1,36 @@
+# Halftone CMYK
+
+Use the listed implementation types, options, capacities, exports, and defaults. Values labeled “editor range” come from the documentation UI definitions; the component does not clamp props to that range.
+
+CMYK halftone printing effect applied to images with customizable dot patterns and ink colors for each channel (Cyan, Magenta, Yellow, Black).
+
+- React: `HalftoneCmyk` and `halftoneCmykPresets` from `@paper-design/shaders-react`.
+- Vanilla: `halftoneCmykFragmentShader` and `HalftoneCmykParams` from `@paper-design/shaders`.
+- Common controls: sizing and motion. Defaults: speed=0, frame=0, fit="cover", scale=1, rotation=0, offsetX=0, offsetY=0, originX=0.5, originY=0.5, worldWidth=0, worldHeight=0.
+- Vanilla requirements: create `u_noiseTexture` with `getShaderNoiseTexture()` and wait for the image to load before constructing `ShaderMount`.
+- Source: `packages/shaders/src/shaders/halftone-cmyk.ts`, `packages/shaders-react/src/shaders/halftone-cmyk.tsx`, `docs/src/shader-defs/halftone-cmyk-def.ts`.
+
+| Prop | Type | Required | React default | Constraints | Effect |
+| --- | --- | --- | --- | --- | --- |
+| `image` | `HTMLImageElement \| string` | no | `""` | — | Source image texture |
+| `colorBack` | `string` | no | `"#fbfaf5"` | — | Background (paper) color in RGBA |
+| `colorC` | `string` | no | `"#00b4ff"` | — | Cyan ink color in RGBA |
+| `colorM` | `string` | no | `"#fc519f"` | — | Magenta ink color in RGBA |
+| `colorY` | `string` | no | `"#ffd800"` | — | Yellow ink color in RGBA |
+| `colorK` | `string` | no | `"#231f20"` | — | Black ink color in RGBA |
+| `size` | `number` | no | `0.2` | editor range: 0…1 | Halftone cell size |
+| `contrast` | `number` | no | `1` | editor range: 0…2 | Image contrast adjustment |
+| `softness` | `number` | no | `1` | editor range: 0…1 | Edge softness of dots |
+| `grainSize` | `number` | no | `0.5` | editor range: 0…1 | Size of grain overlay texture |
+| `grainMixer` | `number` | no | `0` | editor range: 0…1 | Strength of grain affecting dot size |
+| `grainOverlay` | `number` | no | `0` | editor range: 0…1 | Strength of grain overlay on final output |
+| `gridNoise` | `number` | no | `0.2` | editor range: 0…1 | Strength of smooth noise applied to both dot positions and color sampling |
+| `floodC` | `number` | no | `0.15` | editor range: 0…1; shader source documents -1…1 | Flat cyan dot size adjustment applied uniformly |
+| `floodM` | `number` | no | `0` | editor range: 0…1; shader source documents -1…1 | Flat magenta dot size adjustment applied uniformly |
+| `floodY` | `number` | no | `0` | editor range: 0…1; shader source documents -1…1 | Flat yellow dot size adjustment applied uniformly |
+| `floodK` | `number` | no | `0` | editor range: 0…1; shader source documents -1…1 | Flat black dot size adjustment applied uniformly |
+| `gainC` | `number` | no | `0.3` | editor range: -1…1 | Proportional cyan dot size gain (enhances existing dots) |
+| `gainM` | `number` | no | `0` | editor range: -1…1 | Proportional magenta dot size gain (enhances existing dots) |
+| `gainY` | `number` | no | `0.2` | editor range: -1…1 | Proportional yellow dot size gain (enhances existing dots) |
+| `gainK` | `number` | no | `0` | editor range: -1…1 | Proportional black dot size gain (enhances existing dots) |
+| `type` | `HalftoneCmykType` | no | `"ink"` | options: "dots", "ink", "sharp" | Dot shape style (0 = dots, 1 = ink, 2 = sharp) |
diff --git a/skills/paper-shaders/references/shaders/halftone-dots.md b/skills/paper-shaders/references/shaders/halftone-dots.md
new file mode 100644
index 000000000..4e92b3827
--- /dev/null
+++ b/skills/paper-shaders/references/shaders/halftone-dots.md
@@ -0,0 +1,26 @@
+# Halftone Dots
+
+Use the listed implementation types, options, capacities, exports, and defaults. Values labeled “editor range” come from the documentation UI definitions; the component does not clamp props to that range.
+
+A halftone-dot image filter featuring customizable grids, color palettes, and dot styles.
+
+- React: `HalftoneDots` and `halftoneDotsPresets` from `@paper-design/shaders-react`.
+- Vanilla: `halftoneDotsFragmentShader` and `HalftoneDotsParams` from `@paper-design/shaders`.
+- Common controls: sizing and motion. Defaults: speed=0, frame=0, fit="cover", scale=1, rotation=0, offsetX=0, offsetY=0, originX=0.5, originY=0.5, worldWidth=0, worldHeight=0.
+- Source: `packages/shaders/src/shaders/halftone-dots.ts`, `packages/shaders-react/src/shaders/halftone-dots.tsx`, `docs/src/shader-defs/halftone-dots-def.ts`.
+
+| Prop | Type | Required | React default | Constraints | Effect |
+| --- | --- | --- | --- | --- | --- |
+| `image` | `HTMLImageElement \| string` | no | `""` | — | Source image texture |
+| `colorFront` | `string` | no | `"#2b2b2b"` | — | Foreground color in RGBA |
+| `colorBack` | `string` | no | `"#f2f1e8"` | — | Background color in RGBA |
+| `size` | `number` | no | `0.5` | editor range: 0…1 | Grid size relative to the image box |
+| `grid` | `HalftoneDotsGrid` | no | `"hex"` | options: "square", "hex" | Grid type (0 = square, 1 = hex) |
+| `radius` | `number` | no | `1.25` | editor range: 0…2 | Maximum dot size relative to grid cell |
+| `contrast` | `number` | no | `0.4` | editor range: 0…1 | Contrast applied to the sampled image |
+| `originalColors` | `boolean` | no | `false` | — | Use sampled image's original colors instead of colorFront |
+| `inverted` | `boolean` | no | `false` | — | Inverts the image luminance, doesn't affect the color scheme; not effective at zero contrast |
+| `grainMixer` | `number` | no | `0.2` | editor range: 0…1 | Strength of grain distortion applied to shape edges |
+| `grainOverlay` | `number` | no | `0.2` | editor range: 0…1 | Post-processing black/white grain overlay |
+| `grainSize` | `number` | no | `0.5` | editor range: 0…1 | Scale applied to both grain distortion and grain overlay |
+| `type` | `HalftoneDotsType` | no | `"gooey"` | options: "classic", "gooey", "holes", "soft" | Dot style (0 = classic, 1 = gooey, 2 = holes, 3 = soft) |
diff --git a/skills/paper-shaders/references/shaders/heatmap.md b/skills/paper-shaders/references/shaders/heatmap.md
new file mode 100644
index 000000000..4fbff812a
--- /dev/null
+++ b/skills/paper-shaders/references/shaders/heatmap.md
@@ -0,0 +1,23 @@
+# Heatmap
+
+Use the listed implementation types, options, capacities, exports, and defaults. Values labeled “editor range” come from the documentation UI definitions; the component does not clamp props to that range.
+
+A glowing gradient of colors flowing through an input shape. The effect creates a smoothly animated wave of intensity across the image.
+
+- React: `Heatmap` and `heatmapPresets` from `@paper-design/shaders-react`.
+- Vanilla: `heatmapFragmentShader` and `HeatmapParams` from `@paper-design/shaders`.
+- Common controls: sizing and motion. Defaults: speed=1, frame=0, fit="contain", scale=0.75, rotation=0, offsetX=0, offsetY=0, originX=0.5, originY=0.5, worldWidth=0, worldHeight=0.
+- Vanilla requirements: preprocess image input with `toProcessedHeatmap`; pass `["u_image"]` as the `ShaderMount` mipmaps argument.
+- Source: `packages/shaders/src/shaders/heatmap.ts`, `packages/shaders-react/src/shaders/heatmap.tsx`, `docs/src/shader-defs/heatmap-def.ts`.
+
+| Prop | Type | Required | React default | Constraints | Effect |
+| --- | --- | --- | --- | --- | --- |
+| `image` | `HTMLImageElement \| string` | yes | `""` | — | Pre-processed source image texture (R = contour, G = outer blur, B = inner blur) |
+| `contour` | `number` | no | `0.5` | editor range: 0…1 | Heat intensity near the edges of the input shape |
+| `angle` | `number` | no | `0` | editor range: 0…360 | Direction of the heatwaves in degrees |
+| `noise` | `number` | no | `0` | editor range: 0…1 | Grain applied across the entire graphic |
+| `innerGlow` | `number` | no | `0.5` | editor range: 0…1 | Size of the heated area inside the input shape |
+| `outerGlow` | `number` | no | `0.5` | editor range: 0…1 | Size of the heated area outside the input shape |
+| `colorBack` | `string` | no | `"#000000"` | — | Background color in RGBA |
+| `colors` | `string[]` | no | `["#11206a","#1f3ba2","#2f63e7","#6bd7ff","#ffe679","#ff991e","#ff4c00"]` | implementation capacity: 10 | Up to 10 heatmap colors in RGBA |
+| `suspendWhenProcessingImage` | `boolean` | no | `false` | — | React-only. Suspends the component when the image is being processed. |
diff --git a/skills/paper-shaders/references/shaders/image-dithering.md b/skills/paper-shaders/references/shaders/image-dithering.md
new file mode 100644
index 000000000..c191310b4
--- /dev/null
+++ b/skills/paper-shaders/references/shaders/image-dithering.md
@@ -0,0 +1,23 @@
+# Image Dithering
+
+Use the listed implementation types, options, capacities, exports, and defaults. Values labeled “editor range” come from the documentation UI definitions; the component does not clamp props to that range.
+
+A dithering image filter with support for 4 dithering modes and multiple color palettes (2-color, 3-color, and multicolor options, using either predefined colors or colors sampled from the original image).
+
+- React: `ImageDithering` and `imageDitheringPresets` from `@paper-design/shaders-react`.
+- Vanilla: `imageDitheringFragmentShader` and `ImageDitheringParams` from `@paper-design/shaders`.
+- Common controls: sizing and motion. Defaults: speed=0, frame=0, fit="cover", scale=1, rotation=0, offsetX=0, offsetY=0, originX=0.5, originY=0.5, worldWidth=0, worldHeight=0.
+- Source: `packages/shaders/src/shaders/image-dithering.ts`, `packages/shaders-react/src/shaders/image-dithering.tsx`, `docs/src/shader-defs/image-dithering-def.ts`.
+
+| Prop | Type | Required | React default | Constraints | Effect |
+| --- | --- | --- | --- | --- | --- |
+| `image` | `HTMLImageElement \| string` | yes | `""` | — | Source image texture |
+| `colorFront` | `string` | no | `"#94ffaf"` | — | Foreground color in RGBA |
+| `colorBack` | `string` | no | `"#000c38"` | — | Background color in RGBA |
+| `colorHighlight` | `string` | no | `"#eaff94"` | — | Secondary foreground color in RGBA (set same as colorFront for classic 2-color dithering) |
+| `type` | `DitheringType` | no | `"8x8"` | options: "random", "2x2", "4x4", "8x8" | Dithering type (1 = random, 2 = 2x2 Bayer, 3 = 4x4 Bayer, 4 = 8x8 Bayer) |
+| `size` | `number` | no | `2` | editor range: 0.5…20 | Pixel size of dithering grid |
+| `colorSteps` | `number` | no | `2` | editor range: 1…7; step: 1 | Number of colors to use, applies to both color modes |
+| `originalColors` | `boolean` | no | `false` | — | Use the original colors of the image instead of the color palette |
+| `inverted` | `boolean` | no | `false` | — | Inverts the image luminance, doesn't affect the color scheme; not effective at zero contrast |
+| `pxSize` | `number` | no | — | — | React-only. @deprecated use `size` instead |
diff --git a/skills/paper-shaders/references/shaders/liquid-metal.md b/skills/paper-shaders/references/shaders/liquid-metal.md
new file mode 100644
index 000000000..aa1f89325
--- /dev/null
+++ b/skills/paper-shaders/references/shaders/liquid-metal.md
@@ -0,0 +1,26 @@
+# Liquid Metal
+
+Use the listed implementation types, options, capacities, exports, and defaults. Values labeled “editor range” come from the documentation UI definitions; the component does not clamp props to that range.
+
+Futuristic liquid metal material applied to uploaded logo or abstract shape. Fluid motion imitation applied over user image with animated stripe pattern getting distorted along shape edges.
+
+- React: `LiquidMetal` and `liquidMetalPresets` from `@paper-design/shaders-react`.
+- Vanilla: `liquidMetalFragmentShader` and `LiquidMetalParams` from `@paper-design/shaders`.
+- Common controls: sizing and motion. Defaults: speed=1, frame=0, fit="contain", scale=0.6, rotation=0, offsetX=0, offsetY=0, originX=0.5, originY=0.5, worldWidth=0, worldHeight=0.
+- Vanilla requirements: preprocess image input with `toProcessedLiquidMetal`; pass `["u_image"]` as the `ShaderMount` mipmaps argument.
+- Source: `packages/shaders/src/shaders/liquid-metal.ts`, `packages/shaders-react/src/shaders/liquid-metal.tsx`, `docs/src/shader-defs/liquid-metal-def.ts`.
+
+| Prop | Type | Required | React default | Constraints | Effect |
+| --- | --- | --- | --- | --- | --- |
+| `colorBack` | `string` | no | `"#AAAAAC"` | — | Background color in RGBA |
+| `colorTint` | `string` | no | `"#ffffff"` | — | Overlay color in RGBA (color burn blending used) |
+| `image` | `HTMLImageElement \| string \| undefined` | no | `""` | — | Pre-processed source image texture (R = edge gradient, G = opacity) |
+| `repetition` | `number` | no | `2` | editor range: 1…10 | Density of pattern stripes |
+| `shiftRed` | `number` | no | `0.3` | editor range: -1…1 | R-channel dispersion |
+| `shiftBlue` | `number` | no | `0.3` | editor range: -1…1 | B-channel dispersion |
+| `contour` | `number` | no | `0.4` | editor range: 0…1 | Strength of the distortion on the shape edges |
+| `softness` | `number` | no | `0.1` | editor range: 0…1 | Color transition sharpness, 0 = hard edge, 1 = smooth gradient |
+| `distortion` | `number` | no | `0.07` | editor range: 0…1 | Noise distortion over the stripes pattern |
+| `angle` | `number` | no | `70` | editor range: 0…360 | Direction of pattern animation in degrees |
+| `shape` | `LiquidMetalShape` | no | `"diamond"` | options: "none", "circle", "daisy", "diamond", "metaballs" | Predefined shape when no image provided (0 = none, 1 = circle, 2 = daisy, 3 = diamond, 4 = metaballs) |
+| `suspendWhenProcessingImage` | `boolean` | no | `false` | — | React-only. Suspends the component when the image is being processed. |
diff --git a/skills/paper-shaders/references/shaders/mesh-gradient.md b/skills/paper-shaders/references/shaders/mesh-gradient.md
new file mode 100644
index 000000000..b4f7cc99b
--- /dev/null
+++ b/skills/paper-shaders/references/shaders/mesh-gradient.md
@@ -0,0 +1,18 @@
+# Mesh Gradient
+
+Use the listed implementation types, options, capacities, exports, and defaults. Values labeled “editor range” come from the documentation UI definitions; the component does not clamp props to that range.
+
+A flowing composition of color spots, moving along distinct trajectories and transformed by organic distortion.
+
+- React: `MeshGradient` and `meshGradientPresets` from `@paper-design/shaders-react`.
+- Vanilla: `meshGradientFragmentShader` and `MeshGradientParams` from `@paper-design/shaders`.
+- Common controls: sizing and motion. Defaults: speed=1, frame=0, fit="contain", scale=1, rotation=0, offsetX=0, offsetY=0, originX=0.5, originY=0.5, worldWidth=0, worldHeight=0.
+- Source: `packages/shaders/src/shaders/mesh-gradient.ts`, `packages/shaders-react/src/shaders/mesh-gradient.tsx`, `docs/src/shader-defs/mesh-gradient-def.ts`.
+
+| Prop | Type | Required | React default | Constraints | Effect |
+| --- | --- | --- | --- | --- | --- |
+| `colors` | `string[]` | no | `["#e0eaff","#241d9a","#f75092","#9f50d3"]` | implementation capacity: 10 | Up to 10 color spots in RGBA |
+| `distortion` | `number` | no | `0.8` | editor range: 0…1 | Power of organic noise distortion |
+| `swirl` | `number` | no | `0.1` | editor range: 0…1 | Power of vortex distortion |
+| `grainMixer` | `number` | no | `0` | editor range: 0…1 | Strength of grain distortion applied to shape edges |
+| `grainOverlay` | `number` | no | `0` | editor range: 0…1 | Post-processing black/white grain overlay |
diff --git a/skills/paper-shaders/references/shaders/metaballs.md b/skills/paper-shaders/references/shaders/metaballs.md
new file mode 100644
index 000000000..42e0d47c9
--- /dev/null
+++ b/skills/paper-shaders/references/shaders/metaballs.md
@@ -0,0 +1,18 @@
+# Metaballs
+
+Use the listed implementation types, options, capacities, exports, and defaults. Values labeled “editor range” come from the documentation UI definitions; the component does not clamp props to that range.
+
+Up to 20 colored gooey balls moving around the center and merging into smooth organic shapes.
+
+- React: `Metaballs` and `metaballsPresets` from `@paper-design/shaders-react`.
+- Vanilla: `metaballsFragmentShader` and `MetaballsParams` from `@paper-design/shaders`.
+- Common controls: sizing and motion. Defaults: speed=1, frame=0, fit="contain", scale=1, rotation=0, offsetX=0, offsetY=0, originX=0.5, originY=0.5, worldWidth=0, worldHeight=0.
+- Vanilla requirements: create `u_noiseTexture` with `getShaderNoiseTexture()` and wait for the image to load before constructing `ShaderMount`.
+- Source: `packages/shaders/src/shaders/metaballs.ts`, `packages/shaders-react/src/shaders/metaballs.tsx`, `docs/src/shader-defs/metaballs-def.ts`.
+
+| Prop | Type | Required | React default | Constraints | Effect |
+| --- | --- | --- | --- | --- | --- |
+| `colorBack` | `string` | no | `"#000000"` | — | Background color in RGBA |
+| `colors` | `string[]` | no | `["#6e33cc","#ff5500","#ffc105","#ffc800","#f585ff"]` | implementation capacity: 8 | Up to 8 base colors in RGBA |
+| `count` | `number` | no | `10` | editor range: 1…20 | Number of balls |
+| `size` | `number` | no | `0.83` | editor range: 0…1 | Size of the balls |
diff --git a/skills/paper-shaders/references/shaders/neuro-noise.md b/skills/paper-shaders/references/shaders/neuro-noise.md
new file mode 100644
index 000000000..a1326edda
--- /dev/null
+++ b/skills/paper-shaders/references/shaders/neuro-noise.md
@@ -0,0 +1,18 @@
+# Neuro Noise
+
+Use the listed implementation types, options, capacities, exports, and defaults. Values labeled “editor range” come from the documentation UI definitions; the component does not clamp props to that range.
+
+A glowing, web-like structure of fluid lines and soft intersections. Great for creating atmospheric, organic-yet-futuristic visuals.
+
+- React: `NeuroNoise` and `neuroNoisePresets` from `@paper-design/shaders-react`.
+- Vanilla: `neuroNoiseFragmentShader` and `NeuroNoiseParams` from `@paper-design/shaders`.
+- Common controls: sizing and motion. Defaults: speed=1, frame=0, fit="none", scale=1, rotation=0, offsetX=0, offsetY=0, originX=0.5, originY=0.5, worldWidth=0, worldHeight=0.
+- Source: `packages/shaders/src/shaders/neuro-noise.ts`, `packages/shaders-react/src/shaders/neuro-noise.tsx`, `docs/src/shader-defs/neuro-noise-def.ts`.
+
+| Prop | Type | Required | React default | Constraints | Effect |
+| --- | --- | --- | --- | --- | --- |
+| `colorFront` | `string` | no | `"#ffffff"` | — | Graphics highlight color in RGBA |
+| `colorMid` | `string` | no | `"#47a6ff"` | — | Graphics main color in RGBA |
+| `colorBack` | `string` | no | `"#000000"` | — | Background color in RGBA |
+| `brightness` | `number` | no | `0.05` | editor range: 0…1 | Luminosity of the crossing points |
+| `contrast` | `number` | no | `0.3` | editor range: 0…1 | Sharpness of the bright-dark transition |
diff --git a/skills/paper-shaders/references/shaders/paper-texture.md b/skills/paper-shaders/references/shaders/paper-texture.md
new file mode 100644
index 000000000..b07fb450b
--- /dev/null
+++ b/skills/paper-shaders/references/shaders/paper-texture.md
@@ -0,0 +1,32 @@
+# Paper Texture
+
+Use the listed implementation types, options, capacities, exports, and defaults. Values labeled “editor range” come from the documentation UI definitions; the component does not clamp props to that range.
+
+A static texture built from multiple noise layers, usable for realistic paper and cardboard surfaces. Can be used as an image filter or as a standalone texture.
+
+- React: `PaperTexture` and `paperTexturePresets` from `@paper-design/shaders-react`.
+- Vanilla: `paperTextureFragmentShader` and `PaperTextureParams` from `@paper-design/shaders`.
+- Common controls: sizing and motion. Defaults: speed=0, frame=0, fit="cover", scale=0.6, rotation=0, offsetX=0, offsetY=0, originX=0.5, originY=0.5, worldWidth=0, worldHeight=0.
+- Vanilla requirements: create `u_noiseTexture` with `getShaderNoiseTexture()` and wait for the image to load before constructing `ShaderMount`; pass `["u_image"]` as the `ShaderMount` mipmaps argument.
+- Source: `packages/shaders/src/shaders/paper-texture.ts`, `packages/shaders-react/src/shaders/paper-texture.tsx`, `docs/src/shader-defs/paper-texture-def.ts`.
+
+| Prop | Type | Required | React default | Constraints | Effect |
+| --- | --- | --- | --- | --- | --- |
+| `image` | `HTMLImageElement \| string` | no | `""` | — | Optional source image texture |
+| `colorFront` | `string` | no | `"#9fadbc"` | — | Foreground color in RGBA |
+| `colorBack` | `string` | no | `"#ffffff"` | — | Background color in RGBA |
+| `contrast` | `number` | no | `0.3` | editor range: 0…1 | Blending behavior, sharper vs smoother color transitions |
+| `roughness` | `number` | no | `0.4` | editor range: 0…1 | Pixel noise, related to canvas and not scalable |
+| `fiber` | `number` | no | `0.3` | editor range: 0…1 | Curly-shaped noise intensity |
+| `fiberSize` | `number` | no | `0.2` | editor range: 0…1 | Curly-shaped noise scale |
+| `crumples` | `number` | no | `0.3` | editor range: 0…1 | Cell-based crumple pattern intensity |
+| `foldCount` | `number` | no | `5` | editor range: 1…15; step: 1 | Number of folds |
+| `folds` | `number` | no | `0.65` | editor range: 0…1 | Depth of the folds |
+| `fade` | `number` | no | `0` | editor range: 0…1 | Big-scale noise mask applied to the pattern |
+| `crumpleSize` | `number` | no | `0.35` | editor range: 0…1 | Cell-based crumple pattern scale |
+| `drops` | `number` | no | `0.2` | editor range: 0…1 | Visibility of speckle pattern |
+| `seed` | `number` | no | `5.8` | editor range: 0…1000 | Seed applied to folds, crumples and dots |
+| `fiberScale` | `number` | no | — | — | React-only. @deprecated use `fiberSize` instead |
+| `crumplesScale` | `number` | no | — | — | React-only. @deprecated use `crumpleSize` instead |
+| `foldsNumber` | `number` | no | — | — | React-only. @deprecated use `foldCount` instead |
+| `blur` | `number` | no | — | — | React-only. @deprecated use `fade` instead |
diff --git a/skills/paper-shaders/references/shaders/perlin-noise.md b/skills/paper-shaders/references/shaders/perlin-noise.md
new file mode 100644
index 000000000..06618c8f5
--- /dev/null
+++ b/skills/paper-shaders/references/shaders/perlin-noise.md
@@ -0,0 +1,20 @@
+# Perlin Noise
+
+Use the listed implementation types, options, capacities, exports, and defaults. Values labeled “editor range” come from the documentation UI definitions; the component does not clamp props to that range.
+
+Classic animated 3D Perlin noise with exposed controls. Original algorithm: https://www.shadertoy.com/view/NlSGDz
+
+- React: `PerlinNoise` and `perlinNoisePresets` from `@paper-design/shaders-react`.
+- Vanilla: `perlinNoiseFragmentShader` and `PerlinNoiseParams` from `@paper-design/shaders`.
+- Common controls: sizing and motion. Defaults: speed=0.5, frame=0, fit="none", scale=1, rotation=0, offsetX=0, offsetY=0, originX=0.5, originY=0.5, worldWidth=0, worldHeight=0.
+- Source: `packages/shaders/src/shaders/perlin-noise.ts`, `packages/shaders-react/src/shaders/perlin-noise.tsx`, `docs/src/shader-defs/perlin-noise-def.ts`.
+
+| Prop | Type | Required | React default | Constraints | Effect |
+| --- | --- | --- | --- | --- | --- |
+| `colorFront` | `string` | no | `"#fccff7"` | — | Foreground color in RGBA |
+| `colorBack` | `string` | no | `"#632ad5"` | — | Background color in RGBA |
+| `proportion` | `number` | no | `0.35` | editor range: 0…1 | Blend point between 2 colors, 0.5 = equal distribution |
+| `softness` | `number` | no | `0.1` | editor range: 0…1 | Color transition sharpness, 0 = hard edge, 1 = smooth gradient |
+| `octaveCount` | `number` | no | `1` | editor range: 1…8; step: 1 | Perlin noise octaves number, more octaves for more detailed patterns |
+| `persistence` | `number` | no | `1` | editor range: 0.3…1 | Roughness, falloff between octaves |
+| `lacunarity` | `number` | no | `1.5` | editor range: 1.5…10 | Frequency step, defines how compressed the pattern is |
diff --git a/skills/paper-shaders/references/shaders/pulsing-border.md b/skills/paper-shaders/references/shaders/pulsing-border.md
new file mode 100644
index 000000000..3b3d75604
--- /dev/null
+++ b/skills/paper-shaders/references/shaders/pulsing-border.md
@@ -0,0 +1,32 @@
+# Pulsing Border
+
+Use the listed implementation types, options, capacities, exports, and defaults. Values labeled “editor range” come from the documentation UI definitions; the component does not clamp props to that range.
+
+Luminous trails of color merging into a glowing gradient contour.
+
+- React: `PulsingBorder` and `pulsingBorderPresets` from `@paper-design/shaders-react`.
+- Vanilla: `pulsingBorderFragmentShader` and `PulsingBorderParams` from `@paper-design/shaders`.
+- Common controls: sizing and motion. Defaults: speed=1, frame=0, fit="contain", scale=0.6, rotation=0, offsetX=0, offsetY=0, originX=0.5, originY=0.5, worldWidth=0, worldHeight=0.
+- Vanilla requirements: create `u_noiseTexture` with `getShaderNoiseTexture()` and wait for the image to load before constructing `ShaderMount`.
+- Source: `packages/shaders/src/shaders/pulsing-border.ts`, `packages/shaders-react/src/shaders/pulsing-border.tsx`, `docs/src/shader-defs/pulsing-border-def.ts`.
+
+| Prop | Type | Required | React default | Constraints | Effect |
+| --- | --- | --- | --- | --- | --- |
+| `colorBack` | `string` | no | `"#000000"` | — | Background color in RGBA |
+| `colors` | `string[]` | no | `["#0dc1fd","#d915ef","#ff3f2ecc"]` | implementation capacity: 5 | Up to 5 spot colors in RGBA |
+| `roundness` | `number` | no | `0.25` | editor range: 0…1 | Border radius |
+| `thickness` | `number` | no | `0.1` | editor range: 0…1 | Border base width |
+| `margin` | `number` | no | `0` | editor range: 0…1 | Distance from canvas edges to the effect |
+| `marginLeft` | `number` | no | `0` | editor range: 0…1 | Distance from the left edge to the effect |
+| `marginRight` | `number` | no | `0` | editor range: 0…1 | Distance from the right edge to the effect |
+| `marginTop` | `number` | no | `0` | editor range: 0…1 | Distance from the top edge to the effect |
+| `marginBottom` | `number` | no | `0` | editor range: 0…1 | Distance from the bottom edge to the effect |
+| `aspectRatio` | `PulsingBorderAspectRatio` | no | `"auto"` | options: "auto", "square" | Aspect ratio mode (0 = auto, 1 = square) |
+| `softness` | `number` | no | `0.75` | editor range: 0…1 | Border edge sharpness, 0 = hard edge, 1 = smooth gradient |
+| `intensity` | `number` | no | `0.2` | editor range: 0…1 | Thickness of individual color spots |
+| `bloom` | `number` | no | `0.25` | editor range: 0…1 | Power of glow, 0 = normal blending, 1 = additive blending |
+| `spots` | `number` | no | `5` | editor range: 1…20; step: 1; implementation capacity: 4 | Number of spots added for each color |
+| `spotSize` | `number` | no | `0.5` | editor range: 0…1 | Angular size of spots |
+| `pulse` | `number` | no | `0.25` | editor range: 0…1 | Optional pulsing animation intensity |
+| `smoke` | `number` | no | `0.3` | editor range: 0…1 | Optional noisy shape extending the border |
+| `smokeSize` | `number` | no | `0.6` | editor range: 0…1 | Size of the smoke effect |
diff --git a/skills/paper-shaders/references/shaders/simplex-noise.md b/skills/paper-shaders/references/shaders/simplex-noise.md
new file mode 100644
index 000000000..8bde9b55c
--- /dev/null
+++ b/skills/paper-shaders/references/shaders/simplex-noise.md
@@ -0,0 +1,16 @@
+# Simplex Noise
+
+Use the listed implementation types, options, capacities, exports, and defaults. Values labeled “editor range” come from the documentation UI definitions; the component does not clamp props to that range.
+
+A multi-color gradient mapped into smooth, animated curves built as a combination of 2 Simplex noises.
+
+- React: `SimplexNoise` and `simplexNoisePresets` from `@paper-design/shaders-react`.
+- Vanilla: `simplexNoiseFragmentShader` and `SimplexNoiseParams` from `@paper-design/shaders`.
+- Common controls: sizing and motion. Defaults: speed=0.5, frame=0, fit="none", scale=0.6, rotation=0, offsetX=0, offsetY=0, originX=0.5, originY=0.5, worldWidth=0, worldHeight=0.
+- Source: `packages/shaders/src/shaders/simplex-noise.ts`, `packages/shaders-react/src/shaders/simplex-noise.tsx`, `docs/src/shader-defs/simplex-noise-def.ts`.
+
+| Prop | Type | Required | React default | Constraints | Effect |
+| --- | --- | --- | --- | --- | --- |
+| `colors` | `string[]` | no | `["#4449CF","#FFD1E0","#F94446","#FFD36B","#FFFFFF"]` | implementation capacity: 10 | Up to 10 base colors in RGBA |
+| `stepsPerColor` | `number` | no | `2` | editor range: 1…10; step: 1 | Number of extra colors between base colors, 1 = N colors, 2 = 2×N, etc. |
+| `softness` | `number` | no | `0` | editor range: 0…1 | Color transition sharpness, 0 = hard edge, 1 = smooth gradient |
diff --git a/skills/paper-shaders/references/shaders/smoke-ring.md b/skills/paper-shaders/references/shaders/smoke-ring.md
new file mode 100644
index 000000000..4dbe9afd3
--- /dev/null
+++ b/skills/paper-shaders/references/shaders/smoke-ring.md
@@ -0,0 +1,21 @@
+# Smoke Ring
+
+Use the listed implementation types, options, capacities, exports, and defaults. Values labeled “editor range” come from the documentation UI definitions; the component does not clamp props to that range.
+
+Radial multi-colored gradient shaped with layered noise for a natural, smoky aesthetic.
+
+- React: `SmokeRing` and `smokeRingPresets` from `@paper-design/shaders-react`.
+- Vanilla: `smokeRingFragmentShader` and `SmokeRingParams` from `@paper-design/shaders`.
+- Common controls: sizing and motion. Defaults: speed=0.5, frame=0, fit="contain", scale=0.8, rotation=0, offsetX=0, offsetY=0, originX=0.5, originY=0.5, worldWidth=0, worldHeight=0.
+- Vanilla requirements: create `u_noiseTexture` with `getShaderNoiseTexture()` and wait for the image to load before constructing `ShaderMount`.
+- Source: `packages/shaders/src/shaders/smoke-ring.ts`, `packages/shaders-react/src/shaders/smoke-ring.tsx`, `docs/src/shader-defs/smoke-ring-def.ts`.
+
+| Prop | Type | Required | React default | Constraints | Effect |
+| --- | --- | --- | --- | --- | --- |
+| `colorBack` | `string` | no | `"#000000"` | — | Background color in RGBA |
+| `colors` | `string[]` | no | `["#ffffff"]` | implementation capacity: 10 | Up to 10 gradient colors in RGBA |
+| `noiseScale` | `number` | no | `3` | editor range: 0.01…5 | Noise frequency |
+| `thickness` | `number` | no | `0.65` | editor range: 0.01…1 | Thickness of the ring shape |
+| `radius` | `number` | no | `0.25` | editor range: 0…1 | Radius of the ring shape |
+| `innerShape` | `number` | no | `0.7` | editor range: 0…4 | Ring inner fill amount |
+| `noiseIterations` | `number` | no | `8` | editor range: 1…8; step: 1 | Number of noise layers, more layers gives more details |
diff --git a/skills/paper-shaders/references/shaders/spiral.md b/skills/paper-shaders/references/shaders/spiral.md
new file mode 100644
index 000000000..aac027e18
--- /dev/null
+++ b/skills/paper-shaders/references/shaders/spiral.md
@@ -0,0 +1,23 @@
+# Spiral
+
+Use the listed implementation types, options, capacities, exports, and defaults. Values labeled “editor range” come from the documentation UI definitions; the component does not clamp props to that range.
+
+A single-colored animated spiral that morphs across a wide range of shapes - from crisp, thin-lined geometry to flowing whirlpool forms and wavy, abstract rings.
+
+- React: `Spiral` and `spiralPresets` from `@paper-design/shaders-react`.
+- Vanilla: `spiralFragmentShader` and `SpiralParams` from `@paper-design/shaders`.
+- Common controls: sizing and motion. Defaults: speed=1, frame=0, fit="none", scale=1, rotation=0, offsetX=0, offsetY=0, originX=0.5, originY=0.5, worldWidth=0, worldHeight=0.
+- Source: `packages/shaders/src/shaders/spiral.ts`, `packages/shaders-react/src/shaders/spiral.tsx`, `docs/src/shader-defs/spiral-def.ts`.
+
+| Prop | Type | Required | React default | Constraints | Effect |
+| --- | --- | --- | --- | --- | --- |
+| `colorBack` | `string` | no | `"#001429"` | — | Background color in RGBA |
+| `colorFront` | `string` | no | `"#79D1FF"` | — | Foreground (ink) color in RGBA |
+| `density` | `number` | no | `1` | editor range: 0…1 | Spacing falloff simulating perspective, 0 = flat spiral |
+| `distortion` | `number` | no | `0` | editor range: 0…1 | Power of shape distortion applied along the spiral |
+| `strokeWidth` | `number` | no | `0.5` | editor range: 0…1 | Thickness of spiral curve |
+| `strokeTaper` | `number` | no | `0` | editor range: 0…1 | How much stroke loses width away from center, 0 = full visibility |
+| `strokeCap` | `number` | no | `0` | editor range: 0…1 | Extra stroke width at the center, no effect with strokeWidth = 0.5 |
+| `noise` | `number` | no | `0` | editor range: 0…1 | Noise distortion applied over the canvas, no effect with noiseFrequency = 0 |
+| `noiseFrequency` | `number` | no | `0` | editor range: 0…1 | Noise frequency, no effect with noise = 0 |
+| `softness` | `number` | no | `0` | editor range: 0…1 | Color transition sharpness, 0 = hard edge, 1 = smooth gradient |
diff --git a/skills/paper-shaders/references/shaders/static-mesh-gradient.md b/skills/paper-shaders/references/shaders/static-mesh-gradient.md
new file mode 100644
index 000000000..d1e70f872
--- /dev/null
+++ b/skills/paper-shaders/references/shaders/static-mesh-gradient.md
@@ -0,0 +1,22 @@
+# Static Mesh Gradient
+
+Use the listed implementation types, options, capacities, exports, and defaults. Values labeled “editor range” come from the documentation UI definitions; the component does not clamp props to that range.
+
+Multi-point mesh gradient with up to 10 color spots, enhanced by two-direction warping, adjustable blend sharpness, and grain controls.
+
+- React: `StaticMeshGradient` and `staticMeshGradientPresets` from `@paper-design/shaders-react`.
+- Vanilla: `staticMeshGradientFragmentShader` and `StaticMeshGradientParams` from `@paper-design/shaders`.
+- Common controls: sizing and motion. Defaults: speed=0, frame=0, fit="contain", scale=1, rotation=270, offsetX=0, offsetY=0, originX=0.5, originY=0.5, worldWidth=0, worldHeight=0.
+- Source: `packages/shaders/src/shaders/static-mesh-gradient.ts`, `packages/shaders-react/src/shaders/static-mesh-gradient.tsx`, `docs/src/shader-defs/static-mesh-gradient-def.ts`.
+
+| Prop | Type | Required | React default | Constraints | Effect |
+| --- | --- | --- | --- | --- | --- |
+| `colors` | `string[]` | no | `["#ffad0a","#6200ff","#e2a3ff","#ff99fd"]` | implementation capacity: 10 | Up to 10 gradient colors in RGBA |
+| `positions` | `number` | no | `2` | editor range: 0…100 | Color spots placement seed |
+| `waveX` | `number` | no | `1` | editor range: 0…1 | Strength of sine wave distortion along X axis |
+| `waveXShift` | `number` | no | `0.6` | editor range: 0…1 | Phase offset applied to the X-axis wave |
+| `waveY` | `number` | no | `1` | editor range: 0…1 | Strength of sine wave distortion along Y axis |
+| `waveYShift` | `number` | no | `0.21` | editor range: 0…1 | Phase offset applied to the Y-axis wave |
+| `mixing` | `number` | no | `0.93` | editor range: 0…1 | Blending behavior, 0 = hard stripes, 0.5 = smooth, 1 = gradual blend |
+| `grainMixer` | `number` | no | `0` | editor range: 0…1 | Strength of grain distortion applied to shape edges |
+| `grainOverlay` | `number` | no | `0` | editor range: 0…1 | Post-processing black/white grain overlay |
diff --git a/skills/paper-shaders/references/shaders/static-radial-gradient.md b/skills/paper-shaders/references/shaders/static-radial-gradient.md
new file mode 100644
index 000000000..c5c58e67a
--- /dev/null
+++ b/skills/paper-shaders/references/shaders/static-radial-gradient.md
@@ -0,0 +1,25 @@
+# Static Radial Gradient
+
+Use the listed implementation types, options, capacities, exports, and defaults. Values labeled “editor range” come from the documentation UI definitions; the component does not clamp props to that range.
+
+Radial gradient with up to 10 blended colors, featuring advanced mixing modes, focal point controls, shape distortion, and grain effects.
+
+- React: `StaticRadialGradient` and `staticRadialGradientPresets` from `@paper-design/shaders-react`.
+- Vanilla: `staticRadialGradientFragmentShader` and `StaticRadialGradientParams` from `@paper-design/shaders`.
+- Common controls: sizing and motion. Defaults: speed=0, frame=0, fit="contain", scale=1, rotation=0, offsetX=0, offsetY=0, originX=0.5, originY=0.5, worldWidth=0, worldHeight=0.
+- Source: `packages/shaders/src/shaders/static-radial-gradient.ts`, `packages/shaders-react/src/shaders/static-radial-gradient.tsx`, `docs/src/shader-defs/static-radial-gradient-def.ts`.
+
+| Prop | Type | Required | React default | Constraints | Effect |
+| --- | --- | --- | --- | --- | --- |
+| `colorBack` | `string` | no | `"#000000"` | — | Background color in RGBA |
+| `colors` | `string[]` | no | `["#00bbff","#00ffe1","#ffffff"]` | implementation capacity: 10 | Up to 10 gradient colors in RGBA |
+| `radius` | `number` | no | `0.8` | editor range: 0…3 | Size of the shape |
+| `focalDistance` | `number` | no | `0.99` | editor range: 0…3 | Distance of the focal point from center |
+| `focalAngle` | `number` | no | `0` | editor range: 0…360 | Angle of the focal point in degrees, effective with focalDistance > 0 |
+| `falloff` | `number` | no | `0.24` | editor range: -1…1 | Gradient decay, 0 = linear gradient |
+| `mixing` | `number` | no | `0.5` | editor range: 0…1 | Blending behavior, 0 = hard stripes, 1 = smooth gradient |
+| `distortion` | `number` | no | `0` | editor range: 0…1 | Strength of radial distortion |
+| `distortionShift` | `number` | no | `0` | editor range: -1…1 | Radial distortion offset, effective with distortion > 0 |
+| `distortionFreq` | `number` | no | `12` | editor range: 0…20; step: 1 | Radial distortion frequency, effective with distortion > 0 |
+| `grainMixer` | `number` | no | `0` | editor range: 0…1 | Strength of grain distortion applied to shape edges |
+| `grainOverlay` | `number` | no | `0` | editor range: 0…1 | Post-processing black/white grain overlay |
diff --git a/skills/paper-shaders/references/shaders/swirl.md b/skills/paper-shaders/references/shaders/swirl.md
new file mode 100644
index 000000000..10dc116f2
--- /dev/null
+++ b/skills/paper-shaders/references/shaders/swirl.md
@@ -0,0 +1,22 @@
+# Swirl
+
+Use the listed implementation types, options, capacities, exports, and defaults. Values labeled “editor range” come from the documentation UI definitions; the component does not clamp props to that range.
+
+Animated bands of color twisting and bending, producing spirals, arcs, and flowing circular patterns.
+
+- React: `Swirl` and `swirlPresets` from `@paper-design/shaders-react`.
+- Vanilla: `swirlFragmentShader` and `SwirlParams` from `@paper-design/shaders`.
+- Common controls: sizing and motion. Defaults: speed=0.32, frame=0, fit="contain", scale=1, rotation=0, offsetX=0, offsetY=0, originX=0.5, originY=0.5, worldWidth=0, worldHeight=0.
+- Source: `packages/shaders/src/shaders/swirl.ts`, `packages/shaders-react/src/shaders/swirl.tsx`, `docs/src/shader-defs/swirl-def.ts`.
+
+| Prop | Type | Required | React default | Constraints | Effect |
+| --- | --- | --- | --- | --- | --- |
+| `colorBack` | `string` | no | `"#330000"` | — | Background color in RGBA |
+| `colors` | `string[]` | no | `["#ffd1d1","#ff8a8a","#660000"]` | implementation capacity: 10 | Up to 10 stripe colors in RGBA |
+| `bandCount` | `number` | no | `4` | editor range: 0…15; step: 1 | Number of color bands, 0 = concentric ripples |
+| `twist` | `number` | no | `0.1` | editor range: 0…1 | Vortex power, 0 = straight sectoral shapes |
+| `center` | `number` | no | `0.2` | editor range: 0…1 | How far from the center the swirl colors begin to appear |
+| `proportion` | `number` | no | `0.5` | editor range: 0…1 | Blend point between colors, 0.5 = equal distribution |
+| `softness` | `number` | no | `0` | editor range: 0…1 | Color transition sharpness, 0 = hard edge, 1 = smooth gradient |
+| `noiseFrequency` | `number` | no | `0.4` | editor range: 0…1 | Noise frequency, no effect with noise = 0 |
+| `noise` | `number` | no | `0.2` | editor range: 0…1 | Strength of noise distortion, no effect with noiseFrequency = 0 |
diff --git a/skills/paper-shaders/references/shaders/voronoi.md b/skills/paper-shaders/references/shaders/voronoi.md
new file mode 100644
index 000000000..29d2f4ab2
--- /dev/null
+++ b/skills/paper-shaders/references/shaders/voronoi.md
@@ -0,0 +1,21 @@
+# Voronoi
+
+Use the listed implementation types, options, capacities, exports, and defaults. Values labeled “editor range” come from the documentation UI definitions; the component does not clamp props to that range.
+
+Anti-aliased animated Voronoi pattern with smooth and customizable edges.
+
+- React: `Voronoi` and `voronoiPresets` from `@paper-design/shaders-react`.
+- Vanilla: `voronoiFragmentShader` and `VoronoiParams` from `@paper-design/shaders`.
+- Common controls: sizing and motion. Defaults: speed=0.5, frame=0, fit="none", scale=0.5, rotation=0, offsetX=0, offsetY=0, originX=0.5, originY=0.5, worldWidth=0, worldHeight=0.
+- Vanilla requirements: create `u_noiseTexture` with `getShaderNoiseTexture()` and wait for the image to load before constructing `ShaderMount`.
+- Source: `packages/shaders/src/shaders/voronoi.ts`, `packages/shaders-react/src/shaders/voronoi.tsx`, `docs/src/shader-defs/voronoi-def.ts`.
+
+| Prop | Type | Required | React default | Constraints | Effect |
+| --- | --- | --- | --- | --- | --- |
+| `colors` | `string[]` | no | `["#ff8247","#ffe53d"]` | implementation capacity: 5 | Up to 5 base cell colors in RGBA |
+| `stepsPerColor` | `number` | no | `3` | editor range: 1…3; step: 1 | Number of extra colors between base colors, 1 = N colors, 2 = 2×N, etc. |
+| `colorGap` | `string` | no | `"#2e0000"` | — | Color used for cell borders/gaps in RGBA |
+| `colorGlow` | `string` | no | `"#ffffff"` | — | Color tint for radial inner shadow inside cells in RGBA, effective with glow > 0 |
+| `distortion` | `number` | no | `0.4` | editor range: 0…0.5 | Strength of noise-driven displacement of cell centers |
+| `gap` | `number` | no | `0.04` | editor range: 0…0.1 | Width of the border/gap between cells |
+| `glow` | `number` | no | `0` | editor range: 0…1 | Strength of the radial inner shadow inside cells |
diff --git a/skills/paper-shaders/references/shaders/warp.md b/skills/paper-shaders/references/shaders/warp.md
new file mode 100644
index 000000000..a4666e305
--- /dev/null
+++ b/skills/paper-shaders/references/shaders/warp.md
@@ -0,0 +1,23 @@
+# Warp
+
+Use the listed implementation types, options, capacities, exports, and defaults. Values labeled “editor range” come from the documentation UI definitions; the component does not clamp props to that range.
+
+Animated color fields warped by noise and swirls, applied over base patterns (checks, stripes, or split edge). Blends up to 10 colors with adjustable distribution, softness, distortion, and swirl. Great for fluid, smoky, or marbled effects.
+
+- React: `Warp` and `warpPresets` from `@paper-design/shaders-react`.
+- Vanilla: `warpFragmentShader` and `WarpParams` from `@paper-design/shaders`.
+- Common controls: sizing and motion. Defaults: speed=1, frame=0, fit="none", scale=1, rotation=0, offsetX=0, offsetY=0, originX=0.5, originY=0.5, worldWidth=0, worldHeight=0.
+- Vanilla requirements: create `u_noiseTexture` with `getShaderNoiseTexture()` and wait for the image to load before constructing `ShaderMount`.
+- Source: `packages/shaders/src/shaders/warp.ts`, `packages/shaders-react/src/shaders/warp.tsx`, `docs/src/shader-defs/warp-def.ts`.
+
+| Prop | Type | Required | React default | Constraints | Effect |
+| --- | --- | --- | --- | --- | --- |
+| `colors` | `string[]` | no | `["#121212","#9470ff","#121212","#8838ff"]` | implementation capacity: 10 | Up to 10 gradient colors in RGBA |
+| `rotation` | `number` | no | `0` | editor range: 0…360 | Overall rotation angle of the graphics in degrees |
+| `proportion` | `number` | no | `0.45` | editor range: 0…1 | Blend point between colors, 0.5 = equal distribution |
+| `softness` | `number` | no | `1` | editor range: 0…1 | Color transition sharpness, 0 = hard edge, 1 = smooth gradient |
+| `shape` | `WarpPattern` | no | `"checks"` | options: "checks", "stripes", "edge" | Base pattern type (0 = checks, 1 = stripes, 2 = edge) |
+| `shapeScale` | `number` | no | `0.1` | editor range: 0…1 | Zoom level of the base pattern |
+| `distortion` | `number` | no | `0.25` | editor range: 0…1 | Strength of noise-based distortion |
+| `swirl` | `number` | no | `0.8` | editor range: 0…1 | Strength of the swirl distortion |
+| `swirlIterations` | `number` | no | `10` | editor range: 0…20 | Number of layered swirl passes, effective with swirl > 0 |
diff --git a/skills/paper-shaders/references/shaders/water.md b/skills/paper-shaders/references/shaders/water.md
new file mode 100644
index 000000000..730a85c98
--- /dev/null
+++ b/skills/paper-shaders/references/shaders/water.md
@@ -0,0 +1,24 @@
+# Water
+
+Use the listed implementation types, options, capacities, exports, and defaults. Values labeled “editor range” come from the documentation UI definitions; the component does not clamp props to that range.
+
+Water-like surface distortion with natural caustic realism. Works as an image filter or standalone animated texture.
+
+- React: `Water` and `waterPresets` from `@paper-design/shaders-react`.
+- Vanilla: `waterFragmentShader` and `WaterParams` from `@paper-design/shaders`.
+- Common controls: sizing and motion. Defaults: speed=1, frame=0, fit="contain", scale=0.8, rotation=0, offsetX=0, offsetY=0, originX=0.5, originY=0.5, worldWidth=0, worldHeight=0.
+- Vanilla requirements: pass `["u_image"]` as the `ShaderMount` mipmaps argument.
+- Source: `packages/shaders/src/shaders/water.ts`, `packages/shaders-react/src/shaders/water.tsx`, `docs/src/shader-defs/water-def.ts`.
+
+| Prop | Type | Required | React default | Constraints | Effect |
+| --- | --- | --- | --- | --- | --- |
+| `image` | `HTMLImageElement \| string` | no | `""` | — | Optional source image texture |
+| `colorBack` | `string` | no | `"#909090"` | — | Background color in RGBA |
+| `colorHighlight` | `string` | no | `"#ffffff"` | — | Highlight color in RGBA |
+| `highlights` | `number` | no | `0.07` | editor range: 0…1 | Coloring added over image/background following caustic shape |
+| `layering` | `number` | no | `0.5` | editor range: 0…1 | Power of 2nd layer of caustic distortion |
+| `edges` | `number` | no | `0.8` | editor range: 0…1 | Caustic distortion power on the image edges |
+| `caustic` | `number` | no | `0.1` | editor range: 0…1 | Power of caustic distortion |
+| `waves` | `number` | no | `0.3` | editor range: 0…1 | Additional distortion based on simplex noise, independent from caustic |
+| `size` | `number` | no | `1` | editor range: 0.01…7 | Pattern scale relative to the image |
+| `effectScale` | `number` | no | — | — | React-only. @deprecated use `size` instead |
diff --git a/skills/paper-shaders/references/shaders/waves.md b/skills/paper-shaders/references/shaders/waves.md
new file mode 100644
index 000000000..a11207243
--- /dev/null
+++ b/skills/paper-shaders/references/shaders/waves.md
@@ -0,0 +1,23 @@
+# Waves
+
+Use the listed implementation types, options, capacities, exports, and defaults. Values labeled “editor range” come from the documentation UI definitions; the component does not clamp props to that range.
+
+Static line pattern configurable into textures ranging from sharp zigzags to smooth flowing waves.
+
+- React: `Waves` and `wavesPresets` from `@paper-design/shaders-react`.
+- Vanilla: `wavesFragmentShader` and `WavesParams` from `@paper-design/shaders`.
+- Common controls: sizing. Defaults: fit="none", scale=0.6, rotation=0, offsetX=0, offsetY=0, originX=0.5, originY=0.5, worldWidth=0, worldHeight=0.
+- React performance override: `maxPixelCount` defaults to `6016 * 3384`.
+- Source: `packages/shaders/src/shaders/waves.ts`, `packages/shaders-react/src/shaders/waves.tsx`, `docs/src/shader-defs/waves-def.ts`.
+
+| Prop | Type | Required | React default | Constraints | Effect |
+| --- | --- | --- | --- | --- | --- |
+| `colorFront` | `string` | no | `"#ffbb00"` | — | Foreground color in RGBA |
+| `colorBack` | `string` | no | `"#000000"` | — | Background color in RGBA |
+| `rotation` | `number` | no | `0` | editor range: 0…360 | Overall rotation angle of the graphics in degrees |
+| `shape` | `number` | no | `0` | editor range: 0…3 | Line shape, 0 = zigzag, 1 = sine, 2-3 = irregular waves, fractional values morph between shapes |
+| `frequency` | `number` | no | `0.5` | editor range: 0…2 | Wave frequency |
+| `amplitude` | `number` | no | `0.5` | editor range: 0…1 | Wave amplitude |
+| `spacing` | `number` | no | `1.2` | editor range: 0…2 | Space between every two wavy lines |
+| `proportion` | `number` | no | `0.1` | editor range: 0…1 | Blend point between front and back colors, 0.5 = equal distribution |
+| `softness` | `number` | no | `0` | editor range: 0…1 | Color transition sharpness, 0 = hard edge, 1 = smooth gradient |
diff --git a/skills/paper-shaders/references/usage.md b/skills/paper-shaders/references/usage.md
new file mode 100644
index 000000000..5a893a660
--- /dev/null
+++ b/skills/paper-shaders/references/usage.md
@@ -0,0 +1,285 @@
+# Paper Shaders usage
+
+This reference is derived from:
+
+- `packages/shaders/src/index.ts`
+- `packages/shaders/src/shader-mount.ts`
+- `packages/shaders/src/shader-sizing.ts`
+- `packages/shaders/src/get-shader-color-from-string.ts`
+- `packages/shaders-react/src/index.ts`
+- `packages/shaders-react/src/shader-mount.tsx`
+- `packages/shaders-react/src/shaders/*.tsx`
+
+Read the matching file under [Shader references](../SKILL.md#shader-references) for shader-specific props, defaults, ranges, enum options, exports, capacities, and special vanilla requirements.
+
+## Contents
+
+- [Packages](#packages)
+- [React](#react)
+ - [Presets](#presets)
+ - [Low-level React mount](#low-level-react-mount)
+- [Vanilla](#vanilla)
+ - [Convert React props to vanilla uniforms](#convert-react-props-to-vanilla-uniforms)
+ - [Noise textures](#noise-textures)
+ - [Images](#images)
+ - [Preprocessed image shaders](#preprocessed-image-shaders)
+ - [Color syntax](#color-syntax)
+- [Common sizing and motion](#common-sizing-and-motion)
+- [Verification](#verification)
+
+## Packages
+
+- React: `@paper-design/shaders-react`
+- Vanilla JavaScript or TypeScript: `@paper-design/shaders`
+
+The repository README asks consumers to pin the dependency because breaking changes may ship under `0.0.x` versioning. Match the project's existing package manager and version policy.
+
+## React
+
+Use the named component exported by `@paper-design/shaders-react`.
+
+```tsx
+import { MeshGradient } from '@paper-design/shaders-react';
+
+export function HeroShader() {
+ return (
+
+ );
+}
+```
+
+Every named shader component accepts its shader-specific params plus the common component controls:
+
+- ordinary `div` props except `color` and `ref`
+- `ref?: React.Ref`
+- `width?: string | number`
+- `height?: string | number`
+- `minPixelRatio?: number`
+- `maxPixelCount?: number`
+- `webGlContextAttributes?: WebGLContextAttributes`
+
+`width` and `height` become inline styles. Other layout styles can be passed through `style`. Ensure the element resolves to a non-zero width and height.
+
+### Presets
+
+Each named React component has a corresponding exported preset array, such as `meshGradientPresets`. Preset `params` contain the shader params plus all sizing defaults and, when the shader supports motion, `speed` and `frame`. Image presets intentionally omit `image`; React-only component controls are also outside preset `params`.
+
+```tsx
+import { MeshGradient, meshGradientPresets } from '@paper-design/shaders-react';
+
+const preset = meshGradientPresets[0].params;
+
+;
+```
+
+Use the catalog's exact preset export name.
+
+### Low-level React mount
+
+`ShaderMount` from `@paper-design/shaders-react` accepts:
+
+- `fragmentShader`
+- `uniforms`
+- `speed` and `frame`
+- `mipmaps`
+- `minPixelRatio` and `maxPixelCount`
+- `webGlContextAttributes`
+- `width`, `height`, and ordinary supported `div` props
+
+String uniform values are treated as image URLs, not arbitrary string uniforms. Prefer named components for package shaders because they construct and convert uniforms correctly.
+
+## Vanilla
+
+The vanilla package exports fragment shader source and `ShaderMount`; it does not provide named convenience mount functions. Construct the complete uniform object yourself.
+Create and dispose the mount in a browser/client lifecycle: the class requires DOM, `navigator`, canvas, and WebGL APIs.
+
+```ts
+import {
+ ShaderFitOptions,
+ ShaderMount,
+ getShaderColorFromString,
+ meshGradientFragmentShader,
+ type MeshGradientUniforms,
+} from '@paper-design/shaders';
+
+const host = document.querySelector('#shader');
+if (!host) throw new Error('Missing #shader element');
+
+const colors = ['#e0eaff', '#241d9a', '#f75092', '#9f50d3'];
+
+const uniforms: MeshGradientUniforms = {
+ u_colors: colors.map(getShaderColorFromString),
+ u_colorsCount: colors.length,
+ u_distortion: 0.8,
+ u_swirl: 0.1,
+ u_grainMixer: 0,
+ u_grainOverlay: 0,
+ u_fit: ShaderFitOptions.contain,
+ u_scale: 1,
+ u_rotation: 0,
+ u_offsetX: 0,
+ u_offsetY: 0,
+ u_originX: 0.5,
+ u_originY: 0.5,
+ u_worldWidth: 0,
+ u_worldHeight: 0,
+};
+
+const mount = new ShaderMount(
+ host,
+ meshGradientFragmentShader,
+ uniforms,
+ undefined,
+ 1,
+ 0
+);
+
+mount.setUniforms({ u_distortion: 0.5 });
+mount.setSpeed(0.5);
+
+// Call during teardown:
+mount.dispose();
+```
+
+The constructor arguments, in order, are:
+
+1. parent `HTMLElement`
+2. fragment shader string
+3. initial uniform object
+4. optional `WebGLContextAttributes`
+5. speed, default `0`
+6. frame, default `0`
+7. minimum pixel ratio, default `2`
+8. maximum pixel count, default `1920 * 1080 * 4`
+9. uniform names that require mipmaps, default `[]`
+
+The parent receives a prepended canvas, `data-paper-shader`, and `paperShaderMount`. `dispose()` removes the canvas and WebGL resources.
+
+`ShaderMount` supports partial updates with `setUniforms`, and also exposes `getCurrentFrame`, `setFrame`, `setSpeed`, `setMinPixelRatio`, and `setMaxPixelCount`.
+
+### Convert React props to vanilla uniforms
+
+Follow the named React component's `uniforms` object. Apply these source-defined rules:
+
+- Convert each color string with `getShaderColorFromString`.
+- Convert `colors` with `.map(getShaderColorFromString)` and also set `u_colorsCount`.
+- Convert `fit` with `ShaderFitOptions[fit]`.
+- Convert enum props with their exported mapping object, such as `WarpPatterns[shape]`.
+- Pass `speed` and `frame` to `ShaderMount`; they are not shader-specific uniforms.
+- Map common sizing props to `u_fit`, `u_scale`, `u_rotation`, `u_offsetX`, `u_offsetY`, `u_originX`, `u_originY`, `u_worldWidth`, and `u_worldHeight`.
+- Pass booleans as booleans. `ShaderMount` converts them to integer uniforms.
+- Pass textures as fully loaded `HTMLImageElement` instances.
+
+Do not assume every prop becomes `u_${prop}`. These package mappings differ:
+
+- `Dithering.size` and `ImageDithering.size` → `u_pxSize`
+- `DotGrid.size` → `u_dotSize`
+- `FlutedGlass.margin` and `PulsingBorder.margin` → defaults for all four side-specific margin uniforms; an explicitly supplied side value wins
+- `width`, `height`, `minPixelRatio`, `maxPixelCount`, `webGlContextAttributes`, and `ref` configure the mount/container and are not uniforms
+- `suspendWhenProcessingImage` and deprecated compatibility props are React-only
+
+Always inspect the catalog's source paths when constructing a vanilla shader. The uniform interfaces in `packages/shaders/src/shaders/*.ts` list the complete required uniform object.
+
+### Noise textures
+
+For shaders whose catalog says to set `u_noiseTexture`, import `getShaderNoiseTexture` and include:
+
+```ts
+const noiseTexture = getShaderNoiseTexture();
+if (!noiseTexture) throw new Error('Noise textures require a browser');
+await noiseTexture.decode();
+
+// Include in the initial uniforms:
+u_noiseTexture: noiseTexture
+```
+
+The source returns `undefined` outside the browser and a newly created `HTMLImageElement` in the browser. Wait for it to load before constructing the vanilla `ShaderMount`; the mount rejects incomplete images. The React mount performs this wait itself.
+
+### Images
+
+The low-level vanilla mount accepts `HTMLImageElement`, not URL strings. The image must be fully loaded and have a non-zero `naturalWidth`; otherwise mounting the texture throws.
+
+The React mount accepts an `HTMLImageElement` or a string that is either:
+
+- an absolute path beginning with `/`
+- a URL accepted by `new URL(value)`
+- an empty string, which becomes a transparent pixel
+
+For an external URL, the React loader sets `crossOrigin = "anonymous"`. When both natural dimensions are below 1024, it sets the image dimensions so the shorter side is 1024 before upload.
+
+When a texture is supplied as `u_image`, `ShaderMount` automatically looks up and fills `u_imageAspectRatio`.
+
+Pass `["u_image"]` as the final vanilla constructor argument when the catalog requires mipmaps.
+
+### Preprocessed image shaders
+
+`Heatmap`, `LiquidMetal`, and `GemSmoke` preprocess image inputs in their React components. Reproduce this in vanilla:
+
+- `toProcessedHeatmap(fileOrUrl)` returns `Promise<{ blob: Blob }>`
+- `toProcessedLiquidMetal(fileOrUrl)` returns `Promise<{ imageData: ImageData; pngBlob: Blob }>`
+- `toProcessedGemSmoke(fileOrUrl)` returns `Promise<{ imageData: ImageData; pngBlob: Blob }>`
+
+Each function accepts `File | string` and requires browser document/canvas APIs. Load the returned blob into an `HTMLImageElement`, pass that loaded image as `u_image`, and enable mipmaps for `u_image`.
+
+For `LiquidMetal` and `GemSmoke`, also set `u_isImage` from whether an original image was supplied and convert `shape` with `LiquidMetalShapes` or `GemSmokeShapes`. When no original image is supplied, load the exported `emptyPixel` into an `HTMLImageElement`, use that placeholder as the initial `u_image`, and set `u_isImage` to `false`; the shader still declares and samples `u_image`.
+
+### Color syntax
+
+`getShaderColorFromString` supports:
+
+- 3-, 4-, 6-, and 8-digit hex
+- comma-form `rgb(...)` and `rgba(...)`
+- comma-form `hsl(...)` and `hsla(...)`
+- already-normalized RGB or RGBA number tuples when calling the utility directly
+
+It does not parse CSS named colors. Invalid values fall back to `[0.5, 0.5, 0.5, 1]`.
+
+## Common sizing and motion
+
+All named shader components accept:
+
+- `fit?: "none" | "contain" | "cover"`
+- `scale?: number`
+- `rotation?: number`
+- `originX?: number`
+- `originY?: number`
+- `offsetX?: number`
+- `offsetY?: number`
+- `worldWidth?: number`
+- `worldHeight?: number`
+
+Defaults come from one of two source objects, then may be overridden by the component's default preset:
+
+- `defaultObjectSizing`: `fit="contain"` and otherwise `scale=1`, `rotation=0`, offsets `0`, origins `0.5`, and world dimensions `0`
+- `defaultPatternSizing`: the same values except `fit="none"`
+
+The catalog lists the effective defaults for every shader.
+
+Shaders whose params extend `ShaderMotionParams` also accept:
+
+- `speed?: number`: `0` stops the animation loop; negative values play backward
+- `frame?: number`: animation position in milliseconds; the shader receives seconds as `u_time`
+
+The mount pauses animated rendering when the document is hidden. It also pauses when the element leaves the viewport when `IntersectionObserver` is available in the element's window.
+
+`minPixelRatio` defaults to `2`. `maxPixelCount` defaults to `1920 * 1080 * 4` physical pixels. The `Waves` React component overrides its `maxPixelCount` default to `6016 * 3384`.
+The `DotGrid` React component uses the same `6016 * 3384` override.
+
+## Verification
+
+- Type-check against the installed package version.
+- Confirm the mount has a non-zero layout size.
+- Confirm all initial vanilla uniforms are present.
+- Confirm color arrays are non-empty and do not exceed the implementation capacity in the catalog.
+- Confirm image inputs load and satisfy CORS rules.
+- Confirm preprocessors run only in a browser environment.
+- Call `dispose()` for vanilla mounts during teardown.