waygraph 0.12.43 → 0.12.45

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/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  A typed graph of reusable Blocks for driving a browser through E2E flows, in place of a
4
4
  flat pile of ad hoc helper functions.
5
5
 
6
- **Current:** `0.12.43` — overlay beacons (blind-agent gate), expected-failure panel on branched LoginPage, `--mini`, Hide stickiness.
6
+ **Current:** `0.12.44` — floating todo dock (visible under `--mini`, `ctx.todoPos` / click / `--todo-left|right`), live `ctx.title` banner updates, instant highlight tone snaps.
7
7
 
8
8
  This repo is the `waygraph` npm package itself. The full live **Sauce Demo** example
9
9
  (Page inventory hub, Effect Add/Remove, MemNav Open details, `waygraph auto`) ships
package/dist/cli.js CHANGED
@@ -226,6 +226,10 @@ import {
226
226
  formatDemoPaceBadge,
227
227
  formatDemoPaceLabel,
228
228
  normalizeTodos,
229
+ buildTodoDock,
230
+ applyTodoPhase,
231
+ advanceTodoDock,
232
+ completeSequentialTodoDock,
229
233
  } from "waygraph";
230
234
 
231
235
  function walkDir(dir, pattern) {
@@ -412,10 +416,14 @@ function seedMemForFlow(mem, flowBlocks, json, label) {
412
416
  const RING_CSS =
413
417
  "#wg-ring{position:fixed;z-index:2147483646;pointer-events:none;opacity:0;" +
414
418
  "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;}" +
419
+ // Opacity fade only - NEVER transition border-color/box-shadow. Tone swaps
420
+ // (auto gray -> planned purple -> warning yellow) must snap instantly;
421
+ // color transitions read as a muddy gray/purple/yellow morph on ring 2+.
422
+ "box-shadow:0 0 0 4px rgba(124,58,237,.16);transition:opacity .3s ease;}" +
416
423
  // Planned (stubs / fixtures / YAP / instruction.highlights) = purple.
417
424
  // Automation (verify fallback, unmatched fill/click) = gray - operators
418
425
  // can ignore engine checks and watch purple + semantic tones.
426
+ "#wg-ring[data-tone=planned]{border-color:#7C3AED;box-shadow:0 0 0 4px rgba(124,58,237,.16);}" +
419
427
  "#wg-ring[data-tone=auto]{border-color:#9CA3AF;box-shadow:0 0 0 4px rgba(156,163,175,.28);}" +
420
428
  "#wg-ring[data-tone=info]{border-color:#3B82F6;box-shadow:0 0 0 4px rgba(59,130,246,.22);}" +
421
429
  "#wg-ring[data-tone=warning]{border-color:#EAB308;box-shadow:0 0 0 4px rgba(234,179,8,.22);}" +
@@ -431,7 +439,8 @@ const RING_CSS =
431
439
  // window.__wgPositionRing in installOverlay.
432
440
  "#wg-ring-label{position:fixed;z-index:2147483646;pointer-events:none;opacity:0;" +
433
441
  "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;}" +
442
+ "font:600 12px/1.35 system-ui,sans-serif;transition:opacity .3s ease;}" +
443
+ "#wg-ring-label[data-tone=planned]{background:#7C3AED;color:#fff;}" +
435
444
  "#wg-ring-label[data-tone=auto]{background:#6B7280;color:#fff;}" +
436
445
  "#wg-ring-label[data-tone=info]{background:#2563EB;color:#fff;}" +
437
446
  "#wg-ring-label[data-tone=warning]{background:#EAB308;color:#1c1917;}" +
@@ -509,6 +518,18 @@ const RING_CSS =
509
518
  "#wg-panel .wg-pace[data-pace-kind=blitz] .wg-pace-badge{background:#22C55E;color:#052e16;}" +
510
519
  "#wg-panel .wg-pace[data-pace-kind=ms] .wg-pace-badge{background:#3B82F6;color:#fff;}" +
511
520
  "#wg-panel .wg-narration{margin:0 0 12px;font:italic 14px/1.4 system-ui,sans-serif;color:#f0e8ff;}" +
521
+ // Todo checklist lives in #wg-todo-dock (fixed, outside #wg-panel) so
522
+ // --mini / Hide collapse never hides it (.wg-body {display:none} would).
523
+ // Click dock to slide left <-> right (WAYGRAPH_TODO_POS / --todo-left|right).
524
+ "#wg-todo-dock{position:fixed;z-index:2147483646;top:72px;left:14px;" +
525
+ "width:min(280px,42vw);max-height:calc(100vh - 100px);overflow:auto;box-sizing:border-box;" +
526
+ "padding:10px 12px;background:rgba(20,10,40,.94);color:#fff;border-radius:12px;" +
527
+ "border:1px solid rgba(124,58,237,.45);box-shadow:0 8px 24px rgba(0,0,0,.35);" +
528
+ "pointer-events:auto;cursor:pointer;" +
529
+ "transition:transform .4s cubic-bezier(.22,1,.36,1);transform:translateX(0);}" +
530
+ "#wg-todo-dock[data-pos=right]{transform:translateX(calc(100vw - 100% - 28px));}" +
531
+ "#wg-todo-dock[data-pos=left]{transform:translateX(0);}" +
532
+ "#wg-todo-dock #wg-todos{list-style:none;margin:0;padding:0;background:transparent;border:none;}" +
512
533
  "#wg-todos{list-style:none;margin:0 0 12px;padding:8px 10px;background:#0f0620;border-radius:8px;" +
513
534
  "border:1px solid #3a2a60;}" +
514
535
  "#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 +668,12 @@ async function installOverlay(page, title) {
647
668
  // navigation that rebuilds the banner keeps the human's last pick).
648
669
  const envPos = (process.env.WAYGRAPH_TITLE_POS || "left").toLowerCase();
649
670
  const bannerPos = envPos === "center" || envPos === "right" ? envPos : "left";
671
+ const envTodoPos = (process.env.WAYGRAPH_TODO_POS || "left").toLowerCase();
672
+ const todoPos = envTodoPos === "right" ? "right" : "left";
650
673
  const envAutoplay = process.env.WAYGRAPH_AUTOPLAY === "1";
651
674
  await page
652
675
  .evaluate(
653
- ({ title, favicon, bannerPos, envAutoplay }) => {
676
+ ({ title, favicon, bannerPos, todoPos, envAutoplay }) => {
654
677
  // Seed the live autoplay toggle from the env default on first ever
655
678
  // load only - a real navigation re-runs this, and re-stamping here
656
679
  // would silently undo a human's mid-run checkbox click.
@@ -818,32 +841,46 @@ async function installOverlay(page, title) {
818
841
  /* private mode / blocked storage - position still applies this page */
819
842
  }
820
843
  };
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 */
844
+ if (title) {
845
+ let banner = document.getElementById("wg-banner");
846
+ if (!banner) {
847
+ banner = document.createElement("div");
848
+ banner.id = "wg-banner";
849
+ let saved = null;
850
+ try {
851
+ saved = localStorage.getItem("wg-banner-pos");
852
+ } catch {
853
+ /* ignore */
854
+ }
855
+ const startPos =
856
+ saved && POSITIONS.includes(saved) ? saved : bannerPos;
857
+ applyPos(banner, startPos);
858
+ banner.title = "Click to move: top left / center / right";
859
+ banner.addEventListener("click", (e) => {
860
+ e.stopPropagation();
861
+ const i = POSITIONS.indexOf(banner.dataset.pos || "left");
862
+ applyPos(banner, POSITIONS[(i + 1) % POSITIONS.length]);
863
+ });
864
+ const tag = document.createElement("span");
865
+ tag.className = "wg-banner-tag";
866
+ tag.textContent = "waygraph demo";
867
+ const text = document.createElement("span");
868
+ text.className = "wg-banner-text";
869
+ text.textContent = title;
870
+ banner.appendChild(tag);
871
+ banner.appendChild(text);
872
+ document.documentElement.appendChild(banner);
873
+ } else {
874
+ // Fixture / episode title changes every step - update in place
875
+ // (banner is created once; do not leave the first step's text stuck).
876
+ let text = banner.querySelector(".wg-banner-text");
877
+ if (!text) {
878
+ text = document.createElement("span");
879
+ text.className = "wg-banner-text";
880
+ banner.appendChild(text);
881
+ }
882
+ text.textContent = title;
829
883
  }
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
884
  }
848
885
  // Tab title/favicon: a real navigation resets document.title and any
849
886
  // <link rel="icon"> the new document brings, so re-check (not
@@ -1046,8 +1083,177 @@ async function installOverlay(page, title) {
1046
1083
  }
1047
1084
  }
1048
1085
  };
1086
+ // Floating todo dock - lives outside #wg-panel so --mini / Hide never
1087
+ // hide the checklist. Click cycles left <-> right with CSS transform.
1088
+ // Authors set side via ctx.todoPos("left"|"right"); env / --todo-* fallback.
1089
+ // Payload:
1090
+ // { sync: "clear" } -> remove dock
1091
+ // { sync: "keep" } -> leave dock alone (no wipe)
1092
+ // { sync: "set", dock } / { sync:"set", list } -> render / recreate
1093
+ // Compat: bare array = set list (empty array = clear - legacy only).
1094
+ window.__wgSyncTodos = (payload, pos) => {
1095
+ let sync = "set";
1096
+ let list = [];
1097
+ let dockState = null;
1098
+ let wantPos =
1099
+ pos === "left" || pos === "right" ? pos : null;
1100
+ if (Array.isArray(payload)) {
1101
+ list = payload;
1102
+ sync = list.length ? "set" : "clear";
1103
+ } else if (payload && typeof payload === "object") {
1104
+ sync = payload.sync === "clear" || payload.sync === "keep" ? payload.sync : "set";
1105
+ dockState = payload.dock || null;
1106
+ if (payload.pos === "left" || payload.pos === "right") wantPos = payload.pos;
1107
+ if (Array.isArray(payload.list)) list = payload.list;
1108
+ else if (dockState && Array.isArray(dockState.groups)) {
1109
+ // Flatten for legacy callers; full dock rendered below when present.
1110
+ list = [];
1111
+ for (const g of dockState.groups) {
1112
+ if (g && Array.isArray(g.items)) list = list.concat(g.items);
1113
+ }
1114
+ }
1115
+ } else {
1116
+ list = [];
1117
+ sync = "clear";
1118
+ }
1119
+
1120
+ let dock = document.getElementById("wg-todo-dock");
1121
+ // Drop any in-panel leftover (old builds put #wg-todos in .wg-body).
1122
+ document.querySelectorAll("#wg-panel #wg-todos").forEach((el) => el.remove());
1123
+
1124
+ // keep + no new payload: do not remove an existing dock (PIA #10).
1125
+ if (sync === "keep") {
1126
+ if (!dockState && !list.length) return;
1127
+ // After navigation the DOM may be gone - recreate from carried dock.
1128
+ sync = "set";
1129
+ }
1130
+ if (sync === "clear" || (sync === "set" && !dockState && !list.length)) {
1131
+ if (dock) dock.remove();
1132
+ return;
1133
+ }
1134
+
1135
+ const POS = ["left", "right"];
1136
+ if (!dock) {
1137
+ dock = document.createElement("div");
1138
+ dock.id = "wg-todo-dock";
1139
+ dock.setAttribute("data-wg-ui", "1");
1140
+ dock.setAttribute("data-wg-modal", "todos");
1141
+ dock.setAttribute("data-wg-ready", "1");
1142
+ let saved = null;
1143
+ try {
1144
+ saved = localStorage.getItem("wg-todo-pos");
1145
+ } catch {
1146
+ /* ignore */
1147
+ }
1148
+ const start =
1149
+ wantPos ||
1150
+ (saved && POS.includes(saved) ? saved : todoPos === "right" ? "right" : "left");
1151
+ dock.dataset.pos = start;
1152
+ dock.title = "Click to move checklist: left / right";
1153
+ dock.addEventListener("click", (e) => {
1154
+ e.stopPropagation();
1155
+ const i = POS.indexOf(dock.dataset.pos || "left");
1156
+ const next = POS[(i + 1) % POS.length];
1157
+ dock.dataset.pos = next;
1158
+ try {
1159
+ localStorage.setItem("wg-todo-pos", next);
1160
+ } catch {
1161
+ /* ignore */
1162
+ }
1163
+ });
1164
+ document.documentElement.appendChild(dock);
1165
+ } else if (wantPos) {
1166
+ dock.dataset.pos = wantPos;
1167
+ try {
1168
+ localStorage.setItem("wg-todo-pos", wantPos);
1169
+ } catch {
1170
+ /* ignore */
1171
+ }
1172
+ }
1173
+
1174
+ const esc = (s) =>
1175
+ String(s)
1176
+ .replace(/&/g, "&amp;")
1177
+ .replace(/</g, "&lt;")
1178
+ .replace(/>/g, "&gt;")
1179
+ .replace(/"/g, "&quot;");
1180
+ const attrId = (raw) => {
1181
+ const id = raw != null && String(raw).trim() ? String(raw).trim() : "";
1182
+ return id ? ' data-wg-todo-item="' + esc(id) + '"' : "";
1183
+ };
1184
+ const renderItems = (items, style) => {
1185
+ const st = style === "checklist" ? "checklist" : "sequential";
1186
+ return (
1187
+ '<ul id="wg-todos" data-wg-todo-style="' +
1188
+ st +
1189
+ '">' +
1190
+ (items || [])
1191
+ .map((t) => {
1192
+ const cls = t.current
1193
+ ? "wg-todo-current"
1194
+ : t.done
1195
+ ? "wg-todo-done"
1196
+ : "wg-todo-pending";
1197
+ let mark;
1198
+ if (st === "checklist") {
1199
+ mark = t.done ? "\u2611" : "\u2610";
1200
+ } else {
1201
+ mark = t.done ? "\u2713" : t.current ? "\u2192" : "\u25CB";
1202
+ }
1203
+ const rowId = t.id || t.name || "";
1204
+ return (
1205
+ "<li class=\"" +
1206
+ cls +
1207
+ "\"" +
1208
+ attrId(rowId) +
1209
+ '><span class="wg-todo-mark">' +
1210
+ mark +
1211
+ "</span><span>" +
1212
+ esc(t.text || "") +
1213
+ "</span></li>"
1214
+ );
1215
+ })
1216
+ .join("") +
1217
+ "</ul>"
1218
+ );
1219
+ };
1220
+
1221
+ const dockId =
1222
+ (dockState && dockState.id) ||
1223
+ (payload && !Array.isArray(payload) && payload.todoId) ||
1224
+ "";
1225
+ if (dockId) dock.setAttribute("data-wg-todo-id", String(dockId));
1226
+ else dock.removeAttribute("data-wg-todo-id");
1227
+
1228
+ let html = "";
1229
+ if (dockState && Array.isArray(dockState.groups) && dockState.groups.length) {
1230
+ const dockTitle = dockState.title && String(dockState.title).trim();
1231
+ if (dockTitle) {
1232
+ html += '<div class="wg-todo-dock-title">' + esc(dockTitle) + "</div>";
1233
+ }
1234
+ for (const g of dockState.groups) {
1235
+ const gTitle = g.title && String(g.title).trim();
1236
+ const gid = g.id || g.name || "";
1237
+ html +=
1238
+ '<div class="wg-todo-group"' +
1239
+ (gid ? ' data-wg-todo-group="' + esc(gid) + '"' : "") +
1240
+ ">";
1241
+ if (gTitle) {
1242
+ html += '<div class="wg-todo-group-title">' + esc(gTitle) + "</div>";
1243
+ }
1244
+ html += renderItems(g.items, g.style || dockState.style);
1245
+ html += "</div>";
1246
+ }
1247
+ } else {
1248
+ html = renderItems(list, "sequential");
1249
+ }
1250
+ dock.innerHTML = html;
1251
+ if (window.__wgStampModal) {
1252
+ window.__wgStampModal(dock, "todos", { ready: true });
1253
+ }
1254
+ };
1049
1255
  },
1050
- { title, favicon: WAYGRAPH_FAVICON, bannerPos, envAutoplay },
1256
+ { title, favicon: WAYGRAPH_FAVICON, bannerPos, todoPos, envAutoplay },
1051
1257
  )
1052
1258
  .catch(() => {});
1053
1259
  }
