ExklusivBeta

Ghost Run <mv-ghost-run>

Fortschritt im Vergleich zum eigenen vorherigen Versuch, für Aufgaben, die man wiederholt: Ein durchscheinender Geist spielt deinen besten Lauf ab (oder deinen letzten, oder den eines Teamkollegen oder den Team-Median, den die App liefert), in Echtzeit auf derselben Strecke, sodass du in jedem Moment siehst, ob du vor ihm liegst oder zurück. Ein Lauf wird als Checkpoints mit Zeitstempel aufgezeichnet, gespeist von checkpoint(id | value), einer Eigenschaft progress (Zwischenstufen werden automatisch überschritten) oder beobachteten [data-ghost-mark]-Elementen, die abgehakt, erledigt oder sichtbar werden; an jedem Checkpoint zeigt eine Zwischenzeit den Abstand („−1.8 s ahead of your best run“, „+3 min behind last week“ oder „+$203 ahead of September“, wenn der Abstand in Fortschritt statt in Zeit gemessen wird), mit Zwischenzeit-Chips pro Checkpoint unter der Strecke. Drei Strecken: ein Balken mit einem gestreiften Abstandsband zwischen dir und dem Geist, ein Ring für Sessions und Checklisten und eine Sparkline des kumulierten Fortschritts für Ziele auf einer Kalenderuhr (clock="manual"). Pausieren friert auch den Geist ein, laufende Durchgänge lassen sich nach einem Neuladen wiederherstellen, und im Ziel wird ein besserer Lauf still zum neuen Geist (localStorage mit storage-key oder dein Server über das abbrechbare mv-save), während ein langsamerer fragt, ob er behalten werden soll.

KategorieFeedback
TypWeb Component (<mv-ghost-run>)
StatusBeta
Installiert auchbutton
Keywordsexclusive, 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/marvelous

KI-Agent mit dem MCP-Server von Marvelous UI: install_components({ slugs: ["ghost-run"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Kopierte Dateien (inklusive Abhängigkeiten): 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.

Verwendung

Referenz-Markup zum Starten und Anpassen mit Attributen, data-* und CSS-Variablen:

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

Kulturelle Referenz

Mario Kart 64, Nintendo (1996, Spiel). Im Zeitfahren spielt ein durchscheinender Geist deine beste Runde genau so ab, wie du sie gefahren bist, sodass du gegen dein früheres Ich fährst und in jeder Kurve siehst, ob du vorn liegst oder zurück. Übertragen auf die UI: Jede wiederholte Aufgabe wird als Checkpoints mit Zeitstempel aufgezeichnet, der beste oder letzte Lauf wird als blasser Marker auf derselben Strecke abgespielt, und jeder Checkpoint zeigt die Zwischenzeit im Vergleich dazu.

API

Attributes

NameTypDefaultDescription
variantbar | ring | sparkbarTrack 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).
againstbest | lastbestWhich stored run the ghost replays. Ignored while the ghost property holds an app-supplied run.
storage-keystringPersists 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).
marksnumber | comma listDeclared 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.
watchCSS selectorRegion scanned for [data-ghost-mark] elements (default: the element’s own content). Elements added later are picked up.
data-ghost-markstring (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-whendone | checked | visible (on your elements)donevisible: the mark is done once 60% of it has been on screen (or it was scrolled past), for reading and course progress.
splitsnumber4Without declared marks, the progress property records a checkpoint each time it crosses one of these evenly spaced levels (25/50/75/100% by default).
autostartbooleanStarts the run the first time the element is visible. Otherwise a run starts with start(), the built-in control, or the first progress.
auto-pausebooleanPauses the run (and the ghost) while the page is hidden and resumes when it comes back.
manual-finishbooleanReaching 100% does not end the run; call finish() (end of the month, end of the session).
clockreal | manualrealmanual: time only moves when you set elapsed (calendar days, business days, turns). No ticker runs.
measuretime | progresstimeHow 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).
spantime ("30d", "90s", "1h30m", ms)Sparkline x-extent (otherwise the ghost’s duration, growing in steps when you run longer).
total / unit / currency / localenumber / string / ISO 4217 / BCP 47- / - / - / en-USProgress 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.
tenthsbooleanClock and splits to the tenth of a second (drills under a minute).
labelstringTitle shown in the head; also names the progressbar.
ghost-labelstringName of the ghost in sentences and the legend (“last flight”, “September”); capitalized in the legend.
controlsbooleanShows a built-in Start / Pause / Resume button and a “Race again” button in the result.
data-state / data-lead / data-split / data-has-ghostset by the componentidle | 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

NameTypDescription
ghostRun | nullApp-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 / lastRun | nullStored runs; set them from your server, read them after a finish. Persisted with storage-key.
activeGhostRun | nullThe run currently replayed (read-only).
runRunThe current run, serializable: { time, progress, marks: [{ id?, p, t }], date }. Save it to restore later with start({ elapsed, marks }).
progress / valuenumberLive 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.
elapsednumber (ms)Run time, pause-aware. Set it to drive a manual clock.
state / split / leadread-onlyRun state; last split { index, id, progress, time, delta, ahead, lead, measure }; live lead.
stringsPartial<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 }) => stringCustom formatting, e.g. calendar dates for a manual clock (kind is "clock" or "delta").

Methods

NameDescription
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

NameDescription
mv-startCancelable, before a run starts. detail: { reason: "api" | "button" | "again" | "progress" | "view", restored }.
mv-splitA 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-finishCancelable. detail: { time, delta, isBest (against your own best), run, ghost, against }. preventDefault() stores nothing and skips the prompt.
mv-saveCancelable, before a run is persisted. detail: { slot: "best" | "last", run }. Send it to your server; preventDefault() keeps it in memory only.
mv-pause / mv-resumedetail: { elapsed }.
mv-leadThe ghost overtook you or you overtook it between checkpoints (visual only, never announced). detail: { lead, elapsed }.

Content structure

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

NameDescription
mv-ghost-run-hudGenerated 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 / -chipBar parts: rail, your fill, the gap band between you and the ghost, checkpoint notches, split chips (data-lead).
mv-ghost-run-ghost / -youThe ghost marker (dashed, translucent, with -ghost-tag) and your marker, in every variant.
mv-ghost-run-ring / -orbit / -centerRing parts (SVG arcs -ring-fill, -ring-ghost, -ring-gap, -ring-tick).
mv-ghost-run-plot / -spark-ghost / -spark-you / -now / -axisSparkline parts.
mv-ghost-run-resultFinish summary with -result-text, -result-note and the keep / discard / race again buttons.

CSS variables

NameDefaultDescription
--mv-ghost-run-colorvar(--mv-accent)Your fill and marker.
--mv-ghost-run-ghostcolor-mix(fg 42%, transparent)Ghost marker, arc and curve.
--mv-ghost-run-aheadvar(--mv-success)Ahead of the ghost (splits, gap band).
--mv-ghost-run-behindvar(--mv-warning)Behind the ghost.
--mv-ghost-run-size11.5remRing diameter.
--mv-ghost-run-height8.5remSparkline 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.

Diese Seite wurde mit KI übersetzt. Übersetzungsfehler melden