{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "orb",
  "title": "Orb",
  "description": "A sphere that carries what an assistant is doing, settled when idle and moving with the voice when there is one.",
  "registryDependencies": [
    "utils",
    "https://ui.tinkererslabs.com/r/render-surface.json"
  ],
  "files": [
    {
      "path": "registry/default/orb/orb.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport {\n  RenderSurface,\n  useThemeColors,\n  type SurfaceColor,\n} from \"@/registry/default/render-surface/render-surface\"\nimport { cn } from \"@/lib/utils\"\n\nexport type OrbState = \"idle\" | \"listening\" | \"thinking\" | \"speaking\"\n\nexport type OrbProps = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"children\"\n> & {\n  state?: OrbState\n  /** How loud it is right now, nought to one. Only read while listening or speaking. */\n  level?: number\n  /** A theme token, or any CSS colour. */\n  color?: string\n  size?: number\n  /** Said aloud, since the orb itself is a drawing. */\n  label?: string\n}\n\ntype Ring = { phase: number; speed: number; weight: number }\n\nconst PACE: Record<OrbState, number> = {\n  idle: 0.25,\n  listening: 0.7,\n  thinking: 1.5,\n  speaking: 1.1,\n}\n\n/**\n * A sphere that says what an assistant is doing without saying it in words:\n * settled when idle, breathing while it listens, turning over while it thinks,\n * moving with the voice while it speaks.\n *\n * The drawing is decoration. What state it is in is also written down for a\n * screen reader, because a circle changing pace is not a sentence.\n */\nexport function Orb({\n  state = \"idle\",\n  level = 0,\n  color = \"--primary\",\n  size = 120,\n  label,\n  className,\n  ...rootProps\n}: OrbProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const tokens = React.useMemo(\n    () => (color.startsWith(\"--\") ? [color] : []),\n    [color]\n  )\n  const resolved = useThemeColors(rootRef, tokens)\n  const ink = color.startsWith(\"--\") ? resolved[color] : undefined\n\n  // Read in the frame loop rather than through props, so a level arriving at\n  // the audio rate does not re-render the tree.\n  const live = React.useRef({ state, level })\n  React.useEffect(() => {\n    live.current = { state, level }\n  })\n\n  const setup = React.useCallback(\n    (): Ring[] =>\n      Array.from({ length: 3 }, (_, index) => ({\n        phase: index * 2.1,\n        speed: 0.6 + index * 0.35,\n        weight: 1 - index * 0.22,\n      })),\n    []\n  )\n\n  const draw = React.useCallback(\n    ({\n      context,\n      size: box,\n      state: rings,\n      time,\n    }: {\n      context: CanvasRenderingContext2D\n      size: { width: number; height: number; dpr: number }\n      state: Ring[]\n      time: number\n    }) => {\n      if (!ink) return\n\n      context.setTransform(box.dpr, 0, 0, box.dpr, 0, 0)\n      context.clearRect(0, 0, box.width, box.height)\n\n      const { state: mode, level: loudness } = live.current\n      const cx = box.width / 2\n      const cy = box.height / 2\n      // The glow has to finish inside the box. Reaching past the half width\n      // leaves it cut off square at the edges instead of fading out.\n      const half = Math.min(box.width, box.height) / 2\n      const [r, g, b] = ink as SurfaceColor\n      const rgb = `${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}`\n      const t = time * PACE[mode]\n\n      // Only the voice states follow the level; the others keep their own pace.\n      const push =\n        mode === \"listening\" || mode === \"speaking\"\n          ? Math.min(Math.max(loudness, 0), 1) * 0.28\n          : 0\n\n      // Idle is the resting state, so it sits smaller and dimmer rather than\n      // being the same orb moved slowly.\n      const calm = mode === \"idle\" ? 0.78 : 1\n      const breath = rings.reduce(\n        (sum, ring) => sum + Math.sin(t * ring.speed + ring.phase) * 0.02,\n        0\n      )\n      const radius = half * 0.46 * calm * (1 + breath + push)\n\n      const halo = context.createRadialGradient(cx, cy, radius, cx, cy, half)\n      halo.addColorStop(0, `rgba(${rgb}, ${0.26 * calm})`)\n      halo.addColorStop(0.5, `rgba(${rgb}, ${0.08 * calm})`)\n      halo.addColorStop(1, `rgba(${rgb}, 0)`)\n      context.fillStyle = halo\n      context.fillRect(0, 0, box.width, box.height)\n\n      // Lit from above and to the left, which is what makes a filled circle\n      // read as a sphere rather than a disc.\n      const shade = (amount: number) =>\n        `${Math.round(Math.min(255, r * 255 * amount))}, ${Math.round(\n          Math.min(255, g * 255 * amount)\n        )}, ${Math.round(Math.min(255, b * 255 * amount))}`\n\n      const body = context.createRadialGradient(\n        cx - radius * 0.34,\n        cy - radius * 0.34,\n        radius * 0.08,\n        cx,\n        cy,\n        radius\n      )\n      body.addColorStop(0, `rgba(${shade(1.32)}, 1)`)\n      body.addColorStop(0.45, `rgba(${rgb}, 1)`)\n      body.addColorStop(0.88, `rgba(${shade(0.82)}, 1)`)\n      // The last stop feathers the rim, so the edge is not a hard cut.\n      body.addColorStop(1, `rgba(${shade(0.78)}, 0)`)\n\n      context.fillStyle = body\n      context.beginPath()\n      context.arc(cx, cy, radius, 0, Math.PI * 2)\n      context.fill()\n    },\n    [ink]\n  )\n\n  return (\n    <div\n      ref={rootRef}\n      data-slot=\"orb\"\n      data-state={state}\n      className={cn(\"relative isolate\", className)}\n      style={{ width: size, height: size }}\n      {...rootProps}\n    >\n      {ink !== undefined && (\n        <RenderSurface<Ring[], \"2d\">\n          setup={setup}\n          draw={draw}\n          className=\"absolute inset-0\"\n        />\n      )}\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {label ?? state}\n      </span>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "ai",
    "agent",
    "canvas",
    "voice",
    "status"
  ],
  "type": "registry:ui"
}