waygraph 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -294,27 +294,26 @@ function parseChainSpec(spec) {
294
294
  });
295
295
  }
296
296
 
297
- function seedMemForBlock(mem, resolved, json) {
298
- const requires = resolved.block.requires ?? [];
297
+ function seedMemFromRequires(mem, requires, json, label) {
299
298
  if (requires.length === 0) {
300
299
  if (json !== undefined) {
301
300
  throw new Error(
302
- "waygraph chain: \\"" + resolved.exportName + "\\" takes no input (empty requires) but got a payload: " + json,
301
+ "waygraph chain: \\"" + label + "\\" takes no input (empty requires) but got a payload: " + json,
303
302
  );
304
303
  }
305
304
  return;
306
305
  }
307
306
  if (json === undefined) {
308
307
  throw new Error(
309
- "waygraph chain: \\"" + resolved.exportName + "\\" requires " + requires.map((k) => k.name).join(", ") +
310
- " - give a JSON payload, e.g. " + resolved.exportName + "({...})",
308
+ "waygraph chain: \\"" + label + "\\" requires " + requires.map((k) => k.name).join(", ") +
309
+ " - give a JSON payload, e.g. " + label + "({...})",
311
310
  );
312
311
  }
313
312
  let parsed;
314
313
  try {
315
314
  parsed = JSON.parse(json);
316
315
  } catch (err) {
317
- throw new Error("waygraph chain: \\"" + resolved.exportName + "\\" payload is not valid JSON - " + String(err));
316
+ throw new Error("waygraph chain: \\"" + label + "\\" payload is not valid JSON - " + String(err));
318
317
  }
319
318
  if (requires.length === 1) {
320
319
  mem.set(requires[0], parsed);
@@ -322,18 +321,39 @@ function seedMemForBlock(mem, resolved, json) {
322
321
  }
323
322
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
324
323
  throw new Error(
325
- "waygraph chain: \\"" + resolved.exportName + "\\" requires " + requires.length + " keys (" +
324
+ "waygraph chain: \\"" + label + "\\" requires " + requires.length + " keys (" +
326
325
  requires.map((k) => k.name).join(", ") + ") - payload must be an object keyed by each key's name",
327
326
  );
328
327
  }
329
328
  for (const k of requires) {
330
329
  if (!(k.name in parsed)) {
331
- throw new Error("waygraph chain: \\"" + resolved.exportName + "\\" payload is missing required key \\"" + k.name + "\\"");
330
+ throw new Error("waygraph chain: \\"" + label + "\\" payload is missing required key \\"" + k.name + "\\"");
332
331
  }
333
332
  mem.set(k, parsed[k.name]);
334
333
  }
335
334
  }
336
335
 
336
+ function seedMemForBlock(mem, resolved, json) {
337
+ seedMemFromRequires(mem, resolved.block.requires ?? [], json, resolved.exportName);
338
+ }
339
+
340
+ /**
341
+ * Same idea as seedMemForBlock, but for a whole Flow segment
342
+ * ("loginFlow({...}) then ...") - a Flow has no single .requires of its
343
+ * own, so this unions every constituent Block's requires (deduped by key
344
+ * name) and seeds them all from one JSON payload, same keyed-by-name shape
345
+ * as a multi-key Block payload already uses.
346
+ */
347
+ function seedMemForFlow(mem, flowBlocks, json, label) {
348
+ const seen = new Map();
349
+ for (const bi of flowBlocks) {
350
+ for (const k of bi.block.requires ?? []) {
351
+ if (!seen.has(k.name)) seen.set(k.name, k);
352
+ }
353
+ }
354
+ seedMemFromRequires(mem, Array.from(seen.values()), json, label);
355
+ }
356
+
337
357
  // ---------------------------------------------------------------------------
338
358
  // WAYGRAPH_STEP=1 - human-verification overlay: one Block at a time, a
339
359
  // browser-injected panel showing/editing that Block's MemKeys, a "Run this
@@ -347,18 +367,56 @@ const RING_CSS =
347
367
  "#wg-ring{position:fixed;z-index:2147483646;pointer-events:none;opacity:0;" +
348
368
  "border:2.5px solid #7C3AED;border-radius:10px;" +
349
369
  "box-shadow:0 0 0 4px rgba(124,58,237,.16);transition:opacity .3s ease;}" +
350
- "#wg-ring::after{content:attr(data-label);position:absolute;left:0;top:calc(100% + 8px);" +
370
+ // A real element, not a ::after pseudo-element - a pseudo-element's
371
+ // position is CSS-relative to the ring's own box (left:0 always meant
372
+ // "the ring's own left edge"), so it had no way to be clamped back onto
373
+ // screen when that box sat near a viewport edge - the label's text just
374
+ // ran off, invisibly, with no overflow guard at all. A real sibling can
375
+ // be measured (its actual rendered width) and repositioned in JS -
376
+ // pushed back onto screen, same width, never shrunk. See
377
+ // window.__wgPositionRing in installOverlay.
378
+ "#wg-ring-label{position:fixed;z-index:2147483646;pointer-events:none;opacity:0;" +
351
379
  "white-space:nowrap;padding:4px 9px;border-radius:7px;background:#7C3AED;color:#fff;" +
352
- "font:600 12px/1.2 system-ui,sans-serif;}" +
380
+ "font:600 12px/1.2 system-ui,sans-serif;transition:opacity .3s ease;}" +
381
+ // Mouse cursor icon that travels to a target before it's acted on, plus a
382
+ // quick expanding ripple at the moment of a click - same idea as
383
+ // help-center-clip-engine's #clip-cursor/#clip-ring (video-pipeline). The
384
+ // shape itself is an inline SVG set as innerHTML in installOverlay below
385
+ // (dark fill + white stroke, same as the clip-engine's own cursor) - a
386
+ // plain solid-white CSS clip-path (the first attempt here) had no outline
387
+ // at all and all but disappeared against this app's light background.
388
+ // Travel duration is JS-driven per call via --wg-cursor-ms, same reason
389
+ // the clip engine's own comment gives: a hardcoded CSS duration would
390
+ // make the speed param a no-op.
391
+ "#wg-cursor{position:fixed;z-index:2147483647;width:24px;height:24px;pointer-events:none;" +
392
+ "left:0;top:0;opacity:0;margin:0;" +
393
+ "transition:transform var(--wg-cursor-ms,600ms) cubic-bezier(.22,1,.36,1),opacity .2s ease;" +
394
+ "filter:drop-shadow(0 2px 4px rgba(12,12,26,.4));}" +
395
+ "#wg-click-pulse{position:fixed;z-index:2147483647;width:14px;height:14px;" +
396
+ "margin-left:-7px;margin-top:-7px;border-radius:50%;pointer-events:none;opacity:0;" +
397
+ "border:2px solid #7C3AED;background:rgba(124,58,237,.25);}" +
398
+ "#wg-click-pulse.wg-pulse{animation:wg-pulse .5s ease-out;}" +
399
+ "@keyframes wg-pulse{0%{opacity:.9;transform:scale(.4);}100%{opacity:0;transform:scale(2.4);}}" +
353
400
  "#wg-panel{position:fixed;z-index:2147483647;left:50%;bottom:12px;transform:translateX(-50%);" +
354
401
  "max-width:min(92vw,640px);max-height:calc(100vh - 24px);overflow-y:auto;box-sizing:border-box;" +
355
402
  "background:rgba(20,10,40,.94);color:#fff;border-radius:14px;" +
356
403
  "padding:16px 20px;font:14px/1.4 system-ui,sans-serif;box-shadow:0 12px 30px rgba(0,0,0,.35);" +
357
- "opacity:0;transition:opacity .25s ease;}" +
404
+ "opacity:0;transition:opacity .06s ease;}" +
358
405
  "#wg-panel.wg-in{opacity:1;}" +
359
406
  "#wg-panel .wg-auto{margin-top:10px;font:600 13px system-ui,sans-serif;color:#c9a6ff;}" +
407
+ "#wg-panel .wg-autoplay-row{margin-top:8px;}" +
408
+ "#wg-panel .wg-autoplay-row label{display:inline-flex;align-items:center;gap:6px;" +
409
+ "font:12px system-ui,sans-serif;color:#b8a0e0;cursor:pointer;user-select:none;}" +
410
+ "#wg-panel .wg-autoplay-row input{margin:0;cursor:pointer;}" +
360
411
  "#wg-panel h3{margin:0 0 8px;font-size:13px;color:#c9a6ff;font-weight:700;" +
361
412
  "letter-spacing:.02em;text-transform:uppercase;}" +
413
+ // Sits ABOVE the per-Block "Step i/N" heading - the episode/scenario
414
+ // this Block belongs to, not another block-level label. Only rendered
415
+ // when the chain spec actually named a real Flow (chainFlow tags it via
416
+ // BlockInfo.resetSessionBefore's sibling metadata) - a plain ad hoc
417
+ // block chain shows no episode heading at all.
418
+ "#wg-panel .wg-episode{margin:0 0 6px;font:700 15px system-ui,sans-serif;color:#fff;" +
419
+ "padding-bottom:6px;border-bottom:1px solid rgba(124,58,237,.35);}" +
362
420
  "#wg-panel .wg-narration{margin:0 0 12px;font:italic 14px/1.4 system-ui,sans-serif;color:#f0e8ff;}" +
363
421
  "#wg-progress{height:4px;background:#2a1650;border-radius:2px;margin:0 0 12px;overflow:hidden;}" +
364
422
  "#wg-progress-bar{height:100%;background:#7C3AED;border-radius:2px;transition:width .3s ease;}" +
@@ -389,7 +447,13 @@ const RING_CSS =
389
447
  "#wg-panel .wg-toggle{display:flex;gap:4px;margin:0 0 4px;}" +
390
448
  "#wg-panel .wg-toggle button{margin:0;padding:3px 10px;font:600 11px system-ui,sans-serif;" +
391
449
  "background:transparent;border:1px solid #4b2a80;color:#9a7ad1;border-radius:6px;}" +
392
- "#wg-panel .wg-toggle button.wg-active{background:#4b2a80;color:#fff;}";
450
+ "#wg-panel .wg-toggle button.wg-active{background:#4b2a80;color:#fff;}" +
451
+ "#wg-panel.wg-error{border:1.5px solid #e0475c;}" +
452
+ "#wg-panel .wg-error-heading{color:#ff8fa0;}" +
453
+ "#wg-panel .wg-error-msg{font:12px/1.5 monospace;background:#2a0f16;color:#ffc7cf;" +
454
+ "border-radius:8px;padding:10px;margin:0 0 12px;white-space:pre-wrap;max-height:200px;overflow-y:auto;}" +
455
+ "#wg-panel .wg-error-stop{background:#e0475c;}" +
456
+ "#wg-panel .wg-error-stop:hover{background:#c33a4c;}";
393
457
 
394
458
  // Purple dot favicon (matches the overlay's own theme color) - the tab-bar
395
459
  // signal that "this Chromium window is a waygraph run," even at a glance
@@ -408,14 +472,128 @@ async function installOverlay(page, title) {
408
472
  // navigation that rebuilds the banner keeps the human's last pick).
409
473
  const envPos = (process.env.WAYGRAPH_TITLE_POS || "left").toLowerCase();
410
474
  const bannerPos = envPos === "center" || envPos === "right" ? envPos : "left";
475
+ const envAutoplay = process.env.WAYGRAPH_AUTOPLAY === "1";
411
476
  await page
412
477
  .evaluate(
413
- ({ title, favicon, bannerPos }) => {
478
+ ({ title, favicon, bannerPos, envAutoplay }) => {
479
+ // Seed the live autoplay toggle from the env default on first ever
480
+ // load only - a real navigation re-runs this, and re-stamping here
481
+ // would silently undo a human's mid-run checkbox click.
482
+ try {
483
+ if (localStorage.getItem("wg-autoplay") === null) {
484
+ localStorage.setItem("wg-autoplay", envAutoplay ? "1" : "0");
485
+ }
486
+ } catch {
487
+ /* private mode / blocked storage - falls back to manual gating */
488
+ }
414
489
  if (!document.getElementById("wg-ring")) {
415
490
  const ring = document.createElement("div");
416
491
  ring.id = "wg-ring";
417
492
  document.documentElement.appendChild(ring);
418
493
  }
494
+ if (!document.getElementById("wg-ring-label")) {
495
+ const ringLabel = document.createElement("div");
496
+ ringLabel.id = "wg-ring-label";
497
+ document.documentElement.appendChild(ringLabel);
498
+ }
499
+ if (!document.getElementById("wg-cursor")) {
500
+ const cursor = document.createElement("div");
501
+ cursor.id = "wg-cursor";
502
+ // Dark fill + white stroke, same as help-center-clip-engine's own
503
+ // #clip-cursor - visible against any page background, light or
504
+ // dark, unlike a plain solid-white shape.
505
+ cursor.innerHTML =
506
+ "<svg viewBox='0 0 32 32' width='24' height='24'>" +
507
+ "<path fill='#0C0C1A' stroke='#fff' stroke-width='1.4' stroke-linejoin='round' " +
508
+ "d='M6 3.5l1.4 22.5 5.8-5.4 4.2 9.4 3.6-1.6-4.2-9.2H26z'/></svg>";
509
+ document.documentElement.appendChild(cursor);
510
+ }
511
+ if (!document.getElementById("wg-click-pulse")) {
512
+ const pulse = document.createElement("div");
513
+ pulse.id = "wg-click-pulse";
514
+ document.documentElement.appendChild(pulse);
515
+ }
516
+ window.__wgMoveCursorTo = (x, y, ms, instant) => {
517
+ const cursor = document.getElementById("wg-cursor");
518
+ if (!cursor) return;
519
+ cursor.style.setProperty("--wg-cursor-ms", (ms || 600) + "ms");
520
+ if (instant) {
521
+ const prev = cursor.style.transition;
522
+ cursor.style.transition = "none";
523
+ cursor.style.transform = "translate(" + x + "px," + y + "px)";
524
+ void cursor.offsetWidth;
525
+ cursor.style.transition = prev || "";
526
+ } else {
527
+ cursor.style.transform = "translate(" + x + "px," + y + "px)";
528
+ }
529
+ cursor.style.opacity = "1";
530
+ };
531
+ window.__wgHideCursor = () => {
532
+ const cursor = document.getElementById("wg-cursor");
533
+ if (cursor) cursor.style.opacity = "0";
534
+ };
535
+ window.__wgClickPulse = (x, y) => {
536
+ const pulse = document.getElementById("wg-click-pulse");
537
+ if (!pulse) return;
538
+ pulse.style.left = x + "px";
539
+ pulse.style.top = y + "px";
540
+ pulse.classList.remove("wg-pulse");
541
+ void pulse.offsetWidth;
542
+ pulse.classList.add("wg-pulse");
543
+ };
544
+ // Clamps the ring AND its label to stay fully on-screen, same
545
+ // width/height always - only the position moves. A target near a
546
+ // viewport edge (real case: "login success" highlight landing top
547
+ // right) used to just run the label text off-screen with no guard
548
+ // at all - same mistake as the banner had before its own
549
+ // left/center/right positions existed, not repeating it here by
550
+ // shrinking anything, only repositioning.
551
+ window.__wgPositionRing = (box, label) => {
552
+ const ring = document.getElementById("wg-ring");
553
+ const ringLabel = document.getElementById("wg-ring-label");
554
+ if (!ring || !ringLabel) return;
555
+ const margin = 6;
556
+ const vw = window.innerWidth;
557
+ const vh = window.innerHeight;
558
+ let left = box.x - 6;
559
+ let top = box.y - 6;
560
+ const width = box.width + 12;
561
+ const height = box.height + 12;
562
+ if (left < margin) left = margin;
563
+ if (top < margin) top = margin;
564
+ if (left + width > vw - margin) left = Math.max(margin, vw - margin - width);
565
+ if (top + height > vh - margin) top = Math.max(margin, vh - margin - height);
566
+ ring.style.left = left + "px";
567
+ ring.style.top = top + "px";
568
+ ring.style.width = width + "px";
569
+ ring.style.height = height + "px";
570
+ ring.style.opacity = "1";
571
+ ringLabel.textContent = label;
572
+ ringLabel.style.opacity = "1";
573
+ // Measure the label's own natural width/height (its real
574
+ // rendered size, unchanged) before deciding where it fits.
575
+ const lw = ringLabel.offsetWidth;
576
+ const lh = ringLabel.offsetHeight;
577
+ let labelLeft = left;
578
+ let labelTop = top + height + 8;
579
+ if (labelTop + lh > vh - margin) labelTop = top - lh - 8; // flip above if it'd overflow the bottom
580
+ if (labelTop < margin) labelTop = margin;
581
+ if (labelLeft + lw > vw - margin) labelLeft = Math.max(margin, vw - margin - lw);
582
+ if (labelLeft < margin) labelLeft = margin;
583
+ ringLabel.style.left = labelLeft + "px";
584
+ ringLabel.style.top = labelTop + "px";
585
+ };
586
+ window.__wgHideRing = () => {
587
+ // While a narrate() call owns the ring (mid multi-step action),
588
+ // the auto-highlight click/fill patches' own end-of-step hide is
589
+ // a no-op - narrate() itself clears it once the WHOLE wrapped
590
+ // action finishes, not just its first sub-step.
591
+ if (window.__wgNarrateOwnsRing) return;
592
+ const ring = document.getElementById("wg-ring");
593
+ const ringLabel = document.getElementById("wg-ring-label");
594
+ if (ring) ring.style.opacity = "0";
595
+ if (ringLabel) ringLabel.style.opacity = "0";
596
+ };
419
597
  const POSITIONS = ["left", "center", "right"];
420
598
  const applyPos = (el, pos) => {
421
599
  el.dataset.pos = pos;
@@ -466,7 +644,7 @@ async function installOverlay(page, title) {
466
644
  }
467
645
  if (iconLink.href !== favicon) iconLink.href = favicon;
468
646
  },
469
- { title, favicon: WAYGRAPH_FAVICON, bannerPos },
647
+ { title, favicon: WAYGRAPH_FAVICON, bannerPos, envAutoplay },
470
648
  )
471
649
  .catch(() => {});
472
650
  }
@@ -484,8 +662,8 @@ async function renderBeforeStep(page, info) {
484
662
  window.removeEventListener("scroll", window.__wgRingTrack, true);
485
663
  window.__wgRingTrack = null;
486
664
  }
487
- const ring = document.getElementById("wg-ring");
488
- if (ring) ring.style.opacity = "0";
665
+ if (window.__wgHideRing) window.__wgHideRing();
666
+ if (window.__wgHideCursor) window.__wgHideCursor();
489
667
  })
490
668
  .catch(() => {});
491
669
  await page
@@ -507,9 +685,13 @@ async function renderBeforeStep(page, info) {
507
685
  const narrationHtml = info.description
508
686
  ? "<div class=\\"wg-narration\\">" + esc(info.description) + "</div>"
509
687
  : "";
688
+ const episodeHtml = info.episodeNumber
689
+ ? "<div class=\\"wg-episode\\">Episode " + info.episodeNumber + ": " + esc(info.episodeTitle || "") + "</div>"
690
+ : "";
510
691
  let html =
511
692
  "<div id=\\"wg-progress\\"><div id=\\"wg-progress-bar\\" style=\\"width:" + pct + "%\\"></div></div>" +
512
693
  "<div id=\\"wg-modules\\">" + modulesHtml + "</div>" +
694
+ episodeHtml +
513
695
  "<h3>Step " + (info.index + 1) + " / " + info.total + " - " + info.blockName + "</h3>" +
514
696
  narrationHtml;
515
697
  if (info.keys.length === 0) {
@@ -521,9 +703,20 @@ async function renderBeforeStep(page, info) {
521
703
  "<textarea data-key=\\"" + k.name + "\\" rows=\\"2\\">" +
522
704
  k.value.replace(/</g, "&lt;") + "</textarea></div>";
523
705
  }
524
- html += info.autoplay
525
- ? "<div class=\\"wg-auto\\">Auto-advancing...</div>"
526
- : "<button id=\\"wg-run\\">Run this step \\u25B6</button>";
706
+ let autoNow = false;
707
+ try {
708
+ autoNow = localStorage.getItem("wg-autoplay") === "1";
709
+ } catch {
710
+ /* private mode / blocked storage - defaults to manual */
711
+ }
712
+ html +=
713
+ "<div class=\\"wg-autoplay-row\\"><label><input type=\\"checkbox\\" id=\\"wg-autoplay-cb\\"" +
714
+ (autoNow ? " checked" : "") +
715
+ "> Auto-advance</label></div>" +
716
+ "<div id=\\"wg-gate-manual\\"" + (autoNow ? " style=\\"display:none\\"" : "") +
717
+ "><button id=\\"wg-run\\">Run this step \\u25B6</button></div>" +
718
+ "<div id=\\"wg-gate-auto\\" class=\\"wg-auto\\"" + (autoNow ? "" : " style=\\"display:none\\"") +
719
+ ">Auto-advancing...</div>";
527
720
  panel.innerHTML = html;
528
721
  document.documentElement.appendChild(panel);
529
722
  requestAnimationFrame(() => panel.classList.add("wg-in"));
@@ -537,6 +730,26 @@ async function renderBeforeStep(page, info) {
537
730
  window.__wgNext(edits);
538
731
  });
539
732
  }
733
+ // Live toggle - flips localStorage immediately so an in-flight gate()
734
+ // poll (on the Node side) picks it up within its next poll slice,
735
+ // without needing this whole panel to re-render. A manual click
736
+ // still always wins over an active autoplay wait, whichever the
737
+ // checkbox says - this is the "hybrid" control: autoplay is a
738
+ // default, not a lockout.
739
+ const cb = document.getElementById("wg-autoplay-cb");
740
+ if (cb) {
741
+ cb.addEventListener("change", () => {
742
+ try {
743
+ localStorage.setItem("wg-autoplay", cb.checked ? "1" : "0");
744
+ } catch {
745
+ /* private mode / blocked storage - toggle still works this page */
746
+ }
747
+ const manual = document.getElementById("wg-gate-manual");
748
+ const auto = document.getElementById("wg-gate-auto");
749
+ if (manual) manual.style.display = cb.checked ? "none" : "";
750
+ if (auto) auto.style.display = cb.checked ? "" : "none";
751
+ });
752
+ }
540
753
  }, info)
