Exclusivobeta
Unsourced <mv-unsourced>
Fundamentação no nível de cada afirmação para textos gerados ou longos (respostas de IA, respostas de suporte, resumos de pesquisa, relatórios): cada afirmação mostra sozinha, no lugar, se algo a sustenta. Envolva um texto cujas afirmações estão marcadas com <mark data-claim> (ou passe o texto mais os trechos das afirmações por posição ou por correspondência exata, no formato que as APIs de grounding devolvem) e uma biblioteca de fontes; cada afirmação é sourced, weak, contradicted, unsourced, opinion ou checking, derivado das suas evidências (source, quote, stance supports | partial | contradicts, score) ou definido explicitamente. As afirmações fundamentadas ganham pequenos chips de fonte numerados como notas de rodapé; uma afirmação sem fundamento ganha uma cauda fina e discreta a partir da última palavra, mais longa quanto mais dela se apoia em nada (tamanho da afirmação × sustentação que falta), com uma forma distinta por status (anel aberto = sem fonte, barra de parada = contradita, pontilhado = fraca) e uma etiqueta “No source” ao passar o mouse ou no foco, e a cauda se recolhe em um chip no momento em que uma fonte é anexada. Passar o mouse, focar ou clicar em um chip abre as evidências: cada fonte com a citação correspondente, com as palavras em comum com a afirmação destacadas; as afirmações sem sustentação oferecem Find a source (mv-find-source cancelável cujo waitUntil(promise) permite que o app faça a busca e anexe o que encontrar), Mark as opinion e Remove claim (reversível no lugar, e toText() devolve o texto sem ela). Uma barra de resumo mostra “8 claims · 3 sourced · 1 weak · 2 unsourced” sobre um medidor de cobertura, percorre as afirmações que precisam de fonte (teclas [ e ]) e pode atenuar todo o resto; uma lista numerada de Sources opcional fecha o texto.
| Categoria | Exibição de dados |
|---|---|
| Tipo | Web Component (<mv-unsourced>) |
| Status | beta |
| Kit | IA que você pode verificar |
| Também instala | button |
| Keywords | exclusive, culture, ai, llm, grounding, citations, sources, claims, fact-check, hallucination, verification, evidence, provenance, footnotes, review, support-reply, rag, annotation |
When to use
- An AI-drafted support reply or email must be checked for promises and facts that no policy or record backs before it is sent
- An AI answer or research summary should show which statements are cited, weakly supported, contradicted or unsupported
- A grounding or fact-check service returns claim spans with evidence and the text needs inline citations plus a review pass
- Reviewers must step through unsupported claims one by one and fix, soften or remove each without leaving the text
Avoid when
Instalação
node scripts/add.mjs unsourced --out ./src/marvelousAgente de IA com o servidor MCP do Marvelous UI: install_components({ slugs: ["unsourced"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Arquivos copiados (dependências incluídas): tokens/tokens.css, core/base.css, core/dismiss.js, core/dom.js, core/element.js, core/motion.js, core/position.js, components/unsourced/unsourced.js, components/unsourced/unsourced.css, components/button/button.css.
Uso
Início rápido, a menor marcação que funciona:
<mv-unsourced>
<script type="application/json" data-sources>[{ "id": "returns", "title": "Returns policy" }]</script>
<p><mark data-claim data-cites="returns">Damaged items are refunded within 30 days</mark>, and <mark data-claim>we'll waive the $12 restocking fee</mark>.</p>
</mv-unsourced>Marcação de referência, para usar como ponto de partida e personalizar com atributos, data-* e variáveis CSS:
<div id="us-demo" style="width:min(100%,50rem);margin-inline:auto">
<style>
#us-demo { display:grid; gap:2rem }
#us-demo .us-card { display:grid; gap:1rem; padding:1.25rem 1.5rem 1.25rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs) }
#us-demo .us-head { display:flex; align-items:flex-start; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap }
#us-demo .us-head h3 { margin:0; font-size:1rem; letter-spacing:-.01em }
#us-demo .us-head p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#us-demo .us-ask { margin:0; padding:.7rem .9rem; border-radius:var(--mv-radius-lg); background:var(--mv-bg-muted); color:var(--mv-fg); font-size:.875rem; line-height:1.5 }
#us-demo .us-ask b { display:block; margin-bottom:.15rem; color:var(--mv-fg-muted); font-size:.75rem; font-weight:600 }
#us-demo mv-unsourced { font-size:.975rem }
#us-demo mv-unsourced p { margin:0 0 .75rem }
#us-demo .us-foot { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding-top:1rem; border-top:1px solid var(--mv-border) }
#us-demo .us-state { display:flex; align-items:center; gap:.5rem; color:var(--mv-fg-muted); font-size:.8125rem }
#us-demo .us-actions { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap }
#us-demo .us-foot .mv-choice { font-size:.8125rem }
#us-demo details { font-size:.8125rem; color:var(--mv-fg-muted) }
#us-demo summary { cursor:pointer; width:max-content; border-radius:var(--mv-radius-sm) }
#us-demo summary:focus-visible { outline:none; box-shadow:var(--mv-focus-ring) }
#us-demo pre { margin:.5rem 0 0; padding:.75rem .9rem; border-radius:var(--mv-radius-md); background:var(--mv-bg-subtle); color:var(--mv-fg); font:.8125rem/1.55 var(--mv-font-sans); white-space:pre-wrap }
#us-demo .us-hint { margin:-.75rem 0 0; color:var(--mv-fg-muted); font-size:.75rem; text-align:center }
#us-demo kbd { padding:0 .3em; border:1px solid var(--mv-border); border-bottom-width:2px; border-radius:4px; font:600 .6875rem/1.4 var(--mv-font-mono); color:var(--mv-fg) }
</style>
<!-- 1 · AI-drafted support reply, annotated markup -->
<section class="us-card" aria-labelledby="us-t1">
<header class="us-head">
<div>
<h3 id="us-t1">Reply draft</h3>
<p>Ticket #58214 · Ana Oliveira · Order NW-48213</p>
</div>
<span class="mv-badge" data-variant="secondary" data-shape="pill">AI draft</span>
</header>
<p class="us-ask"><b>Ana Oliveira · 9:12 AM</b>Hi, my Brewline French Press arrived with a cracked lid. Can I get a refund, and do I have to pay for return shipping?</p>
<mv-unsourced id="us-reply" label="Reply grounding">
<script type="application/json" data-sources>[
{ "id": "order", "title": "Order NW-48213", "publisher": "Order history", "date": "2026-09-18", "snippet": "Delivered Sep 18, 2026, 2:14 PM · FedEx Ground · signed by A. Oliveira." },
{ "id": "returns", "title": "Returns & refunds policy", "url": "https://help.example.com/returns", "publisher": "Help Center", "date": "2026-03-04" },
{ "id": "shipping", "title": "Return shipping FAQ", "url": "https://help.example.com/return-shipping", "publisher": "Help Center", "date": "2026-05-12" },
{ "id": "warranty", "title": "Warranty terms", "url": "https://help.example.com/warranty", "publisher": "Help Center", "date": "2025-11-20" }
]</script>
<script type="application/json" data-claims>[
{ "id": "c2", "evidence": [{ "source": "returns", "quote": "Items that arrive damaged or defective can be refunded or replaced free of charge within 30 days of delivery." }] },
{ "id": "c3", "evidence": [
{ "source": "returns", "quote": "For damaged items, we cover return shipping and email a prepaid label within one business day." },
{ "source": "shipping", "quote": "Prepaid return labels are sent by email and work at any FedEx or UPS drop-off point." }
] },
{ "id": "c4", "evidence": [{ "source": "returns", "stance": "contradicts", "quote": "Refunds are issued to the original payment method 5 to 10 business days after the carrier scans the return." }] },
{ "id": "c7", "evidence": [{ "source": "warranty", "stance": "partial", "score": 0.35, "quote": "Brewline accessories are covered by our standard limited warranty." }] }
]</script>
<p>Hi Ana, thanks for reaching out, and I'm sorry the lid arrived cracked.</p>
<p><mark data-claim="c1" data-cites="order">Your order was delivered on September 18</mark>, and <mark data-claim="c2">damaged items can be refunded or replaced free of charge within 30 days of delivery</mark>, so you're well within the window. <mark data-claim="c3">Return shipping is on us: we'll email you a prepaid label</mark>.</p>
<p><mark data-claim="c4">Once the carrier scans the package, your refund will post within 2 business days</mark>. <mark data-claim="c5">We'll also waive the $12 restocking fee as a courtesy</mark>.</p>
<p>If you'd rather keep the press, <mark data-claim="c6">replacement lids ship within 24 hours</mark>, and <mark data-claim="c7">the glass carafe is covered by a 2-year warranty</mark>. <mark data-claim="c8" data-support="opinion">A replacement is probably the faster option for you</mark>.</p>
<p>Best,<br>Jordan, Customer Care</p>
</mv-unsourced>
<details id="us-plain">
<summary>Plain text that will be sent</summary>
<pre id="us-plain-text"></pre>
</details>
<footer class="us-foot">
<label class="mv-choice">
<input type="checkbox" role="switch" class="mv-switch" id="us-chips" checked>
<span class="mv-choice-text"><span class="mv-choice-title">Source numbers</span></span>
</label>
<div class="us-actions">
<span class="us-state" id="us-state" aria-live="polite"></span>
<button class="mv-button" type="button" data-size="sm" id="us-send">Send reply</button>
</div>
</footer>
</section>
<!-- 2 · Research summary from data: text + claim spans, references list -->
<section class="us-card" aria-labelledby="us-t2">
<header class="us-head">
<div>
<h3 id="us-t2">Churn analysis summary</h3>
<p>Generated from five internal sources · rendered from text and claim spans</p>
</div>
<button class="mv-button" type="button" data-variant="outline" data-size="sm" id="us-recheck">Re-check sources</button>
</header>
<mv-unsourced id="us-brief" references attention="unsourced contradicted weak" filter-label="Show only claims to review" label="Summary grounding"></mv-unsourced>
</section>
<p class="us-hint">Hover or Tab to a claim to see its evidence · <kbd>[</kbd> <kbd>]</kbd> step through the claims that need a source</p>
<script type="module">
await customElements.whenDefined("mv-unsourced");
const $ = (id) => document.getElementById(id);
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
/* ── 1 · Reply draft ─────────────────────────────── */
const reply = $("us-reply");
const send = $("us-send");
const state = $("us-state");
const plain = $("us-plain-text");
// What the app's retrieval finds (a real app would query its knowledge base here).
const found = {
c6: [{ source: { id: "parts", title: "Replacement parts inventory", publisher: "Inventory · live", date: "2026-09-24" }, quote: "Brewline French Press lids: 148 in stock, ship within 24 hours of order." }],
};
reply.addEventListener("mv-find-source", (e) => {
e.detail.waitUntil(wait(1100).then(() => found[e.detail.id] ?? []));
});
const paint = () => {
if (!reply.isConnected) return;
const s = reply.stats;
state.textContent = s.attention
? `${s.attention} claim${s.attention > 1 ? "s" : ""} to fix before sending`
: "Every claim is sourced or marked as opinion";
send.dataset.variant = s.attention ? "outline" : "";
if (!s.attention) send.removeAttribute("data-variant");
plain.textContent = reply.toText();
};
reply.addEventListener("mv-claim-change", () => queueMicrotask(paint));
send.addEventListener("click", () => {
const s = reply.stats;
if (s.attention) { reply.next(); return; }
send.textContent = "Sent";
send.disabled = true;
});
$("us-chips").addEventListener("change", (e) => {
if (e.target.checked) reply.removeAttribute("citations");
else reply.setAttribute("citations", "hidden");
});
requestAnimationFrame(paint);
/* ── 2 · Summary from data ───────────────────────── */
const brief = $("us-brief");
brief.sources = [
{ id: "dashboard", title: "Churn dashboard", url: "https://metrics.example.com/churn", publisher: "Metrics", date: "2026-09-01" },
{ id: "survey", title: "Cancellation exit survey, August", url: "https://wiki.example.com/research/exit-survey-aug", publisher: "Research ops", date: "2026-09-03" },
{ id: "interviews", title: "Customer interviews, round 3 (n=24)", publisher: "Research ops", date: "2026-08" },
{ id: "experiment", title: "Pricing experiment #14 readout", url: "https://wiki.example.com/growth/exp-14", publisher: "Growth team", date: "2026-08-27" },
{ id: "tickets", title: "Support tickets export", publisher: "Support", date: "2026-09-02" },
];
const claims = [
{ id: "b1", match: "Monthly churn rose to 4.1% in August, up from 3.2% in June", evidence: [{ source: "dashboard", quote: "Monthly plan churn: Jun 3.2% · Jul 3.6% · Aug 4.1%." }] },
{ id: "b2", match: "most customers who left named price as the main reason", evidence: [{ source: "survey", quote: "Price was the top reason for cancelling: 58% of 212 responses in August." }] },
{ id: "b3", match: "the July price increase is the likely trigger", evidence: [{ source: "interviews", stance: "partial", score: 0.3, quote: "Several customers mentioned the new price, but half of them had downgraded before July." }] },
{ id: "b4", match: "Annual plans were largely unaffected", evidence: [{ source: "dashboard", quote: "Annual plan churn: Jun 0.9% · Aug 1.0%." }] },
{ id: "b5", match: "Two competitors now bundle onboarding for free" },
{ id: "b6", match: "the $29 tier lifted trial-to-paid conversion by 9%", evidence: [{ source: "experiment", stance: "contradicts", quote: "Trial-to-paid conversion for the $29 tier: +4.2% (not statistically significant)." }] },
{ id: "b7", match: "Billing tickets doubled over the same period", evidence: [{ source: "tickets", quote: "Billing tickets: 312 in June, 655 in August." }] },
{ id: "b8", match: "Reverting monthly pricing before Q4 would recover most of the lost revenue" },
];
brief.text = "Monthly churn rose to 4.1% in August, up from 3.2% in June, and most customers who left named price as the main reason: the July price increase is the likely trigger. Annual plans were largely unaffected. Two competitors now bundle onboarding for free.\n\nIn the pricing experiment, the $29 tier lifted trial-to-paid conversion by 9%. Billing tickets doubled over the same period. Reverting monthly pricing before Q4 would recover most of the lost revenue.";
brief.claims = claims;
const teardown = [{ source: { id: "teardown", title: "Competitive teardown, Q3", publisher: "Product marketing", date: "2026-09-10" }, quote: "Two of our three main competitors now include guided onboarding in every paid plan at no extra cost." }];
brief.addEventListener("mv-find-source", (e) => {
e.detail.waitUntil(wait(1200).then(() => (e.detail.id === "b5" ? teardown : [])));
});
// Re-check: every claim goes back to “checking”, then resolves one by one.
let run = 0;
$("us-recheck").addEventListener("click", async () => {
const id = ++run;
brief.close();
brief.claims = claims.map((c) => ({ ...c, support: "pending" }));
await wait(700);
for (const c of claims) {
if (id !== run || !brief.isConnected) return;
brief.updateClaim(c.id, { support: null });
await wait(380);
}
});
</script>
</div>Referência cultural
As Aventuras de Pinóquio, Carlo Collodi (1883, livro). Toda vez que o menino de madeira conta uma mentira o nariz dele cresce, então uma inverdade fica visível sozinha, bem em quem a disse, sem que ninguém precise apontá-la. Aqui, cada afirmação sem sustentação ganha uma cauda discreta na própria ponta, mais longa quanto mais dela se apoia em nada, e ela se recolhe em um chip de fonte numerado assim que uma evidência é anexada.
API
Attributes
| Name | Tipo | Default | Description |
|---|---|---|---|
attention | space-separated statuses | unsourced contradicted | Which statuses need attention: they are counted in stats.attention, reached by the stepper and the [ ] keys, and stay lit when the filter is on. Add weak for a stricter review. |
filter | boolean | Dims everything except the claims that need attention (ink fades, their text gets an underline in its status color). Reflects the bar's toggle. | |
filter-label | string | Show only unsourced | Text of the filter toggle (e.g. “Show only claims to review” when attention includes weak). |
summary | "true" | "false" | true | false hides the summary bar (counts, coverage meter, stepper, filter). The [ ] keys and the live announcements keep working. |
citations | "hidden" | Hides the numbered source chips in the text (tails, lens and references are unchanged), for a cleaner reading view. | |
references | boolean | Appends a numbered Sources list after the text: title (linked when http/https), publisher, en-US date, and how many claims each source backs or contradicts. | |
label | string | Claim grounding | Accessible name of the summary bar. |
data-claim | string (id) | On any inline element (<mark> recommended) inside the component: makes it a claim. The value is its id (auto-generated when empty) used by the claims data, events and methods. Nested claims are ignored; claims added later (streaming) are picked up. | |
data-cites / data-contradicts | space-separated source ids | On a claim: sources that support it / contradict it. The quote defaults to the source's snippet. | |
data-support | sourced | weak | contradicted | unsourced | opinion | pending | On a claim: explicit status, overriding the one derived from evidence (claims data with a support key wins over it). | |
data-quote / data-note | string | On a claim: quote for its first cited source / note shown in the lens instead of the default status sentence. | |
data-status / data-attention / data-active / data-current / data-searching | set by the component | On claims: computed status (or removed), needs attention, lens open, reached by the stepper, retrieval running. The host gets data-state="review | clear | empty", data-filter and data-inspecting. |
Properties
| Name | Tipo | Description |
|---|---|---|
sources | Array<{ id, title, url?, publisher?, date?, snippet? }> | Source library. Also read from a <script type="application/json" data-sources> child. Only http(s) URLs become links (new tab); ISO dates are shown as “Mar 4, 2026”. |
claims | Array<{ id, support?, note?, evidence?, start?, end?, match? }> | Claim data, merged with the markup by id (also read from a <script type="application/json" data-claims> child). evidence: [{ source: id | {…source}, quote?, stance?: "supports" | "partial" | "contradicts", score?: 0..1 }]. With text set, start/end (UTF-16 offsets) or match (exact substring, searched in order) place each claim. Reading returns a snapshot per claim: { id, index, text, status, label, attention, note, evidence (with number), opinion, removed, searching, element }. |
text | string | null | Data mode: the component renders this plain text itself (text nodes only, never HTML) into a [data-unsourced-body] container, one <p> per blank-line-separated paragraph, with a <mark data-claim> per claim span. A span never crosses a paragraph break; overlapping spans are dropped. |
stats | { total, sourced, weak, contradicted, unsourced, opinion, pending, removed, attention, coverage } | Read-only tally. total excludes removed claims; coverage = sourced / (total − opinion − pending), null when nothing is factual. |
attention / filter / filterLabel / summary / citations / references / label | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
select(id | element) | Moves focus to a claim, scrolls it into view and opens its evidence. Returns false for unknown or removed claims. |
next() / previous() | Steps to the next / previous claim that needs attention (wrapping), like the stepper and the ] and [ keys. Returns false when none is left. |
updateClaim(id, patch) | Merges { support, evidence, note } into a claim's data (support: null returns to the derived status), e.g. to resolve a pending check. Emits mv-claim-change when the status changes. |
addEvidence(id, evidence | evidence[]) | Attaches evidence found later (when mv-find-source was handled without waitUntil). A supporting source makes an unsourced or weak claim sourced; the tail retracts into a chip. |
removeClaim(id) / restoreClaim(id) / markOpinion(id, on = true) | Programmatic versions of the lens actions. Return false when nothing changed or the change was canceled. |
toText() | The current text as plain text (blank line between blocks), without removed claims and without the component's own marks; punctuation orphaned by a removed sentence is dropped. |
close() | Closes the evidence lens. |
Events
| Name | Description |
|---|---|
mv-claim-select | A claim was opened by click, keyboard focus, a chip, the stepper or select() (not by hover). detail: the claim snapshot plus trigger: "click" | "focus" | "chip" | "step" | "api". |
mv-find-source | Find a source was pressed. Cancelable (preventDefault: nothing happens). detail: { id, text, claim, waitUntil(promise) }. Resolve the promise with evidence (an array, { evidence }, or a single item; inline source objects are added to the library) and it is attached; resolve with nothing for “No source found”, reject for “The search failed”. Without waitUntil the lens says “Search requested” and the app calls addEvidence() later. |
mv-claim-change | A claim's status is changing. detail: { id, action: "remove" | "restore" | "opinion" | "claim" | "evidence" | "update", from, to, user, claim }. Cancelable when the user pressed Remove claim, Undo, Mark as opinion or Treat as a claim (user: true): preventDefault() vetoes it, e.g. a claim that legal requires. |
Content structure
| Name | Description |
|---|---|
(content) | Any text (paragraphs, lists, headings) containing [data-claim] elements, plus optional JSON <script data-sources> / <script data-claims> children. The summary bar is prepended; the Sources list, the lens (a popover) and a visually hidden live region are appended. Claim text is moved into a .mv-unsourced-text wrapper, never rewritten. |
CSS classes
| Name | Description |
|---|---|
mv-unsourced-claim | Every claim (data-status, data-attention…). Inside: .mv-unsourced-text (the author's text), .mv-unsourced-marks (aria-hidden: .mv-unsourced-chips > .mv-unsourced-chip[data-n][data-stance] and .mv-unsourced-tail > .mv-unsourced-tail-line + .mv-unsourced-tail-label) and a visually hidden status suffix. |
mv-unsourced-stub | “Claim removed · Undo” button inserted after a removed claim. |
mv-unsourced-bar | Summary bar: .mv-unsourced-counts (per-status .mv-unsourced-count with a .mv-unsourced-glyph), .mv-unsourced-meter (.mv-unsourced-seg[data-status]), .mv-unsourced-stepper (.mv-unsourced-step, .mv-unsourced-pos) and .mv-unsourced-filter[aria-pressed]. |
mv-unsourced-lens | Evidence popover (data-status): .mv-unsourced-lens-head, -note, .mv-unsourced-evidence > .mv-unsourced-ev[data-stance] (number, title link, .mv-unsourced-stance, meta, .mv-unsourced-quote with .mv-unsourced-hit words), .mv-unsourced-lens-find and .mv-unsourced-actions (.mv-button). |
mv-unsourced-refs | Optional numbered Sources list (.mv-unsourced-ref-list > .mv-unsourced-ref). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-unsourced-color | warning mixed with fg | Tone of unsourced claims: tail, tag, meter segment, active filter. |
--mv-unsourced-contradicted | danger mixed with fg | Tone of contradicted claims and contradicting evidence. |
--mv-unsourced-weak | warning mixed with fg-muted | Tone of weakly supported claims (dotted tail, dashed chips). |
--mv-unsourced-grounded | var(--mv-accent) | Tone of sourced claims (chip hover, meter segment). |
--mv-unsourced-tail-min / --mv-unsourced-tail-max | 0.75em / 2.75em | Shortest and longest tail; the length in between follows --_g, set per claim (0..1, output). |
--mv-unsourced-leading | 1.75 | Line height of the text (leaves room for the chips and the status tag). |
Accessibility
Status never relies on color: each claim is a keyboard stop (role=button, aria-haspopup="dialog", aria-expanded) whose accessible name is its own text plus a visually hidden suffix (“(no source)”, “(sourced, 2 sources)”, “(weak support, 1 source)”, “(contradicted by 1 source)”, “(marked as opinion)”, “(checking sources)”), while the chips and tails are aria-hidden and drawn with distinct shapes (numbered chip, open ring, stop bar, dotted line, dashed line). Keyboard focus opens the evidence without moving focus; Enter or Space moves into it (source links, then the actions), Tab past its last control resumes reading right after the claim, Shift+Tab goes back to the claim and Escape closes it and returns focus. Arrow keys, Home and End move between claims; ] and [ jump to the next and previous claim that needs a source from anywhere inside the component (never while typing in a field), as do the stepper buttons (aria-keyshortcuts, “2 of 3”). The filter is a real toggle button (aria-pressed). The lens is a non-modal role="dialog" labelled by its status line, placed with a manual popover and also closed by an outside click; touch taps open and close it. Removing a claim moves focus to its “Claim removed · Undo” button, whose name quotes the removed text, and Undo returns focus to the claim. A polite live region announces each action and search result followed by the new tally (“Source attached: Replacement parts inventory. Claim is now sourced. 8 claims · 4 sourced · …”), debounced, and never on page load. Source links open in a new tab and say so. Reduced motion (OS setting or data-motion="reduce"): tails and chips appear and retract instantly, the checking pulse stops. Forced colors: tails, chips and tags use CanvasText with borders, highlighted quote words use Mark, focus uses Highlight.