익스클루시브

Whose Words <mv-whose-words>

출처가 섞인 텍스트를 위한 작성자 공개 표시입니다. AI 지원 초안, 공동 작성 문서, 인용 구절, 템플릿 이메일, 번역문에 적합합니다.

문화적 레퍼런스

시라노 드 베르주라크, 에드몽 로스탕 (1897, 희곡). 한 젊은이가 다른 사람이 몰래 써 준 편지와 대사로 사랑하는 여인에게 구애하고, 극 전체는 그녀가 누구의 말을 읽고 있는지 모른다는 사실 위에 서 있다가, 마침내 진실이 드러나며 모든 것이 바뀝니다. UI는 그 폭로를 언제든 가능하게 합니다. 모든 구절은 실제로 누구의 말인지(작성자, AI, 편집 여부, 인용, 템플릿, 번역)를 기억하며, 누구나 텍스트에 그것을 보여 달라고 요청할 수 있습니다.

작동 방식

출처가 섞인 텍스트를 위한 작성자 공개: AI 보조 초안, 공동 작성 문서, 인용 구절, 템플릿 이메일, 번역. 모든 구간은 누구의 말인지 알고 있습니다(기존 마크업의 data-origin, spans 배열 또는 실시간 추적). 작성자 본인이나 이름이 지정된 공동 작성자의 말, 그대로 수락된 AI 제안, 사람이 편집한 AI 제안, 출처에서 붙여 넣은 인용, 템플릿 상용구 또는 번역입니다. “Show whose words” 스위치는 출처마다 고유한 밑줄 패턴을 부여하고(편집되지 않은 AI 텍스트는 희미한 배경이 있는 실선, 편집된 AI 텍스트는 파선, 인용은 이중선, 템플릿은 점선, 번역은 물결선, 공동 작성자는 가는 선), 문장으로 쓰인 여백 요약(“62% written by Lena · 30% AI-assisted, 21% of it edited · 8% quoted”)을 패턴 미터 및 텍스트에서 한 출처를 강조하는 범례와 함께 보여 주며, 호버나 포커스 카드는 구간이 어디서 언제 왔는지, 번역의 원문, 그리고 편집된 AI 텍스트의 경우 “Lena kept 16 of 23 suggested words”와 함께 단어 단위 diff를 보여 줍니다. 세 가지 보기가 있습니다. reader, badge(“Written with AI assistance · Details” 같은 간결한 공개 표시로, 비율에서 도출되거나 재정의됨), 그리고 author입니다. author는 contenteditable 영역이나 <textarea>를 실시간으로 추적하며(붙여 넣은 내용은 인용이 되고, AI 텍스트 안에서 입력하면 편집된 AI 텍스트가 되며 편집을 취소하면 다시 되돌아가고, 그 가장자리에서 입력한 내용은 작성자 본인의 것으로 남으며, insert()와 markOrigin()은 앱이 삽입한 내용을 기록), 초안 중 아무도 편집하지 않은 AI 텍스트가 너무 많으면 게시 전에 단계별 검토와 함께 알려 줍니다.

카테고리데이터 표시
유형Web Component (<mv-whose-words>)
상태안정
키트검증할 수 있는 AI
함께 설치되는 항목button, checkbox, switch
Keywordsexclusive, 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일 by AI and how muc시간 a person edited the분
  • 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

설치

node scripts/add.mjs whose-words --out ./src/marvelous

Marvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["whose-words"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, components/button/button.css, components/checkbox/checkbox.css, components/switch/switch.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.

사용법

빠른 시작, 동작하는 가장 작은 마크업:

<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>

기본 마크업입니다. 여기서 시작해 속성, data-*, 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>

API

Attributes

Name유형DefaultDescription
authorstringName 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.
showbooleanfalseShows whose words: underline patterns in the text, the margin summary (reader view), hover cards and spoken markers. Reflects the switch.
view"reader" | "badge" | "author"readerreader: 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"marginWhere 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-thresholdnumber (0..1)0.4Author view: share of the text that is unedited AI suggestion above which the nudge shows and checkBeforePublish() returns ok: false.
paste-originorigin | "none"quotedLive tracking: origin given to pasted text (as plain text). "none" leaves pastes to the browser, counted as the author’s words.
badge-labelstringOverrides 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).
labelstringWhose wordsTitle of the summary (and its group label).
localeBCP 47 tagen-USDates, 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-modelstring / ISO date / stringOn 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-originalstringOn 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-notestringOn 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-idstringGroups 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-docbooleanMarks 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유형Description
spansArray<{ 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).
stringsobjectOverrides any built-in English text (keys as in the source, e.g. { toggle: "Show origins" }).
author / show / view / summary / nudgeThreshold / pasteOrigin / badgeLabel / label / localereflectedMirror the attributes.

Methods

NameDescription
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

NameDescription
mv-origin-changeA 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-toggleCancelable. 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-nudgeAuthor view: unedited AI text just crossed nudge-threshold. detail: { unedited, threshold, passages, level }.

Content structure

NameDescription
(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

NameDescription
mv-whose-words-bar / -toggle / -chip / -detailsHeader: the switch (input.mv-switch role=switch), the disclosure chip (data-level), and in badge view the Details button.
mv-whose-words-nudgeAuthor view notice before publishing; data-flash while checkBeforePublish() draws attention to it.
mv-whose-words-aside / -sentence / -meter / -seg / -legend / -key / -swatchSummary: sentence, patterned meter (data-key per origin, data-person per co-author) and legend buttons (aria-pressed) that highlight one origin.
mv-whose-words-cardHover, 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 / -mirrorTextarea tracking: the wrapper and the aligned mirror that draws the marks under the textarea’s own text.

CSS variables

NameDefaultDescription
--mv-whose-words-aiviolet (light-dark)AI suggestions, as-is and edited.
--mv-whose-words-quoted / -template / -translatedblue / gray / greenOther origins.
--mv-whose-words-author / --mv-whose-words-person-2…5neutral / amber, pink, teal, olivePrimary author (meter) and co-authors.
--mv-whose-words-margin15.5remWidth of the margin column.
--mv-whose-words-offset0.26emUnderline 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. Known limits: origins are only as good as what the app records (nothing is detected or guessed); the word-level diff splits on spaces, so languages written without spaces (Chinese, Japanese, Thai) compare whole runs, and it is skipped for very long passages (over about 90,000 word pairs); live tracking sees only edits made through the browser (beforeinput and paste), so text replaced by script without insert() or markOrigin() counts as the author’s own.

이 페이지는 AI로 번역되었습니다. 번역 문제 신고