Exclusivebeta
Honest Wait — <mv-honest-wait>
A long-wait indicator that never pretends, for exports, video renders, AI generation, report builds, provisioning, queues and uploads on slow links. You feed it what the job really reports: progress(value, total), queue(position), beat() or nothing. It then says which kind of wait this is, in one plain sentence and a labeled pill. Determinate: “About 3–5 min left”, a range computed from the observed rate (a time-weighted EWMA blended with the overall speed, its variance sets the width). Queued: “12th in line · about 5–7 min to start”, from the observed throughput, with people ahead drawn as dots that leave from the front. Alive with no estimate: “Still working · last update 4 s ago”, on a heartbeat strip where the no-update limit approaches the now marker. Suspicious: “No update for 2 min — this may be stuck”, with Retry, Keep waiting and Report. The ETA is smoothed and counts down in real time, so it never jumps back abruptly. When the new best guess falls outside the range the user was given, it says so: “Revised: taking longer than expected (was about 2–3 min)”, and emits mv-eta-revised. Silence is priced in: when updates stop, the measured speed decays instead of the ETA running down to a false zero, and a paused bar reads “Progress paused at 62% · still responding”. “Notify me when done” runs the Notification permission flow and falls back to the tab title when notifications are blocked. “Leave, we’ll keep going” hands off to your background-jobs UI. Reports carry a diagnostic snapshot(). Timestamps can come from your server ({ at }), so a job that started before the page loaded is still described truthfully.
| Category | Feedback |
|---|---|
| Type | Web Component (<mv-honest-wait>) |
| Status | beta |
| Also installs | 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
Install
node scripts/add.mjs honest-wait --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["honest-wait"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): 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.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<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>Cultural reference
Waiting for Godot — Samuel Beckett (1953, play). Two men wait for someone who is always said to be coming “tomorrow” and never does: the waiting becomes unbearable because nobody ever tells them the truth about it. The UI transposes this into a wait that never lies: it says which kind of wait it is, derives every estimate from what it actually observed, admits when it revised, and says plainly when nothing has happened for too long.
API
Attributes
| Name | Type | 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 | Type | 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.