익스클루시브
Drift Check <mv-drift-check>
전달된 텍스트가 원본에서 단계별로 어떻게 달라졌는지 보여 줍니다. 고객에서 영업, PM, 티켓으로 넘어간 요구 사항, 인터뷰에서 보도 자료와 기사로 옮겨 간 인용문, 요약의 요약, 전달되거나 번역되거나 AI가 다시 쓴 문구에 적합합니다.
문화적 레퍼런스
전화 놀이(어린이 놀이), 전래(영국에서는 Chinese whispers라고 불림)(19세기, 놀이). 한 문장이 줄지어 선 참가자들 사이에서 한 사람씩 전달되다가 바뀐 채로 도착합니다. 재미는 마지막 버전을 원문과 비교하고, 어디서 어긋났는지 한 단계씩 되짚어 보는 데 있습니다. UI에서는 텍스트의 각 전달(영업 메모, AI 요약, 재작성)이 하나의 홉입니다. 컴포넌트는 최종 버전을 원본과 비교하고, 각 홉이 얼마나 바꿨는지 점수를 매기며, 의미가 깨진 홉을 표시하고, 원래 문구를 복원할 수 있게 합니다.
작동 방식
전달된 텍스트가 원본에서 어떻게 변해 왔는지 홉 단위로 보여 줍니다: 고객에서 영업, PM, 티켓으로 전달된 요구 사항, 인터뷰에서 보도 자료를 거쳐 기사로 옮겨 가는 인용문, 요약의 요약, 전달되거나 번역되거나 AI가 다시 쓴 문안. 순서가 있는 버전 체인(원본이 먼저, 각각 레이블, 작성자, 방식, 시점 포함)을 <template data-version> 자식 요소나 versions 속성으로 전달하면, 자체 Myers diff로 모든 홉을 단어 단위로 비교한 뒤 각 변경에 의미 위험 규칙을 적용합니다: 바뀐 숫자와 날짜, 사라지거나 추가된 부정, 더 강해지거나 약해진 조동사(“may need” → “must”), 더 넓어지거나 좁아진 수량 표현(“some” → “most”), 빠진 조건과 한정어(“if”, “only”, “up to”), 뒤바뀐 이름, 그리고 terms에 나열한 용어집 용어. 드리프트 점수(변경된 단어의 비율에 중대한 변경마다 가중치를 더한 값)가 판정문(“Meaning shifted · 7 high-risk changes across 4 hops · most introduced at hop 2”)의 맨 앞에 표시됩니다. 연결된 홉 카드 레일은 각 단계가 얼마나 바뀌었는지 보여 주고 의미가 깨진 홉을 표시합니다. 최종 버전에는 취소선, 밑줄, 번호가 붙은 텍스트 태그가 표시되며, 각 태그는 체인을 거슬러 올라가 그 변경을 처음 도입한 홉까지 추적됩니다. 아무 홉이나 클릭하거나 두 버전을 골라 인라인 또는 나란히 비교할 수 있습니다. 표시된 변경은 쉬운 문장으로 나열되며, Restore 버튼을 누르면 원래 문구가 최종 버전에 되돌아갑니다(취소 가능한 mv-restore, 실행 취소). 순수 정적 메서드 MvDriftCheck.compare(a, b)는 서버 측에서 동일한 diff, 위험, 점수를 반환합니다.
| 카테고리 | 데이터 표시 |
|---|---|
| 유형 | Web Component (<mv-drift-check>) |
| 상태 | 안정 |
| 함께 설치되는 항목 | button, select |
| Keywords | exclusive, culture, diff, word-diff, text-diff, compare, drift, paraphrase, summary, ai-review, translation, requirements, quote-check, fact-check, provenance, hops, restore, server-side |
When to use
- A requirement went from a customer email through sales notes and a PM brief to a ticket, and the team must see what changed on the way
- An editor or fact-checker must compare a published quote with the original interview and find where numbers or hedges changed
- An AI summary, rewrite or translation must be reviewed for lost negations, stronger claims or changed figures before it ships
- A forwarded policy or support macro drifted across teams and the exact hop that changed its meaning must be found and fixed
Avoid when
- One document is edited over time and the question is how much of the signed or approved text survives, and who wrote each part → use Theseus instead
- Two structured records must be reconciled field by field (invoice vs order, form vs ID) → use Cross-Check instead
- The claims of a single text must be checked against sources rather than against an earlier version → use Unsourced instead
설치
node scripts/add.mjs drift-check --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["drift-check"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, components/button/button.css, components/select/select.css, core/dom.js, core/element.js, core/focus.js, core/motion.js, components/drift-check/drift-check.js, components/drift-check/drift-check.css.
사용법
기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:
<div id="dc-demo" style="width:min(100%,72rem);margin-inline:auto">
<style>
#dc-demo { display:grid; gap:1rem }
#dc-demo .dc-bar { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:.75rem 1.25rem }
#dc-demo .dc-title { display:grid; gap:.125rem; min-width:0 }
#dc-demo .dc-title small { color:var(--mv-fg-muted); font-size:.75rem }
#dc-demo .dc-title strong { font-size:1rem; letter-spacing:-.01em }
#dc-demo .dc-controls { display:flex; flex-wrap:wrap; align-items:center; gap:.625rem 1.25rem }
#dc-demo .dc-controls .mv-choice { font-size:.8125rem }
#dc-demo .dc-log { display:flex; align-items:center; gap:.625rem; min-height:2.5rem; margin:0; padding:.5rem .875rem; border:1px dashed var(--mv-border); border-radius:var(--mv-radius-lg); color:var(--mv-fg-muted); font-size:.8125rem }
#dc-demo .dc-log b { color:var(--mv-fg); font-weight:500 }
#dc-demo .dc-log code { font-family:var(--mv-font-mono); font-size:.75rem; color:var(--mv-fg) }
</style>
<div class="dc-bar">
<span class="dc-title">
<small id="dc-kicker">Harborline Freight · Feature request FR-2291</small>
<strong id="dc-heading">Bulk export for warehouse reports</strong>
</span>
<span class="dc-controls">
<mv-segmented id="dc-scenario" aria-label="Scenario" value="request">
<button value="request">Feature request</button>
<button value="quote">Press quote</button>
</mv-segmented>
<label class="mv-choice">
<input type="checkbox" role="switch" class="mv-switch" id="dc-review">
<span class="mv-choice-text"><span class="mv-choice-title">Require reviewer to restore</span></span>
</label>
</span>
</div>
<mv-drift-check id="dc-check" terms="EU workspace, Rotterdam">
<template data-version data-label="Customer email" data-by="Dana Whitfield" data-via="Head of Operations, Harborline" data-at="2026-09-02">
We may need the export to handle up to 50,000 rows by the end of Q4. It should not email reports to all users, only to admins in the EU workspace. If the pilot in Rotterdam goes well, we would roll it out to the other 3 warehouses.
</template>
<template data-version data-label="Call notes" data-by="Marco Bellini" data-via="Paraphrased from a call" data-at="2026-09-04">
Customer may need the export to handle 50,000 rows by the end of Q4. It should not email reports to all users, only admins in the EU workspace. If the Rotterdam pilot goes well, they would roll it out to the other 3 warehouses.
</template>
<template data-version data-label="Account summary" data-by="AI assistant" data-via="Summarized by AI" data-at="2026-09-05">
The customer needs the export to handle 50,000 rows by the end of Q4. Reports should be emailed to all users, including admins in the EU workspace. After the Rotterdam pilot, they will roll it out to the other 3 warehouses.
</template>
<template data-version data-label="Product brief" data-by="Priya Nair" data-via="Edited for the roadmap" data-at="2026-09-08">
The customer needs the export to handle 500,000 rows by the end of Q3. Reports should be emailed to all users, including admins in every workspace. After the Rotterdam pilot, we will roll it out to all 4 warehouses.
</template>
<template data-version data-label="Engineering ticket" data-by="Omar Haddad" data-via="Rewritten as a ticket" data-at="2026-09-10">
Export must handle 500,000 rows by end of Q3. Email reports to all users, including admins in every workspace. After the Rotterdam pilot, we will roll it out to all 4 warehouses.
</template>
</mv-drift-check>
<p class="dc-log" id="dc-log" aria-live="off"><span>Pick a hop, switch to <b>Side by side</b>, or restore a flagged span of the ticket.</span></p>
<script type="module">
const $ = (id) => document.getElementById(id);
const check = $("dc-check");
await customElements.whenDefined("mv-drift-check");
const scenarios = {
request: {
kicker: "Harborline Freight · Feature request FR-2291",
heading: "Bulk export for warehouse reports",
terms: "EU workspace, Rotterdam",
versions: null, // the <template> children
},
quote: {
kicker: "Newsroom fact-check · Quote review",
heading: "“Early results” quote, from interview to social post",
terms: "",
versions: [
{ label: "Interview transcript", by: "Dr. Amara Okafor", via: "Lead researcher, recorded interview", at: "2026-08-18",
text: "In our trial, the treatment reduced symptoms in some patients, but we don't yet know whether the effect lasts beyond 12 weeks. We would not recommend it outside a study for now." },
{ label: "Press release", by: "University press office", via: "Edited for the press release", at: "2026-08-20",
text: "In the trial, the treatment reduced symptoms in many patients, though it is not yet known whether the effect lasts beyond 12 weeks. The team would not recommend it outside a study for now." },
{ label: "News article", by: "Kenji Watanabe", via: "Quoted in an article", at: "2026-08-22",
text: "The treatment reduced symptoms in most patients, and the effect lasts for 12 weeks. The team would recommend it outside a study." },
{ label: "Social post", by: "Health Daily", via: "Shortened for social", at: "2026-08-23",
text: "New treatment cures symptoms in most patients for 3 months. Doctors will recommend it." },
],
},
};
const original = check.versions;
$("dc-scenario").addEventListener("mv-change", (e) => {
const s = scenarios[e.detail.value];
$("dc-kicker").textContent = s.kicker;
$("dc-heading").textContent = s.heading;
check.terms = s.terms || null;
check.removeAttribute("from");
check.removeAttribute("to");
check.layout = e.detail.value === "quote" ? "split" : "inline";
check.versions = s.versions ?? original;
log(`Loaded ${check.versions.length} versions.`);
});
const log = (html) => {
const p = $("dc-log");
p.replaceChildren(...html.map ? html : [html]);
};
const el = (tag, text) => Object.assign(document.createElement(tag), { textContent: text });
check.addEventListener("mv-hop-select", (e) => {
const { from, to, comparison } = e.detail;
log([el("code", "mv-hop-select"), el("span", ` ${from} → ${to} · drift ${comparison.score} · ${comparison.counts.high} high-risk`)]);
});
check.addEventListener("mv-restore", (e) => {
if ($("dc-review").checked) {
e.preventDefault();
log([el("code", "mv-restore"), el("span", " declined: a reviewer must approve changes to the ticket.")]);
return;
}
const c = e.detail.change;
log([el("code", "mv-restore"), el("span", " "), el("b", c.removed ? `“${c.removed}”` : `removed “${c.added}”`), el("span", " is back in the ticket.")]);
});
check.addEventListener("mv-drift-found", (e) => {
const { counts, breakHop } = e.detail;
if (breakHop != null) log([el("code", "mv-drift-found"), el("span", ` ${counts.high} high-risk changes · meaning shifted most at hop ${breakHop}.`)]);
});
</script>
</div>API
Attributes
| Name | 유형 | Default | Description |
|---|---|---|---|
from / to | number (version index) | The pair being compared (0 = source). Defaults to source → final. Clicking the source card selects 0 → last, a hop card selects (n − 1) → n; the pickers allow any pair, reversed pairs included. | |
layout | inline | split | inline | Inline paints the “to” version with removed words struck through before the new ones; split shows both versions side by side (stacked in narrow containers), with the same numbered tags on each side. |
terms | comma-separated list | Glossary terms (product names, legal phrases, “EU workspace”…): any change touching one is flagged as a high-risk Term change. | |
readonly | boolean | Hides the Restore buttons (review-only). | |
label | string | Relay drift | Accessible name of the region. |
locale | BCP 47 tag | en-US | Date formatting on the hop cards. |
<template data-version> | children | One per version, in chain order, source first. The template text is the version (indentation and soft wraps collapsed, blank lines kept as paragraphs); data-label (what it is: “Call notes”), data-by (who), data-via (how: “Summarized by AI”) and data-at (ISO date or free text) describe the hop. | |
data-level / data-layout / data-playing | set by the component | On the host: faithful | reworded | shifted (end-to-end verdict), the current layout, and data-playing while Step through runs. |
Properties
| Name | 유형 | Description |
|---|---|---|
versions | Array<{ text, label?, by?, via?, at? }> | The chain, source first (wins over templates). Setting it resets restores and undo; reading it returns copies, restores included. |
value | string | Current text of the final version, restores included (read-only). |
report | { score, changed, counts, breakHop, hops[], changes[] } | End-to-end analysis (read-only): drift score, share of words changed, counts by severity, the hop that introduced most high-risk changes, per-hop scores, and every change with the hops it came from and a full sentence description. |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement (English defaults; see STRINGS in drift-check.js). |
from / to / layout / terms / readonly / label / locale | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
static compare(a, b, { terms? }) | Pure, DOM-free comparison usable server-side or in tests. Returns { score (0-100), changed (0-1), severity, counts: { high, medium, low, none }, changes: [{ id, type: replace | insert | delete, removed, added, severity, risks: [{ type, severity, direction, detail }], from: [start, end], to: [start, end], description }], parts: [{ type: equal | delete | insert, text }] }. Also exported as compareText. |
select(from, to) | Compares any two versions (indexes). Does not emit mv-hop-select. |
restore(id) | Reverts change id of the current comparison to the “from” wording in the final version (goes through the cancelable mv-restore). Returns true when applied. |
undo() | Undoes the last restore. Returns false when there is nothing to undo. |
play() / stop() | Steps through the chain hop by hop (about 2 s each), then returns to source → final; any click or key press inside stops it. |
Events
| Name | Description |
|---|---|
mv-hop-select | The user picked a hop or a pair. detail: { index (hop number, 0 for source → final, null for a custom pair), from, to, version, comparison: { score, counts, changes } }. |
mv-drift-found | After the chain is analyzed, when source → final has at least one medium or high-risk change (once per distinct result). detail: { score, counts, breakHop, findings: [{ …change, hops, description }] }. |
mv-restore | Cancelable, before a span of the final version is reverted. detail: { change, from, before, after } (after = the resulting final text). preventDefault() keeps the text as is (e.g. the ticket needs a reviewer). |
mv-change | The final text changed through a restore or an undo. detail: { value, reason: "restore" | "undo" }. |
Content structure
| Name | Description |
|---|---|
template[data-version] | Direct <template data-version> children are the chain (ignored once the versions property is set). |
CSS classes
| Name | Description |
|---|---|
mv-drift-check-head | Score (-score-num, role=meter -meter), verdict (-verdict with data-level), summary line, flagged kinds (-kinds / -kind), actions (Undo restore, Step through). |
mv-drift-check-rail / mv-drift-check-hop | The chained hop cards (role=toolbar of buttons with aria-pressed): -hop-top, -hop-num, -hop-risk, -hop-break (data-break), -hop-label, -hop-by, -hop-via, -hop-bar, -hop-foot; data-level low | medium | high, data-in-range and data-endpoint for the pair being compared. |
mv-drift-check-panel | Comparison: -caption, -toolbar (pickers, -segs layout toggle), -view (-text, -split with -col), -findings (-list of -finding with -chip, -finding-diff, -hopchip, -restore; -more toggles minor changes). |
mv-drift-check-change / -del / -ins / -tag / -gap | Diff marks: a change wraps <del> and <ins> (data-severity high = wavy underline, medium = dashed), a numbered text tag for flagged changes, and a gap marker in split view where words were only added or removed. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-drift-check-added | var(--mv-success) | Tint of inserted words. |
--mv-drift-check-removed | var(--mv-danger) | Tint and strike of removed words. |
--mv-drift-check-risk | var(--mv-danger) | High-risk tags, wavy underline, hop flags and the shifted verdict. |
--mv-drift-check-caution | var(--mv-warning) | Medium-risk tags and the reworded verdict. |
--mv-drift-check-faithful | var(--mv-success) | Score bar color when the text stayed faithful. |
--mv-drift-check-text-size | var(--mv-text-base) | Font size of the inline text view. |
Accessibility
The component is a labelled region. The drift score is a role="meter" with a spoken value (“64 of 100, meaning shifted”), and the verdict and summary are plain text. The hop rail is a role="toolbar" of real buttons with aria-pressed and roving tabindex (Left/Right, Home/End, Enter or Space to compare); each card has a full accessible name (“Hop 2: Account summary, AI assistant, summarized by AI, September 5, 2026. Drift score 57, 5 high-risk changes; 72 from the source. Meaning changed most here.”). Diffs never rely on color: removed words are real <del> with line-through and inserted words real <ins> with an underline (wavy for high risk, dashed for medium), each prefixed with visually hidden “Removed:” / “Added:”, and flagged changes carry a numbered text tag (“3 Negation”) plus a hidden “(flag 3: negation dropped)”. Every flagged change is also listed as a sentence (“Changed “It should not email reports” to “Reports should be emailed” at hop 2 (AI assistant): negation dropped. High risk.”) next to its Restore button, whose name quotes the wording it brings back; hop chips jump to the hop that introduced the change. Selecting a pair, restoring and undoing are announced in a polite live region; after a restore focus moves to the next Restore button, so keyboard users keep their place. The pickers are native selects with labels, the layout toggle uses aria-pressed, the minor-changes toggle aria-expanded/aria-controls. Hovering or focusing a change or its finding highlights both. Step through is opt-in, stops on any key or click (and on Escape). Reduced motion (OS or data-motion="reduce"): no view fade, bars jump instead of sliding. Forced colors: tags, chips and flags get system-color borders, the selected hop an Highlight outline, bars use Highlight.