익스클루시브
Bench Sync <mv-bench-sync>
보류 중인 변경 사항을 명확히 보여 주고 쉬는 시점에만 반영하는 로컬 우선 동기화 상태.
문화적 레퍼런스
할로우 나이트, Team Cherry (2017, 게임). 탐험하면 새로운 지역이 드러나지만, 지도는 벤치에 앉아 쉴 때에만 발견한 내용을 기록하므로 그때까지 발견은 보류 상태이며 잃을 수도 있습니다. UI에서는 로컬 변경 사항이 눈에 보이게 대기열에 쌓이고, 쉬는 시점(입력 멈춤, 탭 떠나기, 재연결, 명시적 동기화)에만 서버에 커밋되며, 키 입력 도중에는 절대 커밋되지 않습니다. 실패와 충돌은 해결될 때까지 대기열에 남습니다.
작동 방식
보류 중인 변경 사항을 명확히 보여 주고 쉬는 시점에만 반영하는 로컬 우선 동기화 상태. 앱은 모든 로컬 변경을 queue({ entity, label, key, data })로 기록하고(같은 key는 병합, “4 edits”), 부분적으로 실패할 수 있는 비동기 sync(changes)를 제공합니다. 작은 칩에는 Synced · 3 pending · Syncing… · Offline · 1 conflict가 표시되며, 입력을 멈추면 아이콘 주위의 링이 다음 쉬는 시점을 향해 채워집니다. 변경 사항은 입력이나 클릭 없이 idle 초가 지난 뒤, 탭이 숨겨질 때, 재연결 시 또는 “Sync now”를 누를 때에만 반영되며 키 입력 도중에는 절대 반영되지 않습니다. max-wait는 오래된 대기열의 대기 시간을 줄이고, manual은 이를 명시적 게시로 바꿉니다. 패널은 보류 중인 변경 사항을 엔터티별로 묶어 경과 시간, 상태, 삭제 버튼과 함께 나열합니다. 실패한 변경은 사유와 함께 대기열에 남아 지터가 적용된 지수 백오프로 재시도하고, 거부된 변경은 Retry를 기다리며, 충돌은 취소 가능한 mv-conflict를 통해 상대 버전을 “Keep mine / Keep theirs”와 함께 보여 주고, 그 waitUntil(promise)은 처리 중 상태를 표시합니다. 연결 상태는 navigator.onLine과 선택적 heartbeat URL로 판단하며(또는 앱이 강제 지정), 대기열은 localStorage 또는 IndexedDB에 유지되고, Web Lock으로 탭 간에 공유되어 한 탭만 반영하며, beforeunload 가드는 보류 중인 항목이 있을 때만 연결됩니다.
| 카테고리 | 피드백 |
|---|---|
| 유형 | Web Component (<mv-bench-sync>) |
| 상태 | 안정 |
| 키트 | 정직한 시스템 상태 |
| 함께 설치되는 항목 | button |
| Keywords | exclusive, culture, sync, offline, offline-first, local-first, pwa, queue, outbox, autosave, idle, retry, backoff, conflict, conflict-resolution, status, connectivity, indexeddb, web-locks, beforeunload |
When to use
- A field, inspection or delivery app must keep working on a flaky mobile network and show exactly what has not reached the server
- A notes app, spreadsheet or PWA saves locally first and should sync in batches when the user pauses, not on every keystroke
- Edits from several devices can collide and the user must choose which version wins, change by change
- An editor publishes local changes explicitly and needs a reviewable list of what the next publish will send
Avoid when
- The risk is leaving a form with unsaved edits that are not queued anywhere yet → use Tell-Tale instead
- Form drafts must be restored after a crash or reload rather than pushed to a server → use Bloodstain instead
- The app saves each change with an immediate request and can simply show a spinner or an error on failure
설치
node scripts/add.mjs bench-sync --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["bench-sync"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, components/button/button.css, core/dismiss.js, core/dom.js, core/element.js, core/motion.js, core/position.js, components/bench-sync/bench-sync.js, components/bench-sync/bench-sync.css.
사용법
빠른 시작, 동작하는 가장 작은 마크업:
<mv-bench-sync persist="notes"></mv-bench-sync>
<script type="module">
await customElements.whenDefined("mv-bench-sync"); const bench = document.querySelector("mv-bench-sync");
bench.sync = (changes) => fetch("/api/sync", { method: "POST", body: JSON.stringify(changes) }).then((r) => { if (!r.ok) throw r; });
bench.queue({ entity: "note", key: "title", label: "Title → Q3 plan" }); // on every local edit
</script>기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:
<div id="bs-demo" style="width:min(100%,68rem);margin-inline:auto">
<style>
#bs-demo .bs-layout { display:grid; grid-template-columns:minmax(0,1fr) 23.5rem; gap:1.25rem; align-items:start }
#bs-demo .bs-app { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden }
#bs-demo .bs-bar { display:flex; align-items:center; gap:.875rem; height:3.5rem; padding:0 .875rem 0 1.125rem; border-bottom:1px solid var(--mv-border) }
#bs-demo .bs-brand { display:flex; align-items:center; gap:.5rem; font-weight:650; font-size:.9375rem; letter-spacing:-.01em }
#bs-demo .bs-logo { display:grid; place-items:center; width:1.75rem; height:1.75rem; border-radius:var(--mv-radius-md); background:var(--mv-accent); color:var(--mv-fg-on-accent) }
#bs-demo .bs-logo svg { width:1rem; height:1rem }
#bs-demo .bs-crumb { color:var(--mv-fg-muted); font-size:.8125rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
#bs-demo .bs-end { display:flex; align-items:center; gap:.625rem; margin-inline-start:auto }
#bs-demo .bs-me { display:grid; place-items:center; width:2rem; height:2rem; border-radius:50%; background:var(--mv-bg-emphasis); color:var(--mv-fg); font-size:.75rem; font-weight:600; flex:none }
#bs-demo .bs-body { display:grid; gap:1rem; padding:1.125rem 1.125rem 1.25rem }
#bs-demo .bs-title { display:flex; align-items:flex-start; justify-content:space-between; gap:.75rem }
#bs-demo .bs-title h4 { margin:0; font-size:1rem; letter-spacing:-.01em }
#bs-demo .bs-title p { margin:.1875rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#bs-demo .bs-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:.875rem }
#bs-demo .bs-grid .bs-wide { grid-column:1 / -1 }
#bs-demo .bs-checks { display:flex; flex-wrap:wrap; gap:.5rem 1.25rem; margin:0; padding:.75rem .875rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle) }
#bs-demo .bs-checks legend { float:left; width:100%; margin:0 0 .375rem; padding:0; font-size:.8125rem; font-weight:500 }
#bs-demo .mv-choice { font-size:.8125rem }
#bs-demo textarea { min-height:4.5rem; resize:vertical }
#bs-demo .bs-controls { display:grid; gap:.625rem; padding:.875rem 1.125rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
#bs-demo .bs-row { display:flex; align-items:center; gap:.625rem 1.25rem; flex-wrap:wrap }
#bs-demo .bs-hint { margin:0; color:var(--mv-fg-muted); font-size:.75rem; line-height:1.45 }
#bs-demo .bs-log { display:grid; gap:.25rem; min-height:4.25rem; margin:0; padding:0; list-style:none; font:.75rem/1.4 var(--mv-font-mono); color:var(--mv-fg-muted) }
#bs-demo .bs-log li { display:flex; gap:.625rem; min-width:0 }
#bs-demo .bs-log time { color:var(--mv-fg-subtle); flex:none }
#bs-demo .bs-log b { color:var(--mv-fg); font-weight:600; flex:none }
#bs-demo .bs-log span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#bs-demo .bs-side { display:grid; gap:.75rem }
#bs-demo .bs-side-head { display:grid; gap:.125rem; padding:0 .25rem }
#bs-demo .bs-side-head strong { font-size:.9375rem; letter-spacing:-.01em }
#bs-demo .bs-side-head span { color:var(--mv-fg-muted); font-size:.8125rem }
@media (max-width:60rem) {
#bs-demo .bs-layout { grid-template-columns:minmax(0,1fr) }
}
@media (max-width:36rem) {
#bs-demo .bs-grid { grid-template-columns:minmax(0,1fr) }
#bs-demo .bs-crumb { display:none }
}
</style>
<div class="bs-layout">
<!-- Main: a field inspection app on a flaky network. Changes sync when the technician pauses for 4 s. -->
<section class="bs-app" aria-label="Field inspection app">
<header class="bs-bar">
<span class="bs-brand">
<span class="bs-logo" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3c3 4 6 7 6 11a6 6 0 0 1-12 0c0-4 3-7 6-11Z"/></svg></span>
Fieldbook
</span>
<span class="bs-crumb">Inspections / WO-3317</span>
<span class="bs-end">
<mv-bench-sync id="bs-main" idle="4s" max-wait="15m" retry="3s"></mv-bench-sync>
<span class="bs-me" aria-hidden="true">AO</span>
</span>
</header>
<form class="bs-body" id="bs-form" onsubmit="event.preventDefault()">
<div class="bs-title">
<div>
<h4>Pump station 12 · Riverside</h4>
<p>Quarterly inspection · Wed, Sep 24, 2026 · Amara Okafor</p>
</div>
<span class="mv-badge" data-variant="warning">In progress</span>
</div>
<div class="bs-grid">
<div class="mv-field">
<label class="mv-label" for="bs-pressure">Inlet pressure (bar)</label>
<input class="mv-input" id="bs-pressure" inputmode="decimal" value="3.4" data-key="pressure" data-entity="readings" data-name="Inlet pressure" data-unit=" bar">
</div>
<div class="mv-field">
<label class="mv-label" for="bs-flow">Flow rate (L/s)</label>
<input class="mv-input" id="bs-flow" inputmode="decimal" value="18.6" data-key="flow" data-entity="readings" data-name="Flow rate" data-unit=" L/s">
</div>
<div class="mv-field">
<label class="mv-label" for="bs-condition">Condition</label>
<select class="mv-select" id="bs-condition" data-key="condition" data-entity="readings" data-name="Condition">
<option>Good</option>
<option selected>Needs attention</option>
<option>Needs repair</option>
</select>
</div>
<fieldset class="bs-checks bs-wide">
<legend>Checklist</legend>
<label class="mv-choice"><input type="checkbox" class="mv-checkbox" data-key="check-seals" data-entity="order" data-name="Valve seals inspected" checked> Valve seals inspected</label>
<label class="mv-choice"><input type="checkbox" class="mv-checkbox" data-key="check-generator" data-entity="order" data-name="Backup generator tested"> Backup generator tested</label>
<label class="mv-choice"><input type="checkbox" class="mv-checkbox" data-key="check-signage" data-entity="order" data-name="Safety signage in place" checked> Safety signage in place</label>
</fieldset>
<div class="mv-field bs-wide">
<label class="mv-label" for="bs-notes">Technician notes</label>
<textarea class="mv-textarea" id="bs-notes" data-key="notes" data-entity="order" data-name="Notes">Slight weep at the inlet valve gasket. Replacement ordered, revisit within 14 days.</textarea>
</div>
</div>
</form>
<div class="bs-controls">
<div class="bs-row">
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="bs-net" checked> Network</label>
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="bs-fail"> Server errors</label>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="bs-conflict">Conflict on next sync</button>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="bs-open">Open sync status</button>
</div>
<p class="bs-hint">Type in any field: nothing is sent while you type. Pause for 4 seconds and the ring around the cloud fills, then your changes sync as one batch. Turn the network off to keep working offline.</p>
<ol class="bs-log" id="bs-log" aria-label="Event log"></ol>
</div>
</section>
<!-- Inline + manual: a menu editor that publishes reviewed changes explicitly. -->
<aside class="bs-side" aria-label="Menu editor">
<div class="bs-side-head">
<strong>Menu editor · Harbor Street Café</strong>
<span>Drafts stay on this device until you publish.</span>
</div>
<mv-bench-sync id="bs-publish" inline manual guard="false"></mv-bench-sync>
</aside>
</div>
<script type="module">
await customElements.whenDefined("mv-bench-sync");
const main = document.getElementById("bs-main");
const publish = document.getElementById("bs-publish");
const form = document.getElementById("bs-form");
const net = document.getElementById("bs-net");
const fail = document.getElementById("bs-fail");
const log = document.getElementById("bs-log");
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const ago = (min) => Date.now() - min * 60_000;
const clock = () => new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" });
const say = (name, text) => {
const li = document.createElement("li");
const t = document.createElement("time");
const b = document.createElement("b");
const s = document.createElement("span");
t.textContent = clock();
b.textContent = name;
s.textContent = text;
li.append(t, b, s);
log.prepend(li);
while (log.children.length > 3) log.lastElementChild.remove();
};
const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
/* ── Field app ─────────────────────────────────────── */
const ENTITIES = { readings: "Pump station 12 · Readings", order: "Work order WO-3317", photos: "Photos" };
const THEIRS = {
pressure: "Inlet pressure → 3.1 bar",
flow: "Flow rate → 17.9 L/s",
condition: "Condition → Needs repair",
notes: "Notes · “Gasket replaced on site, no leak.”",
};
let conflictNext = false;
main.sync = async (changes) => {
await wait(900);
if (fail.checked) throw new Error("503 Service Unavailable");
if (conflictNext) {
conflictNext = false;
const c = changes.find((x) => THEIRS[x.key]) ?? changes[0];
return { conflicts: [{ id: c.id, reason: "Also changed by Priya Nair on a tablet", theirs: THEIRS[c.key] ?? `${c.label.split(" →")[0]} (their edit)` }] };
}
};
main.queue({ entity: "photos", entityLabel: ENTITIES.photos, key: "photo-inlet", label: "inlet-valve.jpg · 2.1 MB", createdAt: ago(7) });
main.queue({ entity: "readings", entityLabel: ENTITIES.readings, key: "pressure", label: "Inlet pressure → 3.4 bar", createdAt: ago(4), edits: 2 });
main.queue({ entity: "order", entityLabel: ENTITIES.order, key: "check-seals", label: "Valve seals inspected → checked", createdAt: ago(2) });
say("queue", "3 changes recorded offline in the basement, waiting for a pause");
const describe = (el) => {
const name = el.dataset.name;
if (el.type === "checkbox") return `${name} → ${el.checked ? "checked" : "unchecked"}`;
if (el.tagName === "TEXTAREA") {
const v = el.value.trim().replace(/\s+/g, " ");
return `${name} · “${v.length > 38 ? `${v.slice(0, 38)}…` : v}”`;
}
return `${name} → ${el.value}${el.dataset.unit ?? ""}`;
};
form.addEventListener("input", (e) => {
const el = e.target.closest("[data-key]");
if (!el) return;
main.queue({ entity: el.dataset.entity, entityLabel: ENTITIES[el.dataset.entity], key: el.dataset.key, label: describe(el), data: { value: el.type === "checkbox" ? el.checked : el.value } });
});
net.addEventListener("change", () => { main.connection = net.checked ? "auto" : "offline"; });
document.getElementById("bs-conflict").addEventListener("click", (e) => {
conflictNext = true;
e.currentTarget.textContent = "Conflict armed";
if (!main.pending) main.queue({ entity: "readings", entityLabel: ENTITIES.readings, key: "condition", label: "Condition → Good" });
});
document.getElementById("bs-open").addEventListener("click", (e) => { e.stopPropagation(); main.open(); });
main.addEventListener("mv-sync-start", (e) => say("sync-start", `${plural(e.detail.changes.length, "change")} · ${e.detail.reason}`));
main.addEventListener("mv-sync-done", (e) => {
const { synced, failed, conflicts } = e.detail;
say("sync-done", `${synced.length} synced · ${failed.length} failed · ${conflicts.length} conflict`);
if (conflicts.length) document.getElementById("bs-conflict").textContent = "Conflict on next sync";
});
main.addEventListener("mv-offline", (e) => say("offline", `${plural(e.detail.pending, "change")} kept on device`));
main.addEventListener("mv-online", () => say("online", "syncing at the next pause"));
main.addEventListener("mv-conflict", (e) => {
say("conflict", `keep ${e.detail.choice}`);
e.detail.waitUntil(wait(500));
});
/* ── Menu editor (manual publish) ──────────────────── */
publish.strings = {
syncNow: "Publish",
syncing: "Publishing…",
titlePending: { one: "1 change ready to publish", other: "{n} changes ready to publish" },
textManual: "Saved as a draft on this device. Nothing goes live until you press {syncNow}.",
statusPending: "Draft",
titleSynced: "Menu is live and up to date",
textSynced: "Every change is published. New edits are kept as drafts until you publish.",
};
publish.sync = async (changes) => { await wait(1100); };
publish.queue({ entity: "cold-brew", entityLabel: "Cold brew", key: "cb-price", label: "Price $4.25 → $4.50", createdAt: ago(21), status: "conflict", reason: "Also changed by Marco Rossi", theirs: "Price $4.25 → $4.40 · 10 min ago" });
publish.queue({ entity: "flat-white", entityLabel: "Oat milk flat white", key: "fw-price", label: "Price $4.50 → $4.75", createdAt: ago(14) });
publish.queue({ entity: "flat-white", entityLabel: "Oat milk flat white", key: "fw-size", label: "New size · Large 16 oz, $5.25", createdAt: ago(12) });
publish.queue({ entity: "pumpkin", entityLabel: "Pumpkin spice latte", key: "psl-desc", label: "Description rewritten", createdAt: ago(9), edits: 3 });
publish.queue({ entity: "matcha", entityLabel: "Iced matcha latte", key: "ml-photo", label: "Photo · matcha-latte.png", createdAt: ago(33), status: "rejected", reason: "image is larger than 5 MB" });
publish.addEventListener("mv-conflict", (e) => e.detail.waitUntil(wait(600)));
</script>
</div>API
Attributes
| Name | 유형 | Default | Description |
|---|---|---|---|
idle | time ("4s", "1500ms", ms number) | 4s | Rest point: how long without typing, IME composition or a pressed pointer anywhere in the page before pending changes are pushed. The ring around the chip icon fills over this time (clamped from 300 ms to 1 h). |
max-wait | time | 2m | Once the oldest pending change is this old, a brief pause (1 s) is enough instead of the full idle time, so long typing sessions still sync. Syncing never interrupts an ongoing keystroke burst. 0 disables it. |
manual | boolean | Explicit publish: nothing is pushed automatically (no idle, hidden-tab, reconnect or retry sync); only “Sync now” or syncNow(). Rename the button with strings.syncNow ("Publish"). | |
inline | boolean | Renders the panel in the page flow (always visible, role="region") instead of a chip with a popover: sidebars, mobile screens, publish reviews. | |
persist | string | Storage key for the queue and the last sync time. Pending changes survive reloads and crashes, and tabs using the same key share one queue. Without it the queue lives in memory only. | |
storage | local | indexeddb | local | Where persist writes: localStorage (synchronous, shared live across tabs) or IndexedDB (larger payloads; falls back to localStorage on error). Payloads must be JSON-serializable; never queue secrets. |
connection | auto | online | offline | auto | auto uses navigator.onLine, the online/offline events and the optional heartbeat. online / offline force the state when the app has its own connectivity signal (native bridge, socket state). |
heartbeat | URL | Optional same-origin endpoint probed with a HEAD request (no-store, 5 s timeout). Any answer below 500 means reachable; a network error or 5xx shows “Server unreachable” and counts as offline until a probe succeeds (backoff 3 s → 60 s). | |
heartbeat-interval | time | 30s | Probe period while reachable and visible (minimum 5 s). Probing pauses in hidden tabs. |
retry | time | 2s | Base delay of the exponential backoff after a failed sync (2 s, 4 s, 8 s… with ±20% jitter). Retries still wait for a pause in typing. |
retry-max | time | 5m | Cap of the backoff delay. |
guard | "true" | "false" | true | Attach a beforeunload confirmation while changes are pending (only then, so the back-forward cache stays usable when everything is synced). |
placement | bottom-end | bottom-start | bottom | top-end | top-start | top | bottom-end | Preferred side of the popover panel relative to the chip (flips and shifts to stay in the viewport). |
data-state | synced | pending | syncing | offline | conflict | error | Set by the component (styleable). data-offline is added whenever the connection is down, data-resting while the rest ring fills, data-manual in manual mode. |
Properties
| Name | 유형 | Description |
|---|---|---|
sync | (changes, { reason }) => Promise<void | false | { failed?, conflicts? }> | Your push function. It receives copies of the pending and failed changes. Resolve with nothing to mark all as synced; return { failed: [id | { id, reason, retry? }], conflicts: [id | { id, reason, theirs }] } for partial results (anything not listed is synced; retry: false means refused, no automatic retry). Throwing or returning false fails the whole batch and schedules a retry. A change edited again while in flight stays pending. |
changes | Change[] | Copies of the queued changes, oldest first (read-only): { id, entity, entityLabel, label, key, data, createdAt, updatedAt, edits, attempts, status: pending | failed | rejected | conflict, reason, theirs, resolution }. |
pending | number | Number of changes not yet synced (read-only). |
state | string | Current data-state value (read-only). |
online | boolean | Effective connectivity (read-only). |
syncing | boolean | True while a push is in flight (read-only). |
lastSynced | Date | null | Time of the last successful push (persisted with persist). |
strings | Partial<Record<string, string | { zero?, one, other }>> | Overrides for every visible text and announcement; plural keys take { one, other } (and zero). Keys include chipSynced, chipPending, chipSyncing, chipOffline, chipConflict, chipError, chipDone, title*, text*, syncNow, keepMine, keepTheirs, retry, discardLabel, status*, theirs, edits, lastSynced, agoNow/agoSec/agoMin/agoHour and announce*. English defaults. |
Methods
| Name | Description |
|---|---|
queue(change) | Records a local mutation and returns its id. change: { entity, label, entityLabel?, key?, id?, data?, createdAt? }. A queued change with the same key (or id) is updated in place (latest label and data win, edits counts up). status, reason and theirs can be passed to restore a queue from your own store. |
syncNow() | Pushes every pending and failed change now, ignoring the rest point and the backoff (still requires being online). Returns Promise<{ synced, failed, conflicts } | null>. |
discard(id) | Drops a change without syncing it, after a cancelable mv-discard (revert your local state there). Not possible while the change is in flight. |
resolve(id, "mine" | "theirs") | Answers a conflict like the panel buttons: emits the cancelable mv-conflict, waits for its waitUntil promises, then mine re-queues the change with resolution "mine" (your sync should overwrite) and pushes it, theirs drops it. Returns Promise<boolean>. |
retryChange(id) | Puts a failed or refused change back in line and syncs now. |
clear() | Empties the queue without events (e.g. on sign-out). |
open() / close() | Opens or closes the popover panel. |
Events
| Name | Description |
|---|---|
mv-queue | A change was queued. detail: { change, coalesced }. |
mv-sync-start | Cancelable, before a push. detail: { changes, reason: "idle" | "max-wait" | "hidden" | "online" | "retry" | "manual" | "resolve", waitUntil(promise) }. Without a sync property, push from here and pass the promise (its result has the same shape). preventDefault() skips this rest point until there is new work. |
mv-sync-done | A push settled. detail: { synced, failed, conflicts, reason, duration } (arrays of change copies). |
mv-conflict | Cancelable, when the user picks Keep mine / Keep theirs (or resolve() is called). detail: { change, choice, theirs, waitUntil(promise) }. Apply the server version for theirs here; a rejected promise keeps the conflict and shows an error. preventDefault() leaves it unresolved. |
mv-discard | Cancelable, before a change is dropped. detail: { change }. |
mv-offline | The connection went down. detail: { reason: "network" | "unreachable" | "forced", pending }. |
mv-online | The connection is back (a sync follows at the next brief pause). detail: { offlineFor, pending }. |
mv-state | data-state changed. detail: { state, pending }. |
CSS classes
| Name | Description |
|---|---|
mv-bench-sync-chip | The status chip (<button>, aria-expanded): -chip-icon (with the .mv-bench-sync-rest ring svg and .mv-bench-sync-icon), -chip-text, -chip-more (secondary count). |
mv-bench-sync-panel | Popover panel (or inline region): -head, -head-icon, -title, -text, -list, -empty, -foot, -net (+ -net-dot, -net-text), -now (Sync now button). |
mv-bench-sync-group | One entity: -group-head (-group-name, -group-count) and the -items list. |
mv-bench-sync-item | One change, data-status="pending | syncing | failed | rejected | conflict | resolving": -mark (status shape), -item-label, -item-edits, -item-meta (age · status), -item-extra (other version or error), -item-actions, -discard. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-bench-sync-synced | var(--mv-success) | Synced tone (chip icon, online dot). |
--mv-bench-sync-pending | var(--mv-accent) | Pending and syncing tone (rest ring, pending marks). |
--mv-bench-sync-offline | var(--mv-fg-muted) | Offline tone. |
--mv-bench-sync-conflict | var(--mv-warning) | Conflict tone (chip tint, diamond marks). |
--mv-bench-sync-error | var(--mv-danger) | Failed and refused tone. |
--mv-bench-sync-list-height | 19rem | Max height of the scrollable change list. |
Accessibility
The chip is a real <button> whose accessible name is its visible text (“3 pending”, “Offline · 3 pending”, “1 conflict”) with aria-expanded / aria-controls, and it is described by a visually hidden summary (“3 changes waiting to sync. Online. Last synced 2 min ago.”) that is read on demand, never live. A separate visually hidden role="status" region speaks only on transitions: going offline (with the number of changes kept on the device), back online, a completed sync with its count (routine idle syncs at most once a minute), the first failure of a streak with the retry delay, refused changes and discards; conflicts go to an assertive region. Pending counts and the rest ring never announce. The popover panel is a labelled non-modal dialog placed right after the chip in the DOM, so Tab reaches it naturally; Escape closes it and returns focus to the chip, an outside click closes it. The inline variant is a labelled region. Each entity is a labelled group and each change a list item with native buttons: “Keep mine / Keep theirs”, Retry, and a discard button named after the change (“Discard change: Inlet pressure → 3.4 bar”); buttons that cannot act (in flight, resolving, offline Sync now) stay focusable with aria-disabled, and a pending resolution sets aria-busy. When the focused row leaves (synced or discarded), focus moves to the next row’s discard button, then to Sync now or the chip. State is never color-only: every state has its own icon and words, row marks differ in shape (dot, ring, hollow dot, square, diamond) and the meta line names the status. Reduced motion (OS or data-motion="reduce"): no spinning icons, the rest ring shows as a static dotted ring, rows appear and leave without animation. Forced colors keep the ring, marks and dots visible with system colors. Times older than a day show as a short date in the language of the nearest lang attribute (en-US by default).