waygraph 0.12.42 → 0.12.44

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
@@ -412,10 +412,14 @@ function seedMemForFlow(mem, flowBlocks, json, label) {
412
412
  const RING_CSS =
413
413
  "#wg-ring{position:fixed;z-index:2147483646;pointer-events:none;opacity:0;" +
414
414
  "border:2.5px solid #7C3AED;border-radius:10px;" +
415
- "box-shadow:0 0 0 4px rgba(124,58,237,.16);transition:opacity .3s ease,border-color .15s,box-shadow .15s;}" +
415
+ // Opacity fade only - NEVER transition border-color/box-shadow. Tone swaps
416
+ // (auto gray -> planned purple -> warning yellow) must snap instantly;
417
+ // color transitions read as a muddy gray/purple/yellow morph on ring 2+.
418
+ "box-shadow:0 0 0 4px rgba(124,58,237,.16);transition:opacity .3s ease;}" +
416
419
  // Planned (stubs / fixtures / YAP / instruction.highlights) = purple.
417
420
  // Automation (verify fallback, unmatched fill/click) = gray - operators
418
421
  // can ignore engine checks and watch purple + semantic tones.
422
+ "#wg-ring[data-tone=planned]{border-color:#7C3AED;box-shadow:0 0 0 4px rgba(124,58,237,.16);}" +
419
423
  "#wg-ring[data-tone=auto]{border-color:#9CA3AF;box-shadow:0 0 0 4px rgba(156,163,175,.28);}" +
420
424
  "#wg-ring[data-tone=info]{border-color:#3B82F6;box-shadow:0 0 0 4px rgba(59,130,246,.22);}" +
421
425
  "#wg-ring[data-tone=warning]{border-color:#EAB308;box-shadow:0 0 0 4px rgba(234,179,8,.22);}" +
@@ -431,7 +435,8 @@ const RING_CSS =
431
435
  // window.__wgPositionRing in installOverlay.
432
436
  "#wg-ring-label{position:fixed;z-index:2147483646;pointer-events:none;opacity:0;" +
433
437
  "max-width:min(360px,70vw);white-space:normal;padding:6px 10px;border-radius:7px;background:#7C3AED;color:#fff;" +
434
- "font:600 12px/1.35 system-ui,sans-serif;transition:opacity .3s ease,background .15s,color .15s;}" +
438
+ "font:600 12px/1.35 system-ui,sans-serif;transition:opacity .3s ease;}" +
439
+ "#wg-ring-label[data-tone=planned]{background:#7C3AED;color:#fff;}" +
435
440
  "#wg-ring-label[data-tone=auto]{background:#6B7280;color:#fff;}" +
436
441
  "#wg-ring-label[data-tone=info]{background:#2563EB;color:#fff;}" +
437
442
  "#wg-ring-label[data-tone=warning]{background:#EAB308;color:#1c1917;}" +
@@ -509,6 +514,18 @@ const RING_CSS =
509
514
  "#wg-panel .wg-pace[data-pace-kind=blitz] .wg-pace-badge{background:#22C55E;color:#052e16;}" +
510
515
  "#wg-panel .wg-pace[data-pace-kind=ms] .wg-pace-badge{background:#3B82F6;color:#fff;}" +
511
516
  "#wg-panel .wg-narration{margin:0 0 12px;font:italic 14px/1.4 system-ui,sans-serif;color:#f0e8ff;}" +
517
+ // Todo checklist lives in #wg-todo-dock (fixed, outside #wg-panel) so
518
+ // --mini / Hide collapse never hides it (.wg-body {display:none} would).
519
+ // Click dock to slide left <-> right (WAYGRAPH_TODO_POS / --todo-left|right).
520
+ "#wg-todo-dock{position:fixed;z-index:2147483646;top:72px;left:14px;" +
521
+ "width:min(280px,42vw);max-height:calc(100vh - 100px);overflow:auto;box-sizing:border-box;" +
522
+ "padding:10px 12px;background:rgba(20,10,40,.94);color:#fff;border-radius:12px;" +
523
+ "border:1px solid rgba(124,58,237,.45);box-shadow:0 8px 24px rgba(0,0,0,.35);" +
524
+ "pointer-events:auto;cursor:pointer;" +
525
+ "transition:transform .4s cubic-bezier(.22,1,.36,1);transform:translateX(0);}" +
526
+ "#wg-todo-dock[data-pos=right]{transform:translateX(calc(100vw - 100% - 28px));}" +
527
+ "#wg-todo-dock[data-pos=left]{transform:translateX(0);}" +
528
+ "#wg-todo-dock #wg-todos{list-style:none;margin:0;padding:0;background:transparent;border:none;}" +
512
529
  "#wg-todos{list-style:none;margin:0 0 12px;padding:8px 10px;background:#0f0620;border-radius:8px;" +
513
530
  "border:1px solid #3a2a60;}" +
514
531
  "#wg-todos li{display:flex;gap:8px;align-items:flex-start;margin:0 0 6px;font:600 12.5px/1.35 system-ui,sans-serif;}" +
@@ -647,10 +664,12 @@ async function installOverlay(page, title) {
647
664
  // navigation that rebuilds the banner keeps the human's last pick).
648
665
  const envPos = (process.env.WAYGRAPH_TITLE_POS || "left").toLowerCase();
649
666
  const bannerPos = envPos === "center" || envPos === "right" ? envPos : "left";
667
+ const envTodoPos = (process.env.WAYGRAPH_TODO_POS || "left").toLowerCase();
668
+ const todoPos = envTodoPos === "right" ? "right" : "left";
650
669
  const envAutoplay = process.env.WAYGRAPH_AUTOPLAY === "1";
651
670
  await page
652
671
  .evaluate(
653
- ({ title, favicon, bannerPos, envAutoplay }) => {
672
+ ({ title, favicon, bannerPos, todoPos, envAutoplay }) => {
654
673
  // Seed the live autoplay toggle from the env default on first ever
655
674
  // load only - a real navigation re-runs this, and re-stamping here
656
675
  // would silently undo a human's mid-run checkbox click.
@@ -818,32 +837,46 @@ async function installOverlay(page, title) {
818
837
  /* private mode / blocked storage - position still applies this page */
819
838
  }
820
839
  };
821
- if (title && !document.getElementById("wg-banner")) {
822
- const banner = document.createElement("div");
823
- banner.id = "wg-banner";
824
- let saved = null;
825
- try {
826
- saved = localStorage.getItem("wg-banner-pos");
827
- } catch {
828
- /* ignore */
840
+ if (title) {
841
+ let banner = document.getElementById("wg-banner");
842
+ if (!banner) {
843
+ banner = document.createElement("div");
844
+ banner.id = "wg-banner";
845
+ let saved = null;
846
+ try {
847
+ saved = localStorage.getItem("wg-banner-pos");
848
+ } catch {
849
+ /* ignore */
850
+ }
851
+ const startPos =
852
+ saved && POSITIONS.includes(saved) ? saved : bannerPos;
853
+ applyPos(banner, startPos);
854
+ banner.title = "Click to move: top left / center / right";
855
+ banner.addEventListener("click", (e) => {
856
+ e.stopPropagation();
857
+ const i = POSITIONS.indexOf(banner.dataset.pos || "left");
858
+ applyPos(banner, POSITIONS[(i + 1) % POSITIONS.length]);
859
+ });
860
+ const tag = document.createElement("span");
861
+ tag.className = "wg-banner-tag";
862
+ tag.textContent = "waygraph demo";
863
+ const text = document.createElement("span");
864
+ text.className = "wg-banner-text";
865
+ text.textContent = title;
866
+ banner.appendChild(tag);
867
+ banner.appendChild(text);
868
+ document.documentElement.appendChild(banner);
869
+ } else {
870
+ // Fixture / episode title changes every step - update in place
871
+ // (banner is created once; do not leave the first step's text stuck).
872
+ let text = banner.querySelector(".wg-banner-text");
873
+ if (!text) {
874
+ text = document.createElement("span");
875
+ text.className = "wg-banner-text";
876
+ banner.appendChild(text);
877
+ }
878
+ text.textContent = title;
829
879
  }
830
- const startPos =
831
- saved && POSITIONS.includes(saved) ? saved : bannerPos;
832
- applyPos(banner, startPos);
833
- banner.title = "Click to move: top left / center / right";
834
- banner.addEventListener("click", (e) => {
835
- e.stopPropagation();
836
- const i = POSITIONS.indexOf(banner.dataset.pos || "left");
837
- applyPos(banner, POSITIONS[(i + 1) % POSITIONS.length]);
838
- });
839
- const tag = document.createElement("span");
840
- tag.className = "wg-banner-tag";
841
- tag.textContent = "waygraph demo";
842
- const text = document.createElement("span");
843
- text.textContent = title;
844
- banner.appendChild(tag);
845
- banner.appendChild(text);
846
- document.documentElement.appendChild(banner);
847
880
  }
848
881
  // Tab title/favicon: a real navigation resets document.title and any
849
882
  // <link rel="icon"> the new document brings, so re-check (not
@@ -858,6 +891,47 @@ async function installOverlay(page, title) {
858
891
  document.head.appendChild(iconLink);
859
892
  }
860
893
  if (iconLink.href !== favicon) iconLink.href = favicon;
894
+ // Blind-agent overlay beacons: every modal root gets data-wg-ui /
895
+ // data-wg-modal / data-wg-ready so tests can fail on a blank panel.
896
+ window.__wgStampModal = (el, kind, meta) => {
897
+ if (!el) return;
898
+ meta = meta || {};
899
+ el.setAttribute("data-wg-ui", "1");
900
+ el.setAttribute("data-wg-modal", kind || "panel");
901
+ if (meta.phase != null) el.setAttribute("data-wg-phase", String(meta.phase));
902
+ if (meta.step != null) el.setAttribute("data-wg-step", String(meta.step));
903
+ if (meta.total != null) el.setAttribute("data-wg-total", String(meta.total));
904
+ if (meta.block != null) el.setAttribute("data-wg-block", String(meta.block));
905
+ el.setAttribute(
906
+ "data-wg-collapsed",
907
+ el.classList.contains("wg-collapsed") ? "1" : "0",
908
+ );
909
+ el.setAttribute("data-wg-ready", meta.ready === false ? "0" : "1");
910
+ };
911
+ window.__wgOverlayBeacon = () => {
912
+ return Array.from(document.querySelectorAll("[data-wg-ui=\\"1\\"]")).map((el) => {
913
+ const r = el.getBoundingClientRect();
914
+ const st = getComputedStyle(el);
915
+ return {
916
+ id: el.id || null,
917
+ modal: el.getAttribute("data-wg-modal"),
918
+ ready: el.getAttribute("data-wg-ready") === "1",
919
+ phase: el.getAttribute("data-wg-phase"),
920
+ step: el.getAttribute("data-wg-step"),
921
+ block: el.getAttribute("data-wg-block"),
922
+ collapsed:
923
+ el.getAttribute("data-wg-collapsed") === "1" ||
924
+ el.classList.contains("wg-collapsed"),
925
+ opacity: st.opacity,
926
+ textLen: (el.innerText || "").trim().length,
927
+ w: Math.round(r.width),
928
+ h: Math.round(r.height),
929
+ visible: r.width > 0 && r.height > 0 && Number(st.opacity) > 0.05,
930
+ };
931
+ });
932
+ };
933
+ const existingBanner = document.getElementById("wg-banner");
934
+ if (existingBanner) window.__wgStampModal(existingBanner, "banner", { ready: true });
861
935
  // Hide/Show for #wg-panel - call after every panel.innerHTML refresh.
862
936
  // Collapsed = "N / M · block" + Next (when not auto). Never pass
863
937
  // forceCollapsed:false - omit so Hide/localStorage wins.
@@ -937,6 +1011,7 @@ async function installOverlay(page, title) {
937
1011
  };
938
1012
  const apply = (hidden, persist) => {
939
1013
  panel.classList.toggle("wg-collapsed", hidden);
1014
+ panel.setAttribute("data-wg-collapsed", hidden ? "1" : "0");
940
1015
  const t = panel.querySelector("[data-wg-toggle]");
941
1016
  const titleEl = panel.querySelector(".wg-chrome-title");
942
1017
  const label = panel.dataset.wgStepLabel || stepLabel;
@@ -1004,8 +1079,93 @@ async function installOverlay(page, title) {
1004
1079
  }
1005
1080
  }
1006
1081
  };
1082
+ // Floating todo dock - lives outside #wg-panel so --mini / Hide never
1083
+ // hide the checklist. Click cycles left <-> right with CSS transform.
1084
+ // Authors set side via ctx.todoPos("left"|"right"); env / --todo-* fallback.
1085
+ window.__wgSyncTodos = (list, pos) => {
1086
+ list = Array.isArray(list) ? list : [];
1087
+ const POS = ["left", "right"];
1088
+ let dock = document.getElementById("wg-todo-dock");
1089
+ // Drop any in-panel leftover (old builds put #wg-todos in .wg-body).
1090
+ document.querySelectorAll("#wg-panel #wg-todos").forEach((el) => el.remove());
1091
+ if (!list.length) {
1092
+ if (dock) dock.remove();
1093
+ return;
1094
+ }
1095
+ const wantPos =
1096
+ pos === "left" || pos === "right"
1097
+ ? pos
1098
+ : null;
1099
+ if (!dock) {
1100
+ dock = document.createElement("div");
1101
+ dock.id = "wg-todo-dock";
1102
+ dock.setAttribute("data-wg-ui", "1");
1103
+ dock.setAttribute("data-wg-modal", "todos");
1104
+ dock.setAttribute("data-wg-ready", "1");
1105
+ let saved = null;
1106
+ try {
1107
+ saved = localStorage.getItem("wg-todo-pos");
1108
+ } catch {
1109
+ /* ignore */
1110
+ }
1111
+ const start =
1112
+ wantPos ||
1113
+ (saved && POS.includes(saved) ? saved : todoPos === "right" ? "right" : "left");
1114
+ dock.dataset.pos = start;
1115
+ dock.title = "Click to move checklist: left / right";
1116
+ dock.addEventListener("click", (e) => {
1117
+ e.stopPropagation();
1118
+ const i = POS.indexOf(dock.dataset.pos || "left");
1119
+ const next = POS[(i + 1) % POS.length];
1120
+ dock.dataset.pos = next;
1121
+ try {
1122
+ localStorage.setItem("wg-todo-pos", next);
1123
+ } catch {
1124
+ /* ignore */
1125
+ }
1126
+ });
1127
+ document.documentElement.appendChild(dock);
1128
+ } else if (wantPos) {
1129
+ dock.dataset.pos = wantPos;
1130
+ try {
1131
+ localStorage.setItem("wg-todo-pos", wantPos);
1132
+ } catch {
1133
+ /* ignore */
1134
+ }
1135
+ }
1136
+ const esc = (s) =>
1137
+ String(s)
1138
+ .replace(/&/g, "&amp;")
1139
+ .replace(/</g, "&lt;")
1140
+ .replace(/>/g, "&gt;");
1141
+ dock.innerHTML =
1142
+ '<ul id="wg-todos">' +
1143
+ list
1144
+ .map((t) => {
1145
+ const cls = t.current
1146
+ ? "wg-todo-current"
1147
+ : t.done
1148
+ ? "wg-todo-done"
1149
+ : "wg-todo-pending";
1150
+ const mark = t.done ? "\u2713" : t.current ? "\u2192" : "\u25CB";
1151
+ return (
1152
+ '<li class="' +
1153
+ cls +
1154
+ '"><span class="wg-todo-mark">' +
1155
+ mark +
1156
+ "</span><span>" +
1157
+ esc(t.text || "") +
1158
+ "</span></li>"
1159
+ );
1160
+ })
1161
+ .join("") +
1162
+ "</ul>";
1163
+ if (window.__wgStampModal) {
1164
+ window.__wgStampModal(dock, "todos", { ready: true });
1165
+ }
1166
+ };
1007
1167
  },
1008
- { title, favicon: WAYGRAPH_FAVICON, bannerPos, envAutoplay },
1168
+ { title, favicon: WAYGRAPH_FAVICON, bannerPos, todoPos, envAutoplay },
1009
1169
  )
1010
1170
  .catch(() => {});
1011
1171
  }
@@ -1027,6 +1187,8 @@ async function renderBeforeStep(page, info) {
1027
1187
  if (window.__wgHideCursor) window.__wgHideCursor();
1028
1188
  })
1029
1189
  .catch(() => {});
1190
+ // miniStepLabel / todosHtmlFromInfo are Node-only — browser evaluate has no closure.
1191
+ const payload = { ...info, stepLabel: miniStepLabel(info) };
1030
1192
  await page
1031
1193
  .evaluate((info) => {
1032
1194
  // Reused in place, not removed + recreated, every step - the old
@@ -1093,14 +1255,15 @@ async function renderBeforeStep(page, info) {
1093
1255
  esc(info.paceLabel || "") +
1094
1256
  "</span></div>"
1095
1257
  : "";
1258
+ // Todos render into floating #wg-todo-dock via __wgSyncTodos - never
1259
+ // inside .wg-body (would vanish under --mini / Hide collapse).
1096
1260
  let html =
1097
1261
  "<div id=\\"wg-progress\\"><div id=\\"wg-progress-bar\\" style=\\"width:" + pct + "%\\"></div></div>" +
1098
1262
  episodesHtml +
1099
1263
  paceHtml +
1100
1264
  "<div id=\\"wg-modules\\" class=\\"" + modulesClass + "\\">" + modulesHtml + "</div>" +
1101
1265
  "<h3>Step " + (info.index + 1) + " / " + info.total + " - " + info.blockName + "</h3>" +
1102
- narrationHtml +
1103
- todosHtmlFromInfo(info, esc);
1266
+ narrationHtml;
1104
1267
  if (info.keys.length === 0) {
1105
1268
  html += "<div class=\\"wg-key\\">(no MemKeys required)</div>";
1106
1269
  }
@@ -1130,13 +1293,25 @@ async function renderBeforeStep(page, info) {
1130
1293
  if (isNewPanel) {
1131
1294
  document.documentElement.appendChild(panel);
1132
1295
  requestAnimationFrame(() => panel.classList.add("wg-in"));
1296
+ } else {
1297
+ panel.classList.add("wg-in");
1133
1298
  }
1134
1299
  if (window.__wgWirePanelChrome) {
1135
1300
  window.__wgWirePanelChrome(panel, "wg-panel-hidden", "waygraph demo", {
1136
- stepLabel: miniStepLabel(info),
1301
+ stepLabel: info.stepLabel || "",
1137
1302
  forceCollapsed: info.forceCollapsed === true ? true : undefined,
1138
1303
  });
1139
1304
  }
1305
+ if (window.__wgSyncTodos) window.__wgSyncTodos(info.todos || [], info.todoPos || null);
1306
+ if (window.__wgStampModal) {
1307
+ window.__wgStampModal(panel, "panel", {
1308
+ phase: "before",
1309
+ step: info.index + 1,
1310
+ total: info.total,
1311
+ block: info.blockName || "",
1312
+ ready: true,
1313
+ });
1314
+ }
1140
1315
  // Live, human-readable preview of what a MemKey's raw JSON will
1141
1316
  // actually write - object fields become "Field: value" lines
1142
1317
  // (camelCase split the same way state tags already are); a
@@ -1206,8 +1381,40 @@ async function renderBeforeStep(page, info) {
1206
1381
  if (auto) auto.style.display = cb.checked ? "" : "none";
1207
1382
  });
1208
1383
  }
1209
- }, info)
1384
+ }, payload)
1210
1385
  .catch(() => {});
