익스클루시브

Bloodstain <mv-bloodstain>

작업하던 위치를 정확히 기억하는 폼 초안 복구 기능입니다. 아무 <form>(또는 필드가 있는 영역)을 감싸면 값이 마지막으로 포커스된 필드, 캐럿 위치와 함께 자동 저장됩니다(디바운스 적용, localStorage 또는 sessionStorage).

문화적 레퍼런스

다크 소울, FromSoftware (미야자키 히데타카) (2011, 비디오 게임). 죽으면 가지고 있던 모든 것이 쓰러진 바로 그 자리에 혈흔으로 떨어집니다. 혈흔에 닿으면 모두 되찾지만, 닿기 전에 다시 죽으면 영원히 사라집니다. UI에서는 폼 초안이 세션이 끝난 바로 그 필드에 표시되고 한 번의 터치로 복원되며, 복구하지 않고 편집하면 초안이 영원히 대체됩니다.

작동 방식

현재 위치를 정확히 기억하는 폼 초안 복구: 어떤 <form>(또는 필드가 있는 영역)이든 감싸면 값이 마지막으로 포커스된 필드 및 캐럿 위치와 함께 자동 저장됩니다(디바운스 적용, localStorage 또는 sessionStorage). 세션이 끝나면(크래시, 새로고침, 탭 닫기, 제출 실패) 다음 마운트 시 바로 그 필드에 작게 맥동하는 물방울 표시(“You left off here · 12 min ago”)와 간결한 배너 “Recover your unsaved progress? 7 fields · 12 min ago”가 나타납니다. Recover는 한 번의 터치로 모든 것을 복원하고, 프레임워크가 동기화를 유지하도록 input/change를 발생시키며, 필드에 포커스를 두고 캐럿을 원래 위치로 되돌립니다. 자동으로 복원되는 것은 없으며, 복구하지 않고 저장된 필드에 입력하면 이전 초안이 영원히 대체됩니다(forfeit="never"로 해제). 비밀번호, 카드 정보, 일회용 코드, hidden 및 file 입력은 절대 저장되지 않습니다. 제출이 성공하거나 clear()를 호출하면 초안이 지워지며, 초안은 TTL이 지나면 만료됩니다.

카테고리폼
유형Web Component (<mv-bloodstain>)
상태안정
키트사용자를 놓치지 않는 폼
함께 설치되는 항목button
Keywordsexclusive, 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

설치

node scripts/add.mjs bloodstain --out ./src/marvelous

Marvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["bloodstain"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, components/button/button.css, core/dom.js, core/element.js, core/motion.js, components/bloodstain/bloodstain.js, components/bloodstain/bloodstain.css.

사용법

빠른 시작, 동작하는 가장 작은 마크업:

<mv-bloodstain storage-key="support-reply">
  <form>
    <label>Reply <textarea name="reply"></textarea></label>
    <button>Send</button>
  </form>
</mv-bloodstain>

기본 마크업입니다. 여기서 시작해 속성, data-*, 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 storage-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>

API

Attributes

Name유형DefaultDescription
storage-keystringmv-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.
storagelocal | sessionlocallocalStorage (survives closing the browser) or sessionStorage (this tab only, gone when it closes).
debouncenumber (ms)400Delay 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.
ttltime ("24h", "30m", "7d", ms number)24hAge after which a draft is dropped instead of being offered.
forfeitfield | any | neverfieldWhen 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-ignoreboolean (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유형Description
stain{ t, reason, fields, last, keys } | nullThe 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.
dirtybooleanTrue when the current values differ from the ones the form started with (read-only).
stringsPartial<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

NameDescription
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

NameDescription
mv-saveThe draft was written. detail: { count, t, reason } (count = fields that differ from the initial values). Handy for a “Draft saved” indicator.
mv-stainA draft that differs from the screen was found on mount; the banner and mark are shown. detail: { count, fields, t, reason }.
mv-dieddied() was called (or the page was hidden for good with unsaved edits). detail: { reason, saved, count }.
mv-recoverThe draft was restored. detail: { fields, count, element, reason } (element = the field that received focus).
mv-forfeitCancelable. 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-discardThe user chose Discard. detail: { fields, reason }.

Content structure

NameDescription
(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

NameDescription
mv-bloodstain-bannerRecovery banner inserted at the top of the element: -icon, -copy (-title, -sub with -meta and -hint), -actions (Discard, Recover).
mv-bloodstain-markMark 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

NameDefaultDescription
--mv-bloodstain-colorvar(--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). Relative times (“4 min ago”) are English by default; on a page or container with another lang attribute they use Intl.RelativeTimeFormat wording for that language.

이 페이지는 AI로 번역되었습니다. 번역 문제 신고