waygraph 0.12.22 → 0.12.24

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
@@ -207,6 +207,7 @@ import { tmpdir } from "node:os";
207
207
  import {
208
208
  resolveHighlightSlots,
209
209
  resolveSlides,
210
+ runStubPhase,
210
211
  resolveFixtureDwellMs,
211
212
  resolveSlideDwellMs,
212
213
  formatHighlightCaption,
@@ -224,6 +225,7 @@ import {
224
225
  demoPaceIsSlow,
225
226
  formatDemoPaceBadge,
226
227
  formatDemoPaceLabel,
228
+ normalizeTodos,
227
229
  } from "waygraph";
228
230
 
229
231
  function walkDir(dir, pattern) {
@@ -507,6 +509,14 @@ const RING_CSS =
507
509
  "#wg-panel .wg-pace[data-pace-kind=blitz] .wg-pace-badge{background:#22C55E;color:#052e16;}" +
508
510
  "#wg-panel .wg-pace[data-pace-kind=ms] .wg-pace-badge{background:#3B82F6;color:#fff;}" +
509
511
  "#wg-panel .wg-narration{margin:0 0 12px;font:italic 14px/1.4 system-ui,sans-serif;color:#f0e8ff;}" +
512
+ "#wg-todos{list-style:none;margin:0 0 12px;padding:8px 10px;background:#0f0620;border-radius:8px;" +
513
+ "border:1px solid #3a2a60;}" +
514
+ "#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;}" +
515
+ "#wg-todos li:last-child{margin-bottom:0;}" +
516
+ "#wg-todos .wg-todo-mark{flex:0 0 auto;width:1.1em;text-align:center;}" +
517
+ "#wg-todos .wg-todo-done{color:#6ee7b7;text-decoration:line-through;opacity:.85;}" +
518
+ "#wg-todos .wg-todo-current{color:#fff;}" +
519
+ "#wg-todos .wg-todo-pending{color:#9a7ad1;}" +
510
520
  "#wg-progress{height:4px;background:#2a1650;border-radius:2px;margin:0 0 12px;overflow:hidden;}" +
511
521
  "#wg-progress-bar{height:100%;background:#7C3AED;border-radius:2px;transition:width .3s ease;}" +
512
522
  // A real tab bar for episodes - the currently-active episode reads as
@@ -767,6 +777,31 @@ async function installOverlay(page, title) {
767
777
  const ringLabel = document.getElementById("wg-ring-label");
768
778
  if (ring) ring.style.opacity = "0";
769
779
  if (ringLabel) ringLabel.style.opacity = "0";
780
+ if (window.__wgClearZoom) window.__wgClearZoom();
781
+ };
782
+ window.__wgClearZoom = () => {
783
+ document.querySelectorAll("[data-wg-zoomed=\\"1\\"]").forEach((el) => {
784
+ el.style.removeProperty("transform");
785
+ el.style.removeProperty("transform-origin");
786
+ el.style.removeProperty("transition");
787
+ el.style.removeProperty("z-index");
788
+ el.style.removeProperty("position");
789
+ delete el.dataset.wgZoomed;
790
+ });
791
+ };
792
+ window.__wgApplyZoom = (sel, scale) => {
793
+ if (window.__wgClearZoom) window.__wgClearZoom();
794
+ const n = Number(scale);
795
+ if (!Number.isFinite(n) || n <= 1.001) return;
796
+ const el = document.querySelector(sel);
797
+ if (!el) return;
798
+ const st = getComputedStyle(el);
799
+ if (st.position === "static") el.style.position = "relative";
800
+ el.style.zIndex = "2147483645";
801
+ el.style.transition = "transform .35s ease";
802
+ el.style.transformOrigin = "center center";
803
+ el.style.transform = "scale(" + n + ")";
804
+ el.dataset.wgZoomed = "1";
770
805
  };
771
806
  const POSITIONS = ["left", "center", "right"];
772
807
  const applyPos = (el, pos) => {
@@ -988,7 +1023,8 @@ async function renderBeforeStep(page, info) {
988
1023
  paceHtml +
989
1024
  "<div id=\\"wg-modules\\" class=\\"" + modulesClass + "\\">" + modulesHtml + "</div>" +
990
1025
  "<h3>Step " + (info.index + 1) + " / " + info.total + " - " + info.blockName + "</h3>" +
991
- narrationHtml;
1026
+ narrationHtml +
1027
+ todosHtmlFromInfo(info, esc);
992
1028
  if (info.keys.length === 0) {
993
1029
  html += "<div class=\\"wg-key\\">(no MemKeys required)</div>";
994
1030
  }
@@ -1021,12 +1057,7 @@ async function renderBeforeStep(page, info) {
1021
1057
  }
1022
1058
  if (window.__wgWirePanelChrome) {
1023
1059
  window.__wgWirePanelChrome(panel, "wg-panel-hidden", "waygraph demo", {
1024
- stepLabel:
1025
- (info.index + 1) +
1026
- " / " +
1027
- info.total +
1028
- (info.blockName ? " · " + info.blockName : "") +
1029
- (info.paceBadge ? " · " + info.paceBadge : ""),
1060
+ stepLabel: miniStepLabel(info),
1030
1061
  forceCollapsed: info.forceCollapsed === true ? true : info.forceCollapsed === false ? false : undefined,
1031
1062
  });
1032
1063
  }
@@ -1103,6 +1134,102 @@ async function renderBeforeStep(page, info) {
1103
1134
  .catch(() => {});
1104
1135
  }
1105
1136
 
1137
+ async function ensureSelectorInView(page, selector) {
1138
+ try {
1139
+ const loc = page.locator(selector).first();
1140
+ await loc.scrollIntoViewIfNeeded().catch(() => {});
1141
+ await page
1142
+ .evaluate((sel) => {
1143
+ const el = document.querySelector(sel);
1144
+ if (!el) return;
1145
+ try {
1146
+ el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
1147
+ } catch {
1148
+ try {
1149
+ el.scrollIntoView(true);
1150
+ } catch {
1151
+ /* ignore */
1152
+ }
1153
+ }
1154
+ let p = el.parentElement;
1155
+ while (p && p !== document.documentElement && p !== document.body) {
1156
+ const st = getComputedStyle(p);
1157
+ const ox = st.overflowX;
1158
+ const oy = st.overflowY;
1159
+ if (/(auto|scroll|overlay)/.test(ox) || /(auto|scroll|overlay)/.test(oy)) {
1160
+ const er = el.getBoundingClientRect();
1161
+ const pr = p.getBoundingClientRect();
1162
+ if (er.left < pr.left || er.right > pr.right || er.top < pr.top || er.bottom > pr.bottom) {
1163
+ p.scrollLeft += er.left + er.width / 2 - (pr.left + pr.width / 2);
1164
+ p.scrollTop += er.top + er.height / 2 - (pr.top + pr.height / 2);
1165
+ }
1166
+ }
1167
+ p = p.parentElement;
1168
+ }
1169
+ }, selector)
1170
+ .catch(() => {});
1171
+ } catch {
1172
+ /* best-effort */
1173
+ }
1174
+ }
1175
+
1176
+ async function applyHighlightZoom(page, selector, zoom) {
1177
+ const z = Number(zoom);
1178
+ if (!Number.isFinite(z) || z <= 1.001) {
1179
+ await page.evaluate(() => {
1180
+ if (window.__wgClearZoom) window.__wgClearZoom();
1181
+ }).catch(() => {});
1182
+ return;
1183
+ }
1184
+ await page
1185
+ .evaluate(
1186
+ ({ sel, scale }) => {
1187
+ if (window.__wgApplyZoom) window.__wgApplyZoom(sel, scale);
1188
+ },
1189
+ { sel: selector, scale: z },
1190
+ )
1191
+ .catch(() => {});
1192
+ }
1193
+
1194
+ function todosHtmlFromInfo(info, escFn) {
1195
+ const list = info.todos || [];
1196
+ if (!list.length) return "";
1197
+ return (
1198
+ "<ul id=\\"wg-todos\\">" +
1199
+ list
1200
+ .map((t) => {
1201
+ const cls = t.current ? "wg-todo-current" : t.done ? "wg-todo-done" : "wg-todo-pending";
1202
+ const mark = t.done ? "\\u2713" : t.current ? "\\u2192" : "\\u25CB";
1203
+ return (
1204
+ "<li class=\\"" +
1205
+ cls +
1206
+ "\\"><span class=\\"wg-todo-mark\\">" +
1207
+ mark +
1208
+ "</span><span>" +
1209
+ escFn(t.text || "") +
1210
+ "</span></li>"
1211
+ );
1212
+ })
1213
+ .join("") +
1214
+ "</ul>"
1215
+ );
1216
+ }
1217
+
1218
+ function miniStepLabel(info) {
1219
+ const ep =
1220
+ info.episodeNumber !== undefined && info.episodeNumber !== null
1221
+ ? "Ep " + info.episodeNumber + " \\u00b7 "
1222
+ : "";
1223
+ return (
1224
+ ep +
1225
+ (info.index + 1) +
1226
+ " / " +
1227
+ info.total +
1228
+ (info.blockName ? " \\u00b7 " + info.blockName : "") +
1229
+ (info.paceBadge ? " \\u00b7 " + info.paceBadge : "")
1230
+ );
1231
+ }
1232
+
1106
1233
  async function cycleHighlightRings(page, highlights, gatesFast, opts) {
1107
1234
  const list = highlights || [];
1108
1235
  // defaultHoldMs: when set (fail path), use instead of legacy 900/200 so BUG
@@ -1112,12 +1239,50 @@ async function cycleHighlightRings(page, highlights, gatesFast, opts) {
1112
1239
  for (let i = 0; i < list.length; i++) {
1113
1240
  const h = list[i];
1114
1241
  try {
1242
+ await ensureSelectorInView(page, h.selector);
1243
+ await applyHighlightZoom(page, h.selector, h.zoom);
1115
1244
  const box = await page.locator(h.selector).first().boundingBox();
1116
1245
  if (box) {
1117
1246
  await showRing(page, box, h.label, h.tone || "planned", {
1118
1247
  size: h.size,
1119
1248
  weight: h.weight,
1120
1249
  });
1250
+ if ((opts && opts.todos && opts.todos.length) || (h.todos && h.todos.length)) {
1251
+ const rows =
1252
+ opts && opts.todos && opts.todos.length
1253
+ ? opts.todos
1254
+ : normalizeTodos(h.todos, h.todoIndex);
1255
+ await page
1256
+ .evaluate((todos) => {
1257
+ const ul = document.getElementById("wg-todos");
1258
+ if (!ul) return;
1259
+ ul.innerHTML = todos
1260
+ .map((t) => {
1261
+ const cls = t.current
1262
+ ? "wg-todo-current"
1263
+ : t.done
1264
+ ? "wg-todo-done"
1265
+ : "wg-todo-pending";
1266
+ const mark = t.done ? "\\u2713" : t.current ? "\\u2192" : "\\u25CB";
1267
+ const esc = (s) =>
1268
+ String(s)
1269
+ .replace(/&/g, "&amp;")
1270
+ .replace(/</g, "&lt;")
1271
+ .replace(/>/g, "&gt;");
1272
+ return (
1273
+ "<li class=\\"" +
1274
+ cls +
1275
+ "\\"><span class=\\"wg-todo-mark\\">" +
1276
+ mark +
1277
+ "</span><span>" +
1278
+ esc(t.text || "") +
1279
+ "</span></li>"
1280
+ );
1281
+ })
1282
+ .join("");
1283
+ }, rows)
1284
+ .catch(() => {});
1285
+ }
1121
1286
  const authored = resolveFixtureDwellMs(h, { gatesFast, pace });
1122
1287
  const legacyMs = i === list.length - 1 ? 200 : 900;
1123
1288
  const holdMs =
@@ -1261,6 +1426,7 @@ async function renderAfterStep(page, info) {
1261
1426
  paceHtml +
1262
1427
  "<div id=\\"wg-modules\\" class=\\"" + modulesClass + "\\">" + modulesHtml + "</div>" +
1263
1428
  "<h3>" + heading + "</h3>" +
1429
+ todosHtmlFromInfo(info, escA) +
1264
1430
  resultHtml +
1265
1431
  gateHtml;
1266
1432
  if (isNewPanel) {
@@ -1269,12 +1435,7 @@ async function renderAfterStep(page, info) {
1269
1435
  }
1270
1436
  if (window.__wgWirePanelChrome) {
1271
1437
  window.__wgWirePanelChrome(panel, "wg-panel-hidden", "waygraph demo", {
1272
- stepLabel:
1273
- (info.index + 1) +
1274
- " / " +
1275
- info.total +
1276
- (info.blockName ? " · " + info.blockName : "") +
1277
- (info.paceBadge ? " · " + info.paceBadge : ""),
1438
+ stepLabel: miniStepLabel(info),
1278
1439
  forceCollapsed: info.forceCollapsed === true ? true : info.forceCollapsed === false ? false : undefined,
1279
1440
  });
1280
1441
  }
@@ -1376,12 +1537,7 @@ async function renderStepError(page, info) {
1376
1537
  }
1377
1538
  if (window.__wgWirePanelChrome) {
1378
1539
  window.__wgWirePanelChrome(panel, "wg-panel-hidden", "waygraph demo", {
1379
- stepLabel:
1380
- (info.index + 1) +
1381
- " / " +
1382
- info.total +
1383
- (info.blockName ? " · " + info.blockName : "") +
1384
- (info.paceBadge ? " · " + info.paceBadge : ""),
1540
+ stepLabel: miniStepLabel(info),
1385
1541
  forceCollapsed: info.forceCollapsed === true ? true : info.forceCollapsed === false ? false : undefined,
1386
1542
  });
1387
1543
  }
@@ -1609,6 +1765,8 @@ async function presentSlides(page, slides, _gate, opts) {
1609
1765
  await installOverlay(page, title);
1610
1766
  if (s.selector) {
1611
1767
  try {
1768
+ await ensureSelectorInView(page, s.selector);
1769
+ await applyHighlightZoom(page, s.selector, s.zoom);
1612
1770
  const box = await page.locator(s.selector).first().boundingBox();
1613
1771
  if (box) await showRing(page, box, caption, slideTone, slideStyle);
1614
1772
  } catch {
@@ -1696,11 +1854,14 @@ async function presentSlides(page, slides, _gate, opts) {
1696
1854
  if (window.__wgWirePanelChrome) {
1697
1855
  window.__wgWirePanelChrome(panel, "wg-panel-hidden", "waygraph demo", {
1698
1856
  stepLabel:
1857
+ (info.episodeNumber !== undefined && info.episodeNumber !== null
1858
+ ? "Ep " + info.episodeNumber + " \\u00b7 "
1859
+ : "") +
1699
1860
  "Slide " +
1700
1861
  (info.index + 1) +
1701
1862
  " / " +
1702
1863
  info.total +
1703
- (info.blockName ? " · " + info.blockName : ""),
1864
+ (info.blockName ? " \\u00b7 " + info.blockName : ""),
1704
1865
  forceCollapsed: false,
1705
1866
  });
1706
1867
  }
@@ -2368,9 +2529,11 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2368
2529
  !!r.wasFastForward;
2369
2530
  const fixtures = r.highlightFixtures;
2370
2531
  const flowStyle = r.highlightStyle;
2371
- stubBeforeRef.current = resolveHighlightSlots(r.block, "stubBefore", { fixtures }).map((h) =>
2532
+ const stubBeforePhase = await runStubPhase(r.block, "stubBefore", { fixtures });
2533
+ stubBeforeRef.current = stubBeforePhase.highlights.map((h) =>
2372
2534
  applyHighlightStyleDefaults(h, flowStyle),
2373
2535
  );
2536
+ const stubBeforeTodos = stubBeforePhase.todos || [];
2374
2537
  const isNavBlock = r.block.__waygraphKind === "nav";
2375
2538
  const autoNow = await currentAutoplay();
2376
2539
  // Auto-next: tuck stepper on NavBlocks so the page transition fills the frame.
@@ -2416,6 +2579,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2416
2579
  episodeTitle: r.episodeTitle,
2417
2580
  stepperMode,
2418
2581
  forceCollapsed,
2582
+ todos: stubBeforeTodos,
2419
2583
  ...paceSpeak,
2420
2584
  });
2421
2585
  const edits = await gate();
@@ -2475,9 +2639,11 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2475
2639
  }
2476
2640
  })
2477
2641
  .catch(() => {});
2478
- const errHighlights = resolveHighlightSlots(r.block, "stubOnError", {
2642
+ const errPhase = await runStubPhase(r.block, "stubOnError", {
2479
2643
  fixtures: fixturesOnError,
2480
- }).map((h) => {
2644
+ error: err,
2645
+ });
2646
+ const errHighlights = errPhase.highlights.map((h) => {
2481
2647
  const styled = applyHighlightStyleDefaults(h, r.highlightStyle);
2482
2648
  return {
2483
2649
  selector: styled.selector,
@@ -2487,11 +2653,13 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2487
2653
  tone: styled.tone,
2488
2654
  size: styled.size,
2489
2655
  weight: styled.weight,
2656
+ zoom: styled.zoom,
2490
2657
  };
2491
2658
  });
2492
2659
  await cycleHighlightRings(page, errHighlights, !!pacing.gatesFast, {
2493
2660
  defaultHoldMs: 2000,
2494
2661
  pace: pacing.demoPace,
2662
+ todos: errPhase.todos,
2495
2663
  });
2496
2664
  }
2497
2665
  // A thrown act()/observe()/a failed verify Trait used to just crash
@@ -2557,21 +2725,26 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2557
2725
  });
2558
2726
  }
2559
2727
  let highlights;
2728
+ let stubAfterTodos = [];
2560
2729
  if (hasAuthoredStubAfter(r.block, result, fixturesAfter)) {
2561
- highlights = resolveHighlightSlots(r.block, "stubAfter", { out: result, fixtures: fixturesAfter }).map(
2562
- (h) => {
2563
- const styled = applyHighlightStyleDefaults(h, r.highlightStyle);
2564
- return {
2565
- selector: styled.selector,
2566
- label: formatHighlightCaption(styled),
2567
- duration: styled.duration,
2568
- fastMode: styled.fastMode,
2569
- tone: styled.tone,
2570
- size: styled.size,
2571
- weight: styled.weight,
2572
- };
2573
- },
2574
- );
2730
+ const afterPhase = await runStubPhase(r.block, "stubAfter", {
2731
+ out: result,
2732
+ fixtures: fixturesAfter,
2733
+ });
2734
+ stubAfterTodos = afterPhase.todos || [];
2735
+ highlights = afterPhase.highlights.map((h) => {
2736
+ const styled = applyHighlightStyleDefaults(h, r.highlightStyle);
2737
+ return {
2738
+ selector: styled.selector,
2739
+ label: formatHighlightCaption(styled),
2740
+ duration: styled.duration,
2741
+ fastMode: styled.fastMode,
2742
+ tone: styled.tone,
2743
+ size: styled.size,
2744
+ weight: styled.weight,
2745
+ zoom: styled.zoom,
2746
+ };
2747
+ });
2575
2748
  } else {
2576
2749
  highlights = extractVerifyHighlights(r.block, result.__state).map((h) =>
2577
2750
  applyHighlightStyleDefaults(h, r.highlightStyle),
@@ -2596,6 +2769,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2596
2769
  episodeNumber: r.episodeNumber,
2597
2770
  episodeTitle: r.episodeTitle,
2598
2771
  stepperMode,
2772
+ todos: stubAfterTodos,
2599
2773
  // After a nav under auto-next, stay compact until the next action block expands.
2600
2774
  forceCollapsed: !!process.env.WAYGRAPH_VIDEO || ((await currentAutoplay()) && isNavBlock),
2601
2775
  });
@@ -2690,7 +2864,7 @@ async function runChainedFlows(chainFlow, chainFlows, context, mem, page) {
2690
2864
  /** Parses WAYGRAPH_VIDEO_VIEWPORT / --video-viewport: "1920x1080" or "1920,1080". */
2691
2865
  function parseVideoViewport(raw) {
2692
2866
  if (!raw || !raw.trim()) return null;
2693
- const m = raw.trim().match(/^(\d{3,5})[xX,](\d{3,5})$/);
2867
+ const m = raw.trim().match(/^(\\d{3,5})[xX,](\\d{3,5})$/);
2694
2868
  if (!m) return null;
2695
2869
  const width = Number(m[1]);
2696
2870
  const height = Number(m[2]);
@@ -2981,7 +3155,13 @@ async function main() {
2981
3155
  ...(executablePath ? { executablePath } : {}),
2982
3156
  });
2983
3157
  const contextOpts = fixedVideoViewport
2984
- ? { baseURL, viewport: fixedVideoViewport }
3158
+ ? {
3159
+ baseURL,
3160
+ viewport: fixedVideoViewport,
3161
+ // Lock DPR so --video-viewport 800x450 stays 800x450 in the .webm
3162
+ // (HiDPI hosts otherwise scale recordVideo to e.g. 1920x1080).
3163
+ deviceScaleFactor: 1,
3164
+ }
2985
3165
  : step
2986
3166
  ? { baseURL, viewport: null }
2987
3167
  : { baseURL, viewport: { width: 1280, height: 720 } };
package/dist/engine.d.ts CHANGED
@@ -603,9 +603,9 @@ export type NavBlockOptions<Out extends Checkpoint<string>> = {
603
603
  checkpoint: Out["__state"];
604
604
  requires?: readonly MemKey<any>[];
605
605
  verify?: Trait[] | ((out: Out) => Trait[]);
606
- stubBefore?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
607
- stubAfter?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
608
- stubOnError?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
606
+ stubBefore?: import("./highlights.js").HighlightStubPhaseOrFn<Out>;
607
+ stubAfter?: import("./highlights.js").HighlightStubPhaseOrFn<Out>;
608
+ stubOnError?: import("./highlights.js").HighlightStubPhaseOrFn<Out>;
609
609
  /** @deprecated Prefer stubAfter. */
610
610
  highlights?: readonly WaygraphHighlight[] | ((out: Out) => readonly WaygraphHighlight[]);
611
611
  /**
@@ -667,9 +667,9 @@ export type NavClickBlockOptions<Out extends Checkpoint<string>> = {
667
667
  click: string | ((mem: MemPage) => string);
668
668
  requires?: readonly MemKey<any>[];
669
669
  verify?: Trait[] | ((out: Out) => Trait[]);
670
- stubBefore?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
671
- stubAfter?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
672
- stubOnError?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
670
+ stubBefore?: import("./highlights.js").HighlightStubPhaseOrFn<Out>;
671
+ stubAfter?: import("./highlights.js").HighlightStubPhaseOrFn<Out>;
672
+ stubOnError?: import("./highlights.js").HighlightStubPhaseOrFn<Out>;
673
673
  /** @deprecated Prefer stubAfter. */
674
674
  highlights?: readonly WaygraphHighlight[] | ((out: Out) => readonly WaygraphHighlight[]);
675
675
  instanceOptions?: Block<Checkpoint<string>, Out>["instanceOptions"];
@@ -699,9 +699,9 @@ export type PageBlockOptions<Out extends Checkpoint<string>> = {
699
699
  /** Checkpoint tag for this screen (hub). */
700
700
  checkpoint: Out["__state"];
701
701
  verify?: Trait[] | ((out: Out) => Trait[]);
702
- stubBefore?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
703
- stubAfter?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
704
- stubOnError?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
702
+ stubBefore?: import("./highlights.js").HighlightStubPhaseOrFn<Out>;
703
+ stubAfter?: import("./highlights.js").HighlightStubPhaseOrFn<Out>;
704
+ stubOnError?: import("./highlights.js").HighlightStubPhaseOrFn<Out>;
705
705
  /** @deprecated Prefer stubAfter. */
706
706
  highlights?: readonly WaygraphHighlight[] | ((out: Out) => readonly WaygraphHighlight[]);
707
707
  requires?: readonly MemKey<any>[];
@@ -2,6 +2,10 @@
2
2
  * Block stubBefore / stubAfter / stubOnError + flow highlightFixtures + demo slides.
3
3
  * Demo narration only - never affects runGraph pass/fail.
4
4
  *
5
+ * Stubs are **open block lifecycles** - prefer `stubBefore(ctx) { ... }`, not a
6
+ * closed slot object. Inside the fn authors set fixtures (todos, zoom) and rings.
7
+ * Object maps remain a shorthand for highlight-only slots.
8
+ *
5
9
  * Lifecycle (stubs) ≠ yap slides: stubs are fill/after/error rings tied to the block;
6
10
  * slides are multi-step captions with Next between them (long-process explain).
7
11
  *
@@ -24,6 +28,26 @@ export type FixtureDurationFields = {
24
28
  */
25
29
  fastMode?: number;
26
30
  };
31
+ /**
32
+ * One checklist row for episode planning (demo panel).
33
+ * Set via {@link StubCtx.todos} / {@link StubCtx.todoIndex} inside the lifecycle.
34
+ */
35
+ export type WaygraphTodoItem = {
36
+ text: string;
37
+ /** Explicit done. If omitted, derived from todoIndex (index < todoIndex). */
38
+ done?: boolean;
39
+ /** Explicit current. If omitted, derived from todoIndex (index === todoIndex). */
40
+ current?: boolean;
41
+ };
42
+ export type WaygraphTodoInput = string | WaygraphTodoItem;
43
+ /** Episode-level fixtures authored inside stubBefore/After/OnError(ctx). */
44
+ export type StubPhaseFixtures = {
45
+ todos?: readonly WaygraphTodoInput[];
46
+ /** Which todo is current (0-based). Rows before = done, after = pending. */
47
+ todoIndex?: number;
48
+ /** Default zoom for rings that omit their own zoom. */
49
+ zoom?: number;
50
+ };
27
51
  /** One named highlight slot on a block (selector + caption). */
28
52
  export interface WaygraphHighlightStub extends FixtureDurationFields {
29
53
  selector: string;
@@ -50,11 +74,72 @@ export interface WaygraphHighlightStub extends FixtureDurationFields {
50
74
  * Aliases: regular, strong.
51
75
  */
52
76
  weight?: HighlightWeight;
77
+ /**
78
+ * Magnify this target while its ring is shown (e.g. `1.35`).
79
+ * Cleared when the ring hides / next highlight starts. `1` or omit = off.
80
+ * Episode default: {@link StubCtx.zoom}.
81
+ */
82
+ zoom?: number;
53
83
  }
54
84
  /** Flow fixture patch: label required; selector optional (inherits from stub). */
55
- export type WaygraphHighlightFixture = Partial<Pick<WaygraphHighlightStub, "selector">> & Required<Pick<WaygraphHighlightStub, "label">> & Pick<WaygraphHighlightStub, "detail" | "tag" | "tone" | "size" | "weight"> & FixtureDurationFields;
85
+ export type WaygraphHighlightFixture = Partial<Pick<WaygraphHighlightStub, "selector">> & Required<Pick<WaygraphHighlightStub, "label">> & Pick<WaygraphHighlightStub, "detail" | "tag" | "tone" | "size" | "weight" | "zoom"> & FixtureDurationFields;
56
86
  export type HighlightStubPhase = Record<string, WaygraphHighlightStub>;
57
- export type HighlightStubPhaseOrFn<Out extends Checkpoint<string>> = HighlightStubPhase | ((out: Out) => HighlightStubPhase);
87
+ /**
88
+ * Open stub lifecycle context. Authors set rings + episode fixtures freely —
89
+ * not restricted to returning a closed slot object.
90
+ *
91
+ * @example
92
+ * stubBefore: (ctx) => {
93
+ * ctx.todos(["Enter email", "Enter password", "Click Sign in"]);
94
+ * ctx.todoIndex(0);
95
+ * ctx.zoom(1.35);
96
+ * ctx.highlights({
97
+ * email: { selector: "#email", label: "Email" },
98
+ * submit: { selector: "#login-button", label: "Sign in" },
99
+ * });
100
+ * }
101
+ * // Sequential plan: bump todoIndex in a later phase / later block's stubBefore
102
+ * stubAfter: (ctx) => {
103
+ * ctx.todos(["Enter email", "Enter password", "Click Sign in"]);
104
+ * ctx.todoIndex(2);
105
+ * ctx.ring("inventory", { selector: ".inventory_list", label: "Landed" });
106
+ * }
107
+ */
108
+ export type StubCtx<Out extends Checkpoint<string> = Checkpoint<string>> = {
109
+ /** Present after resolve (stubAfter). Undefined for stubBefore. */
110
+ readonly out: Out | undefined;
111
+ /** Present on stubOnError when the step threw. */
112
+ readonly error: unknown | undefined;
113
+ /** Replace named highlight rings for this phase. */
114
+ highlights(slots: HighlightStubPhase): void;
115
+ /** Set / overwrite one named ring. */
116
+ ring(id: string, stub: WaygraphHighlightStub): void;
117
+ /** Episode checklist (demo panel). */
118
+ todos(items: readonly WaygraphTodoInput[]): void;
119
+ /** Current checklist index (bump for sequential plans). */
120
+ todoIndex(n: number): void;
121
+ /** Default zoom for rings without their own zoom. */
122
+ zoom(n: number): void;
123
+ /** Batch-set rings + episode fixtures. */
124
+ set(partial: {
125
+ highlights?: HighlightStubPhase;
126
+ } & StubPhaseFixtures): void;
127
+ };
128
+ export type StubLifecycleFn<Out extends Checkpoint<string>> = (ctx: StubCtx<Out>) => void | HighlightStubPhase | Promise<void | HighlightStubPhase>;
129
+ /**
130
+ * Block stub authoring:
131
+ * - **preferred:** open lifecycle `stubBefore(ctx) { ctx.todos(...); ctx.ring(...) }`
132
+ * - **shorthand:** static slot map `{ email: { selector, label } }`
133
+ * - **legacy:** `(out) => slotMap` still accepted (treated as highlights only)
134
+ */
135
+ export type HighlightStubPhaseOrFn<Out extends Checkpoint<string>> = HighlightStubPhase | StubLifecycleFn<Out> | ((out: Out) => HighlightStubPhase);
136
+ /** Result of running a stub phase (rings + episode fixtures). */
137
+ export type StubPhaseResult = {
138
+ highlights: ResolvedHighlight[];
139
+ todos: WaygraphTodoItem[];
140
+ todoIndex?: number;
141
+ zoom?: number;
142
+ };
58
143
  /**
59
144
  * One yap/slide in a multi-step demo caption sequence.
60
145
  * Not block lifecycle - demo pauses (Next / auto-next) between slides.
@@ -69,6 +154,8 @@ export interface WaygraphSlide extends FixtureDurationFields {
69
154
  tone?: HighlightTone;
70
155
  size?: HighlightSize;
71
156
  weight?: HighlightWeight;
157
+ /** Magnify selector target while this slide is showing (same as stub zoom). */
158
+ zoom?: number;
72
159
  }
73
160
  /**
74
161
  * Demo ring tones (iconified).
@@ -189,9 +276,25 @@ export interface WaygraphHighlight {
189
276
  }
190
277
  export type ResolvedHighlight = WaygraphHighlightStub;
191
278
  /**
192
- * Resolve ordered highlight slots for a phase, merging flow fixtures over block stubs.
193
- * Shim: empty stubAfter + legacy `instruction.highlights` -> auto-keyed stubAfter.
194
- * `stubOnError` is fail-path only (demo step catch) - never run on success.
279
+ * Normalize checklist rows; apply todoIndex for sequential done/current.
280
+ */
281
+ export declare function normalizeTodos(todos: readonly WaygraphTodoInput[] | undefined, todoIndex?: number): WaygraphTodoItem[];
282
+ /**
283
+ * Run an open stub lifecycle (preferred) or resolve a static slot map.
284
+ * Returns rings + episode fixtures (todos / todoIndex / zoom).
285
+ *
286
+ * @example
287
+ * const before = await runStubPhase(block, "stubBefore", { fixtures });
288
+ * // before.todos, before.highlights
289
+ */
290
+ export declare function runStubPhase(block: Block<any, any>, phase: HighlightStubPhaseName, opts?: {
291
+ out?: Checkpoint<string>;
292
+ error?: unknown;
293
+ fixtures?: HighlightFixtureMap;
294
+ }): Promise<StubPhaseResult>;
295
+ /**
296
+ * Sync resolve of highlight slots. Prefer {@link runStubPhase} for open
297
+ * async lifecycles + episode fixtures (todos/zoom).
195
298
  */
196
299
  export declare function resolveHighlightSlots(block: Block<any, any>, phase: HighlightStubPhaseName, opts?: {
197
300
  out?: Checkpoint<string>;
@@ -214,12 +317,12 @@ export declare function formatHighlightCaption(h: {
214
317
  tone?: HighlightTone | string;
215
318
  }): string;
216
319
  /**
217
- * True when the block authored a non-empty stubAfter (after resolve + shim),
218
- * including legacy highlights. When true, demo should not use verify-trait fallback.
320
+ * True when the block authored stubAfter (lifecycle fn, slots, fixtures, or legacy highlights).
321
+ * When true, demo should not use verify-trait fallback.
219
322
  */
220
323
  export declare function hasAuthoredStubAfter(block: Block<any, any>, out?: Checkpoint<string>, fixtures?: HighlightFixtureMap): boolean;
221
324
  /**
222
- * True when stubOnError resolves to at least one ring (block and/or flow fixture).
325
+ * True when stubOnError is authored (lifecycle fn and/or rings).
223
326
  * Demo fail path only - success never consults this.
224
327
  */
225
328
  export declare function hasAuthoredStubOnError(block: Block<any, any>, fixtures?: HighlightFixtureMap): boolean;
@@ -2,6 +2,10 @@
2
2
  * Block stubBefore / stubAfter / stubOnError + flow highlightFixtures + demo slides.
3
3
  * Demo narration only - never affects runGraph pass/fail.
4
4
  *
5
+ * Stubs are **open block lifecycles** - prefer `stubBefore(ctx) { ... }`, not a
6
+ * closed slot object. Inside the fn authors set fixtures (todos, zoom) and rings.
7
+ * Object maps remain a shorthand for highlight-only slots.
8
+ *
5
9
  * Lifecycle (stubs) ≠ yap slides: stubs are fill/after/error rings tied to the block;
6
10
  * slides are multi-step captions with Next between them (long-process explain).
7
11
  *
@@ -289,11 +293,43 @@ function pickStyleFields(base, patch) {
289
293
  out.weight = normalizeHighlightWeight(weight);
290
294
  return out;
291
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
+ function pickZoom(base, patch) {
322
+ const zoom = patch?.zoom !== undefined ? patch.zoom : base?.zoom;
323
+ if (zoom !== undefined && Number.isFinite(zoom) && zoom > 0)
324
+ return { zoom };
325
+ return {};
326
+ }
292
327
  function mergeSlot(base, patch) {
293
328
  if (!base && !patch)
294
329
  return null;
295
330
  const dwell = pickDurationFields(base, patch);
296
331
  const style = pickStyleFields(base, patch);
332
+ const zoom = pickZoom(base, patch);
297
333
  if (!base && patch) {
298
334
  if (!patch.selector)
299
335
  return null;
@@ -304,6 +340,7 @@ function mergeSlot(base, patch) {
304
340
  ...(patch.tag ? { tag: patch.tag } : {}),
305
341
  ...style,
306
342
  ...dwell,
343
+ ...zoom,
307
344
  };
308
345
  }
309
346
  if (base && !patch) {
@@ -312,6 +349,7 @@ function mergeSlot(base, patch) {
312
349
  ...(base.tone ? { tone: normalizeHighlightTone(base.tone) } : {}),
313
350
  ...(base.size ? { size: normalizeHighlightSize(base.size) } : {}),
314
351
  ...(base.weight ? { weight: normalizeHighlightWeight(base.weight) } : {}),
352
+ ...pickZoom(base, undefined),
315
353
  };
316
354
  }
317
355
  return {
@@ -325,8 +363,78 @@ function mergeSlot(base, patch) {
325
363
  ...(patch.tag !== undefined ? { tag: patch.tag } : base.tag ? { tag: base.tag } : {}),
326
364
  ...style,
327
365
  ...dwell,
366
+ ...zoom,
328
367
  };
329
368
  }
369
+ function createStubCtx(bag, opts) {
370
+ return {
371
+ out: opts.out,
372
+ error: opts.error,
373
+ highlights(slots) {
374
+ bag.highlights = { ...(slots || {}) };
375
+ },
376
+ ring(id, stub) {
377
+ bag.highlights = { ...bag.highlights, [id]: stub };
378
+ },
379
+ todos(items) {
380
+ bag.todos = items;
381
+ },
382
+ todoIndex(n) {
383
+ bag.todoIndex = n;
384
+ },
385
+ zoom(n) {
386
+ if (Number.isFinite(n) && n > 0)
387
+ bag.zoom = n;
388
+ },
389
+ set(partial) {
390
+ if (partial.highlights)
391
+ bag.highlights = { ...partial.highlights };
392
+ if (partial.todos !== undefined)
393
+ bag.todos = partial.todos;
394
+ if (partial.todoIndex !== undefined)
395
+ bag.todoIndex = partial.todoIndex;
396
+ if (partial.zoom !== undefined && Number.isFinite(partial.zoom) && partial.zoom > 0) {
397
+ bag.zoom = partial.zoom;
398
+ }
399
+ },
400
+ };
401
+ }
402
+ /** Lift legacy per-slot todos (0.12.23) onto the phase bag once. */
403
+ function liftLegacySlotTodos(bag) {
404
+ if (bag.todos && bag.todos.length)
405
+ return;
406
+ for (const stub of Object.values(bag.highlights)) {
407
+ const legacy = stub;
408
+ if (legacy.todos && legacy.todos.length) {
409
+ bag.todos = legacy.todos;
410
+ if (legacy.todoIndex !== undefined)
411
+ bag.todoIndex = legacy.todoIndex;
412
+ return;
413
+ }
414
+ }
415
+ }
416
+ function applyDefaultZoom(slots, defaultZoom) {
417
+ if (defaultZoom === undefined || !(defaultZoom > 0))
418
+ return slots;
419
+ return slots.map((h) => (h.zoom !== undefined && h.zoom > 0 ? h : { ...h, zoom: defaultZoom }));
420
+ }
421
+ function mergePhaseMaps(phaseMap, fixturePhase) {
422
+ const slotIds = new Set([...Object.keys(phaseMap), ...Object.keys(fixturePhase || {})]);
423
+ const ordered = [...slotIds].sort((a, b) => {
424
+ const na = Number(a);
425
+ const nb = Number(b);
426
+ if (!Number.isNaN(na) && !Number.isNaN(nb))
427
+ return na - nb;
428
+ return a.localeCompare(b);
429
+ });
430
+ const resolved = [];
431
+ for (const id of ordered) {
432
+ const merged = mergeSlot(phaseMap[id], fixturePhase?.[id]);
433
+ if (merged)
434
+ resolved.push(merged);
435
+ }
436
+ return resolved;
437
+ }
330
438
  function phaseFromLegacyHighlights(highlights) {
331
439
  const out = {};
332
440
  if (!highlights)
@@ -342,58 +450,121 @@ function phaseFromLegacyHighlights(highlights) {
342
450
  });
343
451
  return out;
344
452
  }
453
+ function readStubRaw(block, phase) {
454
+ const instr = block.instruction;
455
+ if (phase === "stubBefore")
456
+ return instr.stubBefore;
457
+ if (phase === "stubAfter")
458
+ return instr.stubAfter;
459
+ return instr.stubOnError;
460
+ }
461
+ function applyLegacyHighlightsShim(block, phase, phaseMap, out) {
462
+ if (phase !== "stubAfter" || Object.keys(phaseMap).length > 0)
463
+ return phaseMap;
464
+ const instr = block.instruction;
465
+ if (!instr.highlights)
466
+ return phaseMap;
467
+ let legacy = instr.highlights;
468
+ if (typeof legacy === "function") {
469
+ try {
470
+ legacy = legacy(out ?? { __state: "" });
471
+ }
472
+ catch {
473
+ legacy = [];
474
+ }
475
+ }
476
+ return phaseFromLegacyHighlights(Array.isArray(legacy) ? legacy : []);
477
+ }
345
478
  /**
346
- * Resolve ordered highlight slots for a phase, merging flow fixtures over block stubs.
347
- * Shim: empty stubAfter + legacy `instruction.highlights` -> auto-keyed stubAfter.
348
- * `stubOnError` is fail-path only (demo step catch) - never run on success.
479
+ * Run an open stub lifecycle (preferred) or resolve a static slot map.
480
+ * Returns rings + episode fixtures (todos / todoIndex / zoom).
481
+ *
482
+ * @example
483
+ * const before = await runStubPhase(block, "stubBefore", { fixtures });
484
+ * // before.todos, before.highlights
349
485
  */
350
- export function resolveHighlightSlots(block, phase, opts) {
351
- const instr = block.instruction;
352
- let raw = phase === "stubBefore"
353
- ? instr.stubBefore
354
- : phase === "stubAfter"
355
- ? instr.stubAfter
356
- : instr.stubOnError;
357
- let phaseMap = {};
486
+ export async function runStubPhase(block, phase, opts) {
487
+ const bag = { highlights: {} };
488
+ const ctx = createStubCtx(bag, {
489
+ ...(opts?.out !== undefined ? { out: opts.out } : {}),
490
+ ...(opts?.error !== undefined ? { error: opts.error } : {}),
491
+ });
492
+ const raw = readStubRaw(block, phase);
358
493
  if (typeof raw === "function") {
359
494
  try {
360
- phaseMap = raw(opts?.out ?? { __state: "" }) || {};
495
+ const ret = await raw(ctx);
496
+ if (isStubPhase(ret)) {
497
+ bag.highlights = { ...bag.highlights, ...ret };
498
+ }
361
499
  }
362
500
  catch {
363
- phaseMap = {};
501
+ /* best-effort */
502
+ }
503
+ // Compat: legacy `(out) => map` that expected Checkpoint (__state on arg)
504
+ if (Object.keys(bag.highlights).length === 0 && !bag.todos) {
505
+ try {
506
+ const legacyOut = (opts?.out ?? { __state: "" });
507
+ const ret2 = raw(legacyOut);
508
+ if (isStubPhase(ret2))
509
+ bag.highlights = { ...ret2 };
510
+ }
511
+ catch {
512
+ /* ignore */
513
+ }
364
514
  }
365
515
  }
366
516
  else if (isStubPhase(raw)) {
367
- phaseMap = { ...raw };
517
+ bag.highlights = { ...raw };
368
518
  }
369
- if (phase === "stubAfter" && Object.keys(phaseMap).length === 0 && instr.highlights) {
370
- let legacy = instr.highlights;
371
- if (typeof legacy === "function") {
519
+ bag.highlights = applyLegacyHighlightsShim(block, phase, bag.highlights, opts?.out);
520
+ liftLegacySlotTodos(bag);
521
+ const fixturePhase = opts?.fixtures?.[block.name]?.[phase];
522
+ let highlights = mergePhaseMaps(bag.highlights, fixturePhase);
523
+ highlights = applyDefaultZoom(highlights, bag.zoom);
524
+ const todos = normalizeTodos(bag.todos, bag.todoIndex);
525
+ return {
526
+ highlights,
527
+ todos,
528
+ ...(bag.todoIndex !== undefined ? { todoIndex: bag.todoIndex } : {}),
529
+ ...(bag.zoom !== undefined ? { zoom: bag.zoom } : {}),
530
+ };
531
+ }
532
+ /**
533
+ * Sync resolve of highlight slots. Prefer {@link runStubPhase} for open
534
+ * async lifecycles + episode fixtures (todos/zoom).
535
+ */
536
+ export function resolveHighlightSlots(block, phase, opts) {
537
+ const bag = { highlights: {} };
538
+ const raw = readStubRaw(block, phase);
539
+ if (typeof raw === "function") {
540
+ try {
541
+ const ctx = createStubCtx(bag, {
542
+ ...(opts?.out !== undefined ? { out: opts.out } : {}),
543
+ });
544
+ const ret = raw(ctx);
545
+ if (isStubPhase(ret)) {
546
+ bag.highlights = { ...bag.highlights, ...ret };
547
+ }
548
+ }
549
+ catch {
372
550
  try {
373
- legacy = legacy(opts?.out ?? { __state: "" });
551
+ const legacyOut = (opts?.out ?? { __state: "" });
552
+ const ret2 = raw(legacyOut);
553
+ if (isStubPhase(ret2))
554
+ bag.highlights = { ...ret2 };
374
555
  }
375
556
  catch {
376
- legacy = [];
557
+ bag.highlights = {};
377
558
  }
378
559
  }
379
- phaseMap = phaseFromLegacyHighlights(Array.isArray(legacy) ? legacy : []);
380
560
  }
381
- const fixturePhase = opts?.fixtures?.[block.name]?.[phase];
382
- const slotIds = new Set([...Object.keys(phaseMap), ...Object.keys(fixturePhase || {})]);
383
- const ordered = [...slotIds].sort((a, b) => {
384
- const na = Number(a);
385
- const nb = Number(b);
386
- if (!Number.isNaN(na) && !Number.isNaN(nb))
387
- return na - nb;
388
- return a.localeCompare(b);
389
- });
390
- const resolved = [];
391
- for (const id of ordered) {
392
- const merged = mergeSlot(phaseMap[id], fixturePhase?.[id]);
393
- if (merged)
394
- resolved.push(merged);
561
+ else if (isStubPhase(raw)) {
562
+ bag.highlights = { ...raw };
395
563
  }
396
- return resolved;
564
+ bag.highlights = applyLegacyHighlightsShim(block, phase, bag.highlights, opts?.out);
565
+ liftLegacySlotTodos(bag);
566
+ const fixturePhase = opts?.fixtures?.[block.name]?.[phase];
567
+ return applyDefaultZoom(mergePhaseMaps(bag.highlights, fixturePhase), bag.zoom);
397
568
  }
398
569
  /**
399
570
  * Resolve multi-step yap slides: flow fixture `slides` wins when present,
@@ -428,21 +599,39 @@ export function formatHighlightCaption(h) {
428
599
  return `${icon}${tag}${primary} - ${h.detail}`;
429
600
  return `${icon}${tag}${primary}`;
430
601
  }
602
+ function stubPhaseLooksAuthored(block, phase, fixtures) {
603
+ const raw = readStubRaw(block, phase);
604
+ if (typeof raw === "function")
605
+ return true;
606
+ if (isStubPhase(raw) && Object.keys(raw).length > 0)
607
+ return true;
608
+ const fx = fixtures?.[block.name]?.[phase];
609
+ if (fx && Object.keys(fx).length > 0)
610
+ return true;
611
+ return false;
612
+ }
431
613
  /**
432
- * True when the block authored a non-empty stubAfter (after resolve + shim),
433
- * including legacy highlights. When true, demo should not use verify-trait fallback.
614
+ * True when the block authored stubAfter (lifecycle fn, slots, fixtures, or legacy highlights).
615
+ * When true, demo should not use verify-trait fallback.
434
616
  */
435
617
  export function hasAuthoredStubAfter(block, out, fixtures) {
618
+ if (stubPhaseLooksAuthored(block, "stubAfter", fixtures))
619
+ return true;
620
+ const instr = block.instruction;
621
+ if (instr.highlights)
622
+ return true;
436
623
  return resolveHighlightSlots(block, "stubAfter", {
437
624
  ...(out !== undefined ? { out } : {}),
438
625
  ...(fixtures !== undefined ? { fixtures } : {}),
439
626
  }).length > 0;
440
627
  }
441
628
  /**
442
- * True when stubOnError resolves to at least one ring (block and/or flow fixture).
629
+ * True when stubOnError is authored (lifecycle fn and/or rings).
443
630
  * Demo fail path only - success never consults this.
444
631
  */
445
632
  export function hasAuthoredStubOnError(block, fixtures) {
633
+ if (stubPhaseLooksAuthored(block, "stubOnError", fixtures))
634
+ return true;
446
635
  return (resolveHighlightSlots(block, "stubOnError", {
447
636
  ...(fixtures !== undefined ? { fixtures } : {}),
448
637
  }).length > 0);
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, resolveFixtureDwellMs, resolveSlideDwellMs, formatHighlightCaption, hasAuthoredStubAfter, hasAuthoredStubOnError, normalizeHighlightTone, normalizeDemoPace, normalizeHighlightSize, normalizeHighlightWeight, applyHighlightStyleDefaults, toneIconPrefix, resolveStepDemoPace, demoPaceScale, demoPaceIsBlitz, demoPaceIsFast, demoPaceIsSlow, demoPaceGateMs, formatDemoPaceBadge, formatDemoPaceLabel, 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, FixtureDurationFields, HighlightStubPhase, HighlightStubPhaseName, HighlightFixtureMap, ResolvedHighlight, WaygraphHighlight, HighlightTone, HighlightSize, HighlightWeight, HighlightStyleDefaults, DemoPace, DemoPaceName, } 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, 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";
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, resolveFixtureDwellMs, resolveSlideDwellMs, formatHighlightCaption, hasAuthoredStubAfter, hasAuthoredStubOnError, normalizeHighlightTone, normalizeDemoPace, normalizeHighlightSize, normalizeHighlightWeight, applyHighlightStyleDefaults, toneIconPrefix, resolveStepDemoPace, demoPaceScale, demoPaceIsBlitz, demoPaceIsFast, demoPaceIsSlow, demoPaceGateMs, formatDemoPaceBadge, formatDemoPaceLabel, 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, 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";
package/dist/types.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { Page } from "@playwright/test";
2
2
  import type { MemPage, MemKey } from "./mem-page.js";
3
3
  import type { Trait } from "./trait.js";
4
4
  import type { HighlightStubPhaseOrFn, WaygraphHighlight, WaygraphSlidesOrFn } from "./highlights.js";
5
- export type { WaygraphHighlight, WaygraphHighlightStub, WaygraphHighlightFixture, WaygraphSlide, HighlightStubPhase, HighlightStubPhaseOrFn, HighlightFixtureMap, ResolvedHighlight, } from "./highlights.js";
5
+ export type { WaygraphHighlight, WaygraphHighlightStub, WaygraphHighlightFixture, WaygraphSlide, HighlightStubPhase, HighlightStubPhaseOrFn, HighlightFixtureMap, ResolvedHighlight, StubCtx, StubLifecycleFn, StubPhaseResult, StubPhaseFixtures, WaygraphTodoItem, WaygraphTodoInput, } from "./highlights.js";
6
6
  /**
7
7
  * A Checkpoint is identified solely by its string tag - a phantom marker with no
8
8
  * embedded data. Two Checkpoints with the same tag are the same state everywhere.
@@ -90,22 +90,34 @@ export interface Instruction<In extends Checkpoint<string>, Out extends Checkpoi
90
90
  */
91
91
  verify?: Trait[] | ((out: Out) => Trait[]);
92
92
  /**
93
- * Catch-all demo slots shown **before / during** `act` (fill/click rings).
94
- * Named map; `{}` is valid. Flow {@link HighlightFixtureMap} overrides labels.
95
- * @example stubBefore: { email: { selector: "#email", label: "Email" } }
93
+ * Open block lifecycle **before / during** `act` (demo rings + fixtures).
94
+ * Prefer `stubBefore(ctx) { ctx.todos(...); ctx.ring(...) }` — not a closed
95
+ * slot object. Object map `{ email: { selector, label } }` remains a shorthand.
96
+ * Flow {@link HighlightFixtureMap} still overrides ring labels.
97
+ * @example stubBefore: (ctx) => {
98
+ * ctx.todos(["Email", "Password", "Submit"]);
99
+ * ctx.todoIndex(0);
100
+ * ctx.highlights({ email: { selector: "#email", label: "Email" } });
101
+ * }
96
102
  */
97
103
  stubBefore?: HighlightStubPhaseOrFn<Out>;
98
104
  /**
99
- * Catch-all demo slots shown **after** resolve (step panel rings).
100
- * Named map; `{}` is valid. Prefer this over legacy {@link Instruction.highlights}.
101
- * @example stubAfter: { badge: { selector: ".cart", label: "Cart updated" } }
105
+ * Open block lifecycle **after** resolve (step panel rings + fixtures).
106
+ * Prefer `stubAfter(ctx) { ... }`. Object map shorthand still works.
107
+ * Prefer this over legacy {@link Instruction.highlights}.
108
+ * @example stubAfter: (ctx) => {
109
+ * ctx.todoIndex(2);
110
+ * ctx.ring("badge", { selector: ".cart", label: "Cart updated" });
111
+ * }
102
112
  */
103
113
  stubAfter?: HighlightStubPhaseOrFn<Out>;
104
114
  /**
105
- * Demo slots shown when a **step throws** (failed verify / act error) -
115
+ * Open block lifecycle when a **step throws** (failed verify / act error) -
106
116
  * before the error panel. Success-only {@link Instruction.stubAfter} never runs
107
- * here; authors keep distinct BUG/FAIL copy. Named map; `{}` is valid.
108
- * @example stubOnError: { card: { selector: ".alt-1", label: "Card at failure" } }
117
+ * here. Prefer `stubOnError(ctx) { ... }`.
118
+ * @example stubOnError: (ctx) => {
119
+ * ctx.ring("card", { selector: ".alt-1", label: "Card at failure", tag: "BUG" });
120
+ * }
109
121
  */
110
122
  stubOnError?: HighlightStubPhaseOrFn<Out>;
111
123
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waygraph",
3
- "version": "0.12.22",
3
+ "version": "0.12.24",
4
4
  "type": "module",
5
5
  "description": "A typed graph of reusable Blocks for driving a browser through E2E flows.",
6
6
  "license": "MIT",