1386
+ if (process.env.WAYGRAPH_PROVE_SHOT || process.env.WAYGRAPH_PROVE_EXIT === "1") {
1387
+ // Default: prove once on first painted panel. PROVE_EVERY=1 re-checks
1388
+ // every step (noisy; useful when hunting a mid-chain blank).
1389
+ const proveEvery = process.env.WAYGRAPH_PROVE_EVERY === "1";
1390
+ const already = globalThis.__wgProveDone;
1391
+ if (!already || proveEvery || process.env.WAYGRAPH_PROVE_EXIT === "1") {
1392
+ await page.waitForTimeout(500).catch(() => {});
1393
+ if (process.env.WAYGRAPH_PROVE_SHOT) {
1394
+ await page.screenshot({ path: process.env.WAYGRAPH_PROVE_SHOT }).catch(() => {});
1395
+ }
1396
+ const ok = await page
1397
+ .evaluate(() => {
1398
+ const beacons =
1399
+ typeof window.__wgOverlayBeacon === "function" ? window.__wgOverlayBeacon() : [];
1400
+ const panel = beacons.find((b) => b.modal === "panel" && b.ready && b.visible);
1401
+ const textOk = panel && panel.textLen >= 12;
1402
+ const opacityOk = panel && Number(panel.opacity) > 0.5;
1403
+ return {
1404
+ ok: !!(panel && textOk && opacityOk),
1405
+ beacons,
1406
+ hasRun: !!document.getElementById("wg-run"),
1407
+ readyAttr: document.getElementById("wg-panel")?.getAttribute("data-wg-ready") || null,
1408
+ };
1409
+ })
1410
+ .catch((e) => ({ ok: false, reason: String(e && e.message ? e.message : e) }));
1411
+ console.error("WAYGRAPH_PROVE " + JSON.stringify(ok));
1412
+ globalThis.__wgProveDone = true;
1413
+ if (process.env.WAYGRAPH_PROVE_EXIT === "1") {
1414
+ process.exit(ok && ok.ok ? 0 : 2);
1415
+ }
1416
+ }
1417
+ }
1211
1418
  }
