{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "atmospheres-core",
  "type": "registry:ui",
  "dependencies": [
    "motion",
    "ogl"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "atmospheres-color.ts",
      "target": "components/ui/atmospheres-color.ts",
      "type": "registry:ui",
      "content": "export type Rgb = [number, number, number];\n\n/** Dark ground: emit. Light ground: stain the ground like pigment. */\nexport type BlendMode = \"emissive\" | \"absorptive\";\n\nconst BLACK: Rgb = [0, 0, 0];\n\n/**\n * A canvas cannot sample the page behind it, so every colour reaches the shader\n * as channels. Tokens are authored in oklch, and painting the string into a 1px\n * canvas is the only reliable way to resolve an arbitrary CSS colour.\n */\nexport function resolveColor(value: string): Rgb {\n  if (typeof document === \"undefined\") return BLACK;\n  const text = value.trim();\n  if (!text) return BLACK;\n  try {\n    const canvas = document.createElement(\"canvas\");\n    canvas.width = 1;\n    canvas.height = 1;\n    const ctx = canvas.getContext(\"2d\", { willReadFrequently: true });\n    if (!ctx) return BLACK;\n    ctx.fillStyle = text;\n    ctx.fillRect(0, 0, 1, 1);\n    const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;\n    return [(r ?? 0) / 255, (g ?? 0) / 255, (b ?? 0) / 255];\n  } catch {\n    return BLACK;\n  }\n}\n\nfunction toLinear(channel: number): number {\n  const c = Math.min(Math.max(channel, 0), 1);\n  return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;\n}\n\n/** WCAG relative luminance, 0..1, from sRGB channels. */\nexport function relativeLuminance([r, g, b]: Rgb): number {\n  return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);\n}\n\n/**\n * The house law. Emissive glow on a light ground cannot be made to look matte,\n * so a light ground flips the atmosphere to pigment. The threshold sits well clear\n * of both poles: savi beige lands near 0.72, every dark theme under 0.02.\n */\nexport const LIGHT_GROUND = 0.4;\n\nexport function blendModeFor(background: Rgb): BlendMode {\n  return relativeLuminance(background) > LIGHT_GROUND\n    ? \"absorptive\"\n    : \"emissive\";\n}\n\nexport function resolveBlendMode(\n  override: BlendMode | undefined,\n  background: Rgb,\n): BlendMode {\n  return override ?? blendModeFor(background);\n}\n\n/**\n * The compositing half of the flip. Emissive canvases add their light to the\n * page; absorptive ones multiply, so a translucent pixel stains the ground and\n * a transparent one leaves it untouched.\n */\nexport function blendStyleFor(mode: BlendMode): {\n  mixBlendMode: \"plus-lighter\" | \"multiply\";\n} {\n  return {\n    mixBlendMode: mode === \"absorptive\" ? \"multiply\" : \"plus-lighter\",\n  };\n}\n\n/** 1.0 when the shader should absorb, 0.0 when it should emit. */\nexport function blendUniform(mode: BlendMode): number {\n  return mode === \"absorptive\" ? 1 : 0;\n}\n\nfunction mixRgb(a: Rgb, b: Rgb, t: number): Rgb {\n  return [\n    a[0] + (b[0] - a[0]) * t,\n    a[1] + (b[1] - a[1]) * t,\n    a[2] + (b[2] - a[2]) * t,\n  ];\n}\n\nexport function pigmentFor(hue: Rgb, ink: Rgb): Rgb {\n  return mixRgb(ink, hue, 0.22);\n}\n\nexport const MAX_STAIN = 0.62;\n"
    },
    {
      "path": "atmospheres-ground.ts",
      "target": "components/ui/atmospheres-ground.ts",
      "type": "registry:ui",
      "content": "import type { BlendMode } from \"./atmospheres-color\";\n\nexport type GroundSupport = \"port\" | \"restrict\" | \"forbid\";\n\nexport const LIGHT_GROUND_SUPPORT = {\n  kynnos: \"port\",\n  vare: \"port\",\n  utu: \"port\",\n  kuulto: \"restrict\",\n  hehku: \"forbid\",\n  loimu: \"forbid\",\n  kajastus: \"forbid\",\n  routa: \"port\",\n} as const satisfies Record<string, GroundSupport>;\n\nexport type AtmosphereName = keyof typeof LIGHT_GROUND_SUPPORT;\n\nexport function supportsGround(\n  name: AtmosphereName,\n  mode: BlendMode,\n): GroundSupport | \"n/a\" {\n  return mode === \"emissive\" ? \"n/a\" : LIGHT_GROUND_SUPPORT[name];\n}\n\n/** True when the atmosphere must not paint: a light ground it has no form on. */\nexport function hiddenOnGround(name: AtmosphereName, mode: BlendMode): boolean {\n  const support = supportsGround(name, mode);\n  return support === \"forbid\" || support === \"restrict\";\n}\n"
    },
    {
      "path": "atmospheres-glsl.ts",
      "target": "components/ui/atmospheres-glsl.ts",
      "type": "registry:ui",
      "content": "/**\n * Shared GLSL as strings, concatenated into a shader at build time. Every\n * fragment shader stays standalone: these are functions, never a framework.\n * An atmosphere must not redeclare a name it pulls in from here.\n */\n\nconst hash13 = /* glsl */ `\nfloat hash11(float p) {\n  p = fract(p * 0.1031);\n  p *= p + 33.33;\n  p *= p + p;\n  return fract(p);\n}\n\nfloat hash12(vec2 p) {\n  vec3 p3 = fract(vec3(p.xyx) * 0.1031);\n  p3 += dot(p3, p3.yzx + 33.33);\n  return fract((p3.x + p3.y) * p3.z);\n}\n\nfloat hash13(vec3 p) {\n  p = fract(p * 0.1031);\n  p += dot(p, p.yzx + 33.33);\n  return fract((p.x + p.y) * p.z);\n}\n\nvec2 hash22(vec2 p) {\n  vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));\n  p3 += dot(p3, p3.yzx + 33.33);\n  return fract((p3.xx + p3.yz) * p3.zy);\n}\n\nvec3 hash33(vec3 p) {\n  p = fract(p * vec3(0.1031, 0.1030, 0.0973));\n  p += dot(p, p.yxz + 33.33);\n  return fract((p.xxy + p.yxx) * p.zyx);\n}\n`;\n\nconst vnoise = /* glsl */ `\nfloat vnoise2(vec2 x) {\n  vec2 i = floor(x);\n  vec2 f = fract(x);\n  f = f * f * (3.0 - 2.0 * f);\n  float a = hash12(i);\n  float b = hash12(i + vec2(1.0, 0.0));\n  float c = hash12(i + vec2(0.0, 1.0));\n  float d = hash12(i + vec2(1.0, 1.0));\n  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);\n}\n\nfloat vnoise(vec3 x) {\n  vec3 i = floor(x);\n  vec3 f = fract(x);\n  f = f * f * (3.0 - 2.0 * f);\n  float n000 = hash13(i + vec3(0.0, 0.0, 0.0));\n  float n100 = hash13(i + vec3(1.0, 0.0, 0.0));\n  float n010 = hash13(i + vec3(0.0, 1.0, 0.0));\n  float n110 = hash13(i + vec3(1.0, 1.0, 0.0));\n  float n001 = hash13(i + vec3(0.0, 0.0, 1.0));\n  float n101 = hash13(i + vec3(1.0, 0.0, 1.0));\n  float n011 = hash13(i + vec3(0.0, 1.0, 1.0));\n  float n111 = hash13(i + vec3(1.0, 1.0, 1.0));\n  return mix(\n    mix(mix(n000, n100, f.x), mix(n010, n110, f.x), f.y),\n    mix(mix(n001, n101, f.x), mix(n011, n111, f.x), f.y),\n    f.z\n  );\n}\n`;\n\nconst fbm = /* glsl */ `\nfloat fbm(vec3 p, int octaves) {\n  float a = 0.5;\n  float s = 0.0;\n  for (int i = 0; i < octaves; i++) {\n    s += a * vnoise(p);\n    p = p * 2.02 + vec3(11.3, 7.7, 3.1);\n    a *= 0.5;\n  }\n  return s;\n}\n\nfloat fbm2(vec2 p, int octaves) {\n  float a = 0.5;\n  float s = 0.0;\n  for (int i = 0; i < octaves; i++) {\n    s += a * vnoise2(p);\n    p = p * 2.02 + vec2(11.3, 7.7);\n    a *= 0.5;\n  }\n  return s;\n}\n`;\n\nconst curl = /* glsl */ `\nvec3 curlPotential(vec3 p) {\n  return vec3(\n    fbm(p, 3),\n    fbm(p + vec3(31.4, 17.7, 9.2), 3),\n    fbm(p + vec3(-7.1, 23.9, 41.3), 3)\n  );\n}\n\n/** Divergence-free: the field shears and stretches but never bunches. */\nvec3 curl(vec3 p, float eps) {\n  vec3 dx = vec3(eps, 0.0, 0.0);\n  vec3 dy = vec3(0.0, eps, 0.0);\n  vec3 dz = vec3(0.0, 0.0, eps);\n  vec3 px1 = curlPotential(p + dx);\n  vec3 px0 = curlPotential(p - dx);\n  vec3 py1 = curlPotential(p + dy);\n  vec3 py0 = curlPotential(p - dy);\n  vec3 pz1 = curlPotential(p + dz);\n  vec3 pz0 = curlPotential(p - dz);\n  float k = 1.0 / (2.0 * eps);\n  return vec3(\n    (py1.z - py0.z) - (pz1.y - pz0.y),\n    (pz1.x - pz0.x) - (px1.z - px0.z),\n    (px1.y - px0.y) - (py1.x - py0.x)\n  ) * k;\n}\n\n/** The 2D case is the perpendicular gradient of a scalar potential. */\nvec2 curl2(vec2 p, float eps) {\n  float ny1 = fbm2(p + vec2(0.0, eps), 3);\n  float ny0 = fbm2(p - vec2(0.0, eps), 3);\n  float nx1 = fbm2(p + vec2(eps, 0.0), 3);\n  float nx0 = fbm2(p - vec2(eps, 0.0), 3);\n  float k = 1.0 / (2.0 * eps);\n  return vec2(ny1 - ny0, nx0 - nx1) * k;\n}\n`;\n\nconst worley = /* glsl */ `\nvec2 worleyF(vec2 p) {\n  vec2 ip = floor(p);\n  vec2 fp = fract(p);\n  float f1 = 8.0;\n  float f2 = 8.0;\n  for (int y = -1; y <= 1; y++) {\n    for (int x = -1; x <= 1; x++) {\n      vec2 g = vec2(float(x), float(y));\n      vec2 o = hash22(ip + g);\n      vec2 r = g + o - fp;\n      float d = dot(r, r);\n      if (d < f1) {\n        f2 = f1;\n        f1 = d;\n      } else if (d < f2) {\n        f2 = d;\n      }\n    }\n  }\n  return vec2(sqrt(f1), sqrt(f2));\n}\n\nfloat worley(vec2 p) {\n  return worleyF(p).x;\n}\n\n/** F2 - F1: zero on the cell walls, so it draws cracks, not cells. */\nfloat craquelure(vec2 p) {\n  vec2 f = worleyF(p);\n  return f.y - f.x;\n}\n`;\n\nconst isoband = /* glsl */ `\nfloat isoband(float fb, float aa) {\n  return smoothstep(0.5 - aa, 0.5, fb) * (1.0 - smoothstep(0.5, 0.5 + aa, fb));\n}\n\n/** Anti-aliased contour rims of a field, count bands per unit. */\nfloat isobands(float v, float count) {\n  float b = v * count;\n  float aa = fwidth(b) * 1.5 + 0.04;\n  return isoband(fract(b), aa);\n}\n`;\n\nconst dither = /* glsl */ `\n/** Interleaved gradient noise: blue-noise-ish for the cost of one fract. */\nfloat ign(vec2 fragCoord) {\n  return fract(52.9829189 * fract(dot(fragCoord, vec2(0.06711056, 0.00583715))));\n}\n\nvec3 dither(vec3 col, vec2 fragCoord, float amount) {\n  return col + (ign(fragCoord) - 0.5) * amount;\n}\n\nfloat ditherAlpha(float a, vec2 fragCoord, float amount) {\n  return a + (ign(fragCoord) - 0.5) * amount;\n}\n`;\n\nconst tonemap = /* glsl */ `\nvec3 tonemapExp(vec3 c, float exposure) {\n  return vec3(1.0) - exp(-c * exposure);\n}\n\nvec3 tonemapACES(vec3 x) {\n  const float a = 2.51;\n  const float b = 0.03;\n  const float c = 2.43;\n  const float d = 0.59;\n  const float e = 0.14;\n  return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0);\n}\n`;\n\nconst stain = /* glsl */ `\nfloat soak(float depth, float sigma) {\n  return clamp(1.0 - exp(-max(depth, 0.0) * sigma), 0.0, 1.0);\n}\n\nvec3 hold(vec3 pigment, float floorLevel) {\n  float lum = dot(pigment, vec3(0.2126, 0.7152, 0.0722));\n  return clamp(pigment * (max(lum, floorLevel) / max(lum, 1e-4)), 0.0, 1.0);\n}\n`;\n\nconst composite = /* glsl */ `\n/**\n * Safari ignores premultipliedAlpha: false and composites the drawing buffer as\n * though it were premultiplied, so an unpremultiplied colour is effectively\n * divided by its own alpha and blows out. The context is therefore premultiplied\n * and every atmosphere ends here.\n *\n * The alpha is squared and the colour attenuated by it because the old pipeline\n * did exactly that: SRC_ALPHA blending into a cleared buffer wrote rgb * a with\n * alpha a * a, and the browser then premultiplied again on composite. Every\n * atmosphere was tuned against that image, so it is the image, not a mistake to\n * unwind.\n */\nvec4 composite(vec3 rgb, float alpha) {\n  float a = clamp(alpha, 0.0, 1.0);\n  float cov = a * a;\n  return vec4(clamp(rgb, 0.0, 1.0) * a * cov, cov);\n}\n`;\n\ninterface Chunk {\n  needs: readonly GlslChunk[];\n  source: string;\n}\n\nexport type GlslChunk =\n  | \"hash13\"\n  | \"vnoise\"\n  | \"fbm\"\n  | \"curl\"\n  | \"worley\"\n  | \"isoband\"\n  | \"dither\"\n  | \"tonemap\"\n  | \"stain\"\n  | \"composite\";\n\nconst CHUNKS: Record<GlslChunk, Chunk> = {\n  hash13: { needs: [], source: hash13 },\n  vnoise: { needs: [\"hash13\"], source: vnoise },\n  fbm: { needs: [\"vnoise\"], source: fbm },\n  curl: { needs: [\"fbm\"], source: curl },\n  worley: { needs: [\"hash13\"], source: worley },\n  isoband: { needs: [], source: isoband },\n  dither: { needs: [], source: dither },\n  tonemap: { needs: [], source: tonemap },\n  stain: { needs: [], source: stain },\n  composite: { needs: [], source: composite },\n};\n\nexport const GLSL_CHUNK_NAMES = Object.keys(CHUNKS) as GlslChunk[];\n\n/** Concatenates chunks with their dependencies, each emitted exactly once. */\nexport function glsl(...names: readonly GlslChunk[]): string {\n  const seen = new Set<GlslChunk>();\n  const parts: string[] = [];\n  const visit = (name: GlslChunk) => {\n    if (seen.has(name)) return;\n    seen.add(name);\n    for (const need of CHUNKS[name].needs) visit(need);\n    parts.push(CHUNKS[name].source);\n  };\n  for (const name of names) visit(name);\n  return parts.join(\"\\n\");\n}\n"
    },
    {
      "path": "atmospheres-gl.ts",
      "target": "components/ui/atmospheres-gl.ts",
      "type": "registry:ui",
      "content": "import { Mesh, Program, Renderer, Triangle } from \"ogl\";\n\n/**\n * ogl gates array uniforms on Array.isArray, so vectors must be plain number\n * arrays. A Float32Array silently uploads nothing at all.\n */\nexport type UniformValue = number | number[] | boolean;\nexport type Uniforms = Record<string, { value: UniformValue }>;\n\n/** Writes a uniform if the shader declares it, so a trimmed shader never throws. */\nexport function setUniform(\n  uniforms: Uniforms,\n  name: string,\n  value: UniformValue,\n): void {\n  const slot = uniforms[name];\n  if (slot) slot.value = value;\n}\n\nexport const fullscreenVertexShader = /* glsl */ `#version 300 es\nin vec2 position;\nvoid main() {\n  gl_Position = vec4(position, 0.0, 1.0);\n}\n`;\n\nexport interface GlSurface {\n  uniforms: Uniforms;\n  resize(width: number, height: number, dpr: number): void;\n  render(): void;\n  dispose(): void;\n}\n\nexport interface CreateGlSurfaceOptions {\n  canvas: HTMLCanvasElement;\n  fragment: string;\n  vertex?: string;\n  uniforms: Uniforms;\n  onContextLost?: () => void;\n}\n\n/**\n * The shared shell: a WebGL2 context drawing one fullscreen triangle. It knows\n * nothing about any image. Returns null when WebGL2 is unavailable, so callers\n * fall back rather than throw.\n */\nexport function createGlSurface(\n  options: CreateGlSurfaceOptions,\n): GlSurface | null {\n  const { canvas, fragment, uniforms, onContextLost } = options;\n\n  let renderer: Renderer;\n  try {\n    renderer = new Renderer({\n      canvas,\n      webgl: 2,\n      dpr: 1,\n      alpha: true,\n      // Safari composites the buffer as premultiplied whatever this says, so the\n      // only value both browsers agree on is true. Shaders end in composite().\n      premultipliedAlpha: true,\n      antialias: false,\n    });\n  } catch {\n    return null;\n  }\n\n  const gl = renderer.gl;\n  if (!gl || !(\"drawBuffers\" in gl)) return null;\n  gl.clearColor(0, 0, 0, 0);\n\n  let program: Program;\n  try {\n    program = new Program(gl, {\n      vertex: options.vertex ?? fullscreenVertexShader,\n      fragment,\n      transparent: true,\n      depthTest: false,\n      uniforms,\n    });\n  } catch {\n    return null;\n  }\n\n  const mesh = new Mesh(gl, { geometry: new Triangle(gl), program });\n  const live = program.uniforms as Uniforms;\n\n  const handleLost = (event: Event) => {\n    event.preventDefault();\n    onContextLost?.();\n  };\n  canvas.addEventListener(\"webglcontextlost\", handleLost);\n\n  return {\n    uniforms: live,\n    resize(width, height, dpr) {\n      renderer.dpr = dpr;\n      renderer.setSize(width, height);\n      const resolution = live.uResolution;\n      if (resolution) resolution.value = [width * dpr, height * dpr];\n    },\n    render() {\n      renderer.render({ scene: mesh });\n    },\n    dispose() {\n      canvas.removeEventListener(\"webglcontextlost\", handleLost);\n      program.remove();\n      mesh.geometry.remove();\n    },\n  };\n}\n"
    },
    {
      "path": "use-token-colors.ts",
      "target": "components/ui/use-token-colors.ts",
      "type": "registry:ui",
      "content": "\"use client\";\nimport * as React from \"react\";\nimport {\n  type BlendMode,\n  blendModeFor,\n  type Rgb,\n  relativeLuminance,\n  resolveColor,\n} from \"./atmospheres-color\";\n\n/**\n * Increments on every theme change. For atmospheres whose colours come from props\n * rather than roles: re-resolve on this and repaint.\n */\nexport function useThemeVersion(): number {\n  const [version, setVersion] = React.useState(0);\n  React.useEffect(() => {\n    if (typeof MutationObserver === \"undefined\") return;\n    const observer = new MutationObserver(() => setVersion((v) => v + 1));\n    observer.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"data-theme\", \"class\", \"style\"],\n    });\n    return () => observer.disconnect();\n  }, []);\n  return version;\n}\n\nexport interface TokenColors<R extends string> {\n  /** Resolved channels for each requested role, keyed by role name. */\n  colors: Record<R, Rgb>;\n  /** The page ground, always resolved, because the blend flip depends on it. */\n  bg: Rgb;\n  /** Relative luminance of the ground, 0..1. */\n  luminance: number;\n  /** The house law, defaulted from the ground. */\n  mode: BlendMode;\n}\n\nexport interface UseTokenColorsOptions {\n  /** Resolve against a scoped theme node rather than the document root. */\n  scopeRef?: React.RefObject<HTMLElement | null>;\n}\n\nconst BLACK: Rgb = [0, 0, 0];\n\nfunction read<R extends string>(\n  roles: readonly R[],\n  scope: HTMLElement | null,\n): TokenColors<R> {\n  const node = scope ?? document.documentElement;\n  const styles = getComputedStyle(node);\n  const token = (role: string) =>\n    styles.getPropertyValue(`--usva-${role}`).trim();\n\n  const colors = {} as Record<R, Rgb>;\n  for (const role of roles) colors[role] = resolveColor(token(role));\n\n  const bg = resolveColor(token(\"bg\"));\n  return {\n    colors,\n    bg,\n    luminance: relativeLuminance(bg),\n    mode: blendModeFor(bg),\n  };\n}\n\nfunction fallback<R extends string>(roles: readonly R[]): TokenColors<R> {\n  const colors = {} as Record<R, Rgb>;\n  for (const role of roles) colors[role] = BLACK;\n  return { colors, bg: BLACK, luminance: 0, mode: \"emissive\" };\n}\n\nfunction serialize<R extends string>(state: TokenColors<R>): string {\n  return `${state.mode}|${state.bg.join()}|${Object.entries(state.colors)\n    .map(([role, rgb]) => `${role}:${(rgb as Rgb).join()}`)\n    .join(\"|\")}`;\n}\n\n/**\n * Resolves token roles to channels and re-resolves them whenever the theme\n * changes. Without the MutationObserver a runtime theme swap leaves every\n * canvas painted in the colours of the theme it mounted under.\n */\nexport function useTokenColors<R extends string>(\n  roles: readonly R[],\n  options: UseTokenColorsOptions = {},\n): TokenColors<R> {\n  const { scopeRef } = options;\n  const key = roles.join(\",\");\n  const rolesRef = React.useRef(roles);\n  rolesRef.current = roles;\n\n  const [state, setState] = React.useState<TokenColors<R>>(() =>\n    fallback(roles),\n  );\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: the role list rides a ref, so its contents key the effect, not its identity.\n  React.useEffect(() => {\n    if (typeof document === \"undefined\") return;\n    const current = rolesRef.current;\n\n    let last = \"\";\n    const sync = () => {\n      const next = read(current, scopeRef?.current ?? null);\n      const signature = serialize(next);\n      if (signature === last) return;\n      last = signature;\n      setState(next);\n    };\n    sync();\n\n    if (typeof MutationObserver === \"undefined\") return;\n    const observer = new MutationObserver(sync);\n    const attributeFilter = [\"data-theme\", \"class\", \"style\"];\n    observer.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter,\n    });\n    const scope = scopeRef?.current;\n    if (scope && scope !== document.documentElement) {\n      observer.observe(scope, { attributes: true, attributeFilter });\n    }\n    return () => observer.disconnect();\n  }, [key, scopeRef]);\n\n  return state;\n}\n"
    },
    {
      "path": "use-gl-canvas.ts",
      "target": "components/ui/use-gl-canvas.ts",
      "type": "registry:ui",
      "content": "\"use client\";\nimport { useReducedMotion } from \"motion/react\";\nimport * as React from \"react\";\nimport { createGlSurface, type Uniforms } from \"./atmospheres-gl\";\n\n/** How long the window has to hold still before the drawing buffer is rebuilt. */\nconst RESIZE_SETTLE_MS = 150;\n\nexport interface CaptureOptions {\n  /** Opaque backdrop painted under the atmosphere, eg the studio canvas colour. */\n  bg: string;\n  /** Multiplier on the backing-store size. Defaults to 1 (already dpr-scaled). */\n  scale?: number;\n}\n\ntype CaptureFn = (opts: CaptureOptions) => Promise<Blob>;\n\n// The renderer runs with preserveDrawingBuffer:false, so the buffer is only\n// readable synchronously in the same tick as render(). Each live canvas\n// registers a grabber here; the drawImage read happens in-frame, the toBlob\n// after is free.\nconst captureRegistry = new WeakMap<HTMLCanvasElement, CaptureFn>();\n\n/**\n * Take an opaque PNG of a mounted atmosphere. Rejects when the canvas is not a\n * currently live atmosphere surface.\n */\nexport function captureAtmosphere(\n  canvas: HTMLCanvasElement,\n  opts: CaptureOptions,\n): Promise<Blob> {\n  const grab = captureRegistry.get(canvas);\n  if (!grab)\n    return Promise.reject(new Error(\"canvas is not a live atmosphere\"));\n  return grab(opts);\n}\n\nexport interface GlPointer {\n  /** Pixels from the container centre, y up. */\n  x: number;\n  y: number;\n  /** Eases 0..1 as the pointer enters and leaves. */\n  amount: number;\n  inside: boolean;\n}\n\nexport interface GlFrame {\n  /** Seconds of visible animation time. Stops accruing while paused. */\n  time: number;\n  /** CSS pixels. */\n  width: number;\n  height: number;\n  /** Device pixel ratio actually used for the backing store. */\n  dpr: number;\n  pointer: GlPointer;\n}\n\nexport interface UseGlCanvasOptions {\n  fragment: string;\n  vertex?: string;\n  /** Built once per context. Every uniform the shader reads must appear here. */\n  uniforms: () => Uniforms;\n  /** Called before each render. uResolution and uTime are already written. */\n  onFrame?: (uniforms: Uniforms, frame: GlFrame) => void;\n  /** Ceiling on the device pixel ratio. Defaults to 2. */\n  maxDpr?: number;\n  /** Backing-store scale. 0.5 renders at half res and lets the browser upscale. */\n  renderScale?: number;\n  /** Track the pointer over the container. Defaults to false. */\n  pointer?: boolean;\n  /** Per-frame ease of pointer.amount toward its target. */\n  pointerEase?: number;\n  /** Time fed to the still frame under prefers-reduced-motion. */\n  stillTime?: number;\n  /** Mount the context at all. Defaults to true. */\n  enabled?: boolean;\n}\n\nexport interface GlCanvas {\n  containerRef: React.RefObject<HTMLDivElement | null>;\n  canvasRef: React.RefObject<HTMLCanvasElement | null>;\n  /** True when a canvas should be mounted: animating or holding a still frame. */\n  active: boolean;\n  /** True while the loop is running, false for the reduced-motion still frame. */\n  animated: boolean;\n  failed: boolean;\n  /** Repaint once. Needed when a still frame's inputs change, eg on theme swap. */\n  redraw: () => void;\n}\n\nconst DEFAULT_POINTER_EASE = 0.06;\n\nfunction approach(current: number, target: number, ease: number): number {\n  return current + (target - current) * ease;\n}\n\n/** Composite the GL buffer over an opaque bg. drawImage must run in-frame. */\nfunction grabToBlob(\n  src: HTMLCanvasElement,\n  { bg, scale = 1 }: CaptureOptions,\n): Promise<Blob> {\n  const w = Math.max(1, Math.round(src.width * scale));\n  const h = Math.max(1, Math.round(src.height * scale));\n  const out = document.createElement(\"canvas\");\n  out.width = w;\n  out.height = h;\n  const ctx = out.getContext(\"2d\");\n  if (!ctx) return Promise.reject(new Error(\"2d context unavailable\"));\n  ctx.fillStyle = bg;\n  ctx.fillRect(0, 0, w, h);\n  ctx.drawImage(src, 0, 0, w, h);\n  return new Promise((resolve, reject) => {\n    out.toBlob(\n      (blob) => (blob ? resolve(blob) : reject(new Error(\"toBlob failed\"))),\n      \"image/png\",\n    );\n  });\n}\n\n/**\n * The plumbing every atmosphere repeats: context, sizing, pause, reduced motion,\n * context loss and cleanup ordering. It owns no image and no parameters.\n */\nexport function useGlCanvas(options: UseGlCanvasOptions): GlCanvas {\n  const {\n    fragment,\n    vertex,\n    maxDpr = 2,\n    renderScale = 1,\n    pointer: trackPointer = false,\n    pointerEase = DEFAULT_POINTER_EASE,\n    stillTime = 0,\n    enabled = true,\n  } = options;\n\n  const reduced = useReducedMotion();\n  const containerRef = React.useRef<HTMLDivElement | null>(null);\n  const canvasRef = React.useRef<HTMLCanvasElement | null>(null);\n  const redrawRef = React.useRef<() => void>(() => {});\n\n  const buildRef = React.useRef(options.uniforms);\n  buildRef.current = options.uniforms;\n  const frameRef = React.useRef(options.onFrame);\n  frameRef.current = options.onFrame;\n\n  const [failed, setFailed] = React.useState(false);\n  const [mounted, setMounted] = React.useState(false);\n  React.useEffect(() => setMounted(true), []);\n\n  const live = mounted && enabled && !failed;\n  const animated = live && !reduced;\n  const still = live && !!reduced;\n  const active = animated || still;\n\n  React.useEffect(() => {\n    if (!active) return;\n    const canvas = canvasRef.current;\n    const container = containerRef.current;\n    if (!canvas || !container) return;\n\n    const surface = createGlSurface({\n      canvas,\n      fragment,\n      vertex,\n      uniforms: buildRef.current(),\n      onContextLost: () => setFailed(true),\n    });\n    if (!surface) {\n      setFailed(true);\n      return;\n    }\n\n    const scale = Math.max(renderScale, 0.1);\n    let width = 0;\n    let height = 0;\n    let dpr = 0;\n\n    const stretch = () => {\n      const box = container.getBoundingClientRect();\n      const w = Math.ceil(box.width);\n      const h = Math.ceil(box.height);\n      if (w <= 0 || h <= 0) return false;\n      canvas.style.width = `${w}px`;\n      canvas.style.height = `${h}px`;\n      return true;\n    };\n\n    const measure = () => {\n      const box = container.getBoundingClientRect();\n      const w = Math.ceil(box.width);\n      const h = Math.ceil(box.height);\n      if (w <= 0 || h <= 0) return false;\n      const nextDpr = Math.min(window.devicePixelRatio || 1, maxDpr) * scale;\n      if (w !== width || h !== height || nextDpr !== dpr) {\n        canvas.style.width = `${w}px`;\n        canvas.style.height = `${h}px`;\n        surface.resize(w, h, nextDpr);\n      }\n      width = w;\n      height = h;\n      dpr = nextDpr;\n      return true;\n    };\n\n    let settleTimer = 0;\n    const settle = (after: () => void) => {\n      stretch();\n      window.clearTimeout(settleTimer);\n      settleTimer = window.setTimeout(() => {\n        if (measure()) after();\n      }, RESIZE_SETTLE_MS);\n    };\n\n    const pointer: GlPointer = { x: 0, y: 0, amount: 0, inside: false };\n    let pointerTarget = 0;\n    let elapsed = 0;\n    let pending: {\n      opts: CaptureOptions;\n      resolve: (blob: Blob) => void;\n      reject: (err: unknown) => void;\n    } | null = null;\n\n    const draw = (time: number) => {\n      pointer.amount = approach(pointer.amount, pointerTarget, pointerEase);\n      const u = surface.uniforms;\n      const clock = u.uTime;\n      if (clock) clock.value = time;\n      frameRef.current?.(u, { time, width, height, dpr, pointer });\n      surface.render();\n      if (pending) {\n        const { opts, resolve, reject } = pending;\n        pending = null;\n        grabToBlob(canvas, opts).then(resolve, reject);\n      }\n    };\n    redrawRef.current = () => {\n      if (measure()) draw(still ? stillTime : elapsed);\n    };\n\n    if (!measure()) {\n      surface.dispose();\n      return;\n    }\n\n    captureRegistry.set(\n      canvas,\n      (opts) =>\n        new Promise<Blob>((resolve, reject) => {\n          pending = { opts, resolve, reject };\n          // Force one in-frame draw+grab; covers the paused and still cases too.\n          redrawRef.current();\n        }),\n    );\n\n    if (still) {\n      draw(stillTime);\n      const observer =\n        typeof ResizeObserver === \"undefined\"\n          ? null\n          : new ResizeObserver(() => {\n              settle(() => draw(stillTime));\n            });\n      observer?.observe(container);\n      return () => {\n        redrawRef.current = () => {};\n        captureRegistry.delete(canvas);\n        window.clearTimeout(settleTimer);\n        observer?.disconnect();\n        surface.dispose();\n        canvas.style.width = \"\";\n        canvas.style.height = \"\";\n      };\n    }\n\n    let raf = 0;\n    let last = performance.now();\n    let killed = false;\n    const tick = () => {\n      if (killed) return;\n      const now = performance.now();\n      elapsed += (now - last) / 1000;\n      last = now;\n      draw(elapsed);\n      raf = requestAnimationFrame(tick);\n    };\n    const stop = () => cancelAnimationFrame(raf);\n    const run = () => {\n      last = performance.now();\n      stop();\n      raf = requestAnimationFrame(tick);\n    };\n\n    let box = container.getBoundingClientRect();\n    const remeasure = () => {\n      box = container.getBoundingClientRect();\n    };\n\n    const onMove = (event: PointerEvent) => {\n      if (!trackPointer) return;\n      pointer.x = event.clientX - box.left - box.width / 2;\n      pointer.y = box.height / 2 - (event.clientY - box.top);\n      pointer.inside = true;\n      pointerTarget = 1;\n    };\n    const onLeave = () => {\n      pointer.inside = false;\n      pointerTarget = 0;\n    };\n    if (trackPointer) {\n      container.addEventListener(\"pointermove\", onMove, { passive: true });\n      container.addEventListener(\"pointerleave\", onLeave, { passive: true });\n      window.addEventListener(\"scroll\", remeasure, {\n        passive: true,\n        capture: true,\n      });\n    }\n\n    let visible = true;\n    const io =\n      typeof IntersectionObserver === \"undefined\"\n        ? null\n        : new IntersectionObserver((entries) => {\n            visible = entries[0]?.isIntersecting ?? true;\n            if (visible && !document.hidden) run();\n            else stop();\n          });\n    io?.observe(container);\n\n    const onVisibility = () => {\n      if (document.hidden || !visible) stop();\n      else run();\n    };\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    const observer =\n      typeof ResizeObserver === \"undefined\"\n        ? null\n        : new ResizeObserver(() => {\n            settle(remeasure);\n          });\n    observer?.observe(container);\n\n    run();\n\n    return () => {\n      killed = true;\n      redrawRef.current = () => {};\n      captureRegistry.delete(canvas);\n      window.clearTimeout(settleTimer);\n      if (trackPointer) {\n        container.removeEventListener(\"pointermove\", onMove);\n        container.removeEventListener(\"pointerleave\", onLeave);\n        window.removeEventListener(\"scroll\", remeasure, { capture: true });\n      }\n      io?.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      observer?.disconnect();\n      stop();\n      surface.dispose();\n      canvas.style.width = \"\";\n      canvas.style.height = \"\";\n    };\n  }, [\n    active,\n    still,\n    fragment,\n    vertex,\n    maxDpr,\n    renderScale,\n    trackPointer,\n    pointerEase,\n    stillTime,\n  ]);\n\n  const redraw = React.useCallback(() => redrawRef.current(), []);\n  return { containerRef, canvasRef, active, animated, failed, redraw };\n}\n"
    }
  ]
}