Exclusifbêta
Ghost Run <mv-ghost-run>
La progression face à votre propre tentative précédente, pour les tâches que l’on répète : un fantôme translucide rejoue votre meilleure course (ou la dernière, ou celle d’un coéquipier, ou la médiane de l’équipe fournie par l’app) sur la même piste en temps réel, pour que vous voyiez à chaque instant si vous êtes en avance ou en retard sur lui. Une course est enregistrée sous forme de points de passage horodatés, alimentés par checkpoint(id | value), une propriété progress (les seuils de passage sont franchis automatiquement) ou des éléments [data-ghost-mark] surveillés qui deviennent cochés, terminés ou visibles ; à chaque point de passage, un temps intermédiaire montre l’écart (« −1,8 s d’avance sur votre meilleure course », « +3 min de retard sur la semaine dernière », ou « +203 $ d’avance sur septembre » quand l’écart se mesure en progression plutôt qu’en temps), avec des puces de temps intermédiaire par point de passage sous la piste. Trois pistes : une barre avec une bande d’écart rayée entre vous et le fantôme, un anneau pour les sessions et les checklists, et une sparkline de progression cumulée pour les objectifs suivis sur le calendrier (clock="manual"). Mettre en pause fige aussi le fantôme, les courses en cours peuvent être restaurées après un rechargement et, à l’arrivée, une meilleure course devient discrètement le nouveau fantôme (localStorage avec storage-key, ou votre serveur via le mv-save annulable), tandis qu’une course plus lente demande s’il faut la conserver.
| Catégorie | Feedback |
|---|---|
| Type | Web Component (<mv-ghost-run>) |
| Statut | bêta |
| Installe aussi | button |
| Keywords | exclusive, culture, progress, personal-best, ghost, pace, splits, time-trial, checkpoints, checklist, habit, drill, learning, goal, savings, sparkline, ring, gamification, comparison |
When to use
- A drill, lesson or practice session is repeated often and people improve by beating their own previous time
- A recurring checklist or report (weekly close, pre-flight check, on-call handover) should be compared with last time as it is filled
- A monthly or weekly goal (savings, sales, pages read) should show day by day whether it is ahead of last period
- A team wants each person to see their pace against a teammate’s or the median run without a public leaderboard
Avoid when
- The task is done once (a signup, a first setup) so there is no previous attempt to race → use Progress instead
- Progress follows named steps the user navigates between, with no time or pace involved → use Stepper instead
- Comparing with a past self would add pressure to a task where speed hurts quality or safety, such as medical or legal review
Installation
node scripts/add.mjs ghost-run --out ./src/marvelousAgent IA avec le serveur MCP Marvelous UI : install_components({ slugs: ["ghost-run"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Fichiers copiés (dépendances comprises) : tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, core/observe.js, components/ghost-run/ghost-run.js, components/ghost-run/ghost-run.css, components/button/button.css.
Utilisation
Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les variables CSS :
<div id="gr-demo" style="width:min(100%,60rem);margin-inline:auto">
<style>
#gr-demo { display:grid; gap:1rem; align-content:start }
#gr-demo .gr-card { display:grid; gap:1rem; padding:1.125rem 1.25rem 1.25rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); min-width:0 }
#gr-demo .gr-top { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap }
#gr-demo .gr-top h3 { margin:0; font-size:1rem; letter-spacing:-.01em }
#gr-demo .gr-top p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#gr-demo .gr-against { display:flex; align-items:center; gap:.5rem; color:var(--mv-fg-muted); font-size:.75rem }
#gr-demo .gr-deck { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:1rem; padding:1rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle) }
#gr-demo .gr-term { display:grid; gap:.25rem; min-width:0 }
#gr-demo .gr-term small { color:var(--mv-fg-subtle); font-size:.6875rem; font-weight:600; letter-spacing:.05em; text-transform:uppercase }
#gr-demo .gr-term strong { font-size:1.25rem; letter-spacing:-.015em }
#gr-demo .gr-term span { min-height:1.25rem; color:var(--mv-fg-muted); font-size:.875rem }
#gr-demo .gr-deck-actions { display:flex; gap:.5rem; flex-wrap:wrap; justify-content:flex-end }
#gr-demo .gr-grid { display:grid; grid-template-columns:minmax(0,19rem) minmax(0,1fr); gap:1rem; align-items:start }
#gr-demo .gr-list { display:grid; gap:.375rem; margin:0; padding:0; list-style:none }
#gr-demo .gr-list li { padding:.5rem .625rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-md); background:var(--mv-surface); font-size:.8125rem; transition:background-color var(--mv-duration-fast) }
#gr-demo .gr-list li:has(:checked) { background:var(--mv-bg-subtle); color:var(--mv-fg-muted) }
#gr-demo .gr-list .mv-choice { width:100% }
#gr-demo .gr-foot { display:flex; align-items:center; justify-content:space-between; gap:.5rem; flex-wrap:wrap }
#gr-demo .gr-log { margin:0; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono) }
#gr-demo .gr-actions { display:flex; gap:.5rem; flex-wrap:wrap }
@media (max-width:46rem) { #gr-demo .gr-grid { grid-template-columns:minmax(0,1fr) } }
@media (max-width:32rem) { #gr-demo .gr-deck { grid-template-columns:minmax(0,1fr) } #gr-demo .gr-deck-actions { justify-content:flex-start } }
</style>
<!-- Main: a flashcard review raced against your best, your last run or the team median -->
<section class="gr-card" aria-labelledby="gr-cards-title">
<div class="gr-top">
<div>
<h3 id="gr-cards-title">Spanish · Lesson 12, At the restaurant</h3>
<p>Daily review: six cards, raced against a previous attempt.</p>
</div>
<span class="gr-against">
<span id="gr-against-label">Race against</span>
<mv-segmented id="gr-against" aria-labelledby="gr-against-label" value="best">
<button value="best">My best</button>
<button value="last">Last run</button>
<button value="team">Team median</button>
</mv-segmented>
</span>
</div>
<mv-ghost-run id="gr-cards" label="Review · 6 cards" marks="check, waiter, ice, to-go, tip, recommend" controls tenths></mv-ghost-run>
<div class="gr-deck">
<div class="gr-term" aria-live="polite">
<small id="gr-count">Card 3 of 6</small>
<strong id="gr-word" lang="es">sin hielo</strong>
<span id="gr-answer"></span>
</div>
<div class="gr-deck-actions">
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="gr-show">Show answer</button>
<button type="button" class="mv-button" data-size="sm" id="gr-got">Got it</button>
</div>
</div>
</section>
<div class="gr-grid">
<!-- Ring: a drone pre-flight checklist watched through data-ghost-mark, raced against the last flight -->
<section class="gr-card" aria-labelledby="gr-pre-title">
<div class="gr-top">
<div>
<h3 id="gr-pre-title">Pre-flight check</h3>
<p>Survey drone SX-4 · Pier 9</p>
</div>
</div>
<mv-ghost-run id="gr-pre" variant="ring" against="last" ghost-label="last flight" watch="#gr-pre-list"></mv-ghost-run>
<ul class="gr-list" id="gr-pre-list">
<li data-ghost-mark="props"><label class="mv-choice"><input type="checkbox" class="mv-checkbox" checked> Propellers seated and locked</label></li>
<li data-ghost-mark="battery"><label class="mv-choice"><input type="checkbox" class="mv-checkbox" checked> Battery above 90%</label></li>
<li data-ghost-mark="gps"><label class="mv-choice"><input type="checkbox" class="mv-checkbox"> GPS lock, 12+ satellites</label></li>
<li data-ghost-mark="rth"><label class="mv-choice"><input type="checkbox" class="mv-checkbox"> Return-to-home altitude set</label></li>
<li data-ghost-mark="airspace"><label class="mv-choice"><input type="checkbox" class="mv-checkbox"> Airspace and site clear</label></li>
</ul>
<div class="gr-foot">
<p class="gr-log" id="gr-pre-log">Tick the boxes to race your last check.</p>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="gr-pre-reset">Reset</button>
</div>
</section>
<!-- Spark: a monthly savings goal on a manual (calendar) clock, raced against last month -->
<section class="gr-card" aria-labelledby="gr-save-title">
<div class="gr-top">
<div>
<h3 id="gr-save-title">Emergency fund</h3>
<p>Goal: $1,500 a month · compared day by day with September</p>
</div>
</div>
<mv-ghost-run id="gr-save" variant="spark" clock="manual" measure="progress" total="1500" currency="USD" span="30d" manual-finish label="October savings" ghost-label="September"></mv-ghost-run>
<div class="gr-foot">
<p class="gr-log" id="gr-save-log">Oct 18 · last deposit $120</p>
<span class="gr-actions">
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="gr-deposit">Deposit $50</button>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="gr-day">Next day</button>
</span>
</div>
</section>
</div>
<script type="module">
await customElements.whenDefined("mv-ghost-run");
const S = 1000;
const DAY = 864e5;
/* ── Flashcards ─────────────────────────────────────── */
const cards = [
["la cuenta", "the check"],
["el mesero", "the waiter"],
["sin hielo", "without ice"],
["para llevar", "to go"],
["la propina", "the tip"],
["¿Qué me recomienda?", "What do you recommend?"],
];
const ids = ["check", "waiter", "ice", "to-go", "tip", "recommend"];
const at = (times) => times.map((t, i) => ({ id: ids[i], p: (i + 1) / 6, t }));
const best = { time: 41.8 * S, marks: at([5.8, 11.9, 18.8, 25.2, 33.4, 41.8].map((x) => x * S)), date: "2026-09-21" };
const last = { time: 46.3 * S, marks: at([6.9, 14.2, 21.0, 29.4, 37.1, 46.3].map((x) => x * S)), date: "2026-09-22" };
const team = { label: "Team median", name: "the team median", time: 43.5 * S, marks: at([6.2, 12.8, 19.9, 27.0, 34.8, 43.5].map((x) => x * S)) };
const run = document.getElementById("gr-cards");
const word = document.getElementById("gr-word");
const answer = document.getElementById("gr-answer");
const count = document.getElementById("gr-count");
const got = document.getElementById("gr-got");
const show = document.getElementById("gr-show");
let index = 2;
const paint = () => {
const done = index >= cards.length;
count.textContent = done ? "Deck complete" : `Card ${index + 1} of ${cards.length}`;
word.textContent = done ? "¡Buen trabajo!" : cards[index][0];
answer.textContent = "";
got.disabled = show.disabled = done;
};
run.best = best;
run.last = last;
// A review already underway (restored after a reload): two cards done.
run.start({ elapsed: 15.2 * S, marks: at([6.4, 13.1].map((x) => x * S)) });
paint();
show.addEventListener("click", () => { if (index < cards.length) answer.textContent = cards[index][1]; });
got.addEventListener("click", () => {
if (index >= cards.length) return;
if (run.state === "paused") run.resume();
run.checkpoint(ids[index]);
index++;
paint();
});
run.addEventListener("mv-start", (e) => {
if (e.detail.reason === "again" || e.detail.reason === "button") { index = 0; paint(); }
});
document.getElementById("gr-against").addEventListener("mv-change", (e) => {
const v = e.detail.value;
run.ghost = v === "team" ? team : null;
run.against = v === "last" ? "last" : "best";
});
/* ── Pre-flight checklist ───────────────────────────── */
const pre = document.getElementById("gr-pre");
const preLog = document.getElementById("gr-pre-log");
const list = document.getElementById("gr-pre-list");
const five = ["props", "battery", "gps", "rth", "airspace"];
if (!pre.last) pre.last = { time: 24 * S, marks: [4.1, 9.3, 13.8, 18.9, 24].map((t, i) => ({ id: five[i], p: (i + 1) / 5, t: t * S })) };
pre.start({ elapsed: 9.8 * S, marks: [{ id: "props", p: 0.2, t: 3.6 * S }, { id: "battery", p: 0.4, t: 8.7 * S }] });
pre.addEventListener("mv-split", (e) => {
const d = e.detail;
if (d.delta === null) return;
preLog.textContent = `${Math.round(d.progress * 5)} of 5 · ${d.delta < 0 ? "−" : "+"}${Math.abs(d.delta / S).toFixed(1)} s vs last flight`;
});
pre.addEventListener("mv-finish", (e) => {
preLog.textContent = `Cleared for takeoff in ${(e.detail.time / S).toFixed(1)} s.`;
});
document.getElementById("gr-pre-reset").addEventListener("click", () => {
pre.reset();
for (const box of list.querySelectorAll("input")) box.checked = false;
preLog.textContent = "The clock starts on the first tick.";
});
/* ── Savings goal (manual clock: one day per step) ──── */
const save = document.getElementById("gr-save");
const saveLog = document.getElementById("gr-save-log");
const deposits = (list) => list.map(([day, amount]) => ({ p: amount / 1500, t: (day - 1) * DAY }));
const oct = (ms) => new Date(2026, 9, 1 + Math.floor(ms / DAY));
save.formatTime = (ms, { spoken }) => oct(ms).toLocaleDateString("en-US", spoken ? { month: "long", day: "numeric" } : { month: "short", day: "numeric" });
save.last = { time: 29 * DAY, progress: 1320 / 1500, marks: deposits([[1, 200], [5, 350], [9, 420], [12, 560], [15, 700], [18, 780], [22, 980], [26, 1150], [30, 1320]]) };
save.start({ elapsed: 17 * DAY, marks: deposits([[1, 250], [4, 400], [8, 520], [11, 640], [15, 810], [17, 930]]) });
const dayLabel = () => save.formatTime(save.elapsed, {});
document.getElementById("gr-deposit").addEventListener("click", () => {
if (save.state === "finished") return;
save.checkpoint(Math.min(1500, save.value + 50));
saveLog.textContent = `${dayLabel()} · deposited $50`;
});
document.getElementById("gr-day").addEventListener("click", () => {
if (save.state === "finished") return;
save.elapsed = Math.min(30 * DAY, save.elapsed + DAY);
saveLog.textContent = `${dayLabel()} · no deposit yet`;
if (save.elapsed >= 30 * DAY) save.finish();
});
</script>
</div>Référence culturelle
Mario Kart 64, Nintendo (1996, jeu). En contre-la-montre, un fantôme translucide rejoue votre meilleur tour exactement comme vous l’avez piloté : vous affrontez votre moi passé et voyez à chaque virage si vous êtes en avance ou en retard. Dans l’interface, n’importe quelle tâche répétée est enregistrée sous forme de points de passage horodatés, la meilleure ou la dernière course se rejoue sous forme de marqueur discret sur la même piste, et chaque point de passage affiche l’écart par rapport à elle.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
variant | bar | ring | spark | bar | Track shape: a linear bar with split chips under each checkpoint, a ring with the split in its center, or a sparkline of cumulative progress over time (the ghost’s whole curve is drawn, yours grows over it). |
against | best | last | best | Which stored run the ghost replays. Ignored while the ghost property holds an app-supplied run. |
storage-key | string | Persists the best and last runs in localStorage under mv-ghost-run:<key>. Without it they live in memory (or come from your server through the best / last properties). | |
marks | number | comma list | Declared checkpoints when none are watched in the DOM: a count ("6") or ids ("intro, setup, invite") for checkpoint(id). Progress is the share of marks done, in any order. | |
watch | CSS selector | Region scanned for [data-ghost-mark] elements (default: the element’s own content). Elements added later are picked up. | |
data-ghost-mark | string (on your elements) | Declares a checkpoint. It is done when it is a checked checkbox or radio, contains a checked checkbox, or carries data-done / aria-checked="true" / aria-pressed="true". | |
data-ghost-when | done | checked | visible (on your elements) | done | visible: the mark is done once 60% of it has been on screen (or it was scrolled past), for reading and course progress. |
splits | number | 4 | Without declared marks, the progress property records a checkpoint each time it crosses one of these evenly spaced levels (25/50/75/100% by default). |
autostart | boolean | Starts the run the first time the element is visible. Otherwise a run starts with start(), the built-in control, or the first progress. | |
auto-pause | boolean | Pauses the run (and the ghost) while the page is hidden and resumes when it comes back. | |
manual-finish | boolean | Reaching 100% does not end the run; call finish() (end of the month, end of the session). | |
clock | real | manual | real | manual: time only moves when you set elapsed (calendar days, business days, turns). No ticker runs. |
measure | time | progress | time | How splits are expressed: time gap at the same progress (−12 s) or progress gap at the same time (+$203). A better run is faster (time) or further along (progress). |
span | time ("30d", "90s", "1h30m", ms) | Sparkline x-extent (otherwise the ghost’s duration, growing in steps when you run longer). | |
total / unit / currency / locale | number / string / ISO 4217 / BCP 47 | - / - / - / en-US | Progress in real units: numeric checkpoints and value are in total units and are displayed as “$930 / $1,500” (currency) or “12 / 40 pages” (unit). Without total, progress is 0..1 shown as a percentage. |
tenths | boolean | Clock and splits to the tenth of a second (drills under a minute). | |
label | string | Title shown in the head; also names the progressbar. | |
ghost-label | string | Name of the ghost in sentences and the legend (“last flight”, “September”); capitalized in the legend. | |
controls | boolean | Shows a built-in Start / Pause / Resume button and a “Race again” button in the result. | |
data-state / data-lead / data-split / data-has-ghost | set by the component | idle | running | paused | finished; live position against the ghost (ahead | behind | even | none); lead of the last split (ahead | behind | even | beyond | none); present when a ghost exists. All styleable. |
Properties
| Name | Type | Description |
|---|---|---|
ghost | Run | null | App-supplied ghost (a teammate, the team median, a coach’s reference). A run may carry label (legend) and name (in sentences). null goes back to best / last. Past splits are recomputed against the new ghost. |
best / last | Run | null | Stored runs; set them from your server, read them after a finish. Persisted with storage-key. |
activeGhost | Run | null | The run currently replayed (read-only). |
run | Run | The current run, serializable: { time, progress, marks: [{ id?, p, t }], date }. Save it to restore later with start({ elapsed, marks }). |
progress / value | number | Live progress 0..1, or in total units. Setting it moves your marker and records the split levels it crosses; the first progress of an idle run starts it. |
elapsed | number (ms) | Run time, pause-aware. Set it to drive a manual clock. |
state / split / lead | read-only | Run state; last split { index, id, progress, time, delta, ahead, lead, measure }; live lead. |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement (ahead, behind, even, ready, racing, first, finishedBetter, finishedWorse, keepPrompt, announceSplit…). English defaults. |
formatTime / formatValue | (ms, { kind, spoken, tenths }) => string / (value, { delta, spoken }) => string | Custom formatting, e.g. calendar dates for a manual clock (kind is "clock" or "delta"). |
Methods
| Name | Description |
|---|---|
start({ elapsed?, marks? }) | Starts a new run, or restores one in progress. Emits the cancelable mv-start first so the app can reset its own UI; what is already done in the watched DOM becomes the starting line. |
checkpoint(idOrValue, { at? }) | Records a checkpoint: a declared mark id, or a value (total units, else 0..1). at = elapsed ms of the checkpoint (restoring, manual clocks). Returns the split or null. |
pause() / resume() | Freezes the clock and the ghost, then picks up where it stopped. |
finish() | Ends the run now (automatic at 100% unless manual-finish). |
keep() / discard() | Answers the “Keep this run as your ghost?” prompt shown after a slower run. |
reset() / clearGhosts() | Back to idle keeping the ghost / forget the stored best and last runs. |
Events
| Name | Description |
|---|---|
mv-start | Cancelable, before a run starts. detail: { reason: "api" | "button" | "again" | "progress" | "view", restored }. |
mv-split | A checkpoint was reached. detail: { index, id, progress, time, delta (ms, negative = ahead; or progress fraction, positive = ahead; null without a comparable ghost), ahead, lead, measure, against }. |
mv-finish | Cancelable. detail: { time, delta, isBest (against your own best), run, ghost, against }. preventDefault() stores nothing and skips the prompt. |
mv-save | Cancelable, before a run is persisted. detail: { slot: "best" | "last", run }. Send it to your server; preventDefault() keeps it in memory only. |
mv-pause / mv-resume | detail: { elapsed }. |
mv-lead | The ghost overtook you or you overtook it between checkpoints (visual only, never announced). detail: { lead, elapsed }. |
Content structure
| Name | Description |
|---|---|
(content) | Optional: the task itself (a checklist with data-ghost-mark items) can live inside the element, after the generated head and track. |
CSS classes
| Name | Description |
|---|---|
mv-ghost-run-hud | Generated block: -head (-label, -vs legend with -swatch, -count, -clock, -state, -toggle), -stage (-readout with -delta and -caption, -meter), -result. |
mv-ghost-run-rail / -fill / -gap / -tick / -chip | Bar parts: rail, your fill, the gap band between you and the ghost, checkpoint notches, split chips (data-lead). |
mv-ghost-run-ghost / -you | The ghost marker (dashed, translucent, with -ghost-tag) and your marker, in every variant. |
mv-ghost-run-ring / -orbit / -center | Ring parts (SVG arcs -ring-fill, -ring-ghost, -ring-gap, -ring-tick). |
mv-ghost-run-plot / -spark-ghost / -spark-you / -now / -axis | Sparkline parts. |
mv-ghost-run-result | Finish summary with -result-text, -result-note and the keep / discard / race again buttons. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-ghost-run-color | var(--mv-accent) | Your fill and marker. |
--mv-ghost-run-ghost | color-mix(fg 42%, transparent) | Ghost marker, arc and curve. |
--mv-ghost-run-ahead | var(--mv-success) | Ahead of the ghost (splits, gap band). |
--mv-ghost-run-behind | var(--mv-warning) | Behind the ghost. |
--mv-ghost-run-size | 11.5rem | Ring diameter. |
--mv-ghost-run-height | 8.5rem | Sparkline height. |
Accessibility
The ghost is purely visual (aria-hidden markers, pointer-events: none) and has a text equivalent: the track is a role="progressbar" named by the label, whose aria-valuetext says where you are and how you stand (“Checkpoint 3 of 6. You are 1.8 seconds ahead of your best run.”), updated at checkpoints only, never per frame. Splits are announced once, politely, at each checkpoint (“Checkpoint 3 of 6: you are 1.8 seconds ahead of your best run.”); overtakes between checkpoints are never announced. Pause, resume and the finish summary are announced politely; the prompt after a slower run uses real buttons, and focus moves to the result only if it was on the built-in control that disappears. The clock is a role="timer" (not live), so it is read on demand. Ahead and behind are always spelled out in words and signs, never by color alone. Reduced motion (OS or data-motion="reduce"): no frame loop, the ghost jumps from checkpoint to checkpoint, markers and fills stop gliding and the paused clock stops blinking. Forced colors: your progress uses Highlight, the ghost GrayText. The frame loop only runs while a run is going, a ghost exists and the element is on screen.