Exclusivobeta
Whose Words <mv-whose-words>
Declaración de autoría para textos de origen mixto: borradores asistidos por IA, documentos escritos a varias manos, pasajes citados, correos electrónicos de plantilla y traducciones. Cada tramo sabe de quién son sus palabras (data-origin en el marcado existente, un array spans o seguimiento en vivo): del autor o de un coautor con nombre, una sugerencia de IA aceptada tal cual, una sugerencia de IA que una persona editó, una cita pegada de una fuente, texto repetitivo de plantilla o una traducción. Un interruptor “Mostrar de quién son las palabras” da a cada origen su propio patrón de subrayado (continuo con un leve fondo para el texto de IA sin editar, discontinuo para el texto de IA editado, doble para las citas, punteado para las plantillas, ondulado para las traducciones, fino para los coautores), un resumen al margen redactado como una frase (“62 % escrito por Lena · 30 % asistido por IA, el 21 % de ello editado · 8 % citado”) con un medidor con patrones y una leyenda que resalta un origen en el texto, y una tarjeta al pasar el cursor o con el foco que dice de dónde y cuándo salió un tramo, el original de una traducción y, para el texto de IA editado, un diff palabra a palabra con “Lena conservó 16 de las 23 palabras sugeridas”. Tres vistas: reader, badge (una declaración compacta publicada como “Escrito con asistencia de IA · Detalles”, derivada de las proporciones o sustituida) y author, que sigue en vivo una región contenteditable o un <textarea> (lo pegado se convierte en cita, escribir dentro de un texto de IA lo convierte en texto de IA editado y deshacer la edición lo devuelve a su estado, escribir en su borde sigue siendo del propio autor, insert() y markOrigin() registran lo que insertó la app) y avisa antes de publicar cuando demasiada parte del borrador es texto de IA que nadie editó, con una revisión paso a paso.
| Categoría | Visualización de datos |
|---|---|
| Tipo | Web Component (<mv-whose-words>) |
| Estado | beta |
| Kit | IA que puedes verificar |
| También instala | button, switch, checkbox |
| Keywords | exclusive, culture, authorship, disclosure, ai, ai-assisted, provenance, attribution, transparency, editor, co-writing, quote, translation, diff, publishing |
When to use
- A publishing tool, CMS or newsletter editor must disclose which parts of an article were suggeste d by AI and how muc h a person edited the min
- A support or sales composer should warn agents before they send a reply that is mostly unedited AI text
- A co-written doc, report or proposal should show who wrote which passage, and what was quoted, templated or translated
- A published page needs a compact AI-assistance badge that readers can expand into a per-passage breakdown
Avoid when
- The question is how much of a document changed across revisions since a signed or approved baseline → use Theseus instead
- Readers need per-passage reliability of generated text rather than who wrote it → use Certainty instead
- The origin of each passage is not actually recorded: guessed or detector-based attributions would mislead readers
Instalación
node scripts/add.mjs whose-words --out ./src/marvelousAgente de IA con el servidor MCP de Marvelous UI: install_components({ slugs: ["whose-words"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Archivos copiados (dependencias incluidas): tokens/tokens.css, core/base.css, core/dismiss.js, core/dom.js, core/element.js, core/motion.js, core/observe.js, core/position.js, components/whose-words/whose-words.js, components/whose-words/whose-words.css, components/button/button.css, components/switch/switch.css, components/checkbox/checkbox.css.
Uso
Inicio rápido, el marcado mínimo que funciona:
<mv-whose-words author="Lena" show>
<p>We cut checkout from 94 to 58 seconds. <span data-origin="ai">Every extra field is a question the customer must answer.</span> <span data-origin="quoted" data-source="Customer interview">“I just want to pay.”</span></p>
</mv-whose-words>Marcado de referencia: parte de él y personalízalo con atributos, data-* y variables CSS:
<div id="ww-demo" style="width:min(100%,66rem);margin-inline:auto">
<style>
#ww-demo { display:grid; gap:1.5rem }
#ww-demo .ww-card { padding:1.5rem 1.75rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs) }
#ww-demo .ww-eyebrow { display:flex; align-items:center; gap:.5rem; margin:0 0 1rem; color:var(--mv-fg-muted); font-size:.75rem; font-weight:500 }
#ww-demo article h3 { margin:0 0 .25rem; font-size:1.375rem; letter-spacing:-.015em; line-height:1.25 }
#ww-demo article .ww-by { margin:0 0 1rem; color:var(--mv-fg-muted); font-size:.8125rem }
#ww-demo article p { margin:0 0 .875rem; font-size:.9375rem; line-height:1.7; color:var(--mv-fg) }
#ww-demo article p:last-child { margin-bottom:0 }
#ww-demo article .ww-foot { padding-top:.75rem; border-top:1px dashed var(--mv-border); color:var(--mv-fg-muted); font-size:.8125rem }
#ww-demo .ww-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:1.5rem; align-items:start }
#ww-demo .ww-help h4 { margin:0 0 .5rem; font-size:1rem; letter-spacing:-.01em }
#ww-demo .ww-help article p { margin:.75rem 0 0; font-size:.875rem; line-height:1.65 }
#ww-demo .ww-draft-head { display:flex; justify-content:space-between; gap:.75rem; margin:0 0 1rem; font-size:.8125rem; color:var(--mv-fg-muted) }
#ww-demo .ww-draft-head b { color:var(--mv-fg); font-weight:600 }
#ww-demo .ww-editor { min-height:9rem; padding:.875rem 1rem; border:1px solid var(--mv-input); border-radius:var(--mv-radius-lg); background:var(--mv-bg); font-size:.875rem; line-height:1.65; outline:none }
#ww-demo .ww-editor:focus-visible { box-shadow:var(--mv-focus-ring) }
#ww-demo .ww-editor p { margin:0 0 .625rem } #ww-demo .ww-editor p:last-child { margin:0 }
#ww-demo .ww-actions { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem; margin-top:1rem }
#ww-demo .ww-actions .ww-status { margin-left:auto; color:var(--mv-fg-muted); font-size:.75rem }
#ww-demo .ww-hint { margin:0; color:var(--mv-fg-muted); font-size:.75rem; text-align:center }
@media (max-width:56rem) { #ww-demo .ww-row { grid-template-columns:minmax(0,1fr) } }
</style>
<section class="ww-card" aria-label="Published article, reader view">
<p class="ww-eyebrow"><span class="mv-badge" data-variant="secondary" data-shape="pill">Reader view</span> Field notes · Payments</p>
<mv-whose-words id="ww-post" author="Lena Fischer" show>
<article>
<h3>How we cut checkout time by 38%</h3>
<p class="ww-by">Lena Fischer, with Omar Haddad · September 18, 2026</p>
<p>For two quarters, our checkout asked for more than it needed. <span data-origin="ai" data-model="Draft assistant" data-at="2026-09-12T10:04:00">Every extra field is a question the customer has to answer before they can pay.</span> We started by counting them: fourteen, on a phone screen, before the Pay button.</p>
<p>The customer interviews said it more bluntly. <span data-origin="quoted" data-source="Customer interview #14, Aug 28" data-href="#interviews" data-at="2026-09-12T10:11:00">“I just want to pay and get back to my day.”</span> <span data-origin="ai-edited" data-model="Draft assistant" data-at="2026-09-12T10:26:00" data-original="The redesigned flow reduced the median checkout time from 94 seconds to 58 seconds, which represents a significant 38% improvement for our customers.">The new flow brought median checkout time down from 94 to 58 seconds, 38% faster, with no drop in average order value.</span></p>
<p><span data-origin="author" data-by="Omar Haddad" data-at="2026-09-13T16:40:00">On the engineering side, we moved card tokenization ahead of the review step, so the Pay button no longer waits on a network round trip.</span> <span data-origin="translated" data-lang="de" data-model="Machine translation" data-at="2026-09-14T09:02:00" data-original="Die Adressprüfung läuft jetzt im Hintergrund und blockiert den Bezahlknopf nicht mehr.">Address validation now runs in the background and no longer blocks the Pay button.</span></p>
<p>We shipped it to 10% of traffic first and watched refunds and support tickets for a week. <span data-origin="ai" data-model="Draft assistant" data-at="2026-09-12T10:31:00">Next, we will test saved payment methods for returning customers and one-tap wallets in six more markets.</span></p>
<p class="ww-foot"><span data-origin="template" data-source="Release note footer" data-at="2026-09-12T09:58:00">Questions about this release? Reach the Payments team in #payments-help.</span></p>
</article>
</mv-whose-words>
</section>
<div class="ww-row">
<section class="ww-card ww-help" aria-label="Help center article, badge view">
<p class="ww-eyebrow"><span class="mv-badge" data-variant="secondary" data-shape="pill">Badge view</span> Help center</p>
<mv-whose-words id="ww-help" view="badge" author="Priya Raman">
<article>
<h4>Reset two-factor authentication</h4>
<p><span data-origin="ai-edited" data-model="Support assistant" data-at="2026-09-02T14:12:00" data-original="If you lost access to your authenticator app, you can reset two-factor authentication from the sign-in page.">Lost your phone or authenticator app? You can reset two-factor authentication yourself from the sign-in page.</span> Choose <b>Use a recovery code</b>, enter one of the codes you saved when you turned it on, then scan the new QR code.</p>
<p><span data-origin="ai" data-model="Support assistant" data-at="2026-09-02T14:15:00">If you no longer have your recovery codes, an admin of your workspace can reset it for you from Members.</span></p>
</article>
</mv-whose-words>
</section>
<section class="ww-card" aria-label="Reply draft, author view">
<p class="ww-eyebrow"><span class="mv-badge" data-variant="secondary" data-shape="pill">Author view</span> Live tracking</p>
<div class="ww-draft-head"><span>Reply to <b>Kenji Watanabe</b></span><span>Ticket #48213 · Refund request</span></div>
<mv-whose-words id="ww-draft" view="author" author="Sofia Marino" summary="none" show>
<div class="ww-editor" contenteditable="true" role="textbox" aria-multiline="true" aria-label="Reply to Kenji Watanabe">
<p>Hi Kenji,</p>
<p><span data-origin="ai" data-model="Reply assistant" data-at="2026-09-24T09:41:00">Thank you for reaching out, and I'm sorry the order arrived damaged. I've issued a full refund of $86.40 to your original payment method; it should appear within 5 to 7 business days.</span> I've also flagged the carrier so it doesn't happen again.</p>
</div>
</mv-whose-words>
<div class="ww-actions">
<button class="mv-button" data-variant="outline" data-size="sm" type="button" id="ww-suggest">Suggest a closing line</button>
<button class="mv-button" data-size="sm" type="button" id="ww-send">Send reply</button>
<span class="ww-status" id="ww-status" role="status">Type inside the highlighted text to edit it</span>
</div>
</section>
</div>
<p class="ww-hint">Hover or Tab through the underlined passages · open the badge’s Details · click a legend row to highlight one origin</p>
<script type="module">
await customElements.whenDefined("mv-whose-words");
const draft = document.getElementById("ww-draft");
const status = document.getElementById("ww-status");
const closings = [
" Please let me know if there is anything else I can help with.",
" If the refund hasn't arrived by next Friday, just reply to this email and I'll follow up with our payments team.",
];
let n = 0;
document.getElementById("ww-suggest").addEventListener("click", () => {
const last = draft.querySelector(".ww-editor p:last-child");
const r = document.createRange();
r.selectNodeContents(last);
r.collapse(false);
const sel = getSelection();
sel.removeAllRanges();
sel.addRange(r);
draft.insert(closings[n++ % closings.length], "ai", { model: "Reply assistant" });
});
document.getElementById("ww-send").addEventListener("click", () => {
const res = draft.checkBeforePublish();
status.textContent = res.ok ? `Sent · disclosed as “${res.label}”` : `Held: ${Math.round(res.unedited * 100)}% unedited AI text`;
});
draft.addEventListener("mv-origin-change", (e) => {
if (e.detail.source === "edit") status.textContent = e.detail.origin === "ai-edited" ? "Edited: now counted as AI-assisted, edited" : "Edit undone: back to AI, as-is";
});
document.querySelector("#ww-post").addEventListener("click", (e) => { if (e.target.closest("a[href^='#']")) e.preventDefault(); });
</script>
</div>Referencia cultural
Cyrano de Bergerac, Edmond Rostand (1897, obra de teatro). Un joven corteja a la mujer que ama con cartas y versos escritos en secreto por otro, y todo el drama descansa en que ella no sabe de quién son las palabras que lee hasta que la verdad, revelada al fin, lo cambia todo. La UI pone esa revelación al alcance en cualquier momento: cada pasaje conserva de quién son realmente sus palabras (del autor, de una IA, editadas o no, una cita, una plantilla, una traducción) y cualquiera puede pedirle al texto que lo muestre.
API
Attributes
| Name | Tipo | Default | Description |
|---|---|---|---|
author | string | Name of the primary author. Unmarked text is theirs; it is used in the summary (“written by Lena”), in the badge (“Written by Lena”) and as data-by of spans the component creates. | |
show | boolean | false | Shows whose words: underline patterns in the text, the margin summary (reader view), hover cards and spoken markers. Reflects the switch. |
view | "reader" | "badge" | "author" | reader | reader: switch + disclosure chip, summary in the margin while shown. badge: a compact published disclosure with a Details button that expands the summary and the switch. author: summary always visible, “Readers see: …” preview of the disclosure and the publish nudge. |
summary | "margin" | "top" | "none" | margin | Where the summary goes. margin needs about 680px of width and falls back to top below that; the margin column is kept while hidden, so toggling never reflows the text. |
nudge-threshold | number (0..1) | 0.4 | Author view: share of the text that is unedited AI suggestion above which the nudge shows and checkBeforePublish() returns ok: false. |
paste-origin | origin | "none" | quoted | Live tracking: origin given to pasted text (as plain text). "none" leaves pastes to the browser, counted as the author’s words. |
badge-label | string | Overrides the disclosure text (otherwise “Written by {author}”, “Written with AI assistance” or “Mostly AI-generated, reviewed by {author}” when unedited AI text is 50% or more). | |
label | string | Whose words | Title of the summary (and its group label). |
locale | BCP 47 tag | en-US | Dates, numbers and language names. |
data-origin | "author" | "ai" | "ai-edited" | "quoted" | "template" | "translated" | On any inline element of the text: its origin. Aliases accepted: human, ai-accepted, edited, quote, pasted, translation. Nested marks: the innermost wins. | |
data-by / data-at / data-model | string / ISO date / string | On a mark: the person responsible (author, co-author, who accepted or edited the suggestion), when it was inserted or last edited, and the AI tool or translation engine. | |
data-original | string | On ai-edited: the suggestion as the AI proposed it (the diff is computed against it). On translated: the source text. Set automatically by live tracking. | |
data-source / data-href / data-lang / data-note | string | On quoted: source title and link; on template: template name; on translated: source language (BCP 47, shown as “From German”); any: a free note shown in the card. | |
data-origin-id | string | Groups several elements into one passage (set automatically). A passage split across paragraphs keeps one id, so its diff and counts stay whole. | |
data-whose-words-doc | boolean | Marks the content element when the host has several children (otherwise the only child, or a wrapper created around them). A <textarea> child enables textarea tracking; a contenteditable child enables live tracking. |
Properties
| Name | Tipo | Description |
|---|---|---|
spans | Array<{ text, origin, by?, at?, model?, original?, source?, href?, lang?, note?, id? }> | Get: the whole text as ordered segments (paragraphs separated by blank lines). Set: renders them (blank lines start a new paragraph) or fills the textarea. Can also come from a <script type="application/json" data-spans> child, or be set before the element is defined. |
stats | { total, words, shares: { human, ai, aiEdited, aiAssisted, quoted, template, translated }, editedOfAI, persons: [{ name, share, primary }], level, label, sentence } | Read-only. Shares are 0..1 by letters (whitespace ignored). level: "empty" | "human" | "assisted" | "generated"; label is the disclosure text; sentence is the summary. |
disclosure | { level, label } | Read-only: what the badge says, for use elsewhere (RSS, meta tags, an export). |
strings | object | Overrides any built-in English text (keys as in the source, e.g. { toggle: "Show origins" }). |
author / show / view / summary / nudgeThreshold / pasteOrigin / badgeLabel / label / locale | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
markOrigin(range, origin, meta?) | Records the origin of text the app inserted or knows about. range: a DOM Range inside the text, { start, end } character offsets (the only form for a textarea), or an element. meta: { by, at, model, original, source, href, lang, note }. AI text marked "ai" is snapshotted, so a later human edit is detected. Returns the first marked element (or the offsets). |
insert(text, origin = "ai", meta?) | Inserts text at the caret (or at the end of the last paragraph) with its origin, e.g. an accepted AI suggestion, and fires an input event so frameworks stay in sync. Returns the new span (or the offsets). |
toggle(force?) | Shows or hides whose words (through the same cancelable event as the switch). Returns false if canceled. |
reviewNext() | Author view: shows whose words and selects the next AI passage nobody edited (the review button calls it). Returns it, or null when none is left. |
checkBeforePublish() | Returns { ok, unedited, level, label }. When not ok, the nudge flashes, focus moves to its review button and the reason is announced: call it from your Publish or Send button. |
refresh() | Re-reads the markup (changes inside the text are already observed). |
Events
| Name | Description |
|---|---|
mv-origin-change | A passage got or changed its origin. detail: { origin, previous, text, source: "api" | "edit" | "paste", target (element or null), range ({ start, end } for a textarea), stats }. Cancelable only for source "paste" (fired before the paste): preventDefault() lets the paste count as the author’s words. |
mv-disclosure-toggle | Cancelable. detail: { part: "text", show, source: "switch" | "api" | "review" | "legend" } when the marks are shown or hidden, or { part: "details", expanded } when the badge’s details open or close. |
mv-nudge | Author view: unedited AI text just crossed nudge-threshold. detail: { unedited, threshold, passages, level }. |
Content structure
| Name | Description |
|---|---|
(content) | One element holding the text (an <article>, a contenteditable <div> or a <textarea>). Marks are data-origin attributes on its inline elements; the component only adds data-origin-id, data-person, data-spot, data-ww-active and, while shown in read mode, tabindex, aria-describedby and a visually hidden marker, all removed when hidden. |
CSS classes
| Name | Description |
|---|---|
mv-whose-words-bar / -toggle / -chip / -details | Header: the switch (input.mv-switch role=switch), the disclosure chip (data-level), and in badge view the Details button. |
mv-whose-words-nudge | Author view notice before publishing; data-flash while checkBeforePublish() draws attention to it. |
mv-whose-words-aside / -sentence / -meter / -seg / -legend / -key / -swatch | Summary: sentence, patterned meter (data-key per origin, data-person per co-author) and legend buttons (aria-pressed) that highlight one origin. |
mv-whose-words-card | Hover, focus or caret card (top layer). data-pinned after a click or Enter: links become clickable and it closes with Escape. |
mv-whose-words-field / -mirror | Textarea tracking: the wrapper and the aligned mirror that draws the marks under the textarea’s own text. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-whose-words-ai | violet (light-dark) | AI suggestions, as-is and edited. |
--mv-whose-words-quoted / -template / -translated | blue / gray / green | Other origins. |
--mv-whose-words-author / --mv-whose-words-person-2…5 | neutral / amber, pink, teal, olive | Primary author (meter) and co-authors. |
--mv-whose-words-margin | 15.5rem | Width of the margin column. |
--mv-whose-words-offset | 0.26em | Underline offset of the marks. |
Accessibility
Nothing is added to the reading experience until someone asks: with the marks hidden, the text is untouched for screen readers. The toggle is a native checkbox with role="switch" and a visible label; showing or hiding announces the summary sentence in a polite live region. The summary is always real text (“62% written by Lena · 30% AI-assisted, 21% of it edited · 8% quoted”), in a group labeled by its title; the meter is decorative and aria-hidden, and the legend is a list of toggle buttons (aria-pressed) whose labels carry the same underline pattern as the text. Origins never rely on color: each has its own line style (solid with a wash, dashed, double, dotted, wavy, thin), repeated in the meter as fill patterns. While shown in read mode, every marked passage gets a visually hidden start marker (“AI suggestion, edited:”) so continuous reading hears where each origin begins, becomes a tab stop and is described (aria-describedby) by a full sentence: origin, who, when, how many suggested words were kept, the original suggestion, source or source-language text. Focus or hover opens the card; Enter, Space or a click pins it as a labeled dialog with focus moved inside (Escape closes it and returns focus); all of this is removed when hidden. In live editing (contenteditable or textarea) passages are not tab stops: moving the caret into one opens the card and announces its origin once, and the review button selects each unedited AI passage in turn. The badge’s Details button is a disclosure (aria-expanded, aria-controls). Reduced motion (OS or data-motion="reduce"): no card, panel or nudge animation. Forced colors: marks use CanvasText (Highlight for unedited AI text), meter patterns are redrawn in system colors, borders use CanvasText.