{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "render-surface",
  "title": "Render Surface",
  "description": "A canvas that sizes itself, sleeps when it is off screen, and holds still when motion is reduced.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/render-surface/render-surface.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type SurfaceContextType = \"2d\" | \"webgl\" | \"none\"\n\nexport type SurfaceContextFor<K extends SurfaceContextType> = K extends \"2d\"\n  ? CanvasRenderingContext2D\n  : K extends \"webgl\"\n    ? WebGLRenderingContext\n    : null\n\nexport type SurfaceSize = {\n  /** CSS pixels. The backing store is this multiplied by dpr. */\n  width: number\n  height: number\n  dpr: number\n}\n\nexport type SurfaceArgs<TState, K extends SurfaceContextType> = {\n  canvas: HTMLCanvasElement\n  context: SurfaceContextFor<K>\n  size: SurfaceSize\n  state: TState\n  /** Seconds of drawing time. Time spent paused or off screen does not count. */\n  time: number\n  /** Seconds since the previous frame, clamped to avoid a jump after a pause. */\n  delta: number\n}\n\nexport type RenderSurfaceProps<\n  TState,\n  K extends SurfaceContextType = \"2d\",\n> = Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> & {\n  contextType?: K\n  /**\n   * Builds whatever the drawing needs. Runs again whenever the canvas is\n   * resized or a lost GPU context is restored, so it must not assume it is\n   * only ever called once.\n   */\n  setup: (args: {\n    canvas: HTMLCanvasElement\n    context: SurfaceContextFor<K>\n    size: SurfaceSize\n  }) => TState\n  draw: (args: SurfaceArgs<TState, K>) => void\n  teardown?: (state: TState) => void\n  /** Highest backing-store scale to use. Above 2 costs far more than it shows. */\n  maxDpr?: number\n  /**\n   * Whether a resize rebuilds the state. Off for a setup that owns a scarce\n   * resource, such as a renderer holding one of the browser's few GPU\n   * contexts, which has to survive a resize rather than be built again.\n   */\n  rebuildOnResize?: boolean\n  /**\n   * Change this to ask for one more frame. Needed by any surface whose content\n   * arrives late, because under reduced motion a single frame is painted, and\n   * a picture that had not loaded by then would never appear at all.\n   */\n  revision?: string | number\n  /**\n   * Change this when what setup builds has itself changed -- a different\n   * shader, a different program. `revision` repaints the state that exists;\n   * this replaces it. Without it a surface whose setup depends on a prop keeps\n   * drawing whatever it was built with the first time.\n   */\n  rebuildKey?: string | number\n  paused?: boolean\n  /** Marks the canvas as meaningful content rather than decoration. */\n  label?: string\n  canvasClassName?: string\n}\n\nconst MAX_DELTA = 1 / 15\n\nfunction readDpr(max: number) {\n  if (typeof window === \"undefined\") return 1\n  return Math.min(window.devicePixelRatio || 1, max)\n}\n\n/**\n * Runs a draw loop against a canvas that stays the size of its box, sleeps\n * whenever it is off screen or its tab is hidden, and paints a single frame\n * instead of animating when the reader has asked for reduced motion.\n */\nexport function RenderSurface<TState, K extends SurfaceContextType = \"2d\">({\n  contextType,\n  setup,\n  draw,\n  teardown,\n  maxDpr = 2,\n  rebuildOnResize = true,\n  revision,\n  rebuildKey,\n  paused = false,\n  label,\n  className,\n  canvasClassName,\n  ...rootProps\n}: RenderSurfaceProps<TState, K>) {\n  const canvasRef = React.useRef<HTMLCanvasElement>(null)\n  const [visible, setVisible] = React.useState(true)\n  const [reduced, setReduced] = React.useState(false)\n\n  // Read through refs so a caller may pass inline functions without the loop\n  // tearing down and rebuilding on every render.\n  const latest = React.useRef({ setup, draw, teardown })\n  React.useEffect(() => {\n    latest.current = { setup, draw, teardown }\n  })\n\n  React.useEffect(() => {\n    const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n    const sync = () => setReduced(query.matches)\n\n    sync()\n    query.addEventListener(\"change\", sync)\n    return () => query.removeEventListener(\"change\", sync)\n  }, [])\n\n  React.useEffect(() => {\n    const canvas = canvasRef.current\n    if (!canvas) return\n\n    const observer = new IntersectionObserver(\n      ([entry]) => setVisible(entry?.isIntersecting ?? true),\n      { rootMargin: \"128px\" }\n    )\n\n    observer.observe(canvas)\n    return () => observer.disconnect()\n  }, [])\n\n  const kind = contextType ?? (\"2d\" as K)\n  const asleep = paused || !visible\n  const repaint = React.useRef<(() => void) | null>(null)\n  const rebuild = React.useRef<(() => void) | null>(null)\n  const builtWith = React.useRef(rebuildKey)\n  const controls = React.useRef<{ start: () => void; stop: () => void } | null>(\n    null\n  )\n\n  // Sleeping is read through a ref so it can change without tearing the canvas\n  // down. Resizing the backing store clears it, so a surface that paints once\n  // and then stops would otherwise be wiped by the very act of stopping.\n  const asleepRef = React.useRef(asleep)\n  React.useEffect(() => {\n    asleepRef.current = asleep\n  })\n\n  React.useEffect(() => {\n    const canvas = canvasRef.current\n    if (!canvas) return\n\n    const context = (\n      kind === \"none\"\n        ? null\n        : canvas.getContext(kind === \"webgl\" ? \"webgl\" : \"2d\", {\n            alpha: true,\n            antialias: true,\n          })\n    ) as SurfaceContextFor<K>\n\n    if (kind !== \"none\" && context === null) return\n\n    let state: TState | undefined\n    let size: SurfaceSize = { width: 0, height: 0, dpr: readDpr(maxDpr) }\n    let raf = 0\n    let last = 0\n    let elapsed = 0\n    let disposed = false\n\n    const measure = () => {\n      const rect = canvas.getBoundingClientRect()\n      if (rect.width === 0 || rect.height === 0) return false\n\n      const dpr = readDpr(maxDpr)\n      canvas.width = Math.round(rect.width * dpr)\n      canvas.height = Math.round(rect.height * dpr)\n      size = { width: rect.width, height: rect.height, dpr }\n      return true\n    }\n\n    const build = () => {\n      if (!measure()) return false\n\n      if (state !== undefined) latest.current.teardown?.(state)\n      state = latest.current.setup({ canvas, context, size })\n      return true\n    }\n\n    const paint = (timestamp: number) => {\n      if (state === undefined) return\n\n      const delta =\n        last === 0 ? 0 : Math.min((timestamp - last) / 1000, MAX_DELTA)\n      last = timestamp\n      elapsed += delta\n\n      latest.current.draw({\n        canvas,\n        context,\n        size,\n        state,\n        time: elapsed,\n        delta,\n      })\n    }\n\n    const loop = (timestamp: number) => {\n      paint(timestamp)\n      raf = window.requestAnimationFrame(loop)\n    }\n\n    const start = () => {\n      if (disposed || raf !== 0) return\n      last = 0\n\n      if (reduced) {\n        raf = window.requestAnimationFrame((timestamp) => {\n          raf = 0\n          paint(timestamp)\n        })\n        return\n      }\n\n      raf = window.requestAnimationFrame(loop)\n    }\n\n    const stop = () => {\n      if (raf !== 0) window.cancelAnimationFrame(raf)\n      raf = 0\n    }\n\n    const onLost = (event: Event) => {\n      event.preventDefault()\n      stop()\n      state = undefined\n    }\n\n    const onRestored = () => {\n      if (build()) start()\n    }\n\n    const onVisibility = () => {\n      if (document.visibilityState === \"hidden\") stop()\n      else if (!asleepRef.current) start()\n    }\n\n    if (!build()) return\n\n    repaint.current = () => {\n      if (disposed || state === undefined) return\n      window.requestAnimationFrame(paint)\n    }\n\n    rebuild.current = () => {\n      if (disposed) return\n      stop()\n      if (!build()) return\n      if (asleepRef.current) window.requestAnimationFrame(paint)\n      else start()\n    }\n\n    const resize = new ResizeObserver(() => {\n      stop()\n      if (!(rebuildOnResize ? build() : measure())) return\n\n      // Resizing the backing store clears it, so a sleeping surface has just\n      // lost whatever it was showing and needs one frame to put it back.\n      if (asleepRef.current) window.requestAnimationFrame(paint)\n      else start()\n    })\n\n    resize.observe(canvas)\n    canvas.addEventListener(\"webglcontextlost\", onLost)\n    canvas.addEventListener(\"webglcontextrestored\", onRestored)\n    document.addEventListener(\"visibilitychange\", onVisibility)\n\n    controls.current = { start, stop }\n    if (!asleepRef.current) start()\n\n    return () => {\n      disposed = true\n      repaint.current = null\n      rebuild.current = null\n      controls.current = null\n      stop()\n      resize.disconnect()\n      canvas.removeEventListener(\"webglcontextlost\", onLost)\n      canvas.removeEventListener(\"webglcontextrestored\", onRestored)\n      document.removeEventListener(\"visibilitychange\", onVisibility)\n      if (state !== undefined) latest.current.teardown?.(state)\n    }\n  }, [kind, maxDpr, rebuildOnResize, reduced])\n\n  React.useEffect(() => {\n    if (asleep) controls.current?.stop()\n    else controls.current?.start()\n  }, [asleep])\n\n  React.useEffect(() => {\n    repaint.current?.()\n  }, [revision])\n\n  React.useEffect(() => {\n    // Mounting has already built once, so only a change asks for another.\n    if (builtWith.current === rebuildKey) return\n    builtWith.current = rebuildKey\n    rebuild.current?.()\n  }, [rebuildKey])\n\n  return (\n    <div\n      data-slot=\"render-surface\"\n      className={cn(\"relative isolate overflow-hidden\", className)}\n      {...rootProps}\n    >\n      <canvas\n        ref={canvasRef}\n        data-slot=\"render-surface-canvas\"\n        role={label ? \"img\" : \"presentation\"}\n        aria-label={label}\n        aria-hidden={label ? undefined : true}\n        className={cn(\"block size-full\", canvasClassName)}\n      />\n    </div>\n  )\n}\n\n/** Straight sRGB, each channel from 0 to 1, ready to hand to a shader. */\nexport type SurfaceColor = readonly [number, number, number]\n\nconst BLACK: SurfaceColor = [0, 0, 0]\n\nlet scratch: CanvasRenderingContext2D | null | undefined\n\n/**\n * Converts any colour the browser understands into plain channels, including\n * the oklch and color-mix values shadcn themes are written in. Painting one\n * pixel and reading it back is the only route that stays correct as CSS gains\n * colour spaces, so the browser does the conversion rather than this file.\n */\nexport function resolveColor(value: string): SurfaceColor {\n  const input = value.trim()\n  if (input === \"\") return BLACK\n\n  if (scratch === undefined) {\n    scratch = document.createElement(\"canvas\").getContext(\"2d\", {\n      willReadFrequently: true,\n    })\n  }\n\n  if (!scratch) return BLACK\n\n  scratch.clearRect(0, 0, 1, 1)\n  scratch.fillStyle = \"#000\"\n  scratch.fillStyle = input\n  scratch.fillRect(0, 0, 1, 1)\n\n  const [r, g, b] = scratch.getImageData(0, 0, 1, 1).data\n  return [(r ?? 0) / 255, (g ?? 0) / 255, (b ?? 0) / 255]\n}\n\n/**\n * Reads theme custom properties off a mounted element and keeps them current\n * across a theme change, so a scene recolours itself to the application it was\n * installed into instead of carrying its own palette.\n */\nexport function useThemeColors<const Names extends readonly string[]>(\n  ref: React.RefObject<HTMLElement | null>,\n  names: Names\n): Record<Names[number], SurfaceColor> {\n  const key = names.join(\",\")\n\n  const read = React.useCallback(() => {\n    const element = ref.current\n    const found = {} as Record<Names[number], SurfaceColor>\n    if (!element) return found\n\n    const styles = getComputedStyle(element)\n    for (const name of key.split(\",\") as Names[number][]) {\n      found[name] = resolveColor(styles.getPropertyValue(name))\n    }\n\n    return found\n  }, [key, ref])\n\n  const [colors, setColors] = React.useState<\n    Record<Names[number], SurfaceColor>\n  >({} as Record<Names[number], SurfaceColor>)\n\n  React.useEffect(() => {\n    const sync = () => setColors(read())\n    sync()\n\n    const observer = new MutationObserver(sync)\n    observer.observe(document.documentElement, {\n      attributeFilter: [\"class\", \"style\", \"data-theme\"],\n    })\n\n    return () => observer.disconnect()\n  }, [read])\n\n  return colors\n}\n\nexport function colorOf(\n  colors: Record<string, SurfaceColor>,\n  name: string,\n  fallback: SurfaceColor = BLACK\n): SurfaceColor {\n  return colors[name] ?? fallback\n}\n\nexport type QuadProgram = {\n  program: WebGLProgram\n  uniform: (name: string) => WebGLUniformLocation | null\n  /** Binds the program and the quad, then draws it across the viewport. */\n  paint: (width: number, height: number, dpr: number) => void\n  dispose: () => void\n}\n\nconst QUAD_VERTEX = `\nattribute vec2 aPosition;\nvarying vec2 vUv;\nvoid main() {\n  vUv = aPosition * 0.5 + 0.5;\n  gl_Position = vec4(aPosition, 0.0, 1.0);\n}\n`\n\nfunction compile(gl: WebGLRenderingContext, type: number, source: string) {\n  const shader = gl.createShader(type)\n  if (!shader) return null\n\n  gl.shaderSource(shader, source)\n  gl.compileShader(shader)\n\n  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n    gl.deleteShader(shader)\n    return null\n  }\n\n  return shader\n}\n\n/**\n * Builds a program that covers the canvas with one rectangle, which is all a\n * background shader ever needs. Returns null rather than throwing when the\n * driver refuses the shader, so a caller can fall back to plain markup.\n */\nexport function createQuadProgram(\n  gl: WebGLRenderingContext,\n  fragmentSource: string\n): QuadProgram | null {\n  const vertex = compile(gl, gl.VERTEX_SHADER, QUAD_VERTEX)\n  const fragment = compile(gl, gl.FRAGMENT_SHADER, fragmentSource)\n  const program = gl.createProgram()\n\n  if (!vertex || !fragment || !program) return null\n\n  gl.attachShader(program, vertex)\n  gl.attachShader(program, fragment)\n  gl.linkProgram(program)\n  gl.deleteShader(vertex)\n  gl.deleteShader(fragment)\n\n  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n    gl.deleteProgram(program)\n    return null\n  }\n\n  const buffer = gl.createBuffer()\n  gl.bindBuffer(gl.ARRAY_BUFFER, buffer)\n  gl.bufferData(\n    gl.ARRAY_BUFFER,\n    new Float32Array([-1, -1, 3, -1, -1, 3]),\n    gl.STATIC_DRAW\n  )\n\n  const position = gl.getAttribLocation(program, \"aPosition\")\n  const cache = new Map<string, WebGLUniformLocation | null>()\n\n  return {\n    program,\n    uniform: (name) => {\n      if (!cache.has(name))\n        cache.set(name, gl.getUniformLocation(program, name))\n      return cache.get(name) ?? null\n    },\n    paint: (width, height, dpr) => {\n      gl.viewport(0, 0, Math.round(width * dpr), Math.round(height * dpr))\n      gl.useProgram(program)\n      gl.bindBuffer(gl.ARRAY_BUFFER, buffer)\n      gl.enableVertexAttribArray(position)\n      gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0)\n      gl.drawArrays(gl.TRIANGLES, 0, 3)\n    },\n    dispose: () => {\n      gl.deleteBuffer(buffer)\n      gl.deleteProgram(program)\n    },\n  }\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "scene",
    "canvas",
    "motion",
    "primitive"
  ],
  "type": "registry:ui"
}