Exclusivobeta
Bloodstain <mv-bloodstain>
Recuperação de rascunhos de formulário que lembra exatamente onde você estava: envolva qualquer <form> (ou região com campos) e os valores são salvos automaticamente (com debounce, em localStorage ou sessionStorage) junto com o último campo em foco e a posição do cursor. Quando a sessão morre (travamento, recarregamento, aba fechada, envio com falha), a montagem seguinte mostra uma pequena marca de gota pulsante nesse mesmo campo (“You left off here · 12 min ago”) e um banner compacto, “Recover your unsaved progress? 7 fields · 12 min ago”; Recover restaura tudo com um toque, dispara input/change para que os frameworks fiquem sincronizados, coloca o foco no campo e devolve o cursor ao lugar. Nada é restaurado automaticamente, e digitar num campo salvo sem recuperar substitui o rascunho antigo de vez (forfeit="never" desativa isso). Senhas, dados de cartão, códigos de uso único, campos ocultos e de arquivo nunca são guardados; um envio bem-sucedido ou clear() apaga o rascunho; os rascunhos expiram após um TTL.
| Categoria | Formulários |
|---|---|
| Tipo | Web Component (<mv-bloodstain>) |
| Status | beta |
| Kit | Formulários que não perdem ninguém |
| Também instala | button |
| Keywords | exclusive, culture, form, draft, autosave, recovery, restore, unsaved-changes, crash, reload, localstorage, session-storage, resume, caret, persistence, form-state |
When to use
- A long form (application, checkout address, support ticket) must survive a crash, a reload or a closed tab without losing what was typed
- A fetch-based submit can fail and the user should find their input, and their place in it, when they come back
- Users jump between tabs mid-form and resuming at the exact field and caret saves real time
- You need a draft that never persists secrets such as passwords, card numbers or one-time codes
Avoid when
- Drafts must follow the user across devices; save them on the server instead of in browser storage
- The form is a two-field sign-in or search box where recovery adds more noise than value
- The page must show what changed while the user was away rather than restore their own input → use While Away instead
Instalação
node scripts/add.mjs bloodstain --out ./src/marvelousAgente de IA com o servidor MCP do Marvelous UI: install_components({ slugs: ["bloodstain"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Arquivos copiados (dependências incluídas): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, components/bloodstain/bloodstain.js, components/bloodstain/bloodstain.css, components/button/button.css.
Uso
Início rápido, a menor marcação que funciona:
<mv-bloodstain key="support-reply">
<form>
<label>Reply <textarea name="reply"></textarea></label>
<button>Send</button>
</form>
</mv-bloodstain>Marcação de referência, para usar como ponto de partida e personalizar com atributos, data-* e variáveis CSS:
<div id="bs-demo" style="width:min(100%,60rem);margin-inline:auto">
<style>
#bs-demo .bs-grid { display:grid; grid-template-columns:minmax(0,1fr) 17rem; gap:1.25rem; align-items:start }
#bs-demo .bs-card { min-width:0; padding:1.25rem 1.375rem 1.375rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm) }
#bs-demo .bs-head { display:flex; align-items:flex-start; justify-content:space-between; gap:.75rem; margin:0 0 1.125rem }
#bs-demo .bs-head h3 { margin:0; font-size:1.0625rem; letter-spacing:-.01em }
#bs-demo .bs-head p { margin:.1875rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#bs-demo .bs-saved { flex:none; color:var(--mv-fg-subtle); font-size:.75rem; white-space:nowrap; padding-top:.1875rem }
#bs-demo .bs-saved[data-on] { color:var(--mv-fg-muted) }
#bs-demo .bs-saved[data-on]::before { content:""; display:inline-block; width:.4375rem; height:.4375rem; margin-inline-end:.375rem; border-radius:50%; background:var(--mv-success); vertical-align:.0625rem }
#bs-demo .bs-radios { display:flex; gap:.5rem 1.25rem; flex-wrap:wrap; padding-top:.25rem }
#bs-demo .bs-foot { display:flex; align-items:center; justify-content:space-between; gap:.75rem; flex-wrap:wrap; margin-top:1.25rem; padding-top:1rem; border-top:1px solid var(--mv-border) }
#bs-demo .bs-foot small { color:var(--mv-fg-subtle); font-size:.75rem }
#bs-demo .bs-side { display:grid; gap:1rem; position:sticky; top:1rem }
#bs-demo .bs-panel { padding:1rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-bg-subtle) }
#bs-demo .bs-panel h4 { margin:0 0 .625rem; font-size:.8125rem }
#bs-demo .bs-steps { margin:0 0 1rem; padding-inline-start:1.125rem; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.5; display:grid; gap:.375rem }
#bs-demo .bs-steps b { color:var(--mv-fg); font-weight:600 }
#bs-demo .bs-crash { width:100% }
#bs-demo .bs-chips { display:flex; flex-wrap:wrap; gap:.375rem; margin:0 0 .75rem; padding:0; list-style:none }
#bs-demo .bs-chips:empty::before { content:"Nothing saved yet"; color:var(--mv-fg-subtle); font-size:.75rem }
#bs-demo .bs-label { margin:0 0 .375rem; color:var(--mv-fg-subtle); font-size:.6875rem; font-weight:600; letter-spacing:.05em; text-transform:uppercase }
#bs-demo .bs-loading { display:grid; place-items:center; min-height:34rem; color:var(--mv-fg-muted); font-size:.875rem }
#bs-demo .bs-done { display:grid; justify-items:center; gap:.5rem; padding:3rem 1rem; text-align:center }
#bs-demo .bs-done p { margin:0; color:var(--mv-fg-muted); font-size:.875rem }
#bs-demo [hidden] { display:none }
@media (max-width:52rem) {
#bs-demo .bs-grid { grid-template-columns:minmax(0,1fr) }
#bs-demo .bs-side { position:static }
}
</style>
<div class="bs-grid">
<div class="bs-card" id="bs-slot"></div>
<aside class="bs-side" aria-label="Try it">
<div class="bs-panel">
<h4>Try it</h4>
<ol class="bs-steps">
<li>The last session crashed mid-sentence. Recover the draft in one click, or start typing in a saved field to lose it for good.</li>
<li>Edit anything, then <b>Simulate a crash</b>: the form is torn down and loads again, empty.</li>
<li>Your latest draft is offered back, with a mark on the field you were in.</li>
</ol>
<button type="button" class="mv-button bs-crash" id="bs-crash">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.6-6.4"/><path d="M21 4v5h-5"/></svg>
Simulate crash
</button>
</div>
<div class="bs-panel">
<h4>What the draft stores</h4>
<p class="bs-label">Saved</p>
<ul class="bs-chips" id="bs-stored"></ul>
<p class="bs-label">Never stored</p>
<ul class="bs-chips">
<li><span class="mv-badge" data-variant="outline">password</span></li>
<li><span class="mv-badge" data-variant="outline">applicant ID (hidden)</span></li>
</ul>
</div>
</aside>
</div>
<template id="bs-template">
<mv-bloodstain key="mv-demo:job-application" debounce="300">
<form id="bs-form" novalidate>
<div class="bs-head">
<div>
<h3>Senior Product Designer</h3>
<p>Remote, United States · Full time</p>
</div>
<span class="bs-saved" id="bs-saved" aria-live="polite">Not saved yet</span>
</div>
<input type="hidden" name="applicant_id" value="apl_7Q2K9">
<div class="mv-field-group">
<div class="mv-field-row">
<div class="mv-field">
<label class="mv-label" for="bs-name" data-required>Full name</label>
<input class="mv-input" id="bs-name" name="name" autocomplete="name" required>
</div>
<div class="mv-field">
<label class="mv-label" for="bs-email" data-required>Email</label>
<input class="mv-input" id="bs-email" name="email" type="email" autocomplete="email" required>
</div>
</div>
<div class="mv-field-row">
<div class="mv-field">
<label class="mv-label" for="bs-phone" data-optional="optional">Phone</label>
<input class="mv-input" id="bs-phone" name="phone" type="tel" autocomplete="tel">
</div>
<div class="mv-field">
<label class="mv-label" for="bs-site">Portfolio URL</label>
<input class="mv-input" id="bs-site" name="portfolio" type="url" placeholder="https://">
</div>
</div>
<div class="mv-field-row">
<div class="mv-field">
<label class="mv-label" for="bs-notice">Available to start</label>
<select class="mv-select" id="bs-notice" name="notice">
<option value="">Select…</option>
<option value="now">Immediately</option>
<option value="2w">In 2 weeks</option>
<option value="1m">In 1 month</option>
<option value="2m">In 2 months or more</option>
</select>
</div>
<fieldset class="mv-field" style="border:0;margin:0;padding:0;min-width:0">
<legend class="mv-label">Work arrangement</legend>
<div class="bs-radios">
<label class="mv-choice"><input type="radio" class="mv-radio" name="arrangement" value="remote"> Remote</label>
<label class="mv-choice"><input type="radio" class="mv-radio" name="arrangement" value="hybrid"> Hybrid</label>
<label class="mv-choice"><input type="radio" class="mv-radio" name="arrangement" value="onsite"> On-site</label>
</div>
</fieldset>
</div>
<div class="mv-field">
<label class="mv-label" for="bs-letter">Why this role?</label>
<textarea class="mv-textarea" id="bs-letter" name="letter" style="--mv-textarea-min-rows:4" placeholder="A few lines about your work and what draws you here…"></textarea>
</div>
<div class="mv-field">
<label class="mv-label" for="bs-pass">Create a password for your candidate account</label>
<input class="mv-input" id="bs-pass" name="password" type="password" autocomplete="new-password" aria-describedby="bs-pass-hint">
<p class="mv-field-description" id="bs-pass-hint">Never saved in the draft: you’ll type it again after a crash.</p>
</div>
<label class="mv-choice"><input type="checkbox" class="mv-checkbox" name="updates" id="bs-updates"> Let me know about similar roles</label>
</div>
<div class="bs-foot">
<small>Drafts are kept on this device for 24 hours.</small>
<button type="submit" class="mv-button" id="bs-submit">Submit application</button>
</div>
</form>
</mv-bloodstain>
</template>
<script type="module">
const root = document.getElementById("bs-demo");
const slot = document.getElementById("bs-slot");
const tpl = document.getElementById("bs-template");
const stored = document.getElementById("bs-stored");
const KEY = "mv-demo:job-application";
const LABELS = { name: "name", email: "email", phone: "phone", portfolio: "portfolio", notice: "start date", arrangement: "arrangement", letter: "cover note", updates: "updates" };
let host = null;
let savedAt = 0;
let ticker = 0;
const readDraft = () => { try { return JSON.parse(localStorage.getItem(KEY) || "null"); } catch { return null; } };
const showStored = () => {
const d = readDraft();
stored.replaceChildren(...Object.entries(d?.fields ?? {})
.filter(([, v]) => v !== "" && v !== null && v !== false && !(Array.isArray(v) && !v.length))
.map(([k]) => { const li = document.createElement("li"); const b = document.createElement("span"); b.className = "mv-badge"; b.dataset.variant = "secondary"; b.textContent = LABELS[k] ?? k; li.append(b); return li; }));
};
const tick = () => {
const s = host?.querySelector("#bs-saved");
if (!s || !savedAt) return;
const sec = Math.round((Date.now() - savedAt) / 1000);
s.textContent = `Draft saved · ${sec < 5 ? "just now" : sec < 60 ? `${sec}s ago` : `${Math.round(sec / 60)} min ago`}`;
s.dataset.on = "";
};
// Listeners live on the stable slot: component events bubble.
slot.addEventListener("mv-save", (e) => { savedAt = e.detail.t; tick(); showStored(); });
slot.addEventListener("mv-stain", () => { const s = host?.querySelector("#bs-saved"); if (s) s.textContent = "Unsaved draft found"; });
slot.addEventListener("mv-forfeit", () => setTimeout(showStored, 400));
slot.addEventListener("mv-discard", showStored);
slot.addEventListener("submit", (e) => {
e.preventDefault();
const btn = host.querySelector("#bs-submit");
btn.setAttribute("aria-busy", "true");
setTimeout(() => {
host.clear();
showStored();
const done = document.createElement("div");
done.className = "bs-done";
const badge = Object.assign(document.createElement("span"), { className: "mv-badge", textContent: "Submitted" });
badge.dataset.variant = "success";
const title = Object.assign(document.createElement("strong"), { textContent: "Application sent" });
const text = Object.assign(document.createElement("p"), { textContent: "The saved draft was wiped. Simulate a crash now: nothing comes back." });
done.append(badge, title, text);
host.querySelector("form").replaceWith(done);
}, 700);
});
function mount() {
slot.replaceChildren(tpl.content.cloneNode(true));
host = slot.querySelector("mv-bloodstain");
savedAt = 0;
showStored();
}
// A draft left by a session that died 4 minutes ago, in the shape the component writes
// (no password: it is never stored). The cover note stops mid-sentence, caret at the end.
function seedDraft() {
const letter = "Hi Maya,\n\nI’ve spent six years designing B2B tools, most recently leading the billing redesign at a fintech startup. What draws me to this role is";
const draft = {
v: 1,
t: Date.now() - 4 * 60e3,
reason: "pagehide",
last: { name: "letter", index: 0, start: letter.length, end: letter.length },
fields: {
name: "Amara Okafor",
email: "[email protected]",
phone: "+1 415 555 0142",
portfolio: "https://amaraokafor.design",
notice: "1m",
arrangement: "remote",
letter,
updates: true,
},
};
try { localStorage.setItem(KEY, JSON.stringify(draft)); } catch {}
}
document.getElementById("bs-crash").addEventListener("click", () => {
if (!host) return;
host.remove(); // pending edits are flushed on disconnect, like a real tab closing
const loading = document.createElement("div");
loading.className = "bs-loading";
loading.textContent = "Reloading the page…";
slot.replaceChildren(loading);
setTimeout(mount, 650);
});
// Opens on a recovered draft. A real reload also works: an existing draft is offered as is.
if (!readDraft()) seedDraft();
mount();
clearInterval(ticker);
ticker = setInterval(() => { if (!root.isConnected) clearInterval(ticker); else tick(); }, 5000);
</script>
</div>Referência cultural
Dark Souls, FromSoftware (Hidetaka Miyazaki) (2011, jogo). Quando você morre, tudo o que carregava cai como uma mancha de sangue exatamente onde você caiu; toque nela para recuperar tudo, mas morra de novo antes de alcançá-la e ela some para sempre. Na interface, um rascunho de formulário fica marcado no campo exato onde a sessão morreu e é restaurado com um toque, e editar sem recuperá-lo o substitui para sempre.
API
Attributes
| Name | Tipo | Default | Description |
|---|---|---|---|
key | string | mv-bloodstain:<pathname>:<form id | name | action> | Storage key of the draft. Set it explicitly (per record, e.g. "ticket-4821") whenever the same form appears on several URLs or several times on a page. |
storage | local | session | local | localStorage (survives closing the browser) or sessionStorage (this tab only, gone when it closes). |
debounce | number (ms) | 400 | Delay after the last edit before the draft is written. Pending edits are always flushed when the tab is hidden, on pagehide and when the element is removed. |
ttl | time ("24h", "30m", "7d", ms number) | 24h | Age after which a draft is dropped instead of being offered. |
forfeit | field | any | never | field | When an unrecovered draft is lost for good: field = on the first edit of a field that has a saved value; any = on the first edit anywhere in the form; never = only via Discard, submit, clear() or the TTL. The banner states the rule (“Editing a saved field will discard it.”). While a draft awaits recovery, autosave is paused so it is never overwritten by accident. |
data-bloodstain-ignore | boolean (on a field or any ancestor) | Excludes fields from the draft, in addition to the built-in exclusions: type password / hidden / file / submit / button / reset / image, and autocomplete tokens cc-*, one-time-code, new-password, current-password. Fields without name or id and disabled fields are skipped too. |
Properties
| Name | Tipo | Description |
|---|---|---|
stain | { t, reason, fields, last, keys } | null | The draft awaiting recovery (read-only): save time, reason ("pagehide", your died() reason, or null), saved values by field name, last position { name, index, start, end } and the names of the fields that differ from the screen. |
dirty | boolean | True when the current values differ from the ones the form started with (read-only). |
strings | Partial<Record<string, string>> | Overrides for visible texts and announcements (title, meta {count} {fields} {ago}, field, fields, hintField, hintAny, hintNever, recover, discard, marker {ago}, describe {ago}, announceFound, announceRecovered, announceDiscarded, announceForfeit). English defaults. |
Methods
| Name | Description |
|---|---|
recover() | Restores the draft: writes the values, dispatches bubbling input and change on each changed control, flashes the restored fields, focuses the field where the user left off and restores the caret/selection. Emits mv-recover. Returns false when there is nothing to recover. |
discard() | Drops the draft without restoring it. Emits mv-discard. |
clear() | Wipes the saved draft and takes the current values as the new baseline. Call it after a successful fetch-based submit (a native submit that nobody prevented does it automatically). |
died(reason) | Tells the component the session just died (failed request, error boundary…): flushes the draft now, tagged with reason. Called automatically on pagehide when there are unsaved edits. Emits mv-died; returns true if something was saved. |
save() | Writes the draft immediately, skipping the debounce. |
Events
| Name | Description |
|---|---|
mv-save | The draft was written. detail: { count, t, reason } (count = fields that differ from the initial values). Handy for a “Draft saved” indicator. |
mv-stain | A draft that differs from the screen was found on mount; the banner and mark are shown. detail: { count, fields, t, reason }. |
mv-died | died() was called (or the page was hidden for good with unsaved edits). detail: { reason, saved, count }. |
mv-recover | The draft was restored. detail: { fields, count, element, reason } (element = the field that received focus). |
mv-forfeit | Cancelable. The user edited the form without recovering and the old draft is about to be replaced. detail: { fields, reason, field }. preventDefault() keeps the draft. |
mv-discard | The user chose Discard. detail: { fields, reason }. |
Content structure
| Name | Description |
|---|---|
(children) | A <form> or any region containing inputs, textareas and selects. Controls are identified by name (or id); radio groups, checkbox groups, multiple selects and repeated names are supported. |
CSS classes
| Name | Description |
|---|---|
mv-bloodstain-banner | Recovery banner inserted at the top of the element: -icon, -copy (-title, -sub with -meta and -hint), -actions (Discard, Recover). |
mv-bloodstain-mark | Mark pinned to the top inline-end corner of the field where the user left off (host-relative, scrolls with the form): -drop (teardrop with pulse ring) and -mark-label. Clicking it recovers. |
[data-bloodstain-restored] | Set for 1.6 s on each restored control: fading outline (the focus ring stays visible). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-bloodstain-color | var(--mv-accent) | Color of the mark, the banner tint and the restored-field outline. |
Accessibility
Nothing is ever restored behind the user’s back. When a draft is found, a polite live region announces it once (“Unsaved progress from 12 minutes ago found: 7 fields. Recover or discard it from the banner at the top of the form.”). The banner is a labelled region placed before the form in the DOM, with two real <button>s (Discard, Recover) reachable with Tab. The visual mark is aria-hidden; instead, the field where the user left off gets an aria-describedby pointing to a hidden description (“You left off here 12 minutes ago…”) so screen reader users hear it when they reach that field, and the reference is removed once the draft is resolved. Recover moves focus to that field, restores the caret or selection and announces “Progress recovered: 7 fields restored.”; Discard announces and moves focus to the first field; a forfeit is announced too. The rule for losing the draft is written in the banner, not only implied. Restored values dispatch real input/change events so framework state and validation stay in sync. Reduced motion (OS or data-motion="reduce"): no pulse, no landing or banner animation; the mark keeps a static halo and restored fields a static outline for 1.6 s. The mark is also clickable with a pointer or touch (a shortcut; the banner stays the accessible path).