541
754
  .catch(() => {});
542
755
  }
@@ -577,21 +790,14 @@ async function renderAfterStep(page, info) {
577
790
  const last = highlights[highlights.length - 1];
578
791
  await page
579
792
  .evaluate((h) => {
580
- const ring = document.getElementById("wg-ring");
581
- if (!ring) return;
793
+ if (!window.__wgPositionRing) return;
582
794
  const reposition = () => {
583
795
  const el = document.querySelector(h.selector);
584
796
  if (!el) {
585
- ring.style.opacity = "0";
797
+ if (window.__wgHideRing) window.__wgHideRing();
586
798
  return;
587
799
  }
588
- const box = el.getBoundingClientRect();
589
- ring.style.left = box.x - 6 + "px";
590
- ring.style.top = box.y - 6 + "px";
591
- ring.style.width = box.width + 12 + "px";
592
- ring.style.height = box.height + 12 + "px";
593
- ring.setAttribute("data-label", h.label);
594
- ring.style.opacity = "1";
800
+ window.__wgPositionRing(el.getBoundingClientRect(), h.label);
595
801
  };
596
802
  reposition();
597
803
  window.__wgRingTrack = reposition;
@@ -638,12 +844,27 @@ async function renderAfterStep(page, info) {
638
844
  prettyText + "</div>" +
639
845
  "<div class=\\"wg-result wg-result-json\\" style=\\"display:" + (pretty ? "none" : "block") + "\\">" +
640
846
  info.resultTag + "</div>";
641
- const gateHtml = info.autoplay
642
- ? "<div class=\\"wg-auto\\">Auto-advancing...</div>"
643
- : "<button id=\\"wg-run\\">" + buttonLabel + "</button>";
847
+ let autoNow = false;
848
+ try {
849
+ autoNow = localStorage.getItem("wg-autoplay") === "1";
850
+ } catch {
851
+ /* private mode / blocked storage - defaults to manual */
852
+ }
853
+ const gateHtml =
854
+ "<div class=\\"wg-autoplay-row\\"><label><input type=\\"checkbox\\" id=\\"wg-autoplay-cb\\"" +
855
+ (autoNow ? " checked" : "") +
856
+ "> Auto-advance</label></div>" +
857
+ "<div id=\\"wg-gate-manual\\"" + (autoNow ? " style=\\"display:none\\"" : "") +
858
+ "><button id=\\"wg-run\\">" + buttonLabel + "</button></div>" +
859
+ "<div id=\\"wg-gate-auto\\" class=\\"wg-auto\\"" + (autoNow ? "" : " style=\\"display:none\\"") +
860
+ ">Auto-advancing...</div>";
861
+ const episodeHtml = info.episodeNumber
862
+ ? "<div class=\\"wg-episode\\">Episode " + info.episodeNumber + ": " + escA(info.episodeTitle || "") + "</div>"
863
+ : "";
644
864
  panel.innerHTML =
645
865
  "<div id=\\"wg-progress\\"><div id=\\"wg-progress-bar\\" style=\\"width:" + pct + "%\\"></div></div>" +
646
866
  "<div id=\\"wg-modules\\">" + modulesHtml + "</div>" +
867
+ episodeHtml +
647
868
  "<h3>" + heading + "</h3>" +
648
869
  resultHtml +
649
870
  gateHtml;
@@ -661,6 +882,58 @@ async function renderAfterStep(page, info) {
661
882
  });
662
883
  const runBtn = document.getElementById("wg-run");
663
884
  if (runBtn) runBtn.addEventListener("click", () => window.__wgNext({}));
885
+ const cb = document.getElementById("wg-autoplay-cb");
886
+ if (cb) {
887
+ cb.addEventListener("change", () => {
888
+ try {
889
+ localStorage.setItem("wg-autoplay", cb.checked ? "1" : "0");
890
+ } catch {
891
+ /* private mode / blocked storage - toggle still works this page */
892
+ }
893
+ const manual = document.getElementById("wg-gate-manual");
894
+ const auto = document.getElementById("wg-gate-auto");
895
+ if (manual) manual.style.display = cb.checked ? "none" : "";
896
+ if (auto) auto.style.display = cb.checked ? "" : "none";
897
+ });
898
+ }
899
+ }, info)
900
+ .catch(() => {});
901
+ }
902
+
903
+ /**
904
+ * A Block threw - act()/observe() rejected, or Flow.run's own verify Trait
905
+ * check failed. Shown instead of letting it crash the whole Node process
906
+ * silently from a human's point of view (the browser closes right after
907
+ * regardless - main()'s own try/finally - but not before this is visible).
908
+ */
909
+ async function renderStepError(page, info) {
910
+ await installOverlay(page, info.title);
911
+ await page
912
+ .evaluate((info) => {
913
+ const old = document.getElementById("wg-panel");
914
+ if (old) old.remove();
915
+ const panel = document.createElement("div");
916
+ panel.id = "wg-panel";
917
+ panel.className = "wg-error";
918
+ const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
919
+ const modulesHtml = info.allNames
920
+ .map((name, idx) => {
921
+ const cls = idx === info.index ? "wg-mod-current" : idx < info.index ? "wg-mod-done" : "wg-mod-upcoming";
922
+ const desc = info.allDescriptions && info.allDescriptions[idx];
923
+ const titleAttr = desc ? " title=\\"" + esc(desc) + "\\"" : "";
924
+ return "<span class=\\"wg-mod " + cls + "\\"" + titleAttr + ">" + name + "</span>";
925
+ })
926
+ .join("");
927
+ panel.innerHTML =
928
+ "<div id=\\"wg-modules\\">" + modulesHtml + "</div>" +
929
+ "<h3 class=\\"wg-error-heading\\">Step " + (info.index + 1) + " / " + info.total + " - " +
930
+ info.blockName + " threw</h3>" +
931
+ "<div class=\\"wg-error-msg\\">" + esc(info.message) + "</div>" +
932
+ "<button id=\\"wg-run\\" class=\\"wg-error-stop\\">Stop</button>";
933
+ document.documentElement.appendChild(panel);
934
+ requestAnimationFrame(() => panel.classList.add("wg-in"));
935
+ const runBtn = document.getElementById("wg-run");
936
+ if (runBtn) runBtn.addEventListener("click", () => window.__wgNext({}));
664
937
  }, info)
665
938
  .catch(() => {});
666
939
  }
