{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"ai-orb","title":"AI Orb","author":"CoDesign-Spa27","description":"An interactive Three.js orb for AI assistants, voice interfaces, and ambient status surfaces.","dependencies":["three"],"devDependencies":["@types/three"],"files":[{"path":"components/ui-components/ai-orb.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type AiOrbProps = {\n  /** Accessible description for the rendered plasma visualization. */\n  ariaLabel?: string;\n  /** Surface color behind the plasma strands. Accepts any valid CSS color. */\n  backgroundColor?: string;\n  className?: string;\n  /** Controls the brightness of the plasma field. */\n  intensity?: number;\n  /** Enables subtle pointer-following offset and rotation on fine pointers. */\n  interactive?: boolean;\n  /** Multiplies the pointer-following depth effect. */\n  pointerStrength?: number;\n  /** Primary plasma strand color. Accepts any valid Three.js color string. */\n  primaryColor?: string;\n  /** Secondary plasma strand color. Accepts any valid Three.js color string. */\n  secondaryColor?: string;\n  /** Internal animation speed. Set to 0 for a still plasma field. */\n  speed?: number;\n  /** Thickness of the ray-marched plasma strands. */\n  strandWidth?: number;\n  /** Number of strand turns packed into the orb. */\n  twist?: number;\n};\n\ntype ThreeModule = typeof import(\"three\");\n\ntype PlasmaUniforms = {\n  uBend1: { value: number };\n  uBend2: { value: number };\n  uColor1: { value: import(\"three\").Color };\n  uColor2: { value: import(\"three\").Color };\n  uDir2: { value: number };\n  uFocalLength: { value: number };\n  uIntensity: { value: number };\n  uLineThickness: { value: number };\n  uOffset: { value: import(\"three\").Vector2 };\n  uResolution: { value: import(\"three\").Vector2 };\n  uRotation: { value: number };\n  uSpeed1: { value: number };\n  uSpeed2: { value: number };\n  uTime: { value: number };\n  uTwist: { value: number };\n};\n\ntype OrbRuntime = {\n  render: () => void;\n  renderer: import(\"three\").WebGLRenderer;\n  uniforms: PlasmaUniforms;\n};\n\ntype OrbSettings = Required<\n  Pick<\n    AiOrbProps,\n    | \"intensity\"\n    | \"interactive\"\n    | \"pointerStrength\"\n    | \"primaryColor\"\n    | \"secondaryColor\"\n    | \"speed\"\n    | \"strandWidth\"\n    | \"twist\"\n  >\n>;\n\nconst DEFAULT_BACKGROUND_COLOR = \"#090a0f\";\nconst DEFAULT_SETTINGS: OrbSettings = {\n  intensity: 1.25,\n  interactive: true,\n  pointerStrength: 1,\n  primaryColor: \"#A855F7\",\n  secondaryColor: \"#06B6D4\",\n  speed: 0.45,\n  strandWidth: 0.5,\n  twist: 5,\n};\n\nconst VERTEX_SHADER = `\n  void main() {\n    gl_Position = vec4(position.xy, 0.0, 1.0);\n  }\n`;\n\n// The plasma is raymarched as a double-helix confined inside an analytic\n// sphere (rather than an unbounded tunnel), so the strands curl around a\n// contained \"ball\" of light instead of streaking off in one direction.\nconst FRAGMENT_SHADER = `\n  precision highp float;\n\n  uniform float uTime;\n  uniform vec2 uResolution;\n  uniform vec2 uOffset;\n  uniform float uRotation;\n  uniform float uFocalLength;\n  uniform float uSpeed1;\n  uniform float uSpeed2;\n  uniform float uDir2;\n  uniform float uBend1;\n  uniform float uBend2;\n  uniform float uIntensity;\n  uniform float uLineThickness;\n  uniform float uTwist;\n  uniform vec3 uColor1;\n  uniform vec3 uColor2;\n\n  const float sphereRadius = 1.2;\n  const float pi = 9.14159;\n  const float pi2 = 6.28318;\n  const float halfPi = 1.5708;\n  #define MAX_STEPS 22\n\n  void renderPlasma(out vec4 outputColor, in vec2 coordinate) {\n    float time = uTime * pi;\n\n    vec3 rayOrigin = vec3(0.0, 0.0, -3.4);\n    vec3 rayDirection = normalize(\n      vec3((coordinate - 0.5 * uResolution) / uResolution.y, uFocalLength)\n    );\n\n    // Analytic ray/sphere intersection. Anything outside the sphere is\n    // simply not part of the volume we march through.\n    float b = dot(rayOrigin, rayDirection);\n    float c = dot(rayOrigin, rayOrigin) - sphereRadius * sphereRadius;\n    float h = b * b - c;\n    if (h < 0.0) discard;\n    h = sqrt(h);\n    float tNear = max(-b - h, 0.0);\n    float tFar = -b + h;\n    if (tFar < 0.0) discard;\n\n    float depth = tNear;\n    float stepDistance = 1.0;\n    vec2 fieldDistance = vec2(1.0);\n    vec3 position = rayOrigin;\n\n    float waveTime1 = time * 0.9;\n    float waveTime2 = time * 0.9;\n    float strandTime1 = time * uSpeed1;\n    float strandTime2 = time * uSpeed2 * uDir2;\n\n    for (int stepIndex = 0; stepIndex < MAX_STEPS; ++stepIndex) {\n      position = rayOrigin + rayDirection * depth;\n\n      // Phase driving the twist of the two strands as they wind through\n      // the ball; multiplying by twistFrequency packs several loops into\n      // the sphere's diameter instead of one long straight run.\n      float twistX = position.x * uTwist;\n      float wobble1 = uBend1 + sin(waveTime1 + twistX * 0.8) * 0.1;\n      float wobble2 = uBend2 + cos(waveTime2 + twistX * 1.1) * 0.1;\n      vec2 sineOffset = sin(vec2(twistX, twistX + halfPi) + strandTime1) * wobble1;\n      vec2 cosineOffset = cos(vec2(twistX, twistX + halfPi) + strandTime2) * wobble2;\n\n      vec2 yz = position.yz;\n      fieldDistance.x = length(yz - sineOffset) - uLineThickness;\n      fieldDistance.y = length(yz - cosineOffset) - uLineThickness;\n\n      float currentDistance = min(fieldDistance.x, fieldDistance.y);\n      stepDistance = min(stepDistance, currentDistance);\n\n      if (stepDistance < 0.001 || depth > tFar) break;\n      depth += max(stepDistance, 0.02) * 0.7;\n    }\n\n    float rootDepth = sqrt(max(depth, 0.0));\n    vec3 raw = max(\n      cos(depth * pi2) - stepDistance * rootDepth - vec3(fieldDistance, 0.0),\n      0.0\n    );\n    raw.gb += 0.1;\n\n    float maximumChannel = max(raw.r, max(raw.g, raw.b));\n    if (maximumChannel < 0.15) discard;\n\n    raw = raw * 0.4 + raw.brg * 0.6 + raw * raw;\n    float luminance = dot(raw, vec3(0.299, 0.587, 0.114));\n    float strandWeight1 = max(0.0, 1.0 - fieldDistance.x * 2.0);\n    float strandWeight2 = max(0.0, 1.0 - fieldDistance.y * 2.0);\n    float totalWeight = strandWeight1 + strandWeight2 + 0.001;\n    vec3 plasmaColor =\n      (uColor1 * strandWeight1 + uColor2 * strandWeight2) /\n      totalWeight * luminance * 3.5 * uIntensity;\n\n    // Soft falloff as we approach the sphere's surface so the plasma\n    // dissolves into the container's glow instead of ending with a hard rim.\n    float edgeFade = 1.0 - smoothstep(sphereRadius * 0.72, sphereRadius, length(position));\n    plasmaColor *= edgeFade;\n\n    outputColor = vec4(plasmaColor, edgeFade);\n  }\n\n  void main() {\n    vec2 coordinate = gl_FragCoord.xy + uOffset;\n    coordinate -= 0.5 * uResolution;\n\n    float cosine = cos(uRotation);\n    float sine = sin(uRotation);\n    coordinate = mat2(cosine, -sine, sine, cosine) * coordinate;\n    coordinate += 0.5 * uResolution;\n\n    vec4 color;\n    renderPlasma(color, coordinate);\n    gl_FragColor = color;\n  }\n`;\n\nexport function AiOrb({\n  ariaLabel = \"Animated AI plasma field\",\n  backgroundColor = DEFAULT_BACKGROUND_COLOR,\n  className,\n  intensity = DEFAULT_SETTINGS.intensity,\n  interactive = DEFAULT_SETTINGS.interactive,\n  pointerStrength = DEFAULT_SETTINGS.pointerStrength,\n  primaryColor = DEFAULT_SETTINGS.primaryColor,\n  secondaryColor = DEFAULT_SETTINGS.secondaryColor,\n  speed = DEFAULT_SETTINGS.speed,\n  strandWidth = DEFAULT_SETTINGS.strandWidth,\n  twist = DEFAULT_SETTINGS.twist,\n}: AiOrbProps) {\n  const containerRef = React.useRef<HTMLDivElement>(null);\n  const runtimeRef = React.useRef<OrbRuntime | null>(null);\n  const settingsRef = React.useRef<OrbSettings>(DEFAULT_SETTINGS);\n  const [renderState, setRenderState] = React.useState<\"loading\" | \"ready\" | \"fallback\">(\"loading\");\n\n  settingsRef.current = {\n    intensity: Math.max(0.25, Math.min(5.5, intensity)),\n    interactive,\n    pointerStrength: Math.max(0, Math.min(2, pointerStrength)),\n    primaryColor,\n    secondaryColor,\n    speed: Math.max(0, Math.min(2, speed)),\n    strandWidth: Math.max(0.2, Math.min(1, strandWidth)),\n    twist: Math.max(1, Math.min(10, twist)),\n  };\n\n  React.useEffect(() => {\n    const runtime = runtimeRef.current;\n    if (!runtime) return;\n\n    runtime.uniforms.uIntensity.value = Math.max(0.25, Math.min(2.5, intensity));\n    runtime.uniforms.uColor1.value.set(primaryColor);\n    runtime.uniforms.uColor2.value.set(secondaryColor);\n    runtime.uniforms.uLineThickness.value = Math.max(0.2, Math.min(1, strandWidth));\n    runtime.uniforms.uTwist.value = Math.max(1, Math.min(10, twist));\n    runtime.render();\n  }, [intensity, primaryColor, secondaryColor, strandWidth, twist]);\n\n  React.useEffect(() => {\n    const container = containerRef.current;\n    if (!container) return;\n\n    let cancelled = false;\n    let frameId = 0;\n    let resizeObserver: ResizeObserver | null = null;\n    let intersectionObserver: IntersectionObserver | null = null;\n    let removePointerListeners = () => {};\n    let disposeRuntime = () => {};\n\n    const setup = async () => {\n      try {\n        const THREE: ThreeModule = await import(\"three\");\n        if (cancelled) return;\n\n        const scene = new THREE.Scene();\n        const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);\n        const renderer = new THREE.WebGLRenderer({\n          alpha: true,\n          antialias: false,\n          depth: false,\n          powerPreference: \"high-performance\",\n          premultipliedAlpha: false,\n          stencil: false,\n        });\n        renderer.setClearColor(0x000000, 0);\n        renderer.outputColorSpace = THREE.SRGBColorSpace;\n        renderer.toneMapping = THREE.NoToneMapping;\n        renderer.domElement.setAttribute(\"aria-hidden\", \"true\");\n        renderer.domElement.className = \"absolute inset-0 size-full\";\n        container.appendChild(renderer.domElement);\n\n        const settings = settingsRef.current;\n        const uniforms: PlasmaUniforms = {\n          uBend1: { value: 1 },\n          uBend2: { value: 0.5 },\n          uColor1: { value: new THREE.Color(settings.primaryColor) },\n          uColor2: { value: new THREE.Color(settings.secondaryColor) },\n          uDir2: { value: 1 },\n          uFocalLength: { value: 1.5 },\n          uIntensity: { value: settings.intensity },\n          uLineThickness: { value: settings.strandWidth },\n          uOffset: { value: new THREE.Vector2() },\n          uResolution: { value: new THREE.Vector2(1, 1) },\n          uRotation: { value: 0 },\n          uSpeed1: { value: 0.05 },\n          uSpeed2: { value: 0.05 },\n          uTime: { value: 0 },\n          uTwist: { value: settings.twist },\n        };\n\n        const geometry = new THREE.BufferGeometry();\n        geometry.setAttribute(\n          \"position\",\n          new THREE.BufferAttribute(new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0]), 3),\n        );\n\n        const material = new THREE.ShaderMaterial({\n          depthTest: false,\n          depthWrite: false,\n          fragmentShader: FRAGMENT_SHADER,\n          transparent: true,\n          uniforms,\n          vertexShader: VERTEX_SHADER,\n        });\n        const plasma = new THREE.Mesh(geometry, material);\n        plasma.frustumCulled = false;\n        scene.add(plasma);\n\n        const pointerTarget = new THREE.Vector2();\n        const pointerCurrent = new THREE.Vector2();\n        let isVisible = true;\n        let lastTime = performance.now();\n\n        const render = () => renderer.render(scene, camera);\n\n        const resize = () => {\n          const { width, height } = container.getBoundingClientRect();\n          const safeWidth = Math.max(1, Math.round(width));\n          const safeHeight = Math.max(1, Math.round(height));\n          renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));\n          renderer.setSize(safeWidth, safeHeight, false);\n          renderer.getDrawingBufferSize(uniforms.uResolution.value);\n          render();\n        };\n\n        const finePointer = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n        const reducedMotion = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n        const applyPointerDepth = () => {\n          const resolution = uniforms.uResolution.value;\n          const strength = settingsRef.current.pointerStrength;\n          uniforms.uOffset.value.set(\n            pointerCurrent.x * resolution.x * 0.075 * strength,\n            -pointerCurrent.y * resolution.y * 0.075 * strength,\n          );\n          uniforms.uRotation.value = pointerCurrent.x * 0.1 * strength;\n        };\n        const resetPointerDepth = () => {\n          pointerTarget.set(0, 0);\n          pointerCurrent.set(0, 0);\n          applyPointerDepth();\n          render();\n        };\n        const handlePointerMove = (event: PointerEvent) => {\n          if (!settingsRef.current.interactive || !finePointer.matches || reducedMotion.matches) {\n            return;\n          }\n\n          const bounds = container.getBoundingClientRect();\n          pointerTarget.set(\n            ((event.clientX - bounds.left) / Math.max(bounds.width, 1) - 0.5) * 2,\n            ((event.clientY - bounds.top) / Math.max(bounds.height, 1) - 0.5) * 2,\n          );\n        };\n        const handlePointerLeave = () => pointerTarget.set(0, 0);\n\n        reducedMotion.addEventListener(\"change\", resetPointerDepth);\n        container.addEventListener(\"pointermove\", handlePointerMove, { passive: true });\n        container.addEventListener(\"pointerleave\", handlePointerLeave);\n        removePointerListeners = () => {\n          reducedMotion.removeEventListener(\"change\", resetPointerDepth);\n          container.removeEventListener(\"pointermove\", handlePointerMove);\n          container.removeEventListener(\"pointerleave\", handlePointerLeave);\n        };\n\n        resizeObserver = new ResizeObserver(resize);\n        resizeObserver.observe(container);\n        intersectionObserver = new IntersectionObserver(([entry]) => {\n          isVisible = entry?.isIntersecting ?? true;\n        });\n        intersectionObserver.observe(container);\n\n        const animate = (now: number) => {\n          if (cancelled) return;\n\n          const delta = Math.min((now - lastTime) / 1000, 0.05);\n          lastTime = now;\n          const currentSettings = settingsRef.current;\n          if (!currentSettings.interactive) pointerTarget.set(0, 0);\n\n          const shouldAnimate =\n            isVisible &&\n            !document.hidden &&\n            !reducedMotion.matches &&\n            (currentSettings.speed > 0 || currentSettings.interactive);\n\n          if (shouldAnimate) {\n            uniforms.uTime.value += delta * currentSettings.speed;\n            pointerCurrent.lerp(pointerTarget, 1 - Math.exp(-delta * 7.5));\n            applyPointerDepth();\n            render();\n          }\n\n          frameId = window.requestAnimationFrame(animate);\n        };\n\n        runtimeRef.current = { render, renderer, uniforms };\n        resize();\n        render();\n        setRenderState(\"ready\");\n        frameId = window.requestAnimationFrame(animate);\n\n        disposeRuntime = () => {\n          window.cancelAnimationFrame(frameId);\n          runtimeRef.current = null;\n          geometry.dispose();\n          material.dispose();\n          renderer.dispose();\n          renderer.domElement.remove();\n        };\n      } catch {\n        if (!cancelled) setRenderState(\"fallback\");\n      }\n    };\n\n    void setup();\n\n    return () => {\n      cancelled = true;\n      window.cancelAnimationFrame(frameId);\n      resizeObserver?.disconnect();\n      intersectionObserver?.disconnect();\n      removePointerListeners();\n      disposeRuntime();\n    };\n  }, []);\n\n  return (\n    <div\n      aria-label={ariaLabel}\n      className={cn(\n        \"relative isolate aspect-square w-[min(82vw,20rem)] overflow-hidden rounded-full shadow-[inset_0_1px_0_rgb(255_255_255/0.06),inset_0_-24px_48px_rgb(0_0_0/0.3)] [contain:layout_paint]\",\n        className,\n      )}\n      data-render-state={renderState}\n      ref={containerRef}\n      role=\"img\"\n      style={{\n        background: `radial-gradient(circle at 50% 42%, color-mix(in srgb, ${backgroundColor} 78%, white) 0%, ${backgroundColor} 58%, color-mix(in srgb, ${backgroundColor} 72%, black) 100%)`,\n      }}\n    >\n      <div\n        aria-hidden=\"true\"\n        className={cn(\n          \"pointer-events-none absolute inset-[12%] rounded-full blur-3xl transition-opacity duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]\",\n          renderState === \"ready\" ? \"opacity-0\" : \"opacity-70\",\n        )}\n        style={{\n          background: `radial-gradient(circle, ${primaryColor}, ${secondaryColor} 52%, transparent 72%)`,\n        }}\n      />\n    </div>\n  );\n}\n","type":"registry:ui"}],"categories":["ai","visualization"],"type":"registry:ui"}