waygraph 0.12.27 → 0.12.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -412,7 +412,17 @@ function seedMemForFlow(mem, flowBlocks, json, label) {
412
412
  const RING_CSS =
413
413
  "#wg-ring{position:fixed;z-index:2147483646;pointer-events:none;opacity:0;" +
414
414
  "border:2.5px solid #7C3AED;border-radius:10px;" +
415
- "box-shadow:0 0 0 4px rgba(124,58,237,.16);transition:opacity .3s ease,border-color .15s,box-shadow .15s;}" +
415
+ "box-shadow:0 0 0 4px rgba(124,58,237,.16);" +
416
+ "transition:opacity .32s ease,left .28s ease,top .28s ease,width .28s ease,height .28s ease,border-color .15s,box-shadow .32s ease;}" +
417
+ // Unquoted [data-focus=1]: this lives inside CHAIN_RUNNER_SCRIPT's template
418
+ // literal - a \" here would be eaten by the outer template and break the
419
+ // generated .mjs. CSS allows unquoted attr values for identifiers.
420
+ "#wg-ring[data-focus=1]{box-shadow:0 0 0 4px rgba(124,58,237,.2),0 0 0 9999px rgba(15,6,32,.55);}" +
421
+ "#wg-ring[data-tone=auto][data-focus=1]{box-shadow:0 0 0 4px rgba(156,163,175,.28),0 0 0 9999px rgba(15,6,32,.55);}" +
422
+ "#wg-ring[data-tone=info][data-focus=1]{box-shadow:0 0 0 4px rgba(59,130,246,.22),0 0 0 9999px rgba(15,6,32,.55);}" +
423
+ "#wg-ring[data-tone=warning][data-focus=1]{box-shadow:0 0 0 4px rgba(234,179,8,.22),0 0 0 9999px rgba(15,6,32,.55);}" +
424
+ "#wg-ring[data-tone=danger][data-focus=1]{box-shadow:0 0 0 4px rgba(239,68,68,.22),0 0 0 9999px rgba(15,6,32,.55);}" +
425
+ "#wg-ring[data-tone=success][data-focus=1]{box-shadow:0 0 0 4px rgba(34,197,94,.22),0 0 0 9999px rgba(15,6,32,.55);}" +
416
426
  // Planned (stubs / fixtures / YAP / instruction.highlights) = purple.
417
427
  // Automation (verify fallback, unmatched fill/click) = gray - operators
418
428
  // can ignore engine checks and watch purple + semantic tones.
@@ -431,7 +441,7 @@ const RING_CSS =
431
441
  // window.__wgPositionRing in installOverlay.
432
442
  "#wg-ring-label{position:fixed;z-index:2147483646;pointer-events:none;opacity:0;" +
433
443
  "max-width:min(360px,70vw);white-space:normal;padding:6px 10px;border-radius:7px;background:#7C3AED;color:#fff;" +
434
- "font:600 12px/1.35 system-ui,sans-serif;transition:opacity .3s ease,background .15s,color .15s;}" +
444
+ "font:600 12px/1.35 system-ui,sans-serif;transition:opacity .32s ease,left .28s ease,top .28s ease,background .15s,color .15s;}" +
435
445
  "#wg-ring-label[data-tone=auto]{background:#6B7280;color:#fff;}" +
436
446
  "#wg-ring-label[data-tone=info]{background:#2563EB;color:#fff;}" +
437
447
  "#wg-ring-label[data-tone=warning]{background:#EAB308;color:#1c1917;}" +
@@ -685,7 +695,8 @@ async function installOverlay(page, title) {
685
695
  window.__wgMoveCursorTo = (x, y, ms, instant) => {
686
696
  const cursor = document.getElementById("wg-cursor");
687
697
  if (!cursor) return;
688
- cursor.style.setProperty("--wg-cursor-ms", (ms || 600) + "ms");
698
+ const travel = ms || 600;
699
+ cursor.style.setProperty("--wg-cursor-ms", travel + "ms");
689
700
  if (instant) {
690
701
  const prev = cursor.style.transition;
691
702
  cursor.style.transition = "none";
@@ -696,6 +707,11 @@ async function installOverlay(page, title) {
696
707
  cursor.style.transform = "translate(" + x + "px," + y + "px)";
697
708
  }
698
709
  cursor.style.opacity = "1";
710
+ // Screen Studio: pan the zoomed camera with the cursor.
711
+ const cam = window.__wgCamera;
712
+ if (cam && cam.scale > 1.001 && window.__wgSetCamera) {
713
+ window.__wgSetCamera(x, y, cam.scale, instant ? 0 : travel);
714
+ }
699
715
  };
700
716
  window.__wgHideCursor = () => {
701
717
  const cursor = document.getElementById("wg-cursor");
@@ -721,6 +737,7 @@ async function installOverlay(page, title) {
721
737
  ring.dataset.size = size;
722
738
  ringLabel.dataset.size = size;
723
739
  ringLabel.dataset.weight = weight;
740
+ ring.dataset.focus = st.focus ? "1" : "0";
724
741
  const pad = size === "sm" ? 3 : size === "lg" ? 10 : 6;
725
742
  const margin = 6;
726
743
  const vw = window.innerWidth;
@@ -740,6 +757,9 @@ async function installOverlay(page, title) {
740
757
  ring.style.opacity = "1";
741
758
  ringLabel.textContent = label;
742
759
  ringLabel.style.opacity = "1";
760
+ const capColor = st.color || st.labelColor || st.textColor;
761
+ if (capColor) ringLabel.style.color = String(capColor);
762
+ else ringLabel.style.removeProperty("color");
743
763
  // Measure the label's own natural width/height (its real
744
764
  // rendered size, unchanged) before deciding where it fits.
745
765
  const lw = ringLabel.offsetWidth;
@@ -779,29 +799,44 @@ async function installOverlay(page, title) {
779
799
  if (ringLabel) ringLabel.style.opacity = "0";
780
800
  if (window.__wgClearZoom) window.__wgClearZoom();
781
801
  };
802
+ // Screen Studio-style camera: scale <body>, keep overlays on <html> fixed.
803
+ window.__wgCamera = { scale: 1, cx: 0, cy: 0 };
804
+ window.__wgSetCamera = (cx, cy, scale, ms) => {
805
+ const body = document.body;
806
+ if (!body) return;
807
+ const s = Math.max(1, Math.min(Number(scale) || 1, 2.75));
808
+ window.__wgCamera = { scale: s, cx, cy };
809
+ const dur = ms != null && Number(ms) >= 0 ? Number(ms) : 500;
810
+ body.style.transition =
811
+ dur <= 0
812
+ ? "none"
813
+ : "transform " + dur / 1000 + "s cubic-bezier(.22,1,.36,1)";
814
+ body.style.transformOrigin = "0 0";
815
+ if (s <= 1.001) {
816
+ body.style.transform = "";
817
+ return;
818
+ }
819
+ body.style.transform =
820
+ "translate(" + cx + "px," + cy + "px) scale(" + s + ") translate(" + -cx + "px," + -cy + "px)";
821
+ };
822
+ window.__wgResetCamera = () => {
823
+ window.__wgSetCamera(window.innerWidth / 2, window.innerHeight / 2, 1);
824
+ };
782
825
  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
- });
826
+ if (window.__wgResetCamera) window.__wgResetCamera();
791
827
  };
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";
828
+ window.__wgSetBannerTitle = (title) => {
829
+ const banner = document.getElementById("wg-banner");
830
+ if (!banner) return;
831
+ let textEl = null;
832
+ for (const sp of banner.querySelectorAll("span")) {
833
+ if (!sp.classList.contains("wg-banner-tag")) { textEl = sp; break; }
834
+ }
835
+ if (!textEl) {
836
+ textEl = document.createElement("span");
837
+ banner.appendChild(textEl);
838
+ }
839
+ textEl.textContent = String(title || "");
805
840
  };
806
841
  const POSITIONS = ["left", "center", "right"];
807
842
  const applyPos = (el, pos) => {
@@ -1202,153 +1237,233 @@ async function ensureSelectorInView(page, selector) {
1202
1237
  }
1203
1238
  }
1204
1239
 
1205
- async function applyHighlightZoom(page, selector, zoom) {
1206
- const z = Number(zoom);
1207
- if (!Number.isFinite(z) || z <= 1.001) {
1240
+ async function applyCameraZoom(page, box, zoom) {
1241
+ const z = zoom != null && Number(zoom) > 1 ? Number(zoom) : 1;
1242
+ if (!box) {
1208
1243
  await page.evaluate(() => {
1209
- if (window.__wgClearZoom) window.__wgClearZoom();
1244
+ if (window.__wgResetCamera) window.__wgResetCamera();
1210
1245
  }).catch(() => {});
1211
1246
  return;
1212
1247
  }
1248
+ const cx = box.x + box.width / 2;
1249
+ const cy = box.y + box.height / 2;
1213
1250
  await page
1214
- .evaluate(
1215
- ({ sel, scale }) => {
1216
- if (window.__wgApplyZoom) window.__wgApplyZoom(sel, scale);
1217
- },
1218
- { sel: selector, scale: z },
1219
- )
1251
+ .evaluate(({ cx, cy, scale }) => {
1252
+ if (window.__wgSetCamera) window.__wgSetCamera(cx, cy, scale);
1253
+ }, { cx, cy, scale: z })
1220
1254
  .catch(() => {});
1221
1255
  }
1222
1256
 
1223
- function todosHtmlFromInfo(info, escFn) {
1224
- const list = info.todos || [];
1225
- if (!list.length) return "";
1226
- return (
1227
- "<ul id=\\"wg-todos\\">" +
1228
- list
1229
- .map((t) => {
1230
- const cls = t.current ? "wg-todo-current" : t.done ? "wg-todo-done" : "wg-todo-pending";
1231
- const mark = t.done ? "\\u2713" : t.current ? "\\u2192" : "\\u25CB";
1232
- return (
1233
- "<li class=\\"" +
1234
- cls +
1235
- "\\"><span class=\\"wg-todo-mark\\">" +
1236
- mark +
1237
- "</span><span>" +
1238
- escFn(t.text || "") +
1239
- "</span></li>"
1240
- );
1241
- })
1242
- .join("") +
1243
- "</ul>"
1244
- );
1257
+ async function resetCameraZoom(page) {
1258
+ await page
1259
+ .evaluate(() => {
1260
+ if (window.__wgResetCamera) window.__wgResetCamera();
1261
+ })
1262
+ .catch(() => {});
1245
1263
  }
1246
1264
 
1247
- function miniStepLabel(info) {
1248
- const ep =
1249
- info.episodeNumber !== undefined && info.episodeNumber !== null
1250
- ? "Ep " + info.episodeNumber + " \\u00b7 "
1251
- : "";
1252
- return (
1253
- ep +
1254
- (info.index + 1) +
1255
- " / " +
1256
- info.total +
1257
- (info.blockName ? " \\u00b7 " + info.blockName : "") +
1258
- (info.paceBadge ? " \\u00b7 " + info.paceBadge : "")
1259
- );
1265
+ async function setDemoBannerTitle(page, title) {
1266
+ if (title == null || title === "") return;
1267
+ await page
1268
+ .evaluate((t) => {
1269
+ if (window.__wgSetBannerTitle) window.__wgSetBannerTitle(t);
1270
+ }, String(title))
1271
+ .catch(() => {});
1260
1272
  }
1261
1273
 
1274
+
1262
1275
  async function cycleHighlightRings(page, highlights, gatesFast, opts) {
1263
1276
  const list = highlights || [];
1264
- // defaultHoldMs: when set (fail path), use instead of legacy 900/200 so BUG
1265
- // rings stay visible ~2s before the error panel (PIA stubOnError RFC).
1266
1277
  const defaultHoldMs = opts && opts.defaultHoldMs != null ? opts.defaultHoldMs : null;
1267
1278
  const pace = opts && opts.pace !== undefined ? opts.pace : undefined;
1279
+ const fadeMs = 320;
1280
+ const n = list.length;
1281
+ const baseStepLabel = (opts && opts.stepLabel) || "";
1282
+
1283
+ const updateHlProgress = async (i) => {
1284
+ if (n <= 0) return;
1285
+ const tag = "[" + (i + 1) + "/" + n + "]";
1286
+ await page
1287
+ .evaluate(
1288
+ ({ tag, baseStepLabel, i, n }) => {
1289
+ const panel = document.getElementById("wg-panel");
1290
+ if (!panel) return;
1291
+ panel.dataset.wgHlIdx = String(i + 1);
1292
+ panel.dataset.wgHlN = String(n);
1293
+ const titleEl = panel.querySelector(".wg-chrome-title");
1294
+ if (titleEl) {
1295
+ const base = panel.dataset.wgStepLabel || baseStepLabel || "waygraph demo";
1296
+ if (panel.classList.contains("wg-collapsed")) {
1297
+ titleEl.textContent = base + " " + tag;
1298
+ }
1299
+ }
1300
+ let span = document.getElementById("wg-hl-idx");
1301
+ const h3 = panel.querySelector("h3");
1302
+ if (h3) {
1303
+ if (!span) {
1304
+ span = document.createElement("span");
1305
+ span.id = "wg-hl-idx";
1306
+ span.style.marginLeft = "8px";
1307
+ span.style.color = "#c9a6ff";
1308
+ span.style.fontWeight = "700";
1309
+ h3.appendChild(span);
1310
+ }
1311
+ span.textContent = tag;
1312
+ }
1313
+ },
1314
+ { tag, baseStepLabel, i, n },
1315
+ )
1316
+ .catch(() => {});
1317
+ };
1318
+
1319
+ const stopLiveFollow = async () => {
1320
+ await page
1321
+ .evaluate(() => {
1322
+ if (window.__wgRingRaf) {
1323
+ cancelAnimationFrame(window.__wgRingRaf);
1324
+ window.__wgRingRaf = null;
1325
+ }
1326
+ if (window.__wgRingTrack) {
1327
+ window.removeEventListener("resize", window.__wgRingTrack);
1328
+ window.removeEventListener("scroll", window.__wgRingTrack, true);
1329
+ window.__wgRingTrack = null;
1330
+ }
1331
+ })
1332
+ .catch(() => {});
1333
+ };
1334
+
1268
1335
  for (let i = 0; i < list.length; i++) {
1269
1336
  const h = list[i];
1270
1337
  try {
1338
+ await stopLiveFollow();
1271
1339
  await ensureSelectorInView(page, h.selector);
1272
- await applyHighlightZoom(page, h.selector, h.zoom);
1273
1340
  const box = await page.locator(h.selector).first().boundingBox();
1274
- if (box) {
1275
- await showRing(page, box, h.label, h.tone || "planned", {
1276
- size: h.size,
1277
- weight: h.weight,
1278
- });
1279
- if ((opts && opts.todos && opts.todos.length) || (h.todos && h.todos.length)) {
1280
- const rows =
1281
- opts && opts.todos && opts.todos.length
1282
- ? opts.todos
1283
- : normalizeTodos(h.todos, h.todoIndex);
1284
- await page
1285
- .evaluate((todos) => {
1286
- const ul = document.getElementById("wg-todos");
1287
- if (!ul) return;
1288
- ul.innerHTML = todos
1289
- .map((t) => {
1290
- const cls = t.current
1291
- ? "wg-todo-current"
1292
- : t.done
1293
- ? "wg-todo-done"
1294
- : "wg-todo-pending";
1295
- const mark = t.done ? "\\u2713" : t.current ? "\\u2192" : "\\u25CB";
1296
- const esc = (s) =>
1297
- String(s)
1298
- .replace(/&/g, "&amp;")
1299
- .replace(/</g, "&lt;")
1300
- .replace(/>/g, "&gt;");
1301
- return (
1302
- "<li class=\\"" +
1303
- cls +
1304
- "\\"><span class=\\"wg-todo-mark\\">" +
1305
- mark +
1306
- "</span><span>" +
1307
- esc(t.text || "") +
1308
- "</span></li>"
1309
- );
1310
- })
1311
- .join("");
1312
- }, rows)
1313
- .catch(() => {});
1314
- }
1315
- const authored = resolveFixtureDwellMs(h, { gatesFast, pace });
1316
- const legacyMs = i === list.length - 1 ? 200 : 900;
1317
- const holdMs =
1318
- authored != null ? authored : defaultHoldMs != null ? defaultHoldMs : legacyMs;
1319
- await new Promise((res) => setTimeout(res, holdMs));
1341
+ if (!box) continue;
1342
+ await updateHlProgress(i);
1343
+ await applyCameraZoom(page, box, h.zoom);
1344
+ const useCursor = h.cursor !== false;
1345
+ if (useCursor) {
1346
+ const cx = box.x + box.width / 2;
1347
+ const cy = box.y + box.height / 2;
1348
+ await page
1349
+ .evaluate(({ cx, cy }) => {
1350
+ if (window.__wgMoveCursorTo) window.__wgMoveCursorTo(cx, cy, 500, false);
1351
+ }, { cx, cy })
1352
+ .catch(() => {});
1353
+ }
1354
+ await showRing(page, box, h.label, h.tone || "planned", {
1355
+ size: h.size,
1356
+ weight: h.weight,
1357
+ focus: !!h.focus,
1358
+ color: h.color || h.labelColor || h.textColor,
1359
+ });
1360
+ // Live follow while this ring is up (seamless, not taped).
1361
+ await page
1362
+ .evaluate(({ sel, label, tone, size, weight, focus, color, zoom }) => {
1363
+ const tick = () => {
1364
+ const el = document.querySelector(sel);
1365
+ if (!el) {
1366
+ window.__wgRingRaf = requestAnimationFrame(tick);
1367
+ return;
1368
+ }
1369
+ const r = el.getBoundingClientRect();
1370
+ const box = { x: r.left, y: r.top, width: r.width, height: r.height };
1371
+ if (window.__wgPositionRing) {
1372
+ window.__wgPositionRing(box, label, tone || "planned", { size, weight, focus, color });
1373
+ }
1374
+ if (zoom && zoom > 1 && window.__wgSetCamera) {
1375
+ // Prefer cursor tip when visible (Screen Studio follow-mouse);
1376
+ // else keep the ring target centered.
1377
+ let cx = box.x + box.width / 2;
1378
+ let cy = box.y + box.height / 2;
1379
+ const cur = document.getElementById("wg-cursor");
1380
+ if (cur && cur.style.opacity !== "0") {
1381
+ const m = /translate\(([-\d.]+)px,\s*([-\d.]+)px\)/.exec(cur.style.transform || "");
1382
+ if (m) {
1383
+ cx = Number(m[1]);
1384
+ cy = Number(m[2]);
1385
+ }
1386
+ }
1387
+ window.__wgSetCamera(cx, cy, zoom, 0);
1388
+ }
1389
+ window.__wgRingRaf = requestAnimationFrame(tick);
1390
+ };
1391
+ window.__wgRingRaf = requestAnimationFrame(tick);
1392
+ const onScroll = () => {};
1393
+ window.__wgRingTrack = onScroll;
1394
+ }, {
1395
+ sel: h.selector,
1396
+ label: h.label,
1397
+ tone: h.tone || "planned",
1398
+ size: h.size || "md",
1399
+ weight: h.weight || "normal",
1400
+ focus: !!h.focus,
1401
+ color: h.color || h.labelColor || h.textColor || "",
1402
+ zoom: h.zoom != null && h.zoom > 1 ? h.zoom : 0,
1403
+ })
1404
+ .catch(() => {});
1405
+
1406
+ if ((opts && opts.todos && opts.todos.length) || (h.todos && h.todos.length)) {
1407
+ const rows =
1408
+ opts && opts.todos && opts.todos.length
1409
+ ? opts.todos
1410
+ : normalizeTodos(h.todos, h.todoIndex);
1411
+ await page
1412
+ .evaluate((todos) => {
1413
+ const ul = document.getElementById("wg-todos");
1414
+ if (!ul) return;
1415
+ ul.innerHTML = todos
1416
+ .map((t) => {
1417
+ const cls = t.current
1418
+ ? "wg-todo-current"
1419
+ : t.done
1420
+ ? "wg-todo-done"
1421
+ : "wg-todo-pending";
1422
+ const mark = t.done ? "\\u2713" : t.current ? "\\u2192" : "\\u25CB";
1423
+ const esc = (s) =>
1424
+ String(s)
1425
+ .replace(/&/g, "&amp;")
1426
+ .replace(/</g, "&lt;")
1427
+ .replace(/>/g, "&gt;");
1428
+ return (
1429
+ "<li class=\\"" +
1430
+ cls +
1431
+ "\\"><span class=\\"wg-todo-mark\\">" +
1432
+ mark +
1433
+ "</span><span>" +
1434
+ esc(t.text || "") +
1435
+ "</span></li>"
1436
+ );
1437
+ })
1438
+ .join("");
1439
+ }, rows)
1440
+ .catch(() => {});
1320
1441
  }
1442
+
1443
+ // Appear settle
1444
+ await new Promise((res) => setTimeout(res, fadeMs));
1445
+ const authored = resolveFixtureDwellMs(h, { gatesFast, pace });
1446
+ const legacyMs = 1100;
1447
+ const holdMs =
1448
+ authored != null ? authored : defaultHoldMs != null ? defaultHoldMs : legacyMs;
1449
+ await new Promise((res) => setTimeout(res, holdMs));
1450
+ // Disappear before next (respect fade)
1451
+ await stopLiveFollow();
1452
+ await hideRing(page);
1453
+ await resetCameraZoom(page);
1454
+ await new Promise((res) => setTimeout(res, fadeMs));
1321
1455
  } catch {
1322
- // best-effort - a selector that doesn't resolve just gets skipped
1456
+ /* best-effort */
1323
1457
  }
1324
1458
  }
1325
- if (list.length > 0) {
1326
- const last = list[list.length - 1];
1327
- await page
1328
- .evaluate((h) => {
1329
- if (!window.__wgPositionRing) return;
1330
- const reposition = () => {
1331
- const el = document.querySelector(h.selector);
1332
- if (!el) {
1333
- if (window.__wgHideRing) window.__wgHideRing();
1334
- return;
1335
- }
1336
- window.__wgPositionRing(el.getBoundingClientRect(), h.label, h.tone || "planned", {
1337
- size: h.size || "md",
1338
- weight: h.weight || "normal",
1339
- });
1340
- };
1341
- reposition();
1342
- window.__wgRingTrack = reposition;
1343
- window.addEventListener("resize", reposition);
1344
- window.addEventListener("scroll", reposition, true);
1345
- }, last)
1346
- .catch(() => {});
1347
- } else {
1459
+ await stopLiveFollow();
1460
+ await resetCameraZoom(page);
1461
+ if (list.length === 0) {
1348
1462
  await hideRing(page);
1349
1463
  }
1350
1464
  }
1351
1465
 
1466
+
1352
1467
  async function renderAfterStep(page, info) {
1353
1468
  await installOverlay(page, info.title);
1354
1469
  // Clear any tracker from a previous highlight before cycling through this
@@ -1698,14 +1813,16 @@ async function markStepRunning(page, _opts) {
1698
1813
  async function showRing(page, box, label, tone, style) {
1699
1814
  const size = normalizeHighlightSize(style && style.size);
1700
1815
  const weight = normalizeHighlightWeight(style && style.weight);
1816
+ const focus = !!(style && style.focus);
1817
+ const color = (style && (style.color || style.labelColor || style.textColor)) || "";
1701
1818
  await page
1702
1819
  .evaluate(
1703
- ({ box, label, tone, size, weight }) => {
1820
+ ({ box, label, tone, size, weight, focus, color }) => {
1704
1821
  if (window.__wgPositionRing) {
1705
- window.__wgPositionRing(box, label, tone || "planned", { size, weight });
1822
+ window.__wgPositionRing(box, label, tone || "planned", { size, weight, focus, color });
1706
1823
  }
1707
1824
  },
1708
- { box, label, tone: tone || "planned", size, weight },
1825
+ { box, label, tone: tone || "planned", size, weight, focus, color },
1709
1826
  )
1710
1827
  .catch(() => {});
1711
1828
  }
@@ -1795,7 +1912,10 @@ async function presentSlides(page, slides, _gate, opts) {
1795
1912
  if (s.selector) {
1796
1913
  try {
1797
1914
  await ensureSelectorInView(page, s.selector);
1798
- await applyHighlightZoom(page, s.selector, s.zoom);
1915
+ {
1916
+ const zb = await page.locator(s.selector).first().boundingBox().catch(() => null);
1917
+ await applyCameraZoom(page, zb, s.zoom);
1918
+ }
1799
1919
  const box = await page.locator(s.selector).first().boundingBox();
1800
1920
  if (box) await showRing(page, box, caption, slideTone, slideStyle);
1801
1921
  } catch {
@@ -2623,6 +2743,39 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2623
2743
  }
2624
2744
  }
2625
2745
  const recordingVideo = !!process.env.WAYGRAPH_VIDEO;
2746
+ // Sequential stubBefore queue (appear -> dwell -> fade) before act.
2747
+ if (stubBeforeRef.current && stubBeforeRef.current.length) {
2748
+ await setDemoBannerTitle(page, stubBeforePhase.title);
2749
+ const beforeRings = stubBeforeRef.current.map((h) => {
2750
+ const styled = applyHighlightStyleDefaults(h, flowStyle);
2751
+ return {
2752
+ selector: styled.selector,
2753
+ label: formatHighlightCaption(styled),
2754
+ duration: styled.duration,
2755
+ fastMode: styled.fastMode,
2756
+ tone: styled.tone,
2757
+ size: styled.size,
2758
+ weight: styled.weight,
2759
+ zoom: styled.zoom,
2760
+ focus: styled.focus,
2761
+ color: styled.color,
2762
+ cursor: styled.cursor,
2763
+ };
2764
+ });
2765
+ await cycleHighlightRings(page, beforeRings, !!pacing.gatesFast, {
2766
+ pace: pacing.demoPace,
2767
+ todos: stubBeforeTodos,
2768
+ stepLabel: miniStepLabel({
2769
+ index: i,
2770
+ total: resolved.length,
2771
+ blockName: r.block.name,
2772
+ episodeNumber: r.episodeNumber,
2773
+ paceBadge: paceSpeak.paceBadge,
2774
+ }),
2775
+ });
2776
+ } else if (stubBeforePhase && stubBeforePhase.title) {
2777
+ await setDemoBannerTitle(page, stubBeforePhase.title);
2778
+ }
2626
2779
  await markStepRunning(page, {});
2627
2780
  // NavBlock click-nav: label the upcoming Locator.click demo cursor as
2628
2781
  // "nav: <block>" so pia/demo watchers see cursor+pulse on click nav
@@ -2672,6 +2825,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2672
2825
  fixtures: fixturesOnError,
2673
2826
  error: err,
2674
2827
  });
2828
+ await setDemoBannerTitle(page, errPhase.title);
2675
2829
  const errHighlights = errPhase.highlights.map((h) => {
2676
2830
  const styled = applyHighlightStyleDefaults(h, r.highlightStyle);
2677
2831
  return {
@@ -2683,6 +2837,9 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2683
2837
  size: styled.size,
2684
2838
  weight: styled.weight,
2685
2839
  zoom: styled.zoom,
2840
+ focus: styled.focus,
2841
+ color: styled.color,
2842
+ cursor: styled.cursor,
2686
2843
  };
2687
2844
  });
2688
2845
  await cycleHighlightRings(page, errHighlights, !!pacing.gatesFast, {
@@ -2761,6 +2918,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2761
2918
  fixtures: fixturesAfter,
2762
2919
  });
2763
2920
  stubAfterTodos = afterPhase.todos || [];
2921
+ await setDemoBannerTitle(page, afterPhase.title);
2764
2922
  highlights = afterPhase.highlights.map((h) => {
2765
2923
  const styled = applyHighlightStyleDefaults(h, r.highlightStyle);
2766
2924
  return {
@@ -2772,6 +2930,9 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2772
2930
  size: styled.size,
2773
2931
  weight: styled.weight,
2774
2932
  zoom: styled.zoom,
2933
+ focus: styled.focus,
2934
+ color: styled.color,
2935
+ cursor: styled.cursor,
2775
2936
  };
2776
2937
  });
2777
2938
  } else {
@@ -45,8 +45,13 @@ export type StubPhaseFixtures = {
45
45
  todos?: readonly WaygraphTodoInput[];
46
46
  /** Which todo is current (0-based). Rows before = done, after = pending. */
47
47
  todoIndex?: number;
48
- /** Default zoom for rings that omit their own zoom. */
48
+ /**
49
+ * Camera zoom (Screen Studio style) - scales the page toward the target /
50
+ * cursor. Not element CSS zoom. Typical `1.25` .. `2`.
51
+ */
49
52
  zoom?: number;
53
+ /** Top demo banner title (the purple "waygraph demo" card). */
54
+ title?: string;
50
55
  };
51
56
  /** One named highlight slot on a block (selector + caption). */
52
57
  export interface WaygraphHighlightStub extends FixtureDurationFields {
@@ -75,14 +80,28 @@ export interface WaygraphHighlightStub extends FixtureDurationFields {
75
80
  */
76
81
  weight?: HighlightWeight;
77
82
  /**
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.
83
+ * Camera zoom while this ring is up (Screen Studio style - page scales toward
84
+ * the target / cursor). Typical `1.25` .. `2`. Cleared when the ring hides.
80
85
  * Episode default: {@link StubCtx.zoom}.
81
86
  */
82
87
  zoom?: number;
88
+ /**
89
+ * Spotlight: dim the rest of the page, keep this target as the focal point.
90
+ */
91
+ focus?: boolean;
92
+ /**
93
+ * Caption text color (CSS color). Overrides tone label color when set.
94
+ * Aliases accepted at runtime: `color`, `labelColor`, `textColor`.
95
+ */
96
+ color?: string;
97
+ /**
98
+ * Move the demo cursor to the target while this ring is up (default true
99
+ * for authored stub queues; set false to keep cursor hidden).
100
+ */
101
+ cursor?: boolean;
83
102
  }
84
103
  /** Flow fixture patch: label required; selector optional (inherits from stub). */
85
- export type WaygraphHighlightFixture = Partial<Pick<WaygraphHighlightStub, "selector">> & Required<Pick<WaygraphHighlightStub, "label">> & Pick<WaygraphHighlightStub, "detail" | "tag" | "tone" | "size" | "weight" | "zoom"> & FixtureDurationFields;
104
+ export type WaygraphHighlightFixture = Partial<Pick<WaygraphHighlightStub, "selector">> & Required<Pick<WaygraphHighlightStub, "label">> & Pick<WaygraphHighlightStub, "detail" | "tag" | "tone" | "size" | "weight" | "zoom" | "focus" | "color" | "cursor"> & FixtureDurationFields;
86
105
  export type HighlightStubPhase = Record<string, WaygraphHighlightStub>;
87
106
  /**
88
107
  * Open stub lifecycle context. Authors set rings + episode fixtures freely —
@@ -118,8 +137,12 @@ export type StubCtx<Out extends Checkpoint<string> = Checkpoint<string>> = {
118
137
  todos(items: readonly WaygraphTodoInput[]): void;
119
138
  /** Current checklist index (bump for sequential plans). */
120
139
  todoIndex(n: number): void;
121
- /** Default zoom for rings without their own zoom. */
140
+ /** Default camera zoom for rings without their own zoom. */
122
141
  zoom(n: number): void;
142
+ /** Top banner title (waygraph demo card). */
143
+ title(text: string): void;
144
+ /** Alias for {@link StubCtx.title}. */
145
+ banner(text: string): void;
123
146
  /** Batch-set rings + episode fixtures. */
124
147
  set(partial: {
125
148
  highlights?: HighlightStubPhase;
@@ -139,6 +162,7 @@ export type StubPhaseResult = {
139
162
  todos: WaygraphTodoItem[];
140
163
  todoIndex?: number;
141
164
  zoom?: number;
165
+ title?: string;
142
166
  };
143
167
  /**
144
168
  * One yap/slide in a multi-step demo caption sequence.
@@ -154,7 +178,7 @@ export interface WaygraphSlide extends FixtureDurationFields {
154
178
  tone?: HighlightTone;
155
179
  size?: HighlightSize;
156
180
  weight?: HighlightWeight;
157
- /** Magnify selector target while this slide is showing (same as stub zoom). */
181
+ /** Magnify via camera (Screen Studio) while this slide is showing. */
158
182
  zoom?: number;
159
183
  }
160
184
  /**
@@ -324,12 +324,26 @@ function pickZoom(base, patch) {
324
324
  return { zoom };
325
325
  return {};
326
326
  }
327
+ function pickFxFields(base, patch) {
328
+ const out = {};
329
+ const focus = patch?.focus !== undefined ? patch.focus : base?.focus;
330
+ const color = patch?.color !== undefined ? patch.color : base?.color;
331
+ const cursor = patch?.cursor !== undefined ? patch.cursor : base?.cursor;
332
+ if (focus !== undefined)
333
+ out.focus = !!focus;
334
+ if (color !== undefined && String(color).trim())
335
+ out.color = String(color).trim();
336
+ if (cursor !== undefined)
337
+ out.cursor = !!cursor;
338
+ return out;
339
+ }
327
340
  function mergeSlot(base, patch) {
328
341
  if (!base && !patch)
329
342
  return null;
330
343
  const dwell = pickDurationFields(base, patch);
331
344
  const style = pickStyleFields(base, patch);
332
345
  const zoom = pickZoom(base, patch);
346
+ const fx = pickFxFields(base, patch);
333
347
  if (!base && patch) {
334
348
  if (!patch.selector)
335
349
  return null;
@@ -341,6 +355,7 @@ function mergeSlot(base, patch) {
341
355
  ...style,
342
356
  ...dwell,
343
357
  ...zoom,
358
+ ...fx,
344
359
  };
345
360
  }
346
361
  if (base && !patch) {
@@ -350,6 +365,7 @@ function mergeSlot(base, patch) {
350
365
  ...(base.size ? { size: normalizeHighlightSize(base.size) } : {}),
351
366
  ...(base.weight ? { weight: normalizeHighlightWeight(base.weight) } : {}),
352
367
  ...pickZoom(base, undefined),
368
+ ...pickFxFields(base, undefined),
353
369
  };
354
370
  }
355
371
  return {
@@ -364,6 +380,7 @@ function mergeSlot(base, patch) {
364
380
  ...style,
365
381
  ...dwell,
366
382
  ...zoom,
383
+ ...fx,
367
384
  };
368
385
  }
369
386
  function createStubCtx(bag, opts) {
@@ -386,6 +403,12 @@ function createStubCtx(bag, opts) {
386
403
  if (Number.isFinite(n) && n > 0)
387
404
  bag.zoom = n;
388
405
  },
406
+ title(text) {
407
+ bag.title = String(text ?? "");
408
+ },
409
+ banner(text) {
410
+ bag.title = String(text ?? "");
411
+ },
389
412
  set(partial) {
390
413
  if (partial.highlights)
391
414
  bag.highlights = { ...partial.highlights };
@@ -396,6 +419,8 @@ function createStubCtx(bag, opts) {
396
419
  if (partial.zoom !== undefined && Number.isFinite(partial.zoom) && partial.zoom > 0) {
397
420
  bag.zoom = partial.zoom;
398
421
  }
422
+ if (partial.title !== undefined)
423
+ bag.title = String(partial.title ?? "");
399
424
  },
400
425
  };
401
426
  }
@@ -527,6 +552,7 @@ export async function runStubPhase(block, phase, opts) {
527
552
  todos,
528
553
  ...(bag.todoIndex !== undefined ? { todoIndex: bag.todoIndex } : {}),
529
554
  ...(bag.zoom !== undefined ? { zoom: bag.zoom } : {}),
555
+ ...(bag.title !== undefined && bag.title !== "" ? { title: bag.title } : {}),
530
556
  };
531
557
  }
532
558
  /**
@@ -45,34 +45,61 @@ export const SubmitLoginActionBlock = defineMethodBlock<LoginPage, LoginSubmitOu
45
45
  out.__state === "LoggedIn"
46
46
  ? [Trait.url({ pathname: "/inventory.html" })]
47
47
  : [Trait.visible('[data-test="error"]')],
48
- // Open lifecycle: todos + zoom; stubAfter rings a lower inventory item
49
- // so smooth auto-scroll is visible after login.
48
+ // Open lifecycle: banner title, todos, Screen Studio camera zoom,
49
+ // sequential highlight queue (appear / dwell / fade), focus + color.
50
50
  stubBefore: (ctx) => {
51
+ ctx.title("Signing in");
51
52
  ctx.todos(["Enter username", "Enter password", "Click Login"]);
52
53
  ctx.todoIndex(0);
53
- ctx.zoom(1.25);
54
+ ctx.zoom(1.35);
54
55
  ctx.highlights({
55
- username: { selector: "#user-name", label: "Username", zoom: 1.3 },
56
- password: { selector: "#password", label: "Password", zoom: 1.3 },
57
- submit: { selector: "#login-button", label: "Login", zoom: 1.45 },
56
+ username: {
57
+ selector: "#user-name",
58
+ label: "Username",
59
+ focus: true,
60
+ color: "#c9a6ff",
61
+ zoom: 1.4,
62
+ },
63
+ password: {
64
+ selector: "#password",
65
+ label: "Password",
66
+ focus: true,
67
+ color: "#c9a6ff",
68
+ zoom: 1.4,
69
+ },
70
+ submit: {
71
+ selector: "#login-button",
72
+ label: "Login",
73
+ focus: true,
74
+ color: "#86efac",
75
+ zoom: 1.55,
76
+ weight: "bold",
77
+ },
58
78
  });
59
79
  },
60
80
  stubAfter: (ctx) => {
81
+ ctx.banner("Inventory");
61
82
  ctx.todos(["Enter username", "Enter password", "Click Login"]);
62
83
  ctx.todoIndex(2);
63
- // Inventory is tall - fleece sits lower; demo smooth-scrolls + zooms it.
84
+ // Fleece sits lower - demo smooth-scrolls + Screen Studio zooms to it.
64
85
  ctx.ring("fleece", {
65
86
  selector: '[data-test="add-to-cart-sauce-labs-fleece-jacket"]',
66
87
  label: "Fleece jacket",
67
- detail: "Smooth scroll + zoom",
68
- zoom: 1.4,
88
+ detail: "Smooth scroll + camera zoom",
89
+ focus: true,
90
+ color: "#86efac",
91
+ zoom: 1.45,
69
92
  duration: true,
70
93
  });
71
94
  },
72
95
  stubOnError: (ctx) => {
96
+ ctx.title("Login failed");
73
97
  ctx.ring("error", {
74
98
  selector: '[data-test="error"]',
75
99
  label: "Login error banner",
100
+ tone: "danger",
101
+ focus: true,
102
+ color: "#f87171",
76
103
  duration: true,
77
104
  });
78
105
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waygraph",
3
- "version": "0.12.27",
3
+ "version": "0.12.29",
4
4
  "type": "module",
5
5
  "description": "A typed graph of reusable Blocks for driving a browser through E2E flows.",
6
6
  "license": "MIT",
@@ -45,31 +45,61 @@ export const SubmitLoginActionBlock = defineMethodBlock<LoginPage, LoginSubmitOu
45
45
  out.__state === "LoggedIn"
46
46
  ? [Trait.url({ pathname: "/inventory.html" })]
47
47
  : [Trait.visible('[data-test="error"]')],
48
+ // Open lifecycle: banner title, todos, Screen Studio camera zoom,
49
+ // sequential highlight queue (appear / dwell / fade), focus + color.
48
50
  stubBefore: (ctx) => {
51
+ ctx.title("Signing in");
49
52
  ctx.todos(["Enter username", "Enter password", "Click Login"]);
50
53
  ctx.todoIndex(0);
51
- ctx.zoom(1.25);
54
+ ctx.zoom(1.35);
52
55
  ctx.highlights({
53
- username: { selector: "#user-name", label: "Username", zoom: 1.3 },
54
- password: { selector: "#password", label: "Password", zoom: 1.3 },
55
- submit: { selector: "#login-button", label: "Login", zoom: 1.45 },
56
+ username: {
57
+ selector: "#user-name",
58
+ label: "Username",
59
+ focus: true,
60
+ color: "#c9a6ff",
61
+ zoom: 1.4,
62
+ },
63
+ password: {
64
+ selector: "#password",
65
+ label: "Password",
66
+ focus: true,
67
+ color: "#c9a6ff",
68
+ zoom: 1.4,
69
+ },
70
+ submit: {
71
+ selector: "#login-button",
72
+ label: "Login",
73
+ focus: true,
74
+ color: "#86efac",
75
+ zoom: 1.55,
76
+ weight: "bold",
77
+ },
56
78
  });
57
79
  },
58
80
  stubAfter: (ctx) => {
81
+ ctx.banner("Inventory");
59
82
  ctx.todos(["Enter username", "Enter password", "Click Login"]);
60
83
  ctx.todoIndex(2);
84
+ // Fleece sits lower - demo smooth-scrolls + Screen Studio zooms to it.
61
85
  ctx.ring("fleece", {
62
86
  selector: '[data-test="add-to-cart-sauce-labs-fleece-jacket"]',
63
87
  label: "Fleece jacket",
64
- detail: "Smooth scroll + zoom",
65
- zoom: 1.4,
88
+ detail: "Smooth scroll + camera zoom",
89
+ focus: true,
90
+ color: "#86efac",
91
+ zoom: 1.45,
66
92
  duration: true,
67
93
  });
68
94
  },
69
95
  stubOnError: (ctx) => {
96
+ ctx.title("Login failed");
70
97
  ctx.ring("error", {
71
98
  selector: '[data-test="error"]',
72
99
  label: "Login error banner",
100
+ tone: "danger",
101
+ focus: true,
102
+ color: "#f87171",
73
103
  duration: true,
74
104
  });
75
105
  },