{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "shader-canvas",
  "type": "registry:ui",
  "title": "Shader Canvas",
  "description": "Minimal WebGL2 fragment-shader runner with time/resolution/pointer uniforms, DPR capping, offscreen pausing, reduced-motion still frame and CSS fallback.",
  "dependencies": [],
  "registryDependencies": [
    "https://kuro.abstergo.fyi/r/kura-theme.json",
    "https://kuro.abstergo.fyi/r/kura-merge-refs.json",
    "https://kuro.abstergo.fyi/r/use-reduced-motion.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/kura/shader-canvas/index.ts",
      "type": "registry:ui",
      "target": "components/kura/shader-canvas/index.ts",
      "content": "export {\n  ShaderCanvas,\n  type ShaderCanvasProps,\n  type UniformInput,\n  type UniformValue,\n} from \"./shader-canvas\";\n"
    },
    {
      "path": "registry/kura/shader-canvas/shader-canvas.tsx",
      "type": "registry:ui",
      "target": "components/kura/shader-canvas/shader-canvas.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useReducedMotion } from \"@/hooks/kura/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { mergeRefs } from \"@/lib/kura/merge-refs\";\n\nexport type UniformValue =\n  | number\n  | readonly [number, number]\n  | readonly [number, number, number]\n  | readonly [number, number, number, number];\n\n/** Static value, or a function evaluated every frame (read refs/analysers here, not React state). */\nexport type UniformInput = UniformValue | ((time: number) => UniformValue);\n\nexport interface ShaderCanvasProps\n  extends Omit<React.ComponentProps<\"div\">, \"children\" | \"onError\"> {\n  /**\n   * GLSL ES 3.00 fragment shader. If it does not start with `#version`, this header is prepended:\n   * `#version 300 es`, `precision highp float;`, `uniform float u_time; uniform vec2 u_resolution;\n   * uniform vec2 u_pointer;`, `out vec4 fragColor;` — so you only write `void main() { … }`.\n   */\n  fragmentShader: string;\n  /**\n   * Custom uniforms by name. number → float, [x,y] → vec2, [x,y,z] → vec3, [x,y,z,w] → vec4.\n   * Declare them in the shader yourself. Values are read every frame, so updating this object\n   * does not recompile the program.\n   */\n  uniforms?: Record<string, UniformInput>;\n  /** Freeze time (keeps the last frame). */\n  paused?: boolean;\n  /** Time multiplier. Default 1. */\n  speed?: number;\n  /** Cap device-pixel-ratio for performance. Default 2. */\n  maxDpr?: number;\n  /** Stop rendering while offscreen (IntersectionObserver). Default true. */\n  pauseWhenOffscreen?: boolean;\n  /** `u_time` (seconds) used for the single static frame under reduced motion. Default 1.5. */\n  staticTime?: number;\n  /** Honour prefers-reduced-motion by rendering one static frame. Default true. */\n  respectReducedMotion?: boolean;\n  /** Rendered instead of the canvas when WebGL2 is unavailable or the shader fails to compile. Default: a soft token gradient. */\n  fallback?: React.ReactNode;\n  /** Called with the compile/link error message. Without it, the error is logged to the console. */\n  onError?: (message: string) => void;\n}\n\nconst VERTEX = `#version 300 es\nin vec2 a_position;\nvoid main() { gl_Position = vec4(a_position, 0.0, 1.0); }`;\n\nconst HEADER = `#version 300 es\nprecision highp float;\nuniform float u_time;\nuniform vec2 u_resolution;\nuniform vec2 u_pointer;\nout vec4 fragColor;\n`;\n\nfunction compile(gl: WebGL2RenderingContext, type: number, src: string): WebGLShader | string {\n  const shader = gl.createShader(type);\n  if (!shader) return \"createShader failed\";\n  gl.shaderSource(shader, src);\n  gl.compileShader(shader);\n  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n    const log = gl.getShaderInfoLog(shader) ?? \"unknown compile error\";\n    gl.deleteShader(shader);\n    return log;\n  }\n  return shader;\n}\n\nfunction setUniform(gl: WebGL2RenderingContext, loc: WebGLUniformLocation, v: UniformValue) {\n  if (typeof v === \"number\") gl.uniform1f(loc, v);\n  else if (v.length === 2) gl.uniform2f(loc, v[0], v[1]);\n  else if (v.length === 3) gl.uniform3f(loc, v[0], v[1], v[2]);\n  else gl.uniform4f(loc, v[0], v[1], v[2], v[3]);\n}\n\n/** Default fallback: a quiet gradient built from Kura tokens. */\nfunction DefaultFallback() {\n  return (\n    <div\n      aria-hidden\n      className=\"absolute inset-0 bg-[radial-gradient(120%_80%_at_30%_20%,var(--kura-accent-subtle),transparent_60%),radial-gradient(100%_70%_at_80%_90%,var(--kura-gray-4),transparent_70%)]\"\n    />\n  );\n}\n\n/**\n * Minimal WebGL2 fragment-shader runner — the base for every Kura shader effect (mesh gradients,\n * auroras, grain, orbs). Handles DPR, resize, pointer, offscreen pausing, reduced motion, context\n * loss and a CSS fallback. Decorative by default (`aria-hidden`).\n */\nexport function ShaderCanvas({\n  fragmentShader,\n  uniforms,\n  paused = false,\n  speed = 1,\n  maxDpr = 2,\n  pauseWhenOffscreen = true,\n  staticTime = 1.5,\n  respectReducedMotion = true,\n  fallback,\n  onError,\n  className,\n  style,\n  ref,\n  ...props\n}: ShaderCanvasProps) {\n  const containerRef = React.useRef<HTMLDivElement>(null);\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n  const kickRef = React.useRef<() => void>(() => {});\n  const [failed, setFailed] = React.useState(false);\n  const reducedMotion = useReducedMotion();\n  const isStatic = respectReducedMotion && reducedMotion;\n\n  // Latest values, read inside the render loop without restarting it.\n  const live = React.useRef({ uniforms, paused, speed, isStatic, staticTime, onError });\n  live.current = { uniforms, paused, speed, isStatic, staticTime, onError };\n\n  React.useEffect(() => {\n    const canvas = canvasRef.current;\n    const container = containerRef.current;\n    if (!canvas || !container) return;\n    const gl = canvas.getContext(\"webgl2\", { antialias: false, premultipliedAlpha: true });\n    if (!gl) {\n      setFailed(true);\n      return;\n    }\n    setFailed(false);\n\n    const report = (msg: string) => {\n      if (!live.current.onError) console.error(`[kura/shader-canvas] ${msg}`);\n      live.current.onError?.(msg);\n      setFailed(true);\n    };\n\n    const fragSrc = fragmentShader.trimStart().startsWith(\"#version\")\n      ? fragmentShader\n      : HEADER + fragmentShader;\n    const vs = compile(gl, gl.VERTEX_SHADER, VERTEX);\n    const fs = compile(gl, gl.FRAGMENT_SHADER, fragSrc);\n    if (typeof vs === \"string\" || typeof fs === \"string\") {\n      report(typeof fs === \"string\" ? fs : (vs as string));\n      return;\n    }\n    const program = gl.createProgram();\n    if (!program) return report(\"createProgram failed\");\n    gl.attachShader(program, vs);\n    gl.attachShader(program, fs);\n    gl.linkProgram(program);\n    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n      return report(gl.getProgramInfoLog(program) ?? \"link failed\");\n    }\n    // biome-ignore lint/correctness/useHookAtTopLevel: WebGL method, not a React hook\n    gl.useProgram(program);\n\n    const buffer = gl.createBuffer();\n    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);\n    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);\n    const loc = gl.getAttribLocation(program, \"a_position\");\n    gl.enableVertexAttribArray(loc);\n    gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);\n\n    const uTime = gl.getUniformLocation(program, \"u_time\");\n    const uRes = gl.getUniformLocation(program, \"u_resolution\");\n    const uPointer = gl.getUniformLocation(program, \"u_pointer\");\n    const customLocs = new Map<string, WebGLUniformLocation | null>();\n\n    const pointer = { x: 0.5, y: 0.5 };\n    let visible = true;\n    let raf = 0;\n    let last = performance.now();\n    let time = 0;\n\n    const resize = () => {\n      const dpr = Math.min(window.devicePixelRatio || 1, maxDpr);\n      const { width, height } = container.getBoundingClientRect();\n      const w = Math.max(1, Math.round(width * dpr));\n      const h = Math.max(1, Math.round(height * dpr));\n      if (canvas.width !== w || canvas.height !== h) {\n        canvas.width = w;\n        canvas.height = h;\n        gl.viewport(0, 0, w, h);\n      }\n    };\n\n    const draw = (t: number) => {\n      resize();\n      if (uTime) gl.uniform1f(uTime, t);\n      if (uRes) gl.uniform2f(uRes, canvas.width, canvas.height);\n      if (uPointer) gl.uniform2f(uPointer, pointer.x, pointer.y);\n      const custom = live.current.uniforms;\n      if (custom) {\n        for (const name in custom) {\n          if (!customLocs.has(name)) customLocs.set(name, gl.getUniformLocation(program, name));\n          const l = customLocs.get(name);\n          const input = custom[name];\n          if (!l || input === undefined) continue;\n          setUniform(gl, l, typeof input === \"function\" ? input(t) : input);\n        }\n      }\n      gl.drawArrays(gl.TRIANGLES, 0, 3);\n    };\n\n    const frame = (now: number) => {\n      const dt = Math.min(0.1, (now - last) / 1000);\n      last = now;\n      const { paused: p, speed: s, isStatic: st, staticTime: stT } = live.current;\n      if (st) {\n        draw(stT);\n      } else {\n        if (!p) time += dt * s;\n        draw(time);\n      }\n      raf = visible && !st && !p ? requestAnimationFrame(frame) : 0;\n    };\n\n    const kick = () => {\n      if (raf) return;\n      last = performance.now();\n      raf = requestAnimationFrame(frame);\n    };\n    kickRef.current = kick;\n    kick();\n\n    const ro = typeof ResizeObserver !== \"undefined\" ? new ResizeObserver(() => kick()) : null;\n    ro?.observe(container);\n\n    const io =\n      pauseWhenOffscreen && typeof IntersectionObserver !== \"undefined\"\n        ? new IntersectionObserver(([entry]) => {\n            visible = entry?.isIntersecting ?? true;\n            if (visible) kick();\n          })\n        : null;\n    io?.observe(container);\n\n    const onPointer = (e: PointerEvent) => {\n      const r = container.getBoundingClientRect();\n      pointer.x = (e.clientX - r.left) / Math.max(1, r.width);\n      pointer.y = 1 - (e.clientY - r.top) / Math.max(1, r.height);\n    };\n    window.addEventListener(\"pointermove\", onPointer, { passive: true });\n\n    const onLost = (e: Event) => {\n      e.preventDefault();\n      cancelAnimationFrame(raf);\n      raf = 0;\n    };\n    canvas.addEventListener(\"webglcontextlost\", onLost);\n\n    return () => {\n      cancelAnimationFrame(raf);\n      ro?.disconnect();\n      io?.disconnect();\n      window.removeEventListener(\"pointermove\", onPointer);\n      canvas.removeEventListener(\"webglcontextlost\", onLost);\n      gl.deleteProgram(program);\n      gl.deleteShader(vs);\n      gl.deleteShader(fs);\n      gl.deleteBuffer(buffer);\n    };\n  }, [fragmentShader, maxDpr, pauseWhenOffscreen]);\n\n  // Resume the loop when un-pausing or leaving reduced motion.\n  React.useEffect(() => {\n    if (!paused || isStatic) kickRef.current();\n  }, [paused, isStatic]);\n\n  // A new `uniforms` object (theme flip, colour or prop change) repaints one frame even while\n  // paused or static, so frozen canvases never show stale colours. While running this is a no-op.\n  // biome-ignore lint/correctness/useExhaustiveDependencies: `uniforms` identity is the trigger\n  React.useEffect(() => {\n    kickRef.current();\n  }, [uniforms]);\n\n  return (\n    <div\n      ref={mergeRefs(containerRef, ref)}\n      data-slot=\"shader-canvas\"\n      data-state={failed ? \"fallback\" : isStatic ? \"static\" : paused ? \"paused\" : \"running\"}\n      aria-hidden\n      className={cn(\"relative overflow-hidden\", className)}\n      style={style}\n      {...props}\n    >\n      <canvas\n        ref={canvasRef}\n        className={cn(\"absolute inset-0 block size-full\", failed && \"hidden\")}\n      />\n      {failed && (fallback ?? <DefaultFallback />)}\n    </div>\n  );\n}\n"
    }
  ],
  "categories": [
    "effects",
    "webgl",
    "shader",
    "glsl",
    "gpu"
  ],
  "docs": "Docs: https://kuro.abstergo.fyi/components/shader-canvas · Markdown for agents: https://kuro.abstergo.fyi/components/shader-canvas.md",
  "meta": {
    "kura": {
      "status": "beta",
      "whenToUse": [
        "Building any GPU visual: mesh gradients, auroras, grain, glows, orbs, generative backgrounds.",
        "A custom decorative shader behind a hero, card or voice surface."
      ],
      "whenNotToUse": [
        "A ready-made effect exists — use mesh-gradient, agent-orb, grain or border-beam.",
        "Static gradients — plain CSS is cheaper.",
        "Anything that conveys information — it is aria-hidden and may fall back to a static gradient."
      ]
    }
  }
}
