waygraph 0.10.9 → 0.11.1

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
@@ -200,6 +200,12 @@ const CHAIN_RUNNER_SCRIPT = `
200
200
  import { readdirSync, mkdirSync } from "node:fs";
201
201
  import { join } from "node:path";
202
202
  import { tmpdir } from "node:os";
203
+ import {
204
+ resolveHighlightSlots,
205
+ resolveSlides,
206
+ formatHighlightCaption,
207
+ hasAuthoredStubAfter,
208
+ } from "waygraph";
203
209
 
204
210
  function walkDir(dir, pattern) {
205
211
  const results = [];
@@ -395,8 +401,8 @@ const RING_CSS =
395
401
  // pushed back onto screen, same width, never shrunk. See
396
402
  // window.__wgPositionRing in installOverlay.
397
403
  "#wg-ring-label{position:fixed;z-index:2147483646;pointer-events:none;opacity:0;" +
398
- "white-space:nowrap;padding:4px 9px;border-radius:7px;background:#7C3AED;color:#fff;" +
399
- "font:600 12px/1.2 system-ui,sans-serif;transition:opacity .3s ease;}" +
404
+ "max-width:min(360px,70vw);white-space:normal;padding:6px 10px;border-radius:7px;background:#7C3AED;color:#fff;" +
405
+ "font:600 12px/1.35 system-ui,sans-serif;transition:opacity .3s ease;}" +
400
406
  // Mouse cursor icon that travels to a target before it's acted on, plus a
401
407
  // quick expanding ripple at the moment of a click - same idea as
402
408
  // help-center-clip-engine's #clip-cursor/#clip-ring (video-pipeline). The
@@ -1337,12 +1343,18 @@ function extractVerifyHighlights(block, resultTag) {
1337
1343
  * __wgNext no-ops once already consumed - but looked live when it wasn't).
1338
1344
  * Disables the button and mem-key textareas, and swaps whichever gate
1339
1345
  * text was showing (manual button or "Auto-advancing...") to "Running...".
1340
- * When autoCollapsePanel is set (QA video / autoplay), tuck the stepper
1341
- * away so ring captions + the app fill the frame - Dan: stepper + auto panels.
1346
+ * ALWAYS collapses the panel while act() runs - leaving it expanded was a
1347
+ * real bug: #wg-panel sits at z-index max with pointer-events:auto, so
1348
+ * Playwright's login/add-to-cart clicks hit the stepper instead of the app
1349
+ * ("subtree intercepts pointer events"). Empty/partial fills + a lucky
1350
+ * edge click on Login then produced Sauce Demo's Epic sadface, verify
1351
+ * still passed on the error banner, and the next block blew up. Collapse
1352
+ * also matches Dan's "step is running / can't interrupt" ask. (_opts kept
1353
+ * for call-site compat; autoCollapsePanel is now unconditional.)
1342
1354
  */
1343
- async function markStepRunning(page, opts) {
1355
+ async function markStepRunning(page, _opts) {
1344
1356
  await page
1345
- .evaluate(({ autoCollapsePanel }) => {
1357
+ .evaluate(() => {
1346
1358
  const runBtn = document.getElementById("wg-run");
1347
1359
  if (runBtn) {
1348
1360
  runBtn.disabled = true;
@@ -1353,18 +1365,16 @@ async function markStepRunning(page, opts) {
1353
1365
  document.querySelectorAll("#wg-panel textarea[data-key]").forEach((ta) => {
1354
1366
  ta.disabled = true;
1355
1367
  });
1356
- if (autoCollapsePanel) {
1357
- const panel = document.getElementById("wg-panel");
1358
- if (panel) {
1359
- panel.classList.add("wg-collapsed");
1360
- const toggle = panel.querySelector("[data-wg-toggle]");
1361
- if (toggle) toggle.textContent = "Show";
1362
- const titleEl = panel.querySelector(".wg-chrome-title");
1363
- const label = panel.dataset.wgStepLabel;
1364
- if (titleEl && label) titleEl.textContent = label;
1365
- }
1368
+ const panel = document.getElementById("wg-panel");
1369
+ if (panel) {
1370
+ panel.classList.add("wg-collapsed");
1371
+ const toggle = panel.querySelector("[data-wg-toggle]");
1372
+ if (toggle) toggle.textContent = "Show";
1373
+ const titleEl = panel.querySelector(".wg-chrome-title");
1374
+ const label = panel.dataset.wgStepLabel;
1375
+ if (titleEl && label) titleEl.textContent = label;
1366
1376
  }
1367
- }, { autoCollapsePanel: !!(opts && opts.autoCollapsePanel) })
1377
+ })
1368
1378
  .catch(() => {});
1369
1379
  }
1370
1380
 
@@ -1379,6 +1389,210 @@ async function showRing(page, box, label) {
1379
1389
  .catch(() => {});
1380
1390
  }
1381
1391
 
1392
+ /** 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;
1395
+ const box = await locator.boundingBox().catch(() => null);
1396
+ if (!box) return fallback;
1397
+ for (const s of stubs) {
1398
+ try {
1399
+ const b = await page.locator(s.selector).first().boundingBox();
1400
+ if (
1401
+ b &&
1402
+ Math.abs(b.x - box.x) < 4 &&
1403
+ Math.abs(b.y - box.y) < 4 &&
1404
+ Math.abs(b.width - box.width) < 8
1405
+ ) {
1406
+ return formatHighlightCaption(s);
1407
+ }
1408
+ } catch {
1409
+ /* skip */
1410
+ }
1411
+ }
1412
+ return fallback;
1413
+ }
1414
+
1415
+ /**
1416
+ * Multi-step yap captions - not block lifecycle. Each slide waits for Next
1417
+ * (or auto-next), optional ring via slide.selector.
1418
+ *
1419
+ * Advance wait does NOT use exposeFunction/gate(): after a real navigation
1420
+ * (e.g. finish-order -> checkout-complete) the panel can render while
1421
+ * __wgNext is momentarily wedged, which left "Next slide" looking live but
1422
+ * doing nothing. In-page data-wg-acked + autoplay poll is the source of truth.
1423
+ */
1424
+ async function presentSlides(page, slides, _gate, opts) {
1425
+ const title = (opts && opts.title) || "waygraph demo";
1426
+ const blockName = (opts && opts.blockName) || "";
1427
+ const episodeNumber = opts && opts.episodeNumber;
1428
+ const episodeTitle = (opts && opts.episodeTitle) || "";
1429
+ const autoplayMs =
1430
+ opts && opts.autoplayMs
1431
+ ? Number(opts.autoplayMs)
1432
+ : process.env.WAYGRAPH_AUTOPLAY_MS
1433
+ ? Number(process.env.WAYGRAPH_AUTOPLAY_MS)
1434
+ : 1800;
1435
+ for (let i = 0; i < slides.length; i++) {
1436
+ const s = slides[i];
1437
+ const caption = formatHighlightCaption(s);
1438
+ const isLast = i === slides.length - 1;
1439
+ await installOverlay(page, title);
1440
+ if (s.selector) {
1441
+ try {
1442
+ const box = await page.locator(s.selector).first().boundingBox();
1443
+ if (box) await showRing(page, box, caption);
1444
+ } catch {
1445
+ await hideRing(page);
1446
+ }
1447
+ } else {
1448
+ await hideRing(page);
1449
+ }
1450
+ await page.evaluate(
1451
+ (info) => {
1452
+ let panel = document.getElementById("wg-panel");
1453
+ const isNew = !panel;
1454
+ if (!panel) {
1455
+ panel = document.createElement("div");
1456
+ panel.id = "wg-panel";
1457
+ }
1458
+ panel.classList.remove("wg-collapsed", "wg-error", "wg-expected");
1459
+ const esc = (t) =>
1460
+ String(t).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
1461
+ const episodeLine =
1462
+ info.episodeNumber !== undefined && info.episodeNumber !== null
1463
+ ? "<div class=\\"wg-episode\\">Episode " +
1464
+ info.episodeNumber +
1465
+ (info.episodeTitle ? ": " + esc(info.episodeTitle) : "") +
1466
+ " · yap slides</div>"
1467
+ : "";
1468
+ panel.innerHTML =
1469
+ "<div class=\\"wg-chrome\\"><span class=\\"wg-chrome-title\\">waygraph demo</span>" +
1470
+ "<button type=\\"button\\" class=\\"wg-hide-btn\\" data-wg-toggle=\\"1\\">Hide</button></div>" +
1471
+ "<div class=\\"wg-body\\">" +
1472
+ episodeLine +
1473
+ "<h3>Slide " +
1474
+ (info.index + 1) +
1475
+ " / " +
1476
+ info.total +
1477
+ (info.blockName ? " · " + esc(info.blockName) : "") +
1478
+ "</h3>" +
1479
+ (info.tag
1480
+ ? "<div style=\\"border:none;padding:0;margin:0 0 6px;font-size:11px;color:#c9a6ff\\">" +
1481
+ esc(info.tag) +
1482
+ "</div>"
1483
+ : "") +
1484
+ "<div class=\\"wg-narration\\">" +
1485
+ esc(info.caption) +
1486
+ "</div>" +
1487
+ (info.detail
1488
+ ? "<div class=\\"wg-result-pretty\\" style=\\"margin:0 0 12px;color:#f0e8ff\\">" +
1489
+ esc(info.detail) +
1490
+ "</div>"
1491
+ : "") +
1492
+ "<div class=\\"wg-autoplay-row\\"><label><input type=\\"checkbox\\" id=\\"wg-autoplay-cb\\"" +
1493
+ (info.autoNow ? " checked" : "") +
1494
+ "> Auto-advance</label></div>" +
1495
+ "<div id=\\"wg-gate-manual\\"" +
1496
+ (info.autoNow ? " style=\\"display:none\\"" : "") +
1497
+ "><button type=\\"button\\" id=\\"wg-run\\">" +
1498
+ (info.isLast ? "Continue \\u25B6" : "Next slide \\u25B6") +
1499
+ "</button></div>" +
1500
+ "<div id=\\"wg-gate-auto\\" class=\\"wg-auto\\"" +
1501
+ (info.autoNow ? "" : " style=\\"display:none\\"") +
1502
+ ">Auto-advancing...</div>" +
1503
+ "</div>";
1504
+ if (isNew) {
1505
+ document.documentElement.appendChild(panel);
1506
+ requestAnimationFrame(() => panel.classList.add("wg-in"));
1507
+ }
1508
+ if (window.__wgWirePanelChrome) {
1509
+ window.__wgWirePanelChrome(panel, "wg-panel-hidden", "waygraph demo", {
1510
+ stepLabel:
1511
+ "Slide " +
1512
+ (info.index + 1) +
1513
+ " / " +
1514
+ info.total +
1515
+ (info.blockName ? " · " + info.blockName : ""),
1516
+ forceCollapsed: false,
1517
+ });
1518
+ }
1519
+ let autoNow = false;
1520
+ try {
1521
+ autoNow = localStorage.getItem("wg-autoplay") === "1";
1522
+ } catch {
1523
+ /* ignore */
1524
+ }
1525
+ const cb = document.getElementById("wg-autoplay-cb");
1526
+ if (cb) {
1527
+ cb.checked = autoNow;
1528
+ cb.addEventListener("change", () => {
1529
+ try {
1530
+ localStorage.setItem("wg-autoplay", cb.checked ? "1" : "0");
1531
+ } catch {
1532
+ /* ignore */
1533
+ }
1534
+ const manual = document.getElementById("wg-gate-manual");
1535
+ const auto = document.getElementById("wg-gate-auto");
1536
+ if (manual) manual.style.display = cb.checked ? "none" : "";
1537
+ if (auto) auto.style.display = cb.checked ? "" : "none";
1538
+ });
1539
+ }
1540
+ const manual = document.getElementById("wg-gate-manual");
1541
+ const auto = document.getElementById("wg-gate-auto");
1542
+ if (manual) manual.style.display = autoNow ? "none" : "";
1543
+ if (auto) auto.style.display = autoNow ? "" : "none";
1544
+ const runBtn = document.getElementById("wg-run");
1545
+ if (runBtn) {
1546
+ 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
+ );
1556
+ }
1557
+ },
1558
+ {
1559
+ index: i,
1560
+ total: slides.length,
1561
+ caption: s.caption,
1562
+ detail: s.detail || "",
1563
+ tag: s.tag || "",
1564
+ blockName,
1565
+ isLast,
1566
+ autoNow: false,
1567
+ episodeNumber: episodeNumber !== undefined ? episodeNumber : null,
1568
+ episodeTitle,
1569
+ },
1570
+ );
1571
+ const started = Date.now();
1572
+ for (;;) {
1573
+ const acked = await page
1574
+ .evaluate(() => {
1575
+ const btn = document.getElementById("wg-run");
1576
+ return !!(btn && btn.getAttribute("data-wg-acked") === "1");
1577
+ })
1578
+ .catch(() => false);
1579
+ if (acked) break;
1580
+ const auto = await page
1581
+ .evaluate(() => {
1582
+ try {
1583
+ return localStorage.getItem("wg-autoplay") === "1";
1584
+ } catch {
1585
+ return false;
1586
+ }
1587
+ })
1588
+ .catch(() => false);
1589
+ if (auto && Date.now() - started >= autoplayMs) break;
1590
+ await new Promise((res) => setTimeout(res, 100));
1591
+ }
1592
+ }
1593
+ await hideRing(page);
1594
+ }
1595
+
1382
1596
  async function hideRing(page) {
1383
1597
  await page
1384
1598
  .evaluate(() => {
@@ -1455,7 +1669,7 @@ async function wasJustNarrated(page) {
1455
1669
  .catch(() => false);
1456
1670
  }
1457
1671
 
1458
- function instrumentInteractionHighlighting(page, mem, slowMo, pacing) {
1672
+ function instrumentInteractionHighlighting(page, mem, slowMo, pacing, stubBeforeRef) {
1459
1673
  // Playwright's own slowMo ALREADY pauses after every single low-level
1460
1674
  // action it dispatches - and pressSequentially() fires one such action
1461
1675
  // PER CHARACTER. Also giving pressSequentially its own fixed delay
@@ -1469,6 +1683,7 @@ function instrumentInteractionHighlighting(page, mem, slowMo, pacing) {
1469
1683
  // per-block (WAYGRAPH_FAST_BLOCKS), and the Locator.fill/click patches
1470
1684
  // below are installed once on the shared prototype but invoked once per
1471
1685
  // real interaction, long after this closure was created.
1686
+ // stubBeforeRef.current = resolved stubBefore slots for the active block.
1472
1687
  const typeDelay = () => (pacing.fast ? 0 : slowMo ? 0 : 30);
1473
1688
  // Click is a single action, not per-character, so it doesn't compound
1474
1689
  // the same way - but slowMo still adds its own pause around the actual
@@ -1505,9 +1720,11 @@ function instrumentInteractionHighlighting(page, mem, slowMo, pacing) {
1505
1720
  // Only trust the "last mem.get()" as THIS fill's source if it
1506
1721
  // happened recently - a stale read from several actions ago is
1507
1722
  // more likely unrelated than actually describing this field.
1508
- const label = memTrack.lastKeyName && Date.now() - memTrack.at < 3000
1723
+ const fallback = memTrack.lastKeyName && Date.now() - memTrack.at < 3000
1509
1724
  ? "from mem: " + memTrack.lastKeyName
1510
1725
  : "writing from mem";
1726
+ const stubs = (stubBeforeRef && stubBeforeRef.current) || [];
1727
+ const label = await captionForLocator(page, this, stubs, fallback);
1511
1728
  await moveCursorTo(page, box, cursorMs(500));
1512
1729
  await showRing(page, box, label);
1513
1730
  await new Promise((res) => setTimeout(res, pacing.fast ? 0 : 200));
@@ -1560,10 +1777,10 @@ function instrumentInteractionHighlighting(page, mem, slowMo, pacing) {
1560
1777
  const box = await this.boundingBox();
1561
1778
  const narrated = box ? await wasJustNarrated(page) : false;
1562
1779
  if (box && !narrated) {
1563
- let label = "click";
1780
+ let fallback = "click";
1564
1781
  try {
1565
1782
  const text = (await this.textContent())?.trim();
1566
- if (text && text.length > 0 && text.length <= 30) label = text;
1783
+ if (text && text.length > 0 && text.length <= 30) fallback = text;
1567
1784
  } catch {
1568
1785
  // element has no simple text (an icon button, say) - generic label is fine
1569
1786
  }
@@ -1571,10 +1788,12 @@ function instrumentInteractionHighlighting(page, mem, slowMo, pacing) {
1571
1788
  // (set on page by runStepMode just before act).
1572
1789
  try {
1573
1790
  const navLabel = await page.evaluate(() => window.__wgPendingNavClickLabel || null);
1574
- if (navLabel) label = String(navLabel);
1791
+ if (navLabel) fallback = String(navLabel);
1575
1792
  } catch {
1576
1793
  /* ignore */
1577
1794
  }
1795
+ const stubs = (stubBeforeRef && stubBeforeRef.current) || [];
1796
+ const label = await captionForLocator(page, this, stubs, fallback);
1578
1797
  clickPoint = await moveCursorTo(page, box, cursorMs(600));
1579
1798
  await showRing(page, box, label);
1580
1799
  // "pop for a few seconds" - Dan's own phrase, matching the
@@ -1703,7 +1922,8 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
1703
1922
  const demoFast = process.env.WAYGRAPH_DEMO_FAST === "1";
1704
1923
  const stepperMode = process.env.WAYGRAPH_STEPPER === "full" ? "full" : "carousel";
1705
1924
  const pacing = { fast: demoFast };
1706
- instrumentInteractionHighlighting(page, mem, slowMo, pacing);
1925
+ const stubBeforeRef = { current: [] };
1926
+ instrumentInteractionHighlighting(page, mem, slowMo, pacing, stubBeforeRef);
1707
1927
  // Force the panel checkbox from this process's flags/env at run start.
1708
1928
  // installOverlay only seeds localStorage when the key is null (so mid-run
1709
1929
  // checkbox clicks survive navigations). Without this, a prior --autoplay
@@ -1733,14 +1953,34 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
1733
1953
  // already is.
1734
1954
  page.setViewportSize = async () => {};
1735
1955
  let resolveNext = null;
1956
+ // Render-to-gate race: a slide/step panel's Next button is wired to
1957
+ // window.__wgNext BEFORE gate() calls waitForNext() (there is at least
1958
+ // one more await - the autoplay-sync evaluate - in between on the
1959
+ // presentSlides path). A click landing in that small window used to be
1960
+ // dropped silently (resolveNext was still null) - the panel then looked
1961
+ // "stuck" forever since gate() started listening AFTER the click already
1962
+ // fired and no second click ever came. Queue it instead: the very next
1963
+ // waitForNext() call picks up a pending click immediately.
1964
+ let pendingNext = null;
1736
1965
  await page.exposeFunction("__wgNext", (edits) => {
1737
1966
  if (resolveNext) {
1738
1967
  const r = resolveNext;
1739
1968
  resolveNext = null;
1740
1969
  r(edits);
1970
+ } else {
1971
+ pendingNext = edits;
1741
1972
  }
1742
1973
  });
1743
- const waitForNext = () => new Promise((res) => (resolveNext = res));
1974
+ const waitForNext = () =>
1975
+ new Promise((res) => {
1976
+ if (pendingNext !== null) {
1977
+ const edits = pendingNext;
1978
+ pendingNext = null;
1979
+ res(edits);
1980
+ return;
1981
+ }
1982
+ resolveNext = res;
1983
+ });
1744
1984
  // WAYGRAPH_AUTOPLAY=1 only sets the STARTING checkbox state now - the
1745
1985
  // panel's own "Auto-advance" checkbox can flip it live, mid-run, and a
1746
1986
  // manual click always wins over an in-flight autoplay wait regardless of
@@ -1852,6 +2092,8 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
1852
2092
  await resetPageState(context, page, baseURL);
1853
2093
  }
1854
2094
  pacing.fast = demoFast || fastBlockNames.has(r.block.name);
2095
+ const fixtures = r.highlightFixtures;
2096
+ stubBeforeRef.current = resolveHighlightSlots(r.block, "stubBefore", { fixtures });
1855
2097
  const isNavBlock = r.block.__waygraphKind === "nav";
1856
2098
  const autoNow = await currentAutoplay();
1857
2099
  // Auto-next: tuck stepper on NavBlocks so the page transition fills the frame.
@@ -1975,7 +2217,29 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
1975
2217
  })
1976
2218
  .catch(() => {});
1977
2219
  result = stepOutcome.result;
1978
- const highlights = extractVerifyHighlights(r.block, result.__state);
2220
+ const fixturesAfter = r.highlightFixtures;
2221
+ const slides = resolveSlides(r.block, { out: result, fixtures: fixturesAfter });
2222
+ if (slides.length > 0) {
2223
+ await presentSlides(page, slides, gate, {
2224
+ title,
2225
+ blockName: r.block.name,
2226
+ fast: pacing.fast,
2227
+ episodeNumber: r.episodeNumber,
2228
+ episodeTitle: r.episodeTitle,
2229
+ autoplayMs,
2230
+ });
2231
+ }
2232
+ let highlights;
2233
+ if (hasAuthoredStubAfter(r.block, result, fixturesAfter)) {
2234
+ highlights = resolveHighlightSlots(r.block, "stubAfter", { out: result, fixtures: fixturesAfter }).map(
2235
+ (h) => ({
2236
+ selector: h.selector,
2237
+ label: formatHighlightCaption(h),
2238
+ }),
2239
+ );
2240
+ } else {
2241
+ highlights = extractVerifyHighlights(r.block, result.__state);
2242
+ }
1979
2243
  await renderAfterStep(page, {
1980
2244
  index: i,
1981
2245
  total: resolved.length,
@@ -2126,6 +2390,7 @@ async function main() {
2126
2390
  block: bi.block,
2127
2391
  exportName: bi.name,
2128
2392
  seedMem: idx === 0 ? seedMem : undefined,
2393
+ highlightFixtures: flow.highlightFixtures,
2129
2394
  expectedFailureReason:
2130
2395
  flow.expectedFailureReason && idx === blockInfos.length - 1
2131
2396
  ? flow.expectedFailureReason
@@ -2187,6 +2452,7 @@ async function main() {
2187
2452
  episodeNumber: episodeCounter,
2188
2453
  episodeTitle: flow.title || seg.ref,
2189
2454
  expectedFailureReason: flow.expectedFailureReason,
2455
+ highlightFixtures: flow.highlightFixtures,
2190
2456
  seedMem: () => seedMemForFlow(mem, flow.blocks(), seg.json, seg.ref),
2191
2457
  });
2192
2458
  } else {
@@ -2195,6 +2461,7 @@ async function main() {
2195
2461
  flowMeta.push({
2196
2462
  episodeNumber: undefined,
2197
2463
  episodeTitle: undefined,
2464
+ highlightFixtures: undefined,
2198
2465
  seedMem: () => seedMemForBlock(mem, r, seg.json),
2199
2466
  });
2200
2467
  }
@@ -2216,6 +2483,7 @@ async function main() {
2216
2483
  resetSession: bi.resetSessionBefore === true,
2217
2484
  episodeNumber: meta.episodeNumber,
2218
2485
  episodeTitle: meta.episodeTitle,
2486
+ highlightFixtures: meta.highlightFixtures,
2219
2487
  expectedFailureReason:
2220
2488
  meta.expectedFailureReason && remainingInFlow === 1 ? meta.expectedFailureReason : undefined,
2221
2489
  seedMem: isFirstOfSegment ? meta.seedMem : undefined,
@@ -2233,8 +2501,13 @@ async function main() {
2233
2501
  }
2234
2502
  }
2235
2503
  const step = process.env.WAYGRAPH_STEP === "1";
2236
- // Stepping through headless defeats the point - a human can't watch it.
2237
- const headed = process.env.WAYGRAPH_HEADED === "1" || step;
2504
+ // Stepping through headless defeats the point for a MANUAL demo - a
2505
+ // human can't watch it. But an explicit WAYGRAPH_HEADED=0 always wins
2506
+ // over that default: --auto-play-video sets STEP=1 (it still needs the
2507
+ // overlay/narration machinery) AND HEADED=0 (unattended + recorded,
2508
+ // nobody has to watch a live window for it to be right).
2509
+ const headed =
2510
+ process.env.WAYGRAPH_HEADED === "0" ? false : process.env.WAYGRAPH_HEADED === "1" || step;
2238
2511
  // Step mode defaults to a visible pace (each fill/click actually shows on
2239
2512
  // screen instead of snapping in) unless the human overrides it - an
2240
2513
  // explicit WAYGRAPH_SLOWMO=0 still means "off".
@@ -2622,13 +2895,23 @@ async function runTryDemo() {
2622
2895
  if (!destDir)
2623
2896
  return;
2624
2897
  process.env.WAYGRAPH_BASE_URL ??= "https://www.saucedemo.com";
2625
- // Default: headed stepper, manual Next. --video records that session (not headless rush).
2626
- process.env.WAYGRAPH_HEADED ??= "1";
2627
2898
  process.env.WAYGRAPH_STEP ??= "1";
2628
2899
  process.env.WAYGRAPH_AUTOPLAY ??= "0";
2629
- // Explicit --no-step --video = unattended headless .webm only.
2630
- if (process.env.WAYGRAPH_VIDEO && process.env.WAYGRAPH_STEP === "0") {
2900
+ // Explicit --no-step --video = unattended headless .webm only (the
2901
+ // documented cloud/CI recipe above) - nobody needs to watch a live
2902
+ // window for Playwright's own recordVideo to capture the right thing.
2903
+ // This must be decided BEFORE the headed default below, or the
2904
+ // unconditional "??=" there always wins and the documented recipe never
2905
+ // actually runs headless.
2906
+ const unattendedVideo = Boolean(process.env.WAYGRAPH_VIDEO) && process.env.WAYGRAPH_STEP === "0";
2907
+ if (unattendedVideo) {
2631
2908
  process.env.WAYGRAPH_AUTOPLAY ??= "1";
2909
+ process.env.WAYGRAPH_HEADED ??= "0";
2910
+ }
2911
+ else {
2912
+ // Default: headed stepper, manual Next. --video records that session
2913
+ // too (not a headless rush) as long as --step is still on.
2914
+ process.env.WAYGRAPH_HEADED ??= "1";
2632
2915
  }
2633
2916
  const videoTo = process.env.WAYGRAPH_VIDEO;
2634
2917
  console.log(videoTo
@@ -2914,6 +3197,10 @@ function parseRunFlags(argv) {
2914
3197
  else if (a === "--auto-play-video") {
2915
3198
  out.autoPlayVideo = true;
2916
3199
  }
3200
+ else if (a === "--auto-play-video-head") {
3201
+ out.autoPlayVideo = true;
3202
+ out.nonHeadless = true;
3203
+ }
2917
3204
  else if (a === "--fast") {
2918
3205
  out.fast = true;
2919
3206
  }
@@ -3022,8 +3309,16 @@ function applyRunFlags(flags, opts) {
3022
3309
  flags.autoplay = true;
3023
3310
  if (flags.video === undefined)
3024
3311
  flags.video = "";
3025
- if (flags.step === undefined)
3026
- flags.step = true;
3312
+ // Overlay narration (captions/rings/slides) needs step mode to run, but
3313
+ // --auto-play-video is unattended-and-recorded by design - no human
3314
+ // has to watch a live window for Playwright's own recordVideo to
3315
+ // capture the right thing. Headless by default; --non-headless (or the
3316
+ // --auto-play-video-head alias, parsed above) opts back into a
3317
+ // visible browser for someone who wants to watch it live too.
3318
+ process.env.WAYGRAPH_STEP = "1";
3319
+ if (!flags.nonHeadless && process.env.WAYGRAPH_HEADED === undefined) {
3320
+ process.env.WAYGRAPH_HEADED = "0";
3321
+ }
3027
3322
  }
3028
3323
  if ((flags.fast || flags.fullStepper) && !opts?.allowDemoUi) {
3029
3324
  console.error("waygraph: --fast / --full are demo-only flags");
@@ -3227,7 +3522,8 @@ Primary (less is more):
3227
3522
  --auto-next Auto-advance steps (alias: --autoplay)
3228
3523
  --fast Faster transitions + shorter auto-next gates
3229
3524
  --full Classic wrap-all block chips (default: carousel)
3230
- --auto-play-video QA: --auto-next + --video (+ step)
3525
+ --auto-play-video Unattended + recorded: --auto-next + --video (+ step); headless by default
3526
+ --auto-play-video-head Same, but keep the browser visible (--non-headless)
3231
3527
  --video-viewport WxH Recording size (default demo: 1920x1080)
3232
3528
  --title / --base-url
3233
3529
  waygraph run [--blocks <flow|file|spec>] Execute (no overlay unless --step)
package/dist/engine.d.ts CHANGED
@@ -180,6 +180,12 @@ export interface Flow<Out extends Checkpoint<string>> {
180
180
  * alarming one indistinguishable from a real break.
181
181
  */
182
182
  readonly expectedFailureReason?: string;
183
+ /**
184
+ * Set once this Flow has been wrapped in {@link withHighlightFixtures} -
185
+ * demo narration overrides (unlimited purposes on `.flow.ts`) keyed by
186
+ * block.name. `run()` never reads this; step/`demo` merges onto block stubs.
187
+ */
188
+ readonly highlightFixtures?: import("./highlights.js").HighlightFixtureMap;
183
189
  /**
184
190
  * This Flow's constituent Blocks, in order between `start`/`end`, as plain
185
191
  * data - for introspection/visualization tools, without running anything.
@@ -266,6 +272,14 @@ export declare function withTitle<Out extends Checkpoint<string>>(flow: Flow<Out
266
272
  * );
267
273
  */
268
274
  export declare function withExpectedFailure<Out extends Checkpoint<string>>(flow: Flow<Out>, reason: string): Flow<Out>;
275
+ /**
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}.
279
+ * Demo/`chain --step` reads `flow.highlightFixtures`; `run()` ignores it.
280
+ * @example withHighlightFixtures(issueFlow, { "submit-login": { stubBefore: { email: { label: "AC-1" } } } })
281
+ */
282
+ export declare function withHighlightFixtures<Out extends Checkpoint<string>>(flow: Flow<Out>, fixtures: import("./highlights.js").HighlightFixtureMap): Flow<Out>;
269
283
  /**
270
284
  * Runs several complete, independent Flows one after another, reusing the
271
285
  * same page/context/mem in order - each remains its own validated
@@ -493,6 +507,9 @@ export type NavBlockOptions<Out extends Checkpoint<string>> = {
493
507
  checkpoint: Out["__state"];
494
508
  requires?: readonly MemKey<any>[];
495
509
  verify?: Trait[] | ((out: Out) => Trait[]);
510
+ stubBefore?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
511
+ stubAfter?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
512
+ /** @deprecated Prefer stubAfter. */
496
513
  highlights?: readonly WaygraphHighlight[] | ((out: Out) => readonly WaygraphHighlight[]);
497
514
  /**
498
515
  * Live per-instance menu rows for `waygraph auto` (see {@link Block.instanceOptions}).
@@ -553,6 +570,9 @@ export type NavClickBlockOptions<Out extends Checkpoint<string>> = {
553
570
  click: string | ((mem: MemPage) => string);
554
571
  requires?: readonly MemKey<any>[];
555
572
  verify?: Trait[] | ((out: Out) => Trait[]);
573
+ stubBefore?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
574
+ stubAfter?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
575
+ /** @deprecated Prefer stubAfter. */
556
576
  highlights?: readonly WaygraphHighlight[] | ((out: Out) => readonly WaygraphHighlight[]);
557
577
  instanceOptions?: Block<Checkpoint<string>, Out>["instanceOptions"];
558
578
  };
@@ -581,6 +601,9 @@ export type PageBlockOptions<Out extends Checkpoint<string>> = {
581
601
  /** Checkpoint tag for this screen (hub). */
582
602
  checkpoint: Out["__state"];
583
603
  verify?: Trait[] | ((out: Out) => Trait[]);
604
+ stubBefore?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
605
+ stubAfter?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
606
+ /** @deprecated Prefer stubAfter. */
584
607
  highlights?: readonly WaygraphHighlight[] | ((out: Out) => readonly WaygraphHighlight[]);
585
608
  requires?: readonly MemKey<any>[];
586
609
  /**
package/dist/engine.js CHANGED
@@ -377,6 +377,21 @@ export function withExpectedFailure(flow, reason) {
377
377
  modBlockVerify: (block, nameOrIndex, newCheck) => withExpectedFailure(flow.modBlockVerify(block, nameOrIndex, newCheck), reason),
378
378
  };
379
379
  }
380
+ /**
381
+ * Attaches demo highlight fixtures to a Flow - unlimited purposes on `.flow.ts`
382
+ * (AC copy, BUG/GATE tags, detail) merged onto each block's stubBefore/stubAfter
383
+ * slots. Non-destructive, same pattern as {@link withTitle}.
384
+ * Demo/`chain --step` reads `flow.highlightFixtures`; `run()` ignores it.
385
+ * @example withHighlightFixtures(issueFlow, { "submit-login": { stubBefore: { email: { label: "AC-1" } } } })
386
+ */
387
+ export function withHighlightFixtures(flow, fixtures) {
388
+ return {
389
+ ...flow,
390
+ highlightFixtures: fixtures,
391
+ withBlockVerify: (block, verify) => withHighlightFixtures(flow.withBlockVerify(block, verify), fixtures),
392
+ modBlockVerify: (block, nameOrIndex, newCheck) => withHighlightFixtures(flow.modBlockVerify(block, nameOrIndex, newCheck), fixtures),
393
+ };
394
+ }
380
395
  function locateInChain(flows, block) {
381
396
  if (typeof block === "number") {
382
397
  let remaining = block;
@@ -659,6 +674,8 @@ export function defineNavBlock(options) {
659
674
  },
660
675
  resolve: () => checkpoint(options.checkpoint),
661
676
  ...(options.verify ? { verify: options.verify } : {}),
677
+ ...(options.stubBefore ? { stubBefore: options.stubBefore } : {}),
678
+ ...(options.stubAfter ? { stubAfter: options.stubAfter } : {}),
662
679
  ...(options.highlights ? { highlights: options.highlights } : {}),
663
680
  },
664
681
  });
@@ -692,6 +709,8 @@ export function defineNavClickBlock(options) {
692
709
  ...(options.description ? { description: options.description } : {}),
693
710
  ...(options.requires ? { requires: options.requires } : {}),
694
711
  ...(options.verify ? { verify: options.verify } : {}),
712
+ ...(options.stubBefore ? { stubBefore: options.stubBefore } : {}),
713
+ ...(options.stubAfter ? { stubAfter: options.stubAfter } : {}),
695
714
  ...(options.highlights ? { highlights: options.highlights } : {}),
696
715
  ...(options.instanceOptions ? { instanceOptions: options.instanceOptions } : {}),
697
716
  });
@@ -738,6 +757,8 @@ export function definePageBlock(options) {
738
757
  },
739
758
  resolve: () => checkpoint(options.checkpoint),
740
759
  ...(options.verify ? { verify: options.verify } : {}),
760
+ ...(options.stubBefore ? { stubBefore: options.stubBefore } : {}),
761
+ ...(options.stubAfter ? { stubAfter: options.stubAfter } : {}),
741
762
  ...(options.highlights ? { highlights: options.highlights } : {}),
742
763
  },
743
764
  });