waygraph 0.11.2 → 0.12.4

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
@@ -214,6 +214,11 @@ overridable, not one giant Block with no per-step confirmation. `composedBlock.w
214
214
  `modStepVerify` patch one step from outside, addressed the same three ways
215
215
  `withBlockVerify`/`modBlockVerify` already are - a `composeBlock` result is a plain Block,
216
216
  so it drops straight into `defineFlow([start, ..., composed, ..., end])` like any other.
217
+ `fastForwardComposeBlock(name, steps)` is the same shape marked for **demo pacing**: one
218
+ opaque step (no per-inner gate) unless `waygraph demo --ff-expand`. Saucedemo
219
+ `checkoutFlow` uses `ff-owner-auth` for the login prefix; `loginFlow` stays expanded for
220
+ Sign In narration. Traverse (later) reuses FF as a seed prefix - see
221
+ `docs/proposals/traverse-ffcompose-rfc.md`.
217
222
  `spawnTab(entry, page, mem)` drives a genuinely separate second tab through its own
218
223
  Block/Flow, in the same browser context an existing `page` already belongs to - the
219
224
  pattern `observe()` could already reach for (it's the only phase allowed to touch
@@ -272,7 +277,7 @@ Mermaid) is `waygraph graph [project]` (`--mermaid`). Unattended JSON execution
272
277
  | Run by export | `waygraph run --blocks shopFlow` |
273
278
  | Manual watch | `waygraph demo src/flows/shop.flow.ts` |
274
279
  | Auto-advance | `waygraph demo --blocks shopFlow --auto-next` (nav auto-hides strip) |
275
- | Fast / full strip | `waygraph demo … --fast` · `--full` (classic chips; default = carousel) |
280
+ | Fast / full strip | `waygraph demo … --fast` (shorter gates; keeps cursor) · `--full` (classic chips; default = carousel) |
276
281
  | QA watch + record | `waygraph demo --blocks shopFlow --auto-play-video` |
277
282
  | Ad-hoc Blocks | `waygraph run --blocks "login then nav-cart" --data '{…}'` |
278
283
  | Headed execute | `waygraph run --blocks shopFlow --non-headless --video` |
package/dist/cli.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Primary verbs (less is more):
6
6
  * auto Explore picker; `.flow.ts` / `--blocks From To` = run that path
7
- * demo Watch (step overlay); `--blocks` `--data` `--auto-next` `--fast` `--full`
7
+ * demo Watch (step overlay); `--blocks` `--data` `--auto-next` `--fast` `--full` `--ff-expand`
8
8
  * run Execute; `--blocks` `--data` `--non-headless` `--video`
9
9
  *
10
10
  * Also: list / nav / validate / check / graph / init / try
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Primary verbs (less is more):
6
6
  * auto Explore picker; `.flow.ts` / `--blocks From To` = run that path
7
- * demo Watch (step overlay); `--blocks` `--data` `--auto-next` `--fast` `--full`
7
+ * demo Watch (step overlay); `--blocks` `--data` `--auto-next` `--fast` `--full` `--ff-expand`
8
8
  * run Execute; `--blocks` `--data` `--non-headless` `--video`
9
9
  *
10
10
  * Also: list / nav / validate / check / graph / init / try
@@ -203,8 +203,11 @@ import { tmpdir } from "node:os";
203
203
  import {
204
204
  resolveHighlightSlots,
205
205
  resolveSlides,
206
+ resolveFixtureDwellMs,
207
+ resolveSlideDwellMs,
206
208
  formatHighlightCaption,
207
209
  hasAuthoredStubAfter,
210
+ hasAuthoredStubOnError,
208
211
  } from "waygraph";
209
212
 
210
213
  function walkDir(dir, pattern) {
@@ -1016,40 +1019,29 @@ async function renderBeforeStep(page, info) {
1016
1019
  .catch(() => {});
1017
1020
  }
1018
1021
 
1019
- async function renderAfterStep(page, info) {
1020
- await installOverlay(page, info.title);
1021
- // Clear any tracker from a previous highlight before cycling through this
1022
- // step's own.
1023
- await page
1024
- .evaluate(() => {
1025
- if (window.__wgRingTrack) {
1026
- window.removeEventListener("resize", window.__wgRingTrack);
1027
- window.removeEventListener("scroll", window.__wgRingTrack, true);
1028
- window.__wgRingTrack = null;
1029
- }
1030
- })
1031
- .catch(() => {});
1032
- const highlights = info.highlights || [];
1033
- // Cycle through EVERY declared/recovered highlight in order, each shown
1034
- // long enough to actually register - "it highlights something [...] then
1035
- // it highlights something [else] and next," not just the first one.
1036
- for (let i = 0; i < highlights.length; i++) {
1037
- const h = highlights[i];
1022
+ async function cycleHighlightRings(page, highlights, gatesFast, opts) {
1023
+ const list = highlights || [];
1024
+ // defaultHoldMs: when set (fail path), use instead of legacy 900/200 so BUG
1025
+ // rings stay visible ~2s before the error panel (PIA stubOnError RFC).
1026
+ const defaultHoldMs = opts && opts.defaultHoldMs != null ? opts.defaultHoldMs : null;
1027
+ for (let i = 0; i < list.length; i++) {
1028
+ const h = list[i];
1038
1029
  try {
1039
1030
  const box = await page.locator(h.selector).first().boundingBox();
1040
1031
  if (box) {
1041
1032
  await showRing(page, box, h.label);
1042
- await new Promise((res) => setTimeout(res, i === highlights.length - 1 ? 200 : 900));
1033
+ const authored = resolveFixtureDwellMs(h, { gatesFast });
1034
+ const legacyMs = i === list.length - 1 ? 200 : 900;
1035
+ const holdMs =
1036
+ authored != null ? authored : defaultHoldMs != null ? defaultHoldMs : legacyMs;
1037
+ await new Promise((res) => setTimeout(res, holdMs));
1043
1038
  }
1044
1039
  } catch {
1045
1040
  // best-effort - a selector that doesn't resolve just gets skipped
1046
1041
  }
1047
1042
  }
1048
- if (highlights.length > 0) {
1049
- // The LAST highlight is the one that stays lit while the human reads
1050
- // the after-step panel - give THAT one live resize/scroll tracking,
1051
- // same as before.
1052
- const last = highlights[highlights.length - 1];
1043
+ if (list.length > 0) {
1044
+ const last = list[list.length - 1];
1053
1045
  await page
1054
1046
  .evaluate((h) => {
1055
1047
  if (!window.__wgPositionRing) return;
@@ -1070,6 +1062,29 @@ async function renderAfterStep(page, info) {
1070
1062
  } else {
1071
1063
  await hideRing(page);
1072
1064
  }
1065
+ }
1066
+
1067
+ async function renderAfterStep(page, info) {
1068
+ await installOverlay(page, info.title);
1069
+ // Clear any tracker from a previous highlight before cycling through this
1070
+ // step's own.
1071
+ await page
1072
+ .evaluate(() => {
1073
+ if (window.__wgRingTrack) {
1074
+ window.removeEventListener("resize", window.__wgRingTrack);
1075
+ window.removeEventListener("scroll", window.__wgRingTrack, true);
1076
+ window.__wgRingTrack = null;
1077
+ }
1078
+ })
1079
+ .catch(() => {});
1080
+ const highlights = info.highlights || [];
1081
+ const gatesFast = !!info.gatesFast;
1082
+ // Cycle through EVERY declared/recovered highlight in order, each shown
1083
+ // long enough to actually register - "it highlights something [...] then
1084
+ // it highlights something [else] and next," not just the first one.
1085
+ // duration / fastMode on stubAfter (or flow fixtures) override the legacy
1086
+ // 900ms / 200ms holds when set.
1087
+ await cycleHighlightRings(page, highlights, gatesFast, null);
1073
1088
  await page
1074
1089
  .evaluate((info) => {
1075
1090
  // Reused in place - see renderBeforeStep's own comment on this.
@@ -1390,10 +1405,10 @@ async function showRing(page, box, label) {
1390
1405
  }
1391
1406
 
1392
1407
  /** Match a locator to a stubBefore slot by overlapping bounding boxes. */
1393
- async function captionForLocator(page, locator, stubs, fallback) {
1394
- if (!stubs || stubs.length === 0) return fallback;
1408
+ async function matchStubForLocator(page, locator, stubs) {
1409
+ if (!stubs || stubs.length === 0) return null;
1395
1410
  const box = await locator.boundingBox().catch(() => null);
1396
- if (!box) return fallback;
1411
+ if (!box) return null;
1397
1412
  for (const s of stubs) {
1398
1413
  try {
1399
1414
  const b = await page.locator(s.selector).first().boundingBox();
@@ -1403,15 +1418,30 @@ async function captionForLocator(page, locator, stubs, fallback) {
1403
1418
  Math.abs(b.y - box.y) < 4 &&
1404
1419
  Math.abs(b.width - box.width) < 8
1405
1420
  ) {
1406
- return formatHighlightCaption(s);
1421
+ return s;
1407
1422
  }
1408
1423
  } catch {
1409
1424
  /* skip */
1410
1425
  }
1411
1426
  }
1427
+ return null;
1428
+ }
1429
+
1430
+ async function captionForLocator(page, locator, stubs, fallback) {
1431
+ const matched = await matchStubForLocator(page, locator, stubs);
1432
+ if (matched) return formatHighlightCaption(matched);
1412
1433
  return fallback;
1413
1434
  }
1414
1435
 
1436
+ async function dwellMatchedStub(page, locator, stubs, pacing) {
1437
+ const matched = await matchStubForLocator(page, locator, stubs);
1438
+ if (!matched) return;
1439
+ const ms = resolveFixtureDwellMs(matched, { gatesFast: !!(pacing && pacing.gatesFast) });
1440
+ if (ms != null && ms > 0) {
1441
+ await new Promise((res) => setTimeout(res, ms));
1442
+ }
1443
+ }
1444
+
1415
1445
  /**
1416
1446
  * Multi-step yap captions - not block lifecycle. Each slide waits for Next
1417
1447
  * (or auto-next), optional ring via slide.selector.
@@ -1426,6 +1456,7 @@ async function presentSlides(page, slides, _gate, opts) {
1426
1456
  const blockName = (opts && opts.blockName) || "";
1427
1457
  const episodeNumber = opts && opts.episodeNumber;
1428
1458
  const episodeTitle = (opts && opts.episodeTitle) || "";
1459
+ const gatesFast = !!(opts && opts.fast);
1429
1460
  const autoplayMs =
1430
1461
  opts && opts.autoplayMs
1431
1462
  ? Number(opts.autoplayMs)
@@ -1436,6 +1467,7 @@ async function presentSlides(page, slides, _gate, opts) {
1436
1467
  const s = slides[i];
1437
1468
  const caption = formatHighlightCaption(s);
1438
1469
  const isLast = i === slides.length - 1;
1470
+ const dwellMs = resolveFixtureDwellMs(s, { gatesFast });
1439
1471
  await installOverlay(page, title);
1440
1472
  if (s.selector) {
1441
1473
  try {
@@ -1489,12 +1521,19 @@ async function presentSlides(page, slides, _gate, opts) {
1489
1521
  esc(info.detail) +
1490
1522
  "</div>"
1491
1523
  : "") +
1524
+ (info.dwellMs != null
1525
+ ? "<div class=\\"wg-auto\\" id=\\"wg-dwell-hint\\" style=\\"margin:0 0 8px\\">Hold " +
1526
+ (info.dwellMs / 1000).toFixed(1) +
1527
+ "s before next...</div>"
1528
+ : "") +
1492
1529
  "<div class=\\"wg-autoplay-row\\"><label><input type=\\"checkbox\\" id=\\"wg-autoplay-cb\\"" +
1493
1530
  (info.autoNow ? " checked" : "") +
1494
1531
  "> Auto-advance</label></div>" +
1495
1532
  "<div id=\\"wg-gate-manual\\"" +
1496
1533
  (info.autoNow ? " style=\\"display:none\\"" : "") +
1497
- "><button type=\\"button\\" id=\\"wg-run\\">" +
1534
+ "><button type=\\"button\\" id=\\"wg-run\\"" +
1535
+ (info.dwellMs != null ? " disabled" : "") +
1536
+ ">" +
1498
1537
  (info.isLast ? "Continue \\u25B6" : "Next slide \\u25B6") +
1499
1538
  "</button></div>" +
1500
1539
  "<div id=\\"wg-gate-auto\\" class=\\"wg-auto\\"" +
@@ -1544,15 +1583,28 @@ async function presentSlides(page, slides, _gate, opts) {
1544
1583
  const runBtn = document.getElementById("wg-run");
1545
1584
  if (runBtn) {
1546
1585
  runBtn.removeAttribute("data-wg-acked");
1547
- runBtn.addEventListener(
1548
- "click",
1549
- (e) => {
1550
- e.preventDefault();
1551
- e.stopPropagation();
1552
- runBtn.setAttribute("data-wg-acked", "1");
1553
- },
1554
- { once: true },
1555
- );
1586
+ runBtn.addEventListener("click", (e) => {
1587
+ e.preventDefault();
1588
+ e.stopPropagation();
1589
+ if (runBtn.disabled) return;
1590
+ runBtn.setAttribute("data-wg-acked", "1");
1591
+ });
1592
+ }
1593
+ if (info.dwellMs != null && info.dwellMs > 0) {
1594
+ const unlockAt = Date.now() + info.dwellMs;
1595
+ const tick = () => {
1596
+ const left = Math.max(0, unlockAt - Date.now());
1597
+ const hint = document.getElementById("wg-dwell-hint");
1598
+ const btn = document.getElementById("wg-run");
1599
+ if (left <= 0) {
1600
+ if (hint) hint.remove();
1601
+ if (btn) btn.disabled = false;
1602
+ return;
1603
+ }
1604
+ if (hint) hint.textContent = "Hold " + (left / 1000).toFixed(1) + "s before next...";
1605
+ setTimeout(tick, 100);
1606
+ };
1607
+ setTimeout(tick, 100);
1556
1608
  }
1557
1609
  },
1558
1610
  {
@@ -1564,11 +1616,15 @@ async function presentSlides(page, slides, _gate, opts) {
1564
1616
  blockName,
1565
1617
  isLast,
1566
1618
  autoNow: false,
1619
+ dwellMs,
1567
1620
  episodeNumber: episodeNumber !== undefined ? episodeNumber : null,
1568
1621
  episodeTitle,
1569
1622
  },
1570
1623
  );
1571
1624
  const started = Date.now();
1625
+ // When duration is set: floor both manual Next and auto-next to dwellMs.
1626
+ // When unset: legacy - auto uses autoplayMs, manual Next is immediate.
1627
+ const autoWaitMs = dwellMs != null ? dwellMs : autoplayMs;
1572
1628
  for (;;) {
1573
1629
  const acked = await page
1574
1630
  .evaluate(() => {
@@ -1576,7 +1632,9 @@ async function presentSlides(page, slides, _gate, opts) {
1576
1632
  return !!(btn && btn.getAttribute("data-wg-acked") === "1");
1577
1633
  })
1578
1634
  .catch(() => false);
1579
- if (acked) break;
1635
+ if (acked) {
1636
+ if (dwellMs == null || Date.now() - started >= dwellMs) break;
1637
+ }
1580
1638
  const auto = await page
1581
1639
  .evaluate(() => {
1582
1640
  try {
@@ -1586,7 +1644,7 @@ async function presentSlides(page, slides, _gate, opts) {
1586
1644
  }
1587
1645
  })
1588
1646
  .catch(() => false);
1589
- if (auto && Date.now() - started >= autoplayMs) break;
1647
+ if (auto && Date.now() - started >= autoWaitMs) break;
1590
1648
  await new Promise((res) => setTimeout(res, 100));
1591
1649
  }
1592
1650
  }
@@ -1678,20 +1736,19 @@ function instrumentInteractionHighlighting(page, mem, slowMo, pacing, stubBefore
1678
1736
  // seconds. When slowMo is already doing the pacing, add none of our own;
1679
1737
  // only fall back to a small typing delay when slowMo is off entirely.
1680
1738
  //
1681
- // Each is a function, not a plain const, and reads pacing.fast fresh
1682
- // on every call - pacing is a shared mutable object runStepMode flips
1683
- // per-block (WAYGRAPH_FAST_BLOCKS), and the Locator.fill/click patches
1684
- // below are installed once on the shared prototype but invoked once per
1685
- // real interaction, long after this closure was created.
1739
+ // pacing.skipTheater (WAYGRAPH_FAST_BLOCKS only): near-zero cursor / pop /
1740
+ // typing - blow past this block. --fast sets pacing.gatesFast instead
1741
+ // and must NOT land here, or demo looks like plain waygraph run.
1686
1742
  // stubBeforeRef.current = resolved stubBefore slots for the active block.
1687
- const typeDelay = () => (pacing.fast ? 0 : slowMo ? 0 : 30);
1743
+ const skipTheater = () => !!pacing.skipTheater;
1744
+ const typeDelay = () => (skipTheater() ? 0 : slowMo ? 0 : 30);
1688
1745
  // Click is a single action, not per-character, so it doesn't compound
1689
1746
  // the same way - but slowMo still adds its own pause around the actual
1690
1747
  // click, so trim our own explicit "pop" pauses when it's already active
1691
1748
  // rather than stacking a full 1.2s on top of that.
1692
- const clickPrePop = () => (pacing.fast ? 0 : slowMo ? 300 : 700);
1693
- const clickPostPop = () => (pacing.fast ? 0 : slowMo ? 200 : 500);
1694
- const cursorMs = (full) => (pacing.fast ? Math.min(120, full) : full);
1749
+ const clickPrePop = () => (skipTheater() ? 0 : slowMo ? 300 : 700);
1750
+ const clickPostPop = () => (skipTheater() ? 0 : slowMo ? 200 : 500);
1751
+ const cursorMs = (full) => (skipTheater() ? Math.min(120, full) : full);
1695
1752
  // Locator.fill()/click() only ever see a raw call, no context of where
1696
1753
  // the value came from. Patching mem.get() to remember the most recently
1697
1754
  // read key's name (Blocks read-then-immediately-fill, e.g. const { email
@@ -1727,7 +1784,8 @@ function instrumentInteractionHighlighting(page, mem, slowMo, pacing, stubBefore
1727
1784
  const label = await captionForLocator(page, this, stubs, fallback);
1728
1785
  await moveCursorTo(page, box, cursorMs(500));
1729
1786
  await showRing(page, box, label);
1730
- await new Promise((res) => setTimeout(res, pacing.fast ? 0 : 200));
1787
+ await dwellMatchedStub(page, this, stubs, pacing);
1788
+ await new Promise((res) => setTimeout(res, skipTheater() ? 0 : 200));
1731
1789
  } else if (box) {
1732
1790
  // Ring/caption already handled by narrate() - still move the
1733
1791
  // cursor there, just skip re-showing the ring with a generic label.
@@ -1796,6 +1854,7 @@ function instrumentInteractionHighlighting(page, mem, slowMo, pacing, stubBefore
1796
1854
  const label = await captionForLocator(page, this, stubs, fallback);
1797
1855
  clickPoint = await moveCursorTo(page, box, cursorMs(600));
1798
1856
  await showRing(page, box, label);
1857
+ await dwellMatchedStub(page, this, stubs, pacing);
1799
1858
  // "pop for a few seconds" - Dan's own phrase, matching the
1800
1859
  // zsign demo-engine's ring-before-click pattern in
1801
1860
  // services/help-center-clip-engine's video-pipeline.
@@ -1816,7 +1875,7 @@ function instrumentInteractionHighlighting(page, mem, slowMo, pacing, stubBefore
1816
1875
  // mid-animation, sometimes cutting it off before it's even visible.
1817
1876
  // Wait out the same .5s the "@keyframes wg-pulse" rule uses, so the
1818
1877
  // mock click visually completes before the real one fires.
1819
- await new Promise((res) => setTimeout(res, pacing.fast ? 0 : 500));
1878
+ await new Promise((res) => setTimeout(res, skipTheater() ? 0 : 500));
1820
1879
  }
1821
1880
  const result = await originalClick.call(this, options);
1822
1881
  await new Promise((res) => setTimeout(res, clickPostPop()));
@@ -1916,12 +1975,14 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
1916
1975
  // WAYGRAPH_FAST_BLOCKS=login) can run through with none of the overlay's
1917
1976
  // own added dwell while the rest of the chain keeps the full theatrical
1918
1977
  // pace. Playwright's own slowMo is process-wide and untouched by this -
1919
- // only OUR added pauses (ring pop, cursor travel, typing delay) shrink.
1920
- // --fast / WAYGRAPH_DEMO_FAST: every step uses fast interaction pacing + shorter gates.
1921
- // WAYGRAPH_FAST_BLOCKS still names per-block overrides when --fast is off.
1978
+ // only OUR added pauses (ring pop, cursor travel, typing delay) shrink
1979
+ // when skipTheater is on.
1980
+ // --fast / WAYGRAPH_DEMO_FAST: shorter auto-next / Next gates ONLY -
1981
+ // keeps smooth cursor travel (not akin to waygraph run).
1982
+ // WAYGRAPH_FAST_BLOCKS: named blocks still skip theater (old blow-past).
1922
1983
  const demoFast = process.env.WAYGRAPH_DEMO_FAST === "1";
1923
1984
  const stepperMode = process.env.WAYGRAPH_STEPPER === "full" ? "full" : "carousel";
1924
- const pacing = { fast: demoFast };
1985
+ const pacing = { gatesFast: demoFast, skipTheater: false };
1925
1986
  const stubBeforeRef = { current: [] };
1926
1987
  instrumentInteractionHighlighting(page, mem, slowMo, pacing, stubBeforeRef);
1927
1988
  // Force the panel checkbox from this process's flags/env at run start.
@@ -2008,7 +2069,8 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2008
2069
  // gates too, not just skip its interaction dwell - otherwise autoplay
2009
2070
  // still stalls the full autoplayMs admiring a step that intentionally
2010
2071
  // ran too fast to watch.
2011
- const ms = pacing.fast ? Math.min(400, autoplayMs) : autoplayMs;
2072
+ const ms =
2073
+ pacing.gatesFast || pacing.skipTheater ? Math.min(400, autoplayMs) : autoplayMs;
2012
2074
  let elapsed = 0;
2013
2075
  for (;;) {
2014
2076
  // Re-read the checkbox EVERY loop tick, not once up front - a human
@@ -2091,7 +2153,10 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2091
2153
  if ((clearSession || r.resetSession) && i > 0) {
2092
2154
  await resetPageState(context, page, baseURL);
2093
2155
  }
2094
- pacing.fast = demoFast || fastBlockNames.has(r.block.name);
2156
+ // gatesFast: --fast (whole run) or FFCompose opaque steps (one short gate).
2157
+ // skipTheater: only WAYGRAPH_FAST_BLOCKS - never --fast, never FF by default.
2158
+ pacing.gatesFast = demoFast || !!r.block.fastForward;
2159
+ pacing.skipTheater = fastBlockNames.has(r.block.name);
2095
2160
  const fixtures = r.highlightFixtures;
2096
2161
  stubBeforeRef.current = resolveHighlightSlots(r.block, "stubBefore", { fixtures });
2097
2162
  const isNavBlock = r.block.__waygraphKind === "nav";
@@ -2170,6 +2235,31 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2170
2235
  delete window.__wgPendingNavClickLabel;
2171
2236
  })
2172
2237
  .catch(() => {});
2238
+ // stubOnError: ring the broken UI BEFORE the error panel (PIA RFC).
2239
+ // stubAfter stays success-only - distinct BUG/FAIL copy on the fail path.
2240
+ const fixturesOnError = r.highlightFixtures;
2241
+ if (hasAuthoredStubOnError(r.block, fixturesOnError)) {
2242
+ await page
2243
+ .evaluate(() => {
2244
+ if (window.__wgRingTrack) {
2245
+ window.removeEventListener("resize", window.__wgRingTrack);
2246
+ window.removeEventListener("scroll", window.__wgRingTrack, true);
2247
+ window.__wgRingTrack = null;
2248
+ }
2249
+ })
2250
+ .catch(() => {});
2251
+ const errHighlights = resolveHighlightSlots(r.block, "stubOnError", {
2252
+ fixtures: fixturesOnError,
2253
+ }).map((h) => ({
2254
+ selector: h.selector,
2255
+ label: formatHighlightCaption(h),
2256
+ duration: h.duration,
2257
+ fastMode: h.fastMode,
2258
+ }));
2259
+ await cycleHighlightRings(page, errHighlights, !!pacing.gatesFast, {
2260
+ defaultHoldMs: 2000,
2261
+ });
2262
+ }
2173
2263
  // A thrown act()/observe()/a failed verify Trait used to just crash
2174
2264
  // the whole Node process with a raw stack trace - the browser closes
2175
2265
  // (main()'s own try/finally) before a human watching ever sees WHY.
@@ -2223,7 +2313,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2223
2313
  await presentSlides(page, slides, gate, {
2224
2314
  title,
2225
2315
  blockName: r.block.name,
2226
- fast: pacing.fast,
2316
+ fast: pacing.gatesFast,
2227
2317
  episodeNumber: r.episodeNumber,
2228
2318
  episodeTitle: r.episodeTitle,
2229
2319
  autoplayMs,
@@ -2235,6 +2325,8 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2235
2325
  (h) => ({
2236
2326
  selector: h.selector,
2237
2327
  label: formatHighlightCaption(h),
2328
+ duration: h.duration,
2329
+ fastMode: h.fastMode,
2238
2330
  }),
2239
2331
  );
2240
2332
  } else {
@@ -2247,6 +2339,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2247
2339
  result,
2248
2340
  resultTag: JSON.stringify(result),
2249
2341
  highlights,
2342
+ gatesFast: pacing.gatesFast,
2250
2343
  isLast: i === resolved.length - 1,
2251
2344
  allNames: moduleNames,
2252
2345
  allDescriptions: moduleDescriptions,
@@ -2362,7 +2455,7 @@ function parseVideoViewport(raw) {
2362
2455
  async function main() {
2363
2456
  const projectDir = process.argv[2];
2364
2457
  const spec = process.argv[3];
2365
- const { connect, MemPage, Engine, start, end, chainFlow } = await import("waygraph");
2458
+ const { connect, MemPage, Engine, start, end, chainFlow, isFastForwardBlock } = await import("waygraph");
2366
2459
  const mem = new MemPage();
2367
2460
  // Computed early - JSON-report mode's stdout is meant to be ONE parseable
2368
2461
  // JSON object for whatever's reading it afterward (a script, an agent),
@@ -2371,6 +2464,27 @@ async function main() {
2371
2464
  // truly unexpected failure outside runJsonReportMode's own try/catch)
2372
2465
  // reaches stdout.
2373
2466
  const jsonReport = process.env.WAYGRAPH_JSON === "1";
2467
+ const ffExpand = process.env.WAYGRAPH_FF_EXPAND === "1";
2468
+ /** Expand or keep fastForwardComposeBlock units for demo/run step lists. */
2469
+ const flattenBlockInfos = (blockInfos) => {
2470
+ const out = [];
2471
+ for (const bi of blockInfos) {
2472
+ const b = bi.block;
2473
+ if (ffExpand && isFastForwardBlock(b)) {
2474
+ for (const step of b.steps()) {
2475
+ out.push({
2476
+ name: step.name,
2477
+ block: step.block,
2478
+ ...(step.routes ? { routes: step.routes } : {}),
2479
+ ...(bi.resetSessionBefore ? { resetSessionBefore: true } : {}),
2480
+ });
2481
+ }
2482
+ } else {
2483
+ out.push(bi);
2484
+ }
2485
+ }
2486
+ return out;
2487
+ };
2374
2488
  let resolved = [];
2375
2489
  let chainFlows = null;
2376
2490
  // A bare identifier (no "(", no "then") might name an existing Flow
@@ -2381,7 +2495,7 @@ async function main() {
2381
2495
  if (bareRef) {
2382
2496
  const flow = await findFlow(projectDir, bareRef);
2383
2497
  if (flow && typeof flow.blocks === "function") {
2384
- const blockInfos = flow.blocks();
2498
+ const blockInfos = flattenBlockInfos(flow.blocks());
2385
2499
  // Same --data / WAYGRAPH_DATA seeding as the multi-segment path.
2386
2500
  // Without this, \`waygraph run shop.flow.ts --data '{...}'\` (and bare
2387
2501
  // export names) hit preflight with an empty MemPage.
@@ -2466,14 +2580,16 @@ async function main() {
2466
2580
  });
2467
2581
  }
2468
2582
  }
2469
- const combinedBlocks = chainFlow(...flows).blocks();
2583
+ const combinedBlocks = flattenBlockInfos(chainFlow(...flows).blocks());
2470
2584
  let fi = 0;
2471
- let remainingInFlow = flows[0].blocks().length;
2585
+ // Episode lengths must follow the same flatten so remainingInFlow stays aligned.
2586
+ const flowLengths = flows.map((f) => flattenBlockInfos(f.blocks()).length);
2587
+ let remainingInFlow = flowLengths[0];
2472
2588
  let isFirstOfSegment = true;
2473
2589
  resolved = combinedBlocks.map((bi) => {
2474
2590
  while (remainingInFlow === 0) {
2475
2591
  fi += 1;
2476
- remainingInFlow = flows[fi].blocks().length;
2592
+ remainingInFlow = flowLengths[fi];
2477
2593
  isFirstOfSegment = true;
2478
2594
  }
2479
2595
  const meta = flowMeta[fi];
@@ -2523,7 +2639,7 @@ async function main() {
2523
2639
  // travel, typing delay, and that block's own gates) - "I want the login
2524
2640
  // block to be faster than the rest of the demo," Dan's own phrase.
2525
2641
  // Playwright's real slowMo is untouched; this only trims what waygraph
2526
- // itself adds on top of it.
2642
+ // itself adds on top of it. Distinct from --fast (gates only).
2527
2643
  const fastBlockNames = new Set(
2528
2644
  (process.env.WAYGRAPH_FAST_BLOCKS || "")
2529
2645
  .split(",")
@@ -3207,6 +3323,9 @@ function parseRunFlags(argv) {
3207
3323
  else if (a === "--full") {
3208
3324
  out.fullStepper = true;
3209
3325
  }
3326
+ else if (a === "--ff-expand") {
3327
+ out.ffExpand = true;
3328
+ }
3210
3329
  else if (a === "--non-headless") {
3211
3330
  out.nonHeadless = true;
3212
3331
  }
@@ -3365,6 +3484,9 @@ function applyRunFlags(flags, opts) {
3365
3484
  if (flags.fullStepper) {
3366
3485
  process.env.WAYGRAPH_STEPPER = "full";
3367
3486
  }
3487
+ if (flags.ffExpand) {
3488
+ process.env.WAYGRAPH_FF_EXPAND = "1";
3489
+ }
3368
3490
  }
3369
3491
  /**
3370
3492
  * BASE_URL when neither --base-url nor WAYGRAPH_BASE_URL is set:
@@ -3520,8 +3642,9 @@ Primary (less is more):
3520
3642
  waygraph demo [--blocks <flow|file|spec>] Watch with step overlay (QA path)
3521
3643
  --data '{...}' Mem seed JSON (or inline flow({...}))
3522
3644
  --auto-next Auto-advance steps (alias: --autoplay)
3523
- --fast Faster transitions + shorter auto-next gates
3645
+ --fast Shorter auto-next / Next gates (keeps smooth cursor)
3524
3646
  --full Classic wrap-all block chips (default: carousel)
3647
+ --ff-expand Expand fastForwardComposeBlock inners as separate steps
3525
3648
  --auto-play-video Unattended + recorded: --auto-next + --video (+ step); headless by default
3526
3649
  --auto-play-video-head Same, but keep the browser visible (--non-headless)
3527
3650
  --video-viewport WxH Recording size (default demo: 1920x1080)
@@ -3531,6 +3654,7 @@ Primary (less is more):
3531
3654
  --non-headless Show browser
3532
3655
  --video [dir] Record .webm
3533
3656
  --video-viewport WxH Recording size (default run: 1280x720)
3657
+ --ff-expand Same as demo (expand FFCompose inners)
3534
3658
 
3535
3659
  Also:
3536
3660
  waygraph list | nav | validate | check | graph | init <name>
@@ -3678,7 +3802,7 @@ async function main() {
3678
3802
  console.error("waygraph demo: missing flow/spec — e.g.\n" +
3679
3803
  " waygraph demo src/flows/shop.flow.ts\n" +
3680
3804
  " waygraph demo --blocks shopFlow\n" +
3681
- " Flags: --blocks --data --auto-next --fast --full --auto-play-video --title --base-url --video");
3805
+ " Flags: --blocks --data --auto-next --fast --full --ff-expand --auto-play-video --title --base-url --video");
3682
3806
  process.exit(1);
3683
3807
  }
3684
3808
  const proj = flags.blocks
package/dist/engine.d.ts CHANGED
@@ -274,8 +274,8 @@ export declare function withTitle<Out extends Checkpoint<string>>(flow: Flow<Out
274
274
  export declare function withExpectedFailure<Out extends Checkpoint<string>>(flow: Flow<Out>, reason: string): Flow<Out>;
275
275
  /**
276
276
  * Attaches demo highlight fixtures to a Flow - unlimited purposes on `.flow.ts`
277
- * (AC copy, BUG/GATE tags, detail) merged onto each block's stubBefore/stubAfter
278
- * slots. Non-destructive, same pattern as {@link withTitle}.
277
+ * (AC copy, BUG/GATE tags, detail) merged onto each block's stubBefore/stubAfter/
278
+ * stubOnError slots. Non-destructive, same pattern as {@link withTitle}.
279
279
  * Demo/`chain --step` reads `flow.highlightFixtures`; `run()` ignores it.
280
280
  * @example withHighlightFixtures(issueFlow, { "submit-login": { stubBefore: { email: { label: "AC-1" } } } })
281
281
  */
@@ -349,6 +349,25 @@ export declare function composeBlock<In extends Checkpoint<string>, B extends Ch
349
349
  export declare function composeBlock<In extends Checkpoint<string>, B extends Checkpoint<string>, C extends Checkpoint<string>, D extends Checkpoint<string>, Out extends Checkpoint<string>>(name: string, steps: readonly [Block<In, B>, Block<B, C>, Block<C, D>, Block<D, Out>]): ComposedBlock<In, Out>;
350
350
  export declare function composeBlock<In extends Checkpoint<string>, B extends Checkpoint<string>, C extends Checkpoint<string>, D extends Checkpoint<string>, E extends Checkpoint<string>, Out extends Checkpoint<string>>(name: string, steps: readonly [Block<In, B>, Block<B, C>, Block<C, D>, Block<D, E>, Block<E, Out>]): ComposedBlock<In, Out>;
351
351
  export declare function composeBlock<In extends Checkpoint<string>, B extends Checkpoint<string>, C extends Checkpoint<string>, D extends Checkpoint<string>, E extends Checkpoint<string>, F extends Checkpoint<string>, Out extends Checkpoint<string>>(name: string, steps: readonly [Block<In, B>, Block<B, C>, Block<C, D>, Block<D, E>, Block<E, F>, Block<F, Out>]): ComposedBlock<In, Out>;
352
+ /**
353
+ * Same shape as {@link composeBlock}, marked {@link FastForwardComposedBlock.fastForward}
354
+ * so demo/run/traverse treat it as **one opaque step** (no per-inner gate/ring)
355
+ * unless `--ff-expand` / `WAYGRAPH_FF_EXPAND=1`. Inner acts still run for real;
356
+ * failures are prefixed `ff-name > …` so the locus names both the FF unit and
357
+ * the inner step. Prefer this for boring prefixes (auth, seed) on demo Flows.
358
+ * @example fastForwardComposeBlock("ff-owner-auth", [NavLoginBlock, SubmitLoginForFlow])
359
+ */
360
+ export interface FastForwardComposedBlock<In extends Checkpoint<string>, Out extends Checkpoint<string>> extends ComposedBlock<In, Out> {
361
+ readonly fastForward: true;
362
+ }
363
+ /** True when `block` came from {@link fastForwardComposeBlock}. */
364
+ export declare function isFastForwardBlock(block: Block<any, any>): block is FastForwardComposedBlock<any, any>;
365
+ export declare function fastForwardComposeBlock<In extends Checkpoint<string>, Out extends Checkpoint<string>>(name: string, steps: readonly [Block<In, Out>]): FastForwardComposedBlock<In, Out>;
366
+ export declare function fastForwardComposeBlock<In extends Checkpoint<string>, B extends Checkpoint<string>, Out extends Checkpoint<string>>(name: string, steps: readonly [Block<In, B>, Block<B, Out>]): FastForwardComposedBlock<In, Out>;
367
+ export declare function fastForwardComposeBlock<In extends Checkpoint<string>, B extends Checkpoint<string>, C extends Checkpoint<string>, Out extends Checkpoint<string>>(name: string, steps: readonly [Block<In, B>, Block<B, C>, Block<C, Out>]): FastForwardComposedBlock<In, Out>;
368
+ export declare function fastForwardComposeBlock<In extends Checkpoint<string>, B extends Checkpoint<string>, C extends Checkpoint<string>, D extends Checkpoint<string>, Out extends Checkpoint<string>>(name: string, steps: readonly [Block<In, B>, Block<B, C>, Block<C, D>, Block<D, Out>]): FastForwardComposedBlock<In, Out>;
369
+ export declare function fastForwardComposeBlock<In extends Checkpoint<string>, B extends Checkpoint<string>, C extends Checkpoint<string>, D extends Checkpoint<string>, E extends Checkpoint<string>, Out extends Checkpoint<string>>(name: string, steps: readonly [Block<In, B>, Block<B, C>, Block<C, D>, Block<D, E>, Block<E, Out>]): FastForwardComposedBlock<In, Out>;
370
+ export declare function fastForwardComposeBlock<In extends Checkpoint<string>, B extends Checkpoint<string>, C extends Checkpoint<string>, D extends Checkpoint<string>, E extends Checkpoint<string>, F extends Checkpoint<string>, Out extends Checkpoint<string>>(name: string, steps: readonly [Block<In, B>, Block<B, C>, Block<C, D>, Block<D, E>, Block<E, F>, Block<F, Out>]): FastForwardComposedBlock<In, Out>;
352
371
  /** Configures how an Engine launches its own browser - only used by `Flow.run(mem)` (no context given); ignored by `Flow.run(context, mem)`, since that context is already launched. */
353
372
  export interface EngineConfig {
354
373
  /** Default `true`, matching Playwright's own default. */
@@ -509,6 +528,7 @@ export type NavBlockOptions<Out extends Checkpoint<string>> = {
509
528
  verify?: Trait[] | ((out: Out) => Trait[]);
510
529
  stubBefore?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
511
530
  stubAfter?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
531
+ stubOnError?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
512
532
  /** @deprecated Prefer stubAfter. */
513
533
  highlights?: readonly WaygraphHighlight[] | ((out: Out) => readonly WaygraphHighlight[]);
514
534
  /**
@@ -572,6 +592,7 @@ export type NavClickBlockOptions<Out extends Checkpoint<string>> = {
572
592
  verify?: Trait[] | ((out: Out) => Trait[]);
573
593
  stubBefore?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
574
594
  stubAfter?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
595
+ stubOnError?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
575
596
  /** @deprecated Prefer stubAfter. */
576
597
  highlights?: readonly WaygraphHighlight[] | ((out: Out) => readonly WaygraphHighlight[]);
577
598
  instanceOptions?: Block<Checkpoint<string>, Out>["instanceOptions"];
@@ -603,6 +624,7 @@ export type PageBlockOptions<Out extends Checkpoint<string>> = {
603
624
  verify?: Trait[] | ((out: Out) => Trait[]);
604
625
  stubBefore?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
605
626
  stubAfter?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
627
+ stubOnError?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
606
628
  /** @deprecated Prefer stubAfter. */
607
629
  highlights?: readonly WaygraphHighlight[] | ((out: Out) => readonly WaygraphHighlight[]);
608
630
  requires?: readonly MemKey<any>[];