{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bars-theme",
  "title": "Bars Theme",
  "description": "A traveling-wave bars visualization with 5 vertical bars sharing a sine-wave frequency, evenly phase-shifted left to right so the wave appears to travel across. Volume scales bar heights with a diamond-shaped idle boost.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "components/ui/bars-theme.tsx",
      "type": "registry:ui",
      "content": "'use client'\n\nimport { useEffect, useRef } from 'react'\nimport type { AriaAttributes, CSSProperties } from 'react'\nimport { useReducedMotion } from 'motion/react'\nimport { useCssColorRgb } from '@/lib/hooks/use-css-color-rgb'\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 BarsThemeProps 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\nconst BAR_COUNT = 5\nconst WAVE_FREQ = 1.4\nconst WAVE_PHASE_STEP = (Math.PI * 2) / BAR_COUNT\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: [number, number, number] = [26, 115, 242]\nconst FALLBACK_DESTRUCTIVE: [number, number, number] = [232, 80, 80]\n\nconst BLEND_MS = 280\n\nexport function BarsTheme({\n  state,\n  volume,\n  size,\n  className,\n  style,\n  disabled = false,\n  interactive = false,\n  onClick,\n  ...controlProps\n}: BarsThemeProps) {\n  const barRefs = useRef<(HTMLSpanElement | null)[]>([])\n  const containerRef = useRef<HTMLSpanElement>(null)\n  const rafRef = useRef<number>(0)\n  const smoothed = useRef<number[]>(new Array(BAR_COUNT).fill(0))\n  const volumeRef = useRef(volume)\n  const hoveredRef = useRef(false)\n  const hoverBoostRef = useRef(0)\n  const blendStartRef = useRef<number | null>(null)\n  const frozenHeightsRef = useRef<number[]>(new Array(BAR_COUNT).fill(0))\n  const prevStateRef = useRef(state)\n  const touchEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n\n  const reduceMotion = useReducedMotion()\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  const currentColorRef = useRef<[number, number, number]>(accentRgb)\n  useEffect(() => {\n    accentRgbRef.current = accentRgb\n  }, [accentRgb])\n  useEffect(() => {\n    destructiveRgbRef.current = destructiveRgb\n  }, [destructiveRgb])\n\n  useEffect(\n    () => () => {\n      if (touchEndTimerRef.current) clearTimeout(touchEndTimerRef.current)\n    },\n    [],\n  )\n\n  useEffect(() => {\n    if (state !== prevStateRef.current) {\n      frozenHeightsRef.current = [...smoothed.current]\n      blendStartRef.current = Date.now()\n      prevStateRef.current = state\n    }\n  }, [state])\n\n  useEffect(() => {\n    volumeRef.current = volume\n  }, [volume])\n\n  useEffect(() => {\n    const maxH = size * 0.55\n    const minH = size * 0.06\n    const hoverBoostMax = size * 0.08\n\n    const diamondWeights = Array.from({ length: BAR_COUNT }, (_, i) => {\n      const center = (BAR_COUNT - 1) / 2\n      return 1 - 0.7 * (Math.abs(i - center) / center)\n    })\n\n    const updateHoverBoost = () => {\n      const target = hoveredRef.current ? hoverBoostMax : 0\n      hoverBoostRef.current += (target - hoverBoostRef.current) * 0.14\n    }\n\n    const applyBars = (heights: number[]) => {\n      updateHoverBoost()\n      const targetRgb =\n        STATE_COLOR_TOKEN[state] === 'destructive'\n          ? destructiveRgbRef.current\n          : accentRgbRef.current\n      const [cr, cg, cb] = currentColorRef.current\n      currentColorRef.current = reduceMotion\n        ? targetRgb\n        : [\n            cr + (targetRgb[0] - cr) * 0.07,\n            cg + (targetRgb[1] - cg) * 0.07,\n            cb + (targetRgb[2] - cb) * 0.07,\n          ]\n      const [r, g, b] = currentColorRef.current.map(Math.round)\n\n      for (let i = 0; i < BAR_COUNT; i++) {\n        const el = barRefs.current[i]\n        if (!el) continue\n        const weight = state === 'idle' ? diamondWeights[i] : 1\n        const boost = hoverBoostRef.current * weight\n        el.style.height = `${Math.min(heights[i] + boost, maxH)}px`\n        el.style.background = `rgb(${r},${g},${b})`\n        el.style.boxShadow = `0 ${Math.round(heights[i] * 0.08)}px ${Math.round(heights[i] * 0.12)}px rgba(0,0,0,0.15)`\n      }\n    }\n\n    if (state === 'speaking') {\n      const animate = () => {\n        const vol = volumeRef.current\n        const t = Date.now() / 1000\n\n        for (let i = 0; i < BAR_COUNT; i++) {\n          const osc = reduceMotion\n            ? 0.5\n            : 0.5 + 0.15 * Math.sin(t * WAVE_FREQ * Math.PI * 2 + i * WAVE_PHASE_STEP)\n          let targetH = minH + (maxH - minH) * vol * osc\n\n          if (!reduceMotion && blendStartRef.current !== null) {\n            const elapsed = Date.now() - blendStartRef.current\n            const progress = Math.min(elapsed / BLEND_MS, 1)\n            const ease = 1 - (1 - progress) * (1 - progress)\n            targetH = frozenHeightsRef.current[i] + (targetH - frozenHeightsRef.current[i]) * ease\n            if (progress >= 1) blendStartRef.current = null\n          }\n\n          smoothed.current[i] = reduceMotion\n            ? targetH\n            : smoothed.current[i] + (targetH - smoothed.current[i]) * 0.95\n        }\n\n        applyBars(smoothed.current)\n        rafRef.current = requestAnimationFrame(animate)\n      }\n      rafRef.current = requestAnimationFrame(animate)\n      return () => cancelAnimationFrame(rafRef.current)\n    }\n\n    if (state === 'listening') {\n      const animate = () => {\n        const vol = volumeRef.current\n        const t = Date.now() / 1000\n\n        for (let i = 0; i < BAR_COUNT; i++) {\n          const osc = reduceMotion\n            ? 0.5\n            : 0.5 + 0.1 * Math.sin(t * WAVE_FREQ * 0.35 * Math.PI * 2 + i * WAVE_PHASE_STEP)\n          let targetH = minH + (maxH * 0.5 - minH) * vol * osc\n\n          if (!reduceMotion && blendStartRef.current !== null) {\n            const elapsed = Date.now() - blendStartRef.current\n            const progress = Math.min(elapsed / BLEND_MS, 1)\n            const ease = 1 - (1 - progress) * (1 - progress)\n            targetH = frozenHeightsRef.current[i] + (targetH - frozenHeightsRef.current[i]) * ease\n            if (progress >= 1) blendStartRef.current = null\n          }\n\n          smoothed.current[i] = reduceMotion\n            ? targetH\n            : smoothed.current[i] + (targetH - smoothed.current[i]) * 0.35\n        }\n\n        applyBars(smoothed.current)\n        rafRef.current = requestAnimationFrame(animate)\n      }\n      rafRef.current = requestAnimationFrame(animate)\n      return () => cancelAnimationFrame(rafRef.current)\n    }\n\n    if (state === 'thinking') {\n      if (reduceMotion) {\n        const animateStatic = () => {\n          updateHoverBoost()\n          const targetH = minH + (maxH * 0.45 - minH) * 0.5\n          for (let i = 0; i < BAR_COUNT; i++) {\n            smoothed.current[i] = targetH\n          }\n          applyBars(smoothed.current)\n          rafRef.current = requestAnimationFrame(animateStatic)\n        }\n        rafRef.current = requestAnimationFrame(animateStatic)\n        return () => cancelAnimationFrame(rafRef.current)\n      }\n\n      const startTime = Date.now()\n      const animate = () => {\n        const t = (Date.now() - startTime) / 1000\n        updateHoverBoost()\n        for (let i = 0; i < BAR_COUNT; i++) {\n          const cycle = (t * 0.55 + (i / BAR_COUNT) * 0.5) % 1.0\n          const wave = cycle < 0.5 ? Math.sin((cycle / 0.5) * Math.PI) : 0\n          const targetH = minH + (maxH * 0.45 - minH) * wave\n          smoothed.current[i] += (targetH - smoothed.current[i]) * 0.12\n        }\n        applyBars(smoothed.current)\n        rafRef.current = requestAnimationFrame(animate)\n      }\n      rafRef.current = requestAnimationFrame(animate)\n      return () => cancelAnimationFrame(rafRef.current)\n    }\n\n    cancelAnimationFrame(rafRef.current)\n    const animateStatic = () => {\n      updateHoverBoost()\n      for (let i = 0; i < BAR_COUNT; i++) {\n        smoothed.current[i] = reduceMotion\n          ? minH\n          : smoothed.current[i] + (minH - smoothed.current[i]) * 0.14\n      }\n      applyBars(smoothed.current)\n      rafRef.current = requestAnimationFrame(animateStatic)\n    }\n    rafRef.current = requestAnimationFrame(animateStatic)\n    return () => cancelAnimationFrame(rafRef.current)\n  }, [state, size, reduceMotion])\n\n  const barW = size * 0.055\n  const gap = size * 0.035\n  const radius = size * 0.03\n  const maxH = size * 0.55\n  const minH = size * 0.06\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 initialBg = state === 'error' ? 'var(--color-destructive)' : 'var(--color-accent)'\n\n  const content = (\n    <span\n      ref={containerRef}\n      onMouseEnter={() => {\n        if (disabled) return\n        hoveredRef.current = true\n      }}\n      onMouseLeave={() => {\n        hoveredRef.current = false\n      }}\n      onTouchEnd={() => {\n        touchEndTimerRef.current = setTimeout(() => {\n          hoveredRef.current = false\n        }, 180)\n      }}\n      style={{\n        display: 'flex',\n        alignItems: 'center',\n        justifyContent: 'center',\n        gap,\n        cursor: interactive ? (disabled ? 'not-allowed' : 'pointer') : 'default',\n        transition: reduceMotion ? 'none' : 'transform 180ms cubic-bezier(0.23, 1, 0.32, 1)',\n      }}\n    >\n      {Array.from({ length: BAR_COUNT }, (_, i) => (\n        <span\n          key={i}\n          ref={(el) => {\n            barRefs.current[i] = el\n          }}\n          style={{\n            width: barW,\n            minHeight: minH,\n            maxHeight: maxH,\n            height: minH,\n            borderRadius: radius,\n            background: initialBg,\n            transition: reduceMotion ? 'none' : 'opacity 200ms ease-out',\n          }}\n        />\n      ))}\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 BarsThemePreview() {\n  return <BarsTheme state=\"listening\" volume={0.5} size={180} />\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"
}
