{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-gl-stage",
  "title": "useGlStage",
  "description": "Runs a WebGL scene in a canvas: context lifecycle, pixel cap, live theme colours, a frame loop only on screen, and a texture atlas for pictures.",
  "files": [
    {
      "path": "registry/manniche/hooks/use-gl-stage.ts",
      "content": "import { useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react'\nimport { useReducedMotion } from '@/registry/manniche/hooks/use-reduced-motion'\n\n/** A picture for a WebGL carousel. The alt text is what screen readers read; the canvas itself is hidden from them. */\nexport type GlImage = { src: string; alt: string }\n\nexport type Rgb = [number, number, number]\n\nexport type GlSceneContext = {\n  gl: WebGLRenderingContext\n  canvas: HTMLCanvasElement\n  /** Call once the scene has what it needs for its first frame, e.g. after its pictures load. The canvas fades in. */\n  ready: () => void\n  /** Draw again, e.g. once a texture has loaded. */\n  redraw: () => void\n  /** Call when the scene cannot run after all, e.g. its pictures could not be uploaded. The stage reports `failed`. */\n  fail: () => void\n  /** The theme's colours when the scene is built, in the order of `colors`, e.g. for a texture's empty cells. */\n  colors: Rgb[]\n  /** Set when the stage is torn down, so work that finishes later can stop. */\n  signal: AbortSignal\n}\n\nexport type GlScene = {\n  /** Draws a frame. `time` is in seconds; it only moves while the stage animates on its own and stands still under reduced motion. */\n  draw: (time: number) => void\n  /** The canvas has a new size in device pixels. The viewport is already set. */\n  resize?: (width: number, height: number, dpr: number) => void\n  /** The theme's colours, in the order of `colors`, as 0–1 RGB. Called before the first frame and on every theme change. */\n  theme?: (colors: Rgb[]) => void\n  /** Frees what the scene made. The context is released right after. */\n  dispose?: () => void\n}\n\nexport type GlStageOptions = {\n  /** Upper bound on device pixels per CSS pixel. */\n  maxDpr?: number\n  /** Upper bound on the canvas's device pixels; past it the canvas renders smaller and the browser scales it up. */\n  maxPixels?: number\n  /** A frame loop of its own while on screen, for motion that does not come from the carousel. Off under reduced motion. */\n  animate?: boolean\n  /** CSS colours the scene needs, e.g. `var(--background)`. Read from the host, so they follow the theme. */\n  colors?: string[]\n  /** Classes for the canvas, which goes first in the host. */\n  className?: string\n  /** Context attributes on top of the defaults (no antialias, low power). */\n  attributes?: WebGLContextAttributes\n}\n\nexport type GlStage = {\n  /** `pending` until the scene is ready, `ready` once it has drawn, `failed` without WebGL or after the GPU drops the context. */\n  status: 'pending' | 'ready' | 'failed'\n  /** Draw a frame now, e.g. from a carousel's `onFrame`. Does nothing before the scene exists. */\n  redraw: () => void\n}\n\n// Seconds of animation that a still frame shows under reduced motion, so it is not the plain first frame.\nconst STILL = 4\n\n/**\n * Runs a WebGL scene in a canvas inside `host`: a fresh canvas each run, sized to the host with a cap on pixels,\n * the theme's colours read live, its own frame loop only while on screen, and the context released on teardown or\n * failure, so a page never holds more than it uses. `build` makes the scene; it runs again when `key` changes, so put\n * everything the scene is built from in it, e.g. the pictures' addresses. Return null from `build` when the scene cannot\n * run, and the stage reports `failed`.\n */\nexport function useGlStage(\n  host: RefObject<HTMLElement | null>,\n  build: (ctx: GlSceneContext) => GlScene | null,\n  key: string,\n  { maxDpr = 2, maxPixels = 2_400_000, animate = false, colors = [], className = '', attributes }: GlStageOptions = {},\n): GlStage {\n  const reduce = useReducedMotion()\n  const [status, setStatus] = useState<GlStage['status']>('pending')\n  // The live scene's draw, for redraw(); null between runs.\n  const live = useRef<(() => void) | null>(null)\n  const palette = colors.join('|')\n  const attrs = JSON.stringify(attributes ?? {})\n  // The latest build, so a new function each render does not rebuild the scene; `key` does.\n  const make = useRef(build)\n  useLayoutEffect(() => {\n    make.current = build\n  })\n\n  useEffect(() => {\n    const wrap = host.current\n    if (!wrap) return\n    const abort = new AbortController()\n    // Reported after the effect, and only while this run is the live one.\n    const report = (s: GlStage['status']) => queueMicrotask(() => abort.signal.aborted || setStatus(s))\n    report('pending')\n    // A fresh canvas per run: a canvas whose context was lost cannot hand out a new one.\n    const el = document.createElement('canvas')\n    el.setAttribute('aria-hidden', 'true')\n    el.className = className\n    wrap.prepend(el)\n\n    const options: WebGLContextAttributes = { antialias: false, alpha: true, premultipliedAlpha: true, powerPreference: 'low-power', ...JSON.parse(attrs) }\n    const gl = el.getContext('webgl', options)\n    if (!gl) {\n      el.remove()\n      report('failed')\n      return () => abort.abort()\n    }\n\n    let scene: GlScene | null = null\n    let dead = false\n    let shown = false\n    let clock = STILL\n    let raf = 0\n    let last = 0\n    let visible = false\n    const looping = animate && !reduce\n\n    const frame = () => {\n      if (!dead && scene) scene.draw(clock)\n    }\n    let released = false\n    const release = () => {\n      if (released) return\n      released = true\n      dead = true\n      live.current = null\n      cancelAnimationFrame(raf)\n      abort.abort()\n      try {\n        scene?.dispose?.()\n      } catch {\n        // The context may already be gone; there is nothing left to free.\n      }\n      gl.getExtension('WEBGL_lose_context')?.loseContext()\n      el.remove()\n    }\n\n    // The theme's colours, read through a 1 px canvas so any CSS colour (oklch, color-mix, var) comes out as RGB.\n    const probe = document.createElement('canvas').getContext('2d', { willReadFrequently: true })\n    const wanted = palette ? palette.split('|') : []\n    const read = () => {\n      const style = getComputedStyle(wrap)\n      return wanted.map((c): Rgb => {\n        const value = c.startsWith('var(') ? style.getPropertyValue(c.slice(4, -1).split(',')[0].trim()).trim() || '#808080' : c\n        if (!probe) return [0.5, 0.5, 0.5]\n        probe.clearRect(0, 0, 1, 1)\n        probe.fillStyle = '#808080'\n        probe.fillStyle = value\n        probe.fillRect(0, 0, 1, 1)\n        const [r, g, b] = probe.getImageData(0, 0, 1, 1).data\n        return [r / 255, g / 255, b / 255]\n      })\n    }\n\n    const ctx: GlSceneContext = {\n      gl,\n      canvas: el,\n      signal: abort.signal,\n      colors: read(),\n      fail: () => {\n        if (dead) return\n        release()\n        queueMicrotask(() => setStatus('failed'))\n      },\n      redraw: () => {\n        // A running loop draws on its next frame anyway.\n        if (!(looping && visible)) frame()\n      },\n      ready: () => {\n        if (dead || shown) return\n        shown = true\n        frame()\n        // Wait a frame so the canvas fades in instead of popping.\n        requestAnimationFrame(() => {\n          if (dead) return\n          el.dataset.ready = ''\n          report('ready')\n        })\n      },\n    }\n\n    try {\n      scene = make.current(ctx)\n    } catch {\n      scene = null\n    }\n    // Free the context straight away, so a scene that cannot run does not hold one of the page's few.\n    if (!scene) {\n      release()\n      queueMicrotask(() => setStatus('failed'))\n      return\n    }\n    live.current = ctx.redraw\n\n    const theme = () => {\n      if (dead || !scene?.theme || !wanted.length) return\n      scene.theme(read())\n      ctx.redraw()\n    }\n    theme()\n    // The theme changes through a class or inline variables on <html>, or the system's colour scheme.\n    const mo = new MutationObserver(theme)\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] })\n    const scheme = window.matchMedia('(prefers-color-scheme: dark)')\n    scheme.addEventListener('change', theme)\n\n    const resize = () => {\n      if (dead) return\n      const w = el.clientWidth\n      const h = el.clientHeight\n      let dpr = Math.min(maxDpr, window.devicePixelRatio || 1)\n      if (w * h * dpr * dpr > maxPixels) dpr = Math.sqrt(maxPixels / Math.max(1, w * h))\n      el.width = Math.max(1, Math.round(w * dpr))\n      el.height = Math.max(1, Math.round(h * dpr))\n      gl.viewport(0, 0, el.width, el.height)\n      scene?.resize?.(el.width, el.height, dpr)\n      frame()\n    }\n    const ro = new ResizeObserver(resize)\n    ro.observe(el)\n    resize()\n\n    const loop = (now: number) => {\n      // Cap the step so a stalled tab does not jump the motion ahead.\n      clock += Math.min(50, now - (last || now)) / 1000\n      last = now\n      frame()\n      raf = requestAnimationFrame(loop)\n    }\n    // Only animate while on screen.\n    const io = new IntersectionObserver(([entry]) => {\n      if (dead || !looping || entry.isIntersecting === visible) return\n      visible = entry.isIntersecting\n      last = 0\n      if (visible) raf = requestAnimationFrame(loop)\n      else cancelAnimationFrame(raf)\n    })\n    io.observe(el)\n\n    // If the GPU drops the context, the component shows its fallback again.\n    const lost = (e: Event) => {\n      e.preventDefault()\n      dead = true\n      live.current = null\n      cancelAnimationFrame(raf)\n      delete el.dataset.ready\n      report('failed')\n    }\n    el.addEventListener('webglcontextlost', lost)\n\n    return () => {\n      ro.disconnect()\n      io.disconnect()\n      mo.disconnect()\n      scheme.removeEventListener('change', theme)\n      el.removeEventListener('webglcontextlost', lost)\n      release()\n    }\n  }, [host, key, reduce, animate, maxDpr, maxPixels, palette, className, attrs])\n\n  const redraw = useCallback(() => live.current?.(), [])\n  return { status, redraw }\n}\n\n/**\n * Compiles and links a program, or returns null so the caller can fall back; the reason goes to the console unless the\n * context was lost. A uniform used in both shaders must have the same precision in each, or linking fails.\n */\nexport function glProgram(gl: WebGLRenderingContext, vertex: string, fragment: string) {\n  const program = gl.createProgram()\n  if (!program) return null\n  const log: string[] = []\n  for (const [type, src] of [\n    [gl.VERTEX_SHADER, vertex],\n    [gl.FRAGMENT_SHADER, fragment],\n  ] as const) {\n    const s = gl.createShader(type)\n    if (!s) {\n      gl.deleteProgram(program)\n      return null\n    }\n    gl.shaderSource(s, src)\n    gl.compileShader(s)\n    if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) log.push(gl.getShaderInfoLog(s) ?? '')\n    gl.attachShader(program, s)\n    // Flagged for deletion; it lives until the program goes.\n    gl.deleteShader(s)\n  }\n  gl.linkProgram(program)\n  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n    if (!gl.isContextLost()) console.warn('WebGL program failed:', log.join('\\n') || gl.getProgramInfoLog(program))\n    gl.deleteProgram(program)\n    return null\n  }\n  return program\n}\n\nexport type GlAtlas = {\n  /** The texture, power-of-two sized, with mipmaps. */\n  texture: WebGLTexture\n  /** Each picture's corner in the atlas as [u0, v0, u1, v1], with v growing downwards (the texture is not flipped). */\n  rects: [number, number, number, number][]\n  /** False for a picture that did not load; its cell shows `empty` instead. */\n  loaded: boolean[]\n  /** A cell's width and height in texels, without its gutter. */\n  cell: [number, number]\n  /** The texture's width and height in texels. */\n  size: [number, number]\n}\n\nconst pow2 = (v: number) => 2 ** Math.ceil(Math.log2(Math.max(1, v)))\n\nfunction load(src: string, signal: AbortSignal) {\n  return new Promise<HTMLImageElement | null>((resolve) => {\n    const img = new Image()\n    // A picture from another origin must send CORS headers to be drawn into WebGL; without them it shows as empty.\n    if (!/^(data|blob):/.test(src)) img.crossOrigin = 'anonymous'\n    img.decoding = 'async'\n    img.onload = () => resolve(img)\n    img.onerror = () => resolve(null)\n    signal.addEventListener('abort', () => resolve(null), { once: true })\n    img.src = src\n  })\n}\n\nexport type GlAtlasOptions = {\n  /** The pictures' width over height; each is cropped from the middle to fill a cell of this shape. */\n  aspect: number\n  /** The longer side of a cell in texels, at most. Cells shrink to keep the texture within `limit`. */\n  cell?: number\n  /** The texture's largest side in texels. A 2048 atlas with mipmaps takes about 22 MB of GPU memory, a 4096 one four times that. */\n  limit?: number\n  /** The colour of a cell whose picture did not load. */\n  empty?: Rgb\n  signal: AbortSignal\n}\n\n/**\n * Loads pictures and paints them, cropped to fill a cell of `aspect` (width over height), into one texture. Each cell\n * has a gutter of its own edge pixels, so mipmaps and filtering never bleed one picture into the next. Resolves to\n * null if the stage was torn down meanwhile, or if the pictures could not be uploaded.\n */\nexport async function glAtlas(\n  gl: WebGLRenderingContext,\n  images: GlImage[],\n  { aspect, cell = 1024, limit = 2048, empty = [0.5, 0.5, 0.5], signal }: GlAtlasOptions,\n): Promise<GlAtlas | null> {\n  const n = Math.max(images.length, 1)\n  const cols = Math.ceil(Math.sqrt(n * aspect) / aspect) || 1\n  const rows = Math.ceil(n / cols)\n  const gutter = 4\n  // The largest cell in the picture's shape that fits the grid within the limit; the limit is a power of two, so the\n  // texture never rounds up past it.\n  const max = Math.min(pow2(limit), gl.getParameter(gl.MAX_TEXTURE_SIZE) as number)\n  const w = aspect >= 1 ? 1 : aspect\n  const h = aspect >= 1 ? 1 / aspect : 1\n  const size = Math.max(16, Math.floor(Math.min(cell, (max / cols - gutter * 2) / w, (max / rows - gutter * 2) / h)))\n  const cw = Math.max(1, Math.floor(size * w))\n  const ch = Math.max(1, Math.floor(size * h))\n  const W = pow2((cw + gutter * 2) * cols)\n  const H = pow2((ch + gutter * 2) * rows)\n\n  const pictures = await Promise.all(images.map((im) => load(im.src, signal)))\n  if (signal.aborted) return null\n\n  const canvas = document.createElement('canvas')\n  canvas.width = W\n  canvas.height = H\n  const c = canvas.getContext('2d')\n  if (!c) return null\n  c.imageSmoothingQuality = 'high'\n  const fill = `rgb(${empty.map((v) => Math.round(v * 255)).join(' ')})`\n  const rects: GlAtlas['rects'] = []\n  const loaded: boolean[] = []\n\n  pictures.forEach((img, i) => {\n    const x = (i % cols) * (cw + gutter * 2) + gutter\n    const y = Math.floor(i / cols) * (ch + gutter * 2) + gutter\n    rects.push([x / W, y / H, (x + cw) / W, (y + ch) / H])\n    const ok = !!img && img.naturalWidth > 0\n    loaded.push(ok)\n    if (!ok) {\n      c.fillStyle = fill\n      c.fillRect(x - gutter, y - gutter, cw + gutter * 2, ch + gutter * 2)\n      return\n    }\n    // Crop to fill the cell, from the middle.\n    const k = Math.max(cw / img.naturalWidth, ch / img.naturalHeight)\n    const sw = cw / k\n    const sh = ch / k\n    const sx = (img.naturalWidth - sw) / 2\n    const sy = (img.naturalHeight - sh) / 2\n    c.drawImage(img, sx, sy, sw, sh, x, y, cw, ch)\n    // The gutter repeats the cell's outermost pixels.\n    c.drawImage(canvas, x, y, cw, 1, x, y - gutter, cw, gutter)\n    c.drawImage(canvas, x, y + ch - 1, cw, 1, x, y + ch, cw, gutter)\n    c.drawImage(canvas, x, y - gutter, 1, ch + gutter * 2, x - gutter, y - gutter, gutter, ch + gutter * 2)\n    c.drawImage(canvas, x + cw - 1, y - gutter, 1, ch + gutter * 2, x + cw, y - gutter, gutter, ch + gutter * 2)\n  })\n\n  const texture = gl.createTexture()\n  if (!texture || gl.isContextLost()) return null\n  gl.bindTexture(gl.TEXTURE_2D, texture)\n  gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true)\n  try {\n    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas)\n  } catch {\n    // A picture tainted the canvas; nothing can be uploaded.\n    gl.deleteTexture(texture)\n    return null\n  }\n  gl.generateMipmap(gl.TEXTURE_2D)\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR)\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)\n  const aniso = gl.getExtension('EXT_texture_filter_anisotropic')\n  if (aniso) gl.texParameterf(gl.TEXTURE_2D, aniso.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(8, gl.getParameter(aniso.MAX_TEXTURE_MAX_ANISOTROPY_EXT)))\n  return { texture, rects, loaded, cell: [cw, ch], size: [W, H] }\n}\n",
      "type": "registry:hook"
    },
    {
      "path": "registry/manniche/hooks/use-reduced-motion.ts",
      "content": "import { useSyncExternalStore } from 'react'\n\nconst QUERY = '(prefers-reduced-motion: reduce)'\n\nfunction subscribe(onChange: () => void) {\n  const mq = window.matchMedia(QUERY)\n  mq.addEventListener('change', onChange)\n  return () => mq.removeEventListener('change', onChange)\n}\n\n/** True when the visitor has asked the system for less motion. */\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(QUERY).matches,\n    () => false,\n  )\n}\n",
      "type": "registry:hook"
    }
  ],
  "categories": [
    "hooks"
  ],
  "type": "registry:hook"
}