익스클루시브

Honest Wait <mv-honest-wait>

결코 꾸며내지 않는 긴 대기 표시기로, 내보내기, 동영상 렌더링, AI 생성, 보고서 빌드, 프로비저닝, 대기열, 느린 회선에서의 업로드를 위한 컴포넌트입니다.

문화적 레퍼런스

고도를 기다리며, 사뮈엘 베케트(1953, 희곡). 두 남자가 늘 “내일” 온다는 말만 들리고 결코 오지 않는 누군가를 기다립니다. 아무도 그 기다림에 대해 진실을 말해 주지 않기 때문에 기다림은 견딜 수 없는 것이 됩니다. UI는 이를 결코 거짓말하지 않는 대기로 옮깁니다. 어떤 종류의 대기인지 말하고, 모든 추정치를 실제로 관찰한 것에서 도출하며, 수정했을 때는 인정하고, 너무 오랫동안 아무 일도 일어나지 않으면 분명하게 말합니다.

작동 방식

결코 꾸며내지 않는 긴 대기 표시기로, 내보내기, 동영상 렌더링, AI 생성, 보고서 빌드, 프로비저닝, 대기열, 느린 회선에서의 업로드를 위한 컴포넌트입니다. 작업이 실제로 보고하는 내용을 전달하면 됩니다: progress(value, total), queue(position), beat() 또는 아무것도 없음. 그러면 이것이 어떤 종류의 대기인지 평이한 한 문장과 라벨이 붙은 알약형 배지로 알려 줍니다. 확정형: “About 3-5 min left”, 관찰된 속도에서 계산한 범위입니다(시간 가중 EWMA를 전체 속도와 혼합하며, 그 분산이 범위의 폭을 정함). 대기열: “12th in line · about 5-7 min to start”, 관찰된 처리량에서 계산하며, 앞선 사람들은 앞쪽부터 빠져나가는 점으로 그려집니다. 추정 없이 작동 중: “Still working · last update 4 s ago”, 하트비트 띠 위에서 무응답 한계가 현재 시점 마커에 다가갑니다. 의심스러움: “No update for 2 min · this may be stuck”, Retry, Keep waiting, Report와 함께 표시됩니다. ETA는 평활화되어 실시간으로 줄어들므로 갑자기 거꾸로 튀지 않습니다. 새로운 최선의 추정이 사용자에게 알려 준 범위를 벗어나면 그 사실을 알립니다: “Revised: taking longer than expected (was about 2-3 min)”, 그리고 mv-eta-revised를 발생시킵니다. 침묵도 계산에 반영됩니다. 업데이트가 멈추면 ETA가 거짓 0까지 내려가는 대신 측정된 속도가 감소하고, 일시 정지된 막대는 “Progress paused at 62% · still responding”으로 표시됩니다. “Notify me when done”은 Notification 권한 흐름을 실행하고, 알림이 차단되면 탭 제목으로 대체합니다. “Leave, we’ll keep going”은 백그라운드 작업 UI로 넘겨줍니다. 보고에는 진단용 snapshot()이 포함됩니다. 타임스탬프는 서버에서 받을 수 있으므로({ at }), 페이지가 로드되기 전에 시작된 작업도 사실대로 설명됩니다.

카테고리피드백
유형Web Component (<mv-honest-wait>)
상태안정
키트정직한 시스템 상태
함께 설치되는 항목button
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

설치

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

Marvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["honest-wait"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, components/button/button.css, core/dom.js, core/element.js, core/motion.js, components/honest-wait/honest-wait.js, components/honest-wait/honest-wait.css.

사용법

빠른 시작, 동작하는 가장 작은 마크업:

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

기본 마크업입니다. 여기서 시작해 속성, data-*, 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>

API

Attributes

Name유형DefaultDescription
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

Name유형Description
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.

이 페이지는 AI로 번역되었습니다. 번역 문제 신고