@@ -1137,41 +1343,15 @@ async function renderBeforeStep(page, info) {
1137
1343
  esc(info.paceLabel || "") +
1138
1344
  "</span></div>"
1139
1345
  : "";
1140
- const todosHtml = (() => {
1141
- const list = info.todos || [];
1142
- if (!list.length) return "";
1143
- return (
1144
- '<ul id=\\"wg-todos\\">' +
1145
- list
1146
- .map((t) => {
1147
- const cls = t.current ? "wg-todo-current" : t.done ? "wg-todo-done" : "wg-todo-pending";
1148
- const mark = t.done
1149
- ? String.fromCharCode(0x2713)
1150
- : t.current
1151
- ? String.fromCharCode(0x2192)
1152
- : String.fromCharCode(0x25cb);
1153
- return (
1154
- '<li class=\\"' +
1155
- cls +
1156
- '\\"><span class=\\"wg-todo-mark\\">' +
1157
- mark +
1158
- '</span><span>' +
1159
- esc(t.text || "") +
1160
- "</span></li>"
1161
- );
1162
- })
1163
- .join("") +
1164
- "</ul>"
1165
- );
1166
- })();
1346
+ // Todos render into floating #wg-todo-dock via __wgSyncTodos - never
1347
+ // inside .wg-body (would vanish under --mini / Hide collapse).
1167
1348
  let html =
1168
1349
  "<div id=\\"wg-progress\\"><div id=\\"wg-progress-bar\\" style=\\"width:" + pct + "%\\"></div></div>" +
1169
1350
  episodesHtml +
1170
1351
  paceHtml +
1171
1352
  "<div id=\\"wg-modules\\" class=\\"" + modulesClass + "\\">" + modulesHtml + "</div>" +
1172
1353
  "<h3>Step " + (info.index + 1) + " / " + info.total + " - " + info.blockName + "</h3>" +
1173
- narrationHtml +
1174
- todosHtml;
1354
+ narrationHtml;
1175
1355
  if (info.keys.length === 0) {
1176
1356
  html += "<div class=\\"wg-key\\">(no MemKeys required)</div>";
1177
1357
  }
@@ -1210,6 +1390,16 @@ async function renderBeforeStep(page, info) {
1210
1390
  forceCollapsed: info.forceCollapsed === true ? true : undefined,
1211
1391
  });
