{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"prompt-bar","title":"Prompt Bar","description":"Composer with @ sources, / commands, model picker, and dictation.","dependencies":["glimm@0.3.1"],"registryDependencies":["https://ward.so/r/foundation.json"],"files":[{"path":"registry/ward/PromptBar.tsx","content":"\"use client\";\n/*\n * MIT License\n *\n * Copyright (c) 2026 Shane Levine\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to deal\n * in the Software without restriction, including without limitation the rights\n * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n * copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n * SOFTWARE.\n */\n\nimport \"./styles/foundation.css\";\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({ children, size = 15, strokeWidth = 1.8 }: { children: React.ReactNode; size?: number; strokeWidth?: number }) {\n  return (\n    <svg width={size} height={size} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={strokeWidth} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n      {children}\n    </svg>\n  );\n}\n\nconst GLYPHS: Record<string, React.ReactNode> = {\n  clip: <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  chart: <path d=\"M4 20V10M10 20V4M16 20v-7M22 20H2\" />,\n  layers: <g><path d=\"M12 2 2 7l10 5 10-5-10-5z\" /><path d=\"M2 17l10 5 10-5M2 12l10 5 10-5\" /></g>,\n  globe: <g><circle cx=\"12\" cy=\"12\" r=\"10\" /><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\" /></g>,\n};\n\n/* real product marks, inline so the file stays self-contained */\nconst BRANDS: Record<string, React.ReactNode> = {\n  figma: (\n    <svg width=\"11\" height=\"16\" viewBox=\"0 0 38 57\" aria-hidden=\"true\">\n      <path d=\"M9.5 57A9.5 9.5 0 0 0 19 47.5V38H9.5a9.5 9.5 0 0 0 0 19z\" fill=\"#0ACF83\" />\n      <path d=\"M0 28.5A9.5 9.5 0 0 1 9.5 19H19v19H9.5A9.5 9.5 0 0 1 0 28.5z\" fill=\"#A259FF\" />\n      <path d=\"M0 9.5A9.5 9.5 0 0 1 9.5 0H19v19H9.5A9.5 9.5 0 0 1 0 9.5z\" fill=\"#F24E1E\" />\n      <path d=\"M19 0h9.5a9.5 9.5 0 1 1 0 19H19V0z\" fill=\"#FF7262\" />\n      <path d=\"M38 28.5a9.5 9.5 0 1 1-19 0 9.5 9.5 0 0 1 19 0z\" fill=\"#1ABCFE\" />\n    </svg>\n  ),\n  slack: (\n    <svg width=\"15\" height=\"15\" viewBox=\"0 0 127 127\" aria-hidden=\"true\">\n      <path d=\"M27.2 80c0 7.3-5.9 13.2-13.2 13.2C6.7 93.2.8 87.3.8 80c0-7.3 5.9-13.2 13.2-13.2h13.2V80zm6.6 0c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2v33c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V80z\" fill=\"#E01E5A\" />\n      <path d=\"M47 27.2c-7.3 0-13.2-5.9-13.2-13.2C33.8 6.7 39.7.8 47 .8c7.3 0 13.2 5.9 13.2 13.2v13.2H47zm0 6.7c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H13.9C6.6 60.3.7 54.4.7 47.1c0-7.3 5.9-13.2 13.2-13.2H47z\" fill=\"#36C5F0\" />\n      <path d=\"M99.9 47.1c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H99.9V47.1zm-6.6 0c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V13.9C66.9 6.6 72.8.7 80.1.7c7.3 0 13.2 5.9 13.2 13.2v33.2z\" fill=\"#2EB67D\" />\n      <path d=\"M80.1 99.8c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V99.8h13.2zm0-6.6c-7.3 0-13.2-5.9-13.2-13.2 0-7.3 5.9-13.2 13.2-13.2h33.1c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H80.1z\" fill=\"#ECB22E\" />\n    </svg>\n  ),\n  gmail: (\n    <svg width=\"15\" height=\"12\" viewBox=\"0 0 256 193\" aria-hidden=\"true\">\n      <path d=\"M58.182 192.05V93.14L27.507 65.077 0 49.504v125.091c0 9.658 7.825 17.455 17.455 17.455h40.727Z\" fill=\"#4285F4\" />\n      <path d=\"M197.818 192.05h40.727c9.659 0 17.455-7.826 17.455-17.455V49.505l-31.156 17.837-27.026 25.798v98.91Z\" fill=\"#34A853\" />\n      <path d=\"m58.182 93.14-4.174-38.647 4.174-36.989L128 69.868l69.818-52.364 4.669 34.992-4.669 40.644L128 145.504 58.182 93.14Z\" fill=\"#EA4335\" />\n      <path d=\"M197.818 17.504V93.14L256 49.504V26.231c0-21.585-24.64-33.89-41.89-20.945l-16.292 12.218Z\" fill=\"#FBBC04\" />\n      <path d=\"m0 49.504 26.759 20.07L58.182 93.14V17.504L41.89 5.286C24.61-7.66 0 4.646 0 26.23v23.273Z\" fill=\"#C5221F\" />\n    </svg>\n  ),\n};\n\ntype Source = {\n  key: string;\n  name: string;\n  desc: string;\n  glyph?: string;\n  brand?: string;\n  attach?: boolean;\n  connect?: boolean;\n};\n\nconst SOURCES: Source[] = [\n  { key: \"attach\", name: \"Add photos & files\", desc: \"Upload from your computer\", glyph: \"clip\", attach: true },\n  { key: \"work\", name: \"Work Data\", desc: \"Sales & plan metrics\", glyph: \"chart\" },\n  { key: \"projects\", name: \"Project records\", desc: \"26 makers, tags, links\", glyph: \"layers\" },\n  { key: \"web\", name: \"Web search\", desc: \"Real-time news and info\", glyph: \"globe\" },\n  { key: \"figma\", name: \"Figma\", desc: \"Design-to-code workflows\", brand: \"figma\" },\n  { key: \"slack\", name: \"Slack\", desc: \"Read and manage Slack\", brand: \"slack\" },\n  { key: \"gmail\", name: \"Gmail\", desc: \"Read and manage Gmail\", brand: \"gmail\", connect: true },\n];\n\nconst COMMANDS = [\n  { key: \"compare\", name: \"/compare\", desc: \"Project vs. last summer\" },\n  { key: \"plan-plan\", name: \"/plan-plan\", desc: \"Draft a plan schedule\" },\n  { key: \"restock\", name: \"/restock\", desc: \"Build a reorder list\" },\n  { key: \"draft-email\", name: \"/draft-email\", desc: \"Write a supplier email\" },\n  { key: \"summarize\", name: \"/summarize\", desc: \"Digest the thread so far\" },\n];\n\nconst MODELS = [\n  { key: \"sprinkles-5\", name: \"Sprinkles 5\", tag: \"Flagship\" },\n  { key: \"vanilla-1\", name: \"Vanilla 1\", tag: \"Basic\" },\n  { key: \"freezer-burn\", name: \"Freezer Burn 0.4\", tag: \"Stale\" },\n];\n\nconst FILES = [\"project-chart.png\", \"summer-menu.pdf\", \"pos-export.csv\"];\nconst DICTATION = \"Compare pistachio weekends to last summer\";\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: \"vanilla-1\", 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: \"sprinkles-5\", 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<(HTMLButtonElement | 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    const timer = setTimeout(() => { setActive(0); setEngaged(false); }, 0);\n    return () => clearTimeout(timer);\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    if (modelOpen) return;\n    const timer = setTimeout(() => setModelHovered(null), 0);\n    return () => clearTimeout(timer);\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    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 === \"sprinkles-5\") 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    let holdTimer: ReturnType<typeof setTimeout> | undefined;\n    const startTimer = setTimeout(() => {\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      holdTimer = setTimeout(() => setAutoStep((s) => s + 1), step.hold);\n    }, 0);\n    return () => { clearTimeout(startTimer); clearTimeout(holdTimer); };\n    // Autoplay advances only when its step changes; selectModel is an event helper.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\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 rounded-[10px] bg-card p-1 shadow-raised\"\n          style={{ animation: \"pop-in 180ms cubic-bezier(0.23,1,0.32,1) both\", transformOrigin: \"bottom center\" }}\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            return (\n              <button\n                key={row.key}\n                type=\"button\"\n                ref={(el) => {\n                  rowRefs.current[i] = el;\n                }}\n                onMouseDown={(event) => event.preventDefault()}\n                onMouseEnter={() => {\n                  setActive(i);\n                  setEngaged(true);\n                }}\n                onClick={() => pick(row)}\n                className=\"relative z-10 flex h-9 w-full items-center gap-2.5 rounded-[6px] px-2 text-left\"\n              >\n                {source && (\n                  <span className=\"flex size-5.5 shrink-0 items-center justify-center text-ink-2\">\n                    {source.brand ? BRANDS[source.brand] : <Icon size={15}>{GLYPHS[source.glyph ?? \"clip\"]}</Icon>}\n                  </span>\n                )}\n                <span className=\"shrink-0 text-[12.5px] font-medium text-ink\">\n                  {row.name}\n                </span>\n                <span className=\"min-w-0 flex-1 truncate text-[12px] text-ink-3\">{row.desc}</span>\n                {source?.connect && (\n                  <span\n                    role=\"button\"\n                    tabIndex={-1}\n                    onClick={(event) => {\n                      event.stopPropagation();\n                      setConnected((current) => !current);\n                    }}\n                    className={`shrink-0 text-[12px] font-medium transition-colors duration-100 ${\n                      connected ? \"text-green\" : \"text-highlight-ink hover:underline\"\n                    }`}\n                  >\n                    {connected ? \"Connected\" : \"Connect\"}\n                  </span>\n                )}\n              </button>\n            );\n          })}\n          {rows.length === 0 && (\n            <div className=\"flex h-9 items-center px-2 text-[12px] text-ink-3\">\n              No matches for “{query}”\n            </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 rounded-[10px] bg-card p-1 shadow-raised\"\n          style={{ left: modelMenuLeft, bottom: modelMenuBottom, animation: \"pop-in 180ms cubic-bezier(0.23,1,0.32,1) both\", transformOrigin: \"bottom left\" }}\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}><path d=\"M20 6L9 17l-5-5\" /></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-card 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 ? (attachments.length > 0 || wide ? \"rounded-[24px]\" : \"rounded-full\") : tall ? \"rounded-[22px]\" : \"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 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                style={{ animation: \"pop-in 200ms cubic-bezier(0.23,1,0.32,1) both\" }}\n              >\n                <Icon size={12}><g><path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z\" /><path d=\"M14 2v6h6\" /></g></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 text-ink-3 transition-colors duration-100 hover:bg-line/70 hover:text-ink ${\n                    pill ? \"rounded-full\" : \"rounded-[5px]\"\n                  }`}\n                >\n                  <Icon size={10} strokeWidth={2.5}><path d=\"M18 6L6 18M6 6l12 12\" /></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\n              ? \"grid-cols-[28px_auto_minmax(0,1fr)_28px_28px]\"\n              : \"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] ${\n              pill ? \"rounded-full\" : \"rounded-[8px]\"\n            } ${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}><path d=\"M12 5v14M5 12h14\" /></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 ${\n              pill ? \"rounded-full\" : \"rounded-[8px]\"\n            } ${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}><path d=\"M6 9l6 6 6-6\" /></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] ${\n              pill ? \"rounded-full\" : \"rounded-[8px]\"\n            } ${listening ? \"bg-highlight-tint text-highlight-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=\"w-[2.5px] rounded-full bg-current\"\n                    style={{ height: \"100%\", animation: `eq-bounce 900ms ease-in-out ${i * 150}ms infinite` }}\n                  />\n                ))}\n              </span>\n            ) : (\n              <Icon size={15} strokeWidth={2}><g><path d=\"M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z\" /><path d=\"M19 10v2a7 7 0 0 1-14 0v-2M12 19v3\" /></g></Icon>\n            )}\n          </button>\n\n          {/* send — tactile square (round in the pill variant) */}\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] ${\n              pill ? \"rounded-full\" : \"rounded-[8px]\"\n            } ${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(--card)\" : \"var(--ink-2)\",\n            }}\n          >\n            <Icon size={16} strokeWidth={2.4}><path d=\"M12 19V5M5 12l7-7 7 7\" /></Icon>\n          </button>\n        </div>\n      </div>\n      </div>\n    </div>\n  );\n}\n","type":"registry:component","target":"components/ward/PromptBar.tsx"}],"meta":{"variants":["Rounded","Pill"],"version":"1.1.0","source":"https://github.com/slev12397/beautiful-ui/blob/44a274e598395ab61e7c96c26fda2758780253b7/components/primitives/PromptBar.tsx","access":"free"},"categories":["ai"],"type":"registry:component"}