{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "voice-recorder",
  "type": "registry:ui",
  "title": "Voice Recorder",
  "description": "Records with live input levels, pauses, and plays back before you send.",
  "categories": [
    "media"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/audio-player.json"
  ],
  "files": [
    {
      "path": "components/ui/voice-recorder.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { AudioPlayer } from \"@/components/ui/audio-player\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\n/* ------------------------------------------------------------------ the recording lifecycle */\n\nexport type RecorderPhase = \"idle\" | \"requesting\" | \"recording\" | \"paused\" | \"review\" | \"sending\" | \"denied\" | \"unavailable\" | \"error\";\n\nexport type VoiceRecording = {\n  blob: Blob;\n  mimeType: string;\n  /** Seconds, measured while recording (MediaRecorder files often report no duration). */\n  duration: number;\n  /** Loudness per slice, 0–1, ready for a waveform. */\n  peaks: number[];\n};\n\nconst TICK = 80; // ms between level samples: ~12 bars a second\nconst MIME = [\"audio/webm;codecs=opus\", \"audio/mp4\", \"audio/ogg;codecs=opus\", \"audio/webm\"];\n\nfunction resample(values: number[], count: number) {\n  if (!values.length) return [];\n  const out: number[] = [];\n  for (let i = 0; i < count; i++) {\n    const a = Math.floor((i * values.length) / count);\n    const b = Math.max(a + 1, Math.floor(((i + 1) * values.length) / count));\n    out.push(Math.max(...values.slice(a, b)));\n  }\n  return out;\n}\n\n/**\n * Microphone to Blob, without the markup: permission, levels, pause, a max length,\n * and cleanup of every track, context and timer. Levels are sampled from Web Audio\n * so the waveform you review is the one you watched while speaking.\n */\nexport function useVoiceRecorder({ maxDuration = 120 }: { maxDuration?: number } = {}) {\n  const [phase, setPhase] = useState<RecorderPhase>(\"idle\");\n  const [elapsed, setElapsed] = useState(0);\n  const [levels, setLevels] = useState<number[]>([]);\n  const [recording, setRecording] = useState<(VoiceRecording & { url: string }) | null>(null);\n\n  const rec = useRef<MediaRecorder | null>(null);\n  const stream = useRef<MediaStream | null>(null);\n  const ctx = useRef<AudioContext | null>(null);\n  const timer = useRef<number>(undefined);\n  const chunks = useRef<Blob[]>([]);\n  const history = useRef<number[]>([]);\n  const clock = useRef({ banked: 0, since: 0 });\n  const urlRef = useRef<string | null>(null);\n\n  const release = useCallback(() => {\n    window.clearInterval(timer.current);\n    stream.current?.getTracks().forEach((t) => t.stop());\n    stream.current = null;\n    ctx.current?.close().catch(() => {});\n    ctx.current = null;\n  }, []);\n\n  const revoke = useCallback(() => {\n    if (urlRef.current) URL.revokeObjectURL(urlRef.current);\n    urlRef.current = null;\n  }, []);\n\n  useEffect(\n    () => () => {\n      if (rec.current && rec.current.state !== \"inactive\") {\n        rec.current.onstop = null;\n        rec.current.stop();\n      }\n      release();\n      revoke();\n    },\n    [release, revoke],\n  );\n\n  const now = () => clock.current.banked + (clock.current.since ? performance.now() - clock.current.since : 0);\n\n  const stop = useCallback(() => {\n    const r = rec.current;\n    if (!r || r.state === \"inactive\") return;\n    clock.current.banked = now();\n    clock.current.since = 0;\n    window.clearInterval(timer.current);\n    r.stop();\n  }, []);\n\n  const sample = useCallback(\n    (analyser: AnalyserNode, buf: Float32Array<ArrayBuffer>) => {\n      analyser.getFloatTimeDomainData(buf);\n      let sum = 0;\n      for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i];\n      const rms = Math.sqrt(sum / buf.length);\n      // Speech sits around 0.02–0.2 RMS; a square-root curve spreads it over the bar height.\n      const level = Math.min(1, Math.sqrt(rms) * 1.9);\n      history.current.push(level);\n      setLevels(history.current.slice(-160));\n      const t = now() / 1000;\n      setElapsed(t);\n      if (t >= maxDuration) stop();\n    },\n    [maxDuration, stop],\n  );\n\n  const start = useCallback(async () => {\n    if (typeof window === \"undefined\") return;\n    if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === \"undefined\") {\n      setPhase(\"unavailable\");\n      return;\n    }\n    revoke();\n    setRecording(null);\n    setPhase(\"requesting\");\n    let s: MediaStream;\n    try {\n      s = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } });\n    } catch (err) {\n      const name = (err as DOMException)?.name;\n      setPhase(name === \"NotAllowedError\" || name === \"SecurityError\" ? \"denied\" : name === \"NotFoundError\" || name === \"OverconstrainedError\" || name === \"NotSupportedError\" ? \"unavailable\" : \"error\");\n      return;\n    }\n    stream.current = s;\n    const mimeType = MIME.find((m) => MediaRecorder.isTypeSupported(m)) ?? \"\";\n    const r = new MediaRecorder(s, mimeType ? { mimeType } : undefined);\n    rec.current = r;\n    chunks.current = [];\n    history.current = [];\n    r.ondataavailable = (e) => {\n      if (e.data.size) chunks.current.push(e.data);\n    };\n    r.onstop = () => {\n      release();\n      const type = r.mimeType || mimeType || \"audio/webm\";\n      const blob = new Blob(chunks.current, { type });\n      const duration = clock.current.banked / 1000;\n      if (duration < 0.5 || !blob.size) {\n        // Too short to be a message: treat it as a slip of the finger.\n        setPhase(\"idle\");\n        return;\n      }\n      const url = URL.createObjectURL(blob);\n      urlRef.current = url;\n      setRecording({ blob, mimeType: type, duration, peaks: resample(history.current, 64), url });\n      setPhase(\"review\");\n    };\n\n    const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;\n    const ac = new AC();\n    ctx.current = ac;\n    const analyser = ac.createAnalyser();\n    analyser.fftSize = 1024;\n    ac.createMediaStreamSource(s).connect(analyser);\n    const buf = new Float32Array(analyser.fftSize);\n\n    clock.current = { banked: 0, since: performance.now() };\n    setElapsed(0);\n    setLevels([]);\n    r.start(250);\n    setPhase(\"recording\");\n    timer.current = window.setInterval(() => sample(analyser, buf), TICK);\n\n    // A microphone unplugged mid-take ends the take rather than recording silence.\n    s.getAudioTracks()[0]?.addEventListener(\"ended\", () => stop());\n  }, [release, revoke, sample, stop]);\n\n  const pause = useCallback(() => {\n    const r = rec.current;\n    if (!r || r.state !== \"recording\") return;\n    r.pause();\n    clock.current.banked = now();\n    clock.current.since = 0;\n    window.clearInterval(timer.current);\n    setPhase(\"paused\");\n  }, []);\n\n  const resume = useCallback(() => {\n    const r = rec.current;\n    const ac = ctx.current;\n    if (!r || r.state !== \"paused\" || !ac || !stream.current) return;\n    r.resume();\n    clock.current.since = performance.now();\n    const analyser = ac.createAnalyser();\n    analyser.fftSize = 1024;\n    ac.createMediaStreamSource(stream.current).connect(analyser);\n    const buf = new Float32Array(analyser.fftSize);\n    timer.current = window.setInterval(() => sample(analyser, buf), TICK);\n    setPhase(\"recording\");\n  }, [sample]);\n\n  const discard = useCallback(() => {\n    const r = rec.current;\n    if (r && r.state !== \"inactive\") {\n      r.onstop = null;\n      r.stop();\n    }\n    release();\n    revoke();\n    rec.current = null;\n    setRecording(null);\n    setLevels([]);\n    setElapsed(0);\n    setPhase(\"idle\");\n  }, [release, revoke]);\n\n  const reset = useCallback(() => setPhase(\"idle\"), []);\n\n  return { phase, setPhase, elapsed, levels, recording, start, pause, resume, stop, discard, reset, maxDuration };\n}\n\n/* ------------------------------------------------------------------ component */\n\nexport type VoiceRecorderProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Called with the finished take. Return a promise to keep the send button busy until it settles; a rejection keeps the take for another try. */\n  onSend: (recording: VoiceRecording) => void | Promise<void>;\n  /** Seconds. Recording stops into review on its own when it gets there. */\n  maxDuration?: number;\n  /** The line shown at rest. */\n  placeholder?: string;\n  disabled?: boolean;\n};\n\nfunction formatClock(seconds: number) {\n  const s = Math.max(0, Math.floor(seconds));\n  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, \"0\")}`;\n}\n\nexport function VoiceRecorder({ onSend, maxDuration = 120, placeholder = \"Record a voice message\", disabled, className, ...rest }: VoiceRecorderProps) {\n  const reduce = !!useReducedMotion();\n  const r = useVoiceRecorder({ maxDuration });\n  const [sendError, setSendError] = useState(false);\n  const [announce, setAnnounce] = useState(\"\");\n  const primary = useRef<HTMLButtonElement>(null);\n  const { phase } = r;\n\n  const live = phase === \"recording\" || phase === \"paused\";\n  const blocked = phase === \"denied\" || phase === \"unavailable\" || phase === \"error\";\n  const left = maxDuration - r.elapsed;\n  const nearEnd = live && left <= 10;\n\n  // Say each change of state once, in words.\n  useEffect(() => {\n    const words: Partial<Record<RecorderPhase, string>> = {\n      recording: \"Recording\",\n      paused: \"Recording paused\",\n      review: \"Recording stopped. Review before sending.\",\n      denied: \"Microphone access is blocked\",\n      unavailable: \"Recording isn’t available here\",\n      error: \"Couldn’t start recording\",\n    };\n    const t = window.setTimeout(() => setAnnounce(words[phase] ?? \"\"), 0);\n    return () => window.clearTimeout(t);\n  }, [phase]);\n\n  // If the person unblocks the microphone in site settings, come back to life without a reload.\n  useEffect(() => {\n    if (phase !== \"denied\" || !navigator.permissions?.query) return;\n    let status: PermissionStatus | null = null;\n    const onChange = () => {\n      if (status?.state !== \"denied\") r.reset();\n    };\n    navigator.permissions\n      .query({ name: \"microphone\" as PermissionName })\n      .then((s) => {\n        status = s;\n        s.addEventListener(\"change\", onChange);\n      })\n      .catch(() => {});\n    return () => status?.removeEventListener(\"change\", onChange);\n  }, [phase, r]);\n\n  const send = async () => {\n    if (!r.recording) return;\n    const { url: _url, ...take } = r.recording;\n    void _url;\n    setSendError(false);\n    r.setPhase(\"sending\");\n    try {\n      await onSend(take);\n      r.discard();\n      setAnnounce(\"Voice message sent\");\n    } catch {\n      r.setPhase(\"review\");\n      setSendError(true);\n    }\n  };\n\n  const onPrimary = () => {\n    if (phase === \"idle\" || blocked) return void r.start();\n    if (live) return r.stop();\n    if (phase === \"review\") return void send();\n  };\n\n  const primaryLabel = live ? \"Stop recording\" : phase === \"review\" || phase === \"sending\" ? \"Send voice message\" : blocked ? \"Try again\" : \"Record voice message\";\n  // The resting target is the same either way, so server and client render the same style.\n  const swap = {\n    initial: reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" },\n    animate: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n    exit: reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: \"blur(2px)\" },\n  };\n\n  return (\n    <div\n      role=\"group\"\n      aria-label=\"Voice message\"\n      data-state={phase}\n      className={cn(\n        \"relative flex min-h-12 min-w-0 items-center gap-1 rounded-xl border bg-raised px-1.5 shadow-[var(--shadow)] transition-[border-color] duration-200\",\n        live ? \"border-line-2\" : blocked ? \"border-danger/30\" : \"border-line\",\n        disabled && \"pointer-events-none opacity-50\",\n        className,\n      )}\n      {...rest}\n    >\n      {/* Discard lives on the left once there's something to throw away. */}\n      <AnimatePresence initial={false}>\n        {(live || phase === \"review\" || phase === \"sending\") && (\n          <motion.div\n            key=\"discard\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8, width: 0 }}\n            animate={{ opacity: 1, scale: 1, width: 32 }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8, width: 0, transition: { duration: 0.14 } }}\n            transition={reduce ? { duration: 0.12 } : { ...spring.snappy }}\n            className=\"shrink-0\"\n          >\n            <IconButton label={live ? \"Cancel recording\" : \"Delete recording\"} onClick={r.discard} disabled={phase === \"sending\"} tone=\"danger\">\n              <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n                <path d=\"M3 4.5h10M6.5 4.5V3.25h3V4.5M4.5 4.5l.6 8.1a1 1 0 0 0 1 .9h3.8a1 1 0 0 0 1-.9l.6-8.1M6.75 7v4M9.25 7v4\" />\n              </svg>\n            </IconButton>\n          </motion.div>\n        )}\n      </AnimatePresence>\n\n      {/* The middle: one region whose content changes with the phase, in place. */}\n      <div className=\"relative flex min-w-0 flex-1 items-center self-stretch\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {(phase === \"idle\" || phase === \"requesting\") && (\n            <motion.p key=\"idle\" {...swap} transition={{ duration: 0.18, ease: ease.out }} className=\"truncate pl-2 text-[13px] text-fg-3\">\n              {phase === \"requesting\" ? \"Waiting for the microphone…\" : placeholder}\n            </motion.p>\n          )}\n\n          {live && (\n            <motion.div key=\"live\" {...swap} transition={{ duration: 0.2, ease: ease.out }} className=\"flex h-full min-w-0 flex-1 items-center gap-2.5 pl-1\">\n              <span className=\"flex shrink-0 items-center gap-2\">\n                <span aria-hidden className=\"relative grid size-2.5 place-items-center\">\n                  {phase === \"recording\" ? (\n                    <>\n                      <span className=\"absolute inset-0 animate-ping-soft rounded-full bg-danger motion-reduce:hidden\" />\n                      <span className=\"relative size-2.5 rounded-full bg-danger\" />\n                    </>\n                  ) : (\n                    <span className=\"relative size-2.5 rounded-[2px] border-x-[3px] border-fg-3\" />\n                  )}\n                </span>\n                <span className={cn(\"tabular font-mono text-[12px] transition-colors duration-200\", nearEnd ? \"text-warning\" : phase === \"paused\" ? \"text-fg-3\" : \"text-fg\")}>\n                  {nearEnd ? `${formatClock(left)} left` : formatClock(r.elapsed)}\n                </span>\n              </span>\n              <LevelStrip levels={r.levels} paused={phase === \"paused\"} reduce={reduce} />\n            </motion.div>\n          )}\n\n          {(phase === \"review\" || phase === \"sending\") && r.recording && (\n            <motion.div key=\"review\" {...swap} transition={{ duration: 0.22, ease: ease.out }} className=\"flex min-w-0 flex-1 flex-col justify-center\">\n              <AudioPlayer\n                src={r.recording.url}\n                peaks={r.recording.peaks}\n                duration={r.recording.duration}\n                label=\"Your recording\"\n                variant=\"bare\"\n                rates={[1]}\n                bars={40}\n                playStyle=\"soft\"\n                className=\"gap-2.5\"\n              />\n            </motion.div>\n          )}\n\n          {blocked && (\n            <motion.div key={phase} {...swap} transition={{ duration: 0.2, ease: ease.out }} role=\"alert\" className=\"flex min-w-0 items-center gap-2.5 py-1.5 pl-1.5\">\n              <span aria-hidden className=\"grid size-6 shrink-0 place-items-center rounded-full bg-danger-soft text-danger\">\n                <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\">\n                  <rect x=\"6\" y=\"2\" width=\"4\" height=\"7.5\" rx=\"2\" />\n                  <path d=\"M3.75 7.5a4.25 4.25 0 0 0 8.5 0M8 11.75v2.25M2.5 2.5l11 11\" />\n                </svg>\n              </span>\n              <span className=\"min-w-0 leading-tight\">\n                <span className=\"block truncate text-[12.5px] font-medium text-fg\">\n                  {phase === \"denied\" ? \"Microphone access is blocked\" : phase === \"unavailable\" ? \"No microphone available\" : \"Couldn’t start recording\"}\n                </span>\n                {/* The hint is what to do next: it wraps rather than truncates on a narrow screen. */}\n                <span className=\"block text-[12px] leading-[1.35] text-fg-3 [text-wrap:pretty]\">\n                  {phase === \"denied\"\n                    ? \"Allow it in your browser’s site settings, then try again.\"\n                    : phase === \"unavailable\"\n                      ? \"Connect a microphone, or use a secure (https) page.\"\n                      : \"Check your microphone, then try again.\"}\n                </span>\n              </span>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n\n      {sendError && phase === \"review\" && (\n        <span role=\"alert\" className=\"pointer-events-none absolute -top-6 right-1 text-[12px] text-danger\">\n          Couldn’t send. Try again.\n        </span>\n      )}\n\n      {/* Pause / resume sits beside the primary while a take is live. */}\n      <AnimatePresence initial={false}>\n        {live && (\n          <motion.div\n            key=\"pause\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8, width: 0 }}\n            animate={{ opacity: 1, scale: 1, width: 32 }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8, width: 0, transition: { duration: 0.14 } }}\n            transition={reduce ? { duration: 0.12 } : { ...spring.snappy }}\n            className=\"shrink-0\"\n          >\n            <IconButton label={phase === \"paused\" ? \"Resume recording\" : \"Pause recording\"} onClick={phase === \"paused\" ? r.resume : r.pause}>\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                <motion.span\n                  key={phase}\n                  className=\"grid place-items-center\"\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n                  animate={{ opacity: 1, scale: 1 }}\n                  exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n                  transition={reduce ? { duration: 0.1 } : spring.pop}\n                >\n                  {phase === \"paused\" ? (\n                    <span className=\"size-2.5 rounded-full bg-danger\" />\n                  ) : (\n                    <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\" aria-hidden>\n                      <path d=\"M5.25 3.5v9M10.75 3.5v9\" />\n                    </svg>\n                  )}\n                </motion.span>\n              </AnimatePresence>\n            </IconButton>\n          </motion.div>\n        )}\n      </AnimatePresence>\n\n      {/* One primary button whose meaning moves with the take: record, stop, send. */}\n      <button\n        ref={primary}\n        type=\"button\"\n        onClick={onPrimary}\n        disabled={disabled || phase === \"requesting\" || phase === \"sending\"}\n        aria-label={primaryLabel}\n        aria-busy={phase === \"sending\" || phase === \"requesting\" || undefined}\n        className={cn(\n          \"relative grid size-8 shrink-0 place-items-center rounded-full outline-none\",\n          \"transition-[background-color,color,scale] duration-150 ease-out-quart 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          \"before:absolute before:-inset-1.5 before:content-[''] pointer-fine:before:hidden disabled:cursor-default\",\n          blocked ? \"h-8 w-auto rounded-lg border border-line-2 bg-raised px-2.5 text-[12.5px] font-medium text-fg hover:bg-hover\" : \"bg-fg text-frame hover:bg-fg/90\",\n        )}\n      >\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            key={blocked ? \"retry\" : live ? \"stop\" : phase === \"review\" || phase === \"sending\" ? \"send\" : \"mic\"}\n            className=\"grid place-items-center\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(3px)\" }}\n            animate={{ opacity: phase === \"sending\" ? 0 : 1, scale: 1, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(3px)\" }}\n            transition={reduce ? { duration: 0.12 } : spring.pop}\n          >\n            {blocked ? (\n              \"Try again\"\n            ) : live ? (\n              <span className=\"size-2.5 rounded-[3px] bg-current\" />\n            ) : phase === \"review\" || phase === \"sending\" ? (\n              <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n                <path d=\"M8 13V3M4 7l4-4 4 4\" />\n              </svg>\n            ) : (\n              <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n                <rect x=\"6\" y=\"2\" width=\"4\" height=\"7.5\" rx=\"2\" />\n                <path d=\"M3.75 7.5a4.25 4.25 0 0 0 8.5 0M8 11.75v2.25\" />\n              </svg>\n            )}\n          </motion.span>\n        </AnimatePresence>\n        {(phase === \"sending\" || phase === \"requesting\") && (\n          <svg aria-hidden viewBox=\"0 0 16 16\" className=\"absolute size-4 animate-spin [animation-duration:0.8s]\">\n            <circle cx=\"8\" cy=\"8\" r=\"6\" fill=\"none\" stroke=\"currentColor\" strokeOpacity=\"0.25\" strokeWidth=\"1.6\" />\n            <path d=\"M14 8a6 6 0 0 0-6-6\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" />\n          </svg>\n        )}\n      </button>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </span>\n    </div>\n  );\n}\n\n/* ------------------------------------------------------------------ parts */\n\n// The newest level enters at the right edge; older ones step left. Empty slots show a quiet baseline.\nfunction LevelStrip({ levels, paused, reduce }: { levels: number[]; paused: boolean; reduce: boolean }) {\n  const ref = useRef<HTMLDivElement>(null);\n  const [slots, setSlots] = useState(40);\n  useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const ro = new ResizeObserver(([e]) => setSlots(Math.max(8, Math.floor(e.contentRect.width / 4))));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n  const shown = levels.slice(-slots);\n  const offset = levels.length - shown.length;\n  const pad = slots - shown.length;\n  return (\n    <div ref={ref} aria-hidden className={cn(\"flex h-7 min-w-0 flex-1 items-center justify-between transition-opacity duration-200\", paused && \"opacity-45\")}>\n      {Array.from({ length: pad }, (_, i) => (\n        <span key={`pad-${i}`} className=\"h-[3px] w-[2px] shrink-0 rounded-full bg-fg/15\" />\n      ))}\n      {shown.map((l, i) => (\n        <motion.span\n          key={offset + i}\n          className=\"w-[2px] shrink-0 rounded-full bg-fg\"\n          style={{ height: `${Math.max(12, Math.round(l * 100))}%`, originY: 0.5 }}\n          initial={reduce ? false : { scaleY: 0.2, opacity: 0.4 }}\n          animate={{ scaleY: 1, opacity: 1 }}\n          transition={{ duration: 0.16, ease: ease.out }}\n        />\n      ))}\n    </div>\n  );\n}\n\nfunction IconButton({ label, onClick, disabled, tone, children }: { label: string; onClick: () => void; disabled?: boolean; tone?: \"danger\"; children: React.ReactNode }) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      title={label}\n      onClick={onClick}\n      disabled={disabled}\n      className={cn(\n        \"relative grid size-8 place-items-center rounded-lg text-fg-2 outline-none\",\n        \"transition-[background-color,color,scale] duration-150 ease-out-quart hover:bg-hover active:scale-[0.9] active:duration-75\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3 disabled:opacity-50\",\n        \"before:absolute before:-inset-1.5 before:content-[''] pointer-fine:before:hidden\",\n        tone === \"danger\" ? \"hover:text-danger\" : \"hover:text-fg\",\n      )}\n    >\n      {children}\n    </button>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/voice-recorder"
}
