diff --git a/AGENTS.md b/AGENTS.md index 4bd1986d..41dd13d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ Milestone history lives in git tags and `docs/superpowers/plans/`. Don't trust a ## Project shape (30-second version) -- **Three-tier model.** Tier 1 is the polished components such as ``, which live under `packages/shaders/src/components/` and are imported from the package root. Tier 2 is the TSL primitives in the same package, such as `fractalNoise` and `voronoi`. Tier 3 is recipes: TSL snippets in the docs site. +- **Two-tier model.** Tier 1 is the polished components such as ``, which live under `packages/shaders/src/components/` and are imported from the package root. Tier 2 is the TSL primitives in the same package, such as `fractalNoise` and `voronoi`. They are exported because the editor's eject-to-code imports them by name, but they have no doc pages (SHA-136). - **Two packages.** `@camp-dev/shaders` is everything users import: the Tier 1 components, the React binding (`ShaderScene`, `useShaderMaterial`, and the hooks), and the framework-free engine (primitives, renderer, scheduler, inputs). `@camp-dev/shaders-cli` has one command, `poster`. Inside the package, `src/engine.ts` is the framework-free barrel. `src/react/` and `src/components/` import it rather than the root index, so dependencies run root to components to react to engine and never back. A `no-restricted-imports` block in the root `eslint.config.js`, scoped to `src/{primitives,runtime,inputs}/**` and `src/color.ts`, rejects React and anything under `src/react` or `src/components`. That rule is what keeps the engine extractable if a second framework binding ever becomes real. Two apps sit alongside the packages: `@shaders/docs` is the docs site, and `@shaders/editor` is the node editor from MAT-94. The editor is a React Flow canvas over the same Tier 2 primitives, with an eject-to-code emitter. A permanent parity gate pixel-compares that emitter's output against the live compiler. - **The editor app is layered by dependency direction**, not by file type. `src/editor/graph/` is the framework-free core, holding the node registry, graph model, param store, live TSL compiler, and code emitter. `src/editor/preset/` handles save, load, undo, and copy-paste, and is also React-free. `src/editor/state/` is the React Flow glue. `canvas/`, `params/`, and `panels/` are UI. Dependencies point one way, toward `graph/`. Siblings import each other as `./x` and everything else as `@/editor//`. `vitest.config.ts` has to declare that `@` alias itself, because Vitest doesn't read tsconfig `paths`. - **Two rendering modes**, with no auto-detection of `@react-three/fiber`. In Mode 1 every Tier 1 component is bare and requires an explicit `` wrap, and you compose by stacking children in one scene. In Mode 2 you call `useShaderMaterial` inside your own r3f ``. diff --git a/apps/docs-tests/a11y/component-pages.spec.ts b/apps/docs-tests/a11y/component-pages.spec.ts index 887584eb..b05238a1 100644 --- a/apps/docs-tests/a11y/component-pages.spec.ts +++ b/apps/docs-tests/a11y/component-pages.spec.ts @@ -16,7 +16,6 @@ const routes = [ '/components/vignette', '/components/voronoi', '/components/wave-lines', - '/recipes', ]; for (const route of routes) { diff --git a/apps/docs/content/docs/examples.mdx b/apps/docs/content/docs/examples.mdx index 7ba6b751..b25731cb 100644 --- a/apps/docs/content/docs/examples.mdx +++ b/apps/docs/content/docs/examples.mdx @@ -13,7 +13,6 @@ This page is a placeholder. Curated examples — hero sections, section backgrou In the meantime: - The [component pages](/components) each include a live demo, a props playground, and the usage snippet to paste into your app. -- The [primitives pages](/primitives) show the lower-level TSL building blocks. If you want to write your own shader, start there. - The [Shared scenes guide](/guides/shared-scenes) shows how to combine multiple Shaders components inside one ``. If you build something with Shaders, [open a PR on GitHub](https://github.com/campdotdev/shaders) — selected examples will land on this page. diff --git a/apps/docs/content/docs/getting-started.mdx b/apps/docs/content/docs/getting-started.mdx index e73baa83..8d984382 100644 --- a/apps/docs/content/docs/getting-started.mdx +++ b/apps/docs/content/docs/getting-started.mdx @@ -23,7 +23,7 @@ Requirements: - React 19 - Three.js `^0.170` -- Next.js 15+ if you're using one (Shaders doesn't require Next, but the docs and recipes assume it) +- Next.js 15+ if you're using one (Shaders doesn't require Next, but the docs assume it) ## Render a component diff --git a/apps/docs/content/docs/reference/shaders.mdx b/apps/docs/content/docs/reference/shaders.mdx index 18b1b0cf..0c8c08b5 100644 --- a/apps/docs/content/docs/reference/shaders.mdx +++ b/apps/docs/content/docs/reference/shaders.mdx @@ -11,7 +11,7 @@ This page lists the engine half of `@camp-dev/shaders`: the TSL primitives, the ## Tier 2 primitives -TSL building blocks for writing shader expressions. Each primitive is documented individually under [/primitives](/primitives) with a live demo and parameter sliders. +TSL building blocks for writing shader expressions, all exported from the package root. - `colorRamp(t, stops)` — multi-stop color gradient sampled at `t` ∈ [0, 1]. - `simplexNoise(uv, opts?)` — single-octave 2D value noise. diff --git a/apps/docs/src/app/primitives/[slug]/page.tsx b/apps/docs/src/app/primitives/[slug]/page.tsx deleted file mode 100644 index 00064189..00000000 --- a/apps/docs/src/app/primitives/[slug]/page.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import Link from 'next/link'; -import { notFound } from 'next/navigation'; - -import { CodeBlock } from '@/components/code-block/code-block'; -import { PrimitiveDemo } from '@/components/PrimitiveDemo'; -import { PRIMITIVES } from '@/data/primitives'; - -export function generateStaticParams() { - return PRIMITIVES.map((p) => ({ slug: p.slug })); -} - -interface PrimitivePageProps { - params: Promise<{ slug: string }>; -} - -export default async function PrimitivePage({ params }: PrimitivePageProps) { - const { slug } = await params; - const prim = PRIMITIVES.find((p) => p.slug === slug); - - if (!prim) notFound(); - - return ( -
-

- primitives / {prim.slug} -

-

{prim.name}()

-

{prim.description}

- -

Signature

- - {prim.usedBy.length > 0 && ( - <> -

Used by

-
    - {prim.usedBy.map((cslug) => ( -
  • - <{cslug}> -
  • - ))} -
- - )} -
- ); -} diff --git a/apps/docs/src/app/primitives/layout.tsx b/apps/docs/src/app/primitives/layout.tsx deleted file mode 100644 index 353d5e72..00000000 --- a/apps/docs/src/app/primitives/layout.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import type { ReactNode } from 'react'; - -import { DocsShell } from '@/components/docs-shell/docs-shell'; - -export default function PrimitivesLayout({ children }: { children: ReactNode }) { - return {children}; -} diff --git a/apps/docs/src/app/primitives/page.tsx b/apps/docs/src/app/primitives/page.tsx deleted file mode 100644 index 8aa971be..00000000 --- a/apps/docs/src/app/primitives/page.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import Link from 'next/link'; - -import { PRIMITIVES } from '@/data/primitives'; - -export const metadata = { - title: 'Primitives', - description: - 'Tier 2 — pure TSL functions exported from @camp-dev/shaders. Compose them into your own shaders.', -}; - -export default function PrimitivesIndex() { - return ( -
-

Primitives

-

- Tier 2 — pure TSL functions exported from @camp-dev/shaders. Use them inside - your own shaders or compose them into Tier 1 components. -

-
    - {PRIMITIVES.map((p) => ( -
  • - {p.name} - - — {p.description} - -
  • - ))} -
-
- ); -} diff --git a/apps/docs/src/app/recipes/[slug]/page.tsx b/apps/docs/src/app/recipes/[slug]/page.tsx deleted file mode 100644 index d85a2206..00000000 --- a/apps/docs/src/app/recipes/[slug]/page.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import Link from 'next/link'; -import { notFound } from 'next/navigation'; - -import { CodeBlock } from '@/components/code-block/code-block'; -import { RecipeViewer } from '@/components/RecipeViewer'; -import { RECIPES } from '@/data/recipes'; - -export function generateStaticParams() { - return RECIPES.map((r) => ({ slug: r.slug })); -} - -interface RecipePageProps { - // Next 15: dynamic-route params is a Promise. - params: Promise<{ slug: string }>; -} - -export default async function RecipePage({ params }: RecipePageProps) { - const { slug } = await params; - const recipe = RECIPES.find((r) => r.slug === slug); - - if (!recipe) notFound(); - - const canonicalVariant = recipe.variants[0]; - - if (!canonicalVariant) notFound(); - - return ( -
-

- recipes / {recipe.slug} -

-

{recipe.name}

-

{recipe.description}

- -

Source

- - {recipe.variants.length > 1 && ( -
-

Variants

-

- Same recipe, different parameters. Each card's caption describes the one-line - change to the source above. -

-
- {recipe.variants.map((v) => ( -
-
- -
-

{v.label}

-

- {v.note} -

-
- ))} -
-
- )} - {recipe.primitivesUsed.length > 0 && ( - <> -

Primitives used

-
    - {recipe.primitivesUsed.map((pslug) => ( -
  • - {pslug} -
  • - ))} -
- - )} -
- ); -} diff --git a/apps/docs/src/app/recipes/_builds.ts b/apps/docs/src/app/recipes/_builds.ts deleted file mode 100644 index 77a878a9..00000000 --- a/apps/docs/src/app/recipes/_builds.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { - colorRamp, - type ColorRampStop, - elapsedTime, - fractalNoise, - quantize, - voronoi, -} from '@camp-dev/shaders'; -import type UniformNode from 'three/src/nodes/core/UniformNode.js'; -import { length, max, sin, smoothstep, uv, vec2, vec3, vec4 } from 'three/tsl'; -import type { ShaderNodeObject } from 'three/tsl'; -import type { Node } from 'three/webgpu'; -import type { Vector2 } from 'three/webgpu'; - -export type RecipeBuild = (deps: { - cursorUniform: ShaderNodeObject>; -}) => ShaderNodeObject; - -/** Shared tail for all animated-stripes variants: same 2-stop ramp, sin→normalizedStripe→colorRamp→vec4. */ -function stripeOutput(phase: ShaderNodeObject): ShaderNodeObject { - const stripe = sin(phase) as ShaderNodeObject; - const normalizedStripe = stripe.mul(0.5).add(0.5).clamp(0, 1) as ShaderNodeObject; - const stops: ColorRampStop[] = [ - { color: vec3(1, 0.5, 0.4), position: 0 }, - { color: vec3(0.4, 0.6, 1), position: 1 }, - ]; - const rampColor = colorRamp(normalizedStripe, stops); - - return vec4(rampColor, 1); -} - -/** Shared FBM base for plasma variants: time-driven uv drift → normalized 0..1. */ -function plasmaBase(): ShaderNodeObject { - const scrolledTime = elapsedTime.mul(0.3); - const samplePosition = (uv() as ShaderNodeObject) - .mul(2) - .add(vec2(scrolledTime, scrolledTime)) as ShaderNodeObject; - - return fractalNoise(samplePosition, { octaves: 4 }).mul(0.5).add(0.5).clamp(0, 1); -} - -export const RECIPE_BUILDS: Record = { - // ─── animated-stripes ───────────────────────────────────────────────── - - 'animated-stripes.canonical': () => { - const scrolledTime = elapsedTime.mul(2); - const phase = (uv() as ShaderNodeObject).x - .mul(20) - .add(scrolledTime) as ShaderNodeObject; - - return stripeOutput(phase); - }, - - 'animated-stripes.diagonal': () => { - const scrolledTime = elapsedTime.mul(2); - const yPhase = (uv() as ShaderNodeObject).y.mul(8) as ShaderNodeObject; - const phase = (uv() as ShaderNodeObject).x - .mul(20) - .add(yPhase) - .add(scrolledTime) as ShaderNodeObject; - - return stripeOutput(phase); - }, - - 'animated-stripes.pulse': () => { - const pulseTime = sin(elapsedTime).mul(2) as ShaderNodeObject; - const phase = (uv() as ShaderNodeObject).x - .mul(8) - .add(pulseTime) as ShaderNodeObject; - - return stripeOutput(phase); - }, - - // ─── cursor-glow ────────────────────────────────────────────────────── - - 'cursor-glow.circular': ({ cursorUniform }) => { - const offset = (uv() as ShaderNodeObject).sub(cursorUniform) as ShaderNodeObject; - const distance = length(offset) as ShaderNodeObject; - const glow = smoothstep(0.3, 0, distance) as ShaderNodeObject; - - return vec4(glow, glow.mul(0.7), glow.mul(1.5), 1); - }, - - 'cursor-glow.square': ({ cursorUniform }) => { - const dx = (uv() as ShaderNodeObject).x - .sub(cursorUniform.x) - .abs() as ShaderNodeObject; - const dy = (uv() as ShaderNodeObject).y - .sub(cursorUniform.y) - .abs() as ShaderNodeObject; - const distance = max(dx, dy) as ShaderNodeObject; - const glow = smoothstep(0.3, 0, distance) as ShaderNodeObject; - - return vec4(glow, glow.mul(0.7), glow.mul(1.5), 1); - }, - - 'cursor-glow.pinpoint': ({ cursorUniform }) => { - const offset = (uv() as ShaderNodeObject).sub(cursorUniform) as ShaderNodeObject; - const distance = length(offset) as ShaderNodeObject; - const glow = smoothstep(0.1, 0, distance) as ShaderNodeObject; - - return vec4(glow, glow, glow, 1); - }, - - // ─── plasma ─────────────────────────────────────────────────────────── - - 'plasma.canonical': () => { - const noiseValue = plasmaBase(); - const stops: ColorRampStop[] = [ - { color: vec3(0.4, 0, 0.8), position: 0 }, - { color: vec3(1, 0.4, 0.6), position: 0.5 }, - { color: vec3(0.4, 0.9, 1), position: 1 }, - ]; - const rampColor = colorRamp(noiseValue, stops); - - return vec4(rampColor, 1); - }, - - 'plasma.monochrome-marble': () => { - const noiseValue = plasmaBase(); - const stops: ColorRampStop[] = [ - { color: vec3(0.05, 0.05, 0.1), position: 0 }, - { color: vec3(0.85, 0.85, 0.9), position: 1 }, - ]; - const rampColor = colorRamp(noiseValue, stops); - - return vec4(rampColor, 1); - }, - - // ─── cellular-tiles ─────────────────────────────────────────────────── - - 'cellular-tiles.canonical': () => { - const samplePosition = (uv() as ShaderNodeObject).mul(8) as ShaderNodeObject; - const cells = voronoi(samplePosition); - const tiered = quantize(cells, 4); - - return vec4(tiered, tiered.mul(0.7), tiered.mul(0.5), 1); - }, - - 'cellular-tiles.coarse-mosaic': () => { - const samplePosition = (uv() as ShaderNodeObject).mul(4) as ShaderNodeObject; - const cells = voronoi(samplePosition); - const tiered = quantize(cells, 3); - - return vec4(tiered.mul(0.6), tiered.mul(0.7), tiered, 1); - }, - - 'cellular-tiles.fine-stained-glass': () => { - const samplePosition = (uv() as ShaderNodeObject).mul(14) as ShaderNodeObject; - const cells = voronoi(samplePosition); - const tiered = quantize(cells, 8); - const stops: ColorRampStop[] = [ - { color: vec3(0.5, 0.05, 0.3), position: 0 }, // ruby - { color: vec3(0.1, 0.2, 0.6), position: 0.33 }, // sapphire - { color: vec3(0.05, 0.5, 0.4), position: 0.66 }, // emerald - { color: vec3(0.9, 0.75, 0.2), position: 1 }, // amber - ]; - const rampColor = colorRamp(tiered, stops); - - return vec4(rampColor, 1); - }, -}; diff --git a/apps/docs/src/app/recipes/page.tsx b/apps/docs/src/app/recipes/page.tsx deleted file mode 100644 index b20825b4..00000000 --- a/apps/docs/src/app/recipes/page.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import Link from 'next/link'; - -import { RECIPES } from '@/data/recipes'; - -export default function RecipesIndex() { - return ( -
-

Recipes

-

- Tier 3 — short TSL snippets that combine{' '} - - primitives - - . Copy-paste into your own component. -

-
    - {RECIPES.map((r) => ( -
  • - {/* Underlined for axe's link-in-text-block rule: on the dark palette - the lime link is under 3:1 against the muted text beside it, so - color alone cannot mark it as a link. Same fix as the primitives - link above. */} - - {r.name} - - - — {r.description} - -
  • - ))} -
-
- ); -} diff --git a/apps/docs/src/components/PrimitiveDemo.tsx b/apps/docs/src/components/PrimitiveDemo.tsx deleted file mode 100644 index 69cc8cc5..00000000 --- a/apps/docs/src/components/PrimitiveDemo.tsx +++ /dev/null @@ -1,61 +0,0 @@ -'use client'; - -import dynamic from 'next/dynamic'; -import { useCallback, useMemo, useState } from 'react'; - -import type { PrimitiveControl } from '@/data/primitives'; - -import { buildPrimitiveParams, initialStateFromSchema } from './primitive-params'; -import { type PropSchema, PropsPlayground, type PropsState } from './PropsPlayground'; - -interface PrimitiveDemoProps { - slug: string; - controls: readonly PrimitiveControl[]; -} - -const PrimitiveScene = dynamic(() => import('./PrimitiveScene').then((m) => m.PrimitiveScene), { - ssr: false, -}); - -const buildSchema = (controls: readonly PrimitiveControl[]): PropSchema => - controls.map((control) => ({ - name: control.name, - type: 'number' as const, - default: control.default, - min: control.min, - max: control.max, - step: control.step, - })); - -export function PrimitiveDemo({ slug, controls }: PrimitiveDemoProps) { - const schema = buildSchema(controls); - const [params, setParams] = useState(() => initialStateFromSchema(schema)); - - const primitive = useMemo(() => buildPrimitiveParams(slug, params), [slug, params]); - - const handleChange = useCallback((next: PropsState) => { - setParams(next); - }, []); - - return ( -
-
- -
- {schema.length > 0 && ( -
- -
- )} -
- ); -} diff --git a/apps/docs/src/components/PrimitiveScene.tsx b/apps/docs/src/components/PrimitiveScene.tsx deleted file mode 100644 index dd18af8f..00000000 --- a/apps/docs/src/components/PrimitiveScene.tsx +++ /dev/null @@ -1,222 +0,0 @@ -'use client'; - -import { useEffect, useMemo } from 'react'; - -import { - colorRamp, - type ColorRampStop, - cursorRipple, - displace, - elapsedTime, - fractalNoise, - mixColor, - quantize, - ShaderScene, - signedDistanceFieldCircle, - simplexNoise, - useShaderContext, - voronoi, -} from '@camp-dev/shaders'; -import type { ShaderNodeObject } from 'three/tsl'; -import { mix, sin, smoothstep, uniform, uv, vec2, vec3, vec4 } from 'three/tsl'; -import { Vector2 } from 'three/webgpu'; -import type { Node } from 'three/webgpu'; - -import { addPlaneMesh } from '@/lib/meshUtils'; - -import type { PrimitiveParams } from './primitive-params'; - -// Per-variant alias so builder signatures stay short. -type ParamsFor = Extract; - -interface PrimitiveSceneProps { - primitive: PrimitiveParams; -} - -const buildStructuralKey = (primitive: PrimitiveParams): string => - JSON.stringify(primitive, Object.keys(primitive).sort()); - -// === Primitive demo builders === - -function buildColorRamp(params: ParamsFor<'color-ramp'>): ShaderNodeObject { - const stops: ColorRampStop[] = [ - { color: vec3(1, 0.4, 0.4), position: 0 }, - { color: vec3(0.4, 1, 0.4), position: 0.5 }, - { color: vec3(0.4, 0.4, 1), position: 1 }, - ]; - - const baseColor = colorRamp(uv().x, stops); - const distFromMarker = uv().x.sub(params.position).abs(); - const marker = smoothstep(0.01, 0, distFromMarker); - - const lit = mix(baseColor, vec3(1, 1, 1), marker.mul(0.6)); - - return vec4(lit, 1); -} - -function buildMixColor(params: ParamsFor<'mix-color'>): ShaderNodeObject { - const mixed = mixColor(vec3(1, 0.2, 0.1), vec3(0.15, 0.35, 1), uv().x); - const distFromMarker = uv().x.sub(params.t).abs(); - const marker = smoothstep(0.01, 0, distFromMarker); - const lit = mix(mixed, vec3(1, 1, 1), marker.mul(0.6)); - - return vec4(lit, 1); -} - -function buildNoise(params: ParamsFor<'noise'>): ShaderNodeObject { - const scaledTime = elapsedTime.mul(params.speed); - const samplePosition = uv().mul(params.scale).add(vec2(scaledTime, scaledTime)); - const noiseValue = simplexNoise(samplePosition); - // noise returns ~[-1, 1]; map to [0, 1] grayscale. - const grayscale = noiseValue.mul(0.5).add(0.5).clamp(0, 1); - - return vec4(grayscale, grayscale, grayscale, 1); -} - -function buildFbm(params: ParamsFor<'fbm'>): ShaderNodeObject { - const scaledTime = elapsedTime.mul(params.speed); - const samplePosition = uv().mul(params.scale).add(vec2(scaledTime, scaledTime)); - const noiseValue = fractalNoise(samplePosition, { - octaves: params.octaves, - lacunarity: params.lacunarity, - gain: params.gain, - }); - const grayscale = noiseValue.mul(0.5).add(0.5).clamp(0, 1); - - return vec4(grayscale, grayscale, grayscale, 1); -} - -function buildVoronoi(params: ParamsFor<'voronoi'>): ShaderNodeObject { - const scaledTime = elapsedTime.mul(params.speed); - const samplePosition = uv().mul(params.scale).add(vec2(scaledTime, scaledTime)); - const voronoiValue = voronoi(samplePosition); - // voronoi (mx_worley_noise_float) is roughly [0, 1]; clamp to be safe. - const grayscale = voronoiValue.clamp(0, 1); - - return vec4(grayscale, grayscale, grayscale, 1); -} - -function buildQuantize(params: ParamsFor<'quantize'>): ShaderNodeObject { - const bins = Math.max(2, Math.round(params.bins)); - const quantizedValue = quantize(uv().x, bins); - const stops: ColorRampStop[] = [ - { color: vec3(0.15, 0.2, 0.4), position: 0 }, - { color: vec3(0.6, 0.4, 0.9), position: 0.5 }, - { color: vec3(1, 0.7, 0.4), position: 1 }, - ]; - const rampColor = colorRamp(quantizedValue, stops); - - return vec4(rampColor, 1); -} - -function buildSdfCircle(params: ParamsFor<'sdf-circle'>): ShaderNodeObject { - const samplePosition = uv().sub(vec2(params.cx, params.cy)); - const sdf = signedDistanceFieldCircle(samplePosition, params.radius); - const antialiasWidth = 0.005; - const mask = smoothstep(antialiasWidth, -antialiasWidth, sdf); - const mixedColor = mix(vec3(0.05, 0.05, 0.1), vec3(1, 1, 1), mask); - - return vec4(mixedColor, 1); -} - -function buildDisplace(params: ParamsFor<'displace'>): ShaderNodeObject { - const scaledTime = elapsedTime.mul(0.2); - const displacedUv = displace(uv(), vec2(params.x, params.y)); - const samplePoint = displacedUv.mul(4).add(vec2(scaledTime, scaledTime)); - const noiseValue = simplexNoise(samplePoint); - const grayscale = noiseValue.mul(0.5).add(0.5).clamp(0, 1); - - return vec4(grayscale, grayscale, grayscale, 1); -} - -function buildCursorRipple( - params: ParamsFor<'cursor-ripple'>, - staticCursor: Parameters[1], -): ShaderNodeObject { - const reach = 1 / params.falloff; - const frequency = params.falloff * 8; - const ripple = cursorRipple(uv(), staticCursor, { - amplitude: params.amplitude, - frequency, - reach, - speed: params.speed * 6, - }); - const grayscale = ripple - .div(params.amplitude * 2 + 0.0001) - .add(0.5) - .clamp(0, 1); - - return vec4(grayscale, grayscale, grayscale, 1); -} - -function buildTime(): ShaderNodeObject { - // No controls. sin(time * 2) → [-1, 1] → grayscale pulse. - const pulse = sin(elapsedTime.mul(2)).mul(0.5).add(0.5); - - return vec4(pulse, pulse, pulse, 1); -} - -export function PrimitiveScene({ primitive }: PrimitiveSceneProps) { - const remountKey = buildStructuralKey(primitive); - - return ( - - - - ); -} - -function PrimitiveMesh({ primitive }: PrimitiveSceneProps) { - const shaderContext = useShaderContext(); - - const staticCursorVec = useMemo(() => new Vector2(0.5, 0.5), []); - const staticCursor = useMemo(() => uniform(staticCursorVec), [staticCursorVec]); - - useEffect(() => { - if (!shaderContext) return; - - let colorNode: ShaderNodeObject; - - switch (primitive.slug) { - case 'color-ramp': - colorNode = buildColorRamp(primitive); - break; - case 'mix-color': - colorNode = buildMixColor(primitive); - break; - case 'noise': - colorNode = buildNoise(primitive); - break; - case 'fbm': - colorNode = buildFbm(primitive); - break; - case 'voronoi': - colorNode = buildVoronoi(primitive); - break; - case 'quantize': - colorNode = buildQuantize(primitive); - break; - case 'sdf-circle': - colorNode = buildSdfCircle(primitive); - break; - case 'displace': - colorNode = buildDisplace(primitive); - break; - case 'cursor-ripple': - colorNode = buildCursorRipple(primitive, staticCursor); - break; - case 'time': - colorNode = buildTime(); - break; - default: { - const _exhaustive: never = primitive; - - throw new Error(`Unhandled primitive variant: ${JSON.stringify(_exhaustive)}`); - } - } - - return addPlaneMesh(shaderContext, colorNode); - }, [shaderContext, primitive, staticCursor]); - - return null; -} diff --git a/apps/docs/src/components/PropsPlayground.tsx b/apps/docs/src/components/PropsPlayground.tsx deleted file mode 100644 index 9d642aba..00000000 --- a/apps/docs/src/components/PropsPlayground.tsx +++ /dev/null @@ -1,298 +0,0 @@ -'use client'; - -import type { CSSProperties, ReactNode } from 'react'; - -export type PropSchemaEntry = - | { name: string; label?: string; type: 'color'; default: string } - | { - name: string; - label?: string; - type: 'number'; - default: number; - min: number; - max: number; - step?: number; - } - | { name: string; label?: string; type: 'boolean'; default: boolean } - | { - name: string; - label?: string; - type: 'enum'; - default: string; - options: readonly string[]; - } - | { - name: string; - label?: string; - type: 'colors'; - default: string[]; - min?: number; - max?: number; - }; - -export type PropSchema = readonly PropSchemaEntry[]; - -export type PropValue = string | number | boolean | string[]; -export type PropsState = Record; - -type LiveEntry = - | { - type: 'color'; - entry: Extract; - value: string; - } - | { - type: 'enum'; - entry: Extract; - value: string; - } - | { - type: 'number'; - entry: Extract; - value: number; - } - | { - type: 'boolean'; - entry: Extract; - value: boolean; - } - | { - type: 'colors'; - entry: Extract; - value: string[]; - }; - -function toLiveEntry(entry: PropSchemaEntry, value: PropValue | undefined): LiveEntry { - if (value === undefined) { - throw new Error(`PropRow: missing state value for '${entry.name}'`); - } - - switch (entry.type) { - case 'color': - if (typeof value !== 'string') { - throw new Error(`PropRow: expected string for '${entry.name}', got ${typeof value}`); - } - - return { type: 'color', entry, value }; - case 'enum': - if (typeof value !== 'string') { - throw new Error(`PropRow: expected string for '${entry.name}', got ${typeof value}`); - } - - return { type: 'enum', entry, value }; - case 'number': - if (typeof value !== 'number') { - throw new Error(`PropRow: expected number for '${entry.name}', got ${typeof value}`); - } - - return { type: 'number', entry, value }; - case 'boolean': - if (typeof value !== 'boolean') { - throw new Error(`PropRow: expected boolean for '${entry.name}', got ${typeof value}`); - } - - return { type: 'boolean', entry, value }; - case 'colors': - if (!Array.isArray(value)) { - throw new Error(`PropRow: expected array for '${entry.name}', got ${typeof value}`); - } - - return { type: 'colors', entry, value }; - } -} - -interface PropsPlaygroundProps { - schema: PropSchema; - state: PropsState; - onChange: (state: PropsState) => void; - className?: string; - style?: CSSProperties; -} - -// Controlled on purpose: the parent owns the state and this panel only -// reports edits. Holding a mirror copy here and syncing it up through an -// effect would render every change twice (once for the local set, once for -// the parent's). -export function PropsPlayground({ - schema, - state, - onChange, - className, - style, -}: PropsPlaygroundProps) { - const update = (name: string, value: PropValue) => { - onChange({ ...state, [name]: value }); - }; - - return ( -
- {schema.map((entry) => ( - - ))} -
- ); -} - -function PropRow({ - live, - onChange, -}: { - live: LiveEntry; - onChange: (name: string, value: PropValue) => void; -}) { - const label = live.entry.label ?? live.entry.name; - const id = `prop-${live.entry.name}`; - - switch (live.type) { - case 'color': - return ( - - onChange(live.entry.name, event.target.value)} - style={{ - width: 40, - height: 28, - padding: 0, - border: 'none', - background: 'transparent', - }} - type="color" - value={live.value} - /> - {live.value} - - ); - - case 'number': { - const step = live.entry.step ?? 0.01; - - const fractionDigits = step >= 1 ? 0 : Math.min(3, -Math.floor(Math.log10(step))); - - return ( - - onChange(live.entry.name, event.target.valueAsNumber)} - step={step} - style={{ flex: 1 }} - type="range" - value={live.value} - /> - - {live.value.toFixed(fractionDigits)} - - - ); - } - - case 'boolean': - return ( - - onChange(live.entry.name, event.target.checked)} - type="checkbox" - /> - - ); - - case 'enum': - return ( - - - - ); - - case 'colors': - return ( - -
- {live.value.map((colorValue, colorIndex) => ( - { - const next = [...live.value]; - - next[colorIndex] = event.target.value; - onChange(live.entry.name, next); - }} - style={{ - width: 32, - height: 28, - padding: 0, - border: 'none', - background: 'transparent', - }} - type="color" - value={colorValue} - /> - ))} -
-
- ); - } -} - -function Field({ id, label, children }: { id: string; label: string; children: ReactNode }) { - return ( - - ); -} diff --git a/apps/docs/src/components/RecipeScene.tsx b/apps/docs/src/components/RecipeScene.tsx deleted file mode 100644 index d60182a6..00000000 --- a/apps/docs/src/components/RecipeScene.tsx +++ /dev/null @@ -1,54 +0,0 @@ -'use client'; - -import { useEffect, useMemo } from 'react'; - -import { ShaderScene, useCursor, useShaderContext } from '@camp-dev/shaders'; -import { uniform } from 'three/tsl'; -import { Vector2 } from 'three/webgpu'; - -import { RECIPE_BUILDS } from '@/app/recipes/_builds'; -import { addPlaneMesh } from '@/lib/meshUtils'; - -interface RecipeSceneProps { - slug: string; - variant: string; -} - -export function RecipeScene({ slug, variant }: RecipeSceneProps) { - return ( - - - - ); -} - -function RecipeMesh({ slug, variant }: { slug: string; variant: string }) { - const shaderContext = useShaderContext(); - const cursor = useCursor(); - - const cursorVec = useMemo(() => new Vector2(0.5, 0.5), []); - const cursorUniform = useMemo(() => uniform(cursorVec), [cursorVec]); - - useEffect(() => { - return cursor.on('change', ([cursorX, cursorY]) => cursorVec.set(cursorX, 1 - cursorY)); - }, [cursor, cursorVec]); - - useEffect(() => { - if (!shaderContext) return; - const key = `${slug}.${variant}`; - const build = RECIPE_BUILDS[key]; - - if (!build) return; - - // `build` is a shader-builder looked up from a static map, not a parent - // callback — nothing here re-renders anything. The detector reads - // "call a passed-in function with local data inside an effect" as - // state-mirroring; this is the repo's standard imperative mesh lifecycle. - // react-doctor-disable-next-line react-doctor/no-pass-data-to-parent - const colorNode = build({ cursorUniform }); - - return addPlaneMesh(shaderContext, colorNode); - }, [shaderContext, slug, variant, cursorUniform]); - - return null; -} diff --git a/apps/docs/src/components/RecipeViewer.tsx b/apps/docs/src/components/RecipeViewer.tsx deleted file mode 100644 index d3f8209c..00000000 --- a/apps/docs/src/components/RecipeViewer.tsx +++ /dev/null @@ -1,34 +0,0 @@ -'use client'; - -import dynamic from 'next/dynamic'; - -interface RecipeViewerProps { - slug: string; - variant: string; - unframed?: boolean; -} - -const RecipeScene = dynamic(() => import('./RecipeScene').then((m) => m.RecipeScene), { - ssr: false, -}); - -export function RecipeViewer({ slug, variant, unframed = false }: RecipeViewerProps) { - if (unframed) { - return ; - } - - return ( -
- -
- ); -} diff --git a/apps/docs/src/components/code-block/code-block.tsx b/apps/docs/src/components/code-block/code-block.tsx index 126cd648..55b08d48 100644 --- a/apps/docs/src/components/code-block/code-block.tsx +++ b/apps/docs/src/components/code-block/code-block.tsx @@ -3,8 +3,7 @@ * string into styled HTML at build time (the site is a static export), so no * highlighting JavaScript ships to the client. The colors are `var(--code-*)` * references from lib/code-theme.ts, resolved by the tokens in globals.css. - * Used by the component pages' Usage section and props table, and the - * primitives and recipes reference pages. + * Used by the component pages' Usage section and props table. */ import { CODE_THEME_NAME } from '@/lib/code-theme'; import { type CodeLang, getHighlighter } from '@/lib/shiki'; diff --git a/apps/docs/src/components/docs-shell/docs-shell.tsx b/apps/docs/src/components/docs-shell/docs-shell.tsx index 442f7189..38045cb8 100644 --- a/apps/docs/src/components/docs-shell/docs-shell.tsx +++ b/apps/docs/src/components/docs-shell/docs-shell.tsx @@ -1,9 +1,9 @@ /** * The two-column frame under the section banner: the docs sidebar on the * left and the page on the right, in the page gutter and 9xl container that - * globals.css defines. The three docs layouts (components, primitives, and - * the MDX content) each render it with their own section, and the sidebar - * shows only that section's groups. Column widths follow the Figma mock: a + * globals.css defines. The two docs layouts (components and the MDX + * content) each render it with their own section, and the sidebar shows + * only that section's groups. Column widths follow the Figma mock: a * 2xs sidebar with no gap, then a main column that insets its own content * by 40px. */ diff --git a/apps/docs/src/components/primitive-params.ts b/apps/docs/src/components/primitive-params.ts deleted file mode 100644 index 244a126d..00000000 --- a/apps/docs/src/components/primitive-params.ts +++ /dev/null @@ -1,90 +0,0 @@ -// Scalar helpers for the primitive demo pages: turning a control schema into -// its initial state, and validating raw playground state into the typed -// per-primitive params PrimitiveScene renders from. Kept out of the component -// files on purpose — PrimitiveDemo loads PrimitiveScene via next/dynamic -// (ssr: false) because three/webgpu cannot run during server rendering, and a -// static import of these helpers from PrimitiveScene would drag the whole -// three graph back into the server bundle. This module is three-free, and -// keeping component files component-only also lets Fast Refresh preserve -// their state across edits. -import type { PropSchema, PropsState } from './PropsPlayground'; - -export type PrimitiveParams = - | { slug: 'color-ramp'; position: number } - | { slug: 'mix-color'; t: number } - | { slug: 'noise'; scale: number; speed: number } - | { - slug: 'fbm'; - scale: number; - speed: number; - octaves: number; - lacunarity: number; - gain: number; - } - | { slug: 'voronoi'; scale: number; speed: number } - | { slug: 'quantize'; bins: number } - | { slug: 'sdf-circle'; radius: number; cx: number; cy: number } - | { slug: 'displace'; x: number; y: number } - | { slug: 'cursor-ripple'; amplitude: number; falloff: number; speed: number } - | { slug: 'time' }; - -export function initialStateFromSchema(schema: PropSchema): PropsState { - const initialState: PropsState = {}; - - for (const entry of schema) { - initialState[entry.name] = entry.type === 'colors' ? [...entry.default] : entry.default; - } - - return initialState; -} - -export function buildPrimitiveParams(slug: string, raw: PropsState): PrimitiveParams { - const num = (key: string): number => { - const paramValue = raw[key]; - - if (typeof paramValue !== 'number') { - throw new Error( - `primitive '${slug}': missing or non-number param '${key}' (got ${typeof paramValue}). Check @/data/primitives.ts.`, - ); - } - - return paramValue; - }; - - switch (slug) { - case 'color-ramp': - return { slug, position: num('position') }; - case 'mix-color': - return { slug, t: num('t') }; - case 'noise': - return { slug, scale: num('scale'), speed: num('speed') }; - case 'fbm': - return { - slug, - scale: num('scale'), - speed: num('speed'), - octaves: num('octaves'), - lacunarity: num('lacunarity'), - gain: num('gain'), - }; - case 'voronoi': - return { slug, scale: num('scale'), speed: num('speed') }; - case 'quantize': - return { slug, bins: num('bins') }; - case 'sdf-circle': - return { slug, radius: num('radius'), cx: num('cx'), cy: num('cy') }; - case 'displace': - return { slug, x: num('x'), y: num('y') }; - case 'cursor-ripple': - return { - slug, - amplitude: num('amplitude'), - falloff: num('falloff'), - speed: num('speed'), - }; - case 'time': - return { slug: 'time' }; - default: - throw new Error(`Unknown primitive slug: '${slug}'`); - } -} diff --git a/apps/docs/src/components/site-header/site-header.tsx b/apps/docs/src/components/site-header/site-header.tsx index bba27357..34aac91e 100644 --- a/apps/docs/src/components/site-header/site-header.tsx +++ b/apps/docs/src/components/site-header/site-header.tsx @@ -28,12 +28,12 @@ export function SiteHeader() {