1212
1419
 
1213
1420
  async function ensureSelectorInView(page, selector) {
@@ -1335,6 +1542,85 @@ function miniStepLabel(info) {
1335
1542
  );
1336
1543
  }
1337
1544
 
1545
+ /**
1546
+ * stubOnError rings + amber "expected outcome" / red error panel, then wait
1547
+ * for Stop/Retry. Used when a step throws OR when withExpectedFailure's last
1548
+ * block succeeds on the intentional fail branch (e.g. LoginPage + error banner).
1549
+ */
1550
+ async function presentFailPanel(page, opts) {
1551
+ const {
1552
+ block,
1553
+ fixtures,
1554
+ error,
1555
+ index,
1556
+ total,
1557
+ blockName,
1558
+ message,
1559
+ allNames,
1560
+ allDescriptions,
1561
+ moduleIndex,
1562
+ allEpisodes,
1563
+ title,
1564
+ episodeNumber,
1565
+ episodeTitle,
1566
+ expectedFailureReason,
1567
+ stepperMode,
1568
+ gatesFast,
1569
+ demoPace,
1570
+ gate,
1571
+ } = opts;
1572
+ if (hasAuthoredStubOnError(block, fixtures)) {
1573
+ await page
1574
+ .evaluate(() => {
1575
+ if (window.__wgRingTrack) {
1576
+ window.removeEventListener("resize", window.__wgRingTrack);
1577
+ window.removeEventListener("scroll", window.__wgRingTrack, true);
1578
+ window.__wgRingTrack = null;
1579
+ }
1580
+ })
1581
+ .catch(() => {});
1582
+ const errPhase = await runStubPhase(block, "stubOnError", {
1583
+ fixtures,
1584
+ error,
1585
+ out: opts.out,
1586
+ });
1587
+ const errHighlights = errPhase.highlights.map((h) => {
1588
+ const styled = applyHighlightStyleDefaults(h, opts.highlightStyle);
1589
+ return {
1590
+ selector: styled.selector,
1591
+ label: formatHighlightCaption(styled),
1592
+ duration: styled.duration,
1593
+ fastMode: styled.fastMode,
1594
+ tone: styled.tone,
1595
+ size: styled.size,
1596
+ weight: styled.weight,
1597
+ zoom: styled.zoom,
1598
+ };
1599
+ });
1600
+ await cycleHighlightRings(page, errHighlights, !!gatesFast, {
1601
+ defaultHoldMs: 2000,
1602
+ pace: demoPace,
1603
+ todos: errPhase.todos,
1604
+ });
1605
+ }
1606
+ await renderStepError(page, {
1607
+ index,
1608
+ total,
1609
+ blockName,
1610
+ message,
1611
+ allNames,
1612
+ allDescriptions,
1613
+ moduleIndex,
1614
+ allEpisodes,
1615
+ title,
1616
+ episodeNumber,
1617
+ episodeTitle,
1618
+ expectedFailureReason,
1619
+ stepperMode,
1620
+ });
1621
+ return gate();
1622
+ }
1623
+
1338
1624
  async function cycleHighlightRings(page, highlights, gatesFast, opts) {
1339
1625
  const list = highlights || [];
1340
1626
  // defaultHoldMs: when set (fail path), use instead of legacy 900/200 so BUG
@@ -1359,6 +1645,11 @@ async function cycleHighlightRings(page, highlights, gatesFast, opts) {
1359
1645
  : normalizeTodos(h.todos, h.todoIndex);
1360
1646
  await page
1361
1647
  .evaluate((todos) => {
1648
+ if (window.__wgSyncTodos) {
1649
+ window.__wgSyncTodos(todos);
1650
+ return;
1651
+ }
1652
+ // Fallback for pages that never got installOverlay sync helper.
1362
1653
  const ul = document.getElementById("wg-todos");
1363
1654
  if (!ul) return;
1364
1655
  ul.innerHTML = todos
@@ -1447,7 +1738,9 @@ async function renderAfterStep(page, info) {
1447
1738
  // 900ms / 200ms holds when set.
1448
1739
  await cycleHighlightRings(page, highlights, gatesFast, {
1449
1740
  pace: info.pace,
1741
+ stepLabel: miniStepLabel(info),
1450
1742
  });
1743
+ const afterPayload = { ...info, stepLabel: miniStepLabel(info) };
1451
1744
  await page
1452
1745
  .evaluate((info) => {
1453
1746
  // Reused in place - see renderBeforeStep's own comment on this.
@@ -1525,25 +1818,38 @@ async function renderAfterStep(page, info) {
1525
1818
  escA(info.paceLabel || "") +
1526
1819
  "</span></div>"
1527
1820
  : "";
1821
+ const todosHtml = ""; // todos float in #wg-todo-dock via __wgSyncTodos
1528
1822
  panel.innerHTML =
1529
1823
  "<div id=\\"wg-progress\\"><div id=\\"wg-progress-bar\\" style=\\"width:" + pct + "%\\"></div></div>" +
1530
1824
  episodesHtml +
1531
1825
  paceHtml +
1532
1826
  "<div id=\\"wg-modules\\" class=\\"" + modulesClass + "\\">" + modulesHtml + "</div>" +
1533
1827
  "<h3>" + heading + "</h3>" +
1534
- todosHtmlFromInfo(info, escA) +
1828
+ todosHtml +
1535
1829
  resultHtml +
1536
1830
  gateHtml;
1537
1831
  if (isNewPanel) {
1538
1832
  document.documentElement.appendChild(panel);
1539
1833
  requestAnimationFrame(() => panel.classList.add("wg-in"));
1834
+ } else {
1835
+ panel.classList.add("wg-in");
1540
1836
  }
1541
1837
  if (window.__wgWirePanelChrome) {
1542
1838
  window.__wgWirePanelChrome(panel, "wg-panel-hidden", "waygraph demo", {
1543
- stepLabel: miniStepLabel(info),
1839
+ stepLabel: info.stepLabel || "",
1544
1840
  forceCollapsed: info.forceCollapsed === true ? true : undefined,
1545
1841
  });
1546
1842
  }
1843
+ if (window.__wgSyncTodos) window.__wgSyncTodos(info.todos || [], info.todoPos || null);
1844
+ if (window.__wgStampModal) {
1845
+ window.__wgStampModal(panel, "panel", {
1846
+ phase: "after",
1847
+ step: info.index + 1,
1848
+ total: info.total,
1849
+ block: info.blockName || "",
1850
+ ready: true,
1851
+ });
1852
+ }
1547
1853
  panel.querySelectorAll(".wg-toggle-btn").forEach((btn) => {
1548
1854
  btn.addEventListener("click", () => {
1549
1855
  const wantPretty = btn.getAttribute("data-mode") === "pretty";
@@ -1570,7 +1876,7 @@ async function renderAfterStep(page, info) {
1570
1876
  if (auto) auto.style.display = cb.checked ? "" : "none";
1571
1877
  });
1572
1878
  }
1573
- }, info)
1879
+ }, afterPayload)
1574
1880
  .catch(() => {});
1575
1881
  }
1576
1882
 
@@ -1582,6 +1888,7 @@ async function renderAfterStep(page, info) {
1582
1888
  */
1583
1889
  async function renderStepError(page, info) {
1584
1890
  await installOverlay(page, info.title);
1891
+ const errorPayload = { ...info, stepLabel: miniStepLabel(info) };
1585
1892
  await page
1586
1893
  .evaluate((info) => {
1587
1894
  // Reused in place - see renderBeforeStep's own comment on this.
@@ -1639,18 +1946,29 @@ async function renderStepError(page, info) {
1639
1946
  if (isNewPanel) {
1640
1947
  document.documentElement.appendChild(panel);
1641
1948
  requestAnimationFrame(() => panel.classList.add("wg-in"));
1949
+ } else {
1950
+ panel.classList.add("wg-in");
1642
1951
  }
1643
1952
  if (window.__wgWirePanelChrome) {
1644
1953
  window.__wgWirePanelChrome(panel, "wg-panel-hidden", "waygraph demo", {
1645
- stepLabel: miniStepLabel(info),
1954
+ stepLabel: info.stepLabel || "",
1646
1955
  forceCollapsed: info.forceCollapsed === true ? true : undefined,
1647
1956
  });
1648
1957
  }
1958
+ if (window.__wgStampModal) {
1959
+ window.__wgStampModal(panel, "panel", {
1960
+ phase: "error",
1961
+ step: info.index + 1,
1962
+ total: info.total,
1963
+ block: info.blockName || "",
1964
+ ready: true,
1965
+ });
1966
+ }
1649
1967
  const runBtn = document.getElementById("wg-run");
1650
1968
  if (runBtn) runBtn.addEventListener("click", () => window.__wgNext({}));
1651
1969
  const retryBtn = document.getElementById("wg-error-retry");
1652
1970
  if (retryBtn) retryBtn.addEventListener("click", () => window.__wgNext({ __wgRetry: "1" }));
1653
- }, info)
1971
+ }, errorPayload)
1654
1972
  .catch(() => {});
1655
1973
  }
1656
1974
 
@@ -1761,6 +2079,14 @@ async function markStepRunning(page, _opts) {
1761
2079
  const panel = document.getElementById("wg-panel");
1762
2080
  if (panel) {
1763
2081
  panel.classList.add("wg-collapsed");
2082
+ panel.setAttribute("data-wg-collapsed", "1");
2083
+ panel.setAttribute("data-wg-phase", "running");
2084
+ if (typeof window.__wgStampModal === "function") {
2085
+ window.__wgStampModal(panel, "panel", {
2086
+ phase: "running",
2087
+ ready: true,
2088
+ });
2089
+ }
1764
2090
  const toggle = panel.querySelector("[data-wg-toggle]");
1765
2091
  if (toggle) toggle.textContent = "Show";
1766
2092
  const titleEl = panel.querySelector(".wg-chrome-title");
@@ -1978,6 +2304,15 @@ async function presentSlides(page, slides, _gate, opts) {
1978
2304
  forceCollapsed: info.forceCollapsed === true ? true : undefined,
1979
2305
  });
1980
2306
  }
2307
+ if (window.__wgStampModal) {
2308
+ window.__wgStampModal(panel, "panel", {
2309
+ phase: "slide",
2310
+ step: info.index + 1,
2311
+ total: info.total,
2312
+ block: info.blockName || "",
2313
+ ready: true,
2314
+ });
2315
+ }
1981
2316
  let autoNow = false;
1982
2317
  try {
1983
2318
  autoNow = localStorage.getItem("wg-autoplay") === "1";
@@ -2128,7 +2463,7 @@ async function teardownOverlay(page) {
2128
2463
  window.__wgRingTrack = null;
2129
2464
  }
2130
2465
  window.__wgNarrateOwnsRing = false;
2131
- for (const id of ["wg-panel", "wg-ring", "wg-ring-label", "wg-cursor", "wg-click-pulse", "wg-banner"]) {
2466
+ for (const id of ["wg-panel", "wg-ring", "wg-ring-label", "wg-cursor", "wg-click-pulse", "wg-banner", "wg-todo-dock"]) {
2132
2467
  const el = document.getElementById(id);
2133
2468
  if (el) el.remove();
2134
2469
  }
@@ -2451,7 +2786,10 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2451
2786
  // installOverlay only seeds localStorage when the key is null (so mid-run
2452
2787
  // checkbox clicks survive navigations). Without this, a prior --autoplay
2453
2788
  // session sticks forever and agents cannot flip back with --no-autoplay.
2454
- if (process.env.WAYGRAPH_AUTOPLAY !== undefined) {
2789
+ // Must run on the *demo origin* (after goto), not about:blank - localStorage
2790
+ // is origin-scoped.
2791
+ const syncAutoplayFromEnv = async () => {
2792
+ if (process.env.WAYGRAPH_AUTOPLAY === undefined) return;
2455
2793
  const on = process.env.WAYGRAPH_AUTOPLAY === "1";
2456
2794
  await page
2457
2795
  .evaluate((want) => {
@@ -2462,7 +2800,8 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2462
2800
  }
2463
2801
  }, on)
2464
2802
  .catch(() => {});
2465
- }
2803
+ };
2804
+ await syncAutoplayFromEnv();
2466
2805
  // Some real Blocks (e.g. zsign-all's login.block.ts) call
2467
2806
  // page.setViewportSize({ width: 1280, height: 720 }) inside their own
2468
2807
  // act() - a hardcoded override for THEIR OWN testing consistency, with no
@@ -2616,6 +2955,8 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2616
2955
  // still rendered on screen.
2617
2956
  if ((clearSession || r.resetSession) && i > 0) {
2618
2957
  await resetPageState(context, page, baseURL);
2958
+ // clear() wiped origin storage - re-apply CLI autoplay default.
2959
+ await syncAutoplayFromEnv();
2619
2960
  }
2620
2961
  // Episode pace: block > flow > CLI --fast (only when no authored pace).
2621
2962
  // Authoring wins: withDemoPace("slow"|2|4500) keeps slow dwell even under --fast.
@@ -2653,6 +2994,12 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2653
2994
  applyHighlightStyleDefaults(h, flowStyle),
2654
2995
  );
2655
2996
  const stubBeforeTodos = stubBeforePhase.todos || [];
2997
+ const overlayTitle =
2998
+ (stubBeforePhase.title && String(stubBeforePhase.title).trim()) ||
2999
+ (r.episodeTitle && String(r.episodeTitle).trim()) ||
3000
+ title ||
3001
+ "waygraph demo";
3002
+ const overlayTodoPos = stubBeforePhase.todoPos || undefined;
2656
3003
  const isNavBlock = r.block.__waygraphKind === "nav";
2657
3004
  const autoNow = await currentAutoplay();
2658
3005
  // Video / --mini: compact pill. Never pass false - Hide/localStorage wins.
@@ -2697,7 +3044,8 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2697
3044
  moduleIndex,
2698
3045
  allEpisodes,
2699
3046
  justEnteredEpisode,
2700
- title,
3047
+ title: overlayTitle,
3048
+ todoPos: overlayTodoPos,
2701
3049
  episodeNumber: r.episodeNumber,
2702
3050
  episodeTitle: r.episodeTitle,
2703
3051
  stepperMode,
@@ -2748,47 +3096,12 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2748
3096
  delete window.__wgPendingNavClickLabel;
2749
3097
  })
2750
3098
  .catch(() => {});
2751
- // stubOnError: ring the broken UI BEFORE the error panel (PIA RFC).
2752
- // stubAfter stays success-only - distinct BUG/FAIL copy on the fail path.
2753
- const fixturesOnError = r.highlightFixtures;
2754
- if (hasAuthoredStubOnError(r.block, fixturesOnError)) {
2755
- await page
2756
- .evaluate(() => {
2757
- if (window.__wgRingTrack) {
2758
- window.removeEventListener("resize", window.__wgRingTrack);
2759
- window.removeEventListener("scroll", window.__wgRingTrack, true);
2760
- window.__wgRingTrack = null;
2761
- }
2762
- })
2763
- .catch(() => {});
2764
- const errPhase = await runStubPhase(r.block, "stubOnError", {
2765
- fixtures: fixturesOnError,
2766
- error: err,
2767
- });
2768
- const errHighlights = errPhase.highlights.map((h) => {
2769
- const styled = applyHighlightStyleDefaults(h, r.highlightStyle);
2770
- return {
2771
- selector: styled.selector,
2772
- label: formatHighlightCaption(styled),
2773
- duration: styled.duration,
2774
- fastMode: styled.fastMode,
2775
- tone: styled.tone,
2776
- size: styled.size,
2777
- weight: styled.weight,
2778
- zoom: styled.zoom,
2779
- };
2780
- });
2781
- await cycleHighlightRings(page, errHighlights, !!pacing.gatesFast, {
2782
- defaultHoldMs: 2000,
2783
- pace: pacing.demoPace,
2784
- todos: errPhase.todos,
2785
- });
2786
- }
2787
- // A thrown act()/observe()/a failed verify Trait used to just crash
2788
- // the whole Node process with a raw stack trace - the browser closes
2789
- // (main()'s own try/finally) before a human watching ever sees WHY.
2790
- // Show it on-screen and let a real click acknowledge it first.
2791
- await renderStepError(page, {
3099
+ // stubOnError + error/expected panel BEFORE acknowledging (PIA RFC).
3100
+ const errEdits = await presentFailPanel(page, {
3101
+ block: r.block,
3102
+ fixtures: r.highlightFixtures,
3103
+ highlightStyle: r.highlightStyle,
3104
+ error: err,
2792
3105
  index: i,
2793
3106
  total: resolved.length,
2794
3107
  blockName: r.block.name,
@@ -2802,14 +3115,13 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2802
3115
  episodeTitle: r.episodeTitle,
2803
3116
  expectedFailureReason: r.expectedFailureReason,
2804
3117
  stepperMode,
2805
- // Omit forceCollapsed on errors so Hide is not stomped.
3118
+ gatesFast: pacing.gatesFast,
3119
+ demoPace: pacing.demoPace,
3120
+ gate,
2806
3121
  });
2807
- const errEdits = await gate();
2808
3122
  if (errEdits && errEdits.__wgRetry) {
2809
- // Same clean slate a normal episode boundary gets - not just
2810
- // re-running the failed block against whatever broken/half-
2811
- // navigated state it left the page in.
2812
3123
  await resetPageState(context, page, baseURL);
3124
+ await syncAutoplayFromEnv();
2813
3125
  i = episodeStartIndex - 1;
2814
3126
  continue;
2815
3127
  }
@@ -2831,6 +3143,50 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2831
3143
  })
2832
3144
  .catch(() => {});
2833
3145
  result = stepOutcome.result;
3146
+ // withExpectedFailure last block that SUCCEEDS on the intentional fail
3147
+ // branch (e.g. submit-login -> LoginPage + error banner). Still show
3148
+ // stubOnError rings + amber "expected outcome" panel - branching no
3149
+ // longer throws, but the demo must not skip the educational overlay.
3150
+ if (r.expectedFailureReason) {
3151
+ const errEdits = await presentFailPanel(page, {
3152
+ block: r.block,
3153
+ fixtures: r.highlightFixtures,
3154
+ highlightStyle: r.highlightStyle,
3155
+ error: undefined,
3156
+ out: result,
3157
+ index: i,
3158
+ total: resolved.length,
3159
+ blockName: r.block.name,
3160
+ message:
3161
+ "Resolved " +
3162
+ (result && result.__state ? result.__state : JSON.stringify(result)) +
3163
+ " (expected failure path)",
3164
+ allNames: moduleNames,
3165
+ allDescriptions: moduleDescriptions,
3166
+ moduleIndex,
3167
+ allEpisodes,
3168
+ title,
3169
+ episodeNumber: r.episodeNumber,
3170
+ episodeTitle: r.episodeTitle,
3171
+ expectedFailureReason: r.expectedFailureReason,
3172
+ stepperMode,
3173
+ gatesFast: pacing.gatesFast,
3174
+ demoPace: pacing.demoPace,
3175
+ gate,
3176
+ });
3177
+ if (errEdits && errEdits.__wgRetry) {
3178
+ await resetPageState(context, page, baseURL);
3179
+ await syncAutoplayFromEnv();
3180
+ i = episodeStartIndex - 1;
3181
+ continue;
3182
+ }
3183
+ if (process.env.WAYGRAPH_JSON !== "1") {
3184
+ console.log(
3185
+ "waygraph: expected failure on " + r.block.name + " - " + r.expectedFailureReason,
3186
+ );
3187
+ }
3188
+ break;
3189
+ }
2834
3190
  const fixturesAfter = r.highlightFixtures;
2835
3191
  const slides = resolveSlides(r.block, { out: result, fixtures: fixturesAfter }).map((s) =>
2836
3192
  applyHighlightStyleDefaults(s, r.highlightStyle),
@@ -2848,12 +3204,18 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2848
3204
  }
2849
3205
  let highlights;
2850
3206
  let stubAfterTodos = [];
3207
+ let afterOverlayTitle = overlayTitle;
3208
+ let afterTodoPos = overlayTodoPos;
2851
3209
  if (hasAuthoredStubAfter(r.block, result, fixturesAfter)) {
2852
3210
  const afterPhase = await runStubPhase(r.block, "stubAfter", {
2853
3211
  out: result,
2854
3212
  fixtures: fixturesAfter,
2855
3213
  });
2856
3214
  stubAfterTodos = afterPhase.todos || [];
3215
+ if (afterPhase.title && String(afterPhase.title).trim()) {
3216
+ afterOverlayTitle = String(afterPhase.title).trim();
3217
+ }
3218
+ if (afterPhase.todoPos) afterTodoPos = afterPhase.todoPos;
2857
3219
  highlights = afterPhase.highlights.map((h) => {
2858
3220
  const styled = applyHighlightStyleDefaults(h, r.highlightStyle);
2859
3221
  return {
@@ -2887,7 +3249,8 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2887
3249
  allDescriptions: moduleDescriptions,
2888
3250
  moduleIndex,
2889
3251
  allEpisodes,
2890
- title,
3252
+ title: afterOverlayTitle,
3253
+ todoPos: afterTodoPos,
2891
3254
  episodeNumber: r.episodeNumber,
2892
3255
  episodeTitle: r.episodeTitle,
2893
3256
  stepperMode,
@@ -3915,6 +4278,12 @@ function parseRunFlags(argv) {
3915
4278
  else if (a === "--mini" || a === "--stepper-mini") {
3916
4279
  out.miniStepper = true;
3917
4280
  }
4281
+ else if (a === "--todo-left") {
4282
+ out.todoPos = "left";
4283
+ }
4284
+ else if (a === "--todo-right") {
4285
+ out.todoPos = "right";
4286
+ }
3918
4287
  else if (a === "--ff-expand") {
3919
4288
  out.ffExpand = true;
3920
4289
  }
@@ -4035,8 +4404,8 @@ function applyRunFlags(flags, opts) {
4035
4404
  process.env.WAYGRAPH_HEADED = "0";
4036
4405
  }
4037
4406
  }
4038
- if ((flags.fast || flags.fullStepper || flags.miniStepper) && !opts?.allowDemoUi) {
4039
- console.error("waygraph: --fast / --full / --mini are demo-only flags");
4407
+ if ((flags.fast || flags.fullStepper || flags.miniStepper || flags.todoPos) && !opts?.allowDemoUi) {
4408
+ console.error("waygraph: --fast / --full / --mini / --todo-left|--todo-right are demo-only flags");
4040
4409
  process.exit(1);
4041
4410
  }
4042
4411
  if (flags.step === true) {
@@ -4083,6 +4452,9 @@ function applyRunFlags(flags, opts) {
4083
4452
  if (flags.miniStepper) {
4084
4453
  process.env.WAYGRAPH_MINI = "1";
4085
4454
  }
4455
+ if (flags.todoPos === "left" || flags.todoPos === "right") {
4456
+ process.env.WAYGRAPH_TODO_POS = flags.todoPos;
4457
+ }
4086
4458
  if (flags.ffExpand) {
4087
4459
  process.env.WAYGRAPH_FF_EXPAND = "1";
4088
4460
  }
@@ -4248,6 +4620,7 @@ Primary (less is more):
4248
4620
  --fast Shorter auto-next / Next gates (keeps smooth cursor)
4249
4621
  --full Classic wrap-all block chips (default: carousel)
4250
4622
  --mini Force compact mini panel (Hide pill + Next); alias --stepper-mini
4623
+ --todo-left|--todo-right Floating checklist dock side (also ctx.todoPos / WAYGRAPH_TODO_POS)
4251
4624
  --ff-expand Expand fastForwardComposeBlock inners as separate steps
4252
4625
  --ff-disabled Dispute: expand FF (alias --no-ff); blitz kept on those inners
4253
4626
  --auto-play-video Unattended + recorded: --auto-next + --video (+ step); headless by default