Exclusivobeta

Honest Wait <mv-honest-wait>

Um indicador de esperas longas que nunca finge, para exportações, renderizações de vídeo, geração com IA, montagem de relatórios, provisionamento, filas e uploads em conexões lentas. Você fornece o que o job realmente informa: progress(value, total), queue(position), beat() ou nada. Ele então diz que tipo de espera é essa, em uma frase simples e uma pílula com rótulo. Determinada: “About 3-5 min left”, um intervalo calculado a partir do ritmo observado (uma EWMA ponderada no tempo combinada com a velocidade geral; sua variância define a largura). Na fila: “12th in line · about 5-7 min to start”, a partir da vazão observada, com as pessoas à frente desenhadas como pontos que saem pela frente. Ativa sem estimativa: “Still working · last update 4 s ago”, sobre uma faixa de batimentos onde o limite sem atualização se aproxima do marcador do agora. Suspeita: “No update for 2 min · this may be stuck”, com Retry, Keep waiting e Report. A ETA é suavizada e faz a contagem regressiva em tempo real, então nunca volta atrás de repente. Quando a nova melhor estimativa fica fora do intervalo que foi dado ao usuário, ele avisa: “Revised: taking longer than expected (was about 2-3 min)”, e emite mv-eta-revised. O silêncio entra na conta: quando as atualizações param, a velocidade medida cai em vez de a ETA descer até um falso zero, e uma barra pausada indica “Progress paused at 62% · still responding”. “Notify me when done” executa o fluxo de permissão de Notification e recorre ao título da aba quando as notificações estão bloqueadas. “Leave, we’ll keep going” passa a vez para a sua interface de jobs em segundo plano. Os relatórios levam um snapshot() de diagnóstico. Os carimbos de tempo podem vir do seu servidor ({ at }), então um job que começou antes de a página carregar continua sendo descrito com fidelidade.

CategoriaFeedback
TipoWeb Component (<mv-honest-wait>)
Statusbeta
KitEstados do sistema honestos
Também instalabutton
Keywordsexclusive, 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

Instalação

node scripts/add.mjs honest-wait --out ./src/marvelous

Agente de IA com o servidor MCP do Marvelous UI: install_components({ slugs: ["honest-wait"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Arquivos copiados (dependências incluídas): 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.

Uso

Início rápido, a menor marcação que funciona:

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

Marcação de referência, para usar como ponto de partida e personalizar com atributos, data-* e variáveis CSS:

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

Referência cultural

Esperando Godot, Samuel Beckett (1953, peça). Dois homens esperam alguém que, segundo lhes dizem sempre, vai chegar “amanhã” e nunca chega: a espera fica insuportável porque ninguém nunca lhes conta a verdade sobre ela. A interface transpõe isso para uma espera que nunca mente: diz que tipo de espera é, deriva cada estimativa do que realmente observou, admite quando a revisou e diz com clareza quando nada aconteceu por tempo demais.

API

Attributes

NameTipoDefaultDescription
labelstringWorking on your requestWhat 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.
unitstringUnit 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.
stalltime ("2m", "90s", "45s", ms number)2mSilence (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.
typicaltimeTypical 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.
startedISO date | ms epochWhen 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.
notifybooleanShows “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.
backgroundbooleanShows “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-titlebooleanMirrors 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"cardcard: 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-kindstarting | determinate | queued | working | stuck | done | failedSet 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

NameTipoDescription
kindstringCurrent kind of wait (read-only), same values as data-kind.
estimate{ low, high } (ms) | nullThe displayed, smoothed time-left range right now, or null while measuring, without an estimate, or once ended (read-only).
elapsednumber (ms)Time since the job started, frozen once done or failed (read-only).
startedAtnumber (ms epoch) | DateRead or set the real start time (same as the started attribute).
notifyingbooleanTrue once “Notify me when done” is armed (read-only).
stringsPartial<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

NameDescription
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

NameDescription
mv-stuckCancelable. 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-resumeAn update arrived after the wait was marked possibly stuck. detail: { silentFor }.
mv-eta-revisedThe 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-donedetail: { duration, firstEstimate: { low, high } | null, revisions }. Use it to log how accurate your estimates are.
mv-faileddetail: { reason, snapshot }.
mv-retryCancelable. Retry was chosen (stuck or failed). detail: { reason: "stuck" | "failed", snapshot }. Resubmit the job. If not cancelled, the component resets itself.
mv-keep-waitingThe 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-reportCancelable. 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-leaveCancelable. “Leave, we’ll keep going” was chosen. detail: { snapshot }.
mv-notifyNotifications 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

NameDescription
doneChildren with slot="done" (“Download MP4”, “View images”) are shown as actions once the wait is done.

CSS classes

NameDescription
mv-honest-wait-headHeader: -icon (pings on every update, a steady beacon when stuck), -title, -pill (kind of wait, dashed while measuring).
mv-honest-wait-sentenceThe honest sentence (plain text, tabular digits); -revision is the revision note below it.
mv-honest-wait-trackDecorative 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-metaDetail line (-detail) and freshness indicator (-signal, -signal-dot), then -message (“Now: …”).
mv-honest-wait-actionsAction row: -buttons (-retry, -keep, -report, -notify, -leave, -slot) and -clock (elapsed); -note explains the notification fallback.

CSS variables

NameDefaultDescription
--mv-honest-wait-progressvar(--mv-accent)Color of a measured, determinate wait.
--mv-honest-wait-waitingvar(--mv-info)Color of starting, queued and working waits.
--mv-honest-wait-stuckvar(--mv-warning)Possibly stuck, quiet signal, no-update limit, “taking longer” revisions.
--mv-honest-wait-donevar(--mv-success)Done state and “going faster” revisions.
--mv-honest-wait-failedvar(--mv-danger)Failed state.
--mv-honest-wait-sentence-sizevar(--mv-text-lg)Font size of the sentence (the inline variant uses --mv-text-sm).
--mv-honest-wait-bar-height0.5remHeight of the determinate bar.
--mv-honest-wait-strip-height2.25remHeight 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.

Esta página foi traduzida com IA. Informar um problema de tradução