익스클루시브

Salvage Stop <mv-salvage-stop>

오래 실행되는 프로세스(AI 에이전트, 내보내기, 생성 작업)를 위한 Stop 버튼으로, 중지하기 전에 무엇을 건질 수 있는지 보여 줍니다. 호버하거나 포커스하면 고정된 패널이 두 개의 트레이를 펼칩니다. “You keep”(완료된 결과물, 밝게, 종류별 아이콘 포함)과 “You lose”(진행 중인 항목, 흐리게, 진행률 포함)이며, 건질 수 있는 가치를 밝히는 제목(“You keep 7 of 9 items · 78% of the work”, 항목별 가중치 적용)도 함께 표시됩니다.

작동 방식

오래 실행되는 프로세스(AI 에이전트, 내보내기, 생성)를 위한 Stop 버튼으로, 멈추기 전에 무엇을 건질 수 있는지 보여 줍니다: 호버나 포커스 시 고정된 패널이 두 개의 트레이를 펼칩니다. “You keep”(완료된 결과물, 밝게, 종류별 글리프 포함)와 “You lose”(진행 중인 항목, 흐리게, 진행률 포함), 그리고 회수 가치를 밝히는 헤드라인(“You keep 7 of 9 items · 78% of the work”, 항목별 가중치 적용)입니다. 중지는 길게 누르기로 합니다: 링이 차오르는 동안 잃는 항목은 비율에 맞춰 부서지고, 남는 항목은 하나씩 잠기며 버튼의 가방 속으로 미끄러져 들어갑니다. 일찍 손을 떼면 모든 것이 튕기듯 원래대로 돌아옵니다. 프로세스가 계속 결과를 만드는 동안 목록은 실시간으로 갱신되고, 2단계 확인 모드도 사용할 수 있으며, 최종 상태는 “Open” 액션과 함께 “Stopped · 7 items kept”로 표시됩니다.

카테고리마이크로 인터랙션
유형Web Component (<mv-salvage-stop>)
상태안정
키트안전한 파괴적 작업과 비밀 정보
Keywordsexclusive, stop, cancel, abort, ai, agent, long-task, hold, long-press, confirm, partial-results, salvage, sunk-cost, progress, popover

When to use

  • A long-running AI agent, export or generation job needs a Stop button that shows what will be kept or lost
  • Stopping discards in-progress work, so users should see partial results before confirming with a hold
  • After stopping, users should get a summary of kept items and an action to open them

Avoid when

  • The action is destructive but has no partial results to salvage → use Hold Button instead
  • The task is short and stopping loses nothing; a plain cancel button is clearer → use Button instead
  • Users need to set limits and follow a long task's progress before and during the run → use Run Contract instead

설치

node scripts/add.mjs salvage-stop --out ./src/marvelous

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

복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, core/dismiss.js, core/dom.js, core/element.js, core/motion.js, core/position.js, core/presence.js, components/salvage-stop/salvage-stop.js, components/salvage-stop/salvage-stop.css.

사용법

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

<mv-salvage-stop unit="section|sections">
  <mv-salvage-item ready>Executive summary</mv-salvage-item>
  <mv-salvage-item ready kind="chart">Chart: monthly revenue</mv-salvage-item>
  <mv-salvage-item progress="0.4">Q4 outlook</mv-salvage-item>
</mv-salvage-stop>

기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:

