← Commit history

web demo: bridge-agnostic reference implementation for rendering measured progress (readProgressBlock/renderProgressBlock), plus a card that animates an ORDINARY slow verb by polling alongside it

John Lauer ·50ec120bc1 ·2mo ago ·parent cbf6c70
1 file changed +251
web-control-demo.html+251
@@ -40,6 +40,19 @@   .pbar-fill { height: 100%; width: 0; background: linear-gradient(90deg, #1f6feb, #2ea043); transition: width .35s ease; }   .pbar-meta { font-size: 11px; color: #9aa4af; margin-bottom: 4px; }   .pbar-meta .beat { color: #6e7681; }+  /* Progress states - each encodes an honesty rule, see renderProgressBlock(). */+  /* Not "measured": the bridge is guessing, so it must not look as confident. */+  .pbar-fill.pbar-estimated { opacity: .5; }+  /* Parked on a HUMAN: amber, and it stops advancing. */+  .pbar-fill.pbar-blocked { background: linear-gradient(90deg, #9e6a03, #d29922); }+  .pbar-fill.pbar-failed  { background: linear-gradient(90deg, #8b1a1a, #f85149); }+  /* Working, but the bridge cannot estimate: sweep instead of a fake 0%. */+  .pbar-fill.pbar-indeterminate {+    background: repeating-linear-gradient(90deg, #1f6feb 0 12px, #10305f 12px 24px);+    animation: pbar-sweep 1s linear infinite;+  }+  @keyframes pbar-sweep { from { background-position: 0 0; } to { background-position: 24px 0; } }+  @media (prefers-reduced-motion: reduce) { .pbar-fill.pbar-indeterminate { animation: none; } }   .pill { font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid #30363d; color: #8b949e; }   details { margin-top: 20px; } summary { cursor: pointer; color: #8b949e; font-size: 12px; }   pre#log { background: #010409; border: 1px solid #21262d; border-radius: 6px; padding: 12px; font-size: 11px; max-height: 240px; overflow: auto; white-space: pre-wrap; margin: 8px 0 0; color: #c9d1d9; }@@ -450,6 +463,143 @@ for (const w of mine) {   }   function esc(s) { return String(s).replace(/[&<>]/g, function (c) { return { "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]; }); } +  // ==========================================================================+  // REFERENCE IMPLEMENTATION - rendering a bridge's measured progress on a web+  // page. THIS is the part to copy onto wiki.adom.inc.+  // --------------------------------------------------------------------------+  // Everything below is bridge-AGNOSTIC. It never mentions kicad or fusion, and+  // it works for a bridge that does not exist yet, because it matches on the+  // SHAPE of the measured-progress contract every bridge emits:+  //+  //   { phase, stepLabel, percent, elapsedSec, estimatedSec, etaSec,+  //     confidence, startKind, blockedBy?, measured? }+  //+  // Two functions, two jobs: readProgressBlock() gets the block out of whatever+  // the bridge answered with, renderProgressBlock() draws it. Keeping them apart+  // is deliberate - the reading half is where the traps are, the drawing half is+  // where the honesty rules are, and you will want to restyle one without+  // touching the other.+  // ==========================================================================++  // ---- 1. READ ------------------------------------------------------------+  // A bridge reports progress at TWO different moments, and a page that reads+  // only the first can show a bar that never moves:+  //+  //   a) TERMINAL FRAME - a top-level `progress` on the slow verb's own+  //      response. Always arrives at percent:100, because by the time the verb+  //      answers, the work is finished. On its own this is a completion+  //      receipt, NOT a progress bar.+  //   b) LIVE POLL - an `active[]` array of those same blocks, one per+  //      in-flight phase, from the bridge's fast progress verb while the slow+  //      work is still running. THIS is the one that animates: poll it about+  //      once a second and you get the stream of rising frames a bar needs.+  //+  // ⚠ THE TRAP: `active` is overloaded in the wild. The canonical contract uses+  // an ARRAY of blocks; some bridges' demo verbs use a BOOLEAN meaning+  // "still running". Disambiguate by TYPE, never by truthiness - `active:false`+  // and `active:[]` both mean idle, but `if (resp.active)` is true for a+  // non-empty array AND for the boolean, and false for BOTH idle forms, so a+  // truthiness test silently conflates a running job with a finished one.+  function readProgressBlock(resp) {+    if (!resp || typeof resp !== "object") return null;++    // Terminal frame wins when present: it is a verb's authoritative last word,+    // and it must not be overridden by a stale in-flight row.+    if (isProgressBlock(resp.progress)) return resp.progress;++    if (Array.isArray(resp.active)) {+      var rows = resp.active.filter(isProgressBlock);+      if (!rows.length) return null;                 // idle - draw nothing+      // Several phases in flight: draw the LEAST COMPLETE one. The honest answer+      // to "how far along is this?" is bounded by the slowest phase; drawing the+      // furthest-along one promises a finish the work has not earned. A row with+      // no percent sorts last so a row that HAS one wins.+      return rows.slice().sort(function (a, b) {+        return pctOf(a) - pctOf(b);+      })[0];+    }+    return null;+  }+  function pctOf(b) {+    return typeof b.percent === "number" ? b.percent : Infinity;+  }+  // A response can carry an unrelated field called `progress` (the toast+  // contract uses {title, value, status}), so a key name proves nothing.+  // Require a field only the measured-progress contract defines.+  function isProgressBlock(o) {+    return !!o && typeof o === "object" && !Array.isArray(o) &&+      ("percent" in o || "phase" in o || "etaSec" in o);+  }++  // ---- 2. RENDER ----------------------------------------------------------+  // Three honesty rules are baked in here on purpose. They are what separate a+  // progress bar the user can trust from one that is merely decorative:+  //+  //   1. An ESTIMATE IS MARKED AS ONE. `confidence` is the bridge telling you+  //      how it got the number: "measured" means it timed this phase on THIS+  //      machine, anything else is a default or a small sample. A guess that+  //      renders identically to a measurement teaches the user to distrust+  //      both, so a non-measured bar is drawn dimmed and labelled.+  //   2. `blockedBy` STOPS THE BAR. It means the work is parked on a HUMAN - a+  //      modal dialog, a login, a save prompt. Nothing will move until the user+  //      acts, so the bar goes amber, stops advancing, and says what it wants.+  //      A bar that keeps sliding while nothing can happen is worse than none.+  //   3. NO PERCENT MEANS INDETERMINATE, not zero. A bridge that honestly says+  //      "I am working but cannot estimate" must not render as a bar stuck at+  //      0%, which reads as "broken".+  function renderProgressBlock(card, b) {+    var p = progressBar(card);+    var blocked = typeof b.blockedBy === "string" && b.blockedBy.trim();+    var estimated = b.confidence && b.confidence !== "measured";+    var pct = typeof b.percent === "number"+      ? Math.max(0, Math.min(100, b.percent)) : null;++    p.fill.style.width = (pct === null ? 100 : pct) + "%";+    p.fill.classList.toggle("pbar-indeterminate", pct === null && !blocked);+    p.fill.classList.toggle("pbar-estimated", !!estimated && !blocked);+    p.fill.classList.toggle("pbar-blocked", !!blocked);+    p.fill.classList.toggle("pbar-failed", b.failed === true);++    // The human line: the bridge's own wording, never ours. `stepLabel` is+    // written for a person; `phase` is the machine-readable namespaced id and+    // is only a fallback.+    var bits = [];+    if (pct !== null) bits.push(Math.round(pct) + "%");+    var label = b.stepLabel || b.phase || "working";++    // Time: prefer what is LEFT (what a person actually wants) over what has+    // elapsed, and show the estimate it is measured against.+    if (typeof b.etaSec === "number" && b.etaSec >= 0) bits.push(secs(b.etaSec) + " left");+    else if (typeof b.elapsedSec === "number") {+      bits.push(secs(b.elapsedSec) + " elapsed" ++        (typeof b.estimatedSec === "number" ? " of ~" + secs(b.estimatedSec) : ""));+    }+    if (b.startKind) bits.push(b.startKind);            // cold | warm+    if (estimated) bits.push(String(b.confidence));     // rule 1+    if (blocked) bits.push("waiting on you: " + b.blockedBy.trim());  // rule 2++    p.meta.innerHTML = esc(label) ++      (bits.length ? ' <span class="beat">- ' + esc(bits.join(" - ")) + "</span>" : "");+    return { blocked: !!blocked, percent: pct };+  }+  function secs(n) {+    if (typeof n !== "number" || !isFinite(n) || n < 0) return "";+    if (n < 60) return Math.round(n) + "s";+    var m = Math.floor(n / 60), s = Math.round(n % 60);+    return s ? m + "m " + s + "s" : m + "m";+  }++  // ---- 3. EVIDENCE --------------------------------------------------------+  // A progress frame may carry `stepShot {step, title, image}` - a screenshot+  // the bridge captured of the thing it just did. Render it verbatim as the+  // step's thumbnail: it is the difference between a bar claiming a footprint+  // opened and the user SEEING that it opened. `image` is a data: URL or bare+  // base64 PNG.+  function readStepShot(frameOrResp) {+    var f = frameOrResp || {};+    return f.stepShot || (f.result && f.result.stepShot) || null;+  }+   // Drive a bridge's demo verb: start (background) -> poll progress -> render the   // returned window array. cfg = { startCmd, startArgs, pollArgs, stateCmd }.   function runBridgeDemo(c, cfg) {@@ -801,6 +951,107 @@ for (const w of mine) {   cApps.btn.onclick = function () { doVerb(cApps.res, "desktop_list_browsers", null, undefined, browsersSummary); };    // ---- Do something: stream the launch, then capture the FULL window array ----+  // ==========================================================================+  // THE CANONICAL PATTERN - drive ANY slow bridge verb and animate its progress.+  // --------------------------------------------------------------------------+  // The two cards above poll a bridge's purpose-built *_demo verb. This one does+  // NOT: it calls an ordinary slow verb, the kind a real page actually needs to+  // wait on, and animates it purely from the measured-progress contract. Nothing+  // below knows what KiCad is - swap the two verb names and it drives any bridge.+  //+  // The shape is the whole lesson, and it is not obvious the first time:+  //+  //   fire the slow verb  ------------------------------------->  resolves (terminal frame)+  //   poll <prefix>_progress every 1s  --> --> --> --> -->        stop+  //+  // TWO CONCURRENT REQUESTS. The slow verb's own response cannot animate+  // anything, because it does not arrive until the work is done - by then the+  // only honest number it can carry is 100%. The poll running ALONGSIDE it is+  // what produces the rising frames. Firing the verb and awaiting it before+  // starting the poll is the single most common way to end up with a bar that+  // jumps 0 -> 100 and nothing in between.+  // ==========================================================================+  function runCanonicalProgress(c, cfg) {+    var target = chosenTarget();+    var who = machineLabel(target);+    c.btn.disabled = true;+    cardLog(c.card).textContent = "";+    var sb = c.card.querySelector("div.shots"); if (sb) sb.innerHTML = "";+    setRes(c.res, "pending", "asking permission (choose “Allow 1 hr”) ...",+      "on " + esc(who) + " - one slow verb, animated purely from its progress block");++    var finished = false, frames = 0, sawMoving = false, lastLabel = null;++    // ---- the poll leg: this is what animates -------------------------------+    function poll() {+      if (finished) return;+      postVerb(cfg.pollCmd, cfg.pollArgs || {}, target).then(function (res) {+        if (finished) return;+        var block = readProgressBlock(res.j);+        if (block) {+          frames++;+          var st = renderProgressBlock(c.card, block);+          if (st.percent !== null && st.percent < 100) sawMoving = true;+          var label = block.stepLabel || block.phase;+          if (label && label !== lastLabel) {+            lastLabel = label;+            clog(c, "  poll -> " + (block.percent != null ? block.percent + "% " : "") + label ++              (block.confidence ? " [" + block.confidence + "]" : "") ++              (block.blockedBy ? " BLOCKED: " + block.blockedBy : ""));+          }+          // A frame can carry its own evidence screenshot - show it verbatim.+          var ss = readStepShot(block) || readStepShot(res.j);+          if (ss && (ss.image || ss.base64)) addShot(c.card, ss.image || ss.base64,+            "step: " + (ss.step || ss.title || label || "step"));+        }+        setTimeout(poll, 1000);+      }).catch(function () { if (!finished) setTimeout(poll, 1000); });+    }++    // ---- the work leg: fire it, do NOT await before polling -----------------+    clog(c, "POST " + cfg.slowCmd + "  (the slow verb; it will not answer until it is done)");+    clog(c, "POST " + cfg.pollCmd + "  every 1s alongside it  <- this is what moves the bar");+    var work = postVerb(cfg.slowCmd, cfg.slowArgs || {}, target);+    poll();++    work.then(function (res) {+      finished = true;+      // The TERMINAL frame: the verb's authoritative last word. Render it so the+      // bar lands on the real final numbers rather than wherever the last poll+      // happened to catch it.+      var block = readProgressBlock(res.j);+      if (block) {+        renderProgressBlock(c.card, block);+        clog(c, "terminal frame -> " + JSON.stringify(block));+      } else {+        clog(c, "terminal frame -> (this verb reported no progress block)");+      }+      var ok = res.s === 200 && res.j && res.j.success !== false;+      var note = frames + " live frame(s) polled" ++        (sawMoving ? ", bar animated" :+          ", none below 100% - this bridge does not populate active[] during this verb yet, " ++          "so the bar could only draw the terminal frame");+      setRes(c.res, ok ? "ok" : "err", ok ? "Allowed &amp; ran" : "Failed",+        "on " + esc(who) + " - " + esc(note));+      c.btn.disabled = false;+    }).catch(function (e) {+      finished = true;+      setRes(c.res, "err", "blocked", String(e));+      c.btn.disabled = false;+    });+  }++  var cCanon = card("grp-do", "verb", "Animate ANY slow verb (the canonical pattern)");+  cCanon.btn.onclick = function () {+    // Ordinary verbs, not demo verbs. Swap these two names for any other bridge.+    runCanonicalProgress(cCanon, {+      slowCmd: "kicad_show_footprint",+      slowArgs: { footprintName: "R_0603_1608Metric", library: "Resistor_SMD" },+      pollCmd: "kicad_progress",+      pollArgs: {}+    });+  };+   var cKicad = card("grp-do", "verb", "Run the KiCad demo (bridge-driven, live progress)");   cKicad.btn.onclick = function () {     // The KiCad bridge OWNS this demo (kicad_demo, 0.9.92+): it builds a sample