{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "message-stream",
  "type": "registry:block",
  "title": "Streaming Message",
  "description": "A chat thread whose replies stream in word by word, each rising out of a soft blur behind a glowing caret, with markdown and syntax-coloured code blocks that never break mid-stream. Copy, regenerate with versions, stop, and auto-scroll that yields when you scroll up.",
  "docs": "Streaming Message was added to public/vividsites/message-stream.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/message-stream",
  "categories": [
    "ui",
    "ai-interfaces"
  ],
  "files": [
    {
      "path": "components/vividsites/message-stream.html",
      "type": "registry:file",
      "target": "public/vividsites/message-stream.html",
      "content": "<!--\nvs-message-stream: a chat thread whose assistant replies stream in token by token. Each\n  word rises 3px out of a 4px blur, a soft caret leads, and a light markdown subset\n  (paragraphs, **bold**, `inline code`, - / 1. lists, ### headings, ``` fenced code with a\n  language header, copy button and syntax colour) is laid out correctly WHILE it streams:\n  the text is parsed into blocks first, and half-received markup (an open ** or `, a\n  half word, a half fence line) is held back, so nothing ever breaks mid-stream.\n\n  Markup: section.vs-message-stream > .vs-message-stream-scroll > .vs-message-stream-msg[data-role]\n    data-role=\"user\"       a bubble (plain text)\n    data-role=\"assistant\"  markdown written inside renders as a finished reply; leave it empty\n                           to stream into it. (Escape < and & as &lt; &amp; in markup.)\n  On the section: data-speed=\"48\" (tokens per second) · data-effect=\"blur | fade | none\" ·\n    data-theme=\"light\"\n  Under a finished reply: copy (icon morphs to a check), regenerate, and a \"‹ 2 / 2 ›\"\n  version switcher once there is more than one answer. \"Stop\" shows while streaming.\n  The thread sticks to the bottom while streaming, lets go the moment the reader scrolls\n  up, and offers a \"Jump to latest\" pill. The finished reply is announced once (aria-live),\n  never token by token.\n\n  JS (window.vsMessageStream):\n    stream(msgEl, markdown | ReadableStream | asyncIterable, {speed}) → Promise<{text, stopped}>\n    append(msgEl, chunk)   feed SSE / fetch chunks as they arrive; end(msgEl) when the stream closes\n                           (a big backlog is caught up faster than data-speed)\n    stop(msgEl?)           stop one (or every) streaming reply where it is\n    add(rootEl, 'user' | 'assistant', text?) → the new message element\n    regenerate(msgEl) · reset(msgEl) · mount(rootEl)\n  Events (bubble from the message):\n    vs-message-stream:regenerate {index, text}  cancelable. Call stream(el, newAnswer) in the\n       handler; call e.preventDefault() if you'll stream later (it waits with a pulsing caret).\n       With no listener the same answer re-streams.\n    vs-message-stream:done {text, stopped, version} · :stop · :copy {text, code?} · :version {index}\n  Variables: --vs-message-stream-accent (caret, check, activity ring), -ink, -dim, -well, -width,\n    -height, -font, -mono, -in (token entrance), and -kw -str -num -fn -com -prop -pun for code.\n  Reduced motion: text still streams, without the rise/blur; auto-scroll jumps.\n-->\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\"><link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500&family=IBM+Plex+Mono:wght@400&display=swap\" rel=\"stylesheet\">\n<section class=\"vs-message-stream\" aria-label=\"Conversation\" data-speed=\"48\" data-effect=\"blur\">\n  <div class=\"vs-message-stream-scroll\">\n    <div class=\"vs-message-stream-msg\" data-role=\"user\">How do I debounce a search input without a library?</div>\n    <div class=\"vs-message-stream-msg\" data-role=\"assistant\">\n      A debounce waits until the user **stops typing**, then runs once, so your search doesn't fire on every keystroke.\n\n      ```js\n      function debounce(fn, wait = 250) {\n        let timer;\n        return (...args) => {\n          clearTimeout(timer);\n          timer = setTimeout(() => fn(...args), wait);\n        };\n      }\n      ```\n\n      Around `250 ms` feels instant to most people.\n    </div>\n  </div>\n</section>\n\n<style>\n.vs-message-stream {\n  --vs-message-stream-bg: #0e1013;                   /* thread surface (transparent to sit on your page) */\n  --vs-message-stream-radius: 20px;\n  --vs-message-stream-pad: 20px;                     /* side padding of the thread */\n  --vs-message-stream-ink: #e8eaed;                  /* text */\n  --vs-message-stream-bright: #ffffff;               /* bold */\n  --vs-message-stream-dim: rgba(232,234,237,.5);     /* meta, markers, icons */\n  --vs-message-stream-line: rgba(232,234,237,.09);   /* hairlines */\n  --vs-message-stream-well: rgba(232,234,237,.06);   /* user bubble, hovers, inline code */\n  --vs-message-stream-code-bg: rgba(0,0,0,.28);      /* code block */\n  --vs-message-stream-pill: #1a1d22;                 /* jump-to-latest pill */\n  --vs-message-stream-accent: #62e0b0;               /* caret, copied check, active */\n  --vs-message-stream-width: 720px;\n  --vs-message-stream-height: 620px;                 /* the thread scrolls inside this */\n  --vs-message-stream-font: \"Inter Tight\", ui-sans-serif, -apple-system, system-ui, \"Segoe UI\", sans-serif;\n  --vs-message-stream-mono: \"IBM Plex Mono\", ui-monospace, \"SF Mono\", Menlo, monospace;\n  --vs-message-stream-in: 180ms;                     /* token entrance */\n  /* syntax */\n  --vs-message-stream-kw: #8fb8ff; --vs-message-stream-str: #dcc68f; --vs-message-stream-num: #f0a37a;\n  --vs-message-stream-fn: #f2efe6; --vs-message-stream-com: #636b76; --vs-message-stream-prop: #b3c2d6; --vs-message-stream-pun: rgba(232,234,237,.5);\n\n  position: relative; box-sizing: border-box; width: 100%; max-width: var(--vs-message-stream-width); height: var(--vs-message-stream-height);\n  font: 400 15.5px/1.65 var(--vs-message-stream-font); letter-spacing: -.004em; color: var(--vs-message-stream-ink);\n  background: var(--vs-message-stream-bg); border-radius: var(--vs-message-stream-radius);\n  -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-rendering: optimizeLegibility;\n}\n.vs-message-stream[data-theme=\"light\"] {\n  --vs-message-stream-bg: #f6f7f8;\n  --vs-message-stream-ink: #1b1d21; --vs-message-stream-bright: #000; --vs-message-stream-dim: rgba(27,29,33,.5);\n  --vs-message-stream-line: rgba(27,29,33,.1); --vs-message-stream-well: rgba(27,29,33,.055); --vs-message-stream-code-bg: rgba(27,29,33,.035);\n  --vs-message-stream-pill: #ffffff; --vs-message-stream-accent: #0e9f6e;\n  --vs-message-stream-kw: #2d5fd3; --vs-message-stream-str: #9a6a12; --vs-message-stream-num: #c0512b;\n  --vs-message-stream-fn: #1b1d21; --vs-message-stream-com: #8a9099; --vs-message-stream-prop: #4a5a70; --vs-message-stream-pun: rgba(27,29,33,.5);\n}\n.vs-message-stream *, .vs-message-stream *::before, .vs-message-stream *::after { box-sizing: border-box; }\n.vs-message-stream-scroll {\n  height: 100%; overflow-y: auto; overscroll-behavior: contain; padding: 28px var(--vs-message-stream-pad) 44px; outline: none; border-radius: inherit;\n  scrollbar-width: thin; scrollbar-color: var(--vs-message-stream-line) transparent;\n  -webkit-mask-image: linear-gradient(transparent, #000 22px, #000 calc(100% - 44px), transparent);\n          mask-image: linear-gradient(transparent, #000 22px, #000 calc(100% - 44px), transparent);\n}\n.vs-message-stream-scroll:focus-visible { box-shadow: 0 0 0 1.5px var(--vs-message-stream-line) inset; border-radius: 12px; }\n\n/* messages */\n.vs-message-stream-msg + .vs-message-stream-msg { margin-top: 26px; }\n.vs-message-stream-msg[data-role=\"user\"] {\n  width: fit-content; max-width: min(78%, 34rem); margin-left: auto; padding: 10px 16px 11px; white-space: pre-wrap;\n  background: var(--vs-message-stream-well); border-radius: 20px 20px 6px 20px; box-shadow: 0 0 0 .5px var(--vs-message-stream-line) inset;\n  font-size: 15px;\n}\n.vs-message-stream-msg[data-role=\"assistant\"] { display: grid; grid-template-columns: 28px minmax(0, 1fr); column-gap: 16px; }\n.vs-message-stream-avatar { position: relative; width: 28px; height: 28px; margin-top: 1px; border-radius: 50%; display: grid; place-items: center; color: var(--vs-message-stream-ink); box-shadow: 0 0 0 .5px var(--vs-message-stream-line) inset, 0 0 0 .5px var(--vs-message-stream-line); background: var(--vs-message-stream-well); }\n.vs-message-stream-avatar > svg { width: 13px; height: 13px; }\n.vs-message-stream-avatar i { position: absolute; inset: -3px; border-radius: 50%; opacity: 0; transition: opacity .4s;\n  background: conic-gradient(from 0deg, transparent 0 62%, var(--vs-message-stream-accent) 100%);\n  -webkit-mask: radial-gradient(closest-side, transparent calc(100% - 1.5px), #000 calc(100% - 1.5px));\n          mask: radial-gradient(closest-side, transparent calc(100% - 1.5px), #000 calc(100% - 1.5px));\n  animation: vs-message-stream-spin 1.1s linear infinite paused; }\n.vs-message-stream-msg[data-state=\"streaming\"] .vs-message-stream-avatar i { opacity: 1; animation-play-state: running; }\n@keyframes vs-message-stream-spin { to { transform: rotate(360deg); } }\n.vs-message-stream-body { min-width: 0; }\n.vs-message-stream-content { min-height: 1.65em; }\n.vs-message-stream-content > * { margin: 0 0 .8em; }\n.vs-message-stream-content > :last-child { margin-bottom: 0; }\n.vs-message-stream-content h3 { font-size: 1.08em; font-weight: 500; line-height: 1.4; color: var(--vs-message-stream-bright); margin-top: 1.1em; }\n.vs-message-stream-content ul, .vs-message-stream-content ol { padding-left: 1.3em; }\n.vs-message-stream-content li { padding-left: .25em; }\n.vs-message-stream-content li + li { margin-top: .3em; }\n.vs-message-stream-content li::marker { color: var(--vs-message-stream-dim); }\n.vs-message-stream-content strong { font-weight: 500; color: var(--vs-message-stream-bright); }\n.vs-message-stream-content code { font: 400 .84em/1 var(--vs-message-stream-mono); }\n.vs-message-stream-content :not(pre) > code { padding: .18em .38em; border-radius: 6px; background: var(--vs-message-stream-well); box-shadow: 0 0 0 .5px var(--vs-message-stream-line) inset; }\n/* tokens */\n.vs-message-stream-content .t { display: inline-block; }\n.vs-message-stream-content .t.in { animation: vs-message-stream-blur var(--vs-message-stream-in) cubic-bezier(.2,.7,.3,1) both; }\n.vs-message-stream[data-effect=\"fade\"] .t.in { animation-name: vs-message-stream-fade; }\n.vs-message-stream[data-effect=\"none\"] .t.in { animation: none; }\n@keyframes vs-message-stream-blur { 0% { opacity: 0; transform: translateY(3px); filter: blur(4px); } 30% { opacity: .75; } 100% { opacity: 1; transform: none; filter: blur(0); } }\n@keyframes vs-message-stream-fade { from { opacity: 0; } }\n/* caret */\n.vs-message-stream-caret {\n  display: inline-block; width: 3px; height: 1.05em; margin-left: 2px; vertical-align: -.16em; border-radius: 2px;\n  background: var(--vs-message-stream-accent); box-shadow: 0 0 10px color-mix(in srgb, var(--vs-message-stream-accent) 70%, transparent);\n}\n.vs-message-stream-caret.is-wait { animation: vs-message-stream-pulse 1s ease-in-out infinite; }\n@keyframes vs-message-stream-pulse { 50% { opacity: .25; } }\npre .vs-message-stream-caret { height: 1.2em; vertical-align: -.25em; }\n.vs-message-stream-content.is-out { transition: opacity .26s cubic-bezier(.4,0,1,1), filter .26s, transform .26s cubic-bezier(.4,0,1,1); opacity: 0; filter: blur(6px); transform: translateY(-4px); }\n.vs-message-stream-content.is-swap { animation: vs-message-stream-fade .3s cubic-bezier(.16,1,.3,1); }\n\n/* code block */\n.vs-message-stream-code { position: relative; border-radius: 12px; background: var(--vs-message-stream-code-bg); box-shadow: 0 0 0 .5px var(--vs-message-stream-line) inset, 0 1px 0 rgba(255,255,255,.03) inset; overflow: hidden; margin: .2em 0 1em; }\n.vs-message-stream-code-head { display: flex; align-items: center; justify-content: space-between; height: 36px; padding: 0 6px 0 14px; border-bottom: .5px solid var(--vs-message-stream-line); font: 400 12px/1 var(--vs-message-stream-mono); color: var(--vs-message-stream-dim); letter-spacing: .01em; }\n.vs-message-stream-code pre { margin: 0; padding: 12px 16px 14px; overflow-x: auto; font: 400 13.2px/1.7 var(--vs-message-stream-mono); color: var(--vs-message-stream-ink); scrollbar-width: thin; scrollbar-color: var(--vs-message-stream-line) transparent; tab-size: 2; }\n.vs-message-stream-code pre code { font: inherit; }\n.vs-message-stream-code .k { color: var(--vs-message-stream-kw); }\n.vs-message-stream-code .s { color: var(--vs-message-stream-str); }\n.vs-message-stream-code .n { color: var(--vs-message-stream-num); }\n.vs-message-stream-code .f { color: var(--vs-message-stream-fn); }\n.vs-message-stream-code .c { color: var(--vs-message-stream-com); }\n.vs-message-stream-code .p { color: var(--vs-message-stream-prop); }\n.vs-message-stream-code .o { color: var(--vs-message-stream-pun); }\n\n/* buttons */\n:where(.vs-message-stream) :where(button) { all: unset; box-sizing: border-box; cursor: pointer; -webkit-tap-highlight-color: transparent; }\n.vs-message-stream-copy {\n  display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 9px 0 8px; border-radius: 7px; color: var(--vs-message-stream-dim);\n  font: 500 12px/1 var(--vs-message-stream-font); letter-spacing: 0; transition: background-color .15s, color .15s, transform .25s cubic-bezier(.3,1.5,.5,1);\n}\n.vs-message-stream-ico { position: relative; width: 15px; height: 15px; flex: none; display: inline-block; }\n.vs-message-stream-ico svg { position: absolute; inset: 0; width: 100%; height: 100%; transition: opacity .2s, transform .45s cubic-bezier(.3,1.6,.45,1); }\n.vs-message-stream-ico svg + svg { opacity: 0; transform: scale(.4) rotate(-30deg); color: var(--vs-message-stream-accent); }\n.is-done > .vs-message-stream-ico svg:first-child { opacity: 0; transform: scale(.4) rotate(30deg); }\n.is-done > .vs-message-stream-ico svg + svg { opacity: 1; transform: none; }\n.vs-message-stream-copy.is-done { color: var(--vs-message-stream-ink); }\n.vs-message-stream-copy:hover, .vs-message-stream-act:hover { background: var(--vs-message-stream-well); color: var(--vs-message-stream-ink); }\n.vs-message-stream-copy:active, .vs-message-stream-act:active:not([aria-disabled=\"true\"]) { transform: scale(.92); }\n.vs-message-stream button:focus-visible { box-shadow: 0 0 0 1.5px var(--vs-message-stream-ink) inset; }\n\n/* actions row + stop */\n.vs-message-stream-bar { position: relative; height: 32px; margin-top: 10px; margin-left: -7px; }\n.vs-message-stream-actions, .vs-message-stream-stop { position: absolute; left: 0; top: 0; display: flex; align-items: center; gap: 2px; transition: opacity .3s cubic-bezier(.16,1,.3,1), transform .45s cubic-bezier(.16,1,.3,1), visibility 0s .3s; }\n.vs-message-stream-msg:not([data-state=\"done\"]) .vs-message-stream-actions,\n.vs-message-stream-msg:not([data-state=\"streaming\"]) .vs-message-stream-stop { opacity: 0; transform: translateY(4px); visibility: hidden; pointer-events: none; }\n.vs-message-stream-msg[data-state=\"done\"] .vs-message-stream-actions, .vs-message-stream-msg[data-state=\"streaming\"] .vs-message-stream-stop { transition-delay: 0s; }\n.vs-message-stream-act { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 8px; color: var(--vs-message-stream-dim); transition: background-color .15s, color .15s, transform .25s cubic-bezier(.3,1.5,.5,1); }\n.vs-message-stream-act > svg, .vs-message-stream-act .vs-message-stream-ico { width: 16px; height: 16px; }\n.vs-message-stream-act[aria-disabled=\"true\"] { opacity: .35; cursor: default; background: none; }\n.vs-message-stream-act.is-spin svg { animation: vs-message-stream-spin .5s cubic-bezier(.3,1,.4,1); }\n.vs-message-stream-ver { display: inline-flex; align-items: center; margin-left: 4px; }\n.vs-message-stream-ver[hidden] { display: none; }\n.vs-message-stream-ver .vs-message-stream-act { width: 24px; }\n.vs-message-stream-ver span { min-width: 34px; text-align: center; font: 400 12px/1 var(--vs-message-stream-mono); color: var(--vs-message-stream-dim); font-variant-numeric: tabular-nums; }\n.vs-message-stream-stop button {\n  display: inline-flex; align-items: center; gap: 8px; height: 30px; padding: 0 13px 0 11px; border-radius: 999px; margin-left: 7px;\n  font: 500 12.5px/1 var(--vs-message-stream-font); color: var(--vs-message-stream-ink); background: var(--vs-message-stream-well);\n  box-shadow: 0 0 0 .5px var(--vs-message-stream-line) inset; transition: background-color .15s, transform .25s cubic-bezier(.3,1.5,.5,1);\n}\n.vs-message-stream-stop button::before { content: \"\"; width: 8px; height: 8px; border-radius: 2px; background: currentColor; }\n.vs-message-stream-stop button:hover { background: color-mix(in srgb, var(--vs-message-stream-ink) 10%, transparent); }\n.vs-message-stream-stop button:active { transform: scale(.94); }\n\n/* jump to latest */\n.vs-message-stream-jump {\n  position: absolute; left: 50%; bottom: 14px; z-index: 2; display: inline-flex; align-items: center; gap: 7px; height: 32px; padding: 0 14px 0 11px; border-radius: 999px;\n  font: 500 12.5px/1 var(--vs-message-stream-font); color: var(--vs-message-stream-ink); background: var(--vs-message-stream-pill);\n  box-shadow: 0 0 0 .5px var(--vs-message-stream-line), 0 1px 0 rgba(255,255,255,.05) inset, 0 10px 24px -8px rgba(0,0,0,.5);\n  opacity: 0; transform: translate(-50%, 10px) scale(.94); pointer-events: none; visibility: hidden;\n  transition: opacity .25s cubic-bezier(.16,1,.3,1), transform .5s cubic-bezier(.3,1.4,.5,1), visibility 0s .25s;\n}\n.vs-message-stream-jump svg { width: 14px; height: 14px; }\n.vs-message-stream-jump.is-on { opacity: 1; transform: translate(-50%, 0) scale(1); pointer-events: auto; visibility: visible; transition-delay: 0s; }\n.vs-message-stream-jump:focus-visible { box-shadow: 0 0 0 1.5px var(--vs-message-stream-ink); }\n.vs-message-stream[data-theme=\"light\"] .vs-message-stream-jump { box-shadow: 0 0 0 .5px var(--vs-message-stream-line), 0 10px 24px -8px rgba(20,24,30,.25); }\n.vs-message-stream-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n\n@media (max-width: 520px) {\n  .vs-message-stream { font-size: 15px; }\n  .vs-message-stream-msg[data-role=\"assistant\"] { grid-template-columns: minmax(0, 1fr); }\n  .vs-message-stream-avatar { margin-bottom: 10px; }\n  .vs-message-stream-code pre { font-size: 12.4px; padding: 10px 12px 12px; }\n}\n@media (prefers-reduced-motion: reduce) {\n  .vs-message-stream .t.in, .vs-message-stream-content.is-swap { animation: none !important; }\n  .vs-message-stream-content.is-out { transition: none !important; }\n  .vs-message-stream-caret.is-wait { animation: none !important; }\n  .vs-message-stream *, .vs-message-stream-jump { transition-duration: .01s !important; }\n}\n</style>\n\n<script>\n(() => {\n  if (window.vsMessageStream) return;\n  const P = 'vs-message-stream';\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  const svg = (d, w = 1.7) => `<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    copy: svg('<rect x=\"8.5\" y=\"8.5\" width=\"12\" height=\"12\" rx=\"2.6\"/><path d=\"M15.5 8.5V6.1a2.6 2.6 0 0 0-2.6-2.6H6.1a2.6 2.6 0 0 0-2.6 2.6v6.8a2.6 2.6 0 0 0 2.6 2.6h2.4\"/>'),\n    check: svg('<path d=\"M4.5 12.5 9.5 17.5 19.5 6.5\"/>', 2.1),\n    regen: svg('<path d=\"M20 11.5A8 8 0 0 0 6.2 6.3L4 8.5\"/><path d=\"M4 3.8v4.7h4.7\"/><path d=\"M4 12.5a8 8 0 0 0 13.8 5.2l2.2-2.2\"/><path d=\"M20 20.2v-4.7h-4.7\"/>'),\n    prev: svg('<path d=\"m14.5 6-6 6 6 6\"/>', 2), next: svg('<path d=\"m9.5 6 6 6-6 6\"/>', 2),\n    down: svg('<path d=\"M12 5v14M6 13l6 6 6-6\"/>', 2),\n    mark: '<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" fill=\"currentColor\"><circle cx=\"12\" cy=\"7.2\" r=\"2.6\"/><circle cx=\"7.4\" cy=\"15.2\" r=\"2.6\" opacity=\".7\"/><circle cx=\"16.6\" cy=\"15.2\" r=\"2.6\" opacity=\".45\"/></svg>',\n  };\n  const ico = () => `<i class=\"${P}-ico\">${I.copy}${I.check}</i>`;\n  const esc = s => String(s).replace(/[&<>\"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;' }[c]));\n\n  // ---------------- markdown subset -> blocks -> reveal tokens\n  function blocks(md) {\n    const L = md.replace(/\\r\\n?/g, '\\n').split('\\n'), out = []; let para = null, list = null;\n    for (let i = 0; i < L.length; i++) {\n      const line = L[i], fence = line.match(/^\\s*```\\s*([\\w+#.-]*)\\s*$/);\n      if (fence) {\n        para = list = null; const buf = []; let j = i + 1, closed = false;\n        for (; j < L.length; j++) { if (/^\\s*```\\s*$/.test(L[j])) { closed = true; break; } buf.push(L[j]); }\n        out.push({ t: 'code', lang: fence[1] || 'text', text: buf.join('\\n'), closed }); i = j; continue;\n      }\n      if (!line.trim()) { para = list = null; continue; }\n      const li = line.match(/^\\s*(?:[-*+]|(\\d+)[.)])\\s+(.*)$/);\n      if (li) {\n        const t = li[1] ? 'ol' : 'ul'; para = null;\n        if (!list || list.t !== t) { list = { t, items: [] }; out.push(list); }\n        list.items.push(li[2]); continue;\n      }\n      const h = line.match(/^#{1,4}\\s+(.*)$/);\n      if (h) { para = list = null; out.push({ t: 'h3', text: h[1] }); continue; }\n      if (list && /^\\s{2,}\\S/.test(line)) { list.items[list.items.length - 1] += ' ' + line.trim(); continue; }\n      if (para) para.text += ' ' + line.trim(); else { list = null; para = { t: 'p', text: line.trim() }; out.push(para); }\n    }\n    return out;\n  }\n  function inline(text, base) {\n    const segs = [], re = /\\*\\*([^*]+)\\*\\*|`([^`]+)`/g; let last = 0, m;\n    while ((m = re.exec(text))) {\n      if (m.index > last) segs.push({ s: text.slice(last, m.index), f: '' });\n      segs.push(m[1] != null ? { s: m[1], f: 'b' } : { s: m[2], f: 'c' }); last = re.lastIndex;\n    }\n    if (last < text.length) segs.push({ s: text.slice(last), f: '' });\n    const out = []; let pend = false;\n    segs.forEach((sg, gi) => {\n      for (const part of sg.s.match(/\\s*\\S+/g) || []) { out.push({ ...base, w: part.trim(), sp: pend || /^\\s/.test(part), f: sg.f, g: gi }); pend = false; }\n      if (/\\s$/.test(sg.s)) pend = true;\n    });\n    return out;\n  }\n  const KW = new Set(('const let var function return if else for while do break continue new class extends import from export default async await try catch finally throw typeof instanceof in of this null undefined true false switch case yield ' +\n    'def self None True False elif lambda pass with as not and or is fn pub use mut impl struct enum match func go type interface package').split(' '));\n  function highlight(code, lang) {\n    const hashCom = /^(py|python|sh|bash|shell|zsh|rb|ruby|yaml|yml|toml)$/.test(lang);\n    const re = /(\\/\\/[^\\n]*|\\/\\*[\\s\\S]*?\\*\\/|#[^\\n]*)|('(?:\\\\.|[^'\\\\\\n])*'|\"(?:\\\\.|[^\"\\\\\\n])*\"|`(?:\\\\.|[^`\\\\])*`)|(\\b\\d[\\d_]*(?:\\.\\d+)?\\b)|([A-Za-z_$][\\w$]*)|(\\s+)|([^\\sA-Za-z_$\\d'\"`]+?(?=[\\sA-Za-z_$\\d'\"`]|$)|[^\\sA-Za-z_$\\d'\"`])/g;\n    const out = []; let m;\n    while ((m = re.exec(code))) {\n      let cls = '';\n      if (m[1]) { if (m[1][0] === '#' && !hashCom) { out.push({ s: '#', cls: 'o' }); re.lastIndex = m.index + 1; continue; } cls = 'c'; }\n      else if (m[2]) cls = 's'; else if (m[3]) cls = 'n';\n      else if (m[4]) cls = KW.has(m[4]) ? 'k' : code[re.lastIndex] === '(' ? 'f' : code[m.index - 1] === '.' ? 'p' : '';\n      else if (m[5]) cls = ' ';\n      else cls = 'o';\n      out.push({ s: m[0], cls });\n    }\n    return out;\n  }\n  function tokenize(md) {\n    const toks = [];\n    blocks(md).forEach((b, bi) => {\n      if (b.t === 'code') {\n        toks.push({ b: bi, bt: 'code', lang: b.lang, open: true, w: '' });\n        for (const seg of highlight(b.text, b.lang)) {\n          for (const piece of seg.s.match(/\\s+|\\S+/g) || []) toks.push({ b: bi, bt: 'code', w: piece, ws: /^\\s/.test(piece), cls: seg.cls === ' ' ? '' : seg.cls });\n        }\n      } else if (b.items) b.items.forEach((it, li) => toks.push(...inline(it, { b: bi, bt: b.t, li })));\n      else toks.push(...inline(b.text, { b: bi, bt: b.t }));\n    });\n    toks.forEach(t => { t.key = [t.b, t.bt, t.li, t.w, t.sp, t.f, t.g, t.cls, t.lang].join('\\u0001'); });\n    return toks;\n  }\n  // what is safe to show from a half-received buffer: no half words, no unclosed ** or `, no half fence lines\n  function safe(buf) {\n    let s = buf;\n    if (!/\\s$/.test(s)) s = s.replace(/\\S+$/, '');\n    const fences = (s.match(/^\\s*```/gm) || []).length;\n    const nl = s.lastIndexOf('\\n'), ll = s.slice(nl + 1);\n    if (/^\\s*(`{1,3}[\\w+#.-]*|[-*+]|\\d+[.)]?|#{1,4})\\s*$/.test(ll) && ll.trim()) s = s.slice(0, nl + 1);\n    if (fences % 2 === 0) {\n      const at = s.lastIndexOf('\\n\\n'), cur = s.slice(at + 1);\n      const bold = cur.split('**').length - 1; if (bold % 2) s = s.slice(0, at + 1 + cur.lastIndexOf('**'));\n      const cur2 = s.slice(at + 1), ticks = (cur2.replace(/```/g, '').match(/`/g) || []).length;\n      if (ticks % 2) s = s.slice(0, at + 1 + cur2.lastIndexOf('`'));\n    }\n    return s;\n  }\n\n  function dedent(t) {\n    const lines = t.replace(/^\\s*\\n|\\s+$/g, '').split('\\n');\n    const pad = Math.min(...lines.filter(l => l.trim()).map(l => l.match(/^[ \\t]*/)[0].length), 99);\n    return lines.map(l => l.slice(Math.min(pad, l.match(/^[ \\t]*/)[0].length))).join('\\n');\n  }\n  // ---------------- per-message state\n  const S = new WeakMap();\n  const fire = (el, name, detail, cancelable) => { const e = new CustomEvent(P + ':' + name, { bubbles: true, cancelable: !!cancelable, detail: { el, ...detail } }); el.dispatchEvent(e); return e; };\n  function ensure(el) {\n    if (S.has(el)) return S.get(el);\n    const md = dedent(el.textContent);   // markdown already in the markup renders as a finished message\n    el.dataset.role = 'assistant';\n    el.innerHTML = `<div class=\"${P}-avatar\" aria-hidden=\"true\">${I.mark}<i></i></div>\n      <div class=\"${P}-body\"><div class=\"${P}-content\"></div>\n        <div class=\"${P}-bar\">\n          <div class=\"${P}-stop\"><button type=\"button\">Stop</button></div>\n          <div class=\"${P}-actions\">\n            <button class=\"${P}-act\" type=\"button\" data-a=\"copy\" aria-label=\"Copy message\" title=\"Copy\">${ico()}</button>\n            <button class=\"${P}-act\" type=\"button\" data-a=\"regen\" aria-label=\"Regenerate\" title=\"Regenerate\">${I.regen}</button>\n            <div class=\"${P}-ver\" hidden><button class=\"${P}-act\" type=\"button\" data-a=\"prev\" aria-label=\"Previous version\">${I.prev}</button><span aria-live=\"off\">1 / 1</span><button class=\"${P}-act\" type=\"button\" data-a=\"next\" aria-label=\"Next version\">${I.next}</button></div>\n          </div>\n        </div></div>`;\n    const q = s => el.querySelector('.' + P + '-' + s);\n    const st = { el, root: el.closest('.' + P), content: q('content'), ver: q('ver'), verText: q('ver').querySelector('span'),\n      caret: Object.assign(document.createElement('span'), { className: P + '-caret' }),\n      toks: [], shown: 0, acc: 0, optSpeed: 0, buf: '', ended: true, running: false, pending: false, versions: [], vi: -1, dom: new Map(), resolve: null, gen: 0 };\n    st.caret.setAttribute('aria-hidden', 'true');\n    S.set(el, st);\n    el.addEventListener('click', e => {\n      const b = e.target.closest('button'); if (!b || !el.contains(b)) return;\n      if (b.closest('.' + P + '-stop')) return stop(el);\n      if (b.classList.contains(P + '-copy')) return copyCode(b);\n      const a = b.dataset.a; if (b.getAttribute('aria-disabled') === 'true') return;\n      if (a === 'copy') { const text = st.stopped != null && st.vi === st.versions.length - 1 ? st.stopped : st.versions[st.vi] || ''; copy(text, b); fire(el, 'copy', { text }); }\n      if (a === 'regen') regenerate(el, b);\n      if (a === 'prev' || a === 'next') show(st, st.vi + (a === 'prev' ? -1 : 1));\n    });\n    st.content.addEventListener('animationend', e => e.target.classList && e.target.classList.remove('in'));\n    if (md.trim()) { st.versions = [md]; st.vi = 0; st.buf = md; st.toks = tokenize(md); renderAll(st, st.toks.length); el.dataset.state = 'done'; syncVer(st); }\n    else el.dataset.state = 'idle';\n    return st;\n  }\n\n  // ---------------- DOM building (a token at a time)\n  function host(st, tk) {\n    let blk = st.dom.get(tk.b);\n    if (!blk) {\n      let node;\n      if (tk.bt === 'code') {\n        node = document.createElement('div'); node.className = P + '-code';\n        node.innerHTML = `<div class=\"${P}-code-head\"><span>${esc(tk.lang)}</span><button class=\"${P}-copy\" type=\"button\" aria-label=\"Copy code\">${ico()}<span>Copy</span></button></div><pre><code></code></pre>`;\n        blk = { el: node, code: node.querySelector('code'), items: new Map() };\n      } else { node = document.createElement(tk.bt); blk = { el: node, items: new Map() }; }\n      st.content.append(node); st.dom.set(tk.b, blk);\n    }\n    if (tk.bt === 'code') return blk.code;\n    if (tk.li == null) return blk.el;\n    let li = blk.items.get(tk.li);\n    if (!li) { li = document.createElement('li'); blk.el.append(li); blk.items.set(tk.li, li); }\n    return li;\n  }\n  function put(st, tk, anim) {\n    const h = host(st, tk);\n    if (tk.open) return h;\n    if (tk.ws) { h.append(tk.w); return h; }\n    let into = h;\n    if (tk.f) {\n      const tag = tk.f === 'b' ? 'STRONG' : 'CODE', last = h.lastElementChild;\n      const same = last && last.tagName === tag && last.dataset.g === String(tk.g);\n      if (same) { into = last; if (tk.sp) into.append(' '); }\n      else { if (tk.sp) h.append(' '); into = document.createElement(tag); into.dataset.g = tk.g; h.append(into); }\n    } else if (tk.sp) h.append(' ');\n    const span = document.createElement('span'); span.className = 't' + (tk.cls ? ' ' + tk.cls : '') + (anim && !reduce.matches ? ' in' : ''); span.textContent = tk.w;\n    into.append(span);\n    return into;\n  }\n  function renderAll(st, n) {\n    st.content.textContent = ''; st.dom = new Map(); let where = null;\n    for (let i = 0; i < n; i++) where = put(st, st.toks[i], false);\n    st.shown = n; return where;\n  }\n\n  // ---------------- the reveal loop (rAF while visible, a slow timer while off-screen, nothing when idle)\n  const active = new Set(); let raf = 0, tmo = 0, last = 0;\n  const visible = st => !st.root || st.root._vsVis !== false;\n  function schedule() {\n    if (raf || tmo || !active.size) return;\n    if ([...active].some(visible)) raf = requestAnimationFrame(loop);\n    else tmo = setTimeout(() => loop(performance.now()), 120);\n  }\n  function loop(now) {\n    raf = tmo = 0; const dt = Math.min(.12, (now - last) / 1000 || .016); last = now;\n    for (const st of active) advance(st, dt);\n    for (const R of roots) follow(R, dt);\n    schedule();\n  }\n  function advance(st, dt) {\n    if (!st.running) return;\n    const remain = st.toks.length - st.shown;\n    if (remain > 0) {\n      st.caret.classList.remove('is-wait');\n      const base = st.optSpeed || +(st.root && st.root.dataset.speed) || 48, speed = st.ended ? base : Math.max(base, remain / 1.5);  // catch up on a big backlog\n      st.acc += dt * speed; let n = Math.floor(st.acc);\n      if (n > 0) {\n        st.acc -= n; let where = null;\n        while (n > 0 && st.shown < st.toks.length) { const tk = st.toks[st.shown++]; where = put(st, tk, true); if (!tk.ws && !tk.open) n--; }\n        if (where) where.append(st.caret);\n      }\n    } else if (st.ended) finish(st, false);\n    else { st.acc = 0; st.caret.classList.add('is-wait'); if (!st.caret.isConnected) st.content.append(st.caret); }\n  }\n  function reparse(st) {\n    const toks = tokenize(st.ended ? st.buf : safe(st.buf));\n    let bad = -1;\n    for (let i = 0; i < Math.min(st.shown, toks.length); i++) if (toks[i].key !== st.toks[i].key) { bad = i; break; }\n    if (bad < 0 && toks.length < st.shown) bad = toks.length;\n    st.toks = toks;\n    if (bad > -1) { const where = renderAll(st, Math.min(st.shown, toks.length)); if (where && st.running) where.append(st.caret); }\n    st.versions[st.vi] = st.buf;\n  }\n\n  // ---------------- public: stream / append / end / stop\n  function begin(st, opts) {\n    st.gen++; if (st.resolve) { const r = st.resolve; st.resolve = null; r({ text: st.buf, stopped: true }); }\n    st.content.classList.remove('is-out', 'is-swap'); st.content.textContent = ''; st.dom = new Map();\n    st.buf = ''; st.toks = []; st.shown = 0; st.acc = 0; st.ended = false; st.running = true; st.stopped = null;\n    st.optSpeed = +(opts && opts.speed) || 0;\n    if (!(st.vi >= 0 && st.versions[st.vi] === '')) st.versions.push('');   // an empty attempt is reused, never kept\n    st.vi = st.versions.length - 1; syncVer(st);\n    st.el.dataset.state = 'streaming'; st.content.setAttribute('aria-busy', 'true');\n    st.content.append(st.caret); st.caret.classList.add('is-wait');\n    active.add(st); if (st.root) stickNow(st.root); schedule();\n  }\n  const dissolve = st => new Promise(res => {\n    if (!st.content.textContent.trim() || reduce.matches) { st.content.textContent = ''; return res(); }\n    st.content.classList.add('is-out'); setTimeout(res, 270);\n  });\n  function stream(el, source, opts = {}) {\n    const st = ensure(el), gen = ++st.gen; st.pending = true;\n    if (st.running) { active.delete(st); st.running = false; }\n    if (st.content.textContent.trim()) el.dataset.state = 'pending';\n    return new Promise(async resolve => {\n      await dissolve(st);\n      if (gen !== st.gen) return resolve({ text: '', stopped: true });\n      st.pending = false; begin(st, opts); st.resolve = resolve;\n      if (typeof source === 'string') { st.buf = source; st.ended = true; reparse(st); return; }\n      if (source && (source[Symbol.asyncIterator] || source.getReader)) {\n        const g = st.gen, dec = new TextDecoder();\n        const it = source.getReader ? (async function* (r) { for (;;) { const { done, value } = await r.read(); if (done) return; yield value; } })(source.getReader()) : source;\n        for await (const ch of it) { if (g !== st.gen || !st.running) return; append(el, typeof ch === 'string' ? ch : dec.decode(ch, { stream: true })); }\n        if (g === st.gen) end(el);\n      }\n    });\n  }\n  function append(el, chunk) {\n    const st = ensure(el);\n    if (!st.running && !st.pending) begin(st, {});\n    st.buf += chunk; if (!st.pending) reparse(st); schedule();\n  }\n  function end(el) { const st = ensure(el); st.ended = true; if (st.running) { reparse(st); schedule(); } }\n  function finish(st, stopped) {\n    active.delete(st); st.running = false; st.caret.remove(); st.caret.classList.remove('is-wait');\n    st.el.dataset.state = 'done'; st.content.removeAttribute('aria-busy');\n    if (stopped) st.stopped = st.content.textContent;\n    syncVer(st);\n    const text = stopped ? st.stopped : st.buf;\n    const R = st.root && st.root._vs; if (R) { R.sr.textContent = ''; setTimeout(() => { R.sr.textContent = (stopped ? 'Stopped. ' : '') + st.content.textContent.slice(0, 1200); }, 60); }\n    fire(st.el, stopped ? 'stop' : 'done', { text, stopped, version: st.vi });\n    if (stopped) fire(st.el, 'done', { text, stopped, version: st.vi });\n    const r = st.resolve; st.resolve = null; r && r({ text, stopped });\n  }\n  function stop(el) {\n    const list = el ? [S.get(el)].filter(Boolean) : [...active];\n    list.forEach(st => { if (st.running) { st.gen++; finish(st, true); } });\n  }\n  function regenerate(el, btn) {\n    const st = ensure(el); if (st.running || st.pending) return;\n    if (btn) { btn.classList.remove('is-spin'); void btn.offsetWidth; btn.classList.add('is-spin'); }\n    const text = st.versions[st.vi] || '';\n    const ev = fire(el, 'regenerate', { index: st.vi, text }, true);\n    if (st.running || st.pending) return;                // a listener called stream() / append()\n    if (ev.defaultPrevented) { dissolve(st).then(() => { if (!st.running && !st.pending) begin(st, {}); }); return; }  // it will stream later\n    stream(el, text);\n  }\n  function show(st, i) {\n    if (i < 0 || i >= st.versions.length || st.running) return;\n    st.vi = i; st.buf = st.versions[i]; st.stopped = null; st.toks = tokenize(st.buf); renderAll(st, st.toks.length);\n    st.content.classList.remove('is-swap'); void st.content.offsetWidth; st.content.classList.add('is-swap');\n    syncVer(st); fire(st.el, 'version', { index: i });\n  }\n  function syncVer(st) {\n    const n = st.versions.length;\n    st.ver.hidden = n < 2; st.verText.textContent = (st.vi + 1) + ' / ' + n;\n    st.ver.firstElementChild.setAttribute('aria-disabled', String(st.vi <= 0));\n    st.ver.lastElementChild.setAttribute('aria-disabled', String(st.vi >= n - 1));\n  }\n\n  // ---------------- copy (message and code), icon morphs to a check\n  async function copyText(text) {\n    try { await navigator.clipboard.writeText(text); return true; } catch (e) {\n      const ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.style.cssText = 'position:fixed;opacity:0;pointer-events:none';\n      document.body.append(ta); ta.select(); let ok = false; try { ok = document.execCommand('copy'); } catch (_) {} ta.remove(); return ok;\n    }\n  }\n  async function copy(text, btn) {\n    const ok = await copyText(text); if (!ok) return;\n    btn.classList.add('is-done'); const lab = btn.querySelector('span:not(.' + P + '-ico)'); if (lab) lab.textContent = 'Copied';\n    clearTimeout(btn._t); btn._t = setTimeout(() => { btn.classList.remove('is-done'); setTimeout(() => { if (lab && !btn.classList.contains('is-done')) lab.textContent = 'Copy'; }, 140); }, 1600);\n  }\n  function copyCode(btn) { const code = btn.closest('.' + P + '-code').querySelector('code'); copy(code.textContent, btn); fire(btn.closest('.' + P + '-msg') || btn, 'copy', { text: code.textContent, code: true }); }\n\n  // ---------------- thread: stick-to-bottom that yields to the reader, \"jump to latest\"\n  const roots = new Set();\n  function mount(root) {\n    if (root._vs) return root._vs;\n    const scroller = root.querySelector('.' + P + '-scroll') || root;\n    const jump = document.createElement('button'); jump.type = 'button'; jump.className = P + '-jump'; jump.innerHTML = I.down + '<span>Jump to latest</span>';\n    const sr = document.createElement('div'); sr.className = P + '-sr'; sr.setAttribute('aria-live', 'polite');\n    root.append(jump, sr);\n    if (!scroller.hasAttribute('tabindex')) scroller.tabIndex = 0;\n    const R = { root, scroller, jump, sr, stick: true, user: 0 };\n    root._vs = R; roots.add(R);\n    const intent = () => { R.user = performance.now(); };\n    ['wheel', 'touchmove', 'pointerdown'].forEach(t => scroller.addEventListener(t, intent, { passive: true }));\n    scroller.addEventListener('keydown', e => { if (/^(Arrow|Page|Home|End| )/.test(e.key)) intent(); });\n    scroller.addEventListener('scroll', () => {\n      const dist = scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop;\n      if (dist < 24) R.stick = true; else if (performance.now() - R.user < 500) R.stick = false;\n      jump.classList.toggle('is-on', !R.stick && dist > 80);\n    }, { passive: true });\n    jump.addEventListener('click', () => { R.stick = true; jump.classList.remove('is-on'); scroller.scrollTo({ top: scroller.scrollHeight, behavior: reduce.matches ? 'auto' : 'smooth' }); });\n    if ('IntersectionObserver' in window) new IntersectionObserver(es => { root._vsVis = es[0].isIntersecting; schedule(); }).observe(root);\n    root.querySelectorAll('.' + P + '-msg[data-role=\"assistant\"]').forEach(ensure);\n    return R;\n  }\n  function stickNow(root) { const R = root._vs; if (R) R.stick = true; }\n  function follow(R, dt) {\n    if (!R.stick) return;\n    const s = R.scroller, max = s.scrollHeight - s.clientHeight, d = max - s.scrollTop;\n    if (d <= .5) return;\n    s.scrollTop = reduce.matches || d > 600 ? max : s.scrollTop + Math.max(1, d * (1 - Math.exp(-dt * 12)));\n  }\n  function add(root, role, text = '') {\n    const R = mount(root), el = document.createElement('div');\n    el.className = P + '-msg'; el.dataset.role = role;\n    (R.scroller.querySelector('.' + P + '-thread') || R.scroller).append(el);\n    if (role === 'assistant') { el.textContent = text; ensure(el); } else el.textContent = text;\n    R.stick = true; requestAnimationFrame(() => { R.scroller.scrollTop = R.scroller.scrollHeight; });\n    return el;\n  }\n  function reset(el) { const st = ensure(el); stop(el); st.gen++; st.versions = []; st.vi = -1; st.buf = ''; st.content.textContent = ''; st.dom = new Map(); el.dataset.state = 'idle'; syncVer(st); }\n\n  const mountAll = () => document.querySelectorAll('.' + P).forEach(mount);\n  window.vsMessageStream = { mount, mountAll, add, stream, append, end, stop, regenerate: el => regenerate(el), reset, parse: tokenize };\n  mountAll(); if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mountAll);\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "AI interfaces",
    "tags": [
      "chat",
      "streaming",
      "llm",
      "markdown",
      "code block",
      "typewriter",
      "sse",
      "ai",
      "thread"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#62e0b0",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/message-stream/index.html",
    "thumbnail": "https://vividsites.app/api/component/message-stream/thumb.jpg",
    "video": "https://vividsites.app/api/component/message-stream/thumb.mp4",
    "page": "https://vividsites.app/c/message-stream"
  }
}