{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "source-cards",
  "type": "registry:block",
  "title": "Source Cards",
  "description": "An AI answer with inline citation pills. Hover or tab to a pill and a source hovercard springs out of it, glides between pills without closing, highlights the cited clause and lifts the matching source card.",
  "docs": "Source Cards was added to public/vividsites/source-cards.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/source-cards",
  "categories": [
    "ui",
    "ai-interfaces"
  ],
  "files": [
    {
      "path": "components/vividsites/source-cards.html",
      "type": "registry:file",
      "target": "public/vividsites/source-cards.html",
      "content": "<!--\nvs-source-cards: an AI answer with inline citations. A row of source cards sits on top;\n  each citation pill in the text opens a hovercard that springs out of the pill, flips above\n  or below at the viewport edge, and stays open while the pointer travels into it. Moving\n  between pills glides the open card to the next one instead of closing and reopening it.\n  The cited clause gets a highlight that wipes through it in reading order and the matching\n  source card lifts; hovering a source card makes every pill that cites it glow.\n\n  Markup (all plain HTML, readable without JS):\n    <article class=\"vs-source-cards\">\n      <ol class=\"vs-source-cards-list\">                    the sources, in citation order\n        <li><a href=\"https://…\" data-date=\"Mar 2025\" data-excerpt=\"Quoted line…\">Title</a></li>\n      </ol>                                                domain = the link's host (or data-name),\n                                                           tile colour from the domain (or data-color)\n      <div class=\"vs-source-cards-answer\"><p>\n        Clause one. <a class=\"vs-source-cards-cite\" data-src=\"1\"></a>\n        Clause two. <a class=\"vs-source-cards-cite\" data-src=\"2,3\"></a>   several sources\n      </p></div>\n    </article>\n  The clause a pill highlights is the text between it and the previous pill (or the start of\n  its paragraph), so keep pills as direct children of the paragraph. Instead of the <ol> you\n  may put <script type=\"application/json\">[{\"url\",\"title\",\"excerpt\",\"date\",\"name\"}]</script>\n  inside the article, with an empty <ol class=\"vs-source-cards-list\"> where the cards go.\n\n  Attributes on .vs-source-cards: data-theme=\"dark\", data-style=\"filled\" (card look),\n    data-highlight=\"off\", data-cite-click=\"toggle\" (click toggles the card instead of\n    following the link; on touch a tap always toggles), data-manual (don't auto-mount).\n  JS: vsSourceCards.mount(el) -> { open(citeEl | index, sourceIndex), close(), refresh(), destroy(), sources }\n    Call refresh() after you stream new text or pills into the answer.\n  Events (bubble from the article): \"vs-source-cards:open\" { cite, source, index, sources },\n    \"vs-source-cards:close\".\n  Keys: Tab to a pill opens its card, ← → switch sources on a multi-source pill, Esc closes.\n  The card is role=\"tooltip\" and wired with aria-describedby. Reduced motion: no springs.\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=Mulish:wght@400;500;600&family=DM+Mono:wght@400&display=swap\" rel=\"stylesheet\">\n<article class=\"vs-source-cards\" aria-label=\"Answer with sources\">\n  <h2 class=\"vs-source-cards-q\">How do tidal marshes store carbon?</h2>\n\n  <div class=\"vs-source-cards-label\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M8 2.2 14 5 8 7.8 2 5z\"/><path d=\"m2 8 6 2.8L14 8M2 11l6 2.8 6-2.8\"/></svg>Sources <b>4</b></div>\n  <ol class=\"vs-source-cards-list\">\n    <li><a href=\"https://tidewatch.net/notes/marsh-carbon\" data-date=\"Mar 2025\"\n      data-excerpt=\"Most of a marsh's carbon never sees daylight. It sits in roots and peat a metre down, where it can stay for thousands of years.\">Why a salt marsh buries carbon faster than a forest</a></li>\n    <li><a href=\"https://fenwick-bay.org/cores\" data-date=\"Aug 2024\"\n      data-excerpt=\"The surface rose 3 to 6 mm a year through the last century, and peat at the base of the deepest cores is over 4,000 years old.\">What sixty sediment cores say about the Fenwick marshes</a></li>\n    <li><a href=\"https://marshnotes.io/anoxic-soils\" data-date=\"Jan 2025\"\n      data-excerpt=\"With oxygen shut out, decay slows to a crawl, and the sulphate in seawater keeps methane-making microbes in check.\">Waterlogged soil, slow microbes: the chemistry of marsh peat</a></li>\n    <li><a href=\"https://blueledger.co/marsh-value\" data-date=\"Jun 2025\"\n      data-excerpt=\"Restoration came in well below most engineered carbon removal, before counting flood protection or the fisheries it supports.\">What a hectare of restored marsh is worth in carbon</a></li>\n  </ol>\n\n  <div class=\"vs-source-cards-label\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"round\" aria-hidden=\"true\"><path d=\"M2.5 4h11M2.5 8h11M2.5 12h7\"/></svg>Answer</div>\n  <div class=\"vs-source-cards-answer\">\n    <p>Tidal marshes keep most of their carbon underground, in roots and peat rather than in the grass you can see. <a class=\"vs-source-cards-cite\" data-src=\"1\"></a> Twice a day the tide drops fine sediment that buries dead roots and leaves, and cores show the surface climbing a few millimetres a year to keep pace with the sea. <a class=\"vs-source-cards-cite\" data-src=\"2\"></a></p>\n    <p>Because that soil stays waterlogged, oxygen can't reach it, so microbes break the material down very slowly and much of it stays put for millennia. <a class=\"vs-source-cards-cite\" data-src=\"3,2\"></a> Per hectare, a healthy marsh can bury carbon several times faster than a mature forest, which makes restoring drained marshland one of the cheaper climate projects on a coast. <a class=\"vs-source-cards-cite\" data-src=\"1,4\"></a></p>\n  </div>\n</article>\n\n<style>\n.vs-source-cards, .vs-source-cards-hc {\n  --vs-source-cards-accent: #0f8b8d;                 /* pills, highlight, lit card ring */\n  --vs-source-cards-bg: #ffffff;                     /* the answer card */\n  --vs-source-cards-card: #ffffff;                   /* each source card (outlined style) */\n  --vs-source-cards-ink: #0e1a20;                    /* text */\n  --vs-source-cards-dim: rgba(14,26,32,.55);         /* domains, labels, excerpts */\n  --vs-source-cards-line: rgba(14,26,32,.11);        /* hairlines */\n  --vs-source-cards-radius: 22px;\n  --vs-source-cards-width: 47.5rem;\n  --vs-source-cards-font: \"Mulish\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", sans-serif;\n  --vs-source-cards-mono: \"DM Mono\", ui-monospace, \"SF Mono\", Menlo, monospace;\n  --vs-source-cards-shadow: 0 0 0 .5px rgba(14,26,32,.1), inset 0 1px 0 rgba(255,255,255,.9), 0 1px 2px rgba(14,26,32,.04), 0 14px 34px -14px rgba(14,26,32,.16), 0 44px 90px -44px rgba(14,26,32,.26);\n  --vs-source-cards-hl: color-mix(in srgb, var(--vs-source-cards-accent) 15%, transparent);\n  --vs-source-cards-acc-ink: color-mix(in srgb, var(--vs-source-cards-accent) 78%, var(--vs-source-cards-ink));\n  --vs-source-cards-ease: cubic-bezier(.16,1,.3,1);\n}\n.vs-source-cards[data-theme=\"dark\"], .vs-source-cards-hc[data-theme=\"dark\"] {\n  --vs-source-cards-bg: #12181b; --vs-source-cards-card: #182125; --vs-source-cards-ink: #e6eef0;\n  --vs-source-cards-dim: rgba(230,238,240,.52); --vs-source-cards-line: rgba(230,238,240,.09);\n  --vs-source-cards-shadow: 0 0 0 .5px rgba(255,255,255,.08), inset 0 1px 0 rgba(255,255,255,.05), 0 14px 34px -14px rgba(0,0,0,.6), 0 44px 90px -44px rgba(0,0,0,.8);\n  --vs-source-cards-hl: color-mix(in srgb, var(--vs-source-cards-accent) 19%, transparent);\n  --vs-source-cards-acc-ink: color-mix(in srgb, var(--vs-source-cards-accent) 55%, #fff);\n}\n.vs-source-cards, .vs-source-cards *, .vs-source-cards-hc, .vs-source-cards-hc * { box-sizing: border-box; }\n\n.vs-source-cards {\n  width: min(100%, var(--vs-source-cards-width)); min-width: 0; padding: 28px 30px 30px; border-radius: var(--vs-source-cards-radius);\n  background: var(--vs-source-cards-bg); color: var(--vs-source-cards-ink); box-shadow: var(--vs-source-cards-shadow);\n  font-family: var(--vs-source-cards-font); -webkit-font-smoothing: antialiased; text-align: left;\n}\n.vs-source-cards-q { margin: 0; font-size: 1.4rem; line-height: 1.3; font-weight: 600; letter-spacing: -.018em; text-wrap: balance; }\n.vs-source-cards-label {\n  display: flex; align-items: center; gap: 7px; margin: 22px 0 11px; font-size: .8rem; font-weight: 600; letter-spacing: .01em; color: var(--vs-source-cards-dim);\n}\n.vs-source-cards-label svg { width: 15px; height: 15px; flex: none; }\n.vs-source-cards-label b { font: 400 .72rem/1 var(--vs-source-cards-mono); font-variant-numeric: tabular-nums; padding: 3px 6px; border-radius: 6px; background: color-mix(in srgb, var(--vs-source-cards-ink) 6%, transparent); }\n\n/* the row of source cards */\n.vs-source-cards-list {\n  list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 9px;\n}\n.vs-source-cards-list > li { display: flex; min-width: 0; }\n.vs-source-cards-card {\n  position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 9px; padding: 11px 12px 12px; border-radius: 13px;\n  color: inherit; text-decoration: none; background: var(--vs-source-cards-card);\n  box-shadow: 0 0 0 .5px var(--vs-source-cards-line), 0 0 0 .5px var(--vs-source-cards-line), 0 1px 2px rgba(14,26,32,.05);\n  transition: transform .5s var(--vs-source-cards-ease), box-shadow .35s ease, background-color .3s ease; will-change: transform;\n  -webkit-tap-highlight-color: transparent;\n}\n.vs-source-cards[data-style=\"filled\"] .vs-source-cards-card { background: color-mix(in srgb, var(--vs-source-cards-ink) 4.5%, transparent); box-shadow: none; }\n.vs-source-cards-card:hover { background: color-mix(in srgb, var(--vs-source-cards-card) 92%, var(--vs-source-cards-accent)); }\n.vs-source-cards[data-style=\"filled\"] .vs-source-cards-card:hover { background: color-mix(in srgb, var(--vs-source-cards-ink) 7%, transparent); }\n.vs-source-cards-card:active { transform: scale(.975); transition-duration: .12s; }\n.vs-source-cards-card:focus-visible { outline: 2px solid var(--vs-source-cards-accent); outline-offset: 2px; }\n.vs-source-cards .vs-source-cards-card.is-lit {\n  transform: translateY(-4px);\n  box-shadow: 0 0 0 1px color-mix(in srgb, var(--vs-source-cards-accent) 55%, transparent), 0 10px 24px -10px color-mix(in srgb, var(--vs-source-cards-accent) 45%, rgba(14,26,32,.3)), 0 2px 6px rgba(14,26,32,.06);\n}\n.vs-source-cards-meta { display: flex; align-items: center; gap: 7px; min-width: 0; font: 400 .72rem/1 var(--vs-source-cards-mono); color: var(--vs-source-cards-dim); }\n.vs-source-cards-dom { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.vs-source-cards-n { flex: none; font-variant-numeric: tabular-nums; opacity: .8; }\n.vs-source-cards-card.is-lit .vs-source-cards-n { color: var(--vs-source-cards-acc-ink); opacity: 1; }\n.vs-source-cards-tile {\n  flex: none; display: grid; place-items: center; width: 17px; height: 17px; border-radius: 5px;\n  font: 700 .62rem/1 var(--vs-source-cards-font); color: #fff; text-transform: uppercase;\n  background: var(--c, #3a4953); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.18), inset 0 -1px 0 rgba(0,0,0,.15);\n}\n.vs-source-cards-ttl {\n  font-size: .82rem; line-height: 1.38; font-weight: 500; letter-spacing: -.003em;\n  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;\n}\n\n/* the answer */\n.vs-source-cards-answer { font-size: 1.04rem; line-height: 1.75; color: color-mix(in srgb, var(--vs-source-cards-ink) 88%, transparent); text-wrap: pretty; }\n.vs-source-cards-answer p { margin: 0 0 .7em; }\n.vs-source-cards-answer p:last-child { margin-bottom: 0; }\n.vs-source-cards-claim {\n  border-radius: 4px; padding: 1px 0;\n  background-image: linear-gradient(var(--vs-source-cards-hl), var(--vs-source-cards-hl));\n  background-repeat: no-repeat; background-size: 0% 100%; background-position: 0 0;\n  transition: background-size .55s var(--vs-source-cards-ease), color .3s ease;\n}\n.vs-source-cards-claim.is-hl { background-size: 100% 100%; color: var(--vs-source-cards-ink); }\n.vs-source-cards-claim.is-soft { background-size: 100% 100%; background-image: linear-gradient(color-mix(in srgb, var(--vs-source-cards-accent) 8%, transparent), color-mix(in srgb, var(--vs-source-cards-accent) 8%, transparent)); }\n.vs-source-cards[data-highlight=\"off\"] .vs-source-cards-claim { background-size: 0% 100%; }\n.vs-source-cards-cite {\n  display: inline-flex; align-items: center; gap: 3px; height: 1.2rem; padding: 0 .38rem; margin: 0 .05rem 0 .2rem; vertical-align: .08em;\n  border-radius: 6px; font: 400 .7rem/1 var(--vs-source-cards-mono); font-variant-numeric: tabular-nums; text-decoration: none; white-space: nowrap;\n  color: var(--vs-source-cards-acc-ink); background: color-mix(in srgb, var(--vs-source-cards-accent) 12%, transparent);\n  box-shadow: inset 0 0 0 .5px color-mix(in srgb, var(--vs-source-cards-accent) 22%, transparent);\n  transition: background-color .22s ease, color .22s ease, box-shadow .4s var(--vs-source-cards-ease), transform .4s var(--vs-source-cards-ease);\n  cursor: pointer; -webkit-tap-highlight-color: transparent; touch-action: manipulation;\n}\n.vs-source-cards-cite i { width: 2px; height: 2px; border-radius: 50%; background: currentColor; opacity: .6; }\n.vs-source-cards-cite:hover, .vs-source-cards-cite.is-open { color: #fff; background: var(--vs-source-cards-accent); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.2), 0 2px 8px -2px color-mix(in srgb, var(--vs-source-cards-accent) 60%, transparent); }\n.vs-source-cards-cite:active { transform: scale(.92); }\n.vs-source-cards-cite:focus-visible { outline: 2px solid var(--vs-source-cards-accent); outline-offset: 2px; }\n.vs-source-cards-cite.is-glow {\n  color: #fff; background: var(--vs-source-cards-accent); transform: translateY(-1px);\n  box-shadow: 0 0 0 3.5px color-mix(in srgb, var(--vs-source-cards-accent) 20%, transparent), 0 3px 12px -2px color-mix(in srgb, var(--vs-source-cards-accent) 70%, transparent);\n}\n\n/* the hovercard (moved to <body> by the script) */\n.vs-source-cards-hc {\n  position: fixed; left: 0; top: 0; z-index: 2147482000; width: min(21rem, calc(100vw - 24px)); padding: 13px 15px 0; border-radius: 15px;\n  background: var(--vs-source-cards-bg); color: var(--vs-source-cards-ink); font-family: var(--vs-source-cards-font); -webkit-font-smoothing: antialiased;\n  box-shadow: 0 0 0 .5px rgba(14,26,32,.12), inset 0 1px 0 rgba(255,255,255,.9), 0 2px 4px rgba(14,26,32,.04), 0 16px 36px -12px rgba(14,26,32,.24), 0 36px 70px -30px rgba(14,26,32,.3);\n  opacity: 0; will-change: transform, opacity; overflow: hidden;\n}\n.vs-source-cards-hc[data-theme=\"dark\"] { box-shadow: 0 0 0 .5px rgba(255,255,255,.1), inset 0 1px 0 rgba(255,255,255,.06), 0 16px 36px -12px rgba(0,0,0,.6), 0 36px 70px -30px rgba(0,0,0,.7); }\n.vs-source-cards-hc[hidden] { display: none; }\n.vs-source-cards-hc-view { position: relative; }\n.vs-source-cards-hc-body { will-change: transform, opacity; }\n.vs-source-cards-hc-body.is-out { position: absolute; left: 0; right: 0; top: 0; pointer-events: none; }\n.vs-source-cards-hc-top { display: flex; align-items: center; gap: 8px; font: 400 .74rem/1 var(--vs-source-cards-mono); color: var(--vs-source-cards-dim); }\n.vs-source-cards-hc-top .vs-source-cards-tile { width: 20px; height: 20px; border-radius: 6px; font-size: .7rem; }\n.vs-source-cards-hc-dom { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.vs-source-cards-hc-top time { flex: none; font-variant-numeric: tabular-nums; }\n.vs-source-cards-hc-ttl {\n  margin: 11px 0 0; font-size: .95rem; line-height: 1.36; font-weight: 600; letter-spacing: -.01em; min-height: 2.72em;\n  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;\n}\n.vs-source-cards-hc-ex {\n  margin: 9px 0 0; padding-left: 10px; border-left: 2px solid color-mix(in srgb, var(--vs-source-cards-accent) 40%, transparent);\n  font-size: .82rem; line-height: 1.5; color: var(--vs-source-cards-dim); min-height: 4.5em;\n  display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;\n}\n.vs-source-cards-hc-foot {\n  display: flex; align-items: center; gap: 8px; margin: 12px -15px 0; padding: 7px 8px 7px 15px; min-height: 42px;\n  border-top: .5px solid var(--vs-source-cards-line); background: color-mix(in srgb, var(--vs-source-cards-ink) 2.5%, transparent);\n}\n.vs-source-cards-hc-tabs { display: flex; gap: 4px; margin-right: auto; }\n.vs-source-cards-hc-tabs:empty::before { content: attr(data-label); font-size: .76rem; color: var(--vs-source-cards-dim); }\n.vs-source-cards-hc-tab {\n  all: unset; box-sizing: border-box; display: inline-flex; align-items: center; gap: 5px; height: 24px; padding: 0 7px 0 4px; border-radius: 7px; cursor: pointer;\n  font: 400 .7rem/1 var(--vs-source-cards-mono); color: var(--vs-source-cards-dim); transition: background-color .2s, color .2s;\n}\n.vs-source-cards-hc-tab .vs-source-cards-tile { width: 16px; height: 16px; font-size: .58rem; border-radius: 4.5px; }\n.vs-source-cards-hc-tab:hover { color: var(--vs-source-cards-ink); background: color-mix(in srgb, var(--vs-source-cards-ink) 5%, transparent); }\n.vs-source-cards-hc-tab[aria-selected=\"true\"] { color: var(--vs-source-cards-ink); background: color-mix(in srgb, var(--vs-source-cards-ink) 7%, transparent); box-shadow: inset 0 0 0 .5px var(--vs-source-cards-line); }\n.vs-source-cards-hc-tab:focus-visible, .vs-source-cards-hc-open:focus-visible { outline: 2px solid var(--vs-source-cards-accent); outline-offset: 1px; }\n.vs-source-cards-hc-open {\n  display: inline-flex; align-items: center; gap: 5px; height: 26px; padding: 0 9px 0 10px; border-radius: 8px; flex: none;\n  font-size: .78rem; font-weight: 600; color: var(--vs-source-cards-acc-ink); text-decoration: none; transition: background-color .2s, transform .3s var(--vs-source-cards-ease);\n}\n.vs-source-cards-hc-open svg { width: 13px; height: 13px; transition: transform .35s var(--vs-source-cards-ease); }\n.vs-source-cards-hc-open:hover { background: color-mix(in srgb, var(--vs-source-cards-accent) 11%, transparent); }\n.vs-source-cards-hc-open:hover svg { transform: translate(1.5px, -1.5px); }\n.vs-source-cards-hc-open:active { transform: scale(.95); }\n\n@media (max-width: 600px) {\n  .vs-source-cards { padding: 22px 18px 22px; border-radius: 18px; }\n  .vs-source-cards-q { font-size: 1.2rem; }\n  .vs-source-cards-list {\n    display: flex; overflow-x: auto; scroll-snap-type: x mandatory; scroll-padding: 0 18px; overscroll-behavior-x: contain;\n    margin: 0 -18px; padding: 4px 18px 6px; scrollbar-width: none;\n  }\n  .vs-source-cards-list::-webkit-scrollbar { display: none; }\n  .vs-source-cards-list > li { flex: 0 0 68%; scroll-snap-align: start; }\n  .vs-source-cards-answer { font-size: .98rem; line-height: 1.7; }\n}\n@media (prefers-reduced-motion: reduce) {\n  .vs-source-cards *, .vs-source-cards-hc * { transition-duration: .01ms !important; }\n}\n</style>\n\n<script>\n(() => {\n  if (window.vsSourceCards) return;\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  const esc = s => String(s == null ? '' : s).replace(/[&<>\"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;' }[c]));\n  const host = u => { try { return new URL(u, location.href).hostname.replace(/^www\\./, ''); } catch (e) { return u || ''; } };\n  // letter-tile \"favicons\": a colour picked from the domain, from a set that all read well under white type\n  const TINTS = ['#1d7a74', '#2f5fd0', '#c0552b', '#8a4a7a', '#2f7a45', '#3a4953', '#a87a1f', '#bf4760', '#256f9c'];\n  const tint = s => { let h = 7; for (const c of s) h = (h * 31 + c.charCodeAt(0)) >>> 0; return TINTS[h % TINTS.length]; };\n  const tile = s => `<span class=\"vs-source-cards-tile\" style=\"--c:${esc(s.color)}\" aria-hidden=\"true\">${esc(s.domain.replace(/[^a-z0-9]/gi, '').charAt(0))}</span>`;\n  const OPEN = '<svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M5.5 10.5 10.5 5.5M6.5 5.5h4v4\"/></svg>';\n  let uid = 0;\n\n  function mount(root) {\n    if (root.__vsSc) return root.__vsSc;\n    const id = 'vs-sc-' + (++uid);\n    let sources = [], cites = [], claims = new Map();\n\n    // ---- read sources: <script type=\"application/json\"> or the <ol class=\"vs-source-cards-list\">\n    const list = root.querySelector('.vs-source-cards-list');\n    function readSources() {\n      const json = root.querySelector('script[type=\"application/json\"]');\n      if (json) { try { sources = JSON.parse(json.textContent).map(s => ({ ...s })); } catch (e) { sources = []; } }\n      else if (list) sources = [...list.children].map(li => { const a = li.querySelector('a') || li; const d = { ...li.dataset, ...a.dataset };\n        return { url: a.getAttribute('href') || '', title: d.title || a.textContent.trim(), excerpt: d.excerpt || '', date: d.date || '', name: d.name || '', color: d.color || '' }; });\n      sources.forEach((s, i) => { s.n = i + 1; s.domain = s.name || host(s.url); s.color = s.color || tint(s.domain); });\n      if (!list) return;\n      if (json && !list.children.length) list.innerHTML = sources.map(s => `<li><a href=\"${esc(s.url)}\">${esc(s.title)}</a></li>`).join('');\n      [...list.children].forEach((li, i) => {\n        const s = sources[i]; if (!s) return; const a = li.querySelector('a') || li;\n        a.classList.add('vs-source-cards-card'); a.dataset.n = s.n;\n        a.innerHTML = `<span class=\"vs-source-cards-meta\">${tile(s)}<span class=\"vs-source-cards-dom\">${esc(s.domain)}</span><span class=\"vs-source-cards-n\">${s.n}</span></span><span class=\"vs-source-cards-ttl\">${esc(s.title)}</span>`;\n        a.setAttribute('aria-label', `Source ${s.n}: ${s.title}, ${s.domain}`);\n        if (a.tagName === 'A' && !a.target && /^https?:/.test(a.getAttribute('href') || '')) { a.target = '_blank'; a.rel = 'noopener'; }\n      });\n    }\n\n    // ---- wrap each cited clause: the run of nodes between the previous cite (or block start) and this cite\n    function wrapClaims() {\n      cites = [...root.querySelectorAll('.vs-source-cards-cite')];\n      cites.forEach((c, ci) => {\n        const nums = (c.dataset.src || '').split(/[\\s,]+/).map(Number).filter(n => sources[n - 1]);\n        c.__nums = nums;\n        if (!c.__ready) {\n          c.__ready = true;\n          c.innerHTML = nums.map(n => `<span>${n}</span>`).join('<i></i>');\n          const first = sources[nums[0] - 1];\n          if (first && first.url && (!c.getAttribute('href') || c.getAttribute('href') === '#')) c.setAttribute('href', first.url);\n          if (c.tagName !== 'A' && !c.hasAttribute('tabindex')) c.tabIndex = 0;\n          c.setAttribute('aria-label', (nums.length > 1 ? 'Sources ' : 'Source ') + nums.map(n => `${n}, ${sources[n - 1].domain}`).join('; '));\n        }\n        if (claims.has(c)) return;\n        let run = [], n = c.previousSibling;\n        while (n && !(n.nodeType === 1 && (n.classList.contains('vs-source-cards-cite') || n.classList.contains('vs-source-cards-claim')))) { run.unshift(n); n = n.previousSibling; }\n        if (!run.some(x => x.textContent.trim())) { // an adjacent cite: share the previous clause\n          const prev = n && n.nodeType === 1 && n.classList.contains('vs-source-cards-cite') ? claims.get(n) : null; claims.set(c, prev || null); return;\n        }\n        const f = run[0]; if (f.nodeType === 3) { const k = f.data.search(/\\S/); if (k > 0) run[0] = f.splitText(k); }\n        const l = run[run.length - 1]; if (l.nodeType === 3) { const m = l.data.match(/\\s+$/); if (m && m.index > 0) l.splitText(m.index); }\n        const span = document.createElement('span'); span.className = 'vs-source-cards-claim';\n        c.parentNode.insertBefore(span, run[0]); run.forEach(x => { if (x.parentNode === c.parentNode && x !== c) span.appendChild(x); });\n        // anything we split off after the clause stays outside; re-check the text node right before the cite\n        claims.set(c, span);\n      });\n    }\n\n    // ---- the hovercard (one per answer, portalled to <body> so no ancestor clips it)\n    const hc = document.createElement('div');\n    hc.className = 'vs-source-cards-hc'; hc.id = id + '-card'; hc.setAttribute('role', 'tooltip'); hc.hidden = true;\n    hc.innerHTML = `<div class=\"vs-source-cards-hc-view\"></div><div class=\"vs-source-cards-hc-foot\"><div class=\"vs-source-cards-hc-tabs\" role=\"tablist\" aria-label=\"Sources for this claim\" data-label=\"1 source\"></div><a class=\"vs-source-cards-hc-open\" target=\"_blank\" rel=\"noopener\">Open ${OPEN}</a></div>`;\n    document.body.appendChild(hc);\n    const view = hc.querySelector('.vs-source-cards-hc-view'), tabs = hc.querySelector('.vs-source-cards-hc-tabs'), openA = hc.querySelector('.vs-source-cards-hc-open');\n    const VARS = ['accent', 'bg', 'card', 'ink', 'dim', 'line', 'font', 'mono', 'hl', 'acc-ink'];\n    const sync = () => { // the card follows the answer's theme and any --vs-source-cards-* you set on it\n      hc.dataset.theme = root.dataset.theme || 'light'; const cs = getComputedStyle(root);\n      VARS.forEach(k => { const v = cs.getPropertyValue('--vs-source-cards-' + k).trim(); if (v) hc.style.setProperty('--vs-source-cards-' + k, v); });\n    };\n    sync(); const mo = new MutationObserver(sync); mo.observe(root, { attributes: true, attributeFilter: ['style', 'class', 'data-theme'] });\n\n    // springs: x, y (position), s (open 0..1). Semi-implicit Euler at 120 Hz, interruptible, velocity carries over\n    const S = { x: { x: 0, v: 0, g: 0, k: 380, c: 34 }, y: { x: 0, v: 0, g: 0, k: 380, c: 34 }, s: { x: 0, v: 0, g: 0, k: 520, c: 38 } };\n    let raf = 0, last = 0, below = true, origin = '50% 0';\n    const paint = () => {\n      const s = Math.max(0, S.s.x), o = Math.min(1, s * 1.35);\n      hc.style.opacity = o.toFixed(3);\n      hc.style.transformOrigin = origin;\n      hc.style.transform = `translate3d(${S.x.x.toFixed(2)}px,${(S.y.x + (1 - Math.min(1, s)) * (below ? -7 : 7)).toFixed(2)}px,0) scale(${(.93 + .07 * s).toFixed(4)})`;\n    };\n    const tick = now => {\n      const dt = Math.min(.064, (now - last) / 1000 || .016); last = now; let busy = false;\n      for (const k in S) { const p = S[k];\n        if (reduce.matches) { p.x = p.g; p.v = 0; continue; }\n        if (Math.abs(p.v) < .01 && Math.abs(p.x - p.g) < .005) { p.x = p.g; p.v = 0; continue; }\n        busy = true; const n = Math.max(1, Math.round(dt * 120)), h = dt / n;\n        for (let i = 0; i < n; i++) { p.v += (-p.k * (p.x - p.g) - p.c * p.v) * h; p.x += p.v * h; } }\n      paint();\n      if (!busy && S.s.g === 0) hc.hidden = true;\n      raf = busy ? requestAnimationFrame(tick) : 0;\n    };\n    const wake = () => { if (!raf) { last = performance.now(); raf = requestAnimationFrame(tick); } };\n\n    let cur = null, idx = 0, openT = 0, closeT = 0, glowFor = 0;\n    const visible = () => !hc.hidden && S.s.g === 1 && S.s.x > .25;\n    function target(c) {\n      const r = c.getBoundingClientRect(), w = hc.offsetWidth, h = hc.offsetHeight, m = 12, gap = 9;\n      const x = Math.max(m, Math.min(innerWidth - w - m, r.left + r.width / 2 - w / 2));\n      below = r.bottom + gap + h <= innerHeight - m || r.top - gap - h < m;\n      origin = `${(r.left + r.width / 2 - x).toFixed(1)}px ${below ? '-4px' : (h + 4) + 'px'}`;\n      return { x, y: below ? r.bottom + gap : r.top - gap - h };\n    }\n    function body(s) {\n      const d = document.createElement('div'); d.className = 'vs-source-cards-hc-body';\n      d.innerHTML = `<div class=\"vs-source-cards-hc-top\">${tile(s)}<span class=\"vs-source-cards-hc-dom\">${esc(s.domain)}</span>${s.date ? `<time>${esc(s.date)}</time>` : ''}</div>\n        <p class=\"vs-source-cards-hc-ttl\">${esc(s.title)}</p>${s.excerpt ? `<p class=\"vs-source-cards-hc-ex\">${esc(s.excerpt)}</p>` : ''}`;\n      return d;\n    }\n    function show(i, dir) {\n      const nums = cur.__nums; idx = (i + nums.length) % nums.length; const s = sources[nums[idx] - 1];\n      const old = view.querySelector('.vs-source-cards-hc-body:not(.is-out)'), nb = body(s);\n      view.appendChild(nb);\n      if (old) {\n        if (reduce.matches || !dir && !visible()) old.remove();\n        else {\n          old.classList.add('is-out'); const dx = (dir || 1) * 10;\n          old.animate([{ opacity: 1, transform: 'none' }, { opacity: 0, transform: `translateX(${-dx}px)`, filter: 'blur(2px)' }], { duration: 200, easing: 'ease-out', fill: 'forwards' }).onfinish = () => old.remove();\n          nb.animate([{ opacity: 0, transform: `translateX(${dx}px)`, filter: 'blur(2px)' }, { opacity: 1, transform: 'none', filter: 'blur(0)' }], { duration: 380, easing: 'cubic-bezier(.16,1,.3,1)' });\n        }\n      }\n      view.querySelectorAll('.is-out').forEach((o, k, all) => { if (k < all.length - 1) o.remove(); });\n      tabs.innerHTML = nums.length > 1 ? nums.map((n, k) => `<button type=\"button\" class=\"vs-source-cards-hc-tab\" role=\"tab\" aria-selected=\"${k === idx}\" data-k=\"${k}\" tabindex=\"${k === idx ? 0 : -1}\">${tile(sources[n - 1])}${n}</button>`).join('') : '';\n      tabs.dataset.label = `Source ${s.n} of ${sources.length}`;\n      openA.href = s.url || '#'; openA.setAttribute('aria-label', `Open ${s.domain} in a new tab`);\n      lit(s.n);\n      root.dispatchEvent(new CustomEvent('vs-source-cards:open', { bubbles: true, detail: { cite: cur, source: s, index: idx, sources: nums.map(n => sources[n - 1]) } }));\n    }\n    function lit(n) {\n      root.querySelectorAll('.vs-source-cards-card').forEach(a => {\n        const on = +a.dataset.n === n; a.classList.toggle('is-lit', on);\n        // on a narrow screen the row scrolls: bring the lit card into view\n        if (on && list && list.scrollWidth > list.clientWidth + 2) {\n          const lr = list.getBoundingClientRect(), ar = a.getBoundingClientRect(), pad = parseFloat(getComputedStyle(list).scrollPaddingLeft) || 0;\n          if (ar.left < lr.left + pad - 1 || ar.right > lr.right + 1) list.scrollTo({ left: list.scrollLeft + ar.left - lr.left - pad, behavior: reduce.matches ? 'auto' : 'smooth' });\n        }\n      });\n    }\n    function open(c, i = 0) {\n      clearTimeout(openT); clearTimeout(closeT);\n      if (typeof c === 'number') c = cites[c]; if (!c || !c.__nums || !c.__nums.length) return;\n      const was = visible(), same = c === cur;\n      if (cur && !same) { cur.classList.remove('is-open'); cur.removeAttribute('aria-describedby'); const cl = claims.get(cur); cl && cl.classList.remove('is-hl'); }\n      const prevIdx = cites.indexOf(cur);\n      cur = c; c.classList.add('is-open'); c.setAttribute('aria-describedby', hc.id);\n      const cl = claims.get(c); cl && cl.classList.add('is-hl');\n      hc.hidden = false;\n      if (!same || !was) show(i, was && !same ? (cites.indexOf(c) > prevIdx ? 1 : -1) : 0);\n      const t = target(c);\n      if (!was) { S.x.x = S.x.g = t.x; S.y.x = S.y.g = t.y; S.x.v = S.y.v = 0; if (S.s.x < .02) S.s.v = 0; }\n      else { S.x.g = t.x; S.y.g = t.y; }\n      S.s.g = 1; wake();\n    }\n    function close() {\n      clearTimeout(openT); clearTimeout(closeT); if (!cur) return;\n      cur.classList.remove('is-open'); cur.removeAttribute('aria-describedby');\n      const cl = claims.get(cur); cl && cl.classList.remove('is-hl');\n      lit(0); cur = null; S.s.g = 0; wake();\n      root.dispatchEvent(new CustomEvent('vs-source-cards:close', { bubbles: true }));\n    }\n    const later = (fn, ms) => { clearTimeout(closeT); closeT = setTimeout(fn, ms); };\n\n    // ---- input: hover intent with grace, focus, touch toggles, keys\n    let lastPtr = 'mouse';\n    const citeOf = e => e.target.closest && e.target.closest('.vs-source-cards-cite');\n    const onDown = e => { lastPtr = e.pointerType || 'mouse'; if (cur && !hc.contains(e.target) && !citeOf(e)) close(); };\n    const onOver = e => {\n      const c = citeOf(e); if (c && e.pointerType !== 'touch' && root.contains(c)) {\n        clearTimeout(closeT); clearTimeout(openT);\n        if (visible()) open(c); else openT = setTimeout(() => open(c), 90);\n      }\n      const card = e.target.closest && e.target.closest('.vs-source-cards-card');\n      if (card && root.contains(card)) glow(+card.dataset.n);\n    };\n    const onOut = e => {\n      const c = citeOf(e); if (c && e.pointerType !== 'touch' && !(e.relatedTarget && c.contains(e.relatedTarget))) { clearTimeout(openT); if (cur) later(close, 280); }\n      const card = e.target.closest && e.target.closest('.vs-source-cards-card');\n      if (card && !(e.relatedTarget && card.contains(e.relatedTarget))) glow(0);\n    };\n    function glow(n) {\n      if (glowFor === n) return; glowFor = n;\n      cites.forEach(c => { const on = !!n && c.__nums.includes(n); c.classList.toggle('is-glow', on); const cl = claims.get(c); cl && cl.classList.toggle('is-soft', on); });\n    }\n    const onClick = e => {\n      const c = citeOf(e); if (!c || !root.contains(c)) return;\n      if (lastPtr === 'touch' || root.dataset.citeClick === 'toggle') { e.preventDefault(); if (cur === c && S.s.g === 1) close(); else open(c); }\n    };\n    const onFocus = e => { const c = citeOf(e); if (c && c.matches(':focus-visible')) open(c); const card = e.target.closest && e.target.closest('.vs-source-cards-card'); if (card) glow(+card.dataset.n); };\n    const onBlur = e => {\n      const c = citeOf(e); if (c && cur === c && !(e.relatedTarget && hc.contains(e.relatedTarget))) later(close, 60);\n      if (e.target.closest && e.target.closest('.vs-source-cards-card')) glow(0);\n    };\n    const onKey = e => {\n      if (!cur) return;\n      if (e.key === 'Escape') { const c = cur; close(); if (hc.contains(document.activeElement)) c.focus(); return; }\n      if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft') && cur.__nums.length > 1 && (document.activeElement === cur || hc.contains(document.activeElement))) {\n        e.preventDefault(); const d = e.key === 'ArrowRight' ? 1 : -1; show(idx + d, d);\n        const t = tabs.querySelector('[aria-selected=\"true\"]'); if (hc.contains(document.activeElement) && t) t.focus();\n      }\n    };\n    hc.addEventListener('pointerenter', () => clearTimeout(closeT));\n    hc.addEventListener('pointerleave', e => { if (e.pointerType !== 'touch') later(close, 220); });\n    hc.addEventListener('focusout', e => { if (!(e.relatedTarget && (hc.contains(e.relatedTarget) || e.relatedTarget === cur))) later(close, 60); });\n    tabs.addEventListener('pointerover', e => { const t = e.target.closest('[data-k]'); if (t && e.pointerType !== 'touch' && +t.dataset.k !== idx) show(+t.dataset.k, +t.dataset.k > idx ? 1 : -1); });\n    tabs.addEventListener('click', e => { const t = e.target.closest('[data-k]'); if (t && +t.dataset.k !== idx) show(+t.dataset.k, +t.dataset.k > idx ? 1 : -1); });\n    const onMove = () => { if (cur && !hc.hidden) { const t = target(cur); S.x.x = S.x.g = t.x; S.y.x = S.y.g = t.y; paint(); } };\n\n    root.addEventListener('pointerover', onOver); root.addEventListener('pointerout', onOut); root.addEventListener('click', onClick);\n    root.addEventListener('focusin', onFocus); root.addEventListener('focusout', onBlur);\n    document.addEventListener('pointerdown', onDown, true); document.addEventListener('keydown', onKey);\n    addEventListener('scroll', onMove, { passive: true, capture: true }); addEventListener('resize', onMove);\n\n    function refresh() { readSources(); wrapClaims(); }\n    refresh();\n    const api = {\n      get sources() { return sources; },\n      open: (c, i) => open(c, i || 0), close, refresh,\n      destroy() {\n        close(); mo.disconnect(); hc.remove();\n        root.removeEventListener('pointerover', onOver); root.removeEventListener('pointerout', onOut); root.removeEventListener('click', onClick);\n        root.removeEventListener('focusin', onFocus); root.removeEventListener('focusout', onBlur);\n        document.removeEventListener('pointerdown', onDown, true); document.removeEventListener('keydown', onKey);\n        removeEventListener('scroll', onMove, { capture: true }); removeEventListener('resize', onMove); delete root.__vsSc;\n      },\n    };\n    root.__vsSc = api; return api;\n  }\n\n  window.vsSourceCards = { mount };\n  const auto = () => document.querySelectorAll('.vs-source-cards:not([data-manual])').forEach(mount);\n  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', auto); else auto();\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "AI interfaces",
    "tags": [
      "ai",
      "citations",
      "sources",
      "hovercard",
      "answer",
      "tooltip",
      "rag"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#0f8b8d",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/source-cards/index.html",
    "thumbnail": "https://vividsites.app/api/component/source-cards/thumb.jpg",
    "video": "https://vividsites.app/api/component/source-cards/thumb.mp4",
    "page": "https://vividsites.app/c/source-cards"
  }
}