{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"voice-input","type":"registry:component","title":"Voice Input","description":"A mic button that morphs into a recording pill with a live Web Audio waveform, elapsed timer, cancel and confirm actions, a shimmering transcription state, and graceful permission-denied fallback.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/agents/voice-input.tsx","type":"registry:component","target":"@components/agents/voice-input.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/agents/voice-input\n\nimport { Check, Loader2, Mic, MicOff, X } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport { type ReactNode, useEffect, useRef, useState } from \"react\";\nimport { TextShimmer } from \"@/components/motion/text-shimmer\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type VoiceInputState =\n  | \"idle\"\n  | \"requesting\"\n  | \"recording\"\n  | \"processing\"\n  | \"done\"\n  | \"error\";\n\nexport type VoiceInputMode = \"recorder\" | \"speech\";\n\nexport interface VoiceInputProps {\n  /**\n   * Turns the recorded audio into text. Receives the MediaRecorder blob (an\n   * empty blob when `simulate` is on). Not called when `mode=\"speech\"` and the\n   * browser's SpeechRecognition API handled the transcript.\n   */\n  onTranscribe?: (audio: Blob) => Promise<string>;\n  /** Called with the final transcript when the done state is reached. */\n  onTranscript?: (text: string) => void;\n  /** Called when recording or transcription is cancelled. */\n  onCancel?: () => void;\n  onStateChange?: (state: VoiceInputState) => void;\n  onError?: (error: unknown) => void;\n  /**\n   * `recorder` captures audio and delegates to `onTranscribe`. `speech` uses\n   * the browser SpeechRecognition API when available, falling back to\n   * `recorder` otherwise.\n   */\n  mode?: VoiceInputMode;\n  /** BCP-47 language for `mode=\"speech\"`. Defaults to the browser language. */\n  lang?: string;\n  /** Fake an animated waveform without touching the microphone. */\n  simulate?: boolean;\n  /** Number of waveform bars. */\n  bars?: number;\n  /** Auto-confirm after this many seconds of recording. */\n  maxDuration?: number;\n  /** How long the done state shows the transcript before returning to idle (ms). */\n  doneDuration?: number;\n  disabled?: boolean;\n  className?: string;\n}\n\n/* ------------------------------------------------------------------------ */\n/* Browser API shapes (SpeechRecognition is not in every TS DOM lib).        */\n/* ------------------------------------------------------------------------ */\n\ninterface SpeechAlternativeLike {\n  transcript: string;\n}\ninterface SpeechResultLike {\n  readonly isFinal: boolean;\n  readonly length: number;\n  readonly [index: number]: SpeechAlternativeLike;\n}\ninterface SpeechEventLike {\n  resultIndex: number;\n  results: ArrayLike<SpeechResultLike>;\n}\ninterface SpeechRecognitionLike {\n  continuous: boolean;\n  interimResults: boolean;\n  lang: string;\n  onresult: ((event: SpeechEventLike) => void) | null;\n  onerror: ((event: { error: string }) => void) | null;\n  onend: (() => void) | null;\n  start(): void;\n  stop(): void;\n  abort(): void;\n}\ntype SpeechRecognitionCtor = new () => SpeechRecognitionLike;\n\nfunction getSpeechRecognition(): SpeechRecognitionCtor | undefined {\n  if (typeof window === \"undefined\") return undefined;\n  const w = window as unknown as {\n    SpeechRecognition?: SpeechRecognitionCtor;\n    webkitSpeechRecognition?: SpeechRecognitionCtor;\n  };\n  return w.SpeechRecognition ?? w.webkitSpeechRecognition;\n}\n\nfunction getAudioContext(): typeof AudioContext | undefined {\n  if (typeof window === \"undefined\") return undefined;\n  return (\n    window.AudioContext ??\n    (window as unknown as { webkitAudioContext?: typeof AudioContext })\n      .webkitAudioContext\n  );\n}\n\n/* ------------------------------------------------------------------------ */\n/* Resource bookkeeping — everything that must be released lives here.       */\n/* ------------------------------------------------------------------------ */\n\ntype Resources = {\n  stream?: MediaStream;\n  ctx?: AudioContext;\n  analyser?: AnalyserNode;\n  recorder?: MediaRecorder;\n  chunks: Blob[];\n  recognition?: SpeechRecognitionLike;\n  speechFinal: string;\n  speechInterim: string;\n  raf?: number;\n  tick?: number;\n  maxTimer?: number;\n};\n\nfunction freshResources(): Resources {\n  return { chunks: [], speechFinal: \"\", speechInterim: \"\" };\n}\n\nfunction stopLoops(r: Resources) {\n  if (r.raf !== undefined) cancelAnimationFrame(r.raf);\n  if (r.tick !== undefined) window.clearInterval(r.tick);\n  if (r.maxTimer !== undefined) window.clearTimeout(r.maxTimer);\n  r.raf = undefined;\n  r.tick = undefined;\n  r.maxTimer = undefined;\n}\n\nfunction release(r: Resources) {\n  stopLoops(r);\n  if (r.recorder) {\n    r.recorder.ondataavailable = null;\n    r.recorder.onstop = null;\n    if (r.recorder.state !== \"inactive\") {\n      try {\n        r.recorder.stop();\n      } catch {\n        // already stopped\n      }\n    }\n  }\n  if (r.recognition) {\n    r.recognition.onresult = null;\n    r.recognition.onerror = null;\n    r.recognition.onend = null;\n    try {\n      r.recognition.abort();\n    } catch {\n      // already ended\n    }\n  }\n  if (r.stream) {\n    for (const track of r.stream.getTracks()) track.stop();\n  }\n  if (r.ctx && r.ctx.state !== \"closed\") r.ctx.close().catch(() => {});\n  r.stream = undefined;\n  r.ctx = undefined;\n  r.analyser = undefined;\n  r.recorder = undefined;\n  r.recognition = undefined;\n}\n\nfunction stopRecorder(r: Resources): Promise<Blob> {\n  const recorder = r.recorder;\n  const type = recorder?.mimeType || \"audio/webm\";\n  if (!recorder || recorder.state === \"inactive\") {\n    return Promise.resolve(new Blob(r.chunks, { type }));\n  }\n  return new Promise((resolve) => {\n    recorder.onstop = () => resolve(new Blob(r.chunks, { type }));\n    recorder.stop();\n  });\n}\n\n/** Stop recognition and wait for its final results (bounded). */\nfunction stopRecognition(r: Resources): Promise<string> {\n  const recognition = r.recognition;\n  const read = () => `${r.speechFinal} ${r.speechInterim}`.trim();\n  if (!recognition) return Promise.resolve(read());\n  return new Promise((resolve) => {\n    const timer = window.setTimeout(() => resolve(read()), 1500);\n    recognition.onend = () => {\n      window.clearTimeout(timer);\n      resolve(read());\n    };\n    try {\n      recognition.stop();\n    } catch {\n      window.clearTimeout(timer);\n      resolve(read());\n    }\n  });\n}\n\nfunction errorMessage(error: unknown) {\n  const name = error instanceof DOMException ? error.name : \"\";\n  if (name === \"NotAllowedError\" || name === \"SecurityError\") {\n    return \"Microphone access was blocked.\";\n  }\n  if (name === \"NotFoundError\" || name === \"OverconstrainedError\") {\n    return \"No microphone was found.\";\n  }\n  if (name === \"NotReadableError\") return \"The microphone is in use.\";\n  return \"Couldn't start the microphone.\";\n}\n\nfunction formatTime(seconds: number) {\n  const m = Math.floor(seconds / 60);\n  const s = seconds % 60;\n  return `${m}:${s.toString().padStart(2, \"0\")}`;\n}\n\n/** RMS of the time-domain signal, mapped to a perceptual 0..1 level. */\nfunction readLevel(analyser: AnalyserNode, buffer: Uint8Array<ArrayBuffer>) {\n  analyser.getByteTimeDomainData(buffer);\n  let sum = 0;\n  for (const sample of buffer) {\n    const v = (sample - 128) / 128;\n    sum += v * v;\n  }\n  const rms = Math.sqrt(sum / buffer.length);\n  return Math.min(1, Math.sqrt(rms) * 1.6);\n}\n\n/** Speech-like bursts: a slow envelope over faster syllable wobble + noise. */\nfunction simulatedLevel(time: number) {\n  const t = time / 1000;\n  const envelope = 0.5 + 0.5 * Math.sin(t * 1.3) * Math.sin(t * 0.7 + 1);\n  const syllable = Math.abs(Math.sin(t * 7.3)) * 0.6 + Math.abs(Math.sin(t * 3.1)) * 0.4;\n  return Math.min(1, Math.max(0.04, envelope * syllable * 0.95 + Math.random() * 0.18));\n}\n\nconst BAR_FLOOR = 0.12;\n/** Waveform history step. Reduced motion samples slower so bars move less. */\nconst SAMPLE_MS = 55;\nconst SAMPLE_MS_REDUCED = 180;\n\n/* ------------------------------------------------------------------------ */\n/* Motion                                                                   */\n/* ------------------------------------------------------------------------ */\n\nconst CONTENT_VARIANTS: Variants = {\n  initial: { opacity: 0, filter: \"blur(4px)\", x: -6 },\n  animate: {\n    opacity: 1,\n    filter: \"blur(0px)\",\n    x: 0,\n    transition: { ...SPRING_SWAP, opacity: { duration: 0.18, delay: 0.04 } },\n  },\n  exit: {\n    opacity: 0,\n    filter: \"blur(4px)\",\n    transition: { duration: 0.12, ease: EASE_OUT },\n  },\n};\n\nconst ICON_VARIANTS: Variants = {\n  initial: { opacity: 0, scale: 0.4, filter: \"blur(6px)\" },\n  animate: { opacity: 1, scale: 1, filter: \"blur(0px)\", transition: SPRING_SWAP },\n  exit: {\n    opacity: 0,\n    scale: 0.4,\n    filter: \"blur(6px)\",\n    transition: { duration: 0.12, ease: EASE_OUT },\n  },\n};\n\nconst FADE_VARIANTS: Variants = {\n  initial: { opacity: 0 },\n  animate: { opacity: 1, transition: { duration: 0.15 } },\n  exit: { opacity: 0, transition: { duration: 0.1 } },\n};\n\n/* ------------------------------------------------------------------------ */\n\nfunction PillButton({\n  label,\n  onClick,\n  tone = \"muted\",\n  children,\n}: {\n  label: string;\n  onClick: () => void;\n  tone?: \"muted\" | \"primary\";\n  children: ReactNode;\n}) {\n  const reduce = useReducedMotion() ?? false;\n  return (\n    <motion.button\n      type=\"button\"\n      aria-label={label}\n      onClick={onClick}\n      whileTap={reduce ? undefined : { scale: 0.88 }}\n      transition={SPRING_PRESS}\n      className={cn(\n        \"grid size-8 shrink-0 place-items-center rounded-full outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/50 [&_svg]:size-4\",\n        tone === \"primary\"\n          ? \"bg-primary text-primary-foreground hover:bg-primary/90\"\n          : \"text-muted-foreground hover:bg-muted hover:text-foreground\",\n      )}\n    >\n      {children}\n    </motion.button>\n  );\n}\n\nexport function VoiceInput({\n  onTranscribe,\n  onTranscript,\n  onCancel,\n  onStateChange,\n  onError,\n  mode = \"recorder\",\n  lang,\n  simulate = false,\n  bars = 24,\n  maxDuration,\n  doneDuration = 2400,\n  disabled = false,\n  className,\n}: VoiceInputProps) {\n  const reduce = useReducedMotion() ?? false;\n  const [state, setState] = useState<VoiceInputState>(\"idle\");\n  const [elapsed, setElapsed] = useState(0);\n  const [error, setError] = useState<string>();\n  const [transcript, setTranscript] = useState(\"\");\n\n  const micRef = useRef<HTMLButtonElement>(null);\n  const barRefs = useRef<(HTMLSpanElement | null)[]>([]);\n  const history = useRef<number[]>([]);\n  const resources = useRef<Resources>(freshResources());\n  const session = useRef(0);\n  const doneTimer = useRef<number | undefined>(undefined);\n  const phase = useRef<VoiceInputState>(\"idle\");\n\n  // Latest props for async flows that outlive the render that started them.\n  const latest = useRef({\n    onTranscribe,\n    onTranscript,\n    onCancel,\n    onStateChange,\n    onError,\n    mode,\n    lang,\n    simulate,\n    maxDuration,\n    doneDuration,\n    reduce,\n  });\n  latest.current = {\n    onTranscribe,\n    onTranscript,\n    onCancel,\n    onStateChange,\n    onError,\n    mode,\n    lang,\n    simulate,\n    maxDuration,\n    doneDuration,\n    reduce,\n  };\n\n  const barCount = Math.max(4, Math.floor(bars));\n\n  useEffect(() => {\n    const r = resources.current;\n    return () => {\n      session.current++;\n      release(r);\n      if (doneTimer.current) window.clearTimeout(doneTimer.current);\n    };\n  }, []);\n\n  const transition = (next: VoiceInputState) => {\n    phase.current = next;\n    setState(next);\n    latest.current.onStateChange?.(next);\n  };\n\n  const fail = (message: string, cause: unknown) => {\n    release(resources.current);\n    setError(message);\n    transition(\"error\");\n    latest.current.onError?.(cause);\n  };\n\n  const paint = () => {\n    const values = history.current;\n    barRefs.current.forEach((el, i) => {\n      if (!el) return;\n      const v = values[i] ?? 0;\n      el.style.transform = `scaleY(${BAR_FLOOR + v * (1 - BAR_FLOOR)})`;\n    });\n  };\n\n  const beginRecording = (\n    id: number,\n    stream: MediaStream | null,\n    speech: SpeechRecognitionCtor | undefined,\n  ) => {\n    const r = resources.current;\n    const { simulate: fake, lang: language } = latest.current;\n\n    if (stream) {\n      r.stream = stream;\n      const Ctx = getAudioContext();\n      if (Ctx) {\n        try {\n          const ctx = new Ctx();\n          const analyser = ctx.createAnalyser();\n          analyser.fftSize = 512;\n          ctx.createMediaStreamSource(stream).connect(analyser);\n          r.ctx = ctx;\n          r.analyser = analyser;\n        } catch {\n          // No analyser — the waveform falls back to a flat idle line.\n        }\n      }\n      if (!speech) {\n        try {\n          const recorder = new MediaRecorder(stream);\n          recorder.ondataavailable = (event) => {\n            if (event.data.size > 0) r.chunks.push(event.data);\n          };\n          recorder.start(250);\n          r.recorder = recorder;\n        } catch (cause) {\n          fail(\"Recording isn't supported in this browser.\", cause);\n          return;\n        }\n      }\n    }\n\n    if (speech && !fake) {\n      try {\n        const recognition = new speech();\n        recognition.continuous = true;\n        recognition.interimResults = true;\n        recognition.lang =\n          language ?? (typeof navigator !== \"undefined\" ? navigator.language : \"en-US\");\n        recognition.onresult = (event) => {\n          let interim = \"\";\n          for (let i = event.resultIndex; i < event.results.length; i++) {\n            const result = event.results[i];\n            const text = result?.[0]?.transcript ?? \"\";\n            if (result?.isFinal) r.speechFinal += `${text} `;\n            else interim += text;\n          }\n          r.speechInterim = interim;\n        };\n        recognition.onerror = (event) => {\n          if (session.current !== id) return;\n          if (event.error === \"not-allowed\" || event.error === \"service-not-allowed\") {\n            fail(\"Speech recognition was blocked.\", event);\n          }\n        };\n        recognition.start();\n        r.recognition = recognition;\n      } catch (cause) {\n        fail(\"Speech recognition couldn't start.\", cause);\n        return;\n      }\n    }\n\n    history.current = Array.from({ length: barCount }, () => 0);\n    const buffer = r.analyser\n      ? new Uint8Array(new ArrayBuffer(r.analyser.fftSize))\n      : null;\n    let lastPush = 0;\n    const loop = (now: number) => {\n      if (session.current !== id) return;\n      const step = latest.current.reduce ? SAMPLE_MS_REDUCED : SAMPLE_MS;\n      if (now - lastPush >= step) {\n        lastPush = now;\n        const level =\n          r.analyser && buffer\n            ? readLevel(r.analyser, buffer)\n            : fake\n              ? simulatedLevel(now)\n              : 0;\n        history.current.shift();\n        history.current.push(level);\n        paint();\n      }\n      r.raf = requestAnimationFrame(loop);\n    };\n    r.raf = requestAnimationFrame(loop);\n\n    const started = performance.now();\n    setElapsed(0);\n    r.tick = window.setInterval(() => {\n      setElapsed(Math.floor((performance.now() - started) / 1000));\n    }, 250);\n\n    const limit = latest.current.maxDuration;\n    if (limit && limit > 0) {\n      r.maxTimer = window.setTimeout(() => {\n        if (session.current === id) void confirm();\n      }, limit * 1000);\n    }\n\n    transition(\"recording\");\n  };\n\n  const start = async () => {\n    if (disabled) return;\n    const id = ++session.current;\n    if (doneTimer.current) window.clearTimeout(doneTimer.current);\n    release(resources.current);\n    resources.current = freshResources();\n    setError(undefined);\n    setTranscript(\"\");\n    setElapsed(0);\n\n    const { simulate: fake, mode: currentMode } = latest.current;\n    const speech = currentMode === \"speech\" ? getSpeechRecognition() : undefined;\n\n    if (fake) {\n      beginRecording(id, null, speech);\n      return;\n    }\n\n    transition(\"requesting\");\n    if (typeof navigator === \"undefined\" || !navigator.mediaDevices?.getUserMedia) {\n      fail(\"Voice input isn't supported in this browser.\", new Error(\"getUserMedia unavailable\"));\n      return;\n    }\n    if (!speech && typeof MediaRecorder === \"undefined\") {\n      fail(\"Recording isn't supported in this browser.\", new Error(\"MediaRecorder unavailable\"));\n      return;\n    }\n\n    let stream: MediaStream;\n    try {\n      stream = await navigator.mediaDevices.getUserMedia({ audio: true });\n    } catch (cause) {\n      if (session.current !== id) return;\n      fail(errorMessage(cause), cause);\n      return;\n    }\n    if (session.current !== id) {\n      for (const track of stream.getTracks()) track.stop();\n      return;\n    }\n    beginRecording(id, stream, speech);\n  };\n\n  async function confirm() {\n    if (phase.current !== \"recording\") return;\n    const id = session.current;\n    const r = resources.current;\n    stopLoops(r);\n    transition(\"processing\");\n    try {\n      let text: string;\n      if (r.recognition) {\n        text = await stopRecognition(r);\n      } else {\n        const audio = latest.current.simulate\n          ? new Blob([], { type: \"audio/webm\" })\n          : await stopRecorder(r);\n        release(r);\n        const handler = latest.current.onTranscribe;\n        text = handler ? await handler(audio) : \"\";\n      }\n      if (session.current !== id) return;\n      release(r);\n      const clean = text.trim();\n      setTranscript(clean);\n      transition(\"done\");\n      latest.current.onTranscript?.(clean);\n      doneTimer.current = window.setTimeout(() => {\n        if (session.current === id) transition(\"idle\");\n      }, latest.current.doneDuration);\n    } catch (cause) {\n      if (session.current !== id) return;\n      fail(\"Couldn't transcribe that. Try again.\", cause);\n    }\n  }\n\n  const cancel = () => {\n    const wasActive = state !== \"error\" && state !== \"done\";\n    session.current++;\n    if (doneTimer.current) window.clearTimeout(doneTimer.current);\n    release(resources.current);\n    setError(undefined);\n    transition(\"idle\");\n    if (wasActive) latest.current.onCancel?.();\n    // The X button unmounts with the pill content; keep focus in the widget.\n    micRef.current?.focus();\n  };\n\n  const busy = state === \"requesting\" || state === \"processing\";\n  const recording = state === \"recording\";\n  const expanded = state !== \"idle\";\n\n  const onMic = () => {\n    if (disabled || busy) return;\n    if (recording) void confirm();\n    else void start();\n  };\n\n  const micLabel = recording\n    ? \"Stop recording and transcribe\"\n    : state === \"error\"\n      ? \"Retry voice input\"\n      : \"Start voice input\";\n\n  const status: Record<VoiceInputState, string> = {\n    idle: \"\",\n    requesting: \"Requesting microphone access\",\n    recording: \"Recording\",\n    processing: \"Transcribing\",\n    done: transcript ? `Transcribed: ${transcript}` : \"No speech detected\",\n    error: error ?? \"Voice input failed\",\n  };\n\n  const icons: Record<VoiceInputState, ReactNode> = {\n    idle: <Mic />,\n    requesting: <Mic />,\n    recording: <Mic />,\n    processing: reduce ? (\n      <Loader2 />\n    ) : (\n      <motion.span\n        className=\"grid place-items-center\"\n        animate={{ rotate: 360 }}\n        transition={{ duration: 0.9, ease: \"linear\", repeat: Number.POSITIVE_INFINITY }}\n      >\n        <Loader2 />\n      </motion.span>\n    ),\n    done: <Check className=\"text-(--color-success)\" />,\n    error: <MicOff />,\n  };\n\n  const iconVariants = reduce ? FADE_VARIANTS : ICON_VARIANTS;\n  const contentVariants = reduce ? FADE_VARIANTS : CONTENT_VARIANTS;\n\n  let content: ReactNode = null;\n  if (state === \"requesting\") {\n    content = (\n      <>\n        <TextShimmer className=\"whitespace-nowrap px-1 text-sm\">\n          Waiting for microphone…\n        </TextShimmer>\n        <PillButton label=\"Cancel\" onClick={cancel}>\n          <X />\n        </PillButton>\n      </>\n    );\n  } else if (state === \"recording\") {\n    content = (\n      <>\n        <span aria-hidden=\"true\" className=\"flex h-6 items-center gap-[2px] px-1\">\n          {Array.from({ length: barCount }, (_, i) => (\n            <span\n              // biome-ignore lint/suspicious/noArrayIndexKey: bars are fixed positional slots.\n              key={i}\n              ref={(el) => {\n                barRefs.current[i] = el;\n              }}\n              className=\"h-full w-[3px] origin-center rounded-full bg-foreground/80\"\n              style={{ transform: `scaleY(${BAR_FLOOR})` }}\n            />\n          ))}\n        </span>\n        <span className=\"w-9 text-right text-xs tabular-nums text-muted-foreground\">\n          {formatTime(elapsed)}\n        </span>\n        <PillButton label=\"Cancel recording\" onClick={cancel}>\n          <X />\n        </PillButton>\n        <PillButton label=\"Finish recording\" tone=\"primary\" onClick={() => void confirm()}>\n          <Check />\n        </PillButton>\n      </>\n    );\n  } else if (state === \"processing\") {\n    content = (\n      <>\n        <TextShimmer className=\"whitespace-nowrap px-1 text-sm\">\n          Transcribing…\n        </TextShimmer>\n        <PillButton label=\"Cancel transcription\" onClick={cancel}>\n          <X />\n        </PillButton>\n      </>\n    );\n  } else if (state === \"done\") {\n    content = (\n      <span className=\"max-w-64 truncate px-1 pr-3 text-sm text-foreground\">\n        {transcript || (\n          <span className=\"text-muted-foreground\">No speech detected</span>\n        )}\n      </span>\n    );\n  } else if (state === \"error\") {\n    content = (\n      <>\n        <span className=\"max-w-56 px-1 text-xs leading-tight text-destructive\">\n          {error}\n        </span>\n        <PillButton label=\"Dismiss\" onClick={cancel}>\n          <X />\n        </PillButton>\n      </>\n    );\n  }\n\n  return (\n    <div className={cn(\"inline-flex\", className)} data-state={state}>\n      <motion.div\n        layout\n        transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n        style={{ borderRadius: 999 }}\n        className={cn(\n          \"relative flex items-center gap-1 overflow-hidden border border-border bg-card p-1 shadow-sm\",\n          state === \"error\" && \"border-destructive/40\",\n        )}\n      >\n        <motion.button\n          ref={micRef}\n          layout=\"position\"\n          type=\"button\"\n          aria-label={micLabel}\n          aria-pressed={recording}\n          aria-disabled={disabled || busy || undefined}\n          disabled={disabled}\n          onClick={onMic}\n          whileTap={reduce || busy || disabled ? undefined : { scale: 0.88 }}\n          transition={SPRING_PRESS}\n          className={cn(\n            \"relative grid size-9 shrink-0 place-items-center rounded-full outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/50 disabled:opacity-50 [&_svg]:size-4\",\n            recording\n              ? \"bg-destructive/10 text-destructive\"\n              : state === \"error\"\n                ? \"text-destructive hover:bg-destructive/10\"\n                : \"text-foreground hover:bg-muted\",\n            busy && \"cursor-default\",\n          )}\n        >\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span\n              key={state === \"requesting\" || state === \"recording\" ? \"mic\" : state}\n              variants={iconVariants}\n              initial=\"initial\"\n              animate=\"animate\"\n              exit=\"exit\"\n              className=\"col-start-1 row-start-1 grid place-items-center\"\n            >\n              {icons[state]}\n            </motion.span>\n          </AnimatePresence>\n          {recording ? (\n            <motion.span\n              aria-hidden=\"true\"\n              className=\"absolute top-1.5 right-1.5 size-1.5 rounded-full bg-destructive\"\n              animate={{ opacity: [1, 0.25, 1] }}\n              transition={{ duration: 1.2, repeat: Number.POSITIVE_INFINITY }}\n            />\n          ) : null}\n        </motion.button>\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {expanded ? (\n            <motion.div\n              key={state}\n              layout=\"position\"\n              variants={contentVariants}\n              initial=\"initial\"\n              animate=\"animate\"\n              exit=\"exit\"\n              className=\"flex items-center gap-1\"\n            >\n              {content}\n            </motion.div>\n          ) : null}\n        </AnimatePresence>\n      </motion.div>\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {status[state]}\n      </span>\n    </div>\n  );\n}\n"},{"path":"components/motion/text-shimmer.tsx","type":"registry:component","target":"@components/motion/text-shimmer.tsx","content":"import { cn } from \"@/lib/utils\";\nimport type { ElementType, ReactNode } from \"react\";\nimport {\n  TEXT_SHIMMER_CLASS_NAME,\n  TEXT_SHIMMER_KEYFRAMES,\n  textShimmerStyle,\n} from \"@/lib/text-shimmer\";\n\nexport interface TextShimmerProps {\n  children: ReactNode;\n  as?: ElementType;\n  duration?: number;\n  className?: string;\n}\n\nexport function TextShimmer({ children, as: Comp = \"span\", duration = 2.5, className }: TextShimmerProps) {\n  return (\n    <>\n      <style>\n        {TEXT_SHIMMER_KEYFRAMES}\n      </style>\n      <Comp\n        style={textShimmerStyle(duration)}\n        className={cn(\n          \"inline-block\",\n          TEXT_SHIMMER_CLASS_NAME,\n          className,\n        )}\n      >\n        {children}\n      </Comp>\n    </>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"lib/text-shimmer.ts","type":"registry:lib","target":"@lib/text-shimmer.ts","content":"import type { CSSProperties } from \"react\";\n\nexport const TEXT_SHIMMER_KEYFRAMES =\n  \"@keyframes karaui-text-shimmer{from{background-position:200% 0}to{background-position:-200% 0}}\";\n\nexport const TEXT_SHIMMER_CLASS_NAME =\n  \"bg-[length:200%_100%] bg-clip-text text-transparent bg-[linear-gradient(110deg,var(--muted-foreground)_30%,var(--foreground)_50%,var(--muted-foreground)_70%)]\";\n\nexport function textShimmerStyle(duration: number): CSSProperties {\n  return {\n    animation: `karaui-text-shimmer ${duration}s linear infinite`,\n  };\n}\n"}]}