@@ -737,14 +1010,7 @@ async function showRing(page, box, label) {
737
1010
  await page
738
1011
  .evaluate(
739
1012
  ({ box, label }) => {
740
- const ring = document.getElementById("wg-ring");
741
- if (!ring) return;
742
- ring.style.left = box.x - 6 + "px";
743
- ring.style.top = box.y - 6 + "px";
744
- ring.style.width = box.width + 12 + "px";
745
- ring.style.height = box.height + 12 + "px";
746
- ring.setAttribute("data-label", label);
747
- ring.style.opacity = "1";
1013
+ if (window.__wgPositionRing) window.__wgPositionRing(box, label);
748
1014
  },
749
1015
  { box, label },
750
1016
  )
@@ -754,13 +1020,73 @@ async function showRing(page, box, label) {
754
1020
  async function hideRing(page) {
755
1021
  await page
756
1022
  .evaluate(() => {
757
- const ring = document.getElementById("wg-ring");
758
- if (ring) ring.style.opacity = "0";
1023
+ if (window.__wgHideRing) window.__wgHideRing();
1024
+ })
1025
+ .catch(() => {});
1026
+ }
1027
+
1028
+ /**
1029
+ * Removes every overlay element (panel, ring, ring-label, cursor,
1030
+ * click-pulse, banner) and stops the live resize/scroll ring tracker, once
1031
+ * the whole chain is genuinely done - "I want to see the same page just
1032
+ * like the demo opened for the first time," not the last step's panel and
1033
+ * highlight ring stuck over the real app forever. installOverlay only ever
1034
+ * ADDS these elements back on demand (idempotent, per-page-load) - nothing
1035
+ * re-creates them once torn down here unless another Block/fill/click runs.
1036
+ */
1037
+ async function teardownOverlay(page) {
1038
+ await page
1039
+ .evaluate(() => {
1040
+ if (window.__wgRingTrack) {
1041
+ window.removeEventListener("resize", window.__wgRingTrack);
1042
+ window.removeEventListener("scroll", window.__wgRingTrack, true);
1043
+ window.__wgRingTrack = null;
1044
+ }
1045
+ window.__wgNarrateOwnsRing = false;
1046
+ for (const id of ["wg-panel", "wg-ring", "wg-ring-label", "wg-cursor", "wg-click-pulse", "wg-banner"]) {
1047
+ const el = document.getElementById(id);
1048
+ if (el) el.remove();
1049
+ }
759
1050
  })
760
1051
  .catch(() => {});
761
1052
  }
762
1053
 
763
- function instrumentInteractionHighlighting(page, mem, slowMo) {
1054
+ async function moveCursorTo(page, box, ms) {
1055
+ const x = box.x + box.width / 2;
1056
+ const y = box.y + box.height / 2;
1057
+ await page
1058
+ .evaluate(({ x, y, ms }) => {
1059
+ if (window.__wgMoveCursorTo) window.__wgMoveCursorTo(x, y, ms);
1060
+ }, { x, y, ms })
1061
+ .catch(() => {});
1062
+ return { x, y };
1063
+ }
1064
+
1065
+ async function clickPulseAt(page, x, y) {
1066
+ await page
1067
+ .evaluate(({ x, y }) => {
1068
+ if (window.__wgClickPulse) window.__wgClickPulse(x, y);
1069
+ }, { x, y })
1070
+ .catch(() => {});
1071
+ }
1072
+
1073
+ /**
1074
+ * True if the engine's own narrate() (waygraph's public export, for a Block
1075
+ * author explicitly captioning one action) JUST positioned the ring for
1076
+ * THIS action, moments ago - the automatic per-fill/per-click narration
1077
+ * below should not immediately overwrite an author's own explicit caption
1078
+ * with its generic guess.
1079
+ */
1080
+ async function wasJustNarrated(page) {
1081
+ return page
1082
+ .evaluate(() => {
1083
+ const w = window;
1084
+ return typeof w.__wgLastNarrate === "number" && Date.now() - w.__wgLastNarrate < 500;
1085
+ })
1086
+ .catch(() => false);
1087
+ }
1088
+
1089
+ function instrumentInteractionHighlighting(page, mem, slowMo, pacing) {
764
1090
  // Playwright's own slowMo ALREADY pauses after every single low-level
765
1091
  // action it dispatches - and pressSequentially() fires one such action
766
1092
  // PER CHARACTER. Also giving pressSequentially its own fixed delay
@@ -768,13 +1094,20 @@ function instrumentInteractionHighlighting(page, mem, slowMo) {
768
1094
  // character) - an ordinary 22-character email alone stretched past 8
769
1095
  // seconds. When slowMo is already doing the pacing, add none of our own;
770
1096
  // only fall back to a small typing delay when slowMo is off entirely.
771
- const typeDelay = slowMo ? 0 : 30;
1097
+ //
1098
+ // Each is a function, not a plain const, and reads pacing.fast fresh
1099
+ // on every call - pacing is a shared mutable object runStepMode flips
1100
+ // per-block (WAYGRAPH_FAST_BLOCKS), and the Locator.fill/click patches
1101
+ // below are installed once on the shared prototype but invoked once per
1102
+ // real interaction, long after this closure was created.
1103
+ const typeDelay = () => (pacing.fast ? 0 : slowMo ? 0 : 30);
772
1104
  // Click is a single action, not per-character, so it doesn't compound
773
1105
  // the same way - but slowMo still adds its own pause around the actual
774
1106
  // click, so trim our own explicit "pop" pauses when it's already active
775
1107
  // rather than stacking a full 1.2s on top of that.
776
- const clickPrePop = slowMo ? 300 : 700;
777
- const clickPostPop = slowMo ? 200 : 500;
1108
+ const clickPrePop = () => (pacing.fast ? 0 : slowMo ? 300 : 700);
1109
+ const clickPostPop = () => (pacing.fast ? 0 : slowMo ? 200 : 500);
1110
+ const cursorMs = (full) => (pacing.fast ? Math.min(120, full) : full);
778
1111
  // Locator.fill()/click() only ever see a raw call, no context of where
779
1112
  // the value came from. Patching mem.get() to remember the most recently
780
1113
  // read key's name (Blocks read-then-immediately-fill, e.g. const { email
@@ -799,15 +1132,20 @@ function instrumentInteractionHighlighting(page, mem, slowMo) {
799
1132
  try {
800
1133
  await installOverlay(page);
801
1134
  const box = await this.boundingBox();
802
- if (box) {
1135
+ if (box && !(await wasJustNarrated(page))) {
803
1136
  // Only trust the "last mem.get()" as THIS fill's source if it
804
1137
  // happened recently - a stale read from several actions ago is
805
1138
  // more likely unrelated than actually describing this field.
806
1139
  const label = memTrack.lastKeyName && Date.now() - memTrack.at < 3000
807
1140
  ? "from mem: " + memTrack.lastKeyName
808
1141
  : "writing from mem";
1142
+ await moveCursorTo(page, box, cursorMs(500));
809
1143
  await showRing(page, box, label);
810
- await new Promise((res) => setTimeout(res, 200));
1144
+ await new Promise((res) => setTimeout(res, pacing.fast ? 0 : 200));
1145
+ } else if (box) {
1146
+ // Ring/caption already handled by narrate() - still move the
1147
+ // cursor there, just skip re-showing the ring with a generic label.
1148
+ await moveCursorTo(page, box, cursorMs(200));
811
1149
  }
812
1150
  } catch {
813
1151
  // best-effort - element not visible/attached yet is not this
@@ -820,7 +1158,7 @@ function instrumentInteractionHighlighting(page, mem, slowMo) {
820
1158
  // that call would resolve back to THIS same patched function and
821
1159
  // recurse forever. Call the real original fill directly instead.
822
1160
  await originalFill.call(this, "", { timeout: options && options.timeout });
823
- result = await this.pressSequentially(String(value), { delay: typeDelay, timeout: options && options.timeout });
1161
+ result = await this.pressSequentially(String(value), { delay: typeDelay(), timeout: options && options.timeout });
824
1162
  } catch {
825
1163
  // pressSequentially unsupported on this element (e.g. a
826
1164
  // contenteditable div, or a locator .fill() genuinely needs to
@@ -839,10 +1177,12 @@ function instrumentInteractionHighlighting(page, mem, slowMo) {
839
1177
  proto.__wgClickPatched = true;
840
1178
  const originalClick = proto.click;
841
1179
  proto.click = async function (options) {
1180
+ let clickPoint = null;
842
1181
  try {
843
1182
  await installOverlay(page);
844
1183
  const box = await this.boundingBox();
845
- if (box) {
1184
+ const narrated = box ? await wasJustNarrated(page) : false;
1185
+ if (box && !narrated) {
846
1186
  let label = "click";
847
1187
  try {
848
1188
  const text = (await this.textContent())?.trim();
@@ -850,17 +1190,23 @@ function instrumentInteractionHighlighting(page, mem, slowMo) {
850
1190
  } catch {
851
1191
  // element has no simple text (an icon button, say) - generic label is fine
852
1192
  }
1193
+ clickPoint = await moveCursorTo(page, box, cursorMs(600));
853
1194
  await showRing(page, box, label);
854
1195
  // "pop for a few seconds" - Dan's own phrase, matching the
855
1196
  // zsign demo-engine's ring-before-click pattern in
856
1197
  // services/help-center-clip-engine's video-pipeline.
857
- await new Promise((res) => setTimeout(res, clickPrePop));
1198
+ await new Promise((res) => setTimeout(res, clickPrePop()));
1199
+ } else if (box) {
1200
+ // Ring/caption already handled by narrate() - still move the
1201
+ // cursor + pulse the click point, just skip re-showing the ring.
1202
+ clickPoint = await moveCursorTo(page, box, cursorMs(200));
858
1203
  }
859
1204
  } catch {
860
1205
  // best-effort - the real click below still runs either way
861
1206
  }
1207
+ if (clickPoint) await clickPulseAt(page, clickPoint.x, clickPoint.y);
862
1208
  const result = await originalClick.call(this, options);
863
- await new Promise((res) => setTimeout(res, clickPostPop));
1209
+ await new Promise((res) => setTimeout(res, clickPostPop()));
864
1210
  await hideRing(page);
865
1211
  return result;
866
1212
  };
@@ -877,19 +1223,42 @@ function instrumentInteractionHighlighting(page, mem, slowMo) {
877
1223
  // helpers some Blocks use for their own pacing are NOT Playwright calls
878
1224
  // at all and can't be touched this way - a real, disclosed limit, not
879
1225
  // silently ignored.
880
- // 3000ms was the first, deliberately conservative pass (this codebase
881
- // has documented real flakiness around slow-but-legitimate hydration
882
- // waits, so a too-aggressive cap risks trading "one annoying dead wait"
883
- // for "logins that sometimes fail outright"). Verified empirically
884
- // (3 consecutive real login runs, no failures) that those legitimate
885
- // waits actually resolve in well under a second in practice - the cap
886
- // was never close to touching them - so it's safe to tighten further.
887
- const WAIT_CAP_MS = 1500;
1226
+ // Pushed down to 700ms earlier this session based on login.block.ts
1227
+ // alone - every waitFor() there is a "give up gracefully" pattern
1228
+ // (wrapped in .catch()/Promise.race, a timeout IS the expected negative
1229
+ // result). That's a different kind of wait than a REQUIRED
1230
+ // synchronization point with no catch - e.g. overview-metrics.block.ts
1231
+ // waits (uncaught) for a "Loading pending actions..." placeholder to
1232
+ // actually appear before it's safe to check for an error, a real
1233
+ // hydration signal the block's own comment calls "deterministic,"
1234
+ // not a probe. A flat 700ms cap broke that block outright on this
1235
+ // shared, loaded box (a real 3-block chain run threw instead of
1236
+ // catching). Bumping the SAME flat cap to 4000ms to cover it just
1237
+ // reintroduced most of the original torture on login.block.ts's own
1238
+ // 5000ms unverified-email check - 4000ms is barely better than the real
1239
+ // 5000ms it's capping.
1240
+ //
1241
+ // A single flat number can't serve both: it has no way to tell "safe to
1242
+ // cut short" from "must actually happen" apart from the outside. But
1243
+ // each call site's OWN declared timeout is already a real signal of
1244
+ // which kind it is - a Block author who wrote timeout: 5000 for a quick
1245
+ // conditional check and one who wrote timeout: 15000 for a real
1246
+ // hydration wait weren't picking the same number by accident. Scale the
1247
+ // cap off that instead of a single constant: 30% of whatever was
1248
+ // declared, never below a 700ms floor (the value already proven safe
1249
+ // for the fast/common case), and never above what was declared in the
1250
+ // first place. login's 5000ms check -> 1500ms (still 3.5s faster than
1251
+ // uncapped). overview-metrics' 15000ms wait -> 4500ms (more headroom
1252
+ // than the flat 4000ms fix, not less).
1253
+ const WAIT_CAP_FLOOR_MS = 700;
1254
+ const WAIT_CAP_FRACTION = 0.3;
1255
+ const scaledWaitCap = (declared) => Math.max(WAIT_CAP_FLOOR_MS, declared * WAIT_CAP_FRACTION);
888
1256
  if (!proto.__wgWaitForPatched) {
889
1257
  proto.__wgWaitForPatched = true;
890
1258
  const originalWaitFor = proto.waitFor;
891
1259
  proto.waitFor = function (options) {
892
- const capped = { ...(options || {}), timeout: Math.min((options && options.timeout) || 30000, WAIT_CAP_MS) };
1260
+ const declared = (options && options.timeout) || 30000;
1261
+ const capped = { ...(options || {}), timeout: Math.min(declared, scaledWaitCap(declared)) };
893
1262
  return originalWaitFor.call(this, capped);
894
1263
  };
895
1264
  }
@@ -898,13 +1267,23 @@ function instrumentInteractionHighlighting(page, mem, slowMo) {
898
1267
  pageProto.__wgWaitForTimeoutPatched = true;
899
1268
  const originalWaitForTimeout = pageProto.waitForTimeout;
900
1269
  pageProto.waitForTimeout = function (ms) {
901
- return originalWaitForTimeout.call(this, Math.min(ms, WAIT_CAP_MS));
1270
+ // A plain sleep (not a "wait for condition X") never has a hidden
1271
+ // "must actually happen" requirement attached - always safe to cut
1272
+ // to the floor, no scaling needed.
1273
+ return originalWaitForTimeout.call(this, Math.min(ms, WAIT_CAP_FLOOR_MS));
902
1274
  };
903
1275
  }
904
1276
  }
905
1277
 
906
- async function runStepMode(engine, start, end, context, page, mem, resolved, slowMo, title) {
907
- instrumentInteractionHighlighting(page, mem, slowMo);
1278
+ async function runStepMode(engine, start, end, context, page, mem, resolved, slowMo, title, fastBlockNames, clearSession, baseURL) {
1279
+ // A shared, mutable pacing knob the interaction patches read live (per
1280
+ // call, not once at setup) - flipped per-block below so one block (e.g.
1281
+ // WAYGRAPH_FAST_BLOCKS=login) can run through with none of the overlay's
1282
+ // own added dwell while the rest of the chain keeps the full theatrical
1283
+ // pace. Playwright's own slowMo is process-wide and untouched by this -
1284
+ // only OUR added pauses (ring pop, cursor travel, typing delay) shrink.
1285
+ const pacing = { fast: false };
1286
+ instrumentInteractionHighlighting(page, mem, slowMo, pacing);
908
1287
  // Some real Blocks (e.g. zsign-all's login.block.ts) call
909
1288
  // page.setViewportSize({ width: 1280, height: 720 }) inside their own
910
1289
  // act() - a hardcoded override for THEIR OWN testing consistency, with no
@@ -926,18 +1305,92 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
926
1305
  }
927
1306
  });
928
1307
  const waitForNext = () => new Promise((res) => (resolveNext = res));
929
- // WAYGRAPH_AUTOPLAY=1 - hands-off, matching the existing
930
- // scripts/waygraph-demo.mjs pattern (plain slowMo, no clicks, just watch)
931
- // but keeping this overlay's progress bar/module breadcrumb/highlight.
932
- const autoplay = process.env.WAYGRAPH_AUTOPLAY === "1";
1308
+ // WAYGRAPH_AUTOPLAY=1 only sets the STARTING checkbox state now - the
1309
+ // panel's own "Auto-advance" checkbox can flip it live, mid-run, and a
1310
+ // manual click always wins over an in-flight autoplay wait regardless of
1311
+ // which way the checkbox is set. That's the hybrid Dan asked for: some
1312
+ // steps auto-advance, some get a manual click, toggled as the demo goes,
1313
+ // not fixed for the whole run from a single env var.
933
1314
  const autoplayMs = process.env.WAYGRAPH_AUTOPLAY_MS ? Number(process.env.WAYGRAPH_AUTOPLAY_MS) : 1800;
934
- const gate = () => (autoplay ? new Promise((res) => setTimeout(() => res({}), autoplayMs)) : waitForNext());
1315
+ const currentAutoplay = () =>
1316
+ page
1317
+ .evaluate(() => {
1318
+ try {
1319
+ return localStorage.getItem("wg-autoplay") === "1";
1320
+ } catch {
1321
+ return false;
1322
+ }
1323
+ })
1324
+ .catch(() => false);
1325
+ const gate = async () => {
1326
+ const next = waitForNext();
1327
+ // WAYGRAPH_FAST_BLOCKS names a block that should blow past its own
1328
+ // gates too, not just skip its interaction dwell - otherwise autoplay
1329
+ // still stalls the full autoplayMs admiring a step that intentionally
1330
+ // ran too fast to watch.
1331
+ const ms = pacing.fast ? Math.min(400, autoplayMs) : autoplayMs;
1332
+ let elapsed = 0;
1333
+ for (;;) {
1334
+ // Re-read the checkbox EVERY loop tick, not once up front - a human
1335
+ // starting a step in manual mode and then checking "Auto-advance"
1336
+ // mid-wait must actually start counting down from that moment, not
1337
+ // get stuck on whatever mode was live when gate() was first called.
1338
+ const auto = await currentAutoplay();
1339
+ const slice = auto
1340
+ ? Math.min(250, Math.max(50, ms - elapsed))
1341
+ : 250; // manual: just a cheap poll tick, waiting on either a click or the box getting checked
1342
+ const winner = await Promise.race([
1343
+ next.then((edits) => ({ clicked: true, edits })),
1344
+ new Promise((res) => setTimeout(() => res({ clicked: false }), slice)),
1345
+ ]);
1346
+ if (winner.clicked) return winner.edits;
1347
+ if (!auto) continue;
1348
+ elapsed += slice;
1349
+ if (elapsed >= ms) return {};
1350
+ }
1351
+ };
935
1352
  const allNames = resolved.map((r) => r.block.name);
936
1353
  const allDescriptions = resolved.map((r) => r.block.description || "");
937
1354
 
938
1355
  let result;
939
1356
  for (let i = 0; i < resolved.length; i++) {
940
1357
  const r = resolved[i];
1358
+ // Reset session state before this block if EITHER: the blanket
1359
+ // WAYGRAPH_CLEAR_SESSION=1 override is set (a manual, always-on-after-
1360
+ // the-first-block stopgap for ad hoc block chains with no real Flow
1361
+ // object), OR this specific block is a Flow's own declared entry point
1362
+ // (defineFlow(...) wrapped in withSessionReset) reached via a
1363
+ // multi-flow chain spec - the proper, opt-in-per-Flow mechanism. Never
1364
+ // resets before the very first block - there's no prior state yet.
1365
+ //
1366
+ // Done HERE, before this step's own "before" panel is even shown - not
1367
+ // after the human clicks "Run this step" - so the panel for the new
1368
+ // episode appears over an already-fresh page, not the PREVIOUS
1369
+ // episode's stale final page (e.g. still showing /dashboard) with the
1370
+ // new episode's panel merely floating on top of it ("overlapping",
1371
+ // Dan's own word for this). The explicit re-navigate (not just
1372
+ // clearing cookies/storage) is what actually makes the page itself
1373
+ // look fresh again - clearing storage alone doesn't change what's
1374
+ // still rendered on screen.
1375
+ if ((clearSession || r.resetSession) && i > 0) {
1376
+ await context.clearCookies().catch(() => {});
1377
+ await page
1378
+ .evaluate(() => {
1379
+ try {
1380
+ localStorage.clear();
1381
+ sessionStorage.clear();
1382
+ } catch {
1383
+ /* storage blocked (e.g. about:blank) - nothing to clear anyway */
1384
+ }
1385
+ })
1386
+ .catch(() => {});
1387
+ if (baseURL) {
1388
+ await page.goto(baseURL).catch(() => {});
1389
+ } else {
1390
+ await page.goto("about:blank").catch(() => {});
1391
+ }
1392
+ }
1393
+ pacing.fast = fastBlockNames.has(r.block.name);
941
1394
  const requires = r.block.requires ?? [];
942
1395
  const keys = requires.map((k) => {
943
1396
  let value = "<not yet set>";
@@ -956,8 +1409,9 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
956
1409
  keys,
957
1410
  allNames,
958
1411
  allDescriptions,
959
- autoplay,
960
1412
  title,
1413
+ episodeNumber: r.episodeNumber,
1414
+ episodeTitle: r.episodeTitle,
961
1415
  });
962
1416
  const edits = await gate();
963
1417
  for (const k of requires) {
@@ -974,7 +1428,26 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
974
1428
  // the plain checkpoint - destructure it, don't treat the wrapper as the
975
1429
  // checkpoint itself (caught via the standalone verify script: this used
976
1430
  // to serialize the whole { result, page } object into the panel/log).
977
- const stepOutcome = await stepFlow.run(context, mem, { page, closeOnFinish: false });
1431
+ let stepOutcome;
1432
+ try {
1433
+ stepOutcome = await stepFlow.run(context, mem, { page, closeOnFinish: false });
1434
+ } catch (err) {
1435
+ // A thrown act()/observe()/a failed verify Trait used to just crash
1436
+ // the whole Node process with a raw stack trace - the browser closes
1437
+ // (main()'s own try/finally) before a human watching ever sees WHY.
1438
+ // Show it on-screen and let a real click acknowledge it first.
1439
+ await renderStepError(page, {
1440
+ index: i,
1441
+ total: resolved.length,
1442
+ blockName: r.block.name,
1443
+ message: err && err.message ? err.message : String(err),
1444
+ allNames,
1445
+ allDescriptions,
1446
+ title,
1447
+ });
1448
+ await gate();
1449
+ throw err;
1450
+ }
978
1451
  result = stepOutcome.result;
979
1452
  const highlights = extractVerifyHighlights(r.block, result.__state);
980
1453
  await renderAfterStep(page, {
@@ -987,18 +1460,20 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
987
1460
  isLast: i === resolved.length - 1,
988
1461
  allNames,
989
1462
  allDescriptions,
990
- autoplay,
991
1463
  title,
1464
+ episodeNumber: r.episodeNumber,
1465
+ episodeTitle: r.episodeTitle,
992
1466
  });
993
1467
  await gate();
994
1468
  }
1469
+ await teardownOverlay(page);
995
1470
  return result;
996
1471
  }
997
1472
 
998
1473
  async function main() {
999
1474
  const projectDir = process.argv[2];
1000
1475
  const spec = process.argv[3];
1001
- const { connect, MemPage, Engine, start, end } = await import("waygraph");
1476
+ const { connect, MemPage, Engine, start, end, chainFlow } = await import("waygraph");
1002
1477
  const mem = new MemPage();
1003
1478
  let resolved = [];
1004
1479
  // A bare identifier (no "(", no "then") might name an existing Flow
@@ -1022,11 +1497,58 @@ async function main() {
1022
1497
  if (segments.length === 0) {
1023
1498
  throw new Error("waygraph chain: empty spec - give at least one block name");
1024
1499
  }
1500
+ // Each "then"-separated segment can ALSO name an existing Flow (a
1501
+ // Flow's own requires, unioned across its Blocks, take the same
1502
+ // JSON-payload shape a single Block's requires would), not just a
1503
+ // Block. "loginFlow then dashboardFlow" chains two whole Flows one
1504
+ // after another - a bare Block segment gets wrapped as its own
1505
+ // trivial one-Block Flow so the whole spec reduces to one thing:
1506
+ // chainFlow(...) does the actual sequencing (session-reset boundaries
1507
+ // included) that used to be hand-rolled here - this is a thin wrapper
1508
+ // over the real engine primitive, not a second copy of its logic.
1509
+ const wrapEngine = new Engine();
1510
+ const flows = [];
1511
+ // Parallel to flows - only a REAL named Flow (found via findFlow)
1512
+ // counts as an "episode" a human would want labeled; a bare Block
1513
+ // segment gets wrapped as its own trivial one-Block Flow so it can
1514
+ // still flow through chainFlow's sequencing uniformly, but it's not
1515
+ // an authored scenario and gets no episode number - keeps the plain
1516
+ // "quick ad hoc block chain" case free of meaningless "Episode 1"/
1517
+ // "Episode 2" labels on things that were never episodes to begin with.
1518
+ const flowMeta = [];
1519
+ let episodeCounter = 0;
1025
1520
  for (const seg of segments) {
1026
- const r = await findBlock(projectDir, seg.ref);
1027
- seedMemForBlock(mem, r, seg.json);
1028
- resolved.push(r);
1521
+ const flow = await findFlow(projectDir, seg.ref);
1522
+ if (flow && typeof flow.blocks === "function") {
1523
+ seedMemForFlow(mem, flow.blocks(), seg.json, seg.ref);
1524
+ flows.push(flow);
1525
+ episodeCounter += 1;
1526
+ flowMeta.push({ episodeNumber: episodeCounter, episodeTitle: flow.title || seg.ref });
1527
+ } else {
1528
+ const r = await findBlock(projectDir, seg.ref);
1529
+ seedMemForBlock(mem, r, seg.json);
1530
+ flows.push(wrapEngine.defineFlow([start, r.block, end]));
1531
+ flowMeta.push(null);
1532
+ }
1029
1533
  }
1534
+ const combinedBlocks = chainFlow(...flows).blocks();
1535
+ let fi = 0;
1536
+ let remainingInFlow = flows[0].blocks().length;
1537
+ resolved = combinedBlocks.map((bi) => {
1538
+ while (remainingInFlow === 0) {
1539
+ fi += 1;
1540
+ remainingInFlow = flows[fi].blocks().length;
1541
+ }
1542
+ remainingInFlow -= 1;
1543
+ const meta = flowMeta[fi];
1544
+ return {
1545
+ block: bi.block,
1546
+ exportName: bi.name,
1547
+ resetSession: bi.resetSessionBefore === true,
1548
+ episodeNumber: meta ? meta.episodeNumber : undefined,
1549
+ episodeTitle: meta ? meta.episodeTitle : undefined,
1550
+ };
1551
+ });
1030
1552
  console.log(
1031
1553
  "waygraph: chaining " + resolved.map((r) => r.block.name).join(" -> ") +
1032
1554
  " (" + resolved.length + " block" + (resolved.length === 1 ? "" : "s") + ")",
@@ -1045,6 +1567,23 @@ async function main() {
1045
1567
  : undefined;
1046
1568
  const baseURL = process.env.WAYGRAPH_BASE_URL;
1047
1569
  const title = process.env.WAYGRAPH_TITLE;
1570
+ // Comma-separated Block names (their real .name, e.g. "login") that
1571
+ // should blow past the overlay's own added dwell (ring pop, cursor
1572
+ // travel, typing delay, and that block's own gates) - "I want the login
1573
+ // block to be faster than the rest of the demo," Dan's own phrase.
1574
+ // Playwright's real slowMo is untouched; this only trims what waygraph
1575
+ // itself adds on top of it.
1576
+ const fastBlockNames = new Set(
1577
+ (process.env.WAYGRAPH_FAST_BLOCKS || "")
1578
+ .split(",")
1579
+ .map((s) => s.trim())
1580
+ .filter(Boolean),
1581
+ );
1582
+ // Off by default - a same-user chain (login -> dashboard -> ...) needs
1583
+ // to STAY authenticated across its own blocks. Only a chain deliberately
1584
+ // re-visiting an auth entry point (e.g. "login" appearing twice, to
1585
+ // demo the same flow starting fresh each time) needs this.
1586
+ const clearSession = process.env.WAYGRAPH_CLEAR_SESSION === "1";
1048
1587
  const engine = new Engine({ headless: !headed, slowMo });
1049
1588
  let result;
1050
1589
  if (step || baseURL) {
@@ -1076,7 +1615,7 @@ async function main() {
1076
1615
  if (baseURL) {
1077
1616
  await page.goto(baseURL).catch(() => {});
1078
1617
  }
1079
- result = await runStepMode(engine, start, end, context, page, mem, resolved, slowMo, title);
1618
+ result = await runStepMode(engine, start, end, context, page, mem, resolved, slowMo, title, fastBlockNames, clearSession, baseURL);
1080
1619
  } else {
1081
1620
  const blocks = resolved.map((r) => r.block);
1082
1621
  const chained = blocks.reduce((a, b) => connect(a, b));
@@ -1211,9 +1750,30 @@ Usage:
1211
1750
  just confirmed, then "Next" before moving on.
1212
1751
  Implies headed - stepping through headless defeats
1213
1752
  the point.
1214
- WAYGRAPH_AUTOPLAY=1 step mode only - hands-off, advances on a
1215
- timer instead of waiting for clicks
1753
+ WAYGRAPH_AUTOPLAY=1 step mode only - starting state for the panel's
1754
+ own "Auto-advance" checkbox (advances on a timer
1755
+ instead of waiting for clicks). The checkbox can
1756
+ be flipped live mid-run - some steps auto, some
1757
+ manual - and a manual click always wins over an
1758
+ in-flight autoplay wait either way.
1216
1759
  WAYGRAPH_AUTOPLAY_MS=ms delay between auto-advances (default 1800)
1760
+ WAYGRAPH_FAST_BLOCKS=name1,name2
1761
+ step mode only - these Blocks' own .name (e.g.
1762
+ "login") blow past the overlay's added dwell -
1763
+ ring pop, cursor travel, typing delay, and that
1764
+ block's own gates - while the rest of the chain
1765
+ keeps the full pace. Playwright's own slowMo is
1766
+ untouched.
1767
+ WAYGRAPH_CLEAR_SESSION=1
1768
+ step mode only - clears cookies + storage before
1769
+ every block AFTER the first. Off by default - a
1770
+ normal chain (login -> dashboard) needs to STAY
1771
+ authenticated across its own blocks. Only turn
1772
+ this on for a chain that deliberately re-visits
1773
+ an auth entry point as a fresh visitor each time
1774
+ (e.g. "login" appearing more than once) - without
1775
+ it the second visit is still authenticated, so
1776
+ the app redirects away before the form renders.
1217
1777
  WAYGRAPH_TITLE="..." step mode only - a persistent top banner
1218
1778
  naming what this whole run is about (default
1219
1779
  top-left; click the banner to cycle left /