{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "prompt-bar",
  "type": "registry:component",
  "title": "Prompt bar",
  "description": "Composer with @ sources, / commands, model picker, and dictation.",
  "categories": [
    "ai-interface"
  ],
  "dependencies": [
    "glimm"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json"
  ],
  "files": [
    {
      "path": "src/registry/components/prompt-bar/prompt-bar.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { createShader, playSweep, accentChain, ACCENTS } from \"glimm\";\n\n/* The built-in \"prism\" palette is only cyan→indigo→magenta, so a sweep\n * reads as blue/purple. Build a true full-spectrum rainbow instead. */\nconst RAINBOW = accentChain([\n  ACCENTS.red,\n  ACCENTS.orange,\n  ACCENTS.yellow,\n  ACCENTS.green,\n  ACCENTS.cyan,\n  ACCENTS.blue,\n  ACCENTS.purple,\n]);\n\n/* ─────────────────────────────────────────────────────────\n * PROMPT BAR\n * A composer with real controls: attach, @ data sources,\n * / commands, a model picker, dictation, and send.\n * Type @ or / to open the menus; ↑↓ + Enter to pick.\n * Variants: Rounded (card radius) · Pill (full radius).\n * ───────────────────────────────────────────────────────── */\n\nfunction Icon({\n  children,\n  size = 15,\n  strokeWidth = 1.8,\n}: {\n  children: React.ReactNode;\n  size?: number;\n  strokeWidth?: number;\n}) {\n  return (\n    <svg\n      width={size}\n      height={size}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={strokeWidth}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n    >\n      {children}\n    </svg>\n  );\n}\n\nconst GLYPHS = {\n  clip: (\n    <path d=\"m21.4 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48\" />\n  ),\n  chart: <path d=\"M4 20V10M10 20V4M16 20v-7M22 20H2\" />,\n  layers: (\n    <g>\n      <path d=\"M12 2 2 7l10 5 10-5-10-5z\" />\n      <path d=\"M2 17l10 5 10-5M2 12l10 5 10-5\" />\n    </g>\n  ),\n  globe: (\n    <g>\n      <circle cx=\"12\" cy=\"12\" r=\"10\" />\n      <path d=\"M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z\" />\n    </g>\n  ),\n  file: (\n    <g>\n      <path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z\" />\n      <path d=\"M14 2v6h6M8 13h8M8 17h5\" />\n    </g>\n  ),\n  bell: (\n    <g>\n      <path d=\"M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9\" />\n      <path d=\"M10.3 21a1.94 1.94 0 0 0 3.4 0\" />\n    </g>\n  ),\n  send: <path d=\"M22 2 11 13M22 2l-7 20-4-9-9-4 20-7z\" />,\n};\n\ntype Source = {\n  key: string;\n  name: string;\n  desc: string;\n  glyph: keyof typeof GLYPHS;\n  attach?: boolean;\n  connect?: boolean;\n};\n\n/* an ErmisAI newsroom desk: the @ menu reaches its own wire, stories and drafts */\nconst SOURCES: Source[] = [\n  { key: \"attach\", name: \"Add photos & files\", desc: \"Upload from your computer\", glyph: \"clip\", attach: true },\n  { key: \"wire\", name: \"Wire\", desc: \"342 of 350 sources monitored\", glyph: \"globe\" },\n  { key: \"stories\", name: \"Stories\", desc: \"46 today, 7 in review\", glyph: \"layers\" },\n  { key: \"drafts\", name: \"Drafts\", desc: \"Localized drafts with sources\", glyph: \"file\" },\n  { key: \"signals\", name: \"Signals\", desc: \"Confidence and conflicts per story\", glyph: \"chart\" },\n  { key: \"rules\", name: \"Monitoring rules\", desc: \"Alerts on the topics you follow\", glyph: \"bell\" },\n  { key: \"delivery\", name: \"Delivery\", desc: \"Send approved stories\", glyph: \"send\", connect: true },\n];\n\nconst COMMANDS = [\n  { key: \"quick-brief\", name: \"/quick-brief\", desc: \"80–160 words\" },\n  { key: \"full-story\", name: \"/full-story\", desc: \"320–700 words\" },\n  { key: \"broadcast-script\", name: \"/broadcast-script\", desc: \"A story written to be read aloud\" },\n  { key: \"digest-block\", name: \"/digest-block\", desc: \"Roll stories into a digest\" },\n  { key: \"summarize\", name: \"/summarize\", desc: \"Sum up the sources so far\" },\n];\n\nconst MODELS = [\n  { key: \"large\", name: \"Large\", tag: \"Flagship\" },\n  { key: \"standard\", name: \"Standard\", tag: \"Default\" },\n  { key: \"small\", name: \"Small\", tag: \"Fast\" },\n];\n\nconst FILES = [\"ferry-routes.png\", \"strike-notice.pdf\", \"sources-export.csv\"];\nconst DICTATION = \"Draft a quick brief on the ferry strike\";\n\n/* self-running demo: walk the @ menu, then the / menu, and repeat.\n * Any pointer or key interaction hands control to the user. */\nconst AUTO_STEPS: {\n  draft: string;\n  active?: number;\n  connect?: boolean;\n  modelOpen?: boolean;\n  model?: string;\n  hold: number;\n}[] = [\n  { draft: \"\", connect: false, model: \"standard\", hold: 1100 },\n  { draft: \"@\", active: 0, hold: 900 },\n  { draft: \"@\", active: 1, hold: 620 },\n  { draft: \"@\", active: 4, hold: 620 },\n  { draft: \"@\", active: 6, hold: 700 },\n  { draft: \"@\", active: 6, connect: true, hold: 1000 },\n  { draft: \"\", hold: 700 },\n  { draft: \"/\", active: 0, hold: 900 },\n  { draft: \"/\", active: 1, hold: 620 },\n  { draft: \"/\", active: 3, hold: 1000 },\n  { draft: \"\", hold: 800 },\n  // open the model picker and upgrade to the flagship → rainbow sweep\n  { draft: \"\", modelOpen: true, hold: 1200 },\n  { draft: \"\", model: \"large\", hold: 2400 },\n  { draft: \"\", hold: 900 },\n];\n\n/* the last @word or /word being typed, if any */\nfunction parseToken(draft: string): { kind: \"at\" | \"slash\"; query: string; start: number } | null {\n  const match = /(^|\\s)([@/])([\\w-]*)$/.exec(draft);\n  if (!match) return null;\n  return {\n    kind: match[2] === \"@\" ? \"at\" : \"slash\",\n    query: match[3].toLowerCase(),\n    start: match.index + match[1].length,\n  };\n}\n\nexport default function PromptBar({\n  variant = \"Rounded\",\n  demo = true,\n  tall = false,\n  placeholder,\n  onSend,\n}: {\n  variant?: string;\n  /** the self-running walkthrough; turn off when embedding in a real surface */\n  demo?: boolean;\n  /** hero sizing: a multi-line input with controls on their own row */\n  tall?: boolean;\n  placeholder?: string;\n  onSend?: (text: string) => void;\n}) {\n  const pill = variant === \"Pill\";\n  const [draft, setDraft] = useState(\"\");\n  const [dismissed, setDismissed] = useState(false);\n  const [plusOpen, setPlusOpen] = useState(false);\n  const [modelOpen, setModelOpen] = useState(false);\n  const [model, setModel] = useState(MODELS[1]);\n  const [attachments, setAttachments] = useState<string[]>([]);\n  const [connected, setConnected] = useState(false);\n  const [active, setActive] = useState(0);\n  const [listening, setListening] = useState(false);\n  const [auto, setAuto] = useState(demo);\n  const [autoStep, setAutoStep] = useState(0);\n  const [expanded, setExpanded] = useState(false);\n  const wide = expanded || tall;\n  const [rowBox, setRowBox] = useState<{ top: number; height: number } | null>(null);\n  const [engaged, setEngaged] = useState(false);\n  const [modelBox, setModelBox] = useState<{ top: number; height: number } | null>(null);\n  const [modelHovered, setModelHovered] = useState<number | null>(null);\n  const [modelMenuLeft, setModelMenuLeft] = useState(0);\n  const [modelMenuBottom, setModelMenuBottom] = useState(0);\n  const composerAnchorRef = useRef<HTMLDivElement>(null);\n  const controlsRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLTextAreaElement>(null);\n  const measureRef = useRef<HTMLSpanElement>(null);\n  const modelRef = useRef<HTMLButtonElement>(null);\n  const rowRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const modelRowRefs = useRef<(HTMLButtonElement | null)[]>([]);\n  const glimmRef = useRef<HTMLCanvasElement>(null);\n  const shaderRef = useRef<ReturnType<typeof createShader> | null>(null);\n  const sweepingRef = useRef(false);\n\n  /* hand control to the user: stop the demo loop, and when they aim at\n   * the input itself, clear the demo's leftover draft for a clean start */\n  const takeOver = (event: { target: EventTarget | null }) => {\n    setAuto(false);\n    if (auto && event.target === inputRef.current) setDraft(\"\");\n  };\n\n  const token = dismissed ? null : parseToken(draft);\n  const menu: \"at\" | \"slash\" | null = plusOpen ? \"at\" : (token?.kind ?? null);\n  const query = plusOpen ? \"\" : (token?.query ?? \"\");\n\n  const rows: { key: string; name: string; desc: string }[] =\n    menu === \"at\"\n      ? SOURCES.filter((s) => s.name.toLowerCase().includes(query))\n      : menu === \"slash\"\n        ? COMMANDS.filter((c) => c.name.slice(1).startsWith(query))\n        : [];\n\n  useEffect(() => {\n    // oxlint-disable-next-line react/set-state-in-effect -- a new menu or query resets the highlighted row\n    setActive(0);\n    setEngaged(false);\n  }, [menu, query]);\n\n  /* a single highlight glides to the active row instead of each row\n   * toggling its own background — matches the gliding pill in the nav */\n  useLayoutEffect(() => {\n    const target = rowRefs.current[active];\n    if (target) setRowBox({ top: target.offsetTop, height: target.offsetHeight });\n  }, [menu, query, active, connected, rows.length]);\n\n  /* same gliding highlight in the model menu — floats to the hovered\n   * row, falling back to the currently-selected model */\n  const modelIndex = MODELS.findIndex((m) => m.key === model.key);\n  useLayoutEffect(() => {\n    if (!modelOpen) return;\n    const target = modelRowRefs.current[modelHovered ?? modelIndex];\n    if (target) setModelBox({ top: target.offsetTop, height: target.offsetHeight });\n  }, [modelOpen, modelHovered, modelIndex]);\n\n  /* The menu is outside the clipped composer, so align it to the model\n   * trigger by measurement instead of pinning it to the far-right edge. */\n  useLayoutEffect(() => {\n    if (!modelOpen || !composerAnchorRef.current || !modelRef.current) return;\n    const anchorRect = composerAnchorRef.current.getBoundingClientRect();\n    const triggerRect = modelRef.current.getBoundingClientRect();\n    setModelMenuLeft(Math.max(0, Math.min(triggerRect.left - anchorRect.left, anchorRect.width - 176)));\n    setModelMenuBottom(anchorRect.bottom - triggerRect.top + 8);\n  }, [modelOpen, wide, model.name]);\n\n  useEffect(() => {\n    // oxlint-disable-next-line react/set-state-in-effect -- closing the model menu clears its hover\n    if (!modelOpen) setModelHovered(null);\n  }, [modelOpen]);\n\n  /* Build the shader with a pinned hue phase. createShader seeds its\n   * internal hueShift from Math.random(), which made the sweep a different\n   * colour on every reload — pin it so the rainbow is identical each time. */\n  const makeShader = () => {\n    const canvas = glimmRef.current;\n    if (!canvas) return null;\n    const random = Math.random;\n    Math.random = () => 0;\n    try {\n      return createShader({\n        canvas,\n        palette: RAINBOW,\n        direction: \"ltr\",\n        bandTight: 10,\n        swellAmount: 0.85,\n      });\n    } finally {\n      Math.random = random;\n    }\n  };\n\n  /* Glimm shader lives inside the composer, invisible at rest. Selecting\n   * the flagship model fires a one-shot rainbow sweep across the interior. */\n  useEffect(() => {\n    shaderRef.current = makeShader();\n    return () => {\n      shaderRef.current?.destroy();\n      shaderRef.current = null;\n    };\n  }, []);\n\n  const celebrate = () => {\n    if (sweepingRef.current) return;\n    if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return;\n    // Recreate the shader per sweep so uTime restarts at 0 — the hue phase\n    // (which drifts with time) is then identical on every trigger.\n    shaderRef.current?.destroy();\n    const shader = makeShader();\n    shaderRef.current = shader;\n    if (!shader) return;\n    sweepingRef.current = true;\n    const sweep = playSweep(shader, {\n      palette: RAINBOW,\n      direction: \"ltr\",\n      sweepMs: 570,\n      outroMs: 80,\n      peakAlpha: 1.3,\n      bandTight: 10,\n      brightness: 1.4,\n      swellAmount: 1,\n      waveSpeed: 1.8,\n      easing: \"easeOutExpo\",\n    });\n    void sweep.done.finally(() => {\n      sweepingRef.current = false;\n    });\n  };\n\n  const selectModel = (next: (typeof MODELS)[number]) => {\n    setModel(next);\n    setModelOpen(false);\n    if (next.key === \"large\") celebrate();\n  };\n\n  /* autoplay: apply the current step, then advance after its hold */\n  useEffect(() => {\n    if (!auto) return;\n    const step = AUTO_STEPS[autoStep % AUTO_STEPS.length];\n    // oxlint-disable-next-line react/set-state-in-effect -- the autoplay demo applies one scripted step per tick\n    setDraft(step.draft);\n    if (step.active !== undefined) setActive(step.active);\n    if (step.connect !== undefined) setConnected(step.connect);\n    if (step.modelOpen !== undefined) setModelOpen(step.modelOpen);\n    if (step.model) {\n      const next = MODELS.find((m) => m.key === step.model);\n      if (next) selectModel(next);\n    }\n    const t = setTimeout(() => setAutoStep((s) => s + 1), step.hold);\n    return () => clearTimeout(t);\n  }, [auto, autoStep]);\n\n  /* dictation resolves after a beat, like a real transcript landing */\n  useEffect(() => {\n    if (!listening) return;\n    const t = setTimeout(() => {\n      setDraft((current) => (current ? `${current.trimEnd()} ${DICTATION}` : DICTATION));\n      setListening(false);\n      inputRef.current?.focus();\n    }, 2200);\n    return () => clearTimeout(t);\n  }, [listening]);\n\n  /* Move wrapped text above the controls, then grow to a compact maximum. */\n  useLayoutEffect(() => {\n    const input = inputRef.current;\n    const controls = controlsRef.current;\n    const measure = measureRef.current;\n    const modelButton = modelRef.current;\n    if (!input || !controls || !measure || !modelButton) return;\n\n    const fixedControlsWidth = 28 * 3 + modelButton.offsetWidth;\n    const inlineGaps = 4 * 4;\n    const inlineInputWidth = controls.clientWidth - fixedControlsWidth - inlineGaps;\n    const needsFullWidth = draft.includes(\"\\n\") || measure.offsetWidth + 8 > inlineInputWidth;\n    if (needsFullWidth !== expanded) {\n      setExpanded(needsFullWidth);\n    }\n\n    const minHeight = 28;\n    const maxHeight = 100;\n    input.style.height = \"0px\";\n    const contentHeight = input.scrollHeight;\n    input.style.height = `${Math.min(Math.max(contentHeight, minHeight), maxHeight)}px`;\n    input.style.overflowY = contentHeight > maxHeight ? \"auto\" : \"hidden\";\n  }, [draft, expanded]);\n\n  /* clicking anywhere outside the composer closes the open menus */\n  useEffect(() => {\n    if (!modelOpen && !plusOpen) return;\n    const close = (event: PointerEvent) => {\n      if (!(event.target as Element).closest(\"[data-promptbar]\")) {\n        setModelOpen(false);\n        setPlusOpen(false);\n      }\n    };\n    document.addEventListener(\"pointerdown\", close);\n    return () => document.removeEventListener(\"pointerdown\", close);\n  }, [modelOpen, plusOpen]);\n\n  const closeMenus = () => {\n    setPlusOpen(false);\n    setModelOpen(false);\n  };\n\n  const pick = (row: { key: string; name: string }) => {\n    const source = SOURCES.find((s) => s.key === row.key);\n    if (source?.attach) {\n      setAttachments((current) => [...current, FILES[current.length % FILES.length]]);\n      if (token) setDraft(draft.slice(0, token.start));\n    } else if (menu === \"at\") {\n      setDraft(`${token ? draft.slice(0, token.start) : draft}@${row.name} `);\n    } else {\n      setDraft(`${token ? draft.slice(0, token.start) : draft}${row.name} `);\n    }\n    setPlusOpen(false);\n    setDismissed(false);\n    inputRef.current?.focus();\n  };\n\n  const canSend = draft.trim().length > 0 || attachments.length > 0;\n  const send = () => {\n    if (!canSend) return;\n    onSend?.(draft.trim());\n    setDraft(\"\");\n    setAttachments([]);\n    closeMenus();\n  };\n\n  return (\n    <div\n      data-promptbar\n      className={demo ? \"flex min-h-[384px] w-full max-w-105 flex-col justify-end pb-8\" : \"w-full\"}\n      onPointerDownCapture={takeOver}\n      onKeyDownCapture={takeOver}\n    >\n      {/* composer is the anchor — menus grow up from its top edge */}\n      <div ref={composerAnchorRef} className=\"relative\">\n        {/* ── @ / slash menu ─────────────────────────────── */}\n        {menu && (\n          <div\n            onMouseLeave={() => setEngaged(false)}\n            className=\"absolute inset-x-0 bottom-full z-10 mb-2 origin-bottom animate-prompt-bar-menu-in rounded-[10px] bg-surface p-1 shadow-raised\"\n          >\n            {/* single gliding highlight — appears once a row is hovered */}\n            <span\n              aria-hidden\n              className=\"pointer-events-none absolute inset-x-1 rounded-[6px] bg-hover\"\n              style={{\n                top: rowBox?.top ?? 0,\n                height: rowBox?.height ?? 0,\n                opacity: rowBox && engaged && rows.length > 0 ? 1 : 0,\n                transition:\n                  \"top 220ms cubic-bezier(0.23,1,0.32,1), height 220ms cubic-bezier(0.23,1,0.32,1), opacity 150ms ease\",\n              }}\n            />\n            {rows.map((row, i) => {\n              const source = menu === \"at\" ? SOURCES.find((s) => s.key === row.key) : undefined;\n              /* the row picks; a source that needs connecting carries its own Connect button beside it */\n              return (\n                <div\n                  key={row.key}\n                  ref={(el) => {\n                    rowRefs.current[i] = el;\n                  }}\n                  onMouseEnter={() => {\n                    setActive(i);\n                    setEngaged(true);\n                  }}\n                  className=\"relative z-10 flex h-9 w-full items-center rounded-[6px]\"\n                >\n                  <button\n                    type=\"button\"\n                    onMouseDown={(event) => event.preventDefault()}\n                    onClick={() => pick(row)}\n                    className={`flex h-full min-w-0 flex-1 items-center gap-2.5 pl-2 text-left ${source?.connect ? \"pr-2.5\" : \"pr-2\"}`}\n                  >\n                    {source && (\n                      <span className=\"flex size-5.5 shrink-0 items-center justify-center text-ink-2\">\n                        <Icon size={15}>{GLYPHS[source.glyph]}</Icon>\n                      </span>\n                    )}\n                    <span className=\"shrink-0 text-[12.5px] font-medium text-ink\">{row.name}</span>\n                    <span className=\"min-w-0 flex-1 truncate text-[12px] text-ink-3\">{row.desc}</span>\n                  </button>\n                  {source?.connect && (\n                    <button\n                      type=\"button\"\n                      tabIndex={-1}\n                      onMouseDown={(event) => event.preventDefault()}\n                      onClick={() => setConnected((current) => !current)}\n                      className={`shrink-0 pr-2 text-[12px] font-medium transition-colors duration-100 ${\n                        connected ? \"text-green\" : \"text-accent-ink hover:underline\"\n                      }`}\n                    >\n                      {connected ? \"Connected\" : \"Connect\"}\n                    </button>\n                  )}\n                </div>\n              );\n            })}\n            {rows.length === 0 && (\n              <div className=\"flex h-9 items-center px-2 text-[12px] text-ink-3\">No matches for “{query}”</div>\n            )}\n            <div className=\"mt-1 border-t border-line px-2 pt-1.5 pb-1 text-[11px] text-ink-3\">\n              {menu === \"at\" ? \"Type to search sources & files\" : \"Type to search commands\"}\n            </div>\n          </div>\n        )}\n\n        {/* ── model menu ─────────────────────────────────── */}\n        {modelOpen && (\n          <div\n            onMouseLeave={() => setModelHovered(null)}\n            className=\"absolute z-10 w-44 origin-bottom-left animate-prompt-bar-menu-in rounded-[10px] bg-surface p-1 shadow-raised\"\n            style={{ left: modelMenuLeft, bottom: modelMenuBottom }}\n          >\n            {/* single gliding highlight — floats to the hovered / selected row */}\n            <span\n              aria-hidden\n              className=\"pointer-events-none absolute inset-x-1 rounded-[6px] bg-hover\"\n              style={{\n                top: modelBox?.top ?? 0,\n                height: modelBox?.height ?? 0,\n                opacity: modelBox && modelHovered !== null ? 1 : 0,\n                transition:\n                  \"top 220ms cubic-bezier(0.23,1,0.32,1), height 220ms cubic-bezier(0.23,1,0.32,1), opacity 150ms ease\",\n              }}\n            />\n            {MODELS.map((m, i) => (\n              <button\n                key={m.key}\n                type=\"button\"\n                ref={(el) => {\n                  modelRowRefs.current[i] = el;\n                }}\n                onMouseDown={(event) => event.preventDefault()}\n                onMouseEnter={() => setModelHovered(i)}\n                onClick={() => {\n                  selectModel(m);\n                  inputRef.current?.focus();\n                }}\n                className=\"relative z-10 flex h-7.5 w-full items-center gap-2 rounded-[6px] px-2 text-left\"\n              >\n                <span className=\"min-w-0 flex-1 truncate text-[12.5px] font-medium text-ink\">{m.name}</span>\n                <span className=\"shrink-0 text-[11px] text-ink-3\">{m.tag}</span>\n                <span className={`shrink-0 text-ink ${m.key === model.key ? \"\" : \"invisible\"}`}>\n                  <Icon size={13} strokeWidth={2.5}>\n                    <path d=\"M20 6L9 17l-5-5\" />\n                  </Icon>\n                </span>\n              </button>\n            ))}\n          </div>\n        )}\n\n        {/* ── composer ───────────────────────────────────── */}\n        <div\n          className={`relative isolate flex flex-col overflow-hidden border border-line bg-surface shadow-card transition-[border-color,border-radius] duration-150 focus-within:border-line-strong ${\n            tall ? \"gap-2.5 p-3.5\" : \"gap-1.5 p-1.5\"\n          } ${\n            pill\n              ? attachments.length > 0 || wide\n                ? \"rounded-[24px]\"\n                : \"rounded-full\"\n              : tall\n                ? \"rounded-[22px]\"\n                : \"rounded-[14px]\"\n          }`}\n        >\n          {/* rainbow glimm sweep — plays across the interior on model change.\n            explicit w/h: a <canvas> is a replaced element and won't stretch\n            to inset-0 alone, which feeds back into the shader's ResizeObserver. */}\n          <canvas\n            ref={glimmRef}\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute inset-0 -z-10 h-full w-full\"\n            style={{ borderRadius: \"inherit\" }}\n          />\n          <span\n            ref={measureRef}\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute invisible whitespace-pre text-[13px] leading-[18px]\"\n          >\n            {draft}\n          </span>\n\n          {attachments.length > 0 && (\n            <div className={`flex flex-wrap gap-1.5 pt-0.5 ${pill ? \"px-1\" : \"px-0.5\"}`}>\n              {attachments.map((file, i) => (\n                <span\n                  key={`${file}-${i}`}\n                  className={`flex h-6.5 animate-prompt-bar-chip-in items-center gap-1.5 bg-field py-1 pr-1 pl-1.5 text-[11.5px] text-ink-2 shadow-hairline ${\n                    pill ? \"rounded-full\" : \"rounded-chip\"\n                  }`}\n                >\n                  <Icon size={12}>\n                    <g>\n                      <path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z\" />\n                      <path d=\"M14 2v6h6\" />\n                    </g>\n                  </Icon>\n                  <span className=\"max-w-36 truncate\">{file}</span>\n                  <button\n                    type=\"button\"\n                    aria-label={`Remove ${file}`}\n                    onClick={() => setAttachments((current) => current.filter((_, j) => j !== i))}\n                    className=\"-my-1 flex size-6 items-center justify-center rounded-button text-ink-3 transition-colors duration-100 hover:bg-line/70 hover:text-ink\"\n                  >\n                    <Icon size={10} strokeWidth={2.5}>\n                      <path d=\"M18 6L6 18M6 6l12 12\" />\n                    </Icon>\n                  </button>\n                </span>\n              ))}\n            </div>\n          )}\n\n          <div\n            ref={controlsRef}\n            className={`grid items-end gap-x-1 gap-y-1.5 ${\n              wide ? \"grid-cols-[28px_auto_minmax(0,1fr)_28px_28px]\" : \"grid-cols-[28px_minmax(0,1fr)_auto_28px_28px]\"\n            }`}\n          >\n            <button\n              type=\"button\"\n              aria-label=\"Add attachments and sources\"\n              aria-expanded={plusOpen}\n              onClick={() => {\n                setModelOpen(false);\n                setPlusOpen((current) => !current);\n                inputRef.current?.focus();\n              }}\n              className={`flex size-7 shrink-0 items-center justify-center justify-self-start text-ink-3 transition-[background-color,color,transform] duration-150 hover:bg-hover hover:text-ink active:scale-[0.94] rounded-button ${plusOpen ? \"bg-hover text-ink\" : \"\"} ${wide ? \"col-start-1 row-start-2\" : \"col-start-1 row-start-1\"}`}\n            >\n              <Icon size={16} strokeWidth={2}>\n                <path d=\"M12 5v14M5 12h14\" />\n              </Icon>\n            </button>\n\n            <textarea\n              ref={inputRef}\n              rows={1}\n              value={draft}\n              onChange={(event) => {\n                setDraft(event.target.value);\n                setDismissed(false);\n                setPlusOpen(false);\n              }}\n              onKeyDown={(event) => {\n                if (menu && rows.length > 0) {\n                  if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n                    event.preventDefault();\n                    setEngaged(true);\n                    setActive((current) => (current + (event.key === \"ArrowDown\" ? 1 : rows.length - 1)) % rows.length);\n                    return;\n                  }\n                  if ((event.key === \"Enter\" && !event.shiftKey) || event.key === \"Tab\") {\n                    event.preventDefault();\n                    pick(rows[active]);\n                    return;\n                  }\n                }\n                if (event.key === \"Escape\") {\n                  setDismissed(true);\n                  closeMenus();\n                  return;\n                }\n                if (event.key === \"Enter\" && !event.shiftKey && !event.nativeEvent.isComposing) {\n                  event.preventDefault();\n                  send();\n                }\n              }}\n              placeholder={listening ? \"Listening…\" : (placeholder ?? \"Write a message…\")}\n              aria-label=\"Prompt\"\n              className={`${tall ? \"min-h-[68px] px-2 py-2 text-[14px] leading-5\" : \"min-h-7 px-1 py-[5px] text-[13px] leading-[18px]\"} min-w-0 w-full resize-none bg-transparent text-ink outline-none [overflow-wrap:anywhere] placeholder:text-ink-3 ${\n                wide ? \"col-span-full col-start-1 row-start-1\" : \"col-start-2 row-start-1\"\n              }`}\n            />\n\n            {/* model picker */}\n            <button\n              ref={modelRef}\n              type=\"button\"\n              aria-expanded={modelOpen}\n              aria-label=\"Choose model\"\n              onClick={() => {\n                setPlusOpen(false);\n                setModelOpen((current) => !current);\n              }}\n              className={`flex h-7 shrink-0 items-center gap-1 px-1.5 text-[12px] font-medium text-ink-2 transition-colors duration-150 hover:bg-hover hover:text-ink rounded-button ${wide ? \"col-start-2 row-start-2 justify-self-start\" : \"col-start-3 row-start-1\"}`}\n            >\n              {model.name}\n              <span className=\"text-ink-3\">\n                <Icon size={11} strokeWidth={2.4}>\n                  <path d=\"M6 9l6 6 6-6\" />\n                </Icon>\n              </span>\n            </button>\n\n            {/* dictation */}\n            <button\n              type=\"button\"\n              aria-label={listening ? \"Stop dictation\" : \"Start dictation\"}\n              aria-pressed={listening}\n              onClick={() => setListening((current) => !current)}\n              className={`flex size-7 shrink-0 items-center justify-center transition-[background-color,color,transform] duration-150 active:scale-[0.94] rounded-button ${listening ? \"bg-accent-tint text-accent-ink\" : \"text-ink-3 hover:bg-hover hover:text-ink\"} ${wide ? \"col-start-4 row-start-2\" : \"col-start-4 row-start-1\"}`}\n            >\n              {listening ? (\n                <span className=\"flex h-3.5 items-center gap-[2.5px]\">\n                  {[0, 1, 2].map((i) => (\n                    <span\n                      key={i}\n                      className=\"h-full w-[2.5px] animate-prompt-bar-eq rounded-full bg-current\"\n                      style={{ animationDelay: `${i * 150}ms` }}\n                    />\n                  ))}\n                </span>\n              ) : (\n                <Icon size={15} strokeWidth={2}>\n                  <g>\n                    <path d=\"M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z\" />\n                    <path d=\"M19 10v2a7 7 0 0 1-14 0v-2M12 19v3\" />\n                  </g>\n                </Icon>\n              )}\n            </button>\n\n            {/* send — tactile circle */}\n            <button\n              type=\"button\"\n              aria-label=\"Send\"\n              disabled={!canSend}\n              onClick={send}\n              className={`flex size-7 shrink-0 items-center justify-center transition-[background-color,color,transform] duration-200 enabled:active:scale-[0.94] rounded-button ${wide ? \"col-start-5 row-start-2\" : \"col-start-5 row-start-1\"}`}\n              style={{\n                background: canSend ? \"var(--ink)\" : \"var(--line-strong)\",\n                color: canSend ? \"var(--surface)\" : \"var(--ink-2)\",\n              }}\n            >\n              <Icon size={16} strokeWidth={2.4}>\n                <path d=\"M12 19V5M5 12l7-7 7 7\" />\n              </Icon>\n            </button>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/iris/prompt-bar/prompt-bar.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/prompt-bar",
  "meta": {
    "kind": "component",
    "group": "AI interface",
    "export": "PromptBar",
    "docs": "https://irisui.dev/components/prompt-bar"
  }
}