1212
1392
  }
1393
+ if (window.__wgSyncTodos) {
1394
+ const sync = info.todoSync || (info.todos && info.todos.length ? "set" : "keep");
1395
+ window.__wgSyncTodos({
1396
+ sync: sync,
1397
+ dock: info.todoDock || null,
1398
+ list: info.todos || [],
1399
+ pos: info.todoPos || null,
1400
+ todoId: info.todoId || (info.todoDock && info.todoDock.id) || null,
1401
+ });
1402
+ }
1213
1403
  if (window.__wgStampModal) {
1214
1404
  window.__wgStampModal(panel, "panel", {
1215
1405
  phase: "before",
@@ -1552,6 +1742,11 @@ async function cycleHighlightRings(page, highlights, gatesFast, opts) {
1552
1742
  : normalizeTodos(h.todos, h.todoIndex);
1553
1743
  await page
1554
1744
  .evaluate((todos) => {
1745
+ if (window.__wgSyncTodos) {
1746
+ window.__wgSyncTodos(todos);
1747
+ return;
1748
+ }
1749
+ // Fallback for pages that never got installOverlay sync helper.
1555
1750
  const ul = document.getElementById("wg-todos");
1556
1751
  if (!ul) return;
1557
1752
  ul.innerHTML = todos
@@ -1720,33 +1915,7 @@ async function renderAfterStep(page, info) {
1720
1915
  escA(info.paceLabel || "") +
1721
1916
  "</span></div>"
1722
1917
  : "";
1723
- const todosHtml = (() => {
1724
- const list = info.todos || [];
1725
- if (!list.length) return "";
1726
- return (
1727
- '<ul id=\\"wg-todos\\">' +
1728
- list
1729
- .map((t) => {
1730
- const cls = t.current ? "wg-todo-current" : t.done ? "wg-todo-done" : "wg-todo-pending";
1731
- const mark = t.done
1732
- ? String.fromCharCode(0x2713)
1733
- : t.current
1734
- ? String.fromCharCode(0x2192)
1735
- : String.fromCharCode(0x25cb);
1736
- return (
1737
- '<li class=\\"' +
1738
- cls +
1739
- '\\"><span class=\\"wg-todo-mark\\">' +
1740
- mark +
1741
- '</span><span>' +
1742
- escA(t.text || "") +
1743
- "</span></li>"
1744
- );
1745
- })
1746
- .join("") +
1747
- "</ul>"
1748
- );
1749
- })();
1918
+ const todosHtml = ""; // todos float in #wg-todo-dock via __wgSyncTodos
1750
1919
  panel.innerHTML =
1751
1920
  "<div id=\\"wg-progress\\"><div id=\\"wg-progress-bar\\" style=\\"width:" + pct + "%\\"></div></div>" +
1752
1921
  episodesHtml +
@@ -1768,6 +1937,16 @@ async function renderAfterStep(page, info) {
1768
1937
  forceCollapsed: info.forceCollapsed === true ? true : undefined,
1769
1938
  });
1770
1939
  }
1940
+ if (window.__wgSyncTodos) {
1941
+ const sync = info.todoSync || (info.todos && info.todos.length ? "set" : "keep");
1942
+ window.__wgSyncTodos({
1943
+ sync: sync,
1944
+ dock: info.todoDock || null,
1945
+ list: info.todos || [],
1946
+ pos: info.todoPos || null,
1947
+ todoId: info.todoId || (info.todoDock && info.todoDock.id) || null,
1948
+ });
1949
+ }
1771
1950
  if (window.__wgStampModal) {
1772
1951
  window.__wgStampModal(panel, "panel", {
1773
1952
  phase: "after",
@@ -2390,7 +2569,7 @@ async function teardownOverlay(page) {
2390
2569
  window.__wgRingTrack = null;
2391
2570
  }
2392
2571
  window.__wgNarrateOwnsRing = false;
2393
- for (const id of ["wg-panel", "wg-ring", "wg-ring-label", "wg-cursor", "wg-click-pulse", "wg-banner"]) {
2572
+ for (const id of ["wg-panel", "wg-ring", "wg-ring-label", "wg-cursor", "wg-click-pulse", "wg-banner", "wg-todo-dock"]) {
2394
2573
  const el = document.getElementById(id);
2395
2574
  if (el) el.remove();
2396
2575
  }
@@ -2835,6 +3014,8 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2835
3014
  }
2836
3015
 
2837
3016
  let result;
3017
+ /** Carry floating todo dock across steps (empty stubBefore must not wipe). */
3018
+ let lastTodoDock = undefined;
2838
3019
  for (let i = 0; i < resolved.length; i++) {
2839
3020
  const r = resolved[i];
2840
3021
  // The block breadcrumb is scoped to THIS step's own episode, not the
@@ -2920,7 +3101,24 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2920
3101
  stubBeforeRef.current = stubBeforePhase.highlights.map((h) =>
2921
3102
  applyHighlightStyleDefaults(h, flowStyle),
2922
3103
  );
2923
- const stubBeforeTodos = stubBeforePhase.todos || [];
3104
+ const appliedBefore = applyTodoPhase(lastTodoDock, {
3105
+ todoSync: stubBeforePhase.todoSync,
3106
+ todoDock: stubBeforePhase.todoDock,
3107
+ todos: stubBeforePhase.todos,
3108
+ todoPos: stubBeforePhase.todoPos,
3109
+ });
3110
+ lastTodoDock = appliedBefore.dock;
3111
+ const stubBeforeTodos =
3112
+ (lastTodoDock && lastTodoDock.groups[0] && lastTodoDock.groups[0].items) ||
3113
+ stubBeforePhase.todos ||
3114
+ [];
3115
+ const overlayTitle =
3116
+ (stubBeforePhase.title && String(stubBeforePhase.title).trim()) ||
3117
+ (r.episodeTitle && String(r.episodeTitle).trim()) ||
3118
+ title ||
3119
+ "waygraph demo";
3120
+ const overlayTodoPos =
3121
+ (lastTodoDock && lastTodoDock.pos) || stubBeforePhase.todoPos || undefined;
2924
3122
  const isNavBlock = r.block.__waygraphKind === "nav";
2925
3123
  const autoNow = await currentAutoplay();
2926
3124
  // Video / --mini: compact pill. Never pass false - Hide/localStorage wins.
@@ -2965,12 +3163,16 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2965
3163
  moduleIndex,
2966
3164
  allEpisodes,
2967
3165
  justEnteredEpisode,
2968
- title,
3166
+ title: overlayTitle,
3167
+ todoPos: overlayTodoPos,
2969
3168
  episodeNumber: r.episodeNumber,
2970
3169
  episodeTitle: r.episodeTitle,
2971
3170
  stepperMode,
2972
3171
  forceCollapsed,
2973
3172
  todos: stubBeforeTodos,
3173
+ todoDock: lastTodoDock,
3174
+ todoSync: appliedBefore.sync,
3175
+ todoId: lastTodoDock && lastTodoDock.id,
2974
3176
  ...paceSpeak,
2975
3177
  });
2976
3178
  const edits = await gate();
@@ -3124,12 +3326,30 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
3124
3326
  }
3125
3327
  let highlights;
3126
3328
  let stubAfterTodos = [];
3329
+ let afterOverlayTitle = overlayTitle;
3330
+ let afterTodoPos = overlayTodoPos;
3331
+ let appliedAfter = { dock: lastTodoDock, sync: "keep" };
3127
3332
  if (hasAuthoredStubAfter(r.block, result, fixturesAfter)) {
3128
3333
  const afterPhase = await runStubPhase(r.block, "stubAfter", {
3129
3334
  out: result,
3130
3335
  fixtures: fixturesAfter,
3131
3336
  });
3132
- stubAfterTodos = afterPhase.todos || [];
3337
+ appliedAfter = applyTodoPhase(lastTodoDock, {
3338
+ todoSync: afterPhase.todoSync,
3339
+ todoDock: afterPhase.todoDock,
3340
+ todos: afterPhase.todos,
3341
+ todoPos: afterPhase.todoPos,
3342
+ });
3343
+ lastTodoDock = appliedAfter.dock;
3344
+ stubAfterTodos =
3345
+ (lastTodoDock && lastTodoDock.groups[0] && lastTodoDock.groups[0].items) ||
3346
+ afterPhase.todos ||
3347
+ [];
3348
+ if (afterPhase.title && String(afterPhase.title).trim()) {
3349
+ afterOverlayTitle = String(afterPhase.title).trim();
3350
+ }
3351
+ if (afterPhase.todoPos) afterTodoPos = afterPhase.todoPos;
3352
+ if (lastTodoDock && lastTodoDock.pos) afterTodoPos = lastTodoDock.pos;
3133
3353
  highlights = afterPhase.highlights.map((h) => {
3134
3354
  const styled = applyHighlightStyleDefaults(h, r.highlightStyle);
3135
3355
  return {
@@ -3163,11 +3383,15 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
3163
3383
  allDescriptions: moduleDescriptions,
3164
3384
  moduleIndex,
3165
3385
  allEpisodes,
3166
- title,
3386
+ title: afterOverlayTitle,
3387
+ todoPos: afterTodoPos,
3167
3388
  episodeNumber: r.episodeNumber,
3168
3389
  episodeTitle: r.episodeTitle,
3169
3390
  stepperMode,
3170
3391
  todos: stubAfterTodos,
3392
+ todoDock: lastTodoDock,
3393
+ todoSync: appliedAfter.sync,
3394
+ todoId: lastTodoDock && lastTodoDock.id,
3171
3395
  forceCollapsed:
3172
3396
  !!process.env.WAYGRAPH_VIDEO ||
3173
3397
  process.env.WAYGRAPH_MINI === "1" ||
@@ -4191,6 +4415,12 @@ function parseRunFlags(argv) {
4191
4415
  else if (a === "--mini" || a === "--stepper-mini") {
4192
4416
  out.miniStepper = true;
4193
4417
  }
4418
+ else if (a === "--todo-left") {
4419
+ out.todoPos = "left";
4420
+ }
4421
+ else if (a === "--todo-right") {
4422
+ out.todoPos = "right";
4423
+ }
4194
4424
  else if (a === "--ff-expand") {
4195
4425
  out.ffExpand = true;
4196
4426
  }
@@ -4311,8 +4541,8 @@ function applyRunFlags(flags, opts) {
4311
4541
  process.env.WAYGRAPH_HEADED = "0";
4312
4542
  }
4313
4543
  }
4314
- if ((flags.fast || flags.fullStepper || flags.miniStepper) && !opts?.allowDemoUi) {
4315
- console.error("waygraph: --fast / --full / --mini are demo-only flags");
4544
+ if ((flags.fast || flags.fullStepper || flags.miniStepper || flags.todoPos) && !opts?.allowDemoUi) {
4545
+ console.error("waygraph: --fast / --full / --mini / --todo-left|--todo-right are demo-only flags");
4316
4546
  process.exit(1);
4317
4547
  }
4318
4548
  if (flags.step === true) {
@@ -4359,6 +4589,9 @@ function applyRunFlags(flags, opts) {
4359
4589
  if (flags.miniStepper) {
4360
4590
  process.env.WAYGRAPH_MINI = "1";
4361
4591
  }
4592
+ if (flags.todoPos === "left" || flags.todoPos === "right") {
4593
+ process.env.WAYGRAPH_TODO_POS = flags.todoPos;
4594
+ }
4362
4595
  if (flags.ffExpand) {
4363
4596
  process.env.WAYGRAPH_FF_EXPAND = "1";
4364
4597
  }
@@ -4524,6 +4757,7 @@ Primary (less is more):
4524
4757
  --fast Shorter auto-next / Next gates (keeps smooth cursor)
4525
4758
  --full Classic wrap-all block chips (default: carousel)
4526
4759
  --mini Force compact mini panel (Hide pill + Next); alias --stepper-mini
4760
+ --todo-left|--todo-right Floating checklist dock side (also ctx.todoPos / WAYGRAPH_TODO_POS)
4527
4761
  --ff-expand Expand fastForwardComposeBlock inners as separate steps
4528
4762
  --ff-disabled Dispute: expand FF (alias --no-ff); blitz kept on those inners
4529
4763
  --auto-play-video Unattended + recorded: --auto-next + --video (+ step); headless by default
@@ -34,17 +34,75 @@ export type FixtureDurationFields = {
34
34
  */
35
35
  export type WaygraphTodoItem = {
36
36
  text: string;
37
+ /**
38
+ * Stable id for external asserts / redirects (Mailhog, deep links).
39
+ * Stamped as `data-wg-todo-item` on the row.
40
+ */
41
+ id?: string;
42
+ /** Alias for {@link WaygraphTodoItem.id}. */
43
+ name?: string;
37
44
  /** Explicit done. If omitted, derived from todoIndex (index < todoIndex). */
38
45
  done?: boolean;
39
46
  /** Explicit current. If omitted, derived from todoIndex (index === todoIndex). */
40
47
  current?: boolean;
41
48
  };
42
49
  export type WaygraphTodoInput = string | WaygraphTodoItem;
50
+ /**
51
+ * Dock list presentation:
52
+ * - `sequential` - arrow on current, strike done (demo walkthrough)
53
+ * - `checklist` - checkbox done/pending only (no current arrow; independent checks)
54
+ */
55
+ export type TodoListStyle = "sequential" | "checklist";
56
+ /** One titled group in the floating todo dock (FR / Scenarios / ACs, etc.). */
57
+ export type WaygraphTodoGroupInput = {
58
+ /** Stable group id -> `data-wg-todo-group`. */
59
+ id?: string;
60
+ /** Alias for {@link WaygraphTodoGroupInput.id}. */
61
+ name?: string;
62
+ /** Group heading shown above the items. */
63
+ title?: string;
64
+ /** Per-group style; falls back to dock default ({@link StubCtx.todoStyle}). */
65
+ style?: TodoListStyle;
66
+ items: readonly WaygraphTodoInput[];
67
+ /** Sequential only: which item is current (0-based). */
68
+ todoIndex?: number;
69
+ };
70
+ export type WaygraphTodoGroup = {
71
+ id?: string;
72
+ title?: string;
73
+ style: TodoListStyle;
74
+ items: WaygraphTodoItem[];
75
+ };
76
+ /** Full floating dock payload passed to the browser. */
77
+ export type TodoDockState = {
78
+ /**
79
+ * Stable dock id for external targeting after redirects
80
+ * (`#wg-todo-dock[data-wg-todo-id="…"]`).
81
+ */
82
+ id?: string;
83
+ /** Default style for groups that omit their own. */
84
+ style: TodoListStyle;
85
+ /** Single-list title when using {@link StubCtx.todos} + {@link StubCtx.todoTitle}. */
86
+ title?: string;
87
+ groups: WaygraphTodoGroup[];
88
+ pos?: "left" | "right";
89
+ };
43
90
  /** Episode-level fixtures authored inside stubBefore/After/OnError(ctx). */
44
91
  export type StubPhaseFixtures = {
45
92
  todos?: readonly WaygraphTodoInput[];
46
93
  /** Which todo is current (0-based). Rows before = done, after = pending. */
47
94
  todoIndex?: number;
95
+ /** Heading above a single {@link StubCtx.todos} list. */
96
+ todoTitle?: string;
97
+ /**
98
+ * Stable dock id (`data-wg-todo-id`) for external redirect asserts
99
+ * (e.g. Mailhog -> back to app and still find the checklist).
100
+ */
101
+ todoId?: string;
102
+ /** `sequential` (default) or `checklist`. */
103
+ todoStyle?: TodoListStyle;
104
+ /** Multiple titled lists (FR / Scenarios / ACs). Wins over bare todos when set. */
105
+ todoGroups?: readonly WaygraphTodoGroupInput[];
48
106
  /**
49
107
  * Camera zoom (Screen Studio style) - scales the page toward the target /
50
108
  * cursor. Not element CSS zoom. Typical `1.25` .. `2`.
@@ -54,6 +112,11 @@ export type StubPhaseFixtures = {
54
112
  zoomOut?: boolean;
55
113
  /** Top demo banner title (the purple "waygraph demo" card). */
56
114
  title?: string;
115
+ /**
116
+ * Floating checklist dock side. `left` | `right`.
117
+ * Authored control (also click / WAYGRAPH_TODO_POS / --todo-left|right).
118
+ */
119
+ todoPos?: "left" | "right";
57
120
  };
58
121
  /** One named highlight slot on a block (selector + caption). */
59
122
  export interface WaygraphHighlightStub extends FixtureDurationFields {
@@ -147,10 +210,34 @@ export type StubCtx<Out extends Checkpoint<string> = Checkpoint<string>> = {
147
210
  highlights(slots: HighlightStubPhase): void;
148
211
  /** Set / overwrite one named ring. */
149
212
  ring(id: string, stub: WaygraphHighlightStub): void;
150
- /** Episode checklist (demo panel). */
213
+ /** Episode checklist (demo panel). Single list; use {@link StubCtx.todoGroups} for FR/AC packs. */
151
214
  todos(items: readonly WaygraphTodoInput[]): void;
152
215
  /** Current checklist index (bump for sequential plans). */
153
216
  todoIndex(n: number): void;
217
+ /** Heading above the single {@link StubCtx.todos} list. */
218
+ todoTitle(text: string): void;
219
+ /**
220
+ * Stable dock id for external selectors after redirects
221
+ * (`#wg-todo-dock[data-wg-todo-id="ep10-coverage"]`).
222
+ */
223
+ todoId(id: string): void;
224
+ /**
225
+ * Dock presentation: `sequential` (arrow walkthrough) or `checklist`
226
+ * (independent checkboxes, no current arrow).
227
+ */
228
+ todoStyle(style: TodoListStyle): void;
229
+ /**
230
+ * Multiple titled lists (e.g. Functional Requirements / Scenarios / ACs).
231
+ * Replaces a bare {@link StubCtx.todos} list when set.
232
+ */
233
+ todoGroups(groups: readonly WaygraphTodoGroupInput[]): void;
234
+ /**
235
+ * Hide the floating todo dock. Omit todos on a later block = **keep** the
236
+ * previous dock (carry forward). Only hideTodos / clearTodos / todos([]) clears it.
237
+ */
238
+ hideTodos(): void;
239
+ /** Alias for {@link StubCtx.hideTodos}. */
240
+ clearTodos(): void;
154
241
  /** Default camera zoom for rings without their own zoom. */
155
242
  zoom(n: number): void;
156
243
  /**
@@ -161,6 +248,11 @@ export type StubCtx<Out extends Checkpoint<string> = Checkpoint<string>> = {
161
248
  title(text: string): void;
162
249
  /** Alias for {@link StubCtx.title}. */
163
250
  banner(text: string): void;
251
+ /**
252
+ * Floating checklist dock side (`left` | `right`). Authors control this in
253
+ * code; click / env / --todo-left|right are fallbacks.
254
+ */
255
+ todoPos(side: "left" | "right"): void;
164
256
  /** Batch-set rings + episode fixtures. */
165
257
  set(partial: {
166
258
  highlights?: HighlightStubPhase;
@@ -177,11 +269,21 @@ export type HighlightStubPhaseOrFn<Out extends Checkpoint<string>> = HighlightSt
177
269
  /** Result of running a stub phase (rings + episode fixtures). */
178
270
  export type StubPhaseResult = {
179
271
  highlights: ResolvedHighlight[];
272
+ /** Flattened first-group items (compat). Prefer {@link todoDock}. */
180
273
  todos: WaygraphTodoItem[];
181
274
  todoIndex?: number;
275
+ todoDock?: TodoDockState;
276
+ /**
277
+ * Todo dock intent for this phase:
278
+ * - `set` - replace dock with {@link todoDock}
279
+ * - `clear` - hide dock (author called hideTodos / empty todos())
280
+ * - `keep` - author omitted todos; demo must leave previous dock alone
281
+ */
282
+ todoSync?: "set" | "clear" | "keep";
182
283
  zoom?: number;
183
284
  zoomOut?: boolean;
184
285
  title?: string;
286
+ todoPos?: "left" | "right";
185
287
  };
186
288
  /**
187
289
  * One yap/slide in a multi-step demo caption sequence.
@@ -318,10 +420,51 @@ export interface WaygraphHighlight {
318
420
  tone?: HighlightTone;
319
421
  }
320
422
  export type ResolvedHighlight = WaygraphHighlightStub;
423
+ /** Normalize checklist dock side. */
424
+ export declare function normalizeTodoPos(raw: unknown): "left" | "right" | undefined;
425
+ /** Normalize dock list style. */
426
+ export declare function normalizeTodoStyle(raw: unknown): TodoListStyle | undefined;
321
427
  /**
322
428
  * Normalize checklist rows; apply todoIndex for sequential done/current.
429
+ * In checklist mode, todoIndex is ignored unless items omit explicit done.
430
+ */
431
+ export declare function normalizeTodos(todos: readonly WaygraphTodoInput[] | undefined, todoIndex?: number, style?: TodoListStyle): WaygraphTodoItem[];
432
+ /** Build the floating dock from bag / phase fields. */
433
+ export declare function buildTodoDock(opts: {
434
+ todos?: readonly WaygraphTodoInput[] | undefined;
435
+ todoIndex?: number | undefined;
436
+ todoTitle?: string | undefined;
437
+ todoId?: string | undefined;
438
+ todoStyle?: TodoListStyle | undefined;
439
+ todoGroups?: readonly WaygraphTodoGroupInput[] | undefined;
440
+ todoPos?: "left" | "right" | undefined;
441
+ }): TodoDockState | undefined;
442
+ /**
443
+ * Carry-forward rules for the floating todo dock across demo steps.
444
+ * - clear: author called hideTodos / empty todos()
445
+ * - set: author authored a new dock this phase
446
+ * - keep: author omitted todos — preserve previous dock (do not wipe)
447
+ */
448
+ export declare function applyTodoPhase(prev: TodoDockState | undefined, phase: {
449
+ todoSync?: "set" | "clear" | "keep";
450
+ todoDock?: TodoDockState;
451
+ todos?: WaygraphTodoItem[];
452
+ todoPos?: "left" | "right";
453
+ todoId?: string;
454
+ }): {
455
+ dock: TodoDockState | undefined;
456
+ sync: "set" | "clear" | "keep";
457
+ };
458
+ /**
459
+ * Advance every sequential group to `index` (capped). Checklist groups unchanged.
460
+ * Used while cycling rings / fill/click stubs so the walkthrough moves.
461
+ */
462
+ export declare function advanceTodoDock(dock: TodoDockState | undefined, index: number): TodoDockState | undefined;
463
+ /**
464
+ * Mark sequential groups complete (all done, none current) - e.g. end of step.
465
+ * Checklist groups keep explicit done flags.
323
466
  */
324
- export declare function normalizeTodos(todos: readonly WaygraphTodoInput[] | undefined, todoIndex?: number): WaygraphTodoItem[];
467
+ export declare function completeSequentialTodoDock(dock: TodoDockState | undefined): TodoDockState | undefined;
325
468
  /**
326
469
  * Run an open stub lifecycle (preferred) or resolve a static slot map.
327
470
  * Returns rings + episode fixtures (todos / todoIndex / zoom).
@@ -293,31 +293,6 @@ function pickStyleFields(base, patch) {
293
293
  out.weight = normalizeHighlightWeight(weight);
294
294
  return out;
295
295
  }
296
- /**
297
- * Normalize checklist rows; apply todoIndex for sequential done/current.
298
- */
299
- export function normalizeTodos(todos, todoIndex) {
300
- if (!todos || todos.length === 0)
301
- return [];
302
- const idx = todoIndex !== undefined && Number.isFinite(todoIndex) ? Math.floor(todoIndex) : undefined;
303
- return todos.map((t, i) => {
304
- const text = typeof t === "string" ? t.trim() : String(t?.text ?? "").trim();
305
- const item = { text: text || `Item ${i + 1}` };
306
- if (typeof t === "object" && t) {
307
- if (t.done !== undefined)
308
- item.done = !!t.done;
309
- if (t.current !== undefined)
310
- item.current = !!t.current;
311
- }
312
- if (idx !== undefined) {
313
- if (item.done === undefined)
314
- item.done = i < idx;
315
- if (item.current === undefined)
316
- item.current = i === idx;
317
- }
318
- return item;
319
- });
320
- }
321
296
  function pickZoom(base, patch) {
322
297
  const zoom = patch?.zoom !== undefined ? patch.zoom : base?.zoom;
323
298
  if (zoom !== undefined && Number.isFinite(zoom) && zoom > 0)
@@ -383,6 +358,191 @@ function mergeSlot(base, patch) {
383
358
  ...fx,
384
359
  };
385
360
  }
361
+ /** Normalize checklist dock side. */
362
+ export function normalizeTodoPos(raw) {
363
+ const s = String(raw ?? "")
364
+ .trim()
365
+ .toLowerCase();
366
+ if (s === "left" || s === "l")
367
+ return "left";
368
+ if (s === "right" || s === "r")
369
+ return "right";
370
+ return undefined;
371
+ }
372
+ /** Normalize dock list style. */
373
+ export function normalizeTodoStyle(raw) {
374
+ const s = String(raw ?? "")
375
+ .trim()
376
+ .toLowerCase();
377
+ if (s === "sequential" || s === "seq" || s === "steps" || s === "step")
378
+ return "sequential";
379
+ if (s === "checklist" || s === "check" || s === "checks" || s === "box")
380
+ return "checklist";
381
+ return undefined;
382
+ }
383
+ /**
384
+ * Normalize checklist rows; apply todoIndex for sequential done/current.
385
+ * In checklist mode, todoIndex is ignored unless items omit explicit done.
386
+ */
387
+ export function normalizeTodos(todos, todoIndex, style = "sequential") {
388
+ if (!todos || todos.length === 0)
389
+ return [];
390
+ const idx = todoIndex !== undefined && Number.isFinite(todoIndex) ? Math.floor(todoIndex) : undefined;
391
+ return todos.map((t, i) => {
392
+ const text = typeof t === "string" ? t.trim() : String(t?.text ?? "").trim();
393
+ const item = { text: text || `Item ${i + 1}` };
394
+ if (typeof t === "object" && t) {
395
+ if (t.done !== undefined)
396
+ item.done = !!t.done;
397
+ if (t.current !== undefined)
398
+ item.current = !!t.current;
399
+ const idRaw = t.id ?? t.name;
400
+ if (idRaw != null && String(idRaw).trim())
401
+ item.id = String(idRaw).trim();
402
+ }
403
+ if (style === "checklist") {
404
+ if (item.done === undefined)
405
+ item.done = false;
406
+ item.current = false;
407
+ return item;
408
+ }
409
+ if (idx !== undefined) {
410
+ if (item.done === undefined)
411
+ item.done = i < idx;
412
+ if (item.current === undefined)
413
+ item.current = i === idx;
414
+ }
415
+ return item;
416
+ });
417
+ }
418
+ /** Build the floating dock from bag / phase fields. */
419
+ export function buildTodoDock(opts) {
420
+ const style = normalizeTodoStyle(opts.todoStyle) || "sequential";
421
+ const pos = normalizeTodoPos(opts.todoPos);
422
+ const dockId = opts.todoId != null && String(opts.todoId).trim() ? String(opts.todoId).trim() : undefined;
423
+ if (opts.todoGroups && opts.todoGroups.length) {
424
+ const groups = opts.todoGroups
425
+ .map((g) => {
426
+ const gs = normalizeTodoStyle(g.style) || style;
427
+ const items = normalizeTodos(g.items, g.todoIndex, gs);
428
+ if (!items.length)
429
+ return null;
430
+ const group = { style: gs, items };
431
+ if (g.title && String(g.title).trim())
432
+ group.title = String(g.title).trim();
433
+ const gid = g.id ?? g.name;
434
+ if (gid != null && String(gid).trim())
435
+ group.id = String(gid).trim();
436
+ return group;
437
+ })
438
+ .filter((g) => !!g);
439
+ if (!groups.length)
440
+ return undefined;
441
+ const dock = { style, groups };
442
+ if (pos)
443
+ dock.pos = pos;
444
+ if (dockId)
445
+ dock.id = dockId;
446
+ return dock;
447
+ }
448
+ const items = normalizeTodos(opts.todos, opts.todoIndex, style);
449
+ if (!items.length)
450
+ return undefined;
451
+ const group = { style, items };
452
+ if (opts.todoTitle && String(opts.todoTitle).trim()) {
453
+ group.title = String(opts.todoTitle).trim();
454
+ }
455
+ const dock = { style, groups: [group] };
456
+ if (opts.todoTitle && String(opts.todoTitle).trim()) {
457
+ dock.title = String(opts.todoTitle).trim();
458
+ }
459
+ if (pos)
460
+ dock.pos = pos;
461
+ if (dockId)
462
+ dock.id = dockId;
463
+ return dock;
464
+ }
465
+ /**
466
+ * Carry-forward rules for the floating todo dock across demo steps.
467
+ * - clear: author called hideTodos / empty todos()
468
+ * - set: author authored a new dock this phase
469
+ * - keep: author omitted todos — preserve previous dock (do not wipe)
470
+ */
471
+ export function applyTodoPhase(prev, phase) {
472
+ const sync = phase.todoSync ||
473
+ (phase.todoDock ? "set" : phase.todos && phase.todos.length ? "set" : "keep");
474
+ if (sync === "clear")
475
+ return { dock: undefined, sync: "clear" };
476
+ if (sync === "set") {
477
+ if (phase.todoDock)
478
+ return { dock: phase.todoDock, sync: "set" };
479
+ if (phase.todos && phase.todos.length) {
480
+ const dock = buildTodoDock({
481
+ todos: phase.todos,
482
+ ...(phase.todoPos ? { todoPos: phase.todoPos } : {}),
483
+ ...(phase.todoId ? { todoId: phase.todoId } : {}),
484
+ });
485
+ return { dock, sync: "set" };
486
+ }
487
+ return { dock: undefined, sync: "clear" };
488
+ }
489
+ return { dock: prev, sync: "keep" };
490
+ }
491
+ /**
492
+ * Advance every sequential group to `index` (capped). Checklist groups unchanged.
493
+ * Used while cycling rings / fill/click stubs so the walkthrough moves.
494
+ */
495
+ export function advanceTodoDock(dock, index) {
496
+ if (!dock || !dock.groups.length)
497
+ return dock;
498
+ const i = Number.isFinite(index) ? Math.max(0, Math.floor(index)) : 0;
499
+ return {
500
+ ...dock,
501
+ groups: dock.groups.map((g) => {
502
+ if (g.style !== "sequential")
503
+ return g;
504
+ const capped = Math.min(i, Math.max(0, g.items.length - 1));
505
+ const raw = g.items.map((it) => {
506
+ const row = { text: it.text };
507
+ if (it.id)
508
+ row.id = it.id;
509
+ else if (it.name)
510
+ row.id = it.name;
511
+ return row;
512
+ });
513
+ return {
514
+ ...g,
515
+ items: normalizeTodos(raw, capped, "sequential"),
516
+ };
517
+ }),
518
+ };
519
+ }
520
+ /**
521
+ * Mark sequential groups complete (all done, none current) - e.g. end of step.
522
+ * Checklist groups keep explicit done flags.
523
+ */
524
+ export function completeSequentialTodoDock(dock) {
525
+ if (!dock)
526
+ return dock;
527
+ return {
528
+ ...dock,
529
+ groups: dock.groups.map((g) => {
530
+ if (g.style !== "sequential")
531
+ return g;
532
+ return {
533
+ ...g,
534
+ items: g.items.map((it) => {
535
+ const row = { text: it.text, done: true, current: false };
536
+ if (it.id)
537
+ row.id = it.id;
538
+ else if (it.name)
539
+ row.id = it.name;
540
+ return row;
541
+ }),
542
+ };
543
+ }),
544
+ };
545
+ }
386
546
  function createStubCtx(bag, opts) {
387
547
  return {
388
548
  out: opts.out,
@@ -394,11 +554,52 @@ function createStubCtx(bag, opts) {
394
554
  bag.highlights = { ...bag.highlights, [id]: stub };
395
555
  },
396
556
  todos(items) {
557
+ bag.todoTouched = true;
558
+ bag.hideTodos = false;
397
559
  bag.todos = items;
560
+ delete bag.todoGroups;
561
+ // Empty list = explicit clear (same as hideTodos).
562
+ if (!items || !items.length)
563
+ bag.hideTodos = true;
398
564
  },
399
565
  todoIndex(n) {
400
566
  bag.todoIndex = n;
401
567
  },
568
+ todoTitle(text) {
569
+ bag.todoTitle = String(text ?? "");
570
+ },
571
+ todoId(id) {
572
+ bag.todoTouched = true;
573
+ const cleaned = String(id ?? "").trim();
574
+ if (cleaned)
575
+ bag.todoId = cleaned;
576
+ else
577
+ delete bag.todoId;
578
+ },
579
+ todoStyle(style) {
580
+ const s = normalizeTodoStyle(style);
581
+ if (s)
582
+ bag.todoStyle = s;
583
+ },
584
+ todoGroups(groups) {
585
+ bag.todoTouched = true;
586
+ bag.hideTodos = false;
587
+ bag.todoGroups = groups;
588
+ if (!groups || !groups.length)
589
+ bag.hideTodos = true;
590
+ },
591
+ hideTodos() {
592
+ bag.todoTouched = true;
593
+ bag.hideTodos = true;
594
+ bag.todos = [];
595
+ delete bag.todoGroups;
596
+ },
597
+ clearTodos() {
598
+ bag.todoTouched = true;
599
+ bag.hideTodos = true;
600
+ bag.todos = [];
601
+ delete bag.todoGroups;
602
+ },
402
603
  zoom(n) {
403
604
  if (Number.isFinite(n) && n > 0)
404
605
  bag.zoom = n;
@@ -412,13 +613,41 @@ function createStubCtx(bag, opts) {
412
613
  banner(text) {
413
614
  bag.title = String(text ?? "");
414
615
  },
616
+ todoPos(side) {
617
+ const p = normalizeTodoPos(side);
618
+ if (p)
619
+ bag.todoPos = p;
620
+ },
415
621
  set(partial) {
416
622
  if (partial.highlights)
417
623
  bag.highlights = { ...partial.highlights };
418
- if (partial.todos !== undefined)
624
+ if (partial.todos !== undefined) {
625
+ bag.todoTouched = true;
626
+ bag.hideTodos = !partial.todos || !partial.todos.length;
419
627
  bag.todos = partial.todos;
628
+ if (partial.todoGroups === undefined)
629
+ delete bag.todoGroups;
630
+ }
420
631
  if (partial.todoIndex !== undefined)
421
632
  bag.todoIndex = partial.todoIndex;
633
+ if (partial.todoTitle !== undefined)
634
+ bag.todoTitle = String(partial.todoTitle ?? "");
635
+ if (partial.todoId !== undefined) {
636
+ bag.todoTouched = true;
637
+ const cleaned = String(partial.todoId ?? "").trim();
638
+ if (cleaned)
639
+ bag.todoId = cleaned;
640
+ else
641
+ delete bag.todoId;
642
+ }
643
+ const ts = normalizeTodoStyle(partial.todoStyle);
644
+ if (ts)
645
+ bag.todoStyle = ts;
646
+ if (partial.todoGroups !== undefined) {
647
+ bag.todoTouched = true;
648
+ bag.hideTodos = !partial.todoGroups || !partial.todoGroups.length;
649
+ bag.todoGroups = partial.todoGroups;
650
+ }
422
651
  if (partial.zoom !== undefined && Number.isFinite(partial.zoom) && partial.zoom > 0) {
423
652
  bag.zoom = partial.zoom;
424
653
  }
@@ -426,6 +655,9 @@ function createStubCtx(bag, opts) {
426
655
  bag.zoomOut = !!partial.zoomOut;
427
656
  if (partial.title !== undefined)
428
657
  bag.title = String(partial.title ?? "");
658
+ const tp = normalizeTodoPos(partial.todoPos);
659
+ if (tp)
660
+ bag.todoPos = tp;
429
661
  },
430
662
  };
431
663
  }
@@ -557,14 +789,39 @@ export async function runStubPhase(block, phase, opts) {
557
789
  let highlights = mergePhaseMaps(bag.highlights, fixturePhase);
558
790
  highlights = applyDefaultZoom(highlights, bag.zoom);
559
791
  highlights = applyDefaultZoomOut(highlights, bag.zoomOut);
560
- const todos = normalizeTodos(bag.todos, bag.todoIndex);
792
+ const todoDock = bag.hideTodos
793
+ ? undefined
794
+ : buildTodoDock({
795
+ todos: bag.todos,
796
+ todoIndex: bag.todoIndex,
797
+ todoTitle: bag.todoTitle,
798
+ todoId: bag.todoId,
799
+ todoStyle: bag.todoStyle,
800
+ todoGroups: bag.todoGroups,
801
+ todoPos: bag.todoPos,
802
+ });
803
+ const todos = todoDock?.groups[0]?.items ||
804
+ (bag.hideTodos
805
+ ? []
806
+ : normalizeTodos(bag.todos, bag.todoIndex, normalizeTodoStyle(bag.todoStyle) || "sequential"));
807
+ // Omit todos entirely => keep previous dock. Explicit empty / hideTodos => clear.
808
+ const todoSync = bag.hideTodos
809
+ ? "clear"
810
+ : bag.todoTouched && todoDock
811
+ ? "set"
812
+ : bag.todoTouched && !todoDock
813
+ ? "clear"
814
+ : "keep";
561
815
  return {
562
816
  highlights,
563
817
  todos,
818
+ todoSync,
564
819
  ...(bag.todoIndex !== undefined ? { todoIndex: bag.todoIndex } : {}),
820
+ ...(todoDock ? { todoDock } : {}),
565
821
  ...(bag.zoom !== undefined ? { zoom: bag.zoom } : {}),
566
822
  ...(bag.zoomOut !== undefined ? { zoomOut: bag.zoomOut } : {}),
567
823
  ...(bag.title !== undefined && bag.title !== "" ? { title: bag.title } : {}),
824
+ ...(bag.todoPos !== undefined ? { todoPos: bag.todoPos } : {}),
568
825
  };
569
826
  }
570
827
  /**
package/dist/index.d.ts CHANGED
@@ -3,8 +3,8 @@ export { connect, checkpoint } from "./types.js";
3
3
  export { MemKey, MemPage, key, keyGroup } from "./mem-page.js";
4
4
  export { Trait, urlMatches, textEquals, visible } from "./trait.js";
5
5
  export { runGraph, spawnTab, narrate, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, defineNavBlock, defineNavClickBlock, defineMethodBlock, defineActionBlock, definePageBlock, defineEffectBlock, defineMemEffectBlock, defineMemNavBlock, locate, composeBlock, fastForwardComposeBlock, isFastForwardBlock, preflight, withSessionReset, withTitle, withDemoPace, withBlockPace, withHighlightStyle, withExpectedFailure, withHighlightFixtures, chainFlow, } from "./engine.js";
6
- export { resolveHighlightSlots, resolveSlides, runStubPhase, resolveFixtureDwellMs, resolveSlideDwellMs, formatHighlightCaption, hasAuthoredStubAfter, hasAuthoredStubOnError, normalizeHighlightTone, normalizeDemoPace, normalizeHighlightSize, normalizeHighlightWeight, applyHighlightStyleDefaults, toneIconPrefix, resolveStepDemoPace, demoPaceScale, demoPaceIsBlitz, demoPaceIsFast, demoPaceIsSlow, demoPaceGateMs, formatDemoPaceBadge, formatDemoPaceLabel, normalizeTodos, FIXTURE_DURATION_MS_DEFAULT, FIXTURE_DURATION_FAST_MS_DEFAULT, FIXTURE_DURATION_SLOW_MS_DEFAULT, SLIDE_DURATION_MS_DEFAULT, SLIDE_DURATION_FAST_MS_DEFAULT, } from "./highlights.js";
7
- export type { WaygraphHighlightStub, WaygraphHighlightFixture, WaygraphSlide, WaygraphTodoItem, WaygraphTodoInput, FixtureDurationFields, HighlightStubPhase, HighlightStubPhaseName, HighlightFixtureMap, ResolvedHighlight, WaygraphHighlight, HighlightTone, HighlightSize, HighlightWeight, HighlightStyleDefaults, DemoPace, DemoPaceName, StubCtx, StubLifecycleFn, StubPhaseResult, StubPhaseFixtures, } from "./highlights.js";
6
+ export { resolveHighlightSlots, resolveSlides, runStubPhase, resolveFixtureDwellMs, resolveSlideDwellMs, formatHighlightCaption, hasAuthoredStubAfter, hasAuthoredStubOnError, normalizeHighlightTone, normalizeDemoPace, normalizeHighlightSize, normalizeHighlightWeight, applyHighlightStyleDefaults, toneIconPrefix, resolveStepDemoPace, demoPaceScale, demoPaceIsBlitz, demoPaceIsFast, demoPaceIsSlow, demoPaceGateMs, formatDemoPaceBadge, formatDemoPaceLabel, normalizeTodos, normalizeTodoPos, normalizeTodoStyle, buildTodoDock, applyTodoPhase, advanceTodoDock, completeSequentialTodoDock, FIXTURE_DURATION_MS_DEFAULT, FIXTURE_DURATION_FAST_MS_DEFAULT, FIXTURE_DURATION_SLOW_MS_DEFAULT, SLIDE_DURATION_MS_DEFAULT, SLIDE_DURATION_FAST_MS_DEFAULT, } from "./highlights.js";
7
+ export type { WaygraphHighlightStub, WaygraphHighlightFixture, WaygraphSlide, WaygraphTodoItem, WaygraphTodoInput, WaygraphTodoGroup, WaygraphTodoGroupInput, TodoDockState, TodoListStyle, FixtureDurationFields, HighlightStubPhase, HighlightStubPhaseName, HighlightFixtureMap, ResolvedHighlight, WaygraphHighlight, HighlightTone, HighlightSize, HighlightWeight, HighlightStyleDefaults, DemoPace, DemoPaceName, StubCtx, StubLifecycleFn, StubPhaseResult, StubPhaseFixtures, } from "./highlights.js";
8
8
  export type { Flow, EngineConfig, ComposedBlock, FastForwardComposedBlock, RunGraphOptions, BlockInfo, NavBlock, NavBlockOptions, NavClickBlock, NavClickBlockOptions, MethodBlock, ActionBlock, PageBlock, PageBlockOptions, PageMethodEntry, EffectBlock, MemEffectBlock, MemNavBlock, } from "./engine.js";
9
9
  export type { ActionPage } from "./types.js";
10
10
  export { discoverGraph, toMermaid, findOrphanBlocks, findBlockPath } from "./graph.js";
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ export { connect, checkpoint } from "./types.js";
2
2
  export { MemKey, MemPage, key, keyGroup } from "./mem-page.js";
3
3
  export { Trait, urlMatches, textEquals, visible } from "./trait.js";
4
4
  export { runGraph, spawnTab, narrate, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, defineNavBlock, defineNavClickBlock, defineMethodBlock, defineActionBlock, definePageBlock, defineEffectBlock, defineMemEffectBlock, defineMemNavBlock, locate, composeBlock, fastForwardComposeBlock, isFastForwardBlock, preflight, withSessionReset, withTitle, withDemoPace, withBlockPace, withHighlightStyle, withExpectedFailure, withHighlightFixtures, chainFlow, } from "./engine.js";
5
- export { resolveHighlightSlots, resolveSlides, runStubPhase, resolveFixtureDwellMs, resolveSlideDwellMs, formatHighlightCaption, hasAuthoredStubAfter, hasAuthoredStubOnError, normalizeHighlightTone, normalizeDemoPace, normalizeHighlightSize, normalizeHighlightWeight, applyHighlightStyleDefaults, toneIconPrefix, resolveStepDemoPace, demoPaceScale, demoPaceIsBlitz, demoPaceIsFast, demoPaceIsSlow, demoPaceGateMs, formatDemoPaceBadge, formatDemoPaceLabel, normalizeTodos, FIXTURE_DURATION_MS_DEFAULT, FIXTURE_DURATION_FAST_MS_DEFAULT, FIXTURE_DURATION_SLOW_MS_DEFAULT, SLIDE_DURATION_MS_DEFAULT, SLIDE_DURATION_FAST_MS_DEFAULT, } from "./highlights.js";
5
+ export { resolveHighlightSlots, resolveSlides, runStubPhase, resolveFixtureDwellMs, resolveSlideDwellMs, formatHighlightCaption, hasAuthoredStubAfter, hasAuthoredStubOnError, normalizeHighlightTone, normalizeDemoPace, normalizeHighlightSize, normalizeHighlightWeight, applyHighlightStyleDefaults, toneIconPrefix, resolveStepDemoPace, demoPaceScale, demoPaceIsBlitz, demoPaceIsFast, demoPaceIsSlow, demoPaceGateMs, formatDemoPaceBadge, formatDemoPaceLabel, normalizeTodos, normalizeTodoPos, normalizeTodoStyle, buildTodoDock, applyTodoPhase, advanceTodoDock, completeSequentialTodoDock, FIXTURE_DURATION_MS_DEFAULT, FIXTURE_DURATION_FAST_MS_DEFAULT, FIXTURE_DURATION_SLOW_MS_DEFAULT, SLIDE_DURATION_MS_DEFAULT, SLIDE_DURATION_FAST_MS_DEFAULT, } from "./highlights.js";
6
6
  export { discoverGraph, toMermaid, findOrphanBlocks, findBlockPath } from "./graph.js";
7
7
  export { isFileSelectToken, parseBlocksSelect, matchBlocksSelect, globToRegExp, } from "./blocks-select.js";
8
8
  export { EdgeLeaseCoordinator } from "./traverse-lease.js";
@@ -1,7 +1,9 @@
1
1
  const RING_CSS = "#wg-ring{position:fixed;z-index:2147483646;pointer-events:none;opacity:0;" +
2
2
  "border:2.5px solid #7C3AED;border-radius:10px;box-shadow:0 0 0 4px rgba(124,58,237,.16);" +
3
- "transition:opacity .3s ease,border-color .15s,box-shadow .15s;}" +
3
+ // Opacity only - tone color must snap (no gray/purple/yellow morph).
4
+ "transition:opacity .3s ease;}" +
4
5
  // Automation = gray; semantic tones for authored highlights.
6
+ "#wg-ring[data-tone=planned]{border-color:#7C3AED;box-shadow:0 0 0 4px rgba(124,58,237,.16);}" +
5
7
  "#wg-ring[data-tone=auto]{border-color:#9CA3AF;box-shadow:0 0 0 4px rgba(156,163,175,.28);}" +
6
8
  "#wg-ring[data-tone=info]{border-color:#3B82F6;box-shadow:0 0 0 4px rgba(59,130,246,.22);}" +
7
9
  "#wg-ring[data-tone=warning]{border-color:#EAB308;box-shadow:0 0 0 4px rgba(234,179,8,.22);}" +
@@ -9,7 +11,8 @@ const RING_CSS = "#wg-ring{position:fixed;z-index:2147483646;pointer-events:none
9
11
  "#wg-ring[data-tone=success]{border-color:#22C55E;box-shadow:0 0 0 4px rgba(34,197,94,.22);}" +
10
12
  "#wg-ring-label{position:fixed;z-index:2147483646;pointer-events:none;opacity:0;" +
11
13
  "white-space:nowrap;padding:4px 9px;border-radius:7px;background:#7C3AED;color:#fff;" +
12
- "font:600 12px/1.2 system-ui,sans-serif;transition:opacity .3s ease,background .15s,color .15s;}" +
14
+ "font:600 12px/1.2 system-ui,sans-serif;transition:opacity .3s ease;}" +
15
+ "#wg-ring-label[data-tone=planned]{background:#7C3AED;color:#fff;}" +
13
16
  "#wg-ring-label[data-tone=auto]{background:#6B7280;color:#fff;}" +
14
17
  "#wg-ring-label[data-tone=info]{background:#2563EB;color:#fff;}" +
15
18
  "#wg-ring-label[data-tone=warning]{background:#EAB308;color:#1c1917;}" +
@@ -17,7 +17,12 @@ export const NavCartBlock = defineNavClickBlock<CartPage>({
17
17
  verify: [Trait.url({ pathname: "/cart.html" })],
18
18
  stubBefore: (ctx) => {
19
19
  ctx.title("Open cart");
20
- ctx.todos(["Find the product", "Add to cart", "Open cart"]);
20
+ ctx.todoId("saucedemo-cart");
21
+ ctx.todos([
22
+ { id: "cart-find", text: "Find the product" },
23
+ { id: "cart-add", text: "Add to cart" },
24
+ { id: "cart-open", text: "Open cart" },
25
+ ]);
21
26
  ctx.todoIndex(2);
22
27
  ctx.zoom(1.4);
23
28
  ctx.zoomOut(false);
@@ -36,7 +36,12 @@ export const AddToCartBlock = defineEffectBlock<Checkpoint<string>, ItemInCart>(
36
36
  // Demo: zoom product -> add (stay zoomed), then badge after act.
37
37
  stubBefore: (ctx) => {
38
38
  ctx.title("Pick a product");
39
- ctx.todos(["Find the product", "Add to cart", "Open cart"]);
39
+ ctx.todoId("saucedemo-cart");
40
+ ctx.todos([
41
+ { id: "cart-find", text: "Find the product" },
42
+ { id: "cart-add", text: "Add to cart" },
43
+ { id: "cart-open", text: "Open cart" },
44
+ ]);
40
45
  ctx.todoIndex(0);
41
46
  ctx.zoom(1.35);
42
47
  ctx.zoomOut(false);
@@ -63,7 +68,12 @@ export const AddToCartBlock = defineEffectBlock<Checkpoint<string>, ItemInCart>(
63
68
  },
64
69
  stubAfter: (ctx) => {
65
70
  ctx.banner("In the cart");
66
- ctx.todos(["Find the product", "Add to cart", "Open cart"]);
71
+ ctx.todoId("saucedemo-cart");
72
+ ctx.todos([
73
+ { id: "cart-find", text: "Find the product" },
74
+ { id: "cart-add", text: "Add to cart" },
75
+ { id: "cart-open", text: "Open cart" },
76
+ ]);
67
77
  ctx.todoIndex(1);
68
78
  ctx.zoomOut(false);
69
79
  ctx.ring("badge", {
@@ -49,7 +49,12 @@ export const SubmitLoginActionBlock = defineMethodBlock<LoginPage, LoginSubmitOu
49
49
  // sequential highlight queue (appear / dwell / fade), focus + color.
50
50
  stubBefore: (ctx) => {
51
51
  ctx.title("Signing in");
52
- ctx.todos(["Enter username", "Enter password", "Click Login"]);
52
+ ctx.todoId("saucedemo-login");
53
+ ctx.todos([
54
+ { id: "login-user", text: "Enter username" },
55
+ { id: "login-pass", text: "Enter password" },
56
+ { id: "login-submit", text: "Click Login" },
57
+ ]);
53
58
  ctx.todoIndex(0);
54
59
  ctx.zoom(1.35);
55
60
  ctx.highlights({
@@ -82,7 +87,12 @@ export const SubmitLoginActionBlock = defineMethodBlock<LoginPage, LoginSubmitOu
82
87
  // inventory shelf. Success (LoggedIn) hands off to shopFlow.
83
88
  if (ctx.out && ctx.out.__state === "LoginPage") {
84
89
  ctx.title("Login blocked");
85
- ctx.todos(["Enter username", "Enter password", "Click Login"]);
90
+ ctx.todoId("saucedemo-login");
91
+ ctx.todos([
92
+ { id: "login-user", text: "Enter username" },
93
+ { id: "login-pass", text: "Enter password" },
94
+ { id: "login-submit", text: "Click Login" },
95
+ ]);
86
96
  ctx.todoIndex(2);
87
97
  ctx.ring("error", {
88
98
  selector: '[data-test="error"]',
@@ -95,7 +105,12 @@ export const SubmitLoginActionBlock = defineMethodBlock<LoginPage, LoginSubmitOu
95
105
  return;
96
106
  }
97
107
  ctx.banner("Inventory");
98
- ctx.todos(["Enter username", "Enter password", "Click Login"]);
108
+ ctx.todoId("saucedemo-login");
109
+ ctx.todos([
110
+ { id: "login-user", text: "Enter username" },
111
+ { id: "login-pass", text: "Enter password" },
112
+ { id: "login-submit", text: "Click Login" },
113
+ ]);
99
114
  ctx.todoIndex(2);
100
115
  // Landed on inventory - hand off to shopFlow for product -> cart zoom.
101
116
  ctx.ring("shelf", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waygraph",
3
- "version": "0.12.43",
3
+ "version": "0.12.45",
4
4
  "type": "module",
5
5
  "description": "A typed graph of reusable Blocks for driving a browser through E2E flows.",
6
6
  "license": "MIT",