{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "glow-orb",
  "title": "Glow Orb",
  "description": "A WebGL fbm-noise glow orb with state-driven colors, volume-responsive scale and glow, idle and connecting pulse animations, and a settle-back transition.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "components/ui/glow-orb.tsx",
      "type": "registry:ui",
      "content": "'use client'\n\nimport { useEffect, useLayoutEffect, useRef, type AriaAttributes, type CSSProperties } from 'react'\nimport { useReducedMotion } from 'motion/react'\nimport { useCssColorRgb } from '@/lib/hooks/use-css-color-rgb'\n\nconst useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect\n\nexport type OrbState = 'idle' | 'connecting' | 'listening' | 'thinking' | 'speaking' | 'error'\n\ntype DataAttributeValue = string | number | boolean | null | undefined\n\nexport interface OrbHtmlAttributes extends AriaAttributes {\n  id?: string\n  title?: string\n  role?: string\n  tabIndex?: number\n  [dataAttribute: `data-${string}`]: DataAttributeValue\n}\n\nexport interface GlowOrbProps extends OrbHtmlAttributes {\n  state: OrbState\n  volume: number\n  size: number\n  className?: string\n  style?: CSSProperties\n  disabled?: boolean\n  interactive?: boolean\n  onClick?: () => void\n}\n\ntype RGB = [number, number, number]\n\nconst VERT = `\nattribute vec2 a_pos;\nvoid main() {\n  gl_Position = vec4(a_pos, 0.0, 1.0);\n}\n`\n\nconst FRAG = `\n#ifdef GL_FRAGMENT_PRECISION_HIGH\nprecision highp float;\n#else\nprecision mediump float;\n#endif\n\nuniform vec2 u_resolution;\nuniform float u_time;\nuniform vec3 u_color;\n\nfloat hash(vec2 p) {\n  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\nfloat 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\nfloat fbm(vec2 p) {\n  float v = 0.0;\n  float a = 0.55;\n  for (int i = 0; i < 4; i++) {\n    v += a * noise(p);\n    p *= 2.0;\n    a *= 0.5;\n  }\n  return v;\n}\n\nvoid main() {\n  vec2 uv = gl_FragCoord.xy / u_resolution.xy;\n  vec2 c = uv - 0.5;\n  float r = length(c);\n  float t = u_time;\n\n  // fake sphere depth: 1 at center, 0 at rim\n  float z = sqrt(max(1.0 - dot(c, c) * 4.0, 0.0));\n\n  // wrap the cloud field around the sphere and drift it sideways\n  vec2 drift = vec2(t * 0.35, -t * 0.16);\n  vec2 p = c * (2.4 + (1.0 - z) * 1.6) + drift;\n\n  // domain-warped fbm = soft billowing clouds\n  vec2 q = vec2(fbm(p + t * 0.12), fbm(p + vec2(4.7, 2.3) - t * 0.09));\n  float clouds = fbm(p * 1.6 + 1.4 * q);\n\n  vec3 sky = u_color;\n  vec3 skyLight = mix(u_color, vec3(1.0), 0.55);\n  vec3 cloud = vec3(0.99, 1.0, 1.0);\n\n  vec3 col = mix(sky, skyLight, smoothstep(0.30, 0.60, clouds));\n  col = mix(col, cloud, smoothstep(0.56, 0.84, clouds));\n\n  // spherical shading: brighter center/top, softly darker rim\n  col *= 0.74 + 0.26 * z;\n  col += (0.5 - uv.y) * -0.10 * z;\n  col = clamp(col, 0.0, 1.0);\n\n  float edge = smoothstep(0.5, 0.485, r);\n\n  gl_FragColor = vec4(col * edge, edge);\n}\n`\n\nfunction compileShader(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  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n    const log = gl.getShaderInfoLog(shader)\n    if (log) console.error('[glow-orb] shader compile failed:', log)\n    gl.deleteShader(shader)\n    return null\n  }\n  return shader\n}\n\ninterface FluidOrbProps {\n  size: number\n  colorRgb: RGB\n  speed: number\n}\n\nfunction FluidOrb({ size, colorRgb, speed }: FluidOrbProps) {\n  const canvasRef = useRef<HTMLCanvasElement>(null)\n  const colorRef = useRef(colorRgb)\n  const speedRef = useRef(speed)\n  const redrawRef = useRef<(() => void) | null>(null)\n\n  useIsomorphicLayoutEffect(() => {\n    colorRef.current = colorRgb\n    speedRef.current = speed\n    redrawRef.current?.()\n  }, [colorRgb, speed])\n\n  useEffect(() => {\n    const canvas = canvasRef.current\n    if (!canvas) return\n\n    const gl = canvas.getContext('webgl', { antialias: true, alpha: true })\n    if (!gl) return\n\n    const program = gl.createProgram()\n    const vert = compileShader(gl, gl.VERTEX_SHADER, VERT)\n    const frag = compileShader(gl, gl.FRAGMENT_SHADER, FRAG)\n    if (!program || !vert || !frag) return\n\n    gl.attachShader(program, vert)\n    gl.attachShader(program, frag)\n    gl.linkProgram(program)\n    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n      const log = gl.getProgramInfoLog(program)\n      if (log) console.error('[glow-orb] program link failed:', log)\n      return\n    }\n    gl.useProgram(program)\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, 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 uColor = gl.getUniformLocation(program, 'u_color')\n\n    const dpr = Math.min(window.devicePixelRatio || 1, 2)\n    const px = 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    const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches\n    let raf = 0\n    let phase = 0\n    let last = performance.now()\n    let curSpeed = speedRef.current\n    let curColor: RGB = [\n      colorRef.current[0] / 255,\n      colorRef.current[1] / 255,\n      colorRef.current[2] / 255,\n    ]\n\n    const draw = () => {\n      gl.uniform3f(uColor, curColor[0], curColor[1], curColor[2])\n      gl.uniform1f(uTime, phase)\n      gl.drawArrays(gl.TRIANGLES, 0, 6)\n    }\n\n    if (reduce) {\n      draw()\n      redrawRef.current = () => {\n        curColor = [colorRef.current[0] / 255, colorRef.current[1] / 255, colorRef.current[2] / 255]\n        draw()\n      }\n    } else {\n      const render = (now: number) => {\n        const dt = Math.min((now - last) / 1000, 0.1)\n        last = now\n\n        curSpeed += (speedRef.current - curSpeed) * 0.05\n        phase += dt * curSpeed\n\n        const target: RGB = [\n          colorRef.current[0] / 255,\n          colorRef.current[1] / 255,\n          colorRef.current[2] / 255,\n        ]\n        curColor = [\n          curColor[0] + (target[0] - curColor[0]) * 0.05,\n          curColor[1] + (target[1] - curColor[1]) * 0.05,\n          curColor[2] + (target[2] - curColor[2]) * 0.05,\n        ]\n\n        draw()\n        raf = requestAnimationFrame(render)\n      }\n      raf = requestAnimationFrame(render)\n    }\n\n    return () => {\n      redrawRef.current = null\n      cancelAnimationFrame(raf)\n      gl.deleteProgram(program)\n      gl.deleteShader(vert)\n      gl.deleteShader(frag)\n      gl.deleteBuffer(buffer)\n    }\n  }, [size])\n\n  return <canvas ref={canvasRef} style={{ width: '100%', height: '100%', display: 'block' }} />\n}\n\nconst STATE_COLOR_TOKEN: Record<OrbState, 'accent' | 'destructive'> = {\n  idle: 'accent',\n  connecting: 'accent',\n  listening: 'accent',\n  thinking: 'accent',\n  speaking: 'accent',\n  error: 'destructive',\n}\n\nconst FALLBACK_ACCENT: RGB = [47, 124, 246]\nconst FALLBACK_DESTRUCTIVE: RGB = [232, 80, 80]\n\nconst STATE_SPEED: Record<OrbState, number> = {\n  idle: 0.15,\n  connecting: 0.3,\n  listening: 0.5,\n  thinking: 0.7,\n  speaking: 1.1,\n  error: 0.08,\n}\n\nconst KEYFRAMES = `\n@keyframes orb-circle-thinking-wave {\n  0%   { transform: scale(1); }\n  25%  { transform: scale(1.03); }\n  50%  { transform: scale(0.97); }\n  75%  { transform: scale(1.02); }\n  100% { transform: scale(1); }\n}\n`\n\nconst SPEAK_BASE = 0.95\nconst SPEAK_RANGE = 0.08\nconst LISTEN_BASE = 0.92\nconst LISTEN_RANGE = 0.06\nconst LISTEN_GLOW = 0\nconst SPEAK_GLOW = 24\nconst LERP = 0.55\nconst SETTLE_RATE = 0.12\nconst SETTLE_SCALE_EPSILON = 0.002\nconst TRANSITION_RATE = 0.06\n\nexport function GlowOrb({\n  state,\n  volume,\n  size,\n  className,\n  style,\n  disabled = false,\n  interactive = false,\n  onClick,\n  ...controlProps\n}: GlowOrbProps) {\n  const circleRef = useRef<HTMLSpanElement>(null)\n  const glowRef = useRef<HTMLSpanElement>(null)\n  const hoverRef = useRef<HTMLSpanElement>(null)\n  const rafRef = useRef<number>(0)\n\n  const reduceMotion = useReducedMotion()\n\n  const volumeRef = useRef(volume)\n  useIsomorphicLayoutEffect(() => {\n    volumeRef.current = volume\n  }, [volume])\n\n  const accentRgb = useCssColorRgb('--color-accent', FALLBACK_ACCENT)\n  const destructiveRgb = useCssColorRgb('--color-destructive', FALLBACK_DESTRUCTIVE)\n  const accentRgbRef = useRef(accentRgb)\n  const destructiveRgbRef = useRef(destructiveRgb)\n  useEffect(() => {\n    accentRgbRef.current = accentRgb\n  }, [accentRgb])\n  useEffect(() => {\n    destructiveRgbRef.current = destructiveRgb\n  }, [destructiveRgb])\n\n  const currentScaleRef = useRef(1)\n  const currentGlowRef = useRef(0)\n  const currentColorRef = useRef<RGB>(accentRgb)\n  const currentBaseRef = useRef(LISTEN_BASE)\n  const currentRangeRef = useRef(LISTEN_RANGE)\n  const touchEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n\n  useEffect(\n    () => () => {\n      if (touchEndTimerRef.current) clearTimeout(touchEndTimerRef.current)\n    },\n    [],\n  )\n\n  useEffect(() => {\n    const id = 'orb-circle-keyframes'\n    if (!document.getElementById(id)) {\n      const el = document.createElement('style')\n      el.id = id\n      el.textContent = KEYFRAMES\n      document.head.appendChild(el)\n    }\n  }, [])\n\n  useEffect(() => {\n    const el = circleRef.current\n    if (!el) return\n\n    const targetToken = STATE_COLOR_TOKEN[state]\n    const tRgb = targetToken === 'destructive' ? destructiveRgbRef.current : accentRgbRef.current\n\n    if (!reduceMotion && (state === 'listening' || state === 'speaking')) {\n      const base = state === 'speaking' ? SPEAK_BASE : LISTEN_BASE\n      const range = state === 'speaking' ? SPEAK_RANGE : LISTEN_RANGE\n      const glow = state === 'speaking' ? SPEAK_GLOW : LISTEN_GLOW\n\n      const animate = () => {\n        const vol = volumeRef.current\n\n        currentBaseRef.current += (base - currentBaseRef.current) * TRANSITION_RATE\n        currentRangeRef.current += (range - currentRangeRef.current) * TRANSITION_RATE\n\n        const tScale = currentBaseRef.current + vol * currentRangeRef.current\n        const tGlow = vol * glow\n\n        if (state === 'listening') {\n          currentScaleRef.current += (tScale - currentScaleRef.current) * LERP\n          currentGlowRef.current += (tGlow - currentGlowRef.current) * LERP\n        } else {\n          currentScaleRef.current = tScale\n          currentGlowRef.current = tGlow\n        }\n\n        const [cr, cg, cb] = currentColorRef.current\n        currentColorRef.current = [\n          cr + (tRgb[0] - cr) * 0.05,\n          cg + (tRgb[1] - cg) * 0.05,\n          cb + (tRgb[2] - cb) * 0.05,\n        ]\n        const [r, g, b] = currentColorRef.current.map(Math.round)\n\n        el.style.transform = `scale(${currentScaleRef.current})`\n        el.style.animation = 'none'\n\n        const ge = glowRef.current\n        if (ge) {\n          const g2 = currentGlowRef.current\n          ge.style.transform = `scale(${currentScaleRef.current})`\n          ge.style.boxShadow = g2 > 0.5 ? `0 0 ${g2}px ${g2 * 0.4}px rgb(${r},${g},${b})` : 'none'\n        }\n\n        rafRef.current = requestAnimationFrame(animate)\n      }\n\n      rafRef.current = requestAnimationFrame(animate)\n\n      return () => {\n        cancelAnimationFrame(rafRef.current)\n      }\n    } else {\n      cancelAnimationFrame(rafRef.current)\n\n      const settle = () => {\n        currentScaleRef.current += (1 - currentScaleRef.current) * SETTLE_RATE\n        currentGlowRef.current += (0 - currentGlowRef.current) * SETTLE_RATE\n\n        const [cr, cg, cb] = currentColorRef.current\n        currentColorRef.current = [\n          cr + (tRgb[0] - cr) * SETTLE_RATE,\n          cg + (tRgb[1] - cg) * SETTLE_RATE,\n          cb + (tRgb[2] - cb) * SETTLE_RATE,\n        ]\n\n        el.style.transform = `scale(${currentScaleRef.current})`\n        el.style.animation = 'none'\n\n        if (glowRef.current) {\n          glowRef.current.style.transform = `scale(${currentScaleRef.current})`\n          glowRef.current.style.boxShadow = 'none'\n        }\n\n        const scaleDone = Math.abs(currentScaleRef.current - 1) < SETTLE_SCALE_EPSILON\n        const glowDone = currentGlowRef.current < 0.1\n        const colorDone = currentColorRef.current.every(\n          (channel, i) => Math.abs(channel - tRgb[i]) < 1,\n        )\n\n        if (scaleDone && glowDone && colorDone) {\n          currentScaleRef.current = 1\n          currentGlowRef.current = 0\n          currentColorRef.current = tRgb\n\n          el.style.transform = ''\n          if (glowRef.current) {\n            glowRef.current.style.transform = 'scale(1)'\n            glowRef.current.style.boxShadow = 'none'\n          }\n\n          if (state === 'thinking' && !reduceMotion) {\n            el.style.animation =\n              'orb-circle-thinking-wave 2.4s cubic-bezier(0.37, 0, 0.63, 1) infinite'\n          }\n\n          return\n        }\n\n        rafRef.current = requestAnimationFrame(settle)\n      }\n\n      rafRef.current = requestAnimationFrame(settle)\n\n      return () => cancelAnimationFrame(rafRef.current)\n    }\n  }, [state, reduceMotion])\n\n  const d = size * 0.55\n  const shaderSpeed = reduceMotion ? 0 : STATE_SPEED[state]\n  const orbColorRgb = STATE_COLOR_TOKEN[state] === 'destructive' ? destructiveRgb : accentRgb\n\n  const rootStyle: CSSProperties = {\n    width: size,\n    height: size,\n    display: 'flex',\n    alignItems: 'center',\n    justifyContent: 'center',\n    position: 'relative',\n    ...style,\n  }\n\n  const content = (\n    <span\n      ref={hoverRef}\n      onMouseEnter={() => {\n        if (hoverRef.current && !disabled) {\n          hoverRef.current.style.transform = 'scale(1.04)'\n          hoverRef.current.style.filter = 'brightness(1.08)'\n        }\n      }}\n      onMouseLeave={() => {\n        if (hoverRef.current) {\n          hoverRef.current.style.transform = 'scale(1)'\n          hoverRef.current.style.filter = 'brightness(1)'\n        }\n      }}\n      onTouchEnd={() => {\n        touchEndTimerRef.current = setTimeout(() => {\n          if (hoverRef.current) {\n            hoverRef.current.style.transform = 'scale(1)'\n            hoverRef.current.style.filter = 'brightness(1)'\n          }\n        }, 180)\n      }}\n      style={{\n        position: 'relative',\n        display: 'inline-block',\n        transition: reduceMotion\n          ? 'none'\n          : 'transform 220ms cubic-bezier(0.23, 1, 0.32, 1), filter 220ms cubic-bezier(0.23, 1, 0.32, 1)',\n        cursor: interactive ? (disabled ? 'not-allowed' : 'pointer') : 'default',\n        borderRadius: '50%',\n        lineHeight: 0,\n      }}\n    >\n      <span\n        ref={glowRef}\n        style={{\n          position: 'absolute',\n          display: 'block',\n          width: d,\n          height: d,\n          borderRadius: '50%',\n          pointerEvents: 'none',\n        }}\n      />\n      <span\n        style={{\n          position: 'absolute',\n          display: 'block',\n          width: d,\n          height: d,\n          borderRadius: '50%',\n          background: 'rgba(0,0,0,0.08)',\n          filter: 'blur(8px)',\n          transform: 'scale(1.15)',\n          pointerEvents: 'none',\n        }}\n      />\n      <span\n        ref={circleRef}\n        style={{\n          position: 'relative',\n          display: 'block',\n          width: d,\n          height: d,\n          borderRadius: '50%',\n          overflow: 'hidden',\n        }}\n      >\n        <FluidOrb size={d} colorRgb={orbColorRgb} speed={shaderSpeed} />\n      </span>\n    </span>\n  )\n\n  if (interactive) {\n    return (\n      <button\n        {...controlProps}\n        type=\"button\"\n        className={className}\n        disabled={disabled}\n        onClick={disabled ? undefined : onClick}\n        style={{\n          appearance: 'none',\n          WebkitAppearance: 'none',\n          border: 0,\n          padding: 0,\n          margin: 0,\n          background: 'transparent',\n          color: 'inherit',\n          font: 'inherit',\n          cursor: disabled ? 'not-allowed' : 'pointer',\n          transition: reduceMotion ? 'none' : 'transform 160ms cubic-bezier(0.23, 1, 0.32, 1)',\n          ...rootStyle,\n        }}\n        onMouseDown={(e) => {\n          if (disabled) return\n          ;(e.currentTarget as HTMLElement).style.transform = 'scale(0.96)'\n        }}\n        onMouseUp={(e) => {\n          ;(e.currentTarget as HTMLElement).style.transform = 'scale(1)'\n        }}\n        onMouseLeave={(e) => {\n          ;(e.currentTarget as HTMLElement).style.transform = 'scale(1)'\n        }}\n      >\n        {content}\n      </button>\n    )\n  }\n\n  return (\n    <div {...controlProps} className={className} style={rootStyle}>\n      {content}\n    </div>\n  )\n}\n\nexport function GlowOrbPreview() {\n  return <GlowOrb state=\"listening\" volume={0.5} size={200} />\n}\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"
}
