ExklusivBeta
Honest Wait <mv-honest-wait>
Eine Anzeige für lange Wartezeiten, die nie etwas vortäuscht, für Exporte, Video-Renderings, KI-Generierung, Report-Builds, Provisionierung, Warteschlangen und Uploads über langsame Verbindungen. Du fütterst sie mit dem, was der Job wirklich meldet: progress(value, total), queue(position), beat() oder gar nichts. Sie sagt dann in einem einfachen Satz und einer beschrifteten Pille, um welche Art von Warten es sich handelt. Bestimmt: „About 3-5 min left“, eine Spanne, berechnet aus der beobachteten Rate (ein zeitgewichteter EWMA, gemischt mit der Gesamtgeschwindigkeit, dessen Varianz die Breite bestimmt). In der Warteschlange: „12th in line · about 5-7 min to start“, aus dem beobachteten Durchsatz, mit den Wartenden davor als Punkte, die vorn abgehen. Aktiv ohne Schätzung: „Still working · last update 4 s ago“, auf einem Herzschlagstreifen, auf dem sich das Limit ohne Update der Jetzt-Markierung nähert. Verdächtig: „No update for 2 min · this may be stuck“, mit Retry, Keep waiting und Report. Die ETA wird geglättet und zählt in Echtzeit herunter, sodass sie nie abrupt zurückspringt. Fällt die neue beste Schätzung aus der Spanne, die dem Nutzer genannt wurde, sagt sie es: „Revised: taking longer than expected (was about 2-3 min)“, und sendet mv-eta-revised. Stille wird eingepreist: Wenn keine Updates mehr kommen, sinkt die gemessene Geschwindigkeit, statt dass die ETA auf eine falsche Null herunterläuft, und ein pausierter Balken zeigt „Progress paused at 62% · still responding“. „Notify me when done“ durchläuft den Ablauf für die Notification-Berechtigung und weicht auf den Tab-Titel aus, wenn Benachrichtigungen blockiert sind. „Leave, we’ll keep going“ übergibt an deine UI für Hintergrund-Jobs. Reports enthalten einen Diagnose-snapshot(). Zeitstempel können von deinem Server kommen ({ at }), sodass auch ein Job, der vor dem Laden der Seite begonnen hat, wahrheitsgemäß beschrieben wird.
| Kategorie | Feedback |
|---|---|
| Typ | Web Component (<mv-honest-wait>) |
| Status | Beta |
| Kit | Ehrliche Systemzustände |
| Installiert auch | button |
| Keywords | exclusive, culture, progress, eta, time-remaining, long-running, background-job, queue, queue-position, heartbeat, stall-detection, stuck, export, render, ai-generation, upload, notification, tab-title, loading, honest-ui |
When to use
- An export, render, report build or AI generation takes minutes and users need a truthful time left instead of a looping spinner
- Jobs wait in a shared queue (GPU, print, CI runners) and users should see their position and how fast the line really moves
- A backend job reports only heartbeats and a silent failure must surface as possibly stuck with retry and report actions
- Users should be free to switch tabs or leave during a long job and be told by notification or tab title when it ends
Avoid when
- The wait is a few seconds and a small inline activity indicator is enough → use Spinner instead
- You only need to draw a known percentage, with no ETA, stall detection or wording → use Progress instead
- The thing being tracked is a message crossing providers hop by hop (email, SMS, webhook delivery) → use Delivery Trail instead
Installation
node scripts/add.mjs honest-wait --out ./src/marvelousKI-Agent mit dem MCP-Server von Marvelous UI: install_components({ slugs: ["honest-wait"], 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, components/honest-wait/honest-wait.js, components/honest-wait/honest-wait.css, components/button/button.css.
Verwendung
Schnellstart mit dem kleinsten funktionierenden Markup:
<mv-honest-wait label="Exporting Q3 Review.mp4" unit="frames" typical="4m"></mv-honest-wait>
<!-- as your job reports: wait.progress(framesDone, totalFrames), then wait.done() -->Referenz-Markup zum Starten und Anpassen mit Attributen, data-* und CSS-Variablen:
<div id="hw-demo" style="width:min(100%,50rem);margin-inline:auto">
<style>
#hw-demo { display:grid; gap:1rem; align-content:start }
#hw-demo .hw-app { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-bg-subtle); overflow:hidden }
#hw-demo .hw-stage { display:grid; gap:1rem; padding:1.125rem }
#hw-demo .hw-controls { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; padding:.75rem 1.125rem; border-top:1px solid var(--mv-border); background:var(--mv-surface) }
#hw-demo .hw-controls p { flex:1 1 100%; margin:0 0 .25rem; color:var(--mv-fg-muted); font-size:.75rem }
#hw-demo .hw-log { flex:1 1 100%; min-height:1.1rem; margin:.25rem 0 0; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono) }
#hw-demo .hw-tray { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs); overflow:hidden }
#hw-demo .hw-tray-head { display:flex; align-items:center; gap:.5rem; padding:.75rem 1rem; border-bottom:1px solid var(--mv-border) }
#hw-demo .hw-tray-head strong { font-size:.875rem; letter-spacing:-.01em }
#hw-demo .hw-tray-head span:last-child { margin-inline-start:auto; color:var(--mv-fg-muted); font-size:.75rem }
#hw-demo .hw-tray-list { display:grid; gap:.5rem; padding:.75rem }
#hw-demo .hw-tray-list mv-honest-wait { background:var(--mv-bg-subtle) }
</style>
<!-- Main: a video export that measures its own speed (accelerated demo: 20 s without updates counts as stuck) -->
<section class="hw-app" aria-label="Video export">
<div class="hw-stage">
<mv-honest-wait id="hw-main" label="Exporting “Q3 Board Review.mp4” · 4K" unit="frames" stall="20s" notify background>
<a slot="done" class="mv-button" data-size="sm" href="#hw-demo">Download MP4 (1.8 GB)</a>
</mv-honest-wait>
</div>
<div class="hw-controls">
<p>Simulate the render farm. The estimate only moves when the speed it measures moves, and it tells you when it had to revise.</p>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="hw-slow" aria-pressed="false">Congest the render farm</button>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="hw-freeze" aria-pressed="false">Stop sending updates</button>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="hw-finish">Finish</button>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="hw-fail">Fail</button>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="hw-restart">Restart</button>
<p class="hw-log" id="hw-log">Export resumed after 58 s of rendering.</p>
</div>
</section>
<!-- Background jobs: the three other kinds of wait, in the compact inline variant -->
<section class="hw-tray" aria-labelledby="hw-tray-title">
<div class="hw-tray-head">
<strong id="hw-tray-title">Background jobs</strong>
<span class="mv-badge" data-variant="secondary">3 running</span>
<span>Each one says what kind of wait it is</span>
</div>
<div class="hw-tray-list">
<mv-honest-wait id="hw-queue" variant="inline" label="Generating 4 product shots · Studio AI">
<a slot="done" class="mv-button" data-size="sm" data-variant="outline" href="#hw-demo">View images</a>
</mv-honest-wait>
<mv-honest-wait id="hw-work" variant="inline" label="Provisioning database cluster · us-east-1" stall="30s"></mv-honest-wait>
<mv-honest-wait id="hw-stuck" variant="inline" label="Building “Annual Report 2026.pdf”" unit="pages" stall="45s"></mv-honest-wait>
</div>
</section>
<script type="module">
await customElements.whenDefined("mv-honest-wait");
const root = document.getElementById("hw-demo");
const $ = (id) => document.getElementById(id);
const main = $("hw-main"), queue = $("hw-queue"), work = $("hw-work"), stuck = $("hw-stuck");
const log = $("hw-log");
const say = (text) => { log.textContent = `${new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" })} · ${text}`; };
const alive = () => root.isConnected;
const every = (ms, fn) => { const id = setInterval(() => (alive() ? fn() : clearInterval(id)), ms); return id; };
const toggle = (b, on) => b.setAttribute("aria-pressed", String(on));
/* ── Main export: 7,200 frames at ~24 frames/s, seeded with the last 40 s ── */
const TOTAL = 7200;
let frames = 0, fast = true, frozen = false, running = true;
const seedMain = () => {
const now = Date.now();
main.reset();
main.startedAt = now - 58_000;
frames = 1392 - 24 * 40;
for (let age = 40; age >= 0; age -= 4) {
main.progress(1392 - 24 * age, TOTAL, { at: now - age * 1000 });
}
frames = 1392;
running = true;
};
seedMain();
every(1000, () => {
if (!running || frozen) return;
const rate = (fast ? 24 : 7) * (0.85 + Math.random() * 0.3);
frames = Math.min(TOTAL, frames + rate);
main.progress(Math.round(frames), TOTAL, frames > 5200 ? "Muxing audio and subtitles" : "Encoding video (H.265, 10-bit)");
if (frames >= TOTAL) { running = false; main.done("Q3 Board Review.mp4 is ready · 1.8 GB"); }
});
main.addEventListener("mv-eta-revised", (e) => say(`mv-eta-revised · ${e.detail.direction}`));
main.addEventListener("mv-stuck", (e) => say(`mv-stuck · silent for ${Math.round(e.detail.silentFor / 1000)} s`));
main.addEventListener("mv-resume", () => say("mv-resume · updates are back"));
main.addEventListener("mv-done", (e) => say(`mv-done · took ${Math.round(e.detail.duration / 1000)} s`));
main.addEventListener("mv-keep-waiting", () => say("mv-keep-waiting"));
main.addEventListener("mv-notify", (e) => say(`mv-notify · permission ${e.detail.permission ?? "granted"}`));
main.addEventListener("mv-leave", () => {
say("mv-leave · the export keeps running in the background");
main.setAttribute("variant", "inline");
main.background = false;
});
main.addEventListener("mv-report", (e) => {
say("mv-report · snapshot attached to a support ticket");
e.detail.waitUntil(new Promise((r) => setTimeout(r, 700)));
});
main.addEventListener("mv-retry", () => {
say("mv-retry · job resubmitted");
frozen = false; toggle($("hw-freeze"), false);
setTimeout(seedMain, 0);
});
$("hw-slow").addEventListener("click", (e) => { fast = !fast; toggle(e.currentTarget, !fast); say(fast ? "Render farm back to normal" : "Render farm congested: about 7 frames/s"); });
$("hw-freeze").addEventListener("click", (e) => { frozen = !frozen; toggle(e.currentTarget, frozen); say(frozen ? "Updates stopped (20 s until “may be stuck”)" : "Updates resumed"); });
$("hw-finish").addEventListener("click", () => { running = false; main.done("Q3 Board Review.mp4 is ready · 1.8 GB"); });
$("hw-fail").addEventListener("click", () => { running = false; main.fail("Render node ran out of GPU memory"); });
$("hw-restart").addEventListener("click", () => {
fast = true; frozen = false; toggle($("hw-slow"), false); toggle($("hw-freeze"), false);
main.removeAttribute("variant");
main.background = true;
seedMain();
say("Export restarted");
});
/* ── Queue: an AI image job waiting for a GPU, then generating ── */
let pos = 7, gen = null;
const seedQueue = () => {
const now = Date.now();
queue.reset();
queue.startedAt = now - 34_000;
[[11, 32], [10, 25], [9, 18], [8, 11], [7, 4]].forEach(([p, age]) => queue.queue(p, { at: now - age * 1000 }));
pos = 7; gen = null;
};
seedQueue();
every(1000, () => {
if (gen === null) {
if (Math.random() < 0.15) { pos -= 1; queue.queue(pos); }
else queue.queue(pos);
if (pos === 0) gen = 0;
} else if (gen < 4) {
gen = Math.min(4, gen + 0.18 + Math.random() * 0.08);
queue.progress(Math.floor(gen * 25), 100, `Rendering image ${Math.min(4, Math.floor(gen) + 1)} of 4`);
if (gen >= 4) { queue.done("4 images ready"); setTimeout(() => alive() && seedQueue(), 6000); }
}
});
/* ── Working: no progress reported, only a heartbeat ── */
const steps = ["Allocating storage volumes", "Configuring 3 replicas", "Applying network policies", "Running health checks"];
const seedWork = () => {
const now = Date.now();
work.reset();
work.startedAt = now - 95_000;
[30, 24, 17, 12, 6, 2].forEach((age) => work.beat({ at: now - age * 1000, message: steps[0] }));
};
seedWork();
let step = 0;
every(1000, () => {
if (work.kind === "done") return;
if (Math.random() < 0.3) {
if (Math.random() < 0.12) step++;
if (step >= steps.length) { work.done("Cluster ready · 3 nodes"); step = 0; setTimeout(() => alive() && seedWork(), 6000); return; }
work.beat(steps[step]);
}
});
/* ── Stuck: the report builder went silent over a minute ago ── */
const seedStuck = () => {
const now = Date.now();
stuck.reset();
stuck.startedAt = now - 4 * 60_000;
// This builder only sends heartbeats; the last one came 84 s ago.
[132, 120, 108, 96, 84].forEach((age, i) => stuck.beat({ at: now - age * 1000, message: `Rendering charts (page ${19 + i} of 38)` }));
};
seedStuck();
stuck.addEventListener("mv-retry", () => {
// Resubmitted: this time it moves.
let page = 0;
const id = every(900, () => {
if (page >= 38) { clearInterval(id); stuck.done("38 pages · 12.4 MB"); setTimeout(() => alive() && seedStuck(), 7000); return; }
page += 1;
stuck.progress(page, 38, `Rendering page ${page} of 38`);
});
});
stuck.addEventListener("mv-report", (e) => e.detail.waitUntil(new Promise((r) => setTimeout(r, 600))));
</script>
</div>Kulturelle Referenz
Warten auf Godot, Samuel Beckett (1953, Theaterstück). Zwei Männer warten auf jemanden, von dem es immer heißt, er komme „morgen“, und der nie kommt: Das Warten wird unerträglich, weil ihnen niemand je die Wahrheit darüber sagt. Die UI überträgt das in ein Warten, das nie lügt: Sie sagt, um welche Art von Warten es sich handelt, leitet jede Schätzung aus dem ab, was sie tatsächlich beobachtet hat, gibt zu, wenn sie korrigiert hat, und sagt klar, wenn zu lange nichts passiert ist.
API
Attributes
| Name | Typ | Default | Description |
|---|---|---|---|
label | string | Working on your request | What is being waited for (“Exporting “Q3 Board Review.mp4””). Shown as the title, used as the group’s accessible name, in the system notification and in reports. |
unit | string | Unit of progress values (“frames”, “MB”, “rows”, “pages”), used in “124 of 200 frames · 24 frames/s”. Without it, the detail shows counts and “items” for the rate. | |
stall | time ("2m", "90s", "45s", ms number) | 2m | Silence (no progress, queue or beat call) after which the wait is called possibly stuck. Automatically raised to 4× the job’s usual update gap, so slow reporters never trigger false alarms. |
typical | time | Typical duration from your history (“4m”), shown as “Usually takes about 4 min” while nothing has been measured yet. It is never presented as a measured estimate. | |
started | ISO date | ms epoch | When the job really started (it may predate the page). Used for elapsed time, the done sentence and the first-update silence. Defaults to the first connection or the first signal. | |
notify | boolean | Shows “Notify me when done” (a toggle, aria-pressed). Asks for Notification permission. Once armed, a system notification is shown on done, failed or possibly stuck while the tab is hidden, and the tab title shows live status (“62% · …”, “✓ Done · …”). If notifications are blocked or unsupported, it says so and uses the tab title only. | |
background | boolean | Shows “Leave, we’ll keep going” for apps that run jobs server-side. It only emits mv-leave: your app navigates, minimizes or moves the job to a tray. | |
tab-title | boolean | Mirrors the status in document.title even without notify (one instance at a time owns the title; it is restored on release, removal or a few seconds after the result was seen). | |
variant | "card" | "inline" | card | card: full layout with details, heartbeat info and actions. inline: one compact row (icon, label, sentence, pill, mini track) for job trays, lists and toolbars. Actions still appear when the wait is stuck or failed. |
data-kind | starting | determinate | queued | working | stuck | done | failed | Set by the component (styleable). data-measuring while a rate is being measured, data-quiet when updates have been silent for a while, data-revised="later|sooner" while a revision note is shown. |
Properties
| Name | Typ | Description |
|---|---|---|
kind | string | Current kind of wait (read-only), same values as data-kind. |
estimate | { low, high } (ms) | null | The displayed, smoothed time-left range right now, or null while measuring, without an estimate, or once ended (read-only). |
elapsed | number (ms) | Time since the job started, frozen once done or failed (read-only). |
startedAt | number (ms epoch) | Date | Read or set the real start time (same as the started attribute). |
notifying | boolean | True once “Notify me when done” is armed (read-only). |
strings | Partial<Record<string, string>> | Overrides for every visible text, announcement, notification and title prefix. Templates use {range}, {percent}, {ordinal}, {ago}, {silence}, {elapsed}, {reason}, {message}, {value}, {total}, {unit}, {rate}, {label}, {position}. Keys include estimate, measuring, almost, paused, queued, queuedNext, queuedMeasuring, working, workingFresh, stuck, stuckAck, done, failed, revisedLater, revisedSooner, typical, count, rate, queueRate, lastSeen, lastStep, now, firstEstimate, kind* (pill labels), notify, notifyOn, leave, retry, keepWaiting, report, title*. English defaults. |
Methods
| Name | Description |
|---|---|
progress(value, total, options?) | Determinate progress in any unit. Omit total for a running count with no known end (“1,240 rows so far · no total known”, no ETA). A lower value than before restarts the measurement. options: a message string (“Now: Encoding video”) or { message, at } where at is the server timestamp of that reading. |
queue(position, options?) | Queue position: 1 = next in line, 0 = your turn (switches to working until progress arrives). Throughput is measured from how fast the position drops, including the time since the last move. Being pushed back restarts the measurement. Same options as progress. |
beat(options?) | Heartbeat: the job is alive. It turns a starting wait into working, keeps the current kind otherwise, and clears a possibly-stuck state (emits mv-resume). Same options (message, at). |
done(message?) | The wait is over: “Done in 4 min 12 s”. The detail shows your message or the first estimate, for honesty. Shows slot="done" content and emits mv-done. |
fail(reason?) | The job failed: “Failed · Render node ran out of GPU memory” (reason rendered as text). Shows Retry and Report and emits mv-failed. |
reset() | Start a new wait (the component calls it itself after an accepted Retry): clears every observation, estimate and revision. |
snapshot() | Everything observed, for support tickets: kind, label, startedAt, elapsed, lastUpdate, silentFor, value, total, unit, rate (units/s), position, queueRate (per min), estimate, firstEstimate, revisions, message, reason, updates. |
notifyWhenDone() | Arms notifications from your own control (call it inside a user gesture). Resolves to the permission: "granted", "denied" or "unsupported". |
Events
| Name | Description |
|---|---|
mv-stuck | Cancelable. Silence reached the stall threshold. detail: { silentFor, kind, snapshot }. preventDefault() keeps the current state (you know the job is in a long silent step) and asks again after another stall period. |
mv-resume | An update arrived after the wait was marked possibly stuck. detail: { silentFor }. |
mv-eta-revised | The middle of the smoothed estimate fell outside the range the user was last given (aged by the time that passed), and the new range moved past the old one’s middle. detail: { direction: "later" | "sooner", previous: { low, high }, estimate: { low, high } } (ms). |
mv-done | detail: { duration, firstEstimate: { low, high } | null, revisions }. Use it to log how accurate your estimates are. |
mv-failed | detail: { reason, snapshot }. |
mv-retry | Cancelable. Retry was chosen (stuck or failed). detail: { reason: "stuck" | "failed", snapshot }. Resubmit the job. If not cancelled, the component resets itself. |
mv-keep-waiting | The user chose to keep waiting on a possibly-stuck job. The sentence becomes “No update for 3 min · still waiting, as you asked” and stays honest. detail: { silentFor }. |
mv-report | Cancelable. Report was chosen. detail: { snapshot, waitUntil(promise) }. Call waitUntil with your ticket request: the button shows “Sending…”, then “Reported”, or an error on rejection. |
mv-leave | Cancelable. “Leave, we’ll keep going” was chosen. detail: { snapshot }. |
mv-notify | Notifications were armed (detail: { permission, armed }), or, cancelable, a system notification is about to be shown (detail: { kind, title, body, armed }). Cancel it to deliver through your own push channel. |
Content structure
| Name | Description |
|---|---|
done | Children with slot="done" (“Download MP4”, “View images”) are shown as actions once the wait is done. |
CSS classes
| Name | Description |
|---|---|
mv-honest-wait-head | Header: -icon (pings on every update, a steady beacon when stuck), -title, -pill (kind of wait, dashed while measuring). |
mv-honest-wait-sentence | The honest sentence (plain text, tabular digits); -revision is the revision note below it. |
mv-honest-wait-track | Decorative track, data-view="bar | line | strip": -bar/-fill (real progress), -line/-dot/-you/-more (queue), -strip/-tick/-flat/-now/-limit/-zone (heartbeat). |
mv-honest-wait-meta | Detail line (-detail) and freshness indicator (-signal, -signal-dot), then -message (“Now: …”). |
mv-honest-wait-actions | Action row: -buttons (-retry, -keep, -report, -notify, -leave, -slot) and -clock (elapsed); -note explains the notification fallback. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-honest-wait-progress | var(--mv-accent) | Color of a measured, determinate wait. |
--mv-honest-wait-waiting | var(--mv-info) | Color of starting, queued and working waits. |
--mv-honest-wait-stuck | var(--mv-warning) | Possibly stuck, quiet signal, no-update limit, “taking longer” revisions. |
--mv-honest-wait-done | var(--mv-success) | Done state and “going faster” revisions. |
--mv-honest-wait-failed | var(--mv-danger) | Failed state. |
--mv-honest-wait-sentence-size | var(--mv-text-lg) | Font size of the sentence (the inline variant uses --mv-text-sm). |
--mv-honest-wait-bar-height | 0.5rem | Height of the determinate bar. |
--mv-honest-wait-strip-height | 2.25rem | Height of the heartbeat strip. |
Accessibility
The host is a role="group" named by the label and described by the sentence, so moving into it reads the honest sentence. The visible sentence is not a live region, because it changes every second (“last update 4 s ago”). A separate, visually hidden role="status" region is updated only on meaningful changes: a new kind of wait (queued, measured, working), the first estimate, a revision (“Revised: taking longer than expected (was about 2-3 min). About 6-8 min left”), possibly stuck, an update after a stall, keep waiting, retry, report and notification setup. It never speaks per percent or per second. A failure goes to a role="alert" region. The bar, queue dots and heartbeat strip are aria-hidden, with no progressbar that would chatter on every value change: the words carry everything, including the counts, the rate and how long ago the last update came. Every action is a native button with a visible label and icon. Notify is a toggle with aria-pressed, Report is aria-busy and aria-disabled while sending. When a focused button disappears (Keep waiting, Retry), focus moves to the next action or to the sentence (tabindex=-1, visible focus ring). The component never steals focus on its own, not even when a job gets stuck or ends. State is never shown by color alone: every kind has its own icon, pill text and sentence. Stuck uses hatching and dashes, quiet signals hollow out the dot, measuring draws the pill dashed. Reduced motion (OS or data-motion="reduce"): the strip no longer slides, the bar jumps, pings stop and the stuck beacon becomes a static ring. Forced colors map tracks to CanvasText and Highlight.