{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "type": "registry:ui",
  "slug": "prompt-composer",
  "name": "Prompt Composer",
  "title": "Prompt Composer",
  "description": "A Tailwind prompt field with a sculpted arc of tinted action cards, a shared model-menu highlight and coordinated GSAP springs.",
  "summary": "Five numbered, individually illustrated cards form a balanced arc above the field. Lavender image collages, taped notes, a mint calendar, coral waveforms and blue document stacks respond together on hover or keyboard focus. The model menu opens on pointer hover, pins on click and moves one rounded highlight between choices. Supports async submissions, retained failed drafts, custom models and actions, optional audio transcription, both themes and reduced motion. Inspired by Pin UI, with an original card treatment and composition.",
  "category": "ai-stuff",
  "tags": [
    "prompt",
    "composer",
    "ai",
    "input",
    "cards",
    "voice",
    "gsap",
    "tailwind"
  ],
  "dependencies": [
    "gsap",
    "@gsap/react",
    "motion",
    "lucide-react",
    "clsx",
    "tailwind-merge"
  ],
  "version": "1.0.0",
  "createdDate": "2026-10-21",
  "updatedDate": "2026-10-22",
  "interactive": true,
  "supportsColor": false,
  "highlights": [
    "Tailwind v4 utilities and scoped CSS variables; no component stylesheet",
    "A five-color card arc with original miniature illustrations and coordinated spring motion",
    "Profile-style shared model-menu hover highlight, pointer opening and click pinning",
    "Configurable width, radius, accent, hover lift, neighbour spread and speed",
    "Controlled or uncontrolled input, custom action labels and custom model IDs",
    "Async sends lock duplicate submissions and preserve failed drafts",
    "Optional real microphone capture with an application-provided transcription callback",
    "Explicit demo voice for previews, with no microphone access",
    "Container queries, both themes, keyboard model navigation and reduced-motion support"
  ],
  "anatomy": [
    "Model picker with radio menu",
    "Five illustrated action cards",
    "Removable action chip and labelled input",
    "Tray, voice and submit controls",
    "Recorder with waveform, timer, cancellation and transcription state",
    "Retryable send or recording alert"
  ],
  "physics": {
    "engine": "GSAP + spring easing",
    "description": "One scoped timeline coordinates the fan and card illustrations. Transforms begin at current values, with a short stagger on reveal. Recorder tickers, media tracks, requests and animations are cleaned up on unmount.",
    "parameters": [
      {
        "label": "Spring",
        "value": "360 stiffness / 32 damping"
      },
      {
        "label": "Hover lift",
        "value": "16px (0–32)"
      },
      {
        "label": "Neighbour spread",
        "value": "10px (0–20)"
      },
      {
        "label": "Speed",
        "value": "1× (0.5–2)"
      }
    ]
  },
  "accessibility": {
    "role": "form",
    "aria": "Labelled form and input, inert hidden cards and menus, menu radio choices, expanded tray state, pending submission state, voice status announcements and retryable alerts. Focus returns to the input after removing an action or finishing voice capture.",
    "reducedMotion": "Card transforms and content changes settle immediately; the recording waveform stays static and spinners do not rotate.",
    "keyboard": [
      {
        "key": "Tab / Shift+Tab",
        "description": "Move through the model picker, visible cards and field controls."
      },
      {
        "key": "Enter / Space",
        "description": "Activate a card, toggle a tray or model menu, or submit the field."
      },
      {
        "key": "Arrow keys / Home / End",
        "description": "Navigate the open model menu."
      },
      {
        "key": "Escape",
        "description": "Close the model menu, cancel voice capture or remove the selected action."
      },
      {
        "key": "Backspace",
        "description": "Remove the selected action when the field is empty."
      }
    ]
  },
  "guidelines": {
    "recommended": [
      "Assistant prompt entry",
      "Contextual creation tools",
      "Document question and image prompt interfaces"
    ],
    "bestPractices": [
      "Connect onSend to your application and resolve only after the prompt is accepted. Rejection retains the draft.",
      "Use stable unique model IDs. The submission model value is the model ID, not its display name.",
      "Cards express prompt intent; image generation, events and document upload are handled by your application.",
      "Voice is disabled unless onTranscribe or demoVoice is provided. Demo mode must stay explicit in user-facing previews.",
      "Real recording requires HTTPS or localhost and browser microphone permission. Send the audio Blob through your server, honor AbortSignal and keep credentials out of the browser.",
      "A transcription callback takes precedence over demoVoice. Cancel and unmount stop microphone tracks and abort transcription.",
      "Provide onValueChange whenever value is controlled. Theme system follows the host dark class."
    ]
  },
  "props": [
    {
      "name": "value / defaultValue",
      "type": "string",
      "defaultValue": "\"\"",
      "description": "Controlled value or initial uncontrolled draft."
    },
    {
      "name": "onValueChange",
      "type": "(value: string) => void",
      "description": "Receives edits, transcript insertion and successful clearing."
    },
    {
      "name": "onSend",
      "type": "(prompt: PromptComposerSubmission) => void | Promise<void>",
      "description": "Receives trimmed text, action ID or null, and model ID. Reject to retain the draft and show errorMessage."
    },
    {
      "name": "actions",
      "type": "readonly PromptComposerAction[]",
      "defaultValue": "PROMPT_COMPOSER_ACTIONS",
      "description": "Up to five unique built-in action IDs with custom labels and placeholders; an empty array removes the cards."
    },
    {
      "name": "models",
      "type": "readonly PromptComposerModel[]",
      "defaultValue": "PROMPT_COMPOSER_MODELS",
      "description": "Model IDs, names and optional descriptions. An empty list disables submission."
    },
    {
      "name": "defaultModel / onModelChange",
      "type": "string / (model: PromptComposerModel) => void",
      "description": "Initial model ID and selection callback. Removed selections fall back to the first model."
    },
    {
      "name": "placeholder / label",
      "type": "string",
      "defaultValue": "Ask anything / Message",
      "description": "Default visual placeholder and accessible input label."
    },
    {
      "name": "theme",
      "type": "\"system\" | \"light\" | \"dark\"",
      "defaultValue": "system",
      "description": "Inherit the host theme or explicitly select a palette."
    },
    {
      "name": "width",
      "type": "number",
      "defaultValue": "760",
      "description": "Maximum width in pixels, bounded to 280–1200. The component always fits its container."
    },
    {
      "name": "radius",
      "type": "number",
      "defaultValue": "18",
      "description": "Field radius in pixels, bounded to 10–28."
    },
    {
      "name": "cardLift / cardSpread",
      "type": "number",
      "defaultValue": "16 / 10",
      "description": "Hover lift and neighbour offset in pixels, bounded to 0–32 and 0–20."
    },
    {
      "name": "speed",
      "type": "number",
      "defaultValue": "1",
      "description": "Animation speed multiplier, bounded to 0.5–2."
    },
    {
      "name": "stiffness / damping",
      "type": "number",
      "defaultValue": "360 / 32",
      "description": "Shared physical spring settings for the card fan, illustrations and model-menu highlight. Stiffness is bounded to 120–600 and damping to 18–60."
    },
    {
      "name": "accentColor",
      "type": "string",
      "defaultValue": "#8662d9",
      "description": "CSS color used for the send control, action glyphs and focus indicators."
    },
    {
      "name": "defaultTrayOpen",
      "type": "boolean",
      "defaultValue": "true",
      "description": "Whether the action cards start open."
    },
    {
      "name": "disabled",
      "type": "boolean",
      "defaultValue": "false",
      "description": "Disable editing and actions; active recording is cancelled."
    },
    {
      "name": "onTranscribe",
      "type": "(audio: Blob, signal: AbortSignal) => Promise<string>",
      "description": "Enables real microphone recording. Return the transcript or reject for a safe retryable error."
    },
    {
      "name": "demoVoice",
      "type": "boolean",
      "defaultValue": "false",
      "description": "Enable a simulated recorder and sample transcript when onTranscribe is absent. Never accesses the microphone."
    },
    {
      "name": "errorMessage",
      "type": "string",
      "defaultValue": "Could not send your message. Please try again.",
      "description": "Safe error text shown when onSend rejects."
    },
    {
      "name": "className / style / ref",
      "type": "string / CSSProperties / Ref<HTMLDivElement>",
      "description": "Root styling and forwarded root ref."
    }
  ],
  "usage": "import { PromptComposer } from \"@/components/ui/prompt-composer\";\n\nexport function AssistantComposer() {\n  return (\n    <PromptComposer\n      models={[{ id: \"assistant\", name: \"Assistant\", description: \"Your connected model\" }]}\n      onSend={async (prompt) => {\n        const response = await fetch(\"/api/prompts\", {\n          method: \"POST\",\n          headers: { \"Content-Type\": \"application/json\" },\n          body: JSON.stringify(prompt),\n        });\n        if (!response.ok) throw new Error(\"Prompt was not accepted\");\n      }}\n    />\n  );\n}",
  "files": [
    {
      "name": "prompt-composer-motion.ts",
      "path": "src/registry/ui/prompt-composer-motion.ts",
      "code": "import { calcGeneratorDuration, spring } from \"motion\";\n\nexport const promptComposerBounded = (\n  value: number,\n  fallback: number,\n  min: number,\n  max: number,\n) => (Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback);\n\nexport function promptComposerSpring(\n  stiffness: number,\n  damping: number,\n  speed: number,\n  reduced: boolean | null,\n) {\n  const response = spring({\n    keyframes: [0, 1],\n    stiffness: promptComposerBounded(stiffness, 360, 120, 600),\n    damping: promptComposerBounded(damping, 32, 18, 60),\n    restSpeed: 0.001,\n    restDelta: 0.001,\n  });\n  const settle = calcGeneratorDuration(response);\n  return {\n    duration: reduced\n      ? 0\n      : settle / 1000 / promptComposerBounded(speed, 1, 0.5, 2),\n    ease: (progress: number) =>\n      progress === 1 ? 1 : response.next(progress * settle).value,\n  };\n}\n",
      "content": "import { calcGeneratorDuration, spring } from \"motion\";\n\nexport const promptComposerBounded = (\n  value: number,\n  fallback: number,\n  min: number,\n  max: number,\n) => (Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback);\n\nexport function promptComposerSpring(\n  stiffness: number,\n  damping: number,\n  speed: number,\n  reduced: boolean | null,\n) {\n  const response = spring({\n    keyframes: [0, 1],\n    stiffness: promptComposerBounded(stiffness, 360, 120, 600),\n    damping: promptComposerBounded(damping, 32, 18, 60),\n    restSpeed: 0.001,\n    restDelta: 0.001,\n  });\n  const settle = calcGeneratorDuration(response);\n  return {\n    duration: reduced\n      ? 0\n      : settle / 1000 / promptComposerBounded(speed, 1, 0.5, 2),\n    ease: (progress: number) =>\n      progress === 1 ? 1 : response.next(progress * settle).value,\n  };\n}\n",
      "type": "registry:ui",
      "target": "@ui/prompt-composer-motion.ts"
    },
    {
      "name": "prompt-composer.tsx",
      "path": "src/registry/ui/prompt-composer.tsx",
      "code": "\"use client\";\n\nimport {\n  forwardRef,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type CSSProperties,\n  type FormEvent,\n} from \"react\";\nimport { useGSAP } from \"@gsap/react\";\nimport gsap from \"gsap\";\nimport {\n  ArrowUp,\n  CalendarDays,\n  FileText,\n  ImagePlus,\n  LoaderCircle,\n  Mic,\n  Plus,\n  StickyNote,\n  X,\n} from \"lucide-react\";\nimport { useReducedMotion } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  promptComposerBounded as bounded,\n  promptComposerSpring,\n} from \"./prompt-composer-motion\";\nimport { PromptComposerArt, type PromptActionId } from \"./prompt-composer-art\";\nimport {\n  PromptComposerModelPicker,\n  type PromptComposerModel,\n} from \"./prompt-composer-model\";\nimport {\n  PromptComposerRecorder,\n  type PromptTranscriber,\n} from \"./prompt-composer-recorder\";\n\ngsap.registerPlugin(useGSAP);\n\nexport type { PromptActionId, PromptComposerModel, PromptTranscriber };\n\nexport interface PromptComposerAction {\n  id: PromptActionId;\n  label: string;\n  placeholder: string;\n}\n\nexport interface PromptComposerSubmission {\n  text: string;\n  action: PromptActionId | null;\n  model: string;\n}\n\nexport interface PromptComposerProps {\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  onSend?: (prompt: PromptComposerSubmission) => void | Promise<void>;\n  actions?: readonly PromptComposerAction[];\n  models?: readonly PromptComposerModel[];\n  defaultModel?: string;\n  onModelChange?: (model: PromptComposerModel) => void;\n  placeholder?: string;\n  label?: string;\n  theme?: \"system\" | \"light\" | \"dark\";\n  width?: number;\n  radius?: number;\n  cardLift?: number;\n  cardSpread?: number;\n  speed?: number;\n  stiffness?: number;\n  damping?: number;\n  accentColor?: string;\n  defaultTrayOpen?: boolean;\n  disabled?: boolean;\n  demoVoice?: boolean;\n  onTranscribe?: PromptTranscriber;\n  errorMessage?: string;\n  className?: string;\n  style?: CSSProperties;\n}\n\nexport const PROMPT_COMPOSER_ACTIONS: readonly PromptComposerAction[] = [\n  {\n    id: \"image\",\n    label: \"Create image\",\n    placeholder: \"Describe the image you want…\",\n  },\n  { id: \"note\", label: \"Add note\", placeholder: \"Write a note…\" },\n  {\n    id: \"event\",\n    label: \"Create event\",\n    placeholder: \"What’s the event, and when?\",\n  },\n  { id: \"voice\", label: \"Voice mode\", placeholder: \"\" },\n  { id: \"docs\", label: \"Analyse docs\", placeholder: \"Ask about a document…\" },\n];\n\nexport const PROMPT_COMPOSER_MODELS: readonly PromptComposerModel[] = [\n  { id: \"quick\", name: \"Quick 1.5\", description: \"Fast answers\" },\n  { id: \"deep\", name: \"Deep 2.0\", description: \"Thinks it through\" },\n  { id: \"vision\", name: \"Vision 1.2\", description: \"Sees your images\" },\n];\n\nconst cardStyle = {\n  image: { tilt: -8, rise: 0, layer: 1, Icon: ImagePlus },\n  note: { tilt: -4, rise: 7, layer: 2, Icon: StickyNote },\n  event: { tilt: 0, rise: 12, layer: 4, Icon: CalendarDays },\n  voice: { tilt: 4, rise: 7, layer: 3, Icon: Mic },\n  docs: { tilt: 8, rise: 0, layer: 2, Icon: FileText },\n} as const;\n\nexport const PromptComposer = forwardRef<HTMLDivElement, PromptComposerProps>(\n  function PromptComposer(\n    {\n      value,\n      defaultValue = \"\",\n      onValueChange,\n      onSend,\n      actions = PROMPT_COMPOSER_ACTIONS,\n      models = PROMPT_COMPOSER_MODELS,\n      defaultModel,\n      onModelChange,\n      placeholder = \"Ask anything\",\n      label = \"Message\",\n      theme = \"system\",\n      width = 760,\n      radius = 18,\n      cardLift = 16,\n      cardSpread = 10,\n      speed = 1,\n      stiffness = 360,\n      damping = 32,\n      accentColor = \"#8662d9\",\n      defaultTrayOpen = true,\n      disabled = false,\n      demoVoice = false,\n      onTranscribe,\n      errorMessage = \"Could not send your message. Please try again.\",\n      className,\n      style,\n    },\n    forwardedRef,\n  ) {\n    const rootRef = useRef<HTMLDivElement>(null);\n    const inputRef = useRef<HTMLInputElement>(null);\n    const mountedRef = useRef(false);\n    const sendingRef = useRef(false);\n    const wasSendingRef = useRef(false);\n    const timelineRef = useRef<gsap.core.Timeline | null>(null);\n    const [draft, setDraft] = useState(defaultValue);\n    const [actionId, setActionId] = useState<PromptActionId | null>(null);\n    const [trayOpen, setTrayOpen] = useState(defaultTrayOpen);\n    const [hovered, setHovered] = useState<PromptActionId | null>(null);\n    const [modelId, setModelId] = useState(defaultModel ?? models[0]?.id);\n    const [voiceOpen, setVoiceOpen] = useState(false);\n    const voiceWasOpenRef = useRef(false);\n    const [sending, setSending] = useState(false);\n    const [error, setError] = useState(\"\");\n    const [round, setRound] = useState(0);\n    const reduced = useReducedMotion();\n    const inputId = useId();\n    const fanId = useId();\n    const errorId = useId();\n    const text = value ?? draft;\n    const uniqueActions = actions\n      .filter(\n        (action, index) =>\n          actions.findIndex((candidate) => candidate.id === action.id) ===\n          index,\n      )\n      .slice(0, 5);\n    const availableModels = models.filter(\n      (model, index) =>\n        models.findIndex((candidate) => candidate.id === model.id) === index,\n    );\n    const model =\n      availableModels.find((candidate) => candidate.id === modelId) ??\n      availableModels[0];\n    const picked =\n      uniqueActions.find((action) => action.id === actionId) ?? null;\n    const voiceEnabled = Boolean(onTranscribe || demoVoice);\n    const [voiceAvailable, setVoiceAvailable] = useState(\n      !disabled && voiceEnabled,\n    );\n    if (voiceAvailable !== (!disabled && voiceEnabled)) {\n      setVoiceAvailable(!disabled && voiceEnabled);\n      setVoiceOpen(false);\n    }\n    const recording = voiceOpen && voiceAvailable;\n    const fanUp = trayOpen && !picked && !recording;\n    const busy = disabled || sending || recording;\n    const motionSpeed = bounded(speed, 1, 0.5, 2);\n    const canSend = Boolean(text.trim() && model && !busy);\n\n    useEffect(() => {\n      mountedRef.current = true;\n      return () => {\n        mountedRef.current = false;\n      };\n    }, []);\n\n    useEffect(() => {\n      if (voiceWasOpenRef.current && !recording && !disabled)\n        inputRef.current?.focus({ preventScroll: true });\n      voiceWasOpenRef.current = recording;\n    }, [recording, disabled]);\n\n    useEffect(() => {\n      if (wasSendingRef.current && !sending && !disabled)\n        inputRef.current?.focus({ preventScroll: true });\n      wasSendingRef.current = sending;\n    }, [sending, disabled]);\n\n    const updateText = (next: string) => {\n      setDraft(next);\n      onValueChange?.(next);\n    };\n    const focusInput = () => inputRef.current?.focus({ preventScroll: true });\n    const clearAction = () => {\n      setActionId(null);\n      setTrayOpen(true);\n      setHovered(null);\n      focusInput();\n    };\n\n    useGSAP(\n      () => {\n        gsap.set(\"[data-prompt-card]\", {\n          yPercent: 120,\n          autoAlpha: 0,\n          scale: 0.92,\n          rotation: 0,\n        });\n      },\n      { scope: rootRef },\n    );\n\n    useGSAP(\n      () => {\n        const root = rootRef.current;\n        if (!root) return;\n        const { duration, ease } = promptComposerSpring(\n          stiffness,\n          damping,\n          motionSpeed,\n          reduced,\n        );\n        timelineRef.current?.kill();\n        const timeline = gsap.timeline({\n          defaults: { duration, ease, overwrite: \"auto\" },\n        });\n        timelineRef.current = timeline;\n        const cards = Array.from(\n          root.querySelectorAll<HTMLButtonElement>(\"[data-prompt-card]\"),\n        );\n        const hoverIndex = cards.findIndex(\n          (card) => card.dataset.promptCard === hovered,\n        );\n        cards.forEach((card, index) => {\n          const id = uniqueActions[index]?.id;\n          if (!id) return;\n          const config = cardStyle[id];\n          const lifted = fanUp && hovered === id;\n          const near = hoverIndex >= 0 && Math.abs(hoverIndex - index) === 1;\n          const direction =\n            hoverIndex < 0 || lifted ? 0 : index < hoverIndex ? -1 : 1;\n          timeline.to(\n            card,\n            {\n              yPercent: fanUp ? 0 : 120,\n              y: fanUp\n                ? -config.rise -\n                  (lifted ? bounded(cardLift, 16, 0, 32) : near ? 3 : 0)\n                : 0,\n              x: fanUp\n                ? direction * bounded(cardSpread, 10, 0, 20) * (near ? 1 : 0.4)\n                : 0,\n              rotation: fanUp\n                ? lifted\n                  ? config.tilt * 0.3\n                  : config.tilt + direction * (near ? 1.5 : 0.5)\n                : 0,\n              scale: fanUp ? (lifted ? 1.05 : 1) : 0.92,\n              autoAlpha: fanUp ? 1 : 0,\n            },\n            reduced\n              ? 0\n              : hovered\n                ? 0\n                : fanUp\n                  ? index * 0.035\n                  : id === actionId\n                    ? 0.05\n                    : 0,\n          );\n          const artwork = (selector: string, vars: gsap.TweenVars) => {\n            const targets = card.querySelectorAll(selector);\n            if (targets.length)\n              timeline.to(\n                targets,\n                {\n                  transformOrigin: \"50% 50%\",\n                  duration,\n                  ...vars,\n                },\n                0,\n              );\n          };\n          artwork(\"[data-art-spark]\", {\n            rotation: lifted && !reduced ? 90 : 0,\n            scale: lifted && !reduced ? 1.2 : 1,\n          });\n          artwork(\"[data-art-triangle]\", {\n            rotation: lifted && !reduced ? -14 : 0,\n          });\n          artwork(\"[data-art-circle], [data-art-date], [data-art-doc]\", {\n            y: lifted && !reduced ? -3 : 0,\n          });\n          artwork(\"[data-art-line]\", {\n            scaleX: lifted && !reduced ? 0.9 : 1,\n            transformOrigin: \"0% 50%\",\n          });\n          artwork(\"[data-art-mic], [data-art-dot]\", {\n            scale: lifted && !reduced ? 1.08 : 1,\n          });\n          artwork(\"[data-art-wave]\", { scaleY: lifted && !reduced ? 0.65 : 1 });\n        });\n        timeline.to(\"[data-tray-icon]\", { rotation: fanUp ? 0 : 135 }, 0);\n        return () => {\n          timeline.kill();\n        };\n      },\n      {\n        scope: rootRef,\n        dependencies: [\n          fanUp,\n          hovered,\n          picked?.id,\n          voiceOpen,\n          reduced,\n          motionSpeed,\n          cardLift,\n          cardSpread,\n          stiffness,\n          damping,\n          round,\n          uniqueActions.map((action) => action.id).join(\",\"),\n        ],\n      },\n    );\n\n    useGSAP(\n      () => {\n        if (!voiceOpen)\n          gsap.fromTo(\n            \"[data-composer-content]\",\n            { opacity: 0, y: reduced ? 0 : 4 },\n            {\n              opacity: 1,\n              y: 0,\n              duration: reduced ? 0 : 0.2 / motionSpeed,\n              ease: \"power2.out\",\n              overwrite: true,\n            },\n          );\n      },\n      {\n        scope: rootRef,\n        dependencies: [picked?.id, voiceOpen, round, reduced, motionSpeed],\n      },\n    );\n\n    const send = async (event: FormEvent<HTMLFormElement>) => {\n      event.preventDefault();\n      if (!canSend || !model || sendingRef.current) return;\n      sendingRef.current = true;\n      setSending(true);\n      setError(\"\");\n      try {\n        await onSend?.({\n          text: text.trim(),\n          action: picked?.id ?? null,\n          model: model.id,\n        });\n        if (!mountedRef.current) return;\n        updateText(\"\");\n        setActionId(null);\n        setTrayOpen(true);\n        setHovered(null);\n        setRound((current) => current + 1);\n      } catch {\n        if (mountedRef.current) setError(errorMessage);\n      } finally {\n        sendingRef.current = false;\n        if (mountedRef.current) {\n          setSending(false);\n          inputRef.current?.focus({ preventScroll: true });\n        }\n      }\n    };\n\n    const tone =\n      \"[--pc-paper:#fff] [--pc-ink:#36333d] [--pc-muted:#77727f] [--pc-border:#3225431c] [--pc-pill:#f1eff5] [--pc-field:#fff] [--pc-image:#e7dcfa] [--pc-note:#f9edc8] [--pc-event:#d9eddf] [--pc-voice:#f6ded9] [--pc-docs:#dbe8f7]\";\n    const darkTone =\n      \"[--pc-paper:#25232a] [--pc-ink:#eeeaf4] [--pc-muted:#b1abbc] [--pc-border:#e8daff1c] [--pc-pill:#302c38] [--pc-field:#211f26] [--pc-image:#40334f] [--pc-note:#4c4430] [--pc-event:#2d463d] [--pc-voice:#503835] [--pc-docs:#304156]\";\n    const systemTone =\n      \"dark:[--pc-paper:#25232a] dark:[--pc-ink:#eeeaf4] dark:[--pc-muted:#b1abbc] dark:[--pc-border:#e8daff1c] dark:[--pc-pill:#302c38] dark:[--pc-field:#211f26] dark:[--pc-image:#40334f] dark:[--pc-note:#4c4430] dark:[--pc-event:#2d463d] dark:[--pc-voice:#503835] dark:[--pc-docs:#304156]\";\n    const ChipIcon = picked ? cardStyle[picked.id].Icon : null;\n\n    return (\n      <div\n        ref={(element) => {\n          rootRef.current = element;\n          if (typeof forwardedRef === \"function\") forwardedRef(element);\n          else if (forwardedRef) forwardedRef.current = element;\n        }}\n        data-slot=\"prompt-composer\"\n        data-theme={theme}\n        className={cn(\n          \"@container/composer w-full min-w-0 text-[var(--pc-ink)]\",\n          tone,\n          theme === \"dark\" ? darkTone : theme === \"system\" ? systemTone : \"\",\n          className,\n        )}\n        style={\n          {\n            maxWidth: bounded(width, 760, 280, 1200),\n            \"--pc-radius\": `${bounded(radius, 18, 10, 28)}px`,\n            \"--pc-accent\": accentColor,\n            ...style,\n          } as CSSProperties\n        }\n      >\n        <form\n          onSubmit={send}\n          aria-label=\"Prompt composer\"\n          aria-busy={sending}\n          onKeyDown={(event) => {\n            if (event.key === \"Escape\" && voiceOpen) {\n              setVoiceOpen(false);\n              focusInput();\n            }\n          }}\n          className=\"relative grid min-w-0 grid-cols-1 items-end gap-x-3 gap-y-3 @min-[561px]/composer:grid-cols-[auto_minmax(0,1fr)]\"\n        >\n          {model && (\n            <div className=\"order-2 @min-[561px]/composer:order-0 @min-[561px]/composer:pb-2\">\n              <PromptComposerModelPicker\n                models={availableModels}\n                value={model}\n                onChange={(next) => {\n                  setModelId(next.id);\n                  onModelChange?.(next);\n                }}\n                disabled={busy}\n                speed={motionSpeed}\n                stiffness={stiffness}\n                damping={damping}\n              />\n            </div>\n          )}\n          <div\n            className={cn(\n              \"@container/field min-w-0\",\n              !model && \"col-span-full\",\n            )}\n          >\n            <div\n              className={cn(\n                \"relative min-w-0\",\n                uniqueActions.length ? \"pt-[calc(16cqw+22px)]\" : \"pt-0\",\n              )}\n            >\n              <div\n                id={fanId}\n                inert={!fanUp || busy}\n                aria-hidden={!fanUp}\n                className=\"absolute inset-x-0 top-2 h-[22cqw] [clip-path:inset(-64px_-32px_calc(6cqw_-_42px)_-32px)]\"\n                onPointerLeave={() => setHovered(null)}\n              >\n                {uniqueActions.map((action, index) => {\n                  const config = cardStyle[action.id];\n                  const isVoiceDisabled =\n                    action.id === \"voice\" && !voiceEnabled;\n                  return (\n                    <button\n                      key={action.id}\n                      type=\"button\"\n                      data-prompt-card={action.id}\n                      aria-label={action.label}\n                      disabled={busy || isVoiceDisabled}\n                      tabIndex={fanUp && !busy ? 0 : -1}\n                      onPointerEnter={(event) => {\n                        if (event.pointerType === \"mouse\")\n                          setHovered(action.id);\n                      }}\n                      onFocus={() => setHovered(action.id)}\n                      onBlur={() => setHovered(null)}\n                      onClick={() => {\n                        setHovered(null);\n                        setError(\"\");\n                        if (action.id === \"voice\") setVoiceOpen(true);\n                        else {\n                          setActionId(action.id);\n                          focusInput();\n                        }\n                      }}\n                      className=\"absolute top-0 h-[22cqw] w-[17cqw] origin-bottom cursor-pointer rounded-[clamp(9px,2.3cqw,17px)] shadow-[0_2px_3px_#20102d08,0_12px_20px_-8px_#20102d20] outline-none focus-visible:ring-2 focus-visible:ring-[var(--pc-accent)] disabled:cursor-default disabled:opacity-60\"\n                      style={\n                        {\n                          left: `calc(${50 + (index - (uniqueActions.length - 1) / 2) * 18.8}% - 8.5cqw)`,\n                          \"--pc-tint\": `var(--pc-${action.id})`,\n                          zIndex: hovered === action.id ? 10 : config.layer,\n                          transform: `rotate(${config.tilt}deg)`,\n                        } as CSSProperties\n                      }\n                    >\n                      <span className=\"absolute inset-0 overflow-hidden rounded-[inherit] border border-[var(--pc-border)] bg-[var(--pc-tint)] shadow-[inset_0_1px_0_#ffffff50]\">\n                        <span\n                          aria-hidden=\"true\"\n                          className=\"absolute inset-x-[10%] top-[9%] flex items-center justify-between text-[clamp(6px,1cqw,8px)] font-medium text-[var(--pc-ink)]/35\"\n                        >\n                          <span className=\"size-[0.65cqw] rounded-full bg-current\" />\n                          <span className=\"font-mono\">0{index + 1}</span>\n                        </span>\n                        <span className=\"absolute inset-x-[7%] top-[23%] text-center text-[clamp(8px,1.65cqw,12px)] leading-tight font-medium tracking-[-0.02em] text-[var(--pc-ink)]/75\">\n                          {action.label}\n                        </span>\n                        <span\n                          aria-hidden=\"true\"\n                          className=\"absolute inset-x-0 top-[43%] bottom-[2%] flex items-start justify-center text-[color-mix(in_srgb,var(--pc-ink)_45%,var(--pc-tint))]\"\n                        >\n                          <PromptComposerArt id={action.id} />\n                        </span>\n                      </span>\n                    </button>\n                  );\n                })}\n              </div>\n              <div className=\"relative z-20 flex h-14 min-w-0 items-center rounded-[var(--pc-radius)] border border-[var(--pc-border)] bg-[var(--pc-field)] pr-2 shadow-[0_2px_4px_#20102d05,0_12px_32px_-12px_#20102d25,inset_0_1px_0_#ffffff10] focus-within:ring-2 focus-within:ring-[var(--pc-accent)]/30\">\n                {recording ? (\n                  <PromptComposerRecorder\n                    demo={!onTranscribe && demoVoice}\n                    onTranscribe={onTranscribe}\n                    onComplete={(transcript) => {\n                      updateText(\n                        [text.trim(), transcript].filter(Boolean).join(\" \"),\n                      );\n                      setVoiceOpen(false);\n                      focusInput();\n                    }}\n                    onCancel={() => {\n                      setVoiceOpen(false);\n                      focusInput();\n                    }}\n                    onError={(message) => {\n                      setError(message);\n                      setVoiceOpen(false);\n                      focusInput();\n                    }}\n                  />\n                ) : (\n                  <div\n                    data-composer-content\n                    className=\"flex h-full min-w-0 flex-1 items-center gap-1 pl-2.5 @min-[421px]/field:gap-2 @min-[421px]/field:pl-4\"\n                  >\n                    {picked && ChipIcon && (\n                      <button\n                        type=\"button\"\n                        disabled={busy}\n                        onClick={clearAction}\n                        aria-label={`Remove ${picked.label}`}\n                        data-action-chip\n                        className=\"flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-lg bg-[var(--pc-pill)] px-2 text-[11px] font-medium outline-none focus-visible:ring-2 focus-visible:ring-[var(--pc-accent)] disabled:opacity-50\"\n                      >\n                        <ChipIcon\n                          className=\"size-3.5 text-[var(--pc-accent)]\"\n                          aria-hidden=\"true\"\n                        />\n                        <span className=\"hidden max-w-28 truncate @min-[481px]/field:inline\">\n                          {picked.label}\n                        </span>\n                        <X className=\"size-3\" aria-hidden=\"true\" />\n                      </button>\n                    )}\n                    <label htmlFor={inputId} className=\"sr-only\">\n                      {label}\n                    </label>\n                    <input\n                      ref={inputRef}\n                      id={inputId}\n                      value={text}\n                      onChange={(event) => {\n                        updateText(event.target.value);\n                        if (error) setError(\"\");\n                      }}\n                      onKeyDown={(event) => {\n                        if (\n                          picked &&\n                          (event.key === \"Escape\" ||\n                            (event.key === \"Backspace\" && !text))\n                        ) {\n                          event.preventDefault();\n                          clearAction();\n                        }\n                      }}\n                      disabled={busy}\n                      placeholder={picked?.placeholder ?? placeholder}\n                      autoComplete=\"off\"\n                      aria-invalid={Boolean(error)}\n                      aria-describedby={error ? errorId : undefined}\n                      className=\"h-full w-0 min-w-0 flex-1 bg-transparent text-base text-[var(--pc-ink)] outline-none placeholder:text-[var(--pc-muted)] disabled:opacity-60 @min-[561px]/composer:text-sm\"\n                    />\n                    <button\n                      type=\"button\"\n                      aria-label={\n                        fanUp ? \"Hide quick actions\" : \"Show quick actions\"\n                      }\n                      aria-expanded={fanUp}\n                      aria-controls={fanId}\n                      disabled={busy}\n                      onClick={() => {\n                        if (picked) clearAction();\n                        else setTrayOpen(!trayOpen);\n                        setHovered(null);\n                      }}\n                      className=\"grid size-10 shrink-0 cursor-pointer place-items-center rounded-full text-[var(--pc-ink)] outline-none hover:bg-[var(--pc-pill)] focus-visible:ring-2 focus-visible:ring-[var(--pc-accent)] disabled:opacity-40\"\n                    >\n                      <Plus\n                        data-tray-icon\n                        className=\"size-5\"\n                        aria-hidden=\"true\"\n                      />\n                    </button>\n                    <button\n                      type=\"button\"\n                      aria-label=\"Voice mode\"\n                      disabled={busy || !voiceEnabled}\n                      onClick={() => {\n                        setError(\"\");\n                        setHovered(null);\n                        setVoiceOpen(true);\n                      }}\n                      className=\"grid size-10 shrink-0 cursor-pointer place-items-center rounded-full outline-none hover:bg-[var(--pc-pill)] focus-visible:ring-2 focus-visible:ring-[var(--pc-accent)] disabled:cursor-default disabled:opacity-35\"\n                    >\n                      <Mic className=\"size-4.5\" aria-hidden=\"true\" />\n                    </button>\n                    <button\n                      type=\"submit\"\n                      aria-label={sending ? \"Sending message\" : \"Send message\"}\n                      disabled={!canSend}\n                      className=\"grid size-9 shrink-0 cursor-pointer place-items-center rounded-xl bg-[var(--pc-accent)] text-white shadow-[inset_0_1px_0_#ffffff20] outline-none focus-visible:ring-2 focus-visible:ring-[var(--pc-accent)] focus-visible:ring-offset-2 disabled:cursor-default disabled:bg-[var(--pc-pill)] disabled:text-[var(--pc-muted)] disabled:shadow-none\"\n                    >\n                      {sending ? (\n                        <LoaderCircle\n                          className=\"size-4 motion-safe:animate-spin\"\n                          aria-hidden=\"true\"\n                        />\n                      ) : (\n                        <ArrowUp\n                          className=\"size-4\"\n                          strokeWidth={2.5}\n                          aria-hidden=\"true\"\n                        />\n                      )}\n                    </button>\n                  </div>\n                )}\n              </div>\n            </div>\n          </div>\n          {error && (\n            <p\n              id={errorId}\n              role=\"alert\"\n              className=\"col-span-full order-3 max-w-full text-xs leading-5 text-red-500\"\n            >\n              {error}\n            </p>\n          )}\n        </form>\n      </div>\n    );\n  },\n);\n\nexport default PromptComposer;\n",
      "content": "\"use client\";\n\nimport {\n  forwardRef,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type CSSProperties,\n  type FormEvent,\n} from \"react\";\nimport { useGSAP } from \"@gsap/react\";\nimport gsap from \"gsap\";\nimport {\n  ArrowUp,\n  CalendarDays,\n  FileText,\n  ImagePlus,\n  LoaderCircle,\n  Mic,\n  Plus,\n  StickyNote,\n  X,\n} from \"lucide-react\";\nimport { useReducedMotion } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  promptComposerBounded as bounded,\n  promptComposerSpring,\n} from \"./prompt-composer-motion\";\nimport { PromptComposerArt, type PromptActionId } from \"./prompt-composer-art\";\nimport {\n  PromptComposerModelPicker,\n  type PromptComposerModel,\n} from \"./prompt-composer-model\";\nimport {\n  PromptComposerRecorder,\n  type PromptTranscriber,\n} from \"./prompt-composer-recorder\";\n\ngsap.registerPlugin(useGSAP);\n\nexport type { PromptActionId, PromptComposerModel, PromptTranscriber };\n\nexport interface PromptComposerAction {\n  id: PromptActionId;\n  label: string;\n  placeholder: string;\n}\n\nexport interface PromptComposerSubmission {\n  text: string;\n  action: PromptActionId | null;\n  model: string;\n}\n\nexport interface PromptComposerProps {\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  onSend?: (prompt: PromptComposerSubmission) => void | Promise<void>;\n  actions?: readonly PromptComposerAction[];\n  models?: readonly PromptComposerModel[];\n  defaultModel?: string;\n  onModelChange?: (model: PromptComposerModel) => void;\n  placeholder?: string;\n  label?: string;\n  theme?: \"system\" | \"light\" | \"dark\";\n  width?: number;\n  radius?: number;\n  cardLift?: number;\n  cardSpread?: number;\n  speed?: number;\n  stiffness?: number;\n  damping?: number;\n  accentColor?: string;\n  defaultTrayOpen?: boolean;\n  disabled?: boolean;\n  demoVoice?: boolean;\n  onTranscribe?: PromptTranscriber;\n  errorMessage?: string;\n  className?: string;\n  style?: CSSProperties;\n}\n\nexport const PROMPT_COMPOSER_ACTIONS: readonly PromptComposerAction[] = [\n  {\n    id: \"image\",\n    label: \"Create image\",\n    placeholder: \"Describe the image you want…\",\n  },\n  { id: \"note\", label: \"Add note\", placeholder: \"Write a note…\" },\n  {\n    id: \"event\",\n    label: \"Create event\",\n    placeholder: \"What’s the event, and when?\",\n  },\n  { id: \"voice\", label: \"Voice mode\", placeholder: \"\" },\n  { id: \"docs\", label: \"Analyse docs\", placeholder: \"Ask about a document…\" },\n];\n\nexport const PROMPT_COMPOSER_MODELS: readonly PromptComposerModel[] = [\n  { id: \"quick\", name: \"Quick 1.5\", description: \"Fast answers\" },\n  { id: \"deep\", name: \"Deep 2.0\", description: \"Thinks it through\" },\n  { id: \"vision\", name: \"Vision 1.2\", description: \"Sees your images\" },\n];\n\nconst cardStyle = {\n  image: { tilt: -8, rise: 0, layer: 1, Icon: ImagePlus },\n  note: { tilt: -4, rise: 7, layer: 2, Icon: StickyNote },\n  event: { tilt: 0, rise: 12, layer: 4, Icon: CalendarDays },\n  voice: { tilt: 4, rise: 7, layer: 3, Icon: Mic },\n  docs: { tilt: 8, rise: 0, layer: 2, Icon: FileText },\n} as const;\n\nexport const PromptComposer = forwardRef<HTMLDivElement, PromptComposerProps>(\n  function PromptComposer(\n    {\n      value,\n      defaultValue = \"\",\n      onValueChange,\n      onSend,\n      actions = PROMPT_COMPOSER_ACTIONS,\n      models = PROMPT_COMPOSER_MODELS,\n      defaultModel,\n      onModelChange,\n      placeholder = \"Ask anything\",\n      label = \"Message\",\n      theme = \"system\",\n      width = 760,\n      radius = 18,\n      cardLift = 16,\n      cardSpread = 10,\n      speed = 1,\n      stiffness = 360,\n      damping = 32,\n      accentColor = \"#8662d9\",\n      defaultTrayOpen = true,\n      disabled = false,\n      demoVoice = false,\n      onTranscribe,\n      errorMessage = \"Could not send your message. Please try again.\",\n      className,\n      style,\n    },\n    forwardedRef,\n  ) {\n    const rootRef = useRef<HTMLDivElement>(null);\n    const inputRef = useRef<HTMLInputElement>(null);\n    const mountedRef = useRef(false);\n    const sendingRef = useRef(false);\n    const wasSendingRef = useRef(false);\n    const timelineRef = useRef<gsap.core.Timeline | null>(null);\n    const [draft, setDraft] = useState(defaultValue);\n    const [actionId, setActionId] = useState<PromptActionId | null>(null);\n    const [trayOpen, setTrayOpen] = useState(defaultTrayOpen);\n    const [hovered, setHovered] = useState<PromptActionId | null>(null);\n    const [modelId, setModelId] = useState(defaultModel ?? models[0]?.id);\n    const [voiceOpen, setVoiceOpen] = useState(false);\n    const voiceWasOpenRef = useRef(false);\n    const [sending, setSending] = useState(false);\n    const [error, setError] = useState(\"\");\n    const [round, setRound] = useState(0);\n    const reduced = useReducedMotion();\n    const inputId = useId();\n    const fanId = useId();\n    const errorId = useId();\n    const text = value ?? draft;\n    const uniqueActions = actions\n      .filter(\n        (action, index) =>\n          actions.findIndex((candidate) => candidate.id === action.id) ===\n          index,\n      )\n      .slice(0, 5);\n    const availableModels = models.filter(\n      (model, index) =>\n        models.findIndex((candidate) => candidate.id === model.id) === index,\n    );\n    const model =\n      availableModels.find((candidate) => candidate.id === modelId) ??\n      availableModels[0];\n    const picked =\n      uniqueActions.find((action) => action.id === actionId) ?? null;\n    const voiceEnabled = Boolean(onTranscribe || demoVoice);\n    const [voiceAvailable, setVoiceAvailable] = useState(\n      !disabled && voiceEnabled,\n    );\n    if (voiceAvailable !== (!disabled && voiceEnabled)) {\n      setVoiceAvailable(!disabled && voiceEnabled);\n      setVoiceOpen(false);\n    }\n    const recording = voiceOpen && voiceAvailable;\n    const fanUp = trayOpen && !picked && !recording;\n    const busy = disabled || sending || recording;\n    const motionSpeed = bounded(speed, 1, 0.5, 2);\n    const canSend = Boolean(text.trim() && model && !busy);\n\n    useEffect(() => {\n      mountedRef.current = true;\n      return () => {\n        mountedRef.current = false;\n      };\n    }, []);\n\n    useEffect(() => {\n      if (voiceWasOpenRef.current && !recording && !disabled)\n        inputRef.current?.focus({ preventScroll: true });\n      voiceWasOpenRef.current = recording;\n    }, [recording, disabled]);\n\n    useEffect(() => {\n      if (wasSendingRef.current && !sending && !disabled)\n        inputRef.current?.focus({ preventScroll: true });\n      wasSendingRef.current = sending;\n    }, [sending, disabled]);\n\n    const updateText = (next: string) => {\n      setDraft(next);\n      onValueChange?.(next);\n    };\n    const focusInput = () => inputRef.current?.focus({ preventScroll: true });\n    const clearAction = () => {\n      setActionId(null);\n      setTrayOpen(true);\n      setHovered(null);\n      focusInput();\n    };\n\n    useGSAP(\n      () => {\n        gsap.set(\"[data-prompt-card]\", {\n          yPercent: 120,\n          autoAlpha: 0,\n          scale: 0.92,\n          rotation: 0,\n        });\n      },\n      { scope: rootRef },\n    );\n\n    useGSAP(\n      () => {\n        const root = rootRef.current;\n        if (!root) return;\n        const { duration, ease } = promptComposerSpring(\n          stiffness,\n          damping,\n          motionSpeed,\n          reduced,\n        );\n        timelineRef.current?.kill();\n        const timeline = gsap.timeline({\n          defaults: { duration, ease, overwrite: \"auto\" },\n        });\n        timelineRef.current = timeline;\n        const cards = Array.from(\n          root.querySelectorAll<HTMLButtonElement>(\"[data-prompt-card]\"),\n        );\n        const hoverIndex = cards.findIndex(\n          (card) => card.dataset.promptCard === hovered,\n        );\n        cards.forEach((card, index) => {\n          const id = uniqueActions[index]?.id;\n          if (!id) return;\n          const config = cardStyle[id];\n          const lifted = fanUp && hovered === id;\n          const near = hoverIndex >= 0 && Math.abs(hoverIndex - index) === 1;\n          const direction =\n            hoverIndex < 0 || lifted ? 0 : index < hoverIndex ? -1 : 1;\n          timeline.to(\n            card,\n            {\n              yPercent: fanUp ? 0 : 120,\n              y: fanUp\n                ? -config.rise -\n                  (lifted ? bounded(cardLift, 16, 0, 32) : near ? 3 : 0)\n                : 0,\n              x: fanUp\n                ? direction * bounded(cardSpread, 10, 0, 20) * (near ? 1 : 0.4)\n                : 0,\n              rotation: fanUp\n                ? lifted\n                  ? config.tilt * 0.3\n                  : config.tilt + direction * (near ? 1.5 : 0.5)\n                : 0,\n              scale: fanUp ? (lifted ? 1.05 : 1) : 0.92,\n              autoAlpha: fanUp ? 1 : 0,\n            },\n            reduced\n              ? 0\n              : hovered\n                ? 0\n                : fanUp\n                  ? index * 0.035\n                  : id === actionId\n                    ? 0.05\n                    : 0,\n          );\n          const artwork = (selector: string, vars: gsap.TweenVars) => {\n            const targets = card.querySelectorAll(selector);\n            if (targets.length)\n              timeline.to(\n                targets,\n                {\n                  transformOrigin: \"50% 50%\",\n                  duration,\n                  ...vars,\n                },\n                0,\n              );\n          };\n          artwork(\"[data-art-spark]\", {\n            rotation: lifted && !reduced ? 90 : 0,\n            scale: lifted && !reduced ? 1.2 : 1,\n          });\n          artwork(\"[data-art-triangle]\", {\n            rotation: lifted && !reduced ? -14 : 0,\n          });\n          artwork(\"[data-art-circle], [data-art-date], [data-art-doc]\", {\n            y: lifted && !reduced ? -3 : 0,\n          });\n          artwork(\"[data-art-line]\", {\n            scaleX: lifted && !reduced ? 0.9 : 1,\n            transformOrigin: \"0% 50%\",\n          });\n          artwork(\"[data-art-mic], [data-art-dot]\", {\n            scale: lifted && !reduced ? 1.08 : 1,\n          });\n          artwork(\"[data-art-wave]\", { scaleY: lifted && !reduced ? 0.65 : 1 });\n        });\n        timeline.to(\"[data-tray-icon]\", { rotation: fanUp ? 0 : 135 }, 0);\n        return () => {\n          timeline.kill();\n        };\n      },\n      {\n        scope: rootRef,\n        dependencies: [\n          fanUp,\n          hovered,\n          picked?.id,\n          voiceOpen,\n          reduced,\n          motionSpeed,\n          cardLift,\n          cardSpread,\n          stiffness,\n          damping,\n          round,\n          uniqueActions.map((action) => action.id).join(\",\"),\n        ],\n      },\n    );\n\n    useGSAP(\n      () => {\n        if (!voiceOpen)\n          gsap.fromTo(\n            \"[data-composer-content]\",\n            { opacity: 0, y: reduced ? 0 : 4 },\n            {\n              opacity: 1,\n              y: 0,\n              duration: reduced ? 0 : 0.2 / motionSpeed,\n              ease: \"power2.out\",\n              overwrite: true,\n            },\n          );\n      },\n      {\n        scope: rootRef,\n        dependencies: [picked?.id, voiceOpen, round, reduced, motionSpeed],\n      },\n    );\n\n    const send = async (event: FormEvent<HTMLFormElement>) => {\n      event.preventDefault();\n      if (!canSend || !model || sendingRef.current) return;\n      sendingRef.current = true;\n      setSending(true);\n      setError(\"\");\n      try {\n        await onSend?.({\n          text: text.trim(),\n          action: picked?.id ?? null,\n          model: model.id,\n        });\n        if (!mountedRef.current) return;\n        updateText(\"\");\n        setActionId(null);\n        setTrayOpen(true);\n        setHovered(null);\n        setRound((current) => current + 1);\n      } catch {\n        if (mountedRef.current) setError(errorMessage);\n      } finally {\n        sendingRef.current = false;\n        if (mountedRef.current) {\n          setSending(false);\n          inputRef.current?.focus({ preventScroll: true });\n        }\n      }\n    };\n\n    const tone =\n      \"[--pc-paper:#fff] [--pc-ink:#36333d] [--pc-muted:#77727f] [--pc-border:#3225431c] [--pc-pill:#f1eff5] [--pc-field:#fff] [--pc-image:#e7dcfa] [--pc-note:#f9edc8] [--pc-event:#d9eddf] [--pc-voice:#f6ded9] [--pc-docs:#dbe8f7]\";\n    const darkTone =\n      \"[--pc-paper:#25232a] [--pc-ink:#eeeaf4] [--pc-muted:#b1abbc] [--pc-border:#e8daff1c] [--pc-pill:#302c38] [--pc-field:#211f26] [--pc-image:#40334f] [--pc-note:#4c4430] [--pc-event:#2d463d] [--pc-voice:#503835] [--pc-docs:#304156]\";\n    const systemTone =\n      \"dark:[--pc-paper:#25232a] dark:[--pc-ink:#eeeaf4] dark:[--pc-muted:#b1abbc] dark:[--pc-border:#e8daff1c] dark:[--pc-pill:#302c38] dark:[--pc-field:#211f26] dark:[--pc-image:#40334f] dark:[--pc-note:#4c4430] dark:[--pc-event:#2d463d] dark:[--pc-voice:#503835] dark:[--pc-docs:#304156]\";\n    const ChipIcon = picked ? cardStyle[picked.id].Icon : null;\n\n    return (\n      <div\n        ref={(element) => {\n          rootRef.current = element;\n          if (typeof forwardedRef === \"function\") forwardedRef(element);\n          else if (forwardedRef) forwardedRef.current = element;\n        }}\n        data-slot=\"prompt-composer\"\n        data-theme={theme}\n        className={cn(\n          \"@container/composer w-full min-w-0 text-[var(--pc-ink)]\",\n          tone,\n          theme === \"dark\" ? darkTone : theme === \"system\" ? systemTone : \"\",\n          className,\n        )}\n        style={\n          {\n            maxWidth: bounded(width, 760, 280, 1200),\n            \"--pc-radius\": `${bounded(radius, 18, 10, 28)}px`,\n            \"--pc-accent\": accentColor,\n            ...style,\n          } as CSSProperties\n        }\n      >\n        <form\n          onSubmit={send}\n          aria-label=\"Prompt composer\"\n          aria-busy={sending}\n          onKeyDown={(event) => {\n            if (event.key === \"Escape\" && voiceOpen) {\n              setVoiceOpen(false);\n              focusInput();\n            }\n          }}\n          className=\"relative grid min-w-0 grid-cols-1 items-end gap-x-3 gap-y-3 @min-[561px]/composer:grid-cols-[auto_minmax(0,1fr)]\"\n        >\n          {model && (\n            <div className=\"order-2 @min-[561px]/composer:order-0 @min-[561px]/composer:pb-2\">\n              <PromptComposerModelPicker\n                models={availableModels}\n                value={model}\n                onChange={(next) => {\n                  setModelId(next.id);\n                  onModelChange?.(next);\n                }}\n                disabled={busy}\n                speed={motionSpeed}\n                stiffness={stiffness}\n                damping={damping}\n              />\n            </div>\n          )}\n          <div\n            className={cn(\n              \"@container/field min-w-0\",\n              !model && \"col-span-full\",\n            )}\n          >\n            <div\n              className={cn(\n                \"relative min-w-0\",\n                uniqueActions.length ? \"pt-[calc(16cqw+22px)]\" : \"pt-0\",\n              )}\n            >\n              <div\n                id={fanId}\n                inert={!fanUp || busy}\n                aria-hidden={!fanUp}\n                className=\"absolute inset-x-0 top-2 h-[22cqw] [clip-path:inset(-64px_-32px_calc(6cqw_-_42px)_-32px)]\"\n                onPointerLeave={() => setHovered(null)}\n              >\n                {uniqueActions.map((action, index) => {\n                  const config = cardStyle[action.id];\n                  const isVoiceDisabled =\n                    action.id === \"voice\" && !voiceEnabled;\n                  return (\n                    <button\n                      key={action.id}\n                      type=\"button\"\n                      data-prompt-card={action.id}\n                      aria-label={action.label}\n                      disabled={busy || isVoiceDisabled}\n                      tabIndex={fanUp && !busy ? 0 : -1}\n                      onPointerEnter={(event) => {\n                        if (event.pointerType === \"mouse\")\n                          setHovered(action.id);\n                      }}\n                      onFocus={() => setHovered(action.id)}\n                      onBlur={() => setHovered(null)}\n                      onClick={() => {\n                        setHovered(null);\n                        setError(\"\");\n                        if (action.id === \"voice\") setVoiceOpen(true);\n                        else {\n                          setActionId(action.id);\n                          focusInput();\n                        }\n                      }}\n                      className=\"absolute top-0 h-[22cqw] w-[17cqw] origin-bottom cursor-pointer rounded-[clamp(9px,2.3cqw,17px)] shadow-[0_2px_3px_#20102d08,0_12px_20px_-8px_#20102d20] outline-none focus-visible:ring-2 focus-visible:ring-[var(--pc-accent)] disabled:cursor-default disabled:opacity-60\"\n                      style={\n                        {\n                          left: `calc(${50 + (index - (uniqueActions.length - 1) / 2) * 18.8}% - 8.5cqw)`,\n                          \"--pc-tint\": `var(--pc-${action.id})`,\n                          zIndex: hovered === action.id ? 10 : config.layer,\n                          transform: `rotate(${config.tilt}deg)`,\n                        } as CSSProperties\n                      }\n                    >\n                      <span className=\"absolute inset-0 overflow-hidden rounded-[inherit] border border-[var(--pc-border)] bg-[var(--pc-tint)] shadow-[inset_0_1px_0_#ffffff50]\">\n                        <span\n                          aria-hidden=\"true\"\n                          className=\"absolute inset-x-[10%] top-[9%] flex items-center justify-between text-[clamp(6px,1cqw,8px)] font-medium text-[var(--pc-ink)]/35\"\n                        >\n                          <span className=\"size-[0.65cqw] rounded-full bg-current\" />\n                          <span className=\"font-mono\">0{index + 1}</span>\n                        </span>\n                        <span className=\"absolute inset-x-[7%] top-[23%] text-center text-[clamp(8px,1.65cqw,12px)] leading-tight font-medium tracking-[-0.02em] text-[var(--pc-ink)]/75\">\n                          {action.label}\n                        </span>\n                        <span\n                          aria-hidden=\"true\"\n                          className=\"absolute inset-x-0 top-[43%] bottom-[2%] flex items-start justify-center text-[color-mix(in_srgb,var(--pc-ink)_45%,var(--pc-tint))]\"\n                        >\n                          <PromptComposerArt id={action.id} />\n                        </span>\n                      </span>\n                    </button>\n                  );\n                })}\n              </div>\n              <div className=\"relative z-20 flex h-14 min-w-0 items-center rounded-[var(--pc-radius)] border border-[var(--pc-border)] bg-[var(--pc-field)] pr-2 shadow-[0_2px_4px_#20102d05,0_12px_32px_-12px_#20102d25,inset_0_1px_0_#ffffff10] focus-within:ring-2 focus-within:ring-[var(--pc-accent)]/30\">\n                {recording ? (\n                  <PromptComposerRecorder\n                    demo={!onTranscribe && demoVoice}\n                    onTranscribe={onTranscribe}\n                    onComplete={(transcript) => {\n                      updateText(\n                        [text.trim(), transcript].filter(Boolean).join(\" \"),\n                      );\n                      setVoiceOpen(false);\n                      focusInput();\n                    }}\n                    onCancel={() => {\n                      setVoiceOpen(false);\n                      focusInput();\n                    }}\n                    onError={(message) => {\n                      setError(message);\n                      setVoiceOpen(false);\n                      focusInput();\n                    }}\n                  />\n                ) : (\n                  <div\n                    data-composer-content\n                    className=\"flex h-full min-w-0 flex-1 items-center gap-1 pl-2.5 @min-[421px]/field:gap-2 @min-[421px]/field:pl-4\"\n                  >\n                    {picked && ChipIcon && (\n                      <button\n                        type=\"button\"\n                        disabled={busy}\n                        onClick={clearAction}\n                        aria-label={`Remove ${picked.label}`}\n                        data-action-chip\n                        className=\"flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-lg bg-[var(--pc-pill)] px-2 text-[11px] font-medium outline-none focus-visible:ring-2 focus-visible:ring-[var(--pc-accent)] disabled:opacity-50\"\n                      >\n                        <ChipIcon\n                          className=\"size-3.5 text-[var(--pc-accent)]\"\n                          aria-hidden=\"true\"\n                        />\n                        <span className=\"hidden max-w-28 truncate @min-[481px]/field:inline\">\n                          {picked.label}\n                        </span>\n                        <X className=\"size-3\" aria-hidden=\"true\" />\n                      </button>\n                    )}\n                    <label htmlFor={inputId} className=\"sr-only\">\n                      {label}\n                    </label>\n                    <input\n                      ref={inputRef}\n                      id={inputId}\n                      value={text}\n                      onChange={(event) => {\n                        updateText(event.target.value);\n                        if (error) setError(\"\");\n                      }}\n                      onKeyDown={(event) => {\n                        if (\n                          picked &&\n                          (event.key === \"Escape\" ||\n                            (event.key === \"Backspace\" && !text))\n                        ) {\n                          event.preventDefault();\n                          clearAction();\n                        }\n                      }}\n                      disabled={busy}\n                      placeholder={picked?.placeholder ?? placeholder}\n                      autoComplete=\"off\"\n                      aria-invalid={Boolean(error)}\n                      aria-describedby={error ? errorId : undefined}\n                      className=\"h-full w-0 min-w-0 flex-1 bg-transparent text-base text-[var(--pc-ink)] outline-none placeholder:text-[var(--pc-muted)] disabled:opacity-60 @min-[561px]/composer:text-sm\"\n                    />\n                    <button\n                      type=\"button\"\n                      aria-label={\n                        fanUp ? \"Hide quick actions\" : \"Show quick actions\"\n                      }\n                      aria-expanded={fanUp}\n                      aria-controls={fanId}\n                      disabled={busy}\n                      onClick={() => {\n                        if (picked) clearAction();\n                        else setTrayOpen(!trayOpen);\n                        setHovered(null);\n                      }}\n                      className=\"grid size-10 shrink-0 cursor-pointer place-items-center rounded-full text-[var(--pc-ink)] outline-none hover:bg-[var(--pc-pill)] focus-visible:ring-2 focus-visible:ring-[var(--pc-accent)] disabled:opacity-40\"\n                    >\n                      <Plus\n                        data-tray-icon\n                        className=\"size-5\"\n                        aria-hidden=\"true\"\n                      />\n                    </button>\n                    <button\n                      type=\"button\"\n                      aria-label=\"Voice mode\"\n                      disabled={busy || !voiceEnabled}\n                      onClick={() => {\n                        setError(\"\");\n                        setHovered(null);\n                        setVoiceOpen(true);\n                      }}\n                      className=\"grid size-10 shrink-0 cursor-pointer place-items-center rounded-full outline-none hover:bg-[var(--pc-pill)] focus-visible:ring-2 focus-visible:ring-[var(--pc-accent)] disabled:cursor-default disabled:opacity-35\"\n                    >\n                      <Mic className=\"size-4.5\" aria-hidden=\"true\" />\n                    </button>\n                    <button\n                      type=\"submit\"\n                      aria-label={sending ? \"Sending message\" : \"Send message\"}\n                      disabled={!canSend}\n                      className=\"grid size-9 shrink-0 cursor-pointer place-items-center rounded-xl bg-[var(--pc-accent)] text-white shadow-[inset_0_1px_0_#ffffff20] outline-none focus-visible:ring-2 focus-visible:ring-[var(--pc-accent)] focus-visible:ring-offset-2 disabled:cursor-default disabled:bg-[var(--pc-pill)] disabled:text-[var(--pc-muted)] disabled:shadow-none\"\n                    >\n                      {sending ? (\n                        <LoaderCircle\n                          className=\"size-4 motion-safe:animate-spin\"\n                          aria-hidden=\"true\"\n                        />\n                      ) : (\n                        <ArrowUp\n                          className=\"size-4\"\n                          strokeWidth={2.5}\n                          aria-hidden=\"true\"\n                        />\n                      )}\n                    </button>\n                  </div>\n                )}\n              </div>\n            </div>\n          </div>\n          {error && (\n            <p\n              id={errorId}\n              role=\"alert\"\n              className=\"col-span-full order-3 max-w-full text-xs leading-5 text-red-500\"\n            >\n              {error}\n            </p>\n          )}\n        </form>\n      </div>\n    );\n  },\n);\n\nexport default PromptComposer;\n",
      "type": "registry:ui",
      "target": "@ui/prompt-composer.tsx"
    },
    {
      "name": "prompt-composer-art.tsx",
      "path": "src/registry/ui/prompt-composer-art.tsx",
      "code": "\"use client\";\n\nexport type PromptActionId = \"image\" | \"note\" | \"event\" | \"voice\" | \"docs\";\n\nexport function PromptComposerArt({ id }: { id: PromptActionId }) {\n  if (id === \"note\")\n    return (\n      <span className=\"relative mt-[1cqw] flex w-[72%] -rotate-3 flex-col gap-[1.5cqw] rounded-[1cqw] border border-current/15 bg-[var(--pc-paper)]/50 px-[1cqw] py-[1.5cqw] shadow-[2px_3px_0_#00000006]\">\n        {[0.7, 1, 0.9, 0.55].map((width, index) => (\n          <span\n            key={index}\n            data-art-line\n            className=\"h-[1.5px] origin-left rounded-full bg-current opacity-55\"\n            style={{ width: `${width * 100}%` }}\n          />\n        ))}\n        <span className=\"absolute -top-[0.7cqw] right-[1.2cqw] h-[1.4cqw] w-[4.5cqw] rotate-8 rounded-sm bg-current/15\" />\n      </span>\n    );\n\n  if (id === \"event\")\n    return (\n      <span\n        data-art-date\n        className=\"relative flex w-[66%] flex-col items-center rounded-[1cqw] border border-current/15 bg-[var(--pc-paper)]/55 pb-[1cqw] shadow-[2px_3px_0_#00000006]\"\n      >\n        <span className=\"w-full border-b border-current/10 py-[0.5cqw] text-center text-[1.3cqw] font-semibold tracking-[0.15em]\">\n          OCT\n        </span>\n        <span className=\"text-[6cqw] leading-[1.15] font-semibold tracking-[-0.08em]\">\n          12\n        </span>\n        <span className=\"absolute -top-[0.7cqw] left-[1.2cqw] h-[1.3cqw] w-[0.45cqw] rounded-full bg-current/50\" />\n        <span className=\"absolute -top-[0.7cqw] right-[1.2cqw] h-[1.3cqw] w-[0.45cqw] rounded-full bg-current/50\" />\n      </span>\n    );\n\n  if (id === \"voice\")\n    return (\n      <svg\n        className=\"w-[78%] overflow-visible\"\n        viewBox=\"0 0 96 80\"\n        fill=\"none\"\n        aria-hidden=\"true\"\n      >\n        <rect\n          x=\"9\"\n          y=\"11\"\n          width=\"78\"\n          height=\"55\"\n          rx=\"15\"\n          fill=\"currentColor\"\n          fillOpacity=\"0.08\"\n          stroke=\"currentColor\"\n          strokeOpacity=\"0.12\"\n        />\n        {[14, 27, 39, 24, 47, 32, 18].map((height, index) => (\n          <rect\n            key={index}\n            data-art-wave\n            x={22 + index * 8}\n            y={39 - height / 2}\n            width=\"4\"\n            height={height}\n            rx=\"2\"\n            fill=\"currentColor\"\n            fillOpacity={index === 3 ? 0.9 : 0.45}\n          />\n        ))}\n        <circle data-art-dot cx=\"76\" cy=\"63\" r=\"9\" fill=\"var(--pc-paper)\" />\n        <circle cx=\"76\" cy=\"63\" r=\"4\" fill=\"currentColor\" />\n      </svg>\n    );\n\n  if (id === \"docs\")\n    return (\n      <svg\n        className=\"w-[74%] overflow-visible\"\n        viewBox=\"0 0 96 80\"\n        fill=\"none\"\n        aria-hidden=\"true\"\n      >\n        <rect\n          x=\"20\"\n          y=\"9\"\n          width=\"49\"\n          height=\"60\"\n          rx=\"7\"\n          transform=\"rotate(-9 20 9)\"\n          fill=\"currentColor\"\n          fillOpacity=\"0.13\"\n        />\n        <g data-art-doc>\n          <rect\n            x=\"27\"\n            y=\"8\"\n            width=\"49\"\n            height=\"61\"\n            rx=\"7\"\n            fill=\"var(--pc-paper)\"\n            fillOpacity=\"0.65\"\n            stroke=\"currentColor\"\n            strokeOpacity=\"0.2\"\n          />\n          <rect\n            x=\"37\"\n            y=\"21\"\n            width=\"16\"\n            height=\"5\"\n            rx=\"2.5\"\n            fill=\"currentColor\"\n            fillOpacity=\"0.55\"\n          />\n          <path\n            d=\"M37 36h29M37 44h29M37 52h17\"\n            stroke=\"currentColor\"\n            strokeOpacity=\"0.35\"\n            strokeWidth=\"3\"\n            strokeLinecap=\"round\"\n          />\n        </g>\n        <circle cx=\"72\" cy=\"63\" r=\"11\" fill=\"currentColor\" fillOpacity=\"0.8\" />\n        <path\n          d=\"m67 63 3 3 6-7\"\n          stroke=\"var(--pc-paper)\"\n          strokeWidth=\"2\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n      </svg>\n    );\n\n  return (\n    <svg\n      className=\"w-[80%] overflow-visible\"\n      viewBox=\"0 0 96 80\"\n      fill=\"none\"\n      aria-hidden=\"true\"\n    >\n      <rect\n        x=\"13\"\n        y=\"12\"\n        width=\"65\"\n        height=\"50\"\n        rx=\"8\"\n        transform=\"rotate(-8 13 12)\"\n        fill=\"currentColor\"\n        fillOpacity=\"0.12\"\n      />\n      <g data-art-triangle>\n        <rect\n          x=\"19\"\n          y=\"15\"\n          width=\"65\"\n          height=\"50\"\n          rx=\"8\"\n          fill=\"var(--pc-paper)\"\n          fillOpacity=\"0.65\"\n          stroke=\"currentColor\"\n          strokeOpacity=\"0.2\"\n        />\n        <path\n          d=\"m25 56 16-19 12 13 8-9 17 15H25Z\"\n          fill=\"currentColor\"\n          fillOpacity=\"0.3\"\n        />\n      </g>\n      <circle\n        data-art-circle\n        cx=\"66\"\n        cy=\"29\"\n        r=\"6\"\n        fill=\"currentColor\"\n        fillOpacity=\"0.4\"\n      />\n      <path\n        data-art-spark\n        d=\"M81 2c1 7 3 9 10 10-7 1-9 3-10 10-1-7-3-9-10-10 7-1 9-3 10-10Z\"\n        fill=\"currentColor\"\n        fillOpacity=\"0.7\"\n      />\n    </svg>\n  );\n}\n",
      "content": "\"use client\";\n\nexport type PromptActionId = \"image\" | \"note\" | \"event\" | \"voice\" | \"docs\";\n\nexport function PromptComposerArt({ id }: { id: PromptActionId }) {\n  if (id === \"note\")\n    return (\n      <span className=\"relative mt-[1cqw] flex w-[72%] -rotate-3 flex-col gap-[1.5cqw] rounded-[1cqw] border border-current/15 bg-[var(--pc-paper)]/50 px-[1cqw] py-[1.5cqw] shadow-[2px_3px_0_#00000006]\">\n        {[0.7, 1, 0.9, 0.55].map((width, index) => (\n          <span\n            key={index}\n            data-art-line\n            className=\"h-[1.5px] origin-left rounded-full bg-current opacity-55\"\n            style={{ width: `${width * 100}%` }}\n          />\n        ))}\n        <span className=\"absolute -top-[0.7cqw] right-[1.2cqw] h-[1.4cqw] w-[4.5cqw] rotate-8 rounded-sm bg-current/15\" />\n      </span>\n    );\n\n  if (id === \"event\")\n    return (\n      <span\n        data-art-date\n        className=\"relative flex w-[66%] flex-col items-center rounded-[1cqw] border border-current/15 bg-[var(--pc-paper)]/55 pb-[1cqw] shadow-[2px_3px_0_#00000006]\"\n      >\n        <span className=\"w-full border-b border-current/10 py-[0.5cqw] text-center text-[1.3cqw] font-semibold tracking-[0.15em]\">\n          OCT\n        </span>\n        <span className=\"text-[6cqw] leading-[1.15] font-semibold tracking-[-0.08em]\">\n          12\n        </span>\n        <span className=\"absolute -top-[0.7cqw] left-[1.2cqw] h-[1.3cqw] w-[0.45cqw] rounded-full bg-current/50\" />\n        <span className=\"absolute -top-[0.7cqw] right-[1.2cqw] h-[1.3cqw] w-[0.45cqw] rounded-full bg-current/50\" />\n      </span>\n    );\n\n  if (id === \"voice\")\n    return (\n      <svg\n        className=\"w-[78%] overflow-visible\"\n        viewBox=\"0 0 96 80\"\n        fill=\"none\"\n        aria-hidden=\"true\"\n      >\n        <rect\n          x=\"9\"\n          y=\"11\"\n          width=\"78\"\n          height=\"55\"\n          rx=\"15\"\n          fill=\"currentColor\"\n          fillOpacity=\"0.08\"\n          stroke=\"currentColor\"\n          strokeOpacity=\"0.12\"\n        />\n        {[14, 27, 39, 24, 47, 32, 18].map((height, index) => (\n          <rect\n            key={index}\n            data-art-wave\n            x={22 + index * 8}\n            y={39 - height / 2}\n            width=\"4\"\n            height={height}\n            rx=\"2\"\n            fill=\"currentColor\"\n            fillOpacity={index === 3 ? 0.9 : 0.45}\n          />\n        ))}\n        <circle data-art-dot cx=\"76\" cy=\"63\" r=\"9\" fill=\"var(--pc-paper)\" />\n        <circle cx=\"76\" cy=\"63\" r=\"4\" fill=\"currentColor\" />\n      </svg>\n    );\n\n  if (id === \"docs\")\n    return (\n      <svg\n        className=\"w-[74%] overflow-visible\"\n        viewBox=\"0 0 96 80\"\n        fill=\"none\"\n        aria-hidden=\"true\"\n      >\n        <rect\n          x=\"20\"\n          y=\"9\"\n          width=\"49\"\n          height=\"60\"\n          rx=\"7\"\n          transform=\"rotate(-9 20 9)\"\n          fill=\"currentColor\"\n          fillOpacity=\"0.13\"\n        />\n        <g data-art-doc>\n          <rect\n            x=\"27\"\n            y=\"8\"\n            width=\"49\"\n            height=\"61\"\n            rx=\"7\"\n            fill=\"var(--pc-paper)\"\n            fillOpacity=\"0.65\"\n            stroke=\"currentColor\"\n            strokeOpacity=\"0.2\"\n          />\n          <rect\n            x=\"37\"\n            y=\"21\"\n            width=\"16\"\n            height=\"5\"\n            rx=\"2.5\"\n            fill=\"currentColor\"\n            fillOpacity=\"0.55\"\n          />\n          <path\n            d=\"M37 36h29M37 44h29M37 52h17\"\n            stroke=\"currentColor\"\n            strokeOpacity=\"0.35\"\n            strokeWidth=\"3\"\n            strokeLinecap=\"round\"\n          />\n        </g>\n        <circle cx=\"72\" cy=\"63\" r=\"11\" fill=\"currentColor\" fillOpacity=\"0.8\" />\n        <path\n          d=\"m67 63 3 3 6-7\"\n          stroke=\"var(--pc-paper)\"\n          strokeWidth=\"2\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n      </svg>\n    );\n\n  return (\n    <svg\n      className=\"w-[80%] overflow-visible\"\n      viewBox=\"0 0 96 80\"\n      fill=\"none\"\n      aria-hidden=\"true\"\n    >\n      <rect\n        x=\"13\"\n        y=\"12\"\n        width=\"65\"\n        height=\"50\"\n        rx=\"8\"\n        transform=\"rotate(-8 13 12)\"\n        fill=\"currentColor\"\n        fillOpacity=\"0.12\"\n      />\n      <g data-art-triangle>\n        <rect\n          x=\"19\"\n          y=\"15\"\n          width=\"65\"\n          height=\"50\"\n          rx=\"8\"\n          fill=\"var(--pc-paper)\"\n          fillOpacity=\"0.65\"\n          stroke=\"currentColor\"\n          strokeOpacity=\"0.2\"\n        />\n        <path\n          d=\"m25 56 16-19 12 13 8-9 17 15H25Z\"\n          fill=\"currentColor\"\n          fillOpacity=\"0.3\"\n        />\n      </g>\n      <circle\n        data-art-circle\n        cx=\"66\"\n        cy=\"29\"\n        r=\"6\"\n        fill=\"currentColor\"\n        fillOpacity=\"0.4\"\n      />\n      <path\n        data-art-spark\n        d=\"M81 2c1 7 3 9 10 10-7 1-9 3-10 10-1-7-3-9-10-10 7-1 9-3 10-10Z\"\n        fill=\"currentColor\"\n        fillOpacity=\"0.7\"\n      />\n    </svg>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/prompt-composer-art.tsx"
    },
    {
      "name": "prompt-composer-model.tsx",
      "path": "src/registry/ui/prompt-composer-model.tsx",
      "code": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type KeyboardEvent,\n} from \"react\";\nimport { useGSAP } from \"@gsap/react\";\nimport gsap from \"gsap\";\nimport { Check, ChevronDown, Flower } from \"lucide-react\";\nimport { useReducedMotion } from \"motion/react\";\nimport { promptComposerSpring } from \"./prompt-composer-motion\";\n\ngsap.registerPlugin(useGSAP);\n\nexport interface PromptComposerModel {\n  id: string;\n  name: string;\n  description?: string;\n}\n\nexport function PromptComposerModelPicker({\n  models,\n  value,\n  onChange,\n  disabled,\n  speed,\n  stiffness,\n  damping,\n}: {\n  models: readonly PromptComposerModel[];\n  value: PromptComposerModel;\n  onChange: (model: PromptComposerModel) => void;\n  disabled: boolean;\n  speed: number;\n  stiffness: number;\n  damping: number;\n}) {\n  const [open, setOpen] = useState(false);\n  const rootRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const menuRef = useRef<HTMLDivElement>(null);\n  const highlightRef = useRef<HTMLSpanElement>(null);\n  const pinnedRef = useRef(false);\n  const focusOnOpenRef = useRef(false);\n  const focusEdgeRef = useRef<\"first\" | \"last\" | \"selected\">(\"selected\");\n  const closeDelayRef = useRef<gsap.core.Tween | null>(null);\n  const highlightedRef = useRef(false);\n  const [hovered, setHovered] = useState<string | null>(null);\n  const menuId = useId();\n  const reduced = useReducedMotion();\n  const visible = open && !disabled;\n  const { contextSafe } = useGSAP(() => () => closeDelayRef.current?.kill(), {\n    scope: rootRef,\n  });\n\n  const close = useCallback(() => {\n    closeDelayRef.current?.kill();\n    focusOnOpenRef.current = false;\n    pinnedRef.current = false;\n    setOpen(false);\n    setHovered(null);\n  }, []);\n\n  const focusMenu = useCallback((edge: \"first\" | \"last\" | \"selected\") => {\n    const options = Array.from(\n      menuRef.current?.querySelectorAll<HTMLButtonElement>(\n        '[role=\"menuitemradio\"]',\n      ) ?? [],\n    );\n    const option =\n      edge === \"first\"\n        ? options[0]\n        : edge === \"last\"\n          ? options.at(-1)\n          : options.find(\n              (item) => item.getAttribute(\"aria-checked\") === \"true\",\n            );\n    option?.focus({ preventScroll: true });\n  }, []);\n\n  const scheduleClose = () =>\n    contextSafe(() => {\n      if (\n        pinnedRef.current ||\n        menuRef.current?.contains(document.activeElement)\n      )\n        return;\n      closeDelayRef.current?.kill();\n      closeDelayRef.current = gsap.delayedCall(0.18, close);\n    })();\n\n  useGSAP(\n    () => {\n      gsap.to(menuRef.current, {\n        autoAlpha: visible ? 1 : 0,\n        y: visible ? 0 : 7,\n        scale: visible ? 1 : 0.96,\n        ...promptComposerSpring(stiffness, damping, speed, reduced),\n        overwrite: true,\n      });\n      gsap.to(\"[data-model-chevron]\", {\n        rotation: visible ? 180 : 0,\n        ...promptComposerSpring(stiffness, damping, speed, reduced),\n        overwrite: true,\n      });\n    },\n    {\n      scope: rootRef,\n      dependencies: [visible, reduced, speed, stiffness, damping],\n    },\n  );\n\n  useGSAP(\n    () => {\n      const target = Array.from(\n        menuRef.current?.querySelectorAll<HTMLButtonElement>(\n          \"[data-model-option]\",\n        ) ?? [],\n      ).find((option) => option.dataset.modelOption === (hovered ?? value.id));\n      if (!target || !visible) {\n        highlightedRef.current = false;\n        return;\n      }\n      const vars = {\n        y: target.offsetTop,\n        height: target.offsetHeight,\n        autoAlpha: 1,\n      };\n      if (!highlightedRef.current || reduced)\n        gsap.set(highlightRef.current, vars);\n      else\n        gsap.to(highlightRef.current, {\n          ...vars,\n          ...promptComposerSpring(stiffness, damping, speed, reduced),\n          overwrite: true,\n        });\n      highlightedRef.current = true;\n    },\n    {\n      scope: rootRef,\n      dependencies: [\n        hovered,\n        visible,\n        value.id,\n        reduced,\n        speed,\n        stiffness,\n        damping,\n        models.map((model) => model.id).join(\",\"),\n      ],\n    },\n  );\n\n  useEffect(() => {\n    if (!visible) return;\n    if (focusOnOpenRef.current) focusMenu(focusEdgeRef.current);\n    focusOnOpenRef.current = false;\n    const dismiss = (event: PointerEvent) => {\n      if (\n        event.target instanceof Node &&\n        !rootRef.current?.contains(event.target)\n      )\n        close();\n    };\n    document.addEventListener(\"pointerdown\", dismiss);\n    return () => document.removeEventListener(\"pointerdown\", dismiss);\n  }, [visible, close, focusMenu]);\n\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"Escape\") {\n      event.preventDefault();\n      event.stopPropagation();\n      close();\n      triggerRef.current?.focus({ preventScroll: true });\n      return;\n    }\n    const options = Array.from(\n      menuRef.current?.querySelectorAll<HTMLButtonElement>(\n        '[role=\"menuitemradio\"]',\n      ) ?? [],\n    );\n    const index = options.indexOf(document.activeElement as HTMLButtonElement);\n    const next =\n      event.key === \"Home\"\n        ? 0\n        : event.key === \"End\"\n          ? options.length - 1\n          : event.key === \"ArrowDown\"\n            ? (index + 1) % options.length\n            : event.key === \"ArrowUp\"\n              ? (index - 1 + options.length) % options.length\n              : null;\n    if (next !== null) {\n      event.preventDefault();\n      options[next]?.focus({ preventScroll: true });\n    }\n  };\n\n  return (\n    <div\n      ref={rootRef}\n      className=\"relative z-30 w-fit shrink-0\"\n      onPointerEnter={(event) => {\n        closeDelayRef.current?.kill();\n        if (event.pointerType === \"mouse\" && !disabled && models.length > 1)\n          setOpen(true);\n      }}\n      onPointerLeave={(event) => {\n        if (event.pointerType === \"mouse\") scheduleClose();\n      }}\n      onBlur={(event) => {\n        if (!event.currentTarget.contains(event.relatedTarget)) close();\n      }}\n    >\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        aria-label={`Model: ${value.name}`}\n        aria-haspopup=\"menu\"\n        aria-expanded={visible}\n        aria-controls={menuId}\n        disabled={disabled || models.length < 2}\n        onClick={() => {\n          closeDelayRef.current?.kill();\n          if (visible && pinnedRef.current) close();\n          else {\n            pinnedRef.current = true;\n            focusEdgeRef.current = \"selected\";\n            focusOnOpenRef.current = true;\n            setOpen(true);\n            if (visible) {\n              focusOnOpenRef.current = false;\n              focusMenu(\"selected\");\n            }\n          }\n        }}\n        onKeyDown={(event) => {\n          if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n            event.preventDefault();\n            closeDelayRef.current?.kill();\n            pinnedRef.current = true;\n            const edge = event.key === \"ArrowUp\" ? \"last\" : \"first\";\n            if (visible) focusMenu(edge);\n            else {\n              focusEdgeRef.current = edge;\n              focusOnOpenRef.current = true;\n              setOpen(true);\n            }\n          }\n        }}\n        className=\"flex min-h-10 w-36 max-w-full cursor-pointer items-center gap-2 rounded-full bg-[var(--pc-pill)] px-3.5 text-xs font-medium text-[var(--pc-muted)] outline-none hover:text-[var(--pc-ink)] focus-visible:ring-2 focus-visible:ring-[var(--pc-accent)] focus-visible:ring-offset-2 disabled:cursor-default disabled:opacity-60\"\n      >\n        <Flower className=\"size-3.5 shrink-0\" aria-hidden=\"true\" />\n        <span data-model-name className=\"truncate\">\n          {value.name}\n        </span>\n        <ChevronDown\n          data-model-chevron\n          className=\"size-3 shrink-0\"\n          aria-hidden=\"true\"\n        />\n      </button>\n      <div\n        ref={menuRef}\n        id={menuId}\n        role=\"menu\"\n        aria-label=\"Choose a model\"\n        inert={!visible}\n        aria-hidden={!visible}\n        onKeyDown={onKeyDown}\n        className=\"invisible absolute bottom-[calc(100%+8px)] left-0 flex w-56 origin-bottom-left flex-col gap-0.5 rounded-2xl border border-[var(--pc-border)] bg-[var(--pc-paper)] p-1.5 opacity-0 shadow-xl @min-[561px]/composer:top-[calc(100%+8px)] @min-[561px]/composer:bottom-auto @min-[561px]/composer:origin-top-left\"\n      >\n        <span\n          ref={highlightRef}\n          data-model-highlight\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute top-0 right-1.5 left-1.5 rounded-xl bg-[var(--pc-pill)] opacity-0\"\n        />\n        {models.map((model) => (\n          <button\n            key={model.id}\n            type=\"button\"\n            role=\"menuitemradio\"\n            data-model-option={model.id}\n            aria-checked={value.id === model.id}\n            tabIndex={visible && value.id === model.id ? 0 : -1}\n            onPointerEnter={(event) => {\n              if (event.pointerType === \"mouse\") setHovered(model.id);\n            }}\n            onFocus={() => setHovered(model.id)}\n            onClick={() => {\n              onChange(model);\n              close();\n              triggerRef.current?.focus({ preventScroll: true });\n            }}\n            className=\"relative z-10 flex min-h-12 cursor-pointer items-center justify-between gap-3 rounded-xl px-3 py-2 text-left outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[var(--pc-accent)]/40\"\n          >\n            <span className=\"flex min-w-0 flex-col gap-0.5\">\n              <span className=\"truncate text-xs font-medium\">{model.name}</span>\n              {model.description && (\n                <span className=\"truncate text-[11px] text-[var(--pc-muted)]\">\n                  {model.description}\n                </span>\n              )}\n            </span>\n            {value.id === model.id && (\n              <Check className=\"size-3.5 shrink-0\" aria-hidden=\"true\" />\n            )}\n          </button>\n        ))}\n      </div>\n    </div>\n  );\n}\n",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type KeyboardEvent,\n} from \"react\";\nimport { useGSAP } from \"@gsap/react\";\nimport gsap from \"gsap\";\nimport { Check, ChevronDown, Flower } from \"lucide-react\";\nimport { useReducedMotion } from \"motion/react\";\nimport { promptComposerSpring } from \"./prompt-composer-motion\";\n\ngsap.registerPlugin(useGSAP);\n\nexport interface PromptComposerModel {\n  id: string;\n  name: string;\n  description?: string;\n}\n\nexport function PromptComposerModelPicker({\n  models,\n  value,\n  onChange,\n  disabled,\n  speed,\n  stiffness,\n  damping,\n}: {\n  models: readonly PromptComposerModel[];\n  value: PromptComposerModel;\n  onChange: (model: PromptComposerModel) => void;\n  disabled: boolean;\n  speed: number;\n  stiffness: number;\n  damping: number;\n}) {\n  const [open, setOpen] = useState(false);\n  const rootRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const menuRef = useRef<HTMLDivElement>(null);\n  const highlightRef = useRef<HTMLSpanElement>(null);\n  const pinnedRef = useRef(false);\n  const focusOnOpenRef = useRef(false);\n  const focusEdgeRef = useRef<\"first\" | \"last\" | \"selected\">(\"selected\");\n  const closeDelayRef = useRef<gsap.core.Tween | null>(null);\n  const highlightedRef = useRef(false);\n  const [hovered, setHovered] = useState<string | null>(null);\n  const menuId = useId();\n  const reduced = useReducedMotion();\n  const visible = open && !disabled;\n  const { contextSafe } = useGSAP(() => () => closeDelayRef.current?.kill(), {\n    scope: rootRef,\n  });\n\n  const close = useCallback(() => {\n    closeDelayRef.current?.kill();\n    focusOnOpenRef.current = false;\n    pinnedRef.current = false;\n    setOpen(false);\n    setHovered(null);\n  }, []);\n\n  const focusMenu = useCallback((edge: \"first\" | \"last\" | \"selected\") => {\n    const options = Array.from(\n      menuRef.current?.querySelectorAll<HTMLButtonElement>(\n        '[role=\"menuitemradio\"]',\n      ) ?? [],\n    );\n    const option =\n      edge === \"first\"\n        ? options[0]\n        : edge === \"last\"\n          ? options.at(-1)\n          : options.find(\n              (item) => item.getAttribute(\"aria-checked\") === \"true\",\n            );\n    option?.focus({ preventScroll: true });\n  }, []);\n\n  const scheduleClose = () =>\n    contextSafe(() => {\n      if (\n        pinnedRef.current ||\n        menuRef.current?.contains(document.activeElement)\n      )\n        return;\n      closeDelayRef.current?.kill();\n      closeDelayRef.current = gsap.delayedCall(0.18, close);\n    })();\n\n  useGSAP(\n    () => {\n      gsap.to(menuRef.current, {\n        autoAlpha: visible ? 1 : 0,\n        y: visible ? 0 : 7,\n        scale: visible ? 1 : 0.96,\n        ...promptComposerSpring(stiffness, damping, speed, reduced),\n        overwrite: true,\n      });\n      gsap.to(\"[data-model-chevron]\", {\n        rotation: visible ? 180 : 0,\n        ...promptComposerSpring(stiffness, damping, speed, reduced),\n        overwrite: true,\n      });\n    },\n    {\n      scope: rootRef,\n      dependencies: [visible, reduced, speed, stiffness, damping],\n    },\n  );\n\n  useGSAP(\n    () => {\n      const target = Array.from(\n        menuRef.current?.querySelectorAll<HTMLButtonElement>(\n          \"[data-model-option]\",\n        ) ?? [],\n      ).find((option) => option.dataset.modelOption === (hovered ?? value.id));\n      if (!target || !visible) {\n        highlightedRef.current = false;\n        return;\n      }\n      const vars = {\n        y: target.offsetTop,\n        height: target.offsetHeight,\n        autoAlpha: 1,\n      };\n      if (!highlightedRef.current || reduced)\n        gsap.set(highlightRef.current, vars);\n      else\n        gsap.to(highlightRef.current, {\n          ...vars,\n          ...promptComposerSpring(stiffness, damping, speed, reduced),\n          overwrite: true,\n        });\n      highlightedRef.current = true;\n    },\n    {\n      scope: rootRef,\n      dependencies: [\n        hovered,\n        visible,\n        value.id,\n        reduced,\n        speed,\n        stiffness,\n        damping,\n        models.map((model) => model.id).join(\",\"),\n      ],\n    },\n  );\n\n  useEffect(() => {\n    if (!visible) return;\n    if (focusOnOpenRef.current) focusMenu(focusEdgeRef.current);\n    focusOnOpenRef.current = false;\n    const dismiss = (event: PointerEvent) => {\n      if (\n        event.target instanceof Node &&\n        !rootRef.current?.contains(event.target)\n      )\n        close();\n    };\n    document.addEventListener(\"pointerdown\", dismiss);\n    return () => document.removeEventListener(\"pointerdown\", dismiss);\n  }, [visible, close, focusMenu]);\n\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"Escape\") {\n      event.preventDefault();\n      event.stopPropagation();\n      close();\n      triggerRef.current?.focus({ preventScroll: true });\n      return;\n    }\n    const options = Array.from(\n      menuRef.current?.querySelectorAll<HTMLButtonElement>(\n        '[role=\"menuitemradio\"]',\n      ) ?? [],\n    );\n    const index = options.indexOf(document.activeElement as HTMLButtonElement);\n    const next =\n      event.key === \"Home\"\n        ? 0\n        : event.key === \"End\"\n          ? options.length - 1\n          : event.key === \"ArrowDown\"\n            ? (index + 1) % options.length\n            : event.key === \"ArrowUp\"\n              ? (index - 1 + options.length) % options.length\n              : null;\n    if (next !== null) {\n      event.preventDefault();\n      options[next]?.focus({ preventScroll: true });\n    }\n  };\n\n  return (\n    <div\n      ref={rootRef}\n      className=\"relative z-30 w-fit shrink-0\"\n      onPointerEnter={(event) => {\n        closeDelayRef.current?.kill();\n        if (event.pointerType === \"mouse\" && !disabled && models.length > 1)\n          setOpen(true);\n      }}\n      onPointerLeave={(event) => {\n        if (event.pointerType === \"mouse\") scheduleClose();\n      }}\n      onBlur={(event) => {\n        if (!event.currentTarget.contains(event.relatedTarget)) close();\n      }}\n    >\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        aria-label={`Model: ${value.name}`}\n        aria-haspopup=\"menu\"\n        aria-expanded={visible}\n        aria-controls={menuId}\n        disabled={disabled || models.length < 2}\n        onClick={() => {\n          closeDelayRef.current?.kill();\n          if (visible && pinnedRef.current) close();\n          else {\n            pinnedRef.current = true;\n            focusEdgeRef.current = \"selected\";\n            focusOnOpenRef.current = true;\n            setOpen(true);\n            if (visible) {\n              focusOnOpenRef.current = false;\n              focusMenu(\"selected\");\n            }\n          }\n        }}\n        onKeyDown={(event) => {\n          if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n            event.preventDefault();\n            closeDelayRef.current?.kill();\n            pinnedRef.current = true;\n            const edge = event.key === \"ArrowUp\" ? \"last\" : \"first\";\n            if (visible) focusMenu(edge);\n            else {\n              focusEdgeRef.current = edge;\n              focusOnOpenRef.current = true;\n              setOpen(true);\n            }\n          }\n        }}\n        className=\"flex min-h-10 w-36 max-w-full cursor-pointer items-center gap-2 rounded-full bg-[var(--pc-pill)] px-3.5 text-xs font-medium text-[var(--pc-muted)] outline-none hover:text-[var(--pc-ink)] focus-visible:ring-2 focus-visible:ring-[var(--pc-accent)] focus-visible:ring-offset-2 disabled:cursor-default disabled:opacity-60\"\n      >\n        <Flower className=\"size-3.5 shrink-0\" aria-hidden=\"true\" />\n        <span data-model-name className=\"truncate\">\n          {value.name}\n        </span>\n        <ChevronDown\n          data-model-chevron\n          className=\"size-3 shrink-0\"\n          aria-hidden=\"true\"\n        />\n      </button>\n      <div\n        ref={menuRef}\n        id={menuId}\n        role=\"menu\"\n        aria-label=\"Choose a model\"\n        inert={!visible}\n        aria-hidden={!visible}\n        onKeyDown={onKeyDown}\n        className=\"invisible absolute bottom-[calc(100%+8px)] left-0 flex w-56 origin-bottom-left flex-col gap-0.5 rounded-2xl border border-[var(--pc-border)] bg-[var(--pc-paper)] p-1.5 opacity-0 shadow-xl @min-[561px]/composer:top-[calc(100%+8px)] @min-[561px]/composer:bottom-auto @min-[561px]/composer:origin-top-left\"\n      >\n        <span\n          ref={highlightRef}\n          data-model-highlight\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute top-0 right-1.5 left-1.5 rounded-xl bg-[var(--pc-pill)] opacity-0\"\n        />\n        {models.map((model) => (\n          <button\n            key={model.id}\n            type=\"button\"\n            role=\"menuitemradio\"\n            data-model-option={model.id}\n            aria-checked={value.id === model.id}\n            tabIndex={visible && value.id === model.id ? 0 : -1}\n            onPointerEnter={(event) => {\n              if (event.pointerType === \"mouse\") setHovered(model.id);\n            }}\n            onFocus={() => setHovered(model.id)}\n            onClick={() => {\n              onChange(model);\n              close();\n              triggerRef.current?.focus({ preventScroll: true });\n            }}\n            className=\"relative z-10 flex min-h-12 cursor-pointer items-center justify-between gap-3 rounded-xl px-3 py-2 text-left outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[var(--pc-accent)]/40\"\n          >\n            <span className=\"flex min-w-0 flex-col gap-0.5\">\n              <span className=\"truncate text-xs font-medium\">{model.name}</span>\n              {model.description && (\n                <span className=\"truncate text-[11px] text-[var(--pc-muted)]\">\n                  {model.description}\n                </span>\n              )}\n            </span>\n            {value.id === model.id && (\n              <Check className=\"size-3.5 shrink-0\" aria-hidden=\"true\" />\n            )}\n          </button>\n        ))}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/prompt-composer-model.tsx"
    },
    {
      "name": "prompt-composer-recorder.tsx",
      "path": "src/registry/ui/prompt-composer-recorder.tsx",
      "code": "\"use client\";\n\nimport { useEffect, useEffectEvent, useRef, useState } from \"react\";\nimport { useGSAP } from \"@gsap/react\";\nimport gsap from \"gsap\";\nimport { LoaderCircle, Square, X } from \"lucide-react\";\nimport { useReducedMotion } from \"motion/react\";\n\ngsap.registerPlugin(useGSAP);\n\nexport type PromptTranscriber = (\n  audio: Blob,\n  signal: AbortSignal,\n) => Promise<string>;\n\nconst BAR_COUNT = 40;\nconst DEMO_TRANSCRIPT =\n  \"Remind me to send the design review notes to Sam at 5 pm\";\n\nexport function PromptComposerRecorder({\n  demo,\n  onTranscribe,\n  onComplete,\n  onCancel,\n  onError,\n}: {\n  demo: boolean;\n  onTranscribe?: PromptTranscriber;\n  onComplete: (text: string) => void;\n  onCancel: () => void;\n  onError: (message: string) => void;\n}) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const levelsRef = useRef<Uint8Array<ArrayBuffer> | null>(null);\n  const analyserRef = useRef<AnalyserNode | null>(null);\n  const stopRef = useRef<(() => void) | null>(null);\n  const [phase, setPhase] = useState<\n    \"requesting\" | \"recording\" | \"transcribing\"\n  >(demo ? \"recording\" : \"requesting\");\n  const [seconds, setSeconds] = useState(0);\n  const reduced = useReducedMotion();\n  const complete = useEffectEvent(onComplete);\n  const fail = useEffectEvent(onError);\n\n  useEffect(() => {\n    const controller = new AbortController();\n    let stream: MediaStream | null = null;\n    let recorder: MediaRecorder | null = null;\n    let audioContext: AudioContext | null = null;\n    let demoDelay: gsap.core.Tween | null = null;\n    let started = 0;\n    let lastSecond = 0;\n    const clock = () => {\n      const elapsed = Math.floor((performance.now() - started) / 1000);\n      if (elapsed !== lastSecond) {\n        lastSecond = elapsed;\n        setSeconds(elapsed);\n      }\n    };\n    const beginClock = () => {\n      started = performance.now();\n      gsap.ticker.add(clock);\n    };\n    const stopTracks = () => {\n      stream?.getTracks().forEach((track) => track.stop());\n      gsap.ticker.remove(clock);\n    };\n    const transcribe = async (blob: Blob) => {\n      if (controller.signal.aborted) return;\n      stopTracks();\n      try {\n        if (!onTranscribe) throw new Error(\"Missing transcription provider\");\n        const transcript = await onTranscribe(blob, controller.signal);\n        if (controller.signal.aborted) return;\n        if (!transcript.trim()) {\n          fail(\"No speech was detected. Please try again.\");\n          return;\n        }\n        complete(transcript.trim());\n      } catch {\n        if (!controller.signal.aborted)\n          fail(\"Transcription failed. Your draft is saved. Please try again.\");\n      }\n    };\n    if (demo) {\n      beginClock();\n      stopRef.current = () => {\n        gsap.ticker.remove(clock);\n        setPhase(\"transcribing\");\n        demoDelay = gsap.delayedCall(0.65, () => complete(DEMO_TRANSCRIPT));\n      };\n    } else {\n      const start = async () => {\n        try {\n          if (\n            !navigator.mediaDevices?.getUserMedia ||\n            typeof MediaRecorder === \"undefined\"\n          ) {\n            fail(\n              \"Voice recording is unavailable in this browser. Please type your message.\",\n            );\n            return;\n          }\n          const media = await navigator.mediaDevices.getUserMedia({\n            audio: true,\n          });\n          if (controller.signal.aborted) {\n            media.getTracks().forEach((track) => track.stop());\n            return;\n          }\n          stream = media;\n          audioContext = new AudioContext();\n          const analyser = audioContext.createAnalyser();\n          analyser.fftSize = 256;\n          audioContext.createMediaStreamSource(media).connect(analyser);\n          analyserRef.current = analyser;\n          levelsRef.current = new Uint8Array(analyser.frequencyBinCount);\n          recorder = new MediaRecorder(media);\n          const chunks: Blob[] = [];\n          recorder.ondataavailable = (event) => {\n            if (event.data.size > 0) chunks.push(event.data);\n          };\n          recorder.onstop = () => {\n            void transcribe(\n              new Blob(chunks, { type: recorder?.mimeType || \"audio/webm\" }),\n            );\n          };\n          recorder.onerror = () => {\n            stopTracks();\n            if (!controller.signal.aborted)\n              fail(\"Recording failed. Please try again.\");\n          };\n          recorder.start();\n          beginClock();\n          setPhase(\"recording\");\n          stopRef.current = () => {\n            if (recorder?.state !== \"recording\") return;\n            setPhase(\"transcribing\");\n            recorder.stop();\n          };\n        } catch (error) {\n          stopTracks();\n          if (controller.signal.aborted) return;\n          fail(\n            error instanceof DOMException && error.name === \"NotAllowedError\"\n              ? \"Microphone access was denied. You can still type your message.\"\n              : \"Could not start recording. Please check your microphone and try again.\",\n          );\n        }\n      };\n      void start();\n    }\n    return () => {\n      controller.abort();\n      demoDelay?.kill();\n      stopRef.current = null;\n      if (recorder && recorder.state !== \"inactive\") recorder.stop();\n      stopTracks();\n      analyserRef.current = null;\n      levelsRef.current = null;\n      if (audioContext && audioContext.state !== \"closed\")\n        void audioContext.close();\n    };\n  }, [demo, onTranscribe]);\n\n  useGSAP(\n    () => {\n      const bars = Array.from(\n        rootRef.current?.querySelectorAll<HTMLElement>(\"[data-voice-bar]\") ??\n          [],\n      );\n      if (reduced || phase !== \"recording\") {\n        gsap.set(bars, {\n          scaleY: (index) => 0.12 + Math.abs(Math.sin(index * 0.8)) * 0.3,\n        });\n        return;\n      }\n      const levels = Array.from({ length: BAR_COUNT }, () => 0.08);\n      let previous = 0;\n      const paint = () => {\n        const time = performance.now();\n        if (time - previous < 75) return;\n        previous = time;\n        let level = 0.08;\n        if (demo) {\n          level =\n            0.12 + Math.abs(Math.sin(time / 190) * Math.sin(time / 470)) * 0.85;\n        } else if (analyserRef.current && levelsRef.current) {\n          const data = levelsRef.current;\n          analyserRef.current.getByteFrequencyData(data);\n          level = Math.max(\n            0.06,\n            data.reduce((sum, sample) => sum + sample, 0) / data.length / 150,\n          );\n        }\n        levels.shift();\n        levels.push(Math.min(1, level));\n        bars.forEach((bar, index) => {\n          bar.style.transform = `scaleY(${levels[index]})`;\n        });\n      };\n      gsap.ticker.add(paint);\n      gsap.to(\"[data-recording-dot]\", {\n        opacity: 0.35,\n        duration: 0.6,\n        repeat: -1,\n        yoyo: true,\n        ease: \"sine.inOut\",\n      });\n      return () => gsap.ticker.remove(paint);\n    },\n    {\n      scope: rootRef,\n      dependencies: [phase, reduced, demo],\n      revertOnUpdate: true,\n    },\n  );\n\n  return (\n    <div\n      ref={rootRef}\n      className=\"flex h-full min-w-0 flex-1 items-center gap-3 pl-3 sm:pl-4\"\n      aria-busy={phase !== \"recording\"}\n    >\n      <span className=\"flex shrink-0 items-center gap-2 text-xs tabular-nums text-[var(--pc-muted)]\">\n        <span\n          data-recording-dot\n          className=\"size-2 rounded-full bg-red-400\"\n          aria-hidden=\"true\"\n        />\n        {Math.floor(seconds / 60)}:{String(seconds % 60).padStart(2, \"0\")}\n      </span>\n      <span className=\"sr-only\" role=\"status\">\n        {phase === \"requesting\"\n          ? \"Waiting for microphone permission\"\n          : phase === \"transcribing\"\n            ? \"Transcribing audio\"\n            : demo\n              ? \"Recording preview. The microphone is not active.\"\n              : \"Recording audio\"}\n      </span>\n      <span\n        className=\"flex h-7 min-w-0 flex-1 items-center justify-end gap-[3px] overflow-hidden text-[var(--pc-muted)]\"\n        aria-hidden=\"true\"\n      >\n        {phase === \"recording\" ? (\n          Array.from({ length: BAR_COUNT }, (_, index) => (\n            <span\n              key={index}\n              data-voice-bar\n              className=\"h-full w-0.5 shrink-0 origin-center rounded-full bg-current\"\n            />\n          ))\n        ) : (\n          <span className=\"truncate text-xs\">\n            {phase === \"requesting\" ? \"Connecting…\" : \"Transcribing…\"}\n          </span>\n        )}\n      </span>\n      <button\n        autoFocus\n        type=\"button\"\n        onClick={onCancel}\n        aria-label=\"Cancel recording\"\n        className=\"grid size-10 shrink-0 cursor-pointer place-items-center rounded-full outline-none hover:bg-[var(--pc-pill)] focus-visible:ring-2 focus-visible:ring-[var(--pc-accent)]\"\n      >\n        <X className=\"size-4\" />\n      </button>\n      <button\n        type=\"button\"\n        disabled={phase !== \"recording\"}\n        onClick={() => stopRef.current?.()}\n        aria-label=\"Stop recording\"\n        className=\"grid size-9 shrink-0 cursor-pointer place-items-center rounded-full bg-red-400/15 text-red-500 outline-none focus-visible:ring-2 focus-visible:ring-red-400 disabled:cursor-wait disabled:opacity-50\"\n      >\n        {phase === \"recording\" ? (\n          <Square className=\"size-3 fill-current\" />\n        ) : (\n          <LoaderCircle className=\"size-4 motion-safe:animate-spin\" />\n        )}\n      </button>\n    </div>\n  );\n}\n",
      "content": "\"use client\";\n\nimport { useEffect, useEffectEvent, useRef, useState } from \"react\";\nimport { useGSAP } from \"@gsap/react\";\nimport gsap from \"gsap\";\nimport { LoaderCircle, Square, X } from \"lucide-react\";\nimport { useReducedMotion } from \"motion/react\";\n\ngsap.registerPlugin(useGSAP);\n\nexport type PromptTranscriber = (\n  audio: Blob,\n  signal: AbortSignal,\n) => Promise<string>;\n\nconst BAR_COUNT = 40;\nconst DEMO_TRANSCRIPT =\n  \"Remind me to send the design review notes to Sam at 5 pm\";\n\nexport function PromptComposerRecorder({\n  demo,\n  onTranscribe,\n  onComplete,\n  onCancel,\n  onError,\n}: {\n  demo: boolean;\n  onTranscribe?: PromptTranscriber;\n  onComplete: (text: string) => void;\n  onCancel: () => void;\n  onError: (message: string) => void;\n}) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const levelsRef = useRef<Uint8Array<ArrayBuffer> | null>(null);\n  const analyserRef = useRef<AnalyserNode | null>(null);\n  const stopRef = useRef<(() => void) | null>(null);\n  const [phase, setPhase] = useState<\n    \"requesting\" | \"recording\" | \"transcribing\"\n  >(demo ? \"recording\" : \"requesting\");\n  const [seconds, setSeconds] = useState(0);\n  const reduced = useReducedMotion();\n  const complete = useEffectEvent(onComplete);\n  const fail = useEffectEvent(onError);\n\n  useEffect(() => {\n    const controller = new AbortController();\n    let stream: MediaStream | null = null;\n    let recorder: MediaRecorder | null = null;\n    let audioContext: AudioContext | null = null;\n    let demoDelay: gsap.core.Tween | null = null;\n    let started = 0;\n    let lastSecond = 0;\n    const clock = () => {\n      const elapsed = Math.floor((performance.now() - started) / 1000);\n      if (elapsed !== lastSecond) {\n        lastSecond = elapsed;\n        setSeconds(elapsed);\n      }\n    };\n    const beginClock = () => {\n      started = performance.now();\n      gsap.ticker.add(clock);\n    };\n    const stopTracks = () => {\n      stream?.getTracks().forEach((track) => track.stop());\n      gsap.ticker.remove(clock);\n    };\n    const transcribe = async (blob: Blob) => {\n      if (controller.signal.aborted) return;\n      stopTracks();\n      try {\n        if (!onTranscribe) throw new Error(\"Missing transcription provider\");\n        const transcript = await onTranscribe(blob, controller.signal);\n        if (controller.signal.aborted) return;\n        if (!transcript.trim()) {\n          fail(\"No speech was detected. Please try again.\");\n          return;\n        }\n        complete(transcript.trim());\n      } catch {\n        if (!controller.signal.aborted)\n          fail(\"Transcription failed. Your draft is saved. Please try again.\");\n      }\n    };\n    if (demo) {\n      beginClock();\n      stopRef.current = () => {\n        gsap.ticker.remove(clock);\n        setPhase(\"transcribing\");\n        demoDelay = gsap.delayedCall(0.65, () => complete(DEMO_TRANSCRIPT));\n      };\n    } else {\n      const start = async () => {\n        try {\n          if (\n            !navigator.mediaDevices?.getUserMedia ||\n            typeof MediaRecorder === \"undefined\"\n          ) {\n            fail(\n              \"Voice recording is unavailable in this browser. Please type your message.\",\n            );\n            return;\n          }\n          const media = await navigator.mediaDevices.getUserMedia({\n            audio: true,\n          });\n          if (controller.signal.aborted) {\n            media.getTracks().forEach((track) => track.stop());\n            return;\n          }\n          stream = media;\n          audioContext = new AudioContext();\n          const analyser = audioContext.createAnalyser();\n          analyser.fftSize = 256;\n          audioContext.createMediaStreamSource(media).connect(analyser);\n          analyserRef.current = analyser;\n          levelsRef.current = new Uint8Array(analyser.frequencyBinCount);\n          recorder = new MediaRecorder(media);\n          const chunks: Blob[] = [];\n          recorder.ondataavailable = (event) => {\n            if (event.data.size > 0) chunks.push(event.data);\n          };\n          recorder.onstop = () => {\n            void transcribe(\n              new Blob(chunks, { type: recorder?.mimeType || \"audio/webm\" }),\n            );\n          };\n          recorder.onerror = () => {\n            stopTracks();\n            if (!controller.signal.aborted)\n              fail(\"Recording failed. Please try again.\");\n          };\n          recorder.start();\n          beginClock();\n          setPhase(\"recording\");\n          stopRef.current = () => {\n            if (recorder?.state !== \"recording\") return;\n            setPhase(\"transcribing\");\n            recorder.stop();\n          };\n        } catch (error) {\n          stopTracks();\n          if (controller.signal.aborted) return;\n          fail(\n            error instanceof DOMException && error.name === \"NotAllowedError\"\n              ? \"Microphone access was denied. You can still type your message.\"\n              : \"Could not start recording. Please check your microphone and try again.\",\n          );\n        }\n      };\n      void start();\n    }\n    return () => {\n      controller.abort();\n      demoDelay?.kill();\n      stopRef.current = null;\n      if (recorder && recorder.state !== \"inactive\") recorder.stop();\n      stopTracks();\n      analyserRef.current = null;\n      levelsRef.current = null;\n      if (audioContext && audioContext.state !== \"closed\")\n        void audioContext.close();\n    };\n  }, [demo, onTranscribe]);\n\n  useGSAP(\n    () => {\n      const bars = Array.from(\n        rootRef.current?.querySelectorAll<HTMLElement>(\"[data-voice-bar]\") ??\n          [],\n      );\n      if (reduced || phase !== \"recording\") {\n        gsap.set(bars, {\n          scaleY: (index) => 0.12 + Math.abs(Math.sin(index * 0.8)) * 0.3,\n        });\n        return;\n      }\n      const levels = Array.from({ length: BAR_COUNT }, () => 0.08);\n      let previous = 0;\n      const paint = () => {\n        const time = performance.now();\n        if (time - previous < 75) return;\n        previous = time;\n        let level = 0.08;\n        if (demo) {\n          level =\n            0.12 + Math.abs(Math.sin(time / 190) * Math.sin(time / 470)) * 0.85;\n        } else if (analyserRef.current && levelsRef.current) {\n          const data = levelsRef.current;\n          analyserRef.current.getByteFrequencyData(data);\n          level = Math.max(\n            0.06,\n            data.reduce((sum, sample) => sum + sample, 0) / data.length / 150,\n          );\n        }\n        levels.shift();\n        levels.push(Math.min(1, level));\n        bars.forEach((bar, index) => {\n          bar.style.transform = `scaleY(${levels[index]})`;\n        });\n      };\n      gsap.ticker.add(paint);\n      gsap.to(\"[data-recording-dot]\", {\n        opacity: 0.35,\n        duration: 0.6,\n        repeat: -1,\n        yoyo: true,\n        ease: \"sine.inOut\",\n      });\n      return () => gsap.ticker.remove(paint);\n    },\n    {\n      scope: rootRef,\n      dependencies: [phase, reduced, demo],\n      revertOnUpdate: true,\n    },\n  );\n\n  return (\n    <div\n      ref={rootRef}\n      className=\"flex h-full min-w-0 flex-1 items-center gap-3 pl-3 sm:pl-4\"\n      aria-busy={phase !== \"recording\"}\n    >\n      <span className=\"flex shrink-0 items-center gap-2 text-xs tabular-nums text-[var(--pc-muted)]\">\n        <span\n          data-recording-dot\n          className=\"size-2 rounded-full bg-red-400\"\n          aria-hidden=\"true\"\n        />\n        {Math.floor(seconds / 60)}:{String(seconds % 60).padStart(2, \"0\")}\n      </span>\n      <span className=\"sr-only\" role=\"status\">\n        {phase === \"requesting\"\n          ? \"Waiting for microphone permission\"\n          : phase === \"transcribing\"\n            ? \"Transcribing audio\"\n            : demo\n              ? \"Recording preview. The microphone is not active.\"\n              : \"Recording audio\"}\n      </span>\n      <span\n        className=\"flex h-7 min-w-0 flex-1 items-center justify-end gap-[3px] overflow-hidden text-[var(--pc-muted)]\"\n        aria-hidden=\"true\"\n      >\n        {phase === \"recording\" ? (\n          Array.from({ length: BAR_COUNT }, (_, index) => (\n            <span\n              key={index}\n              data-voice-bar\n              className=\"h-full w-0.5 shrink-0 origin-center rounded-full bg-current\"\n            />\n          ))\n        ) : (\n          <span className=\"truncate text-xs\">\n            {phase === \"requesting\" ? \"Connecting…\" : \"Transcribing…\"}\n          </span>\n        )}\n      </span>\n      <button\n        autoFocus\n        type=\"button\"\n        onClick={onCancel}\n        aria-label=\"Cancel recording\"\n        className=\"grid size-10 shrink-0 cursor-pointer place-items-center rounded-full outline-none hover:bg-[var(--pc-pill)] focus-visible:ring-2 focus-visible:ring-[var(--pc-accent)]\"\n      >\n        <X className=\"size-4\" />\n      </button>\n      <button\n        type=\"button\"\n        disabled={phase !== \"recording\"}\n        onClick={() => stopRef.current?.()}\n        aria-label=\"Stop recording\"\n        className=\"grid size-9 shrink-0 cursor-pointer place-items-center rounded-full bg-red-400/15 text-red-500 outline-none focus-visible:ring-2 focus-visible:ring-red-400 disabled:cursor-wait disabled:opacity-50\"\n      >\n        {phase === \"recording\" ? (\n          <Square className=\"size-3 fill-current\" />\n        ) : (\n          <LoaderCircle className=\"size-4 motion-safe:animate-spin\" />\n        )}\n      </button>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/prompt-composer-recorder.tsx"
    }
  ]
}
