{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "halftone-bloom",
  "title": "HalftoneBloom",
  "description": "Halftone ring field revealed by soft drifting color blooms: teal and amber light behind a punched-metal screen.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/components/halftone-bloom/halftone-bloom.tsx",
      "content": "\"use client\";\n\n/*\n * HalftoneBloom: a punched-metal halftone screen revealed by soft\n * drifting color lights. The default rig recreates the heritage\n * background (cool teal left, warm amber right overexposing toward\n * cream); pass a custom lights array to recreate any of the neon.com\n * pattern strips or your own rig. Transparent outside the lit rings,\n * so it works over dark and light surfaces alike. Static single frame\n * under reduced motion. The GLSL lives in halftone-bloom-shader.ts.\n */\n\nimport { useEffect, useMemo, useRef } from \"react\";\nimport type { ComponentProps } from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { BLOOM_FRAGMENT, BLOOM_VERTEX } from \"./halftone-bloom-shader\";\n\nexport interface BloomLight {\n  /** Light color. */\n  color: string;\n  /** 0-1 anchor across the width. */\n  x: number;\n  /** 0-1 anchor up from the bottom. */\n  y: number;\n  /** Light radius as a share of the width. */\n  radius?: number;\n  /** Brightness gain. */\n  intensity?: number;\n  /** 0-1 pull toward the highlight color at the hottest cells. */\n  overexpose?: number;\n}\n\nexport type HalftoneBloomProps = Omit<ComponentProps<\"canvas\">, \"children\"> & {\n  /** Light drift speed multiplier; 0 freezes the field. */\n  speed?: number;\n  /** Cell pitch in CSS pixels. */\n  gap?: number;\n  /** 0-1 punched-hole radius as a share of the cell. */\n  holeSize?: number;\n  /** Hole offset from the cell center, in cell units. */\n  holeOffset?: [number, number];\n  /** Overall brightness multiplier. */\n  intensity?: number;\n  /** 0-1 how far lights wander from their anchors. */\n  drift?: number;\n  /** The overexposed highlight color. */\n  highlight?: string;\n  /** The light rig, up to 6 lights. */\n  lights?: BloomLight[];\n};\n\nconst MAX_LIGHTS = 6;\n\nconst DEFAULT_HOLE_OFFSET: [number, number] = [-0.09, -0.09];\n\nconst DEFAULT_LIGHTS: BloomLight[] = [\n  { color: \"#1d5e57\", radius: 0.25, x: 0.06, y: 0.9 },\n  { color: \"#1d5e57\", intensity: 0.95, radius: 0.21, x: 0.42, y: 0.05 },\n  {\n    color: \"#c4692e\",\n    intensity: 1.6,\n    overexpose: 1,\n    radius: 0.27,\n    x: 0.92,\n    y: 0.45,\n  },\n  {\n    color: \"#c4692e\",\n    intensity: 1.1,\n    overexpose: 1,\n    radius: 0.19,\n    x: 0.78,\n    y: 0.08,\n  },\n];\n\n/**\n * Seconds for a dial change to close ~63% of its gap — uniforms ease\n * toward their targets each frame, so a dragged slider glides\n * instead of snapping.\n */\nconst SMOOTH_TAU = 0.12;\n\nconst parseColor = (css: string): [number, number, number] => {\n  const probe = document.createElement(\"canvas\");\n  probe.width = 1;\n  probe.height = 1;\n  const context = probe.getContext(\"2d\");\n\n  if (!context) {\n    return [0, 0.9, 0.6];\n  }\n\n  context.fillStyle = css;\n  context.fillRect(0, 0, 1, 1);\n  const [r, g, b] = context.getImageData(0, 0, 1, 1).data;\n  return [(r ?? 0) / 255, (g ?? 0) / 255, (b ?? 0) / 255];\n};\n\nconst warnDev = (message: string) => {\n  if (typeof process !== \"undefined\" && process.env.NODE_ENV !== \"production\") {\n    console.warn(message);\n  }\n};\n\nconst compile = (\n  gl: WebGLRenderingContext,\n  type: number,\n  source: string\n): WebGLShader | null => {\n  const shader = gl.createShader(type);\n\n  if (!shader) {\n    return null;\n  }\n\n  gl.shaderSource(shader, source);\n  gl.compileShader(shader);\n\n  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n    warnDev(\n      `neon-ui: shader compile failed: ${gl.getShaderInfoLog(shader) ?? \"unknown\"}`\n    );\n    gl.deleteShader(shader);\n    return null;\n  }\n\n  return shader;\n};\n\nexport const HalftoneBloom = ({\n  className,\n  drift = 1,\n  gap = 12,\n  highlight = \"#ecdcae\",\n  holeOffset = DEFAULT_HOLE_OFFSET,\n  holeSize = 0.22,\n  intensity = 1,\n  lights = DEFAULT_LIGHTS,\n  speed = 1,\n  ...props\n}: HalftoneBloomProps) => {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  // Shader time, accumulated frame by frame so a speed change scales\n  // the flow from here instead of teleporting the whole field.\n  const phaseRef = useRef(0);\n  const lastFrameRef = useRef<number | null>(null);\n  // Smoothed dial values, persisting across prop-driven re-inits.\n  const smoothedRef = useRef<Record<string, number> | null>(null);\n  const [holeX, holeY] = holeOffset;\n  const rigKey = useMemo(\n    () =>\n      lights\n        .map(\n          (light) =>\n            `${light.color}/${light.x}/${light.y}/${light.radius ?? 0.25}/${light.intensity ?? 1}/${light.overexpose ?? 0}`\n        )\n        .join(\"|\"),\n    [lights]\n  );\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    const gl = canvas?.getContext(\"webgl\", {\n      alpha: true,\n      premultipliedAlpha: true,\n    });\n\n    if (!(canvas && gl)) {\n      return;\n    }\n\n    const vertex = compile(gl, gl.VERTEX_SHADER, BLOOM_VERTEX);\n    const fragment = compile(gl, gl.FRAGMENT_SHADER, BLOOM_FRAGMENT);\n    const program = gl.createProgram();\n\n    if (!(vertex && fragment && program)) {\n      if (vertex) {\n        gl.deleteShader(vertex);\n      }\n      if (fragment) {\n        gl.deleteShader(fragment);\n      }\n      return;\n    }\n\n    gl.attachShader(program, vertex);\n    gl.attachShader(program, fragment);\n    gl.linkProgram(program);\n\n    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n      warnDev(\n        `neon-ui: program link failed: ${gl.getProgramInfoLog(program) ?? \"unknown\"}`\n      );\n      gl.deleteProgram(program);\n      gl.deleteShader(vertex);\n      gl.deleteShader(fragment);\n      return;\n    }\n\n    // oxlint-disable-next-line react/react-compiler -- WebGL method, not a React hook\n    gl.useProgram(program);\n\n    const quad = gl.createBuffer();\n    gl.bindBuffer(gl.ARRAY_BUFFER, quad);\n    gl.bufferData(\n      gl.ARRAY_BUFFER,\n      new Float32Array([-1, -1, 3, -1, -1, 3]),\n      gl.STATIC_DRAW\n    );\n    const position = gl.getAttribLocation(program, \"a_position\");\n    gl.enableVertexAttribArray(position);\n    gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);\n\n    const uResolution = gl.getUniformLocation(program, \"u_resolution\");\n    const uTime = gl.getUniformLocation(program, \"u_time\");\n    const uGap = gl.getUniformLocation(program, \"u_gap\");\n    const uHole = gl.getUniformLocation(program, \"u_hole\");\n    const uHoleOffset = gl.getUniformLocation(program, \"u_hole_offset\");\n    const uIntensity = gl.getUniformLocation(program, \"u_intensity\");\n    const uDrift = gl.getUniformLocation(program, \"u_drift\");\n    const uHighlight = gl.getUniformLocation(program, \"u_highlight\");\n    const uCount = gl.getUniformLocation(program, \"u_count\");\n\n    const dpr = Math.min(window.devicePixelRatio || 1, 2);\n    const targets: Record<string, number> = {\n      gap,\n      holeSize,\n      intensity,\n      speed,\n    };\n\n    smoothedRef.current ??= { ...targets };\n    const smoothed = smoothedRef.current;\n\n    /** Ease every dial toward its target and upload; k=1 snaps. */\n    const applyUniforms = (k: number) => {\n      for (const key of Object.keys(targets)) {\n        const current = smoothed[key] ?? targets[key] ?? 0;\n        smoothed[key] = current + ((targets[key] ?? 0) - current) * k;\n      }\n\n      gl.uniform1f(uGap, (smoothed.gap ?? gap) * dpr);\n      gl.uniform1f(uHole, smoothed.holeSize ?? holeSize);\n      gl.uniform1f(uIntensity, smoothed.intensity ?? intensity);\n    };\n    gl.uniform2f(uHoleOffset, holeX, holeY);\n    gl.uniform1f(uIntensity, intensity);\n    gl.uniform1f(uDrift, drift);\n\n    const [hr, hg, hb] = parseColor(highlight);\n    gl.uniform3f(uHighlight, hr, hg, hb);\n\n    const rig = lights.slice(0, MAX_LIGHTS);\n    const positions = new Float32Array(MAX_LIGHTS * 2);\n    const rigColors = new Float32Array(MAX_LIGHTS * 3);\n    const radii = new Float32Array(MAX_LIGHTS);\n    const gains = new Float32Array(MAX_LIGHTS);\n    const overs = new Float32Array(MAX_LIGHTS);\n\n    for (const [index, light] of rig.entries()) {\n      const [r, g, b] = parseColor(light.color);\n      positions[index * 2] = light.x;\n      positions[index * 2 + 1] = light.y;\n      rigColors[index * 3] = r;\n      rigColors[index * 3 + 1] = g;\n      rigColors[index * 3 + 2] = b;\n      radii[index] = light.radius ?? 0.25;\n      gains[index] = light.intensity ?? 1;\n      overs[index] = light.overexpose ?? 0;\n    }\n\n    gl.uniform1i(uCount, rig.length);\n    gl.uniform2fv(gl.getUniformLocation(program, \"u_pos\"), positions);\n    gl.uniform3fv(gl.getUniformLocation(program, \"u_col\"), rigColors);\n    gl.uniform1fv(gl.getUniformLocation(program, \"u_rad\"), radii);\n    gl.uniform1fv(gl.getUniformLocation(program, \"u_gain\"), gains);\n    gl.uniform1fv(gl.getUniformLocation(program, \"u_over\"), overs);\n\n    let frame = 0;\n    let staticFrame = false;\n\n    const renderStatic = () => {\n      applyUniforms(1);\n      gl.uniform1f(uTime, 5);\n      gl.drawArrays(gl.TRIANGLES, 0, 3);\n    };\n\n    const resize = () => {\n      const width = Math.max(1, Math.round(canvas.clientWidth * dpr));\n      const height = Math.max(1, Math.round(canvas.clientHeight * dpr));\n\n      if (canvas.width !== width || canvas.height !== height) {\n        canvas.width = width;\n        canvas.height = height;\n        gl.viewport(0, 0, width, height);\n      }\n\n      gl.uniform2f(uResolution, canvas.width, canvas.height);\n    };\n\n    const observer = new ResizeObserver(() => {\n      resize();\n\n      if (staticFrame) {\n        renderStatic();\n      }\n    });\n    observer.observe(canvas);\n    resize();\n\n    const dispose = () => {\n      observer.disconnect();\n      gl.deleteBuffer(quad);\n      gl.deleteProgram(program);\n      gl.deleteShader(vertex);\n      gl.deleteShader(fragment);\n    };\n\n    const draw = (now: number) => {\n      const last = lastFrameRef.current ?? now;\n      lastFrameRef.current = now;\n      // Skip GL work while hidden: keep the loop alive, drop the cost.\n      if (!(canvas.checkVisibility?.() ?? true)) {\n        frame = requestAnimationFrame(draw);\n        return;\n      }\n\n      const dt = (now - last) / 1000;\n      applyUniforms(1 - Math.exp(-dt / SMOOTH_TAU));\n      phaseRef.current += dt * (smoothed.speed ?? speed);\n      gl.uniform1f(uTime, phaseRef.current);\n      gl.drawArrays(gl.TRIANGLES, 0, 3);\n      frame = requestAnimationFrame(draw);\n    };\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n\n    if (reduced.matches || speed === 0) {\n      staticFrame = true;\n      resize();\n      renderStatic();\n      return dispose;\n    }\n\n    frame = requestAnimationFrame(draw);\n\n    return () => {\n      cancelAnimationFrame(frame);\n      dispose();\n    };\n    // rigKey covers the lights array contents.\n    // oxlint-disable-next-line react-hooks/exhaustive-deps\n  }, [drift, gap, highlight, holeSize, holeX, holeY, intensity, rigKey, speed]);\n\n  return (\n    <canvas\n      aria-hidden=\"true\"\n      className={cn(\"size-full\", className)}\n      data-slot=\"halftone-bloom\"\n      ref={canvasRef}\n      {...props}\n    />\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/halftone-bloom/halftone-bloom-shader.ts",
      "content": "/*\n * The visual shader for HalftoneBloom, kept separate from the React\n * mount.\n *\n * A punched-metal halftone screen revealed by soft color lights: every\n * cell is a ring (a disc with a smaller hole punched off-center), and\n * the only light in the scene is a configurable set of blurred blobs\n * drifting behind the screen. Where a light burns hottest it can\n * overexpose toward a highlight cream, the way the reference art blows\n * out at its center.\n *\n * Each ring samples the light field once at its cell center, so a ring\n * reads as one flat tone and the gradient steps cell by cell, which is\n * what sells the print-halftone look.\n *\n * Fragment shader uniforms:\n * - u_resolution (vec2): canvas resolution in pixels\n * - u_time (float): animation time in seconds (pre-multiplied by speed)\n * - u_gap (float): cell pitch in device pixels\n * - u_hole (float): 0-1 punched-hole radius as a share of the cell\n * - u_hole_offset (vec2): hole offset from the cell center\n * - u_intensity (float): overall light brightness multiplier\n * - u_drift (float): 0-1 how far lights wander from their anchors\n * - u_highlight (vec3): the overexposed highlight color\n * - u_count (int): number of live lights (up to 6)\n * - u_pos[6] (vec2): light anchors; x across the width, y up the\n *   height, both 0-1\n * - u_col[6] (vec3): light colors\n * - u_rad[6] (float): light radii as a share of the width\n * - u_gain[6] (float): light brightness gains\n * - u_over[6] (float): 0-1 per-light pull toward the highlight\n */\n\nexport const BLOOM_VERTEX = `\nattribute vec2 a_position;\nvoid main() {\n  gl_Position = vec4(a_position, 0.0, 1.0);\n}\n`;\n\nexport const BLOOM_FRAGMENT = `\nprecision mediump float;\nuniform vec2 u_resolution;\nuniform float u_time;\nuniform float u_gap;\nuniform float u_hole;\nuniform vec2 u_hole_offset;\nuniform float u_intensity;\nuniform float u_drift;\nuniform vec3 u_highlight;\nuniform int u_count;\nuniform vec2 u_pos[6];\nuniform vec3 u_col[6];\nuniform float u_rad[6];\nuniform float u_gain[6];\nuniform float u_over[6];\n\nvoid main() {\n  vec2 cell = floor(gl_FragCoord.xy / u_gap);\n  vec2 local = fract(gl_FragCoord.xy / u_gap) - 0.5;\n\n  // Sample the light field at the cell center so each ring is one\n  // flat tone. Everything lives in width units: x runs 0-1 across,\n  // y runs 0-1/aspect up, so lights stay circular in pixels and a\n  // rig reads the same on any canvas shape.\n  float aspect = u_resolution.x / u_resolution.y;\n  vec2 uv = ((cell + 0.5) * u_gap) / u_resolution.x;\n  float t = u_time * 0.5;\n\n  // Accumulate the lights: each wanders its own lissajous path and\n  // breathes its radius so the field visibly lives.\n  float total = 0.0;\n  vec3 acc = vec3(0.0);\n  float over = 0.0;\n\n  for (int i = 0; i < 6; i++) {\n    if (i >= u_count) {\n      break;\n    }\n\n    float fi = float(i);\n    vec2 center = vec2(u_pos[i].x, u_pos[i].y / aspect) +\n      u_drift * vec2(\n        0.12 * sin(t * (0.5 + 0.13 * fi) + fi * 1.7),\n        0.1 * cos(t * (0.7 + 0.11 * fi) + fi * 2.3) / aspect\n      );\n    float radius = u_rad[i] *\n      (1.0 + 0.15 * u_drift * sin(t * (0.4 + 0.09 * fi) + fi));\n    vec2 d = uv - center;\n    float energy = exp(-dot(d, d) / (radius * radius)) * u_gain[i];\n\n    total += energy;\n    acc += u_col[i] * energy;\n    over += energy * u_over[i];\n  }\n\n  float light = total * u_intensity;\n  float sum = max(total, 0.001);\n  vec3 tone = acc / sum;\n\n  // Overexposure: the hottest cells of lights that opt in lift toward\n  // the highlight cream; the rest stay saturated like the reference.\n  tone = mix(tone, u_highlight, smoothstep(0.9, 1.7, light) * (over / sum));\n  tone *= min(light, 1.0);\n\n  // The ring: a disc filling the cell with a hole punched off-center,\n  // leaving the little crescent the reference art has.\n  float aa = 1.5 / u_gap;\n  float disc = 1.0 - smoothstep(0.5 - aa, 0.5, length(local));\n  float hole = 1.0 -\n    smoothstep(u_hole - aa, u_hole + aa, length(local - u_hole_offset));\n  float ring = disc * (1.0 - hole);\n\n  float alpha = ring * min(light, 1.0);\n  gl_FragColor = vec4(tone * ring, alpha);\n}\n`;\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component"
}