{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "voice-input",
  "type": "registry:ui",
  "title": "Voice Input",
  "description": "A mic that grows into a listening pill with live levels and words.",
  "categories": [
    "ai-agents"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/voice-input.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Check, Mic, Warning, X } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\n\n/* -------------------------------------------------------------------------------------------------\n * Engine: where the audio, the level and the words come from\n * -----------------------------------------------------------------------------------------------*/\n\nexport type VoiceError = \"denied\" | \"unavailable\" | \"no-speech\" | \"failed\";\n\nexport type VoiceEvents = {\n  lang?: string;\n  /** Input loudness, 0–1. Call it as often as you have it; the bars sample it. */\n  onLevel: (level: number) => void;\n  /** The transcript so far. `final` is false while the last words may still change. */\n  onTranscript: (text: string, final: boolean) => void;\n  /** After `stop()`: the finished transcript, and the recording when there is one. */\n  onEnd: (result: { text: string; audio?: Blob }) => void;\n  onError: (error: VoiceError) => void;\n};\n\nexport type VoiceSession = { stop: () => void; cancel: () => void };\n\n/** Resolves once the microphone is open; rejects with a VoiceError when it can't be. */\nexport type VoiceEngine = (events: VoiceEvents) => Promise<VoiceSession>;\n\ntype Recognition = {\n  continuous: boolean;\n  interimResults: boolean;\n  lang: string;\n  onresult: ((e: { results: ArrayLike<ArrayLike<{ transcript: string }> & { isFinal: boolean }> }) => void) | null;\n  onerror: ((e: { error: string }) => void) | null;\n  onend: (() => void) | null;\n  start: () => void;\n  stop: () => void;\n  abort: () => void;\n};\ntype RecognitionCtor = new () => Recognition;\n\n/**\n * The built-in engine: the microphone for levels and a recording, and the\n * browser's own speech recognition for live words where it exists. Pass\n * `transcribe` to VoiceInput to send the recording to your own service instead.\n */\nexport const browserVoiceEngine: VoiceEngine = async (ev) => {\n  if (!navigator.mediaDevices?.getUserMedia) throw \"unavailable\" satisfies VoiceError;\n  let stream: MediaStream;\n  try {\n    stream = await navigator.mediaDevices.getUserMedia({\n      audio: { echoCancellation: true, noiseSuppression: true },\n    });\n  } catch (e) {\n    const name = (e as DOMException)?.name;\n    throw (name === \"NotAllowedError\" || name === \"SecurityError\" ? \"denied\" : \"unavailable\") satisfies VoiceError;\n  }\n\n  const ctx = new AudioContext();\n  const analyser = ctx.createAnalyser();\n  analyser.fftSize = 512;\n  ctx.createMediaStreamSource(stream).connect(analyser);\n  const buf = new Float32Array(analyser.fftSize);\n  let raf = 0;\n  const tick = () => {\n    analyser.getFloatTimeDomainData(buf);\n    let sum = 0;\n    for (const v of buf) sum += v * v;\n    // Speech sits around 0.02–0.15 RMS; stretch it so a normal voice fills the bars.\n    ev.onLevel(Math.min(1, Math.sqrt(sum / buf.length) * 7));\n    raf = requestAnimationFrame(tick);\n  };\n  tick();\n\n  const chunks: Blob[] = [];\n  const recorder = typeof MediaRecorder !== \"undefined\" ? new MediaRecorder(stream) : null;\n  if (recorder) {\n    recorder.ondataavailable = (e) => e.data.size && chunks.push(e.data);\n    recorder.start();\n  }\n\n  const w = window as unknown as {\n    SpeechRecognition?: RecognitionCtor;\n    webkitSpeechRecognition?: RecognitionCtor;\n  };\n  const SR = w.SpeechRecognition ?? w.webkitSpeechRecognition;\n  const rec = SR ? new SR() : null;\n  let finalText = \"\";\n  let interim = \"\";\n  if (rec) {\n    rec.continuous = true;\n    rec.interimResults = true;\n    rec.lang = ev.lang ?? document.documentElement.lang ?? \"en-US\";\n    rec.onresult = (e) => {\n      finalText = \"\";\n      interim = \"\";\n      for (let i = 0; i < e.results.length; i++) {\n        const r = e.results[i];\n        if (r.isFinal) finalText += r[0].transcript;\n        else interim += r[0].transcript;\n      }\n      ev.onTranscript(`${finalText}${interim}`.trim(), !interim);\n    };\n    rec.onerror = (e) => {\n      if (e.error === \"not-allowed\") ev.onError(\"denied\");\n    };\n    try {\n      rec.start();\n    } catch {\n      /* Already started or unsupported; levels and recording still work. */\n    }\n  }\n\n  const release = () => {\n    cancelAnimationFrame(raf);\n    stream.getTracks().forEach((t) => t.stop());\n    void ctx.close();\n  };\n\n  return {\n    stop() {\n      cancelAnimationFrame(raf);\n      const audio = new Promise<Blob | undefined>((resolve) => {\n        if (!recorder || recorder.state === \"inactive\") return resolve(undefined);\n        recorder.onstop = () => resolve(new Blob(chunks, { type: recorder.mimeType }));\n        recorder.stop();\n      });\n      // Recognition delivers its last final result just before it ends; don't wait forever for it.\n      const words = new Promise<void>((resolve) => {\n        if (!rec) return resolve();\n        const t = window.setTimeout(resolve, 1500);\n        rec.onend = () => (window.clearTimeout(t), resolve());\n        rec.stop();\n      });\n      void Promise.all([audio, words]).then(([blob]) => {\n        release();\n        ev.onEnd({ text: `${finalText} ${interim}`.trim(), audio: blob });\n      });\n    },\n    cancel() {\n      rec?.abort();\n      if (recorder && recorder.state !== \"inactive\") recorder.stop();\n      release();\n    },\n  };\n};\n\n/* -------------------------------------------------------------------------------------------------\n * useVoiceInput: the state machine on its own\n * -----------------------------------------------------------------------------------------------*/\n\nexport type VoiceState = \"idle\" | \"requesting\" | \"listening\" | \"transcribing\" | \"error\";\n\nexport type UseVoiceInputOptions = {\n  engine?: VoiceEngine;\n  lang?: string;\n  /** Turn the recording into text yourself. When set, it replaces the engine's live words at the end. */\n  transcribe?: (audio: Blob) => Promise<string>;\n  /** Seconds before listening stops on its own. */\n  maxDuration?: number;\n  onTranscript?: (text: string) => void;\n  onError?: (error: VoiceError) => void;\n};\n\nexport function useVoiceInput({ engine = browserVoiceEngine, lang, transcribe, maxDuration = 120, onTranscript, onError }: UseVoiceInputOptions = {}) {\n  const [state, setState] = useState<VoiceState>(\"idle\");\n  const [error, setError] = useState<VoiceError | null>(null);\n  const [transcript, setTranscript] = useState({ text: \"\", final: true });\n  const [elapsed, setElapsed] = useState(0);\n  const phase = useRef<VoiceState>(\"idle\");\n  const session = useRef<VoiceSession | null>(null);\n  const run = useRef(0);\n  const levels = useRef(new Set<(level: number) => void>());\n  const latest = useRef({ transcribe, onTranscript, onError });\n  useEffect(() => {\n    latest.current = { transcribe, onTranscript, onError };\n  });\n\n  const go = (next: VoiceState) => {\n    phase.current = next;\n    setState(next);\n  };\n  const fail = useCallback((e: VoiceError) => {\n    session.current?.cancel();\n    session.current = null;\n    run.current++;\n    phase.current = \"error\";\n    setState(\"error\");\n    setError(e);\n    latest.current.onError?.(e);\n  }, []);\n\n  const start = async () => {\n    if (phase.current !== \"idle\" && phase.current !== \"error\") return;\n    const id = ++run.current;\n    const live = () => run.current === id;\n    setError(null);\n    setTranscript({ text: \"\", final: true });\n    setElapsed(0);\n    go(\"requesting\");\n    try {\n      const s = await engine({\n        lang,\n        onLevel: (l) => live() && levels.current.forEach((fn) => fn(l)),\n        onTranscript: (text, final) => live() && setTranscript({ text, final }),\n        onError: (e) => live() && fail(e),\n        onEnd: async ({ text, audio }) => {\n          if (!live()) return;\n          session.current = null;\n          let result = text;\n          const { transcribe: own } = latest.current;\n          if (own && audio) {\n            try {\n              result = await own(audio);\n            } catch {\n              if (live()) fail(\"failed\");\n              return;\n            }\n          }\n          if (!live()) return;\n          if (!result.trim()) return fail(\"no-speech\");\n          setTranscript({ text: result.trim(), final: true });\n          go(\"idle\");\n          latest.current.onTranscript?.(result.trim());\n        },\n      });\n      if (!live()) return s.cancel();\n      session.current = s;\n      go(\"listening\");\n    } catch (e) {\n      if (live()) fail(typeof e === \"string\" ? (e as VoiceError) : \"unavailable\");\n    }\n  };\n\n  const stop = useCallback(() => {\n    if (phase.current !== \"listening\") return;\n    phase.current = \"transcribing\";\n    setState(\"transcribing\");\n    session.current?.stop();\n  }, []);\n\n  const cancel = useCallback(() => {\n    run.current++;\n    session.current?.cancel();\n    session.current = null;\n    phase.current = \"idle\";\n    setState(\"idle\");\n    setError(null);\n  }, []);\n\n  // The clock, and the ceiling on how long one take can run.\n  useEffect(() => {\n    if (state !== \"listening\") return;\n    const t0 = Date.now();\n    const id = window.setInterval(() => {\n      const s = (Date.now() - t0) / 1000;\n      setElapsed(s);\n      if (s >= maxDuration) stop();\n    }, 200);\n    return () => window.clearInterval(id);\n  }, [state, maxDuration, stop]);\n\n  // Never leave a microphone open behind an unmounted component.\n  useEffect(\n    () => () => {\n      run.current++;\n      session.current?.cancel();\n    },\n    [],\n  );\n\n  const subscribeLevel = useCallback((fn: (level: number) => void) => {\n    levels.current.add(fn);\n    return () => void levels.current.delete(fn);\n  }, []);\n\n  return {\n    state,\n    error,\n    transcript,\n    elapsed,\n    start,\n    stop,\n    cancel,\n    subscribeLevel,\n  };\n}\n\n/* -------------------------------------------------------------------------------------------------\n * VoiceInput\n * -----------------------------------------------------------------------------------------------*/\n\nexport type VoiceInputProps = Omit<React.ComponentProps<\"div\">, \"onError\"> &\n  UseVoiceInputOptions & {\n    /** The mic button's accessible name and tooltip. */\n    label?: string;\n    /** Where the live transcript floats. */\n    side?: \"top\" | \"bottom\";\n    disabled?: boolean;\n  };\n\nconst errors: Record<VoiceError, { short: string; hint: string; retry: boolean }> = {\n  denied: {\n    short: \"Microphone blocked\",\n    hint: \"Allow microphone access for this site in your browser’s settings, then try again.\",\n    retry: true,\n  },\n  unavailable: {\n    short: \"No microphone\",\n    hint: \"Connect a microphone, or check that this page is allowed to use one.\",\n    retry: true,\n  },\n  \"no-speech\": {\n    short: \"Didn’t catch that\",\n    hint: \"Nothing was heard. Speak a little closer to the microphone.\",\n    retry: true,\n  },\n  failed: {\n    short: \"Couldn’t transcribe\",\n    hint: \"The recording didn’t turn into text. Try again in a moment.\",\n    retry: true,\n  },\n};\n\nconst fmt = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, \"0\")}`;\n\nconst iconButton = cn(\n  \"relative grid shrink-0 place-items-center rounded-full outline-none\",\n  \"transition-[background-color,color,scale,opacity] duration-150 ease-out active:scale-[0.92] active:duration-75\",\n  \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n  \"disabled:pointer-events-none disabled:opacity-50\",\n  // Drawn small, touched at 44px.\n  \"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden\",\n);\n\nexport function VoiceInput({\n  engine,\n  lang,\n  transcribe,\n  maxDuration = 120,\n  onTranscript,\n  onError,\n  label = \"Dictate\",\n  side = \"top\",\n  disabled = false,\n  className,\n  ...rest\n}: VoiceInputProps) {\n  const reduce = !!useReducedMotion();\n  const voice = useVoiceInput({\n    engine,\n    lang,\n    transcribe,\n    maxDuration,\n    onTranscript,\n    onError,\n  });\n  const { state, error, transcript, elapsed } = voice;\n  const root = useRef<HTMLDivElement>(null);\n  const micRef = useRef<HTMLButtonElement>(null);\n  const doneRef = useRef<HTMLButtonElement>(null);\n  const cancelRef = useRef<HTMLButtonElement>(null);\n  const retryRef = useRef<HTMLButtonElement>(null);\n  const open = state !== \"idle\";\n  const ending = maxDuration - elapsed <= 10;\n\n  // Whether the last press came from a key (Enter/Space give a click with detail 0).\n  const keyboard = useRef(false);\n  // Room to the left of the pill's right edge, so the floating caption never runs off screen.\n  const [room, setRoom] = useState<number>();\n  const press = (fn: () => void) => (e: React.MouseEvent) => {\n    keyboard.current = e.detail === 0;\n    const r = root.current?.getBoundingClientRect();\n    if (r) setRoom(Math.max(160, r.right - 28));\n    fn();\n  };\n\n  // Keep focus on the control that now does the job, but only if focus was\n  // already ours: the host may have moved it (e.g. into the field it filled).\n  // After a pointer press it moves quietly, without a ring.\n  useEffect(() => {\n    const active = document.activeElement;\n    if (active && active !== document.body && !root.current?.contains(active)) return;\n    const target = state === \"idle\" ? micRef : state === \"listening\" ? doneRef : state === \"error\" ? retryRef : cancelRef;\n    target.current?.focus({\n      preventScroll: true,\n      focusVisible: keyboard.current,\n    } as FocusOptions);\n  }, [state]);\n\n  // Escape cancels from anywhere while the microphone is open, not only from inside the pill.\n  useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key !== \"Escape\" || e.defaultPrevented) return;\n      e.preventDefault();\n      keyboard.current = true;\n      voice.cancel();\n    };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [open, voice.cancel]);\n\n  const spoken =\n    state === \"requesting\"\n      ? \"Waiting for microphone access\"\n      : state === \"listening\"\n        ? \"Listening\"\n        : state === \"transcribing\"\n          ? \"Transcribing\"\n          : state === \"error\" && error\n            ? errors[error].short\n            : \"\";\n\n  const caption =\n    state === \"error\" && error\n      ? { kind: \"hint\" as const, text: errors[error].hint }\n      : state === \"requesting\"\n        ? {\n            kind: \"hint\" as const,\n            text: \"Allow the microphone when your browser asks.\",\n          }\n        : (state === \"listening\" || state === \"transcribing\") && transcript.text\n          ? { kind: \"words\" as const, text: transcript.text }\n          : null;\n\n  const enter = reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9, filter: \"blur(2px)\" };\n  const shown = { opacity: 1, scale: 1, filter: \"blur(0px)\" };\n  const leave = reduce\n    ? { opacity: 0 }\n    : {\n        opacity: 0,\n        scale: 0.9,\n        filter: \"blur(2px)\",\n        transition: { duration: 0.1 },\n      };\n\n  return (\n    <div ref={root} data-state={state} className={cn(\"relative inline-flex shrink-0\", className)} {...rest}>\n      {/* Live words float beside the pill, newest at the edge; the start fades out. */}\n      <AnimatePresence>\n        {caption && (\n          <motion.div\n            key={caption.kind}\n            aria-hidden={caption.kind === \"words\"}\n            style={room ? ({ \"--room\": `${room}px` } as React.CSSProperties) : undefined}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: side === \"top\" ? 4 : -4, scale: 0.97 }}\n            animate={{ opacity: 1, y: 0, scale: 1 }}\n            exit={{ opacity: 0, transition: { duration: 0.12 } }}\n            transition={{ duration: 0.2, ease: ease.out }}\n            className={cn(\n              \"pointer-events-none absolute right-0 z-(--z-popover) w-max max-w-[min(18rem,var(--room,calc(100vw-2rem)))] rounded-xl border border-line-2 bg-raised px-3 py-2 shadow-pop\",\n              side === \"top\" ? \"bottom-full mb-2 origin-bottom-right\" : \"top-full mt-2 origin-top-right\",\n            )}\n          >\n            {caption.kind === \"hint\" ? (\n              <p className=\"text-pretty text-[12px] leading-[1.45] text-fg-2\">{caption.text}</p>\n            ) : (\n              <p className=\"flex justify-end overflow-hidden whitespace-nowrap text-[13px] leading-5 text-fg [mask-image:linear-gradient(to_right,transparent,black_28px)]\">\n                <span className={cn(\"transition-colors duration-200\", !transcript.final && state === \"listening\" && \"text-fg-2\")}>{caption.text}</span>\n              </p>\n            )}\n          </motion.div>\n        )}\n      </AnimatePresence>\n\n      {/* The mic button grows into the pill from where it sits; content is pinned to the right edge. */}\n      <motion.div\n        initial={false}\n        animate={{ width: open ? \"auto\" : 32 }}\n        transition={reduce ? { duration: 0 } : open ? spring.snappy : { duration: 0.2, ease: ease.inOut }}\n        className={cn(\n          \"flex h-8 items-center justify-end overflow-hidden rounded-full border transition-[background-color,border-color,box-shadow] duration-200\",\n          open ? \"border-line-2 bg-raised shadow-[var(--shadow)]\" : \"border-transparent\",\n          state === \"error\" && \"border-danger/30\",\n        )}\n      >\n        {/* Both faces share one grid cell pinned right, so the one leaving is clipped by the shrinking pill instead of jumping. */}\n        <span className=\"grid shrink-0 [&>*]:col-start-1 [&>*]:row-start-1 [&>*]:justify-self-end\">\n          <AnimatePresence initial={false}>\n            {!open ? (\n              <motion.span key=\"mic\" initial={enter} animate={shown} exit={leave} transition={reduce ? { duration: 0.12 } : spring.pop} className=\"flex\">\n                <button\n                  ref={micRef}\n                  type=\"button\"\n                  aria-label={label}\n                  title={label}\n                  disabled={disabled}\n                  onClick={press(() => void voice.start())}\n                  className={cn(iconButton, \"group/mic size-[30px] text-fg-2 hover:bg-hover hover:text-fg\")}\n                >\n                  <Mic className=\"transition-transform duration-200 ease-out group-hover/mic:-translate-y-px\" />\n                </button>\n              </motion.span>\n            ) : (\n              <motion.div\n                key=\"pill\"\n                role=\"group\"\n                aria-label=\"Voice input\"\n                initial={reduce ? { opacity: 0 } : { opacity: 0, x: 8 }}\n                animate={{ opacity: 1, x: 0 }}\n                exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                transition={{ duration: 0.2, ease: ease.out }}\n                className=\"flex shrink-0 items-center gap-1.5 py-0.5 pl-0.5 pr-0.5\"\n              >\n                {state === \"error\" && error ? (\n                  <>\n                    <span className=\"flex items-center gap-1.5 pl-2 text-[12px] font-medium text-danger\">\n                      <Warning size={14} />\n                      <span className=\"whitespace-nowrap\">{errors[error].short}</span>\n                    </span>\n                    <button\n                      ref={retryRef}\n                      type=\"button\"\n                      onClick={press(() => void voice.start())}\n                      className={cn(iconButton, \"h-[26px] rounded-full px-2.5 text-[12px] font-medium text-fg hover:bg-hover\")}\n                    >\n                      Try again\n                    </button>\n                    <button\n                      type=\"button\"\n                      aria-label=\"Dismiss\"\n                      onClick={press(voice.cancel)}\n                      className={cn(iconButton, \"size-[26px] text-fg-3 hover:bg-hover hover:text-fg\")}\n                    >\n                      <X size={14} />\n                    </button>\n                  </>\n                ) : (\n                  <>\n                    <button\n                      ref={cancelRef}\n                      type=\"button\"\n                      aria-label=\"Cancel dictation\"\n                      aria-keyshortcuts=\"Escape\"\n                      onClick={press(voice.cancel)}\n                      className={cn(iconButton, \"size-[26px] text-fg-3 hover:bg-hover hover:text-fg\")}\n                    >\n                      <X size={14} />\n                    </button>\n\n                    <span className=\"relative grid h-5 w-[108px] place-items-center\">\n                      <LevelBars subscribe={voice.subscribeLevel} live={state === \"listening\"} reduce={reduce} />\n                      <AnimatePresence initial={false}>\n                        {state !== \"listening\" && (\n                          <motion.span\n                            key={state}\n                            initial={{ opacity: 0 }}\n                            animate={{ opacity: 1 }}\n                            exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                            transition={{\n                              duration: 0.18,\n                              ease: ease.out,\n                              delay: reduce ? 0 : 0.1,\n                            }}\n                            className=\"absolute inset-0 grid place-items-center whitespace-nowrap text-[11.5px] text-fg-3\"\n                          >\n                            <span className={cn(state === \"requesting\" && \"animate-pulse-soft\")}>{state === \"requesting\" ? \"Waiting for access\" : \"Transcribing…\"}</span>\n                          </motion.span>\n                        )}\n                      </AnimatePresence>\n                    </span>\n\n                    <span\n                      aria-hidden\n                      className={cn(\n                        \"tabular w-8 text-right font-mono text-[11px] transition-colors duration-200\",\n                        state === \"listening\" ? (ending ? \"text-warning\" : \"text-fg-2\") : \"text-fg-4\",\n                      )}\n                    >\n                      {fmt(elapsed)}\n                    </span>\n\n                    <button\n                      ref={doneRef}\n                      type=\"button\"\n                      aria-label={state === \"transcribing\" ? \"Transcribing\" : \"Finish dictation\"}\n                      aria-busy={state === \"transcribing\" || undefined}\n                      disabled={state === \"requesting\"}\n                      onClick={press(voice.stop)}\n                      className={cn(iconButton, \"size-[26px] bg-fg text-frame hover:bg-fg/90\", state === \"transcribing\" && \"pointer-events-none\")}\n                    >\n                      <AnimatePresence initial={false} mode=\"popLayout\">\n                        {state === \"transcribing\" ? (\n                          <motion.svg\n                            key=\"spin\"\n                            initial={enter}\n                            animate={shown}\n                            exit={leave}\n                            transition={reduce ? { duration: 0.12 } : spring.pop}\n                            width={14}\n                            height={14}\n                            viewBox=\"0 0 16 16\"\n                            fill=\"none\"\n                            aria-hidden\n                          >\n                            <circle cx=\"8\" cy=\"8\" r=\"5.5\" stroke=\"currentColor\" strokeOpacity={0.3} strokeWidth={1.6} />\n                            <path\n                              d=\"M8 2.5a5.5 5.5 0 0 1 5.5 5.5\"\n                              stroke=\"currentColor\"\n                              strokeWidth={1.6}\n                              strokeLinecap=\"round\"\n                              className=\"origin-center animate-spin-slow motion-reduce:animate-none\"\n                            />\n                          </motion.svg>\n                        ) : (\n                          <motion.span key=\"check\" initial={enter} animate={shown} exit={leave} transition={reduce ? { duration: 0.12 } : spring.pop} className=\"grid\">\n                            <Check size={14} strokeWidth={1.8} />\n                          </motion.span>\n                        )}\n                      </AnimatePresence>\n                    </button>\n                  </>\n                )}\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </span>\n      </motion.div>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {spoken}\n      </span>\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * LevelBars\n * -----------------------------------------------------------------------------------------------*/\n\nconst BARS = 22;\n\n/**\n * A short scrolling history of input level: the newest bar on the right follows\n * the voice live, and every 50ms it becomes history. Written straight to the DOM\n * on animation frames, so a loud room never re-renders React.\n */\nfunction LevelBars({ subscribe, live, reduce }: { subscribe: (fn: (l: number) => void) => () => void; live: boolean; reduce: boolean }) {\n  const ref = useRef<HTMLSpanElement>(null);\n\n  useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const bars = Array.from(el.children) as HTMLElement[];\n    const history = new Array<number>(BARS).fill(0);\n    let level = 0;\n    let peak = 0;\n    let last = performance.now();\n    let raf = 0;\n    const off = subscribe((l) => {\n      level = l;\n      peak = Math.max(peak, l);\n    });\n\n    const draw = (now: number) => {\n      if (!live) {\n        history.fill(0);\n      } else if (reduce) {\n        // No scrolling: every bar shows the current level, shaped like a hill, updated gently.\n        if (now - last > 120) {\n          last = now;\n          for (let i = 0; i < BARS; i++) history[i] = peak * (0.55 + 0.45 * Math.sin((Math.PI * (i + 0.5)) / BARS));\n          peak = level;\n        }\n      } else {\n        if (now - last > 50) {\n          last = now;\n          history.shift();\n          history.push(peak);\n          peak = level;\n        }\n        history[BARS - 1] = Math.max(history[BARS - 1] * 0.85, level);\n      }\n      for (let i = 0; i < BARS; i++) bars[i].style.transform = `scaleY(${0.14 + Math.min(1, history[i]) * 0.86})`;\n      if (live) raf = requestAnimationFrame(draw);\n    };\n    raf = requestAnimationFrame(draw);\n    return () => {\n      off();\n      cancelAnimationFrame(raf);\n    };\n  }, [subscribe, live, reduce]);\n\n  return (\n    <span ref={ref} aria-hidden className={cn(\"flex h-5 items-center gap-[2px] transition-opacity duration-200\", !live && \"opacity-0\")}>\n      {Array.from({ length: BARS }, (_, i) => (\n        <span\n          key={i}\n          className={cn(\n            \"h-full w-[2.5px] rounded-full bg-fg transition-transform duration-100 ease-linear\",\n            // Older bars dim toward the left, so the eye reads time.\n            i < 6 ? \"opacity-40\" : i < 12 ? \"opacity-70\" : \"opacity-100\",\n            !live && \"duration-300 ease-out-expo\",\n          )}\n          style={{ transform: \"scaleY(0.14)\" }}\n        />\n      ))}\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/voice-input"
}
