익스클루시브
Accounts <mv-accounts>
같은 사건에 대한 여러 출처의 기록을 나란히 정렬하고, 사실 하나하나마다 어디서 일치하고 어디서 서로 모순되며 어느 출처가 침묵하는지 보여 주는 다중 출처 타임라인입니다. 장애 사후 분석(게이트웨이 로그, 결제 대행사, 클라이언트 텔레메트리, 당직 메모), 배송 분쟁(고객, 창고, 운송사), 감사 추적, 팩트 체크, 여러 참석자의 회의록에 적합합니다.
문화적 레퍼런스
라쇼몽, 구로사와 아키라 (1950, 영화). 하나의 사건을 네 명의 증인이 네 번 이야기합니다. 각 진술은 그럴듯하지만 서로 모순되므로, 진실은 삼각측량으로만 찾을 수 있습니다. UI에서는 사건에 대한 각 출처의 진술이 하나의 레인이 되고, 같은 사실들이 레인을 가로질러 정렬되어 일치, 모순, 근거 없음으로 판정됩니다. 이야기는 나란히 비교하거나, 병합하거나, 한 증인의 시점으로 읽을 수 있습니다.
작동 방식
같은 사건에 대한 여러 기록을 나란히 놓고, 사실 하나하나에 대해 어디서 일치하고, 어디서 서로 모순되며, 어디서 출처가 침묵하는지 보여 주는 다중 출처 타임라인입니다. 장애 사후 분석(게이트웨이 로그 vs 결제 대행사 vs 클라이언트 텔레메트리 vs 온콜 메모), 배송 분쟁(고객 vs 창고 vs 운송사), 감사 추적, 팩트 체크, 여러 참석자의 회의록 등에 쓰입니다. 각 출처는 평범한 <ol data-source>(또는 sources 속성)이며, 타임스탬프가 붙은 <li data-at data-key> 항목이 사실을 주장합니다. 같은 key를 공유하는 항목은 한 행에 정렬되어 판정됩니다. agree, conflict(다른 data-value, 또는 허용 오차를 벗어난 시간이며, 다수결은 사실별로 계산되고 동률은 선택적 기준 출처로 결정됨), only-one-source 중 하나이며, 행에 없는 출처는 침묵으로 표시됩니다. 뷰는 세 가지입니다. Side by side(출처마다 레인 하나, 사실별로 정렬된 행, 레인을 가로지르는 연결선, 합의 시각 거터), Merged(사실마다 누가 뒷받침하고 누가 반박하는지와 합의 게이지를 보여 주는 하나의 연대기), One witness(한 출처가 말하는 대로 재정렬한 이야기로, 나머지는 옆에 채도를 낮춰 표시)입니다. 시계는 서로 어긋나므로 각 출처에는 오프셋이 있으며, Clocks 패널에서 편집하거나 다른 출처와 공유하는 사실로부터 추정할 수 있습니다(Match, Align all clocks). 행은 FLIP으로 재정렬되고 바뀐 판정은 깜박입니다. 요약(“3 conflicts · 2 facts only one source mentions”)은 모든 불일치를 이동 링크로 나열하고, 사실을 선택하면 해당 행 아래에 글로 쓴 판정이 열리며, 모든 상태는 글로 명시되고 구별되는 모양으로 그려지므로 색상에만 의존하지 않습니다.
| 카테고리 | 데이터 표시 |
|---|---|
| 유형 | Web Component (<mv-accounts>) |
| 상태 | 안정 |
| Keywords | exclusive, culture, timeline, multi-source, reconciliation, post-mortem, incident, audit-trail, dispute, fact-check, comparison, consensus, conflict, clock-skew, swimlanes, logs, provenance, keyboard |
When to use
- An incident post-mortem must reconcile logs, vendor reports, client telemetry and human notes into one agreed timeline
- A support or claims team compares a customer's story with carrier and warehouse records to settle a dispute
- Several sources report the same events with clocks that drift, and the skew must be corrected before judging who is right
- Fact-checkers or auditors need to see at a glance which claims are corroborated, contradicted or backed by a single source
Avoid when
- The documents are static forms or records compared field by field rather than timestamped narratives → use Cross-Check instead
- There is a single source of events: a plain chronological list or activity feed is clearer → use Animated List instead
- Thousands of events must be reconciled in bulk; this is a review surface for tens of facts, use a data table with a match column → use Data table instead
설치
node scripts/add.mjs accounts --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["accounts"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/focus.js, core/motion.js, components/accounts/accounts.js, components/accounts/accounts.css.
사용법
기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:
<div id="acc-demo" style="width:min(100%,78rem);margin-inline:auto">
<style>
#acc-demo { display:grid; gap:2.25rem }
#acc-demo .acc-head { display:flex; align-items:flex-end; justify-content:space-between; gap:.75rem 1.5rem; flex-wrap:wrap; margin:0 0 .875rem }
#acc-demo .acc-kicker { display:flex; align-items:center; gap:.5rem; margin:0 0 .375rem; color:var(--mv-fg-muted); font-size:.75rem; font-variant-numeric:tabular-nums }
#acc-demo .acc-head h3 { margin:0; font-size:1.125rem; letter-spacing:-.01em }
#acc-demo .acc-sub { margin:.25rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem; max-width:62ch }
#acc-demo .acc-side { display:flex; align-items:center; gap:1rem; flex-wrap:wrap }
#acc-demo .acc-side .mv-choice { font-size:.8125rem }
#acc-demo .acc-log { margin:.625rem 0 0; min-height:1.25rem; color:var(--mv-fg-muted); font-size:.75rem }
#acc-demo .acc-log b { color:var(--mv-fg); font-weight:600 }
#acc-demo code { font-family:var(--mv-font-mono); font-size:.92em }
</style>
<section aria-labelledby="acc-t1">
<div class="acc-head">
<div>
<p class="acc-kicker"><span class="mv-badge" data-variant="danger" data-shape="pill">SEV-2</span> INC-2291 · Sep 14, 2026 · post-mortem draft</p>
<h3 id="acc-t1">Checkout payments failing for 12 minutes</h3>
<p class="acc-sub">Four sources, one outage. Rows line up the same fact across every account; select one to read the verdict, or switch to Merged and One witness.</p>
</div>
<div class="acc-side">
<label class="mv-choice" data-control="end">
<input type="checkbox" role="switch" class="mv-switch" id="acc-skew" checked>
<span class="mv-choice-text"><span class="mv-choice-title">Correct the mobile app clock</span></span>
</label>
<button class="mv-button" data-variant="outline" data-size="sm" type="button" id="acc-next">Next conflict</button>
</div>
</div>
<mv-accounts id="acc-incident" tolerance="1m" time-zone="UTC" label="Incident accounts" selected="degraded">
<ol data-source="API gateway" data-id="gateway" data-description="Edge access logs">
<li data-at="2026-09-14T14:01:05Z" data-key="deploy" data-label="Deploy v4.18.0">Config reload after deploy <code>v4.18.0</code></li>
<li data-at="2026-09-14T14:02:29Z" data-key="degraded" data-label="Payments start timing out">First upstream timeout to payments (30 s)</li>
<li data-at="2026-09-14T14:02:41Z" data-key="first-502" data-value="502" data-label="First 502 at checkout">on <code>POST /checkout</code></li>
<li data-at="2026-09-14T14:08:10Z" data-key="retries" data-label="Retry storm">Traffic on /checkout up 4×, mostly retries</li>
<li data-at="2026-09-14T14:12:20Z" data-key="rollback" data-value="v4.17.2" data-label="Rollback">Config reload after rollback</li>
<li data-at="2026-09-14T14:14:00Z" data-key="recovered" data-label="Checkout recovers">Error rate back under 0.5%</li>
</ol>
<ol data-source="Payment provider" data-id="provider" data-description="Webhook log and RCA">
<li data-at="2026-09-14T14:06:30Z" data-key="degraded">Incident start, per the provider’s RCA</li>
<li data-at="2026-09-14T14:13:30Z" data-key="charges" data-value="0 captured" data-label="Charges during the outage">No charges captured from 14:02 to 14:14</li>
<li data-at="2026-09-14T14:14:30Z" data-key="recovered">Status page: resolved</li>
</ol>
<ol data-source="Mobile app" data-id="app" data-offset="-2m40s" data-description="Client telemetry">
<li data-at="2026-09-14T14:05:11Z" data-key="degraded">Card payment spinner, then a timeout</li>
<li data-at="2026-09-14T14:05:24Z" data-key="first-502" data-value="502">“Something went wrong” shown to 1,204 users</li>
<li data-at="2026-09-14T14:10:53Z" data-key="retries">Auto-retry fires up to 3× per tap</li>
<li data-at="2026-09-14T14:16:05Z" data-key="charges" data-value="Charged twice">Support ticket: charged twice for order #88121</li>
<li data-at="2026-09-14T14:16:40Z" data-key="recovered">Checkout success rate back to 99%</li>
</ol>
<ol data-source="On-call notes" data-id="oncall" data-description="Incident channel">
<li data-at="2026-09-14T14:01:00Z" data-key="deploy">Maya Okafor ships v4.18.0</li>
<li data-at="2026-09-14T14:07:00Z" data-key="paged" data-label="On-call paged">Paged: checkout error rate at 6%</li>
<li data-at="2026-09-14T14:12:00Z" data-key="rollback" data-value="v4.17.2">Rolled back to v4.17.2</li>
<li data-at="2026-09-14T14:18:00Z" data-key="recovered">Declared resolved in #incidents</li>
<li data-at="2026-09-14T14:25:00Z" data-key="rca" data-label="Suspected cause">New connection-pool limit in v4.18.0</li>
</ol>
</mv-accounts>
<p class="acc-log" id="acc-log" aria-live="off">Tip: Alt + ←/→ jumps to the same fact in the next source, Alt + ↑/↓ to the next disagreement.</p>
</section>
<section aria-labelledby="acc-t2">
<div class="acc-head">
<div>
<p class="acc-kicker"><span class="mv-badge" data-variant="warning" data-shape="pill">Dispute</span> Order #88342 · Chicago time</p>
<h3 id="acc-t2">“My package never arrived”</h3>
<p class="acc-sub">Customer, warehouse and carrier merged into one chronology: each fact shows who backs it and who contradicts it. Weight readings are compared on value only.</p>
</div>
</div>
<mv-accounts id="acc-dispute" view="merged" tolerance="30m" time-zone="America/Chicago" label="Delivery dispute accounts">
<ol data-source="Customer" data-description="Support ticket #44710">
<li data-at="2026-09-08T09:12:00-05:00" data-key="ordered" data-label="Order placed">Placed order #88342 on the website</li>
<li data-at="2026-09-10T08:05:00-05:00" data-key="out" data-label="Out for delivery">Got the “out for delivery” text</li>
<li data-at="2026-09-10T18:30:00-05:00" data-key="delivered" data-value="Not received" data-label="Delivery">Nothing at the door when I got home</li>
</ol>
<ol data-source="Warehouse" data-description="Scan events">
<li data-at="2026-09-08T09:13:00-05:00" data-key="ordered">Order received by the warehouse system</li>
<li data-at="2026-09-08T14:40:00-05:00" data-key="packed" data-label="Packed">Packed and sealed, 2 items</li>
<li data-at="2026-09-08T14:41:00-05:00" data-key="weight" data-value="1.4 kg" data-tolerance="any" data-label="Parcel weight">Weighed at the packing station</li>
<li data-at="2026-09-08T17:05:00-05:00" data-key="handoff" data-label="Handed to carrier">Handed over, manifest 7731</li>
</ol>
<ol data-source="Carrier" data-description="Tracking history">
<li data-at="2026-09-08T17:20:00-05:00" data-key="handoff">Picked up at origin</li>
<li data-at="2026-09-09T03:10:00-05:00" data-key="weight" data-value="0.6 kg" data-tolerance="any">Re-weighed at the regional hub</li>
<li data-at="2026-09-10T08:02:00-05:00" data-key="out">Out for delivery</li>
<li data-at="2026-09-10T11:47:00-05:00" data-key="delivered" data-value="Delivered">Left at front door, photo on file</li>
</ol>
</mv-accounts>
</section>
<script type="module">
const inc = document.getElementById("acc-incident");
await customElements.whenDefined("mv-accounts");
const log = document.getElementById("acc-log");
// Toggle the known clock drift of the mobile app: uncorrected, its skew fakes conflicts.
document.getElementById("acc-skew").addEventListener("change", (e) => {
inc.setOffset("app", e.target.checked ? "-2m40s" : 0);
});
inc.addEventListener("mv-offset-change", (e) => {
if (e.detail.source === "app") document.getElementById("acc-skew").checked = e.detail.offset !== 0;
});
document.getElementById("acc-next").addEventListener("click", () => {
const issues = inc.facts.filter((f) => f.status === "conflict");
if (!issues.length) return;
const i = issues.findIndex((f) => f.key === inc.selected);
inc.select(issues[(i + 1) % issues.length].key, { focus: true });
});
inc.addEventListener("mv-select-fact", (e) => {
const { fact } = e.detail;
log.replaceChildren("mv-select-fact → ", Object.assign(document.createElement("b"), { textContent: fact.label }), ` · ${fact.status} · ${fact.entries.length} of ${inc.sources.length} sources`);
});
inc.addEventListener("mv-view-change", (e) => {
log.replaceChildren("mv-view-change → ", Object.assign(document.createElement("b"), { textContent: e.detail.view }));
});
</script>
</div>API
Attributes
| Name | 유형 | Default | Description |
|---|---|---|---|
view | lanes | merged | witness | lanes | lanes: one column per source, rows aligned by fact. merged: one chronology of facts with their supporters, dissenters and a consensus meter. witness: lanes reordered by the witness source's own chronology, that lane first and wider, the others dimmed. |
witness | source id | Source focused by the witness view (default: the first source). | |
tolerance | duration | 1m | Times closer than this count as the same moment ("90s", "2m", "1h", "500ms" or ms). Per fact, data-tolerance on any entry (or factLabels[key].tolerance) overrides it; "any" means time is not compared for that fact (e.g. a measurement taken twice). |
reference | source id | System of record: wins value and time ties when no majority exists, and is the clock kept fixed by Align all clocks (default anchor: the first source). | |
selected | fact key | Selected fact: highlighted in every view, with a written verdict under its row in the lanes views. Reflects user selection. | |
filter | all | issues | all | issues hides facts on which every source agrees (the “Only disagreements” toggle). |
locale | BCP 47 | en-US | Locale for times, durations, plurals and name lists. |
time-zone | IANA zone | Zone used to display times (default: the viewer's; UTC when every time is wall-clock only). Shown in the gutter header. | |
precision | auto | seconds | minutes | auto | Time display precision; auto shows seconds when any entry has them. |
label | string | Accounts | Accessible name of the timeline group. |
heading-level | 1-6 | 3 | Level of the lane headings. |
controls | "true" | "false" | true | "false" hides the built-in toolbar (views, filter, Clocks) when the app drives them. |
data-source | string | On a child <ol>/<ul>: the source's display name. Makes it a lane. The list itself is hidden and re-read live when it changes. | |
data-id / data-offset / data-description | string / duration / string | On a source list: stable id (default: slug of the name), clock correction added to its times ("-2m40s" for a clock 2 min 40 s fast) and a short description under its heading. | |
data-at | ISO date-time | epoch ms | HH:MM[:SS] | On an entry: when the source says it happened. Wall-clock times are compared as is. Entries without a time are ordered after the previous timed entry of their source and never conflict on time. | |
data-key | string | On an entry: the fact it claims. Same key across sources = same fact. Entries without a key are facts only their source mentions. | |
data-value | string | On an entry: the comparable claim (status code, amount, outcome). Compared case-, accent- and space-insensitively (or with the compare property); entries without a value never conflict on value. | |
data-label / data-tolerance | string / duration | "any" | On an entry: the fact's title (first one found wins, default: the key humanized) and a per-fact tolerance. |
Properties
| Name | 유형 | Description |
|---|---|---|
sources | Array<{ id?, name, offset?, description?, entries: Array<{ key?, at?, value?, text, label?, tolerance? }> }> | Data alternative to the markup (takes precedence over child lists). Reading it returns the current model with live offsets. |
facts | Array<Fact> | Read-only analysis in consensus order: { key, label, status: "agree" | "conflict" | "single", at (consensus ms), conflict: { value, time }, value (majority), entries: [{ source, key, at, reportedAt, value, text, status: "agree" | "dissent" | "split" | "single", delta, node }], silent: [source ids], verdict (sentence) }. |
summary | { facts, agreed, conflicts, single, silentGaps } | Counts behind the summary line (read-only). |
offsets | Record<sourceId, ms> | Current clock corrections (read-only; use setOffset). |
factLabels | Record<key, { label?, tolerance? }> | Titles and tolerances per fact, when the markup cannot carry them. |
compare | (a: string, b: string, key: string) => boolean | Custom value equality (numeric tolerance, synonyms…). Default: case-, accent- and whitespace-insensitive. |
strings | Partial<Record<string, string>> | Overrides for every visible label, verdict sentence and announcement (plural forms as "one|other", placeholders like {names}, {delta}), including versus ("vs" between contradicting values), adjusted (the "adj." mark of corrected times) and sourceFallback ("Source {n}" for an unnamed source). English defaults. |
view / witness / tolerance / reference / selected / filter / locale / timeZone / precision / label / headingLevel / controls | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
select(key, { source?, focus? }) | Selects a fact (null clears). Emits the cancelable mv-select-fact; focus: true moves focus to its first dissenting entry (or the given source) and scrolls it into view. Returns false if unknown or vetoed. |
setOffset(sourceId, value) | Corrects a source's clock (ms or duration string). Emits the cancelable mv-offset-change, re-judges every fact and announces the new summary. |
suggestOffset(sourceId) | Estimated correction for a source: median gap between its times and the other sources' times on the facts they share, rounded to the second. null when it shares nothing. |
alignClocks() | Applies suggestOffset to every source except the reference (two passes) and returns the offsets. Real disagreements survive because the median ignores a minority of outliers. |
refresh() | Re-reads the markup (normally automatic through a MutationObserver). |
Events
| Name | Description |
|---|---|
mv-select-fact | Cancelable, before a fact is selected (click, Enter/Space, summary link, select()). detail: { key, fact, source (id of the entry clicked, or null), entry }. |
mv-view-change | Cancelable, before the view changes. detail: { view, previous, witness }. |
mv-offset-change | Cancelable, before a clock correction is applied. detail: { source, offset, previous, reason: "input" | "align" | "reset" | "api" }. |
mv-reconcile | After every analysis (data, tolerance or offset change). detail: the summary counts. |
Content structure
| Name | Description |
|---|---|
(sources) | Child <ol data-source> / <ul data-source> lists of <li> entries. Entry content (inline markup such as <code> or links) is cloned into the lanes; ids are stripped from the clones. |
CSS classes
| Name | Description |
|---|---|
mv-accounts-bar / -views / -view / -tool / -pick | Toolbar: view radio group, witness picker, “Only disagreements” and Clocks toggles. |
mv-accounts-clocks / -clock-row / -offset | Clock correction panel and its per-source offset inputs. |
mv-accounts-summary / -details / -jumps / -jump | Summary line with counts and the list of disagreements as jump links. |
mv-accounts-grid / -lane / -head / -list | Lanes layout (CSS grid with subgrid lanes so rows align across sources). The fact gutter is the lane with data-role="facts"; data-focused / data-dimmed in witness view. |
mv-accounts-entry / -fact / -link / -verdict | Entry cards (data-status: agree | dissent | split | single | silent, data-kind: time | value), gutter facts, links across lanes and the verdict row. data-hot / data-linked mark every piece of the hovered or focused fact. |
mv-accounts-merged / -card / -claims / -claim / -consensus / -dot | Merged view: fact cards on a rail, their claims per source and the consensus meter (circle = agrees, square = contradicts, dashed = silent). |
mv-accounts-glyph | Status shapes: check disc (agree), crossed diamond (conflict), half disc (single source), dashed ring (silent). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-accounts-agree | var(--mv-success) | Agreement tone. |
--mv-accounts-conflict | var(--mv-danger) | Conflict tone. |
--mv-accounts-single | var(--mv-warning) | Tone of facts only one source mentions. |
--mv-accounts-silent | var(--mv-fg-subtle) | Tone of silent cells. |
--mv-accounts-gutter | 12.5rem | Width of the fact gutter (9.5rem in narrow containers). |
--mv-accounts-lane-min | 12.5rem | Minimum lane width; below it the lanes scroll horizontally with the gutter sticky. |
--mv-accounts-max-height | none | Caps the height of the lanes / merged list; the lane headers then stay sticky while scrolling. |
Accessibility
Every lane is a <section> with a real heading (level set by heading-level) and an ordered list, the fact gutter included, so screen reader users can browse each account by heading and list. Each entry is a list item that names its fact and ends with a full verdict sentence (“Places it 4 min later than API gateway and Mobile app (2:02:31 PM).”, “Silent here; API gateway and Mobile app mention it.”, the reported time and correction when a clock was adjusted); the short visible labels (Agrees, +4 min, Contradicts, Only source, Silent) are paired with distinct shapes (check disc, crossed diamond, half disc, dashed ring) and never rely on color. The body is one tab stop with a roving tabindex: arrows move within a lane and across lanes on the same fact row, Alt+Left/Right jumps to the same fact in the next source that mentions it (skipping silent ones), Alt+Up/Down to the previous or next disagreement, Home/End and Ctrl+Home/End to the ends, Enter or Space selects a fact and Escape clears it; these instructions are exposed as the group's description. Hovering or focusing any piece of a fact highlights its entries and link in every lane. The view switcher is a radio group (arrow keys), the filter a toggle button (aria-pressed), Clocks a disclosure (aria-expanded) whose offset inputs are labelled per source, accept durations like -2m40s and step with Up/Down (1 s, Shift 1 min) and PageUp/PageDown. The summary is a native <details> whose items are buttons that move focus to the disagreement. View changes, selections (with their verdict) and clock corrections (with the new summary) are announced in a polite live region; focus is kept on the same fact across re-renders. The lanes of other sources in witness view lose their color but keep full text contrast. Reduced motion (OS or data-motion="reduce"): no FLIP reordering, no status flash, instant scrolling. Forced colors: shapes use CanvasText, selection, the current summary item and active controls use Highlight outlines, conflicts get a thicker border. Known limits: a review surface for tens of facts, not thousands (every re-analysis rebuilds the lanes); one entry per source and fact (a second one is ignored with a console warning); below the lane minimum width the lanes scroll sideways with the fact gutter sticky; data-motion="reduce" stops the FLIP reordering and smooth scrolling only when set on <html> (on another ancestor it still stops the CSS flash).