{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "fluid-orb",
  "title": "Fluid Orb",
  "description": "A WebGL fluid orb in muted/neutral design tokens with sphere normals, fBm noise, and audio-reactive pulse.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "components/ui/fluid-orb.tsx",
      "type": "registry:ui",
      "content": "'use client'\n\nimport {\n  forwardRef,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ComponentProps,\n} from 'react'\nimport { useReducedMotion } from 'motion/react'\nimport { cn } from '@/lib/utils'\nimport { useCssColorRgb } from '@/lib/hooks/use-css-color-rgb'\n\nexport type FluidOrbProps = ComponentProps<'div'> & {\n  size?: number\n  /**\n   * Hex color override. When omitted, uses the design-system muted/neutral\n   * foreground token so the orb follows light/dark theme.\n   */\n  color?: string\n  audioLevel?: number\n}\n\nconst VERT = [\n  'attribute vec2 a_pos;',\n  'void main() {',\n  '  gl_Position = vec4(a_pos, 0.0, 1.0);',\n  '}',\n].join('\\n')\n\nconst FRAG = [\n  'precision mediump float;',\n  'uniform vec2 u_resolution;',\n  'uniform float u_time;',\n  'uniform float u_pulse;',\n  'uniform vec3 u_color;',\n  'float hash(vec2 p) {',\n  '  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);',\n  '}',\n  'float noise(vec2 p) {',\n  '  vec2 i = floor(p);',\n  '  vec2 f = fract(p);',\n  '  vec2 u = f * f * (3.0 - 2.0 * f);',\n  '  return mix(',\n  '    mix(hash(i + vec2(0.0, 0.0)), hash(i + vec2(1.0, 0.0)), u.x),',\n  '    mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),',\n  '    u.y',\n  '  );',\n  '}',\n  'float fbm(vec2 p) {',\n  '  float v = 0.0;',\n  '  float a = 0.5;',\n  '  for (int i = 0; i < 5; i++) {',\n  '    v += a * noise(p);',\n  '    p *= 2.0;',\n  '    a *= 0.5;',\n  '  }',\n  '  return v;',\n  '}',\n  'void main() {',\n  '  vec2 uv = gl_FragCoord.xy / u_resolution.xy;',\n  '  vec2 p = uv - 0.5;',\n  '  p.x *= u_resolution.x / max(u_resolution.y, 1.0);',\n  '  float dist = length(p);',\n  '  float edge = smoothstep(0.5, 0.49, dist);',\n  '  vec3 sphereNormal = normalize(vec3(p.x, p.y, sqrt(max(0.0, 0.25 - dot(p, p)))));',\n  '  float t = u_time * 0.15;',\n  '  vec2 flow = vec2(t * 0.5, t * 0.3);',\n  '  float fluid = fbm(p * 3.0 + flow);',\n  '  fluid = pow(fluid, 1.5);',\n  '  vec3 keyLightDir = normalize(vec3(-0.5, 0.6, 0.5));',\n  '  vec3 fillLightDir = normalize(vec3(0.4, -0.3, 0.2));',\n  '  float keyLight = max(dot(sphereNormal, keyLightDir), 0.0);',\n  '  float fillLight = max(dot(sphereNormal, fillLightDir), 0.0);',\n  '  vec3 viewDir = vec3(0.0, 0.0, 1.0);',\n  '  vec3 halfVec = normalize(keyLightDir + viewDir);',\n  '  float specular = pow(max(dot(sphereNormal, halfVec), 0.0), 48.0);',\n  '  float coreRadius = 0.16 + 0.18 * u_pulse;',\n  '  float coreMask = 1.0 - smoothstep(coreRadius, coreRadius + 0.22, dist);',\n  '  vec3 coreColor = mix(u_color * 0.45, u_color * 1.2, u_pulse);',\n  '  vec3 col = u_color * 0.22;',\n  '  col = mix(col, u_color * 1.25, fluid * 0.6);',\n  '  col *= (0.55 + keyLight * 0.7 + fillLight * 0.25);',\n  '  col += specular * 0.3;',\n  '  col = mix(col, coreColor, coreMask * (0.35 + 0.4 * u_pulse));',\n  '  gl_FragColor = vec4(col * edge, edge);',\n  '}',\n].join('\\n')\n\nfunction hexToRgb01(hex: string): [number, number, number] | null {\n  let h = hex.replace('#', '').trim()\n  if (h.length === 3) {\n    h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]\n  }\n  const n = parseInt(h, 16)\n  if (h.length !== 6 || Number.isNaN(n)) return null\n  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]\n}\n\nfunction rgb255To01(rgb: [number, number, number]): [number, number, number] {\n  return [rgb[0] / 255, rgb[1] / 255, rgb[2] / 255]\n}\n\nfunction rgb01Css([r, g, b]: [number, number, number], alpha = 1): string {\n  return `rgb(${Math.round(r * 255)} ${Math.round(g * 255)} ${Math.round(b * 255)} / ${alpha})`\n}\n\nconst FALLBACK_MUTED: [number, number, number] = [115, 115, 115]\n\nfunction compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader | null {\n  const shader = gl.createShader(type)\n  if (!shader) return null\n  gl.shaderSource(shader, src)\n  gl.compileShader(shader)\n  const ok = gl.getShaderParameter(shader, gl.COMPILE_STATUS)\n  if (!ok) {\n    gl.deleteShader(shader)\n    return null\n  }\n  return shader\n}\n\nexport const FluidOrb = forwardRef<HTMLDivElement, FluidOrbProps>(\n  ({ size = 280, color, audioLevel, className, style, ...props }, ref) => {\n    const canvasRef = useRef<HTMLCanvasElement>(null)\n    const glowRef = useRef<HTMLDivElement>(null)\n    const pulseRef = useRef(0)\n    const audioLevelRef = useRef(audioLevel)\n    const reduceMotion = useReducedMotion()\n    const reduceMotionRef = useRef(reduceMotion)\n    const glRef = useRef<WebGLRenderingContext | null>(null)\n    const uColorRef = useRef<WebGLUniformLocation | null>(null)\n    const [webglOk, setWebglOk] = useState(true)\n\n    const mutedRgb = useCssColorRgb('--color-muted', FALLBACK_MUTED)\n    const color01 = useMemo((): [number, number, number] => {\n      if (color) {\n        const parsed = hexToRgb01(color)\n        if (parsed) return parsed\n      }\n      return rgb255To01(mutedRgb)\n    }, [color, mutedRgb])\n    const color01Ref = useRef(color01)\n\n    useEffect(() => {\n      audioLevelRef.current = audioLevel\n    }, [audioLevel])\n\n    useEffect(() => {\n      reduceMotionRef.current = reduceMotion\n    }, [reduceMotion])\n\n    useEffect(() => {\n      color01Ref.current = color01\n      const gl = glRef.current\n      const loc = uColorRef.current\n      if (gl && loc && !gl.isContextLost()) {\n        try {\n          gl.uniform3f(loc, ...color01)\n        } catch {\n          /* context may be mid-teardown */\n        }\n      }\n    }, [color01])\n\n    useLayoutEffect(() => {\n      const canvas = canvasRef.current\n      if (!canvas || typeof window === 'undefined') return\n\n      let cancelled = false\n      let raf = 0\n      let program: WebGLProgram | null = null\n      let vert: WebGLShader | null = null\n      let frag: WebGLShader | null = null\n      let buffer: WebGLBuffer | null = null\n      let gl: WebGLRenderingContext | null = null\n\n      const start = () => {\n        if (cancelled) return\n\n        const opts: WebGLContextAttributes = {\n          antialias: true,\n          alpha: true,\n          premultipliedAlpha: true,\n          preserveDrawingBuffer: false,\n          powerPreference: 'default',\n        }\n\n        gl =\n          (canvas.getContext('webgl', opts) as WebGLRenderingContext | null) ??\n          (canvas.getContext('experimental-webgl', opts) as WebGLRenderingContext | null)\n\n        if (!gl || gl.isContextLost()) {\n          setWebglOk(false)\n          return\n        }\n\n        program = gl.createProgram()\n        vert = compile(gl, gl.VERTEX_SHADER, VERT)\n        frag = compile(gl, gl.FRAGMENT_SHADER, FRAG)\n        if (!program || !vert || !frag) {\n          setWebglOk(false)\n          glRef.current = null\n          return\n        }\n\n        gl.attachShader(program, vert)\n        gl.attachShader(program, frag)\n        gl.linkProgram(program)\n        if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n          setWebglOk(false)\n          gl.deleteShader(vert)\n          gl.deleteShader(frag)\n          gl.deleteProgram(program)\n          glRef.current = null\n          return\n        }\n\n        gl.useProgram(program)\n        gl.enable(gl.BLEND)\n        gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)\n        gl.clearColor(0, 0, 0, 0)\n\n        buffer = gl.createBuffer()\n        gl.bindBuffer(gl.ARRAY_BUFFER, buffer)\n        gl.bufferData(\n          gl.ARRAY_BUFFER,\n          new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),\n          gl.STATIC_DRAW,\n        )\n        const aPos = gl.getAttribLocation(program, 'a_pos')\n        gl.enableVertexAttribArray(aPos)\n        gl.vertexAttribPointer(aPos, 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 uPulse = gl.getUniformLocation(program, 'u_pulse')\n        const uColor = gl.getUniformLocation(program, 'u_color')\n        uColorRef.current = uColor\n        gl.uniform3f(uColor, ...color01Ref.current)\n\n        const dpr = Math.min(window.devicePixelRatio || 1, 2)\n        const px = Math.max(1, Math.round(size * dpr))\n        canvas.width = px\n        canvas.height = px\n        gl.viewport(0, 0, px, px)\n        gl.uniform2f(uResolution, px, px)\n\n        glRef.current = gl\n        setWebglOk(true)\n\n        const t0 = performance.now()\n        const render = (now: number) => {\n          if (cancelled || !gl || gl.isContextLost()) return\n          const reduce = reduceMotionRef.current\n          const time = (now - t0) / 1000\n          const level = audioLevelRef.current\n          const target =\n            level !== undefined\n              ? level\n              : 0.22 + 0.14 * Math.sin(time * 1.3) + 0.1 * Math.sin(time * 2.1 + 1.7)\n          pulseRef.current += (target - pulseRef.current) * 0.06\n\n          gl.uniform1f(uTime, reduce ? 0 : time)\n          gl.uniform1f(uPulse, reduce ? 0.3 : pulseRef.current)\n          gl.clear(gl.COLOR_BUFFER_BIT)\n          gl.drawArrays(gl.TRIANGLES, 0, 6)\n          if (glowRef.current) {\n            glowRef.current.style.transform = `scale(${1 + pulseRef.current * 0.15})`\n          }\n          if (!reduce) raf = requestAnimationFrame(render)\n        }\n        render(t0)\n      }\n\n      const boot = requestAnimationFrame(start)\n\n      return () => {\n        cancelled = true\n        cancelAnimationFrame(boot)\n        cancelAnimationFrame(raf)\n        uColorRef.current = null\n        glRef.current = null\n        if (gl && !gl.isContextLost()) {\n          if (buffer) gl.deleteBuffer(buffer)\n          if (vert) gl.deleteShader(vert)\n          if (frag) gl.deleteShader(frag)\n          if (program) gl.deleteProgram(program)\n        }\n      }\n    }, [size])\n\n    return (\n      <div\n        ref={ref}\n        data-slot=\"fluid-orb\"\n        className={cn('relative flex items-center justify-center', className)}\n        style={{ width: size, height: size, ...style }}\n        {...props}\n      >\n        <div\n          ref={glowRef}\n          className=\"absolute inset-0 rounded-full transition-transform duration-100 ease-out motion-reduce:transition-none motion-reduce:transform-none\"\n          style={{\n            transform: 'scale(1)',\n            background: `radial-gradient(circle, ${rgb01Css(color01, 0.28)} 0%, transparent 70%)`,\n            filter: 'blur(20px)',\n          }}\n        />\n        <canvas\n          ref={canvasRef}\n          className=\"relative h-full w-full rounded-full\"\n          style={{\n            filter: `drop-shadow(0 0 15px ${rgb01Css(color01, 0.2)})`,\n            opacity: webglOk ? 1 : 0,\n          }}\n          aria-hidden={!webglOk}\n        />\n        {!webglOk && (\n          <div\n            className=\"absolute inset-0 rounded-full\"\n            style={{\n              background: `radial-gradient(circle at 35% 30%, ${rgb01Css(color01, 0.9)} 0%, ${rgb01Css(color01, 0.75)} 45%, ${rgb01Css(color01, 0.45)} 100%)`,\n              boxShadow: `0 0 24px ${rgb01Css(color01, 0.25)}`,\n            }}\n          />\n        )}\n      </div>\n    )\n  },\n)\n\nFluidOrb.displayName = 'FluidOrb'\n\nexport default FluidOrb\n"
    },
    {
      "path": "lib/hooks/use-css-color-rgb.ts",
      "type": "registry:lib",
      "content": "'use client'\n\nimport { useEffect, useState } from 'react'\nimport { resolveCssColor } from '@/lib/resolve-css-color'\n\n/**\n * Subscribes to a CSS custom property's resolved RGB value, re-resolving\n * whenever the document's theme class changes rather than reading it once\n * or during render — the `.dark` class is applied pre-hydration and can\n * change at any time via the theme toggle, so a one-shot read would go\n * stale (see AGENTS.md's \"never read the theme during render\" rule, which\n * applies equally to any CSS variable whose value is theme-scoped).\n */\nexport function useCssColorRgb(\n  varName: string,\n  fallback: [number, number, number],\n): [number, number, number] {\n  const [rgb, setRgb] = useState<[number, number, number]>(fallback)\n\n  useEffect(() => {\n    const resolve = () => {\n      const next = resolveCssColor(varName)\n      if (next) setRgb(next)\n    }\n    resolve()\n\n    const observer = new MutationObserver(resolve)\n    observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })\n    return () => observer.disconnect()\n  }, [varName])\n\n  return rgb\n}\n"
    },
    {
      "path": "lib/resolve-css-color.ts",
      "type": "registry:lib",
      "content": "'use client'\n\n/**\n * Resolves a CSS custom property (hex, oklch, rgb, whatever the current\n * theme defines) to an [r, g, b] 0-255 triple by letting the browser's own\n * color computation do the conversion, instead of hand-parsing color\n * syntax. Lets components do numeric color math (lerp/blend) while still\n * only ever referencing semantic tokens, never a literal value.\n *\n * Client-only — returns null during SSR or if resolution fails.\n */\nexport function resolveCssColor(varName: string): [number, number, number] | null {\n  if (typeof document === 'undefined') return null\n\n  const probe = document.createElement('span')\n  probe.style.position = 'fixed'\n  probe.style.pointerEvents = 'none'\n  probe.style.opacity = '0'\n  probe.style.color = `var(${varName})`\n  document.body.appendChild(probe)\n  const resolved = getComputedStyle(probe).color\n  document.body.removeChild(probe)\n\n  const match = resolved.match(/rgba?\\(\\s*([\\d.]+),\\s*([\\d.]+),\\s*([\\d.]+)/)\n  if (!match) return null\n  return [Number(match[1]), Number(match[2]), Number(match[3])]\n}\n"
    }
  ],
  "type": "registry:ui"
}
