Exclusifbêta
Salvage Stop <mv-salvage-stop>
Un bouton Stop pour les processus longs (agents IA, exports, génération) qui montre ce que vous conservez avant d’arrêter : au survol ou au focus, un panneau ancré déploie deux plateaux, « Vous conservez » (artefacts terminés, lumineux, avec le pictogramme de leur type) et « Vous perdez » (éléments en cours, estompés, avec leur progression), plus un titre qui énonce ce qui est sauvé (« Vous conservez 7 éléments sur 9 · 78 % du travail », pondéré par élément). L’arrêt se fait par appui maintenu : un anneau se remplit, les éléments perdus s’effritent en proportion tandis que les éléments conservés se verrouillent un par un et glissent dans la besace du bouton ; relâcher trop tôt remet tout en place d’un coup de ressort. La liste se met à jour en direct pendant que le processus continue de produire, un mode de confirmation en deux étapes est disponible, et l’état final affiche « Arrêté · 7 éléments conservés » avec une action « Ouvrir ».
| Catégorie | Micro-interactions |
|---|---|
| Type | Web Component (<mv-salvage-stop>) |
| Statut | bêta |
| Kit | Actions destructives et secrets en sécurité |
| Keywords | exclusive, 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
Installation
node scripts/add.mjs salvage-stop --out ./src/marvelousAgent IA avec le serveur MCP Marvelous UI : install_components({ slugs: ["salvage-stop"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Fichiers copiés (dépendances comprises) : 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.
Utilisation
Démarrage rapide, le balisage minimal qui fonctionne :
<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>Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les variables 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 | Type | Default | Description |
|---|---|---|---|
label | string | Stop | Label of the generated button (ignored when you provide your own child <button>, whose content is kept). |
hold | number (ms) | 1200 | Hold duration required to stop (minimum 300). |
mode | hold | confirm | hold | hold: 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. |
unit | string | item|items | Artifact noun as “singular|plural” (without a bar, the plural adds “s”). |
placement | bottom-end | bottom-start | bottom | top-end | top-start | top | bottom-end | Preferred panel side; it moves to the side with more room and its height is capped to the available space. |
done-label | string | Stopped · {n} {unit} kept | Final label; {n} is the kept count, {unit} the unit agreed with it, {s} an “s” when plural. |
action-label | string | Open | Label of the action shown after stopping (emits mv-salvage-open). |
disabled | boolean | Disables the button. | |
data-size | sm | (md) | Compact version (toolbars, list rows). |
data-state | idle | armed | holding | rewinding | stopped | Set by the component (styleable). | |
<mv-salvage-item> ready | progress | weight | kind | meta | label | declarative child | One 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 | Type | 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). |
state | string | Current state (read-only): idle, armed, holding, rewinding, stopped. |
Methods
| Name | Description |
|---|---|
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
| Name | Description |
|---|---|
mv-stop-intent | The panel opens: the user is considering stopping. detail: { kept, lost, ratio }. |
mv-stop | Stop confirmed. detail: { kept, lost, ratio } (the list is frozen at that moment). |
mv-stop-cancel | Hold released early, confirmation abandoned or Escape. detail: { reason: "release" | "escape" | "blur" | "outside" | "timeout" | "close" }. |
mv-salvage-open | Click on the final “Open” action. detail: { kept }. |
Content structure
| Name | Description |
|---|---|
(children) | <mv-salvage-item> or [data-salvage-item] elements (data, never rendered as-is); an optional child <button> becomes the trigger. |
CSS classes
| Name | Description |
|---|---|
mv-salvage-stop-trigger | The button: hold ring (-ring), label, satchel (-satchel) that fills as items lock. |
mv-salvage-stop-panel | Anchored panel (Popover API, top layer): -head (headline + meter), -tray[data-tray=keep|lose], -foot (shortcuts). |
mv-salvage-stop-row | Artifact row; [data-locked] once secured during the hold, [data-fresh] when it just finished. |
mv-salvage-stop-action | Action shown after stopping. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-salvage-stop-color | var(--mv-danger) | Stop color: ring, fill, lost items. |
--mv-salvage-stop-keep | var(--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, 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. 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.