<div id="ss-demo" style="width:min(100%,40rem);margin-inline:auto">
  <style>
    #ss-demo { display:grid; gap:1rem; align-content:start }
    #ss-demo .ss-card { display:grid; gap:1rem; padding:1.125rem 1.25rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm) }
    #ss-demo .ss-head { display:flex; align-items:flex-start; gap:.875rem }
    #ss-demo .ss-icon { display:grid; place-items:center; width:2.5rem; height:2.5rem; flex:none; border-radius:var(--mv-radius-lg); background:var(--mv-accent-subtle); color:var(--mv-accent-fg) }
    #ss-demo .ss-icon svg { width:1.25rem; height:1.25rem }
    #ss-demo .ss-title { display:grid; gap:.125rem; min-width:0; flex:1 }
    #ss-demo .ss-title strong { font-size:1rem; letter-spacing:-.01em; line-height:1.3 }
    #ss-demo .ss-title span { color:var(--mv-fg-muted); font-size:.8125rem }
    #ss-demo .ss-status { display:inline-flex; align-items:center; gap:.4rem; flex:none; height:1.5rem; padding:0 .6rem; border-radius:var(--mv-radius-full); background:var(--mv-bg-muted); color:var(--mv-fg-muted); font-size:.75rem; font-weight:600 }
    #ss-demo .ss-status i { width:.45rem; height:.45rem; border-radius:50%; background:var(--mv-accent); animation:ss-pulse 1.6s ease-in-out infinite }
    #ss-demo .ss-status[data-done] i { background:var(--mv-fg-subtle); animation:none }
    @keyframes ss-pulse { 50% { opacity:.35 } }
    @media (prefers-reduced-motion: reduce) { #ss-demo .ss-status i { animation:none } }
    :where([data-motion="reduce"]) #ss-demo .ss-status i { animation:none }
    #ss-demo .ss-step { margin:0; display:flex; align-items:center; gap:.5rem; color:var(--mv-fg-muted); font-size:.8125rem; min-width:0 }
    #ss-demo .ss-step b { color:var(--mv-fg); font-weight:500; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
    #ss-demo .ss-foot { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding-top:.875rem; border-top:1px solid var(--mv-border) }
    #ss-demo .ss-foot p { margin:0; color:var(--mv-fg-subtle); font-size:.75rem; flex:1 1 14rem }
    #ss-demo .ss-actions { display:flex; align-items:center; gap:.5rem; margin-inline-start:auto }
    #ss-demo .ss-row { display:flex; align-items:center; gap:.875rem; padding:.75rem .875rem .75rem 1rem; flex-wrap:wrap }
    #ss-demo .ss-row .ss-icon { width:2.25rem; height:2.25rem; background:var(--mv-bg-muted); color:var(--mv-fg) }
    #ss-demo .ss-row .ss-title strong { font-size:.875rem }
    #ss-demo .ss-row .ss-title span { font-size:.75rem }
    #ss-demo .ss-count { color:var(--mv-fg-muted); font-size:.75rem; font-variant-numeric:tabular-nums; white-space:nowrap }
    #ss-demo [hidden] { display:none }
  </style>

  <!-- Compact: a video export, confirm mode, items provided as a JS property -->
  <div class="ss-card ss-row">
    <span class="ss-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="13" height="14" rx="2"/><path d="m16 10 5-3v10l-5-3"/></svg></span>
    <span class="ss-title"><strong>Video export · product-launch.mp4</strong><span>4K · H.265 · 10 segments</span></span>
    <span class="ss-actions">
      <span class="ss-count" id="ss-video-count">3 / 10</span>
      <mv-salvage-stop id="ss-video" data-size="sm" mode="confirm" label="Cancel" unit="segment|segments" action-label="View draft"></mv-salvage-stop>
      <button class="mv-button" data-variant="ghost" data-size="sm" id="ss-video-again" hidden>Restart</button>
    </span>
  </div>

  <!-- Main: an AI job producing a quarterly report -->
  <article class="ss-card" aria-labelledby="ss-report-title">
    <div class="ss-head">
      <span class="ss-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/></svg></span>
      <span class="ss-title"><strong id="ss-report-title">Generating the Q3 quarterly report</strong><span>Analyst agent · Q3 2026 · started 6 min ago</span></span>
      <span class="ss-status" id="ss-status"><i aria-hidden="true"></i><span>Running</span></span>
    </div>

    <div class="mv-progress-field">
      <label class="mv-progress-label" for="ss-progress">Overall progress</label>
      <output class="mv-progress-value" for="ss-progress" id="ss-progress-value">86%</output>
      <progress class="mv-progress" id="ss-progress" max="100" value="86" data-shine></progress>
      <span class="mv-progress-hint" id="ss-eta">About 3 min left</span>
    </div>

    <p class="ss-step" id="ss-step-line"><span>Now writing:</span> <b id="ss-step">Chapter 3: Customers and retention</b></p>

    <div class="ss-foot">
      <p>Hover “Stop” or reach it with the keyboard: you see what you keep before you decide.</p>
      <span class="ss-actions">
        <button class="mv-button" data-variant="outline" data-size="sm" id="ss-report-again" hidden>Restart generation</button>
        <mv-salvage-stop id="ss-report" label="Stop" hold="1200">
          <mv-salvage-item ready kind="doc" weight="2" meta="2 pp.">Executive summary</mv-salvage-item>
          <mv-salvage-item ready kind="doc" weight="3" meta="6 pp.">Chapter 1: Sales by region</mv-salvage-item>
          <mv-salvage-item ready kind="chart" meta="PNG">Chart: monthly revenue</mv-salvage-item>
          <mv-salvage-item ready kind="doc" weight="3" meta="5 pp.">Chapter 2: Margins and costs</mv-salvage-item>
          <mv-salvage-item ready kind="table" meta="XLSX">KPI table</mv-salvage-item>
          <mv-salvage-item ready kind="chart" meta="PNG">Chart: market share</mv-salvage-item>
          <mv-salvage-item kind="doc" weight="3" meta="4 pp." progress="0.9">Chapter 3: Customers and retention</mv-salvage-item>
          <mv-salvage-item kind="doc" weight="3" meta="5 pp." progress="0.42">Chapter 4: Q4 outlook</mv-salvage-item>
          <mv-salvage-item kind="data" meta="CSV" progress="0">Appendix: raw data</mv-salvage-item>
        </mv-salvage-stop>
      </span>
    </div>
  </article>

  <script type="module">
    await customElements.whenDefined("mv-salvage-stop");
    const root = document.getElementById("ss-demo");
    const $ = (id) => document.getElementById(id);
    const fmt = (r) => `${Math.round(r * 100)}%`;
    const every = (ms, fn) => { const t = setInterval(() => { if (!root.isConnected) clearInterval(t); else fn(); }, ms); return t; };

    /* ── Report: two workers write chapters in parallel, items flip to ready live ── */
    const report = $("ss-report");
    const items = [...report.querySelectorAll("mv-salvage-item")];
    const initial = items.map((el) => ({ ready: el.hasAttribute("ready"), progress: el.getAttribute("progress") }));
    const speed = [0, 0, 0, 0, 0, 0, 0.2, 0.02, 0.03]; // share per second
    let running = true;

    const renderReport = () => {
      const w = (el) => Number(el.getAttribute("weight") ?? 1);
      const total = items.reduce((s, el) => s + w(el), 0);
      const done = items.reduce((s, el) => s + w(el) * (el.hasAttribute("ready") ? 1 : Number(el.getAttribute("progress") ?? 0)), 0);
      const r = done / total;
      $("ss-progress").value = Math.round(r * 100);
      $("ss-progress-value").textContent = fmt(r);
      const active = items.filter((el) => !el.hasAttribute("ready") && Number(el.getAttribute("progress")) > 0);
      $("ss-step").textContent = active.map((el) => el.textContent).join(" · ") || "Final layout";
      const left = Math.max(1, Math.round((1 - r) * 22));
      if (running) $("ss-eta").textContent = r >= 1 ? "Report ready" : `About ${left} min left`;
    };

    every(250, () => {
      if (!running) return;
      items.forEach((el, i) => {
        if (el.hasAttribute("ready")) return;
        const p = Number(el.getAttribute("progress") ?? 0);
        // The appendix starts once chapter 3 is finished.
        if (i === 8 && !items[6].hasAttribute("ready")) return;
        const next = Math.min(1, p + speed[i] * 0.25);
        if (next >= 1) { el.removeAttribute("progress"); el.toggleAttribute("ready", true); }
        else el.setAttribute("progress", next.toFixed(3));
      });
      renderReport();
      if (items.every((el) => el.hasAttribute("ready"))) {
        running = false;
        $("ss-status").dataset.done = "";
        $("ss-status").lastElementChild.textContent = "Done";
        setTimeout(restartReport, 6000);
      }
    });

    function restartReport() {
      items.forEach((el, i) => {
        el.toggleAttribute("ready", initial[i].ready);
        if (initial[i].progress === null) el.removeAttribute("progress"); else el.setAttribute("progress", initial[i].progress);
      });
      report.reset();
      $("ss-report-again").hidden = true;
      delete $("ss-status").dataset.done;
      $("ss-status").lastElementChild.textContent = "Running";
      $("ss-step-line").hidden = false;
      running = true;
      renderReport();
    }

    report.addEventListener("mv-stop", (e) => {
      running = false;
      $("ss-status").dataset.done = "";
      $("ss-status").lastElementChild.textContent = "Stopped";
      $("ss-eta").textContent = `${e.detail.kept.length} items kept · ${fmt(e.detail.ratio)} of the work`;
      $("ss-step-line").hidden = true;
      $("ss-report-again").hidden = false;
    });
    report.addEventListener("mv-salvage-open", (e) => {
      $("ss-eta").textContent = `Opening ${e.detail.kept.length} items in the editor…`;
    });
    $("ss-report-again").addEventListener("click", restartReport);
    renderReport();
    // Show the salvage panel straight away so the idea reads at a glance.
    report.open();

    /* ── Video export: segments rendered two at a time, fed through the `items` property ── */
    const video = $("ss-video");
    const durations = [12, 9, 14, 11, 8, 13, 10, 12, 9, 7];
    const tc = (s) => `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
    const makeSegments = () => {
      let t = 0;
      return durations.map((d, i) => {
        const seg = { id: `seg-${i + 1}`, label: `Segment ${String(i + 1).padStart(2, "0")} · ${tc(t)}-${tc(t + d)}`, kind: "video", weight: d, meta: `${d}s`, progress: i < 3 ? 1 : i === 3 ? 0.55 : i === 4 ? 0.2 : 0 };
        t += d;
        return seg;
      });
    };
    let segments = makeSegments();
    let exporting = true;
    const pushVideo = () => {
      video.items = segments.map((s) => ({ ...s, ready: s.progress >= 1 }));
      $("ss-video-count").textContent = `${segments.filter((s) => s.progress >= 1).length} / ${segments.length}`;
    };
    every(300, () => {
      if (!exporting) return;
      let active = 0;
      for (const s of segments) {
        if (s.progress >= 1) continue;
        if (active++ >= 2) break;
        s.progress = Math.min(1, s.progress + 0.3 / s.weight * 0.9);
      }
      pushVideo();
      if (segments.every((s) => s.progress >= 1)) { exporting = false; setTimeout(restartVideo, 5000); }
    });
    function restartVideo() {
      segments = makeSegments();
      video.reset();
      $("ss-video-again").hidden = true;
      exporting = true;
      pushVideo();
    }
    video.addEventListener("mv-stop", () => { exporting = false; $("ss-video-again").hidden = false; });
    $("ss-video-again").addEventListener("click", restartVideo);
    pushVideo();
  </script>
</div>

API

Attributes

Name유형DefaultDescription
labelstringStopLabel of the generated button (ignored when you provide your own child <button>, whose content is kept).
holdnumber (ms)1200Hold duration required to stop (minimum 300).
modehold | confirmholdhold: press and hold (mouse, touch, Space); Enter twice still works from the keyboard. confirm: two clicks / two Enter presses, the second one plays the stop animation fast-forwarded.
unitstringitem|itemsArtifact noun as “singular|plural” (without a bar, the plural adds “s”).
placementbottom-end | bottom-start | bottom | top-end | top-start | topbottom-endPreferred panel side; it moves to the side with more room and its height is capped to the available space.
done-labelstringStopped · {n} {unit} keptFinal label; {n} is the kept count, {unit} the unit agreed with it, {s} an “s” when plural.
action-labelstringOpenLabel of the action shown after stopping (emits mv-salvage-open).
disabledbooleanDisables the button.
data-sizesm(md)Compact version (toolbars, list rows).
data-stateidle | armed | holding | rewinding | stoppedSet by the component (styleable).
<mv-salvage-item> ready | progress | weight | kind | meta | labeldeclarative childOne artifact: ready (finished) or progress 0-1, weight (share in the salvage ratio, 1 by default), kind (doc | chart | table | image | video | audio | code | data | slides | file), meta (short trailing text: “6 pp.”, “PNG”), label (otherwise the element text). Any child element with data-salvage-item and the same attributes as data-* works too. Changes are tracked live.

Properties

Name유형Description
items{ id?, label, kind?, meta?, weight?, progress?, ready? }[]Artifacts provided from JS (appended after declared children; reassign the array to update). When read: the consolidated list, with element for declared children.
summary{ kept, lost, ratio, total }What stopping now would keep / lose (read-only).
statestringCurrent state (read-only): idle, armed, holding, rewinding, stopped.

Methods

NameDescription
open()Opens the panel and keeps it open (Escape, an outside click or close() closes it).
close()Closes the panel and cancels any hold or pending confirmation.
stop()Stops immediately, without the hold (timeout, host decision…); emits mv-stop.
reset()Back to the running state (a new run started): the list is live again.

Events

NameDescription
mv-stop-intentThe panel opens: the user is considering stopping. detail: { kept, lost, ratio }.
mv-stopStop confirmed. detail: { kept, lost, ratio } (the list is frozen at that moment).
mv-stop-cancelHold released early, confirmation abandoned or Escape. detail: { reason: "release" | "escape" | "blur" | "outside" | "timeout" | "close" }.
mv-salvage-openClick on the final “Open” action. detail: { kept }.

Content structure

NameDescription
(children)<mv-salvage-item> or [data-salvage-item] elements (data, never rendered as-is); an optional child <button> becomes the trigger.

CSS classes

NameDescription
mv-salvage-stop-triggerThe button: hold ring (-ring), label, satchel (-satchel) that fills as items lock.
mv-salvage-stop-panelAnchored panel (Popover API, top layer): -head (headline + meter), -tray[data-tray=keep|lose], -foot (shortcuts).
mv-salvage-stop-rowArtifact row; [data-locked] once secured during the hold, [data-fresh] when it just finished.
mv-salvage-stop-actionAction shown after stopping.

CSS variables

NameDefaultDescription
--mv-salvage-stop-colorvar(--mv-danger)Stop color: ring, fill, lost items.
--mv-salvage-stop-keepvar(--mv-success)Color of what is kept: satchel, locks, meter.

Accessibility

A real <button> with aria-expanded / aria-controls pointing to the panel and aria-describedby pointing to a summary sentence kept up to date (“If you stop now, you keep 7 of 9 items (78% of the work); 2 in progress will be lost. Hold Space, or press Enter twice, to stop.”). The panel is a labelled non-modal region placed right after the button in the DOM (its content is also summed up in the description sentence), with two lists labelled by their titles (“You keep 7”, “You lose 2”); each row reads its state (“done”, “in progress, 42% done”, “queued”). Keyboard: keyboard focus opens the panel, holding Space stops, Enter then Enter confirms, Escape cancels at any point and closes; losing focus cancels a hold or pending confirmation. When the panel is capped by a small screen and scrolls, its scroll area becomes a Tab stop right after the button (a group named by the headline): arrow keys scroll it, the panel stays open while focus is inside, and Escape closes it and returns focus to the button. Touch: a long press is enough, a tap opens the panel and shows the hint, scrolling cancels. A role=status region announces the stop starting, the cancellation (“nothing was lost”) and the result; after stopping, focus moves to the “Open” action. Colors are always paired with an icon and a label (check / dashed crossed circle, “You keep” / “You lose”). The crumbling, the dust and the tokens flying into the satchel are aria-hidden. Reduced motion (OS setting or data-motion="reduce"): the hold gauge stays, crumbling becomes a plain fade, nothing flies, no spring. The animation loop runs only while holding and rewinding, never at rest; the panel closes when the button scrolls out of view. Forced colors: the stop square, ring, meter, progress bars and locks switch to system colors or borders. Right-to-left: the hold fill, the meter, the bars, the crumbling and the dust run from the inline start. Known limits: only direct children of mv-salvage-stop count as items (a wrapper hides the items inside it); the panel texts and announcements are English (only label, done-label and action-label can be changed).

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