익스클루시브
Tell-Tale <mv-tell-tale>
걸려 있는 것이 클수록 더 강하게 알리는 저장되지 않은 작업 표시기입니다.
문화적 레퍼런스
고자질하는 심장, 에드거 앨런 포(1843, 책). 화자는 마룻바닥 아래 숨겨진 심장의 박동 소리가 점점 커지고 빨라지는 것을 듣다가, 더는 무시할 수 없게 되자 스스로 죄를 털어놓습니다. 여기서는 폼 속에 숨은 저장되지 않은 작업이 작은 표시 안에서 고동칩니다. 편집 직후에는 희미하다가 변경 사항과 그 경과 시간이 쌓일수록 더 집요해지며, 저장되거나 그 박동이 탭과 이탈 방지 장치에 이를 때까지 이어집니다.
작동 방식
걸린 것이 많을수록 더 집요해지는 미저장 작업 표시기입니다. 폼이나 편집기 영역을 지켜보며(input/change 이벤트, 직접 볼 수 없는 편집기에는 mark(count), 또는 dirty 속성) 모든 필드를 마지막으로 저장된 값과 비교하고(값을 원래대로 다시 입력하면 다시 깨끗한 상태가 되고, 라디오 그룹은 한 번만 세며, 긴 텍스트는 가중치가 더 크고, data-tell-tale-weight로 어떤 필드든 가중치를 지정), 고동치는 작은 표시를 보여 줍니다. 편집 직후에는 거의 움직이지 않고 희미하다가, 변경 수와 경과 시간이 늘어날수록 설정 가능한 calm / threshold / urgent 곡선(경과 시간 × √가중치, 따라서 변경 9개는 1개보다 세 배 빨리 도달)을 따라 더 강하고 빨라집니다. 헤더에 상태 칩(“7 unsaved”, 클릭하면 저장)으로 놓이거나 직접 만든 Save 버튼 모서리에 붙으며, 상세 툴팁(“7 unsaved changes · last saved 18 min ago · Save ⌘S”)을 제공합니다. 정해진 수준부터는 탭 제목 앞에 “• ”를 붙이고 파비콘에 점을 찍을 수 있으며, beforeunload 가드는 정말 필요할 때만 연결합니다(깨끗한 상태나 네이티브 폼 제출 중에는 절대 연결하지 않으므로 뒤로/앞으로 캐시를 계속 사용할 수 있음). ⌘S / Ctrl+S는 선택 사항입니다. mv-save는 취소 가능하며, 그 waitUntil(promise)은 “Saving…”을 표시하고, 요청 중에 입력한 편집은 미저장 상태로 유지하며, 실패를 알립니다. saved()는 부드러운 “Saved” 박동으로 상태를 마무리합니다.
| 카테고리 | 피드백 |
|---|---|
| 유형 | Web Component (<mv-tell-tale>) |
| 상태 | 안정 |
| 키트 | 사용자를 놓치지 않는 폼 |
| Keywords | exclusive, culture, unsaved-changes, dirty-state, save-status, save-indicator, beforeunload, leave-guard, form, editor, keyboard-shortcut, cmd-s, tab-title, favicon, heartbeat, pulse, escalation, status |
When to use
- A long editor or settings form is saved manually and people lose work by closing the tab or navigating away
- The header needs a calm, always-there save status that only raises its voice when a lot of work is at risk
- A rich editor, canvas or grid tracks its own changes and needs a shared unsaved indicator, tab mark and leave guard
- Power users expect Cmd/Ctrl+S to save in the browser instead of opening the Save page dialog
Avoid when
- The work is already lost (crash, reload, expired session) and must be restored on the next visit → use Bloodstain instead
- The app autosaves continuously and a status that never escalates is enough; a static “Saved” label is simpler
- The risk is the session timing out rather than the page being left → use Hatch instead
설치
node scripts/add.mjs tell-tale --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["tell-tale"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, core/canvas.js, core/dom.js, core/element.js, core/motion.js, core/position.js, components/tell-tale/tell-tale.js, components/tell-tale/tell-tale.css.
사용법
빠른 시작, 동작하는 가장 작은 마크업:
<form>
<label>Title <input name="title" value="Q3 report"></label>
<mv-tell-tale></mv-tell-tale>
</form>기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:
<div id="tt-demo" style="width:min(100%,64rem);margin-inline:auto">
<style>
#tt-demo { display:grid; gap:1.25rem; align-content:start }
#tt-demo .tt-layout { display:grid; grid-template-columns:minmax(0,1fr) 17rem; gap:1.25rem; align-items:start }
#tt-demo .tt-app { min-width:0; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden }
#tt-demo .tt-bar { display:flex; align-items:center; gap:.75rem 1rem; flex-wrap:wrap; min-height:3.5rem; padding:.625rem 1rem .625rem 1.125rem; border-bottom:1px solid var(--mv-border) }
#tt-demo .tt-crumbs { display:grid; gap:.125rem; min-width:0; flex:1 1 14rem }
#tt-demo .tt-crumbs small { color:var(--mv-fg-subtle); font-size:.75rem }
#tt-demo .tt-crumbs strong { font-size:.9375rem; letter-spacing:-.01em; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#tt-demo .tt-end { display:flex; align-items:center; gap:.5rem; margin-inline-start:auto }
#tt-demo .tt-form { display:grid; gap:1rem; padding:1.125rem }
#tt-demo .tt-row { display:grid; grid-template-columns:minmax(0,1fr) 12rem; gap:1rem }
#tt-demo .tt-radios { display:flex; gap:.5rem 1.25rem; flex-wrap:wrap; padding-top:.25rem; border:0; margin:0; padding-inline:0 }
#tt-demo .tt-radios legend { padding:0; margin-bottom:.375rem }
#tt-demo textarea { min-height:8.5rem; resize:vertical; line-height:1.55 }
#tt-demo .tt-controls { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding:.875rem 1.125rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
#tt-demo .tt-toggles { display:flex; align-items:center; gap:.5rem 1.25rem; flex-wrap:wrap }
#tt-demo .tt-toggles .mv-choice { font-size:.8125rem }
#tt-demo .tt-readout { display:flex; align-items:center; gap:.5rem; color:var(--mv-fg-muted); font-size:.75rem; font-variant-numeric:tabular-nums }
#tt-demo .tt-foot { display:flex; align-items:center; justify-content:space-between; gap:.5rem 1rem; flex-wrap:wrap; padding:0 1.125rem .875rem; background:var(--mv-bg-subtle) }
#tt-demo .tt-log { margin:0; flex:1 1 16rem; min-width:0; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono) }
#tt-demo .tt-side { display:grid; gap:1rem; align-content:start }
#tt-demo .tt-panel { padding:1rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs) }
#tt-demo .tt-panel h4 { margin:0 0 .25rem; font-size:.875rem; letter-spacing:-.01em }
#tt-demo .tt-panel > p { margin:0 0 .875rem; color:var(--mv-fg-muted); font-size:.75rem; line-height:1.45 }
#tt-demo .tt-curve { display:grid; gap:.5rem; margin:0; padding:0; list-style:none }
#tt-demo .tt-curve li { display:flex; align-items:center; justify-content:space-between; gap:.75rem }
#tt-demo .tt-curve span { display:grid; gap:.0625rem; font-size:.8125rem; font-weight:500 }
#tt-demo .tt-curve small { color:var(--mv-fg-subtle); font-size:.6875rem; font-weight:400 }
#tt-demo .tt-settings { display:grid; gap:.75rem }
#tt-demo .tt-settings-foot { display:flex; align-items:center; justify-content:space-between; gap:.75rem; padding-top:.25rem }
#tt-demo .tt-settings-foot small { color:var(--mv-fg-subtle); font-size:.6875rem; line-height:1.35 }
@media (max-width:54rem) { #tt-demo .tt-layout { grid-template-columns:minmax(0,1fr) } }
@media (max-width:36rem) { #tt-demo .tt-row { grid-template-columns:minmax(0,1fr) } }
</style>
<div class="tt-layout">
<!-- Main: a help-center article editor. Time runs about 12× faster here. -->
<section class="tt-app" aria-label="Article editor">
<header class="tt-bar">
<span class="tt-crumbs">
<small>Help Center · Billing</small>
<strong>Refunds for annual plans</strong>
</span>
<span class="tt-end">
<mv-tell-tale id="tt-main" for="tt-article" shortcut tab="both" calm="6s" threshold="25s" urgent="75s"></mv-tell-tale>
<button type="reset" form="tt-article" class="mv-button" data-variant="ghost" data-size="sm" id="tt-discard">Discard</button>
<button type="submit" form="tt-article" class="mv-button" data-size="sm">Save</button>
</span>
</header>
<form id="tt-article" class="tt-form" novalidate>
<div class="tt-row">
<div class="mv-field">
<label class="mv-label" for="tt-title">Title</label>
<input class="mv-input" id="tt-title" name="title" value="Refunds for annual plans">
</div>
<div class="mv-field">
<label class="mv-label" for="tt-category">Category</label>
<select class="mv-select" id="tt-category" name="category">
<option selected>Billing</option>
<option>Plans & pricing</option>
<option>Account</option>
</select>
</div>
</div>
<div class="mv-field">
<label class="mv-label" for="tt-summary">Summary</label>
<input class="mv-input" id="tt-summary" name="summary" value="How refunds work when you cancel an annual subscription.">
</div>
<div class="mv-field">
<label class="mv-label" for="tt-body">Article</label>
<textarea class="mv-textarea" id="tt-body" name="body">Annual plans can be canceled at any time from Settings → Billing. If you cancel within 30 days of your renewal date, we refund the full amount to your original payment method.
After 30 days, your plan stays active until the end of the billing period and is not renewed.</textarea>
</div>
<fieldset class="tt-radios">
<legend class="mv-label">Visible to</legend>
<label class="mv-choice"><input type="radio" class="mv-radio" name="audience" value="all" checked> All customers</label>
<label class="mv-choice"><input type="radio" class="mv-radio" name="audience" value="enterprise"> Enterprise only</label>
<label class="mv-choice"><input type="checkbox" class="mv-checkbox" name="notify" id="tt-notify"> Notify subscribers</label>
</fieldset>
</form>
<div class="tt-controls">
<span class="tt-toggles">
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="tt-guard" checked> Warn before leaving</label>
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="tt-tab" checked> Mark the browser tab</label>
<label class="mv-choice"><input type="checkbox" class="mv-checkbox" id="tt-fail"> Next save fails</label>
</span>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="tt-ff">Let 1 min pass</button>
</div>
<div class="tt-foot">
<p class="tt-log" id="tt-log" aria-live="polite">Press ⌘S / Ctrl+S, click the status chip or Save.</p>
<span class="tt-readout">
<span class="mv-badge" data-variant="outline" data-shape="pill" id="tt-level">calm</span>
<span id="tt-weight">0 changes</span>
</span>
</div>
</section>
<aside class="tt-side">
<!-- The stakes curve: same component, five moments of the same draft -->
<div class="tt-panel">
<h4>Stakes curve</h4>
<p>More unsaved work, left longer, beats stronger and faster. Hover a mark for its detail.</p>
<ul class="tt-curve" id="tt-curve">
<li><span>Clean<small>Everything saved</small></span><mv-tell-tale guard="off" data-seed="0,0"></mv-tell-tale></li>
<li><span>Calm<small>1 edit, seconds ago</small></span><mv-tell-tale guard="off" data-seed="1,10"></mv-tell-tale></li>
<li><span>Stirring<small>2 edits, 2 min</small></span><mv-tell-tale guard="off" data-seed="2,120"></mv-tell-tale></li>
<li><span>Insistent<small>4 edits, 4 min</small></span><mv-tell-tale guard="off" data-seed="4,240"></mv-tell-tale></li>
<li><span>Urgent<small>9 edits, 8 min</small></span><mv-tell-tale guard="off" data-seed="9,480"></mv-tell-tale></li>
</ul>
</div>
<!-- Corner mode: the mark sits on the app's own Save button -->
<form class="tt-panel" id="tt-settings" novalidate>
<h4>Workspace settings</h4>
<p>Corner mode: the mark rides on your own Save button.</p>
<div class="tt-settings">
<div class="mv-field">
<label class="mv-label" for="tt-ws">Workspace name</label>
<input class="mv-input" id="tt-ws" value="Harbor Labs">
</div>
<div class="mv-field">
<label class="mv-label" for="tt-cur">Default currency</label>
<select class="mv-select" id="tt-cur">
<option selected>USD (US dollar)</option>
<option>EUR (Euro)</option>
<option>JPY (Japanese yen)</option>
</select>
</div>
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="tt-2fa"> Require two-factor sign-in</label>
<div class="tt-settings-foot">
<small id="tt-settings-note"></small>
<mv-tell-tale id="tt-corner" guard="off" style="--mv-tell-tale-cutout:var(--mv-surface)" calm="5s" threshold="20s" urgent="60s">
<button type="submit" class="mv-button" data-variant="outline" data-size="sm">Save changes</button>
</mv-tell-tale>
</div>
</div>
</form>
</aside>
</div>
<script type="module">
await customElements.whenDefined("mv-tell-tale");
const $ = (id) => document.getElementById(id);
const main = $("tt-main");
const form = $("tt-article");
const log = $("tt-log");
const clock = () => new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" });
const say = (text) => { log.textContent = `${clock()} · ${text}`; };
const edit = (el, apply) => { apply(el); el.dispatchEvent(new Event("input", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); };
// A draft already in progress: last saved 18 minutes ago, four edits since.
main.savedAt = Date.now() - 18 * 60_000;
edit($("tt-summary"), (el) => { el.value = "How refunds work when you cancel or downgrade an annual subscription."; });
edit($("tt-body"), (el) => { el.value += "\n\nDowngrades from Business to Team are prorated: the difference is credited to your next invoice, never refunded in cash. Credits never expire and apply to add-ons as well."; });
edit(form.querySelector('[value="enterprise"]'), (el) => { el.checked = true; });
edit($("tt-notify"), (el) => { el.checked = true; });
main.dirtySince = Date.now() - 12_000;
// Fake API: 900 ms round trip, optionally failing.
const api = () => new Promise((resolve, reject) => setTimeout(() => ($("tt-fail").checked ? reject(new Error("503")) : resolve()), 900));
main.addEventListener("mv-save", (e) => {
say(`Saving ${e.detail.changes} change${e.detail.changes === 1 ? "" : "s"} (${e.detail.source})…`);
e.detail.waitUntil(api());
});
main.addEventListener("mv-saved", (e) => { $("tt-fail").checked = false; say(`Saved ${e.detail.changes} change${e.detail.changes === 1 ? "" : "s"}.`); });
main.addEventListener("mv-save-error", () => say("Save failed: server unavailable. Your edits are still here."));
main.addEventListener("mv-stake", (e) => { if (e.detail.previous !== "clean" || e.detail.level !== "calm") say(`Stakes: ${e.detail.previous} → ${e.detail.level}.`); });
form.addEventListener("submit", (e) => { e.preventDefault(); main.save({ source: "button" }); });
$("tt-discard").addEventListener("click", (e) => { e.preventDefault(); const n = main.revert(); say(n ? `Discarded ${n} change${n === 1 ? "" : "s"}.` : "Nothing to discard."); });
const readout = () => {
$("tt-level").textContent = main.level;
$("tt-weight").textContent = `${main.changes} change${main.changes === 1 ? "" : "s"} · weight ${main.weight}`;
};
for (const type of ["mv-stake", "mv-saved", "input", "change"]) main.closest("section").addEventListener(type, () => queueMicrotask(readout));
main.addEventListener("mv-stake", readout);
readout();
$("tt-guard").addEventListener("change", (e) => { main.guard = e.target.checked ? "insistent" : "off"; });
$("tt-tab").addEventListener("change", (e) => { main.tab = e.target.checked ? "both" : "off"; });
$("tt-ff").addEventListener("click", () => {
if (!main.changes) { say("Nothing unsaved: edit a field first."); return; }
main.dirtySince -= 5_000; // ≈ 1 min at this demo's speed
readout();
});
// Stakes curve: fixed moments with the default curve (1m / 5m / 15m).
const seed = (el) => {
const [count, secs] = el.dataset.seed.split(",").map(Number);
el.savedAt = Date.now() - (secs + 90) * 1000;
el.mark(count);
if (count) el.dirtySince = Date.now() - secs * 1000;
};
for (const el of $("tt-curve").querySelectorAll("mv-tell-tale")) {
seed(el);
el.addEventListener("mv-save", (e) => e.detail.waitUntil(new Promise((r) => setTimeout(r, 500))));
el.addEventListener("mv-saved", () => setTimeout(() => seed(el), 3200));
}
// Corner mode: one setting already changed 20 seconds ago.
const corner = $("tt-corner");
corner.savedAt = Date.now() - 26 * 3600_000;
$("tt-settings-note").textContent = `Last saved ${new Date(corner.savedAt).toLocaleDateString("en-US", { month: "short", day: "numeric" })}`;
edit($("tt-cur"), (el) => { el.selectedIndex = 1; });
corner.dirtySince = Date.now() - 20_000;
$("tt-settings").addEventListener("submit", (e) => {
e.preventDefault();
$("tt-settings-note").textContent = "Saving…";
setTimeout(() => { corner.saved(); $("tt-settings-note").textContent = "Saved just now"; }, 700);
});
</script>
</div>API
Attributes
| Name | 유형 | Default | Description |
|---|---|---|---|
for | id | Id of the watched region: a <form> (its elements, including those attached with form="…") or any container of fields and contenteditable roots. Defaults to the Save button's form in corner mode, then the closest <form> ancestor; without a region only mark() and dirty count. | |
calm | time ("1m", "45s", ms number) | 1m | End of the calm level for a single change: until then the mark is small, faint and nearly still. |
threshold | time | 5m | Start of the insistent level for a single change: the mark turns to the warning tone and throws an echo ring on each beat; the default level for the tab mark and the leave guard. |
urgent | time | 15m | Start of the urgent level for a single change: largest, fastest beat, danger tone, emphasized chip. All three times are divided by √weight, so more work at risk escalates sooner. |
dirty | boolean | number | Changes the app reports itself: present or "true" = 1, a number = that many, "0" or "false" = none. Same as mark(); added to the fields the component sees. | |
last-saved | ISO date | epoch ms | When the record was last saved (from your server), shown as “last saved 18 min ago”. Every save then updates the savedAt property (the attribute is never rewritten). | |
guard | off | any | stirring | insistent | urgent | insistent | Level from which a beforeunload guard asks for confirmation before leaving. The listener is attached only while that level is reached, and dropped during a native (non-prevented) submit of the watched form. |
tab | off | title | favicon | both | off | Mirror the stakes in the browser tab: title prefixes document.title with “• ” (kept when an SPA rewrites the title), favicon redraws the current icon with a dot in the mark's tone. Shared safely by several instances and fully restored when no longer needed. |
tab-at | any | stirring | insistent | urgent | insistent | Level from which the tab mirror applies. |
shortcut | boolean | Cmd+S (macOS) / Ctrl+S saves: the browser's Save page dialog is prevented and mv-save fires with source "shortcut". With several instances, the one whose region holds focus wins. With nothing unsaved it only confirms “No unsaved changes”. | |
compact | boolean | Chip mode only: the mark alone in a round button; the status label stays available to assistive tech. | |
placement | bottom | top (-start | -end) | bottom | Preferred side of the detail tooltip (flips to stay in the viewport). |
locale | BCP 47 tag | en-US | Locale of the relative times and dates (“18 minutes ago”, “on September 3”); the words around them come from strings. An unknown tag falls back to en-US. |
data-tell-tale-weight | number | On a field or any ancestor (a fieldset): its weight when changed. Default 1; textareas and contenteditable roots weigh 1 + 1 per ~280 changed characters, up to 6. | |
data-tell-tale-ignore | marker attribute | On a field or ancestor: never counted (search boxes, filters, view options inside the editor). | |
data-tell-tale-save | marker attribute | Marks the child used as the Save control (corner mode). Without it, the first child button or link is used. | |
data-level / data-mode / data-flash / data-pending | set by the component | data-level: clean | calm | stirring | insistent | urgent; data-mode: chip | corner; data-flash: saved | error for a few seconds; data-pending while a save is in flight. |
Properties
| Name | 유형 | Description |
|---|---|---|
level | "clean" | "calm" | "stirring" | "insistent" | "urgent" | Current level (read-only). |
changes | number | Unsaved changes: dirty fields (a radio group counts once) + marked changes (read-only). |
weight | number | Total weight of the unsaved changes (read-only). |
intensity | number (0..1) | Continuous stake along the curve, also exposed as --mv-tell-tale-intensity (read-only). |
dirtySince | number (epoch ms) | null | Time of the oldest unsaved change. Settable while changes exist, e.g. to restore the age of a draft reopened from storage. |
savedAt | number (epoch ms) | null | Time of the last save. Accepts a Date, epoch ms or ISO string. |
pending | boolean | True while promises passed to waitUntil() are in flight (read-only). |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement (unsaved, saved, saving, notSaved, noChanges, changeOne, changeMany, lastSaved, neverSaved, allSaved, nothingToSave, savingMany, saveFailed, save, saveNow, tryAgain, justNow, minutesAgo, hoursAgo, status, statusNever, statusClean, statusNone, statusShortcut, announceInsistent, announceUrgent, announceSaved, announceNothing, announceError, savedOn, keyMac, keyOther, keyMacSpoken, keyOtherSpoken). English defaults. |
Methods
| Name | Description |
|---|---|
mark(count) | Report changes the component cannot see (rich editor, canvas, data grid). The count replaces the previous mark and adds to the watched fields; mark(0) clears it. |
saved({ at? }) | The app saved (autosave, custom flow): the current values become the reference, marks are cleared, the mark settles with a “Saved” beat and mv-saved fires. at = server time of the save. |
save({ source? }) | Asks for a save exactly like ⌘S or a chip click: emits the cancelable mv-save; without waitUntil, clicks the corner Save button or calls requestSubmit() on the watched form. Returns Promise<boolean>. |
revert() | Discard: puts every watched field back to its last saved value and fires input and change so frameworks follow (contenteditable and file inputs are skipped, marks are kept). Returns how many fields changed. |
recheck() | Re-reads every field, after the app changed values without firing input events. |
rebase() | The current values become the reference without any “Saved” feedback (a different record was loaded into the same form). |
Events
| Name | Description |
|---|---|
mv-stake | The level changed. detail: { level, previous, changes, weight, age (ms since the oldest unsaved change), intensity }. |
mv-save | Cancelable, a save was requested (chip, shortcut or save()). detail: { source: "button" | "shortcut" | "api", changes, weight, level, waitUntil(promise) }. Call waitUntil() synchronously with your request: the chip shows “Saving…”, success commits exactly the values present when the save started (edits typed meanwhile stay unsaved), a rejection keeps everything and shows “Not saved”; a promise resolving to a Date or epoch ms sets the saved time. preventDefault() cancels it. |
mv-saved | The work is safe. detail: { at, changes (how many were saved), source: "button" | "shortcut" | "api" }. |
mv-save-error | A waitUntil promise rejected. detail: { source, error }. |
Content structure
| Name | Description |
|---|---|
(default) | Optional Save control (a <button>, or any child with data-tell-tale-save). When present the component switches to corner mode: no chip, the mark rides on the control's top-end corner, the tooltip opens from it and the status is added to its aria-describedby. Empty: chip mode. |
CSS classes
| Name | Description |
|---|---|
mv-tell-tale-chip | Chip mode status button: .mv-tell-tale-mark and .mv-tell-tale-label (“7 unsaved”, “Saving…”, “Saved”, “Not saved”, “No changes”); data-compact in compact mode. |
mv-tell-tale-mark | The beating mark (aria-hidden): .mv-tell-tale-dot (scaled on each beat), .mv-tell-tale-ring (echo ring from stirring up) and .mv-tell-tale-check (the saved glyph). data-corner in corner mode. |
mv-tell-tale-tip | Detail tooltip (Popover API, aria-hidden visual twin of the status): .mv-tell-tale-tip-text with <strong>, .mv-tell-tale-sep, .mv-tell-tale-action and .mv-tell-tale-key. |
CSS variables
| Name | 유형 | Default | Description |
|---|---|---|---|
--mv-tell-tale-color | var(--mv-warning) | Tone of the insistent level (stirring mixes it with a neutral). | |
--mv-tell-tale-urgent | var(--mv-danger) | Tone of the urgent level and of a failed save. | |
--mv-tell-tale-saved | var(--mv-success) | Background of the saved check. | |
--mv-tell-tale-cutout | var(--mv-bg) | Corner mode: color of the thin ring that separates the mark from the button edge (set it to your card surface). | |
--mv-tell-tale-tip-bg / --mv-tell-tale-tip-fg | inverted neutrals | Tooltip colors. | |
--mv-tell-tale-intensity | output | Set on the element: continuous stake 0..1, for your own styling (a Save button that firms up, a banner). |
Accessibility
The textual status is always available: a visually hidden sentence (“7 unsaved changes. Last saved 18 minutes ago. Press Command S to save.”) is the aria-describedby of the chip or of your own Save button, so it is read on focus and never chatters; the tooltip that shows the same facts is aria-hidden and opens on hover and on keyboard focus (Escape closes it). The chip is a real <button> whose name is its visible label plus “save now” while changes exist; when nothing is unsaved it is aria-disabled and only confirms. A polite live region speaks only when the stakes cross a level upward into insistent or urgent (“7 unsaved changes, last saved 18 minutes ago.”), never on a beat, never while typing through calm and stirring, and never downward; “All changes saved.” is announced after a save the user asked for (chip, shortcut) or one that ended an insistent or urgent state, so a frequent autosave stays silent; a failed save is announced assertively and the edits stay unsaved. Levels are never encoded by color alone: the dot grows in size from calm to urgent, gains an echo ring, the chip label carries the count and the urgent chip is set in a heavier weight; saving shows a check glyph and the word “Saved”. Reduced motion (OS setting or data-motion="reduce"): no beat at all, the same levels become static steps of size and ring weight, and the saved check appears without the pop. Forced colors: the mark uses CanvasText, Canvas and Highlight. The ⌘S / Ctrl+S shortcut is opt-in, ignored with Alt or Shift (Save as) and on key repeat, and the leave guard is only attached while the chosen level is reached. Known limits: browsers show their own generic leave confirmation (a custom message is never displayed), a cross-origin favicon served without CORS becomes the dot alone, revert() skips contenteditable regions and file inputs, and the component only sees fields that fire input or change events (call recheck() or mark() otherwise).