{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chat-input",
  "type": "registry:block",
  "title": "Chat Composer",
  "description": "An AI chat composer that grows with your text on a spring, takes files by clip, drop or paste as chips with upload rings, and picks a model from a keyboard-complete menu. Send morphs into a stop button while the reply generates.",
  "docs": "Chat Composer was added to public/vividsites/chat-input.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/chat-input",
  "categories": [
    "ui",
    "ai-interfaces"
  ],
  "files": [
    {
      "path": "components/vividsites/chat-input.html",
      "type": "registry:file",
      "target": "public/vividsites/chat-input.html",
      "content": "<!--\nvs-chat-input: an AI chat composer. The box grows with the text on a spring (up to\n  --vs-chat-input-lines, then scrolls), takes files from the paperclip, drag-and-drop or\n  paste as chips with an upload ring, has a keyboard-complete model picker, and the send\n  button morphs into a stop button while the reply is generating.\n\n  Markup: a <form class=\"vs-chat-input\"> holding\n    textarea.vs-chat-input-text                  the prompt (placeholder is yours)\n    button.vs-chat-input-attach                  optional; opens the file picker\n    ul.vs-chat-input-menu[role=listbox] > li[role=option][data-value]   the models;\n      the first text is the name, <small> is the one-line descriptor, aria-selected=\"true\" = default\n    button.vs-chat-input-send                    send / stop\n  Attributes on the form: data-theme=\"dark\" · data-accept=\".pdf,image/*\" · data-max-files=\"10\"\n    data-auto-busy=\"false\" (don't switch to stop on submit) · data-esc-stops=\"false\"\n\n  Keys: Enter sends, Shift+Enter is a new line (touch keyboards: Enter is a new line),\n    Backspace in an empty box removes the last file, Esc stops while busy.\n    Model menu: arrows, Home/End, type-ahead, Enter/Space, Esc.\n\n  JS: el.vsChatInput (or vsChatInput.mount(el)) →\n    setBusy(bool)            show stop (true) / send (false); call setBusy(false) when your stream ends\n    setProgress(id, 0..1)    drive a file chip's upload ring; send stays disabled until every file hits 1\n    addFiles(FileList|File[]) → ids, removeFile(id), clear(), submit(), stop(), focus(), setDisabled(bool)\n    value, model, files, busy  (value and model are settable)\n  Events (bubble from the form):\n    vs-chat-input:submit  {text, files:[{id,file,name,size,type}], model}  cancelable; if not\n                          prevented the box clears and turns busy\n    vs-chat-input:stop    the user pressed stop (the button returns to send by itself)\n    vs-chat-input:files   {added:[{id,file}]}  start your uploads here and report setProgress\n    vs-chat-input:remove  {id,file} · vs-chat-input:model {model}\n  Variables: --vs-chat-input-accent (send fill), -bg, -ink, -dim, -radius, -width, -chip, -lines, -font.\n  Reduced motion: springs snap, the busy ring turns slowly.\n-->\n<link rel=\"preconnect\" href=\"https://api.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=general-sans@400,500&display=swap\" rel=\"stylesheet\">\n<form class=\"vs-chat-input\" aria-label=\"Message composer\">\n  <textarea class=\"vs-chat-input-text\" rows=\"1\" name=\"message\" placeholder=\"Ask anything, or drop a file…\" aria-label=\"Message\"></textarea>\n  <div class=\"vs-chat-input-bar\">\n    <button class=\"vs-chat-input-attach\" type=\"button\" aria-label=\"Attach files\"></button>\n    <div class=\"vs-chat-input-model\">\n      <button class=\"vs-chat-input-model-btn\" type=\"button\" aria-haspopup=\"listbox\"></button>\n      <ul class=\"vs-chat-input-menu\" role=\"listbox\" aria-label=\"Model\" hidden>\n        <li role=\"option\" data-value=\"loom-3\" aria-selected=\"true\">Loom 3<small>Balanced for everyday work</small></li>\n        <li role=\"option\" data-value=\"loom-3-deep\">Loom 3 Deep<small>Thinks longer on hard problems</small></li>\n        <li role=\"option\" data-value=\"atlas-mini\">Atlas Mini<small>Fastest, for quick replies</small></li>\n        <li role=\"option\" data-value=\"atlas-vision\">Atlas Vision<small>Reads images, charts and scans</small></li>\n      </ul>\n    </div>\n    <button class=\"vs-chat-input-send\" type=\"submit\" aria-label=\"Send message\"></button>\n  </div>\n</form>\n\n<style>\n.vs-chat-input {\n  --vs-chat-input-bg: #fffefb;                    /* composer surface */\n  --vs-chat-input-ink: #1d1b17;                   /* text, icons, stop button */\n  --vs-chat-input-dim: rgba(29,27,23,.46);        /* placeholder, meta */\n  --vs-chat-input-line: rgba(29,27,23,.11);       /* hairlines */\n  --vs-chat-input-well: rgba(29,27,23,.045);      /* chips, hovers */\n  --vs-chat-input-pop: #fffefc;                   /* model menu */\n  --vs-chat-input-accent: #0e7c7b;                /* send button fill */\n  --vs-chat-input-accent-ink: #fff;               /* arrow on the fill */\n  --vs-chat-input-radius: 22px;\n  --vs-chat-input-width: 720px;\n  --vs-chat-input-chip: 212px;                    /* width of a file chip */\n  --vs-chat-input-lines: 8;                       /* grows to this many lines, then scrolls */\n  --vs-chat-input-font: \"General Sans\", ui-sans-serif, -apple-system, system-ui, \"Segoe UI\", sans-serif;\n  --vs-chat-input-shadow:\n    0 0 0 .5px rgba(40,32,20,.16),\n    0 1px 0 rgba(255,255,255,.95) inset,\n    0 1px 2px rgba(40,32,20,.05),\n    0 10px 24px -10px rgba(40,32,20,.14),\n    0 34px 64px -34px rgba(40,32,20,.22);\n  --vs-chat-input-shadow-focus:\n    0 0 0 .5px rgba(40,32,20,.26),\n    0 1px 0 rgba(255,255,255,.95) inset,\n    0 1px 2px rgba(40,32,20,.06),\n    0 14px 30px -12px rgba(40,32,20,.18),\n    0 40px 80px -36px rgba(40,32,20,.28);\n\n  position: relative; box-sizing: border-box; width: 100%; max-width: var(--vs-chat-input-width); margin: 0;\n  display: flex; flex-direction: column;\n  font-family: var(--vs-chat-input-font); color: var(--vs-chat-input-ink);\n  background: var(--vs-chat-input-bg); border-radius: var(--vs-chat-input-radius);\n  box-shadow: var(--vs-chat-input-shadow);\n  transition: box-shadow .35s cubic-bezier(.16,1,.3,1), opacity .2s;\n  -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;\n  -webkit-tap-highlight-color: transparent;\n}\n.vs-chat-input[data-theme=\"dark\"] {\n  --vs-chat-input-bg: #201f1d; --vs-chat-input-ink: #f3f1ec; --vs-chat-input-dim: rgba(243,241,236,.44);\n  --vs-chat-input-line: rgba(243,241,236,.1); --vs-chat-input-well: rgba(243,241,236,.06); --vs-chat-input-pop: #262522;\n  --vs-chat-input-shadow: 0 0 0 .5px rgba(255,255,255,.12), 0 1px 0 rgba(255,255,255,.06) inset, 0 10px 24px -10px rgba(0,0,0,.5), 0 34px 64px -30px rgba(0,0,0,.7);\n  --vs-chat-input-shadow-focus: 0 0 0 .5px rgba(255,255,255,.22), 0 1px 0 rgba(255,255,255,.07) inset, 0 14px 30px -12px rgba(0,0,0,.55), 0 40px 80px -34px rgba(0,0,0,.75);\n}\n.vs-chat-input *, .vs-chat-input *::before, .vs-chat-input *::after { box-sizing: border-box; }\n.vs-chat-input:focus-within { box-shadow: var(--vs-chat-input-shadow-focus); }\n.vs-chat-input[data-disabled] { opacity: .55; pointer-events: none; }\n\n/* the growing part: its height follows the content on a spring, anchored to the bottom */\n.vs-chat-input-grow { position: relative; display: flex; flex-direction: column; justify-content: flex-end; overflow: hidden; overflow: clip; border-radius: var(--vs-chat-input-radius) var(--vs-chat-input-radius) 0 0; }\n.vs-chat-input-inner { flex: none; }\n.vs-chat-input-text {\n  display: block; width: 100%; margin: 0; border: 0; outline: none; resize: none; background: none; color: inherit;\n  padding: 16px 20px 6px; min-height: 46px; overflow-y: hidden;\n  font: 400 16px/24px var(--vs-chat-input-font); letter-spacing: -.003em;\n  caret-color: var(--vs-chat-input-accent);\n  scrollbar-width: thin; scrollbar-color: var(--vs-chat-input-line) transparent;\n}\n.vs-chat-input[data-overflow] .vs-chat-input-text { -webkit-mask-image: linear-gradient(transparent 4px, #000 22px, #000 calc(100% - 8px), transparent); mask-image: linear-gradient(transparent 4px, #000 22px, #000 calc(100% - 8px), transparent); }\n.vs-chat-input-text::placeholder { color: var(--vs-chat-input-dim); opacity: 1; }\n\n/* attachments */\n.vs-chat-input-files { display: flex; flex-wrap: wrap; row-gap: 8px; padding: 12px 12px 2px; }\n.vs-chat-input-files:empty { display: none; }\n.vs-chat-input-chip { position: relative; flex: none; height: 58px; margin-right: 8px; overflow: hidden; border-radius: calc(var(--vs-chat-input-radius) * .6); will-change: transform, opacity; transform-origin: 0 50%; }\n.vs-chat-input-chip-in {\n  position: relative; display: flex; align-items: center; gap: 10px; height: 100%; width: var(--vs-chat-input-chip); padding: 0 10px;\n  background: var(--vs-chat-input-well); border-radius: inherit; box-shadow: 0 0 0 .5px var(--vs-chat-input-line) inset;\n}\n.vs-chat-input-chip[data-kind=\"image\"] .vs-chat-input-chip-in { width: 58px; padding: 0; }\n.vs-chat-input-thumb {\n  position: relative; flex: none; width: 38px; height: 38px; border-radius: 10px; display: grid; place-items: center; overflow: hidden;\n  background: var(--t, #77726a); color: #fff; font: 600 9.5px/1 var(--vs-chat-input-font); letter-spacing: .06em;\n  box-shadow: 0 1px 0 rgba(255,255,255,.22) inset, 0 0 0 .5px rgba(0,0,0,.08);\n}\n.vs-chat-input-chip[data-kind=\"image\"] .vs-chat-input-thumb { width: 100%; height: 100%; border-radius: inherit; background: var(--vs-chat-input-well); }\n.vs-chat-input-thumb b { font: inherit; }\n.vs-chat-input-thumb img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }\n.vs-chat-input-meta { min-width: 0; flex: 1; display: grid; gap: 3px; padding-right: 22px; }\n.vs-chat-input-name { font-size: 13px; font-weight: 500; line-height: 16px; white-space: nowrap; overflow: hidden; }\n.vs-chat-input-size { font-size: 11.5px; line-height: 14px; color: var(--vs-chat-input-dim); font-variant-numeric: tabular-nums; white-space: nowrap; }\n/* upload ring */\n.vs-chat-input-ring { position: absolute; inset: 0; display: grid; place-items: center; background: rgba(20,18,14,.42); opacity: 0; pointer-events: none; }\n.vs-chat-input-ring svg { width: 22px; height: 22px; transform: rotate(-90deg); overflow: visible; }\n.vs-chat-input-ring circle { fill: none; stroke-width: 2.4; stroke-linecap: round; }\n.vs-chat-input-ring circle:first-child { stroke: rgba(255,255,255,.28); }\n.vs-chat-input-ring circle + circle { stroke: #fff; }\n/* remove */\n.vs-chat-input-x {\n  all: unset; position: absolute; top: 6px; right: 6px; width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center; cursor: pointer;\n  color: var(--vs-chat-input-bg); background: var(--vs-chat-input-ink); opacity: 0; transform: scale(.7);\n  transition: opacity .18s, transform .3s cubic-bezier(.3,1.5,.5,1);\n  box-shadow: 0 0 0 1.5px var(--vs-chat-input-bg);\n}\n.vs-chat-input-x svg { width: 10px; height: 10px; }\n.vs-chat-input-chip:hover .vs-chat-input-x, .vs-chat-input-x:focus-visible { opacity: 1; transform: none; }\n.vs-chat-input-x:focus-visible { box-shadow: 0 0 0 1.5px var(--vs-chat-input-bg), 0 0 0 3px var(--vs-chat-input-ink); }\n.vs-chat-input-x:active { transform: scale(.88); }\n@media (hover: none) { .vs-chat-input-x { opacity: 1; transform: none; } }\n\n/* bottom bar */\n.vs-chat-input-bar { display: flex; align-items: center; gap: 2px; padding: 6px 10px 10px 10px; }\n.vs-chat-input-attach, .vs-chat-input-model-btn {\n  all: unset; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; color: var(--vs-chat-input-ink);\n  height: 36px; border-radius: 999px; transition: background-color .18s, color .18s, transform .25s cubic-bezier(.3,1.5,.5,1);\n}\n.vs-chat-input-attach { width: 36px; color: color-mix(in srgb, var(--vs-chat-input-ink) 72%, transparent); }\n.vs-chat-input-attach svg { width: 19px; height: 19px; }\n.vs-chat-input-model-btn { gap: 5px; padding: 0 10px 0 12px; font-size: 13.5px; font-weight: 500; letter-spacing: -.005em; color: color-mix(in srgb, var(--vs-chat-input-ink) 78%, transparent); }\n.vs-chat-input-model-btn svg { width: 14px; height: 14px; opacity: .6; transition: transform .4s cubic-bezier(.16,1,.3,1); }\n.vs-chat-input-model-btn[aria-expanded=\"true\"] svg { transform: rotate(180deg); }\n.vs-chat-input-attach:hover, .vs-chat-input-model-btn:hover, .vs-chat-input-model-btn[aria-expanded=\"true\"] { background: var(--vs-chat-input-well); color: var(--vs-chat-input-ink); }\n.vs-chat-input-attach:active, .vs-chat-input-model-btn:active { transform: scale(.94); }\n.vs-chat-input-attach:focus-visible, .vs-chat-input-model-btn:focus-visible { box-shadow: 0 0 0 2px var(--vs-chat-input-ink) inset; }\n.vs-chat-input-model { position: relative; }\n\n/* send / stop */\n.vs-chat-input-send {\n  all: unset; position: relative; flex: none; width: 36px; height: 36px; margin-left: auto; border-radius: 50%; cursor: pointer; display: grid; place-items: center;\n  color: var(--vs-chat-input-accent-ink); transition: transform .3s cubic-bezier(.3,1.5,.5,1);\n}\n.vs-chat-input-send:disabled { cursor: default; }\n.vs-chat-input-send:not(:disabled):hover .vs-chat-input-fill { filter: brightness(1.06) saturate(1.05); }\n.vs-chat-input-send:not(:disabled):active { transform: scale(.9); }\n.vs-chat-input-send:focus-visible { box-shadow: 0 0 0 2px var(--vs-chat-input-bg), 0 0 0 3.5px var(--vs-chat-input-ink); }\n.vs-chat-input-send > span { position: absolute; inset: 0; border-radius: inherit; }\n.vs-chat-input-base { background: color-mix(in srgb, var(--vs-chat-input-ink) 8%, transparent); }\n.vs-chat-input-fill {\n  background: var(--vs-chat-input-accent); opacity: 0;\n  box-shadow: 0 1px 0 rgba(255,255,255,.28) inset, 0 -1px 0 rgba(0,0,0,.08) inset, 0 2px 6px -1px color-mix(in srgb, var(--vs-chat-input-accent) 55%, transparent), 0 0 0 .5px rgba(0,0,0,.08);\n}\n.vs-chat-input-stopbg { background: var(--vs-chat-input-ink); opacity: 0; box-shadow: 0 1px 0 rgba(255,255,255,.14) inset, 0 2px 6px -1px rgba(0,0,0,.25); }\n.vs-chat-input-send svg { position: relative; width: 18px; height: 18px; overflow: visible; }\n.vs-chat-input-arrow { position: absolute; width: 18px; height: 18px; color: color-mix(in srgb, var(--vs-chat-input-ink) 34%, transparent); transition: color .2s; }\n.vs-chat-input-send:not(:disabled) .vs-chat-input-arrow, .vs-chat-input[data-busy] .vs-chat-input-arrow { color: var(--vs-chat-input-accent-ink); }\n.vs-chat-input-square { position: absolute; width: 11px; height: 11px; border-radius: 2.5px; background: var(--vs-chat-input-bg); opacity: 0; }\n.vs-chat-input-spin { position: absolute; inset: -4px; width: 44px !important; height: 44px !important; opacity: 0; pointer-events: none; }\n.vs-chat-input-spin circle { fill: none; stroke-width: 2; }\n.vs-chat-input-spin circle:first-child { stroke: color-mix(in srgb, var(--vs-chat-input-ink) 12%, transparent); }\n.vs-chat-input-spin circle + circle { stroke: var(--vs-chat-input-accent); stroke-linecap: round; stroke-dasharray: 30 99; transform-origin: 22px 22px; animation: vs-chat-input-spin .95s linear infinite paused; }\n.vs-chat-input[data-busy] .vs-chat-input-spin circle + circle { animation-play-state: running; }\n@keyframes vs-chat-input-spin { to { transform: rotate(360deg); } }\n\n/* model menu */\n.vs-chat-input-menu {\n  position: absolute; left: 0; bottom: calc(100% + 10px); z-index: 20; width: 272px; margin: 0; padding: 6px; list-style: none; outline: none;\n  background: var(--vs-chat-input-pop); border-radius: 16px; will-change: transform, opacity;\n  box-shadow: 0 0 0 .5px var(--vs-chat-input-line), 0 1px 0 rgba(255,255,255,.7) inset, 0 18px 40px -14px rgba(30,24,14,.3), 0 4px 10px -4px rgba(30,24,14,.1);\n}\n.vs-chat-input[data-theme=\"dark\"] .vs-chat-input-menu { box-shadow: 0 0 0 .5px rgba(255,255,255,.12), 0 1px 0 rgba(255,255,255,.06) inset, 0 20px 44px -12px rgba(0,0,0,.65); }\n.vs-chat-input-menu[data-below] { bottom: auto; top: calc(100% + 10px); }\n.vs-chat-input-menu[hidden] { display: none; }\n.vs-chat-input-hl { position: absolute; left: 6px; right: 6px; top: 0; border-radius: 11px; background: var(--vs-chat-input-well); pointer-events: none; will-change: transform; }\n.vs-chat-input-menu [role=\"option\"] {\n  position: relative; display: grid; grid-template-columns: 1fr 18px; align-items: center; column-gap: 12px; row-gap: 2px;\n  padding: 9px 12px 10px; border-radius: 11px; cursor: pointer; font-size: 14px; font-weight: 500; line-height: 18px; letter-spacing: -.005em;\n}\n.vs-chat-input-menu [role=\"option\"] small { grid-column: 1; font-size: 12.5px; font-weight: 400; line-height: 16px; color: var(--vs-chat-input-dim); letter-spacing: 0; }\n.vs-chat-input-menu [role=\"option\"] > svg { grid-column: 2; grid-row: 1 / span 2; width: 16px; height: 16px; opacity: 0; transform: scale(.6); transition: opacity .2s, transform .35s cubic-bezier(.3,1.5,.5,1); }\n.vs-chat-input-menu [role=\"option\"][aria-selected=\"true\"] > svg { opacity: 1; transform: none; }\n.vs-chat-input-menu-label { padding: 8px 12px 6px; font-size: 11.5px; font-weight: 500; color: var(--vs-chat-input-dim); }\n\n/* drop zone */\n.vs-chat-input-drop {\n  position: absolute; inset: 0; z-index: 10; display: grid; place-items: center; pointer-events: none; opacity: 0;\n  border-radius: inherit; background: color-mix(in srgb, var(--vs-chat-input-bg) 88%, transparent);\n  -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px);\n}\n.vs-chat-input-drop::before { content: \"\"; position: absolute; inset: 6px; border-radius: calc(var(--vs-chat-input-radius) - 6px); border: 1.5px dashed color-mix(in srgb, var(--vs-chat-input-ink) 30%, transparent); }\n.vs-chat-input-drop div { display: flex; align-items: center; gap: 10px; font-size: 14px; font-weight: 500; }\n.vs-chat-input-drop svg { width: 20px; height: 20px; }\n.vs-chat-input-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }\n\n@media (max-width: 520px) {\n  .vs-chat-input { --vs-chat-input-chip: 186px; }\n  .vs-chat-input-text { padding: 14px 16px 4px; }\n  .vs-chat-input-bar { padding: 4px 8px 8px; }\n  .vs-chat-input-menu { width: min(272px, calc(100vw - 48px)); }\n}\n@media (prefers-reduced-motion: reduce) {\n  .vs-chat-input, .vs-chat-input * { transition: none !important; }\n  .vs-chat-input-spin circle { animation-duration: 3s; }\n}\n</style>\n\n<script>\n(() => {\n  if (window.vsChatInput) return;\n  const P = 'vs-chat-input';\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  const coarse = matchMedia('(pointer: coarse)');\n  const svg = (d, w = 1.8) => `<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"${w}\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">${d}</svg>`;\n  const I = {\n    clip: svg('<path d=\"m20.5 11.3-8.6 8.6a5.6 5.6 0 0 1-7.9-7.9l8.6-8.6a3.75 3.75 0 0 1 5.3 5.3l-8.6 8.6a1.9 1.9 0 0 1-2.7-2.7l7.9-7.9\"/>'),\n    chev: svg('<path d=\"m6 9 6 6 6-6\"/>', 2),\n    check: svg('<path d=\"M20 6 9 17l-5-5\"/>', 2.2),\n    x: svg('<path d=\"M17 7 7 17M7 7l10 10\"/>', 3),\n    arrow: svg('<path d=\"M12 19V5.5M5.5 12 12 5.5 18.5 12\"/>', 2.2),\n    tray: svg('<path d=\"M12 14.5V3.5M7.5 8 12 3.5 16.5 8\"/><path d=\"M20.5 14.5v3a3 3 0 0 1-3 3h-11a3 3 0 0 1-3-3v-3\"/>'),\n  };\n  const esc = s => String(s).replace(/[&<>\"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;' }[c]));\n  const clamp = (v, a, b) => Math.max(a, Math.min(b, v));\n  const size = b => b < 1024 ? b + ' B' : b < 1048576 ? Math.max(1, Math.round(b / 1024)) + ' KB' : (b / 1048576).toFixed(b < 10485760 ? 1 : 0) + ' MB';\n  const KINDS = [\n    [/^(pdf)$/, 'PDF', '#d8483f'], [/^(csv|tsv|xlsx?|numbers)$/, null, '#2c9163'], [/^(docx?|rtf|pages|odt)$/, 'DOC', '#3a67c9'],\n    [/^(md|txt|log)$/, null, '#6f6a61'], [/^(js|jsx|ts|tsx|py|rb|go|rs|java|json|html|css|sh|sql|ya?ml|swift|kt|c|cpp|h)$/, null, '#35302a'],\n    [/^(zip|gz|tar|rar|7z)$/, 'ZIP', '#b0772a'], [/^(mp3|wav|m4a|flac|ogg)$/, 'AUD', '#8a4fb5'], [/^(mp4|mov|webm|mkv)$/, 'VID', '#c2416b'],\n  ];\n  function kindOf(f) {\n    const ext = (f.name.includes('.') ? f.name.split('.').pop() : '').toLowerCase();\n    if (/^image\\//.test(f.type) || /^(png|jpe?g|gif|webp|avif|heic)$/.test(ext)) return { kind: 'image' };\n    for (const [re, lab, tone] of KINDS) if (re.test(ext)) return { kind: 'file', label: lab || ext.toUpperCase().slice(0, 4), tone };\n    return { kind: 'file', label: (ext || 'file').toUpperCase().slice(0, 4), tone: '#77726a' };\n  }\n  // \"quarterly-revenue-forecast-v2.pdf\" -> \"quarterly-rev…cast-v2.pdf\", measured in the real font\n  const ctx2d = document.createElement('canvas').getContext('2d');\n  function midTrunc(name, max, font) {\n    ctx2d.font = font; const fits = t => ctx2d.measureText(t).width <= max;\n    if (!max || fits(name)) return name;\n    const dot = name.lastIndexOf('.'), ext = dot > 0 && name.length - dot <= 7 ? name.slice(dot) : '', base = ext ? name.slice(0, dot) : name;\n    const cut = k => base.slice(0, Math.ceil(k * .58)) + '…' + base.slice(base.length - Math.floor(k * .42)) + ext;\n    let lo = 1, hi = base.length - 1;\n    while (lo < hi) { const m = (lo + hi + 1) >> 1; if (fits(cut(m))) lo = m; else hi = m - 1; }\n    return cut(lo);\n  }\n\n  // a tiny semi-implicit spring; interruptible because it keeps its velocity when the goal moves\n  class Spring {\n    constructor(x, k = 420, c = 36, eps = .001) { this.x = x; this.v = 0; this.g = x; this.k = k; this.c = c; this.eps = eps; }\n    step(dt) {\n      if (reduce.matches) { this.x = this.g; this.v = 0; return false; }\n      const n = Math.max(1, Math.ceil(dt * 240)), h = dt / n;\n      for (let i = 0; i < n; i++) { this.v += (-this.k * (this.x - this.g) - this.c * this.v) * h; this.x += this.v * h; }\n      if (Math.abs(this.v) < this.eps * 12 && Math.abs(this.x - this.g) < this.eps) { this.x = this.g; this.v = 0; return false; }\n      return true;\n    }\n    snap(v) { this.x = this.g = v; this.v = 0; }\n  }\n\n  function mount(root) {\n    if (root.vsChatInput) return root.vsChatInput;\n    const uid = Math.random().toString(36).slice(2, 7);\n    const $ = s => root.querySelector('.' + P + '-' + s);\n    const ta = $('text'), bar = $('bar'), attach = $('attach'), send = $('send'), mBtn = $('model-btn'), menu = $('menu');\n    // build the parts the markup leaves out\n    const grow = document.createElement('div'); grow.className = P + '-grow';\n    const inner = document.createElement('div'); inner.className = P + '-inner';\n    const list = document.createElement('div'); list.className = P + '-files'; list.setAttribute('role', 'list'); list.setAttribute('aria-label', 'Attachments');\n    ta.before(grow); grow.append(inner); inner.append(list, ta);\n    const picker = document.createElement('input'); picker.type = 'file'; picker.multiple = true; picker.hidden = true; picker.tabIndex = -1;\n    if (root.dataset.accept) picker.accept = root.dataset.accept;\n    root.append(picker);\n    if (attach && !attach.innerHTML.trim()) attach.innerHTML = I.clip;\n    send.innerHTML = `<span class=\"${P}-base\"></span><span class=\"${P}-fill\"></span><span class=\"${P}-stopbg\"></span>\n      <svg class=\"${P}-spin\" viewBox=\"0 0 44 44\" aria-hidden=\"true\"><circle cx=\"22\" cy=\"22\" r=\"20.5\"/><circle cx=\"22\" cy=\"22\" r=\"20.5\"/></svg>\n      <i class=\"${P}-arrow\">${I.arrow}</i><i class=\"${P}-square\"></i>`;\n    const fill = $('fill'), stopbg = $('stopbg'), spin = $('spin'), arrow = $('arrow'), square = $('square');\n    const drop = document.createElement('div'); drop.className = P + '-drop'; drop.setAttribute('aria-hidden', 'true');\n    drop.innerHTML = `<div>${I.tray}<span>${esc(root.dataset.dropLabel || 'Drop to attach')}</span></div>`;\n    const sr = document.createElement('span'); sr.className = P + '-sr'; sr.setAttribute('aria-live', 'polite');\n    root.append(drop, sr);\n    const say = t => { sr.textContent = ''; setTimeout(() => { sr.textContent = t; }, 40); };\n\n    // ---- one rAF loop per composer, only alive while something moves\n    const anims = new Set(); let raf = 0, last = 0;\n    const tick = now => {\n      const dt = Math.min(.05, (now - last) / 1000 || .016); last = now;\n      for (const a of anims) if (!a(dt)) anims.delete(a);\n      raf = anims.size ? requestAnimationFrame(tick) : 0;\n    };\n    const wake = a => { anims.add(a); if (!raf) { last = performance.now(); raf = requestAnimationFrame(tick); } };\n\n    // ---- height: textarea sized instantly, the wrapper follows on a spring\n    const H = new Spring(0, 520, 46, .1);\n    const drawH = () => { grow.style.height = H.x.toFixed(2) + 'px'; };\n    const animH = dt => { const a = H.step(dt); drawH(); if (!a) grow.style.height = ''; return a; };\n    let first = true;\n    const measure = () => {\n      const h = inner.offsetHeight;\n      if (first || !root.isConnected || !root.getClientRects().length) { H.snap(h); first = false; grow.style.height = ''; return; }\n      if (Math.abs(h - H.g) < .5 && !anims.has(animH)) return;\n      if (!anims.has(animH)) { H.x = H.g; H.v = 0; }\n      H.g = h; drawH(); wake(animH);\n    };\n    new ResizeObserver(measure).observe(inner);\n    const fit = () => {\n      const cs = getComputedStyle(ta), lh = parseFloat(cs.lineHeight) || 24, pad = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);\n      const lines = parseFloat(getComputedStyle(root).getPropertyValue('--vs-chat-input-lines')) || 8, max = lh * lines + pad;\n      const st = ta.scrollTop; ta.style.height = '0px';\n      const sh = ta.scrollHeight; ta.style.height = Math.min(sh, max) + 'px'; const over = sh > max + 1; ta.style.overflowY = over ? 'auto' : 'hidden'; root.toggleAttribute('data-overflow', over); ta.scrollTop = st;\n    };\n    grow.addEventListener('scroll', () => { grow.scrollTop = 0; });\n\n    // ---- files\n    const files = new Map(); let seq = 0;\n    const maxFiles = +root.dataset.maxFiles || 10;\n    const accepts = f => {\n      const acc = (root.dataset.accept || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);\n      if (!acc.length) return true;\n      const n = f.name.toLowerCase(), t = (f.type || '').toLowerCase();\n      return acc.some(a => a.startsWith('.') ? n.endsWith(a) : a.endsWith('/*') ? t.startsWith(a.slice(0, -1)) : t === a);\n    };\n    function chipAnim(c) {\n      return dt => {\n        const a = c.e.step(dt), b = c.prog.step(dt), v = c.vis.step(dt);\n        const e = Math.max(0, c.e.x);\n        c.el.style.width = (c.w * Math.min(e, 1.03)).toFixed(2) + 'px';\n        c.el.style.marginRight = (8 * Math.min(1, e)).toFixed(2) + 'px';\n        c.el.style.opacity = clamp(e * 1.4 - .25, 0, 1).toFixed(3);\n        c.el.style.transform = `scale(${(.82 + .18 * Math.min(e, 1.06)).toFixed(4)})`;\n        c.arc.style.strokeDashoffset = (C * (1 - clamp(c.prog.x, 0, 1))).toFixed(2);\n        c.ring.style.opacity = clamp(c.vis.x, 0, 1).toFixed(3); if (c.lab) c.lab.style.opacity = (1 - clamp(c.vis.x, 0, 1)).toFixed(3);\n        if (c.uploading) c.sizeEl.textContent = 'Uploading · ' + Math.round(clamp(c.prog.x, 0, 1) * 100) + '%';\n        if (c.uploading && c.target >= 1 && c.prog.x > .995) { c.uploading = false; c.vis.g = 0; c.sizeEl.textContent = size(c.size); update(); return true; }\n        if (c.removing && e <= .002 && !a) { c.el.remove(); if (c.url) URL.revokeObjectURL(c.url); files.delete(c.id); update(); return false; }\n        const busy = a || b || v;\n        if (!busy) { c.el.style.width = c.el.style.transform = c.el.style.opacity = ''; c.el.style.marginRight = ''; }\n        return busy;\n      };\n    }\n    const C = 2 * Math.PI * 9;\n    function addFiles(fileList) {\n      const ids = [];\n      for (const file of [...(fileList || [])]) {\n        if (files.size >= maxFiles || !accepts(file)) continue;\n        const id = 'f' + (++seq) + '-' + uid, k = kindOf(file);\n        const el = document.createElement('div'); el.className = P + '-chip'; el.dataset.kind = k.kind; el.setAttribute('role', 'listitem');\n        let url = '';\n        if (k.kind === 'image') { url = URL.createObjectURL(file); }\n        el.innerHTML = `<div class=\"${P}-chip-in\" title=\"${esc(file.name)}\">\n            <div class=\"${P}-thumb\" style=\"--t:${k.tone || ''}\">${url ? `<img src=\"${url}\" alt=\"\">` : `<b>${esc(k.label)}</b>`}\n              <div class=\"${P}-ring\"><svg viewBox=\"0 0 22 22\"><circle cx=\"11\" cy=\"11\" r=\"9\"/><circle cx=\"11\" cy=\"11\" r=\"9\" stroke-dasharray=\"${C.toFixed(2)}\" stroke-dashoffset=\"${C.toFixed(2)}\"/></svg></div></div>\n            ${k.kind === 'image' ? '' : `<div class=\"${P}-meta\"><span class=\"${P}-name\"></span><span class=\"${P}-size\">${size(file.size)}</span></div>`}\n            <button class=\"${P}-x\" type=\"button\" aria-label=\"Remove ${esc(file.name)}\">${I.x}</button></div>`;\n        list.append(el);\n        const c = { id, file, name: file.name, size: file.size, type: file.type, el, url, w: el.firstElementChild.offsetWidth,\n          e: new Spring(0, 380, 30, .001), prog: new Spring(0, 140, 24, .0005), vis: new Spring(0, 300, 34, .002), target: 1, uploading: false, removing: false,\n          ring: el.querySelector('.' + P + '-ring'), arc: el.querySelector('circle + circle'), sizeEl: el.querySelector('.' + P + '-size') || { textContent: '' }, lab: el.querySelector('.' + P + '-thumb b') };\n        const nameEl = el.querySelector('.' + P + '-name');\n        c.trunc = () => { if (nameEl) nameEl.textContent = midTrunc(file.name, nameEl.clientWidth, getComputedStyle(nameEl).font); };\n        c.trunc();\n        c.el.style.width = '0px'; c.el.style.opacity = '0';\n        c.e.g = 1; c.anim = chipAnim(c); files.set(id, c); wake(c.anim);\n        el.querySelector('.' + P + '-x').addEventListener('click', () => { removeFile(id); ta.focus({ preventScroll: true }); });\n        ids.push(id);\n      }\n      if (ids.length) {\n        update(); say(ids.length === 1 ? 'Attached ' + files.get(ids[0]).name : 'Attached ' + ids.length + ' files');\n        root.dispatchEvent(new CustomEvent(P + ':files', { bubbles: true, detail: { added: ids.map(id => ({ id, file: files.get(id).file })) } }));\n      }\n      return ids;\n    }\n    function removeFile(id) {\n      const c = files.get(id); if (!c || c.removing) return;\n      c.removing = true; c.w = c.el.firstElementChild.offsetWidth; c.e.g = 0; c.el.setAttribute('aria-hidden', 'true'); wake(c.anim);\n      say('Removed ' + c.name);\n      root.dispatchEvent(new CustomEvent(P + ':remove', { bubbles: true, detail: { id, file: c.file } }));\n      update();\n    }\n    function setProgress(id, p) {\n      const c = files.get(id); if (!c || c.removing) return;\n      p = clamp(+p || 0, 0, 1); c.target = p;\n      if (p < 1 && !c.uploading) { c.uploading = true; c.vis.g = 1; }\n      c.prog.g = p; wake(c.anim); update();\n    }\n    const live = () => [...files.values()].filter(c => !c.removing);\n\n    // ---- send / stop morph\n    let busy = false, disabled = false;\n    const M = new Spring(0, 520, 34, .001), EN = new Spring(0, 520, 40, .001);\n    const drawSend = () => {\n      const m = M.x, en = clamp(EN.x, 0, 1.1);\n      fill.style.opacity = clamp(en * (1 - m), 0, 1).toFixed(3);\n      fill.style.transform = `scale(${(.86 + .14 * en).toFixed(4)})`;\n      stopbg.style.opacity = clamp(m, 0, 1).toFixed(3);\n      arrow.style.opacity = clamp(1 - m * 1.6, 0, 1).toFixed(3);\n      arrow.style.transform = `translateY(${(-7 * m).toFixed(2)}px) scale(${(1 - .35 * clamp(m, 0, 1)).toFixed(4)})`;\n      square.style.opacity = clamp(m * 1.4 - .2, 0, 1).toFixed(3);\n      square.style.transform = `scale(${(.2 + .8 * m).toFixed(4)}) rotate(${(90 * (1 - m)).toFixed(2)}deg)`;\n      spin.style.opacity = clamp(m * 1.2 - .2, 0, 1).toFixed(3);\n      spin.style.transform = `scale(${(.8 + .2 * m).toFixed(4)})`;\n    };\n    const animSend = dt => { const a = M.step(dt), b = EN.step(dt); drawSend(); return a || b; };\n    function canSend() { return !disabled && !busy && (ta.value.trim().length > 0 || live().length > 0) && !live().some(c => c.uploading); }\n    function update() {\n      const ok = canSend();\n      send.disabled = disabled || (!busy && !ok);\n      send.setAttribute('aria-label', busy ? 'Stop generating' : 'Send message');\n      const en = busy || ok ? 1 : 0;\n      if (EN.g !== en) { EN.g = en; wake(animSend); }\n    }\n    function setBusy(b) {\n      busy = !!b; root.toggleAttribute('data-busy', busy); M.g = busy ? 1 : 0; wake(animSend); update();\n    }\n    function submit() {\n      if (!canSend()) return false;\n      const detail = { text: ta.value, files: live().map(c => ({ id: c.id, file: c.file, name: c.name, size: c.size, type: c.type })), model: api.model };\n      const ev = new CustomEvent(P + ':submit', { bubbles: true, cancelable: true, detail });\n      root.dispatchEvent(ev);\n      if (ev.defaultPrevented) return false;\n      clear(); if (root.dataset.autoBusy !== 'false') setBusy(true);\n      say('Message sent'); return true;\n    }\n    function stop() {\n      root.dispatchEvent(new CustomEvent(P + ':stop', { bubbles: true }));\n      setBusy(false); say('Stopped'); ta.focus({ preventScroll: true });\n    }\n    function clear() {\n      ta.value = ''; fit(); for (const id of [...files.keys()]) removeFile(id); update();\n    }\n    root.addEventListener('submit', e => { e.preventDefault(); busy ? stop() : submit(); });\n    send.addEventListener('click', e => { e.preventDefault(); busy ? stop() : submit(); });\n\n    // ---- textarea\n    ta.addEventListener('input', () => { fit(); update(); });\n    ta.addEventListener('keydown', e => {\n      if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229 && !coarse.matches) {\n        e.preventDefault(); if (!busy) submit(); return;\n      }\n      if (e.key === 'Backspace' && !ta.value && files.size) { const l = live().pop(); if (l) { e.preventDefault(); removeFile(l.id); } }\n      if (e.key === 'Escape' && busy && root.dataset.escStops !== 'false') { e.preventDefault(); stop(); }\n    });\n    ta.addEventListener('paste', e => {\n      const fs = e.clipboardData && e.clipboardData.files;\n      if (fs && fs.length) { if (!e.clipboardData.getData('text')) e.preventDefault(); addFiles(fs); }\n    });\n\n    // ---- attach + drag and drop\n    attach && attach.addEventListener('click', () => picker.click());\n    picker.addEventListener('change', () => { addFiles(picker.files); picker.value = ''; ta.focus({ preventScroll: true }); });\n    const D = new Spring(0, 520, 40, .001);\n    const animD = dt => { const a = D.step(dt); drop.style.opacity = clamp(D.x, 0, 1).toFixed(3); drop.style.transform = `scale(${(1.015 - .015 * D.x).toFixed(4)})`; return a; };\n    let depth = 0;\n    const hasFiles = e => e.dataTransfer && [...(e.dataTransfer.types || [])].includes('Files');\n    root.addEventListener('dragenter', e => { if (!hasFiles(e)) return; e.preventDefault(); if (++depth === 1) { D.g = 1; wake(animD); root.setAttribute('data-dragging', ''); } });\n    root.addEventListener('dragover', e => { if (!hasFiles(e)) return; e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; });\n    root.addEventListener('dragleave', e => { if (!hasFiles(e)) return; if (--depth <= 0) { depth = 0; D.g = 0; wake(animD); root.removeAttribute('data-dragging'); } });\n    root.addEventListener('drop', e => {\n      if (!hasFiles(e)) return; e.preventDefault(); depth = 0; D.g = 0; wake(animD); root.removeAttribute('data-dragging');\n      addFiles(e.dataTransfer.files); ta.focus({ preventScroll: true });\n    });\n\n    // ---- model picker (listbox popover)\n    const opts = menu ? [...menu.querySelectorAll('[role=\"option\"]')] : [];\n    let model = '', active = 0, open = false;\n    const O = new Spring(0, 600, 38, .001), HY = new Spring(0, 700, 48, .05);\n    let hl;\n    const label = o => o.childNodes[0].textContent.trim();\n    if (menu) {\n      menu.id = menu.id || P + '-menu-' + uid; menu.tabIndex = -1;\n      mBtn.setAttribute('aria-controls', menu.id); mBtn.setAttribute('aria-expanded', 'false');\n      hl = document.createElement('div'); hl.className = P + '-hl'; hl.setAttribute('aria-hidden', 'true'); menu.prepend(hl);\n      opts.forEach((o, i) => { o.id = o.id || menu.id + '-o' + i; o.insertAdjacentHTML('beforeend', I.check);\n        o.addEventListener('pointermove', () => { if (active !== i) setActive(i); });\n        o.addEventListener('click', () => { choose(i); close(true); }); });\n      const sel = opts.find(o => o.getAttribute('aria-selected') === 'true') || opts[0];\n      setModel(opts.indexOf(sel), false);\n    }\n    function setModel(i, fire) {\n      opts.forEach((o, j) => o.setAttribute('aria-selected', String(i === j)));\n      model = opts[i].dataset.value || label(opts[i]);\n      mBtn.innerHTML = `<span>${esc(label(opts[i]))}</span>${I.chev}`;\n      mBtn.setAttribute('aria-label', 'Model: ' + label(opts[i]));\n      if (fire) { root.dispatchEvent(new CustomEvent(P + ':model', { bubbles: true, detail: { model } })); say(label(opts[i]) + ' selected'); }\n    }\n    function choose(i) { setModel(i, true); }\n    function setActive(i, snap) {\n      active = (i + opts.length) % opts.length; const o = opts[active];\n      menu.setAttribute('aria-activedescendant', o.id);\n      hl.style.height = o.offsetHeight + 'px';\n      if (snap) HY.snap(o.offsetTop); else HY.g = o.offsetTop;\n      wake(animO);\n    }\n    const drawO = () => {\n      const o = O.x;\n      menu.style.opacity = clamp(o * 1.5, 0, 1).toFixed(3);\n      const dy = (menu.hasAttribute('data-below') ? -8 : 8) * (1 - o);\n      menu.style.transform = `translate3d(0,${dy.toFixed(2)}px,0) scale(${(.9 + .1 * o).toFixed(4)})`;\n      hl.style.transform = `translate3d(0,${HY.x.toFixed(2)}px,0)`;\n    };\n    const animO = dt => { const a = O.step(dt), b = HY.step(dt); drawO(); if (!a && !open) menu.hidden = true; return a || b; };\n    function openMenu() {\n      if (open || !menu) return; open = true; menu.hidden = false;\n      const r = mBtn.getBoundingClientRect(), mh = menu.offsetHeight;\n      menu.toggleAttribute('data-below', r.top < mh + 20 && innerHeight - r.bottom > r.top);\n      const ox = r.left + r.width / 2 - menu.getBoundingClientRect().left;\n      menu.style.transformOrigin = `${ox.toFixed(1)}px ${menu.hasAttribute('data-below') ? '0' : '100%'}`;\n      mBtn.setAttribute('aria-expanded', 'true');\n      setActive(opts.findIndex(o => o.getAttribute('aria-selected') === 'true'), true);\n      O.g = 1; wake(animO); menu.focus({ preventScroll: true });\n      document.addEventListener('pointerdown', outside, true);\n    }\n    function close(refocus) {\n      if (!open) return; open = false; O.g = 0; wake(animO); mBtn.setAttribute('aria-expanded', 'false');\n      document.removeEventListener('pointerdown', outside, true);\n      if (refocus) mBtn.focus({ preventScroll: true });\n    }\n    const outside = e => { if (!menu.contains(e.target) && !mBtn.contains(e.target)) close(false); };\n    if (menu) {\n      mBtn.addEventListener('click', () => open ? close(true) : openMenu());\n      mBtn.addEventListener('keydown', e => { if (['ArrowDown', 'ArrowUp'].includes(e.key)) { e.preventDefault(); openMenu(); } });\n      let typed = '', tt = 0;\n      menu.addEventListener('keydown', e => {\n        const k = e.key;\n        if (k === 'ArrowDown' || k === 'ArrowUp') { e.preventDefault(); setActive(active + (k === 'ArrowDown' ? 1 : -1)); }\n        else if (k === 'Home' || k === 'End') { e.preventDefault(); setActive(k === 'Home' ? 0 : opts.length - 1); }\n        else if (k === 'Enter' || k === ' ') { e.preventDefault(); choose(active); close(true); }\n        else if (k === 'Escape') { e.preventDefault(); e.stopPropagation(); close(true); }\n        else if (k === 'Tab') { close(false); }\n        else if (k.length === 1 && /\\S/.test(k)) {\n          clearTimeout(tt); typed += k.toLowerCase(); tt = setTimeout(() => { typed = ''; }, 600);\n          const j = opts.findIndex(o => label(o).toLowerCase().startsWith(typed)); if (j > -1) setActive(j);\n        }\n      });\n    }\n\n    // fonts can arrive after mount: re-measure the middle truncation\n    if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => { files.forEach(c => c.trunc()); fit(); });\n    addEventListener('resize', () => fit());\n\n    const api = {\n      el: root,\n      get value() { return ta.value; }, set value(v) { ta.value = v; fit(); update(); },\n      get model() { return model; },\n      set model(v) { const i = opts.findIndex(o => o.dataset.value === v); if (i > -1) setModel(i, false); },\n      get files() { return live().map(c => ({ id: c.id, file: c.file, name: c.name, size: c.size, type: c.type })); },\n      get busy() { return busy; },\n      addFiles, removeFile, setProgress, setBusy, submit, stop, clear,\n      setDisabled(d) { disabled = !!d; root.toggleAttribute('data-disabled', disabled); ta.disabled = disabled; update(); },\n      focus() { ta.focus(); },\n      openModels: openMenu, closeModels: () => close(false),\n    };\n    root.vsChatInput = api;\n    fit(); update(); drawSend(); measure();\n    return api;\n  }\n  const mountAll = () => document.querySelectorAll('.' + P).forEach(mount);\n  window.vsChatInput = { mount, mountAll };\n  mountAll(); if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mountAll);\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "AI interfaces",
    "tags": [
      "chat",
      "composer",
      "prompt input",
      "ai",
      "textarea",
      "file upload",
      "drag and drop",
      "model picker",
      "listbox"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#0e7c7b",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/chat-input/index.html",
    "thumbnail": "https://vividsites.app/api/component/chat-input/thumb.jpg",
    "video": "https://vividsites.app/api/component/chat-input/thumb.mp4",
    "page": "https://vividsites.app/c/chat-input"
  }
}