waygraph 0.12.27 → 0.12.28

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,14 @@ 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
+ "#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);}" +
418
+ "#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);}" +
419
+ "#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);}" +
420
+ "#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);}" +
421
+ "#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);}" +
422
+ "#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
423
  // Planned (stubs / fixtures / YAP / instruction.highlights) = purple.
417
424
  // Automation (verify fallback, unmatched fill/click) = gray - operators
418
425
  // can ignore engine checks and watch purple + semantic tones.
@@ -431,7 +438,7 @@ const RING_CSS =
431
438
  // window.__wgPositionRing in installOverlay.
432
439
  "#wg-ring-label{position:fixed;z-index:2147483646;pointer-events:none;opacity:0;" +
433
440
  "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;}" +
441
+ "font:600 12px/1.35 system-ui,sans-serif;transition:opacity .32s ease,left .28s ease,top .28s ease,background .15s,color .15s;}" +
435
442
  "#wg-ring-label[data-tone=auto]{background:#6B7280;color:#fff;}" +
436
443
  "#wg-ring-label[data-tone=info]{background:#2563EB;color:#fff;}" +
437
444
  "#wg-ring-label[data-tone=warning]{background:#EAB308;color:#1c1917;}" +
@@ -685,7 +692,8 @@ async function installOverlay(page, title) {
685
692
  window.__wgMoveCursorTo = (x, y, ms, instant) => {
686
693
  const cursor = document.getElementById("wg-cursor");
687
694
  if (!cursor) return;
688
- cursor.style.setProperty("--wg-cursor-ms", (ms || 600) + "ms");
695
+ const travel = ms || 600;
696
+ cursor.style.setProperty("--wg-cursor-ms", travel + "ms");
689
697
  if (instant) {
690
698
  const prev = cursor.style.transition;
691
699
  cursor.style.transition = "none";
@@ -696,6 +704,11 @@ async function installOverlay(page, title) {
696
704
  cursor.style.transform = "translate(" + x + "px," + y + "px)";
697
705
  }
698
706
  cursor.style.opacity = "1";
707
+ // Screen Studio: pan the zoomed camera with the cursor.
708
+ const cam = window.__wgCamera;
709
+ if (cam && cam.scale > 1.001 && window.__wgSetCamera) {
710
+ window.__wgSetCamera(x, y, cam.scale, instant ? 0 : travel);
711
+ }
699
712
  };
700
713
  window.__wgHideCursor = () => {
701
714
  const cursor = document.getElementById("wg-cursor");
@@ -721,6 +734,7 @@ async function installOverlay(page, title) {
721
734
  ring.dataset.size = size;
722
735
  ringLabel.dataset.size = size;
723
736
  ringLabel.dataset.weight = weight;
737
+ ring.dataset.focus = st.focus ? "1" : "0";
724
738
  const pad = size === "sm" ? 3 : size === "lg" ? 10 : 6;
725
739
  const margin = 6;
726
740
  const vw = window.innerWidth;
@@ -740,6 +754,9 @@ async function installOverlay(page, title) {
740
754
  ring.style.opacity = "1";
741
755
  ringLabel.textContent = label;
742
756
  ringLabel.style.opacity = "1";
757
+ const capColor = st.color || st.labelColor || st.textColor;
758
+ if (capColor) ringLabel.style.color = String(capColor);
759
+ else ringLabel.style.removeProperty("color");
743
760
  // Measure the label's own natural width/height (its real
744
761
  // rendered size, unchanged) before deciding where it fits.
745
762
  const lw = ringLabel.offsetWidth;
@@ -779,29 +796,44 @@ async function installOverlay(page, title) {
779
796
  if (ringLabel) ringLabel.style.opacity = "0";
780
797
  if (window.__wgClearZoom) window.__wgClearZoom();
781
798
  };
799
+ // Screen Studio-style camera: scale <body>, keep overlays on <html> fixed.
800
+ window.__wgCamera = { scale: 1, cx: 0, cy: 0 };
801
+ window.__wgSetCamera = (cx, cy, scale, ms) => {
802
+ const body = document.body;
803
+ if (!body) return;
804
+ const s = Math.max(1, Math.min(Number(scale) || 1, 2.75));
805
+ window.__wgCamera = { scale: s, cx, cy };
806
+ const dur = ms != null && Number(ms) >= 0 ? Number(ms) : 500;
807
+ body.style.transition =
808
+ dur <= 0
809
+ ? "none"
810
+ : "transform " + dur / 1000 + "s cubic-bezier(.22,1,.36,1)";
811
+ body.style.transformOrigin = "0 0";
812
+ if (s <= 1.001) {
813
+ body.style.transform = "";
814
+ return;
815
+ }
816
+ body.style.transform =
817
+ "translate(" + cx + "px," + cy + "px) scale(" + s + ") translate(" + -cx + "px," + -cy + "px)";
818
+ };
819
+ window.__wgResetCamera = () => {
820
+ window.__wgSetCamera(window.innerWidth / 2, window.innerHeight / 2, 1);
821
+ };
782
822
  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
- });
823
+ if (window.__wgResetCamera) window.__wgResetCamera();
791
824
  };
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";
825
+ window.__wgSetBannerTitle = (title) => {
826
+ const banner = document.getElementById("wg-banner");
827
+ if (!banner) return;
828
+ let textEl = null;
829
+ for (const sp of banner.querySelectorAll("span")) {
830
+ if (!sp.classList.contains("wg-banner-tag")) { textEl = sp; break; }
831
+ }
832
+ if (!textEl) {
833
+ textEl = document.createElement("span");
834
+ banner.appendChild(textEl);
835
+ }
836
+ textEl.textContent = String(title || "");
805
837
  };
806
838
  const POSITIONS = ["left", "center", "right"];
807
839
  const applyPos = (el, pos) => {
@@ -1202,153 +1234,233 @@ async function ensureSelectorInView(page, selector) {
1202
1234
  }
1203
1235
  }
1204
1236
 
1205
- async function applyHighlightZoom(page, selector, zoom) {
1206
- const z = Number(zoom);
1207
- if (!Number.isFinite(z) || z <= 1.001) {
1237
+ async function applyCameraZoom(page, box, zoom) {
1238
+ const z = zoom != null && Number(zoom) > 1 ? Number(zoom) : 1;
1239
+ if (!box) {
1208
1240
  await page.evaluate(() => {
1209
- if (window.__wgClearZoom) window.__wgClearZoom();
1241
+ if (window.__wgResetCamera) window.__wgResetCamera();
1210
1242
  }).catch(() => {});
1211
1243
  return;
1212
1244
  }
1245
+ const cx = box.x + box.width / 2;
1246
+ const cy = box.y + box.height / 2;
1213
1247
  await page
1214
- .evaluate(
1215
- ({ sel, scale }) => {
1216
- if (window.__wgApplyZoom) window.__wgApplyZoom(sel, scale);
1217
- },
1218
- { sel: selector, scale: z },
1219
- )
1248
+ .evaluate(({ cx, cy, scale }) => {
1249
+ if (window.__wgSetCamera) window.__wgSetCamera(cx, cy, scale);
1250
+ }, { cx, cy, scale: z })
1220
1251
  .catch(() => {});
1221
1252
  }
1222
1253
 
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
- );
1254
+ async function resetCameraZoom(page) {
1255
+ await page
1256
+ .evaluate(() => {
1257
+ if (window.__wgResetCamera) window.__wgResetCamera();
1258
+ })
1259
+ .catch(() => {});
1245
1260
  }
1246
1261
 
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
- );
1262
+ async function setDemoBannerTitle(page, title) {
1263
+ if (title == null || title === "") return;
1264
+ await page
1265
+ .evaluate((t) => {
1266
+ if (window.__wgSetBannerTitle) window.__wgSetBannerTitle(t);
1267
+ }, String(title))
1268
+ .catch(() => {});
1260
1269
  }
1261
1270
 
1271
+
1262
1272
  async function cycleHighlightRings(page, highlights, gatesFast, opts) {
1263
1273
  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
1274
  const defaultHoldMs = opts && opts.defaultHoldMs != null ? opts.defaultHoldMs : null;
1267
1275
  const pace = opts && opts.pace !== undefined ? opts.pace : undefined;
1276
+ const fadeMs = 320;
1277
+ const n = list.length;
1278
+ const baseStepLabel = (opts && opts.stepLabel) || "";
1279
+
1280
+ const updateHlProgress = async (i) => {
1281
+ if (n <= 0) return;
1282
+ const tag = "[" + (i + 1) + "/" + n + "]";
1283
+ await page
1284
+ .evaluate(
1285
+ ({ tag, baseStepLabel, i, n }) => {
1286
+ const panel = document.getElementById("wg-panel");
1287
+ if (!panel) return;
1288
+ panel.dataset.wgHlIdx = String(i + 1);
1289
+ panel.dataset.wgHlN = String(n);
1290
+ const titleEl = panel.querySelector(".wg-chrome-title");
1291
+ if (titleEl) {
1292
+ const base = panel.dataset.wgStepLabel || baseStepLabel || "waygraph demo";
1293
+ if (panel.classList.contains("wg-collapsed")) {
1294
+ titleEl.textContent = base + " " + tag;
1295
+ }
1296
+ }
1297
+ let span = document.getElementById("wg-hl-idx");
1298
+ const h3 = panel.querySelector("h3");
1299
+ if (h3) {
1300
+ if (!span) {
1301
+ span = document.createElement("span");
1302
+ span.id = "wg-hl-idx";
1303
+ span.style.marginLeft = "8px";
1304
+ span.style.color = "#c9a6ff";
1305
+ span.style.fontWeight = "700";
1306
+ h3.appendChild(span);
1307
+ }
1308
+ span.textContent = tag;
1309
+ }
1310
+ },
1311
+ { tag, baseStepLabel, i, n },
1312
+ )
1313
+ .catch(() => {});
1314
+ };
1315
+
1316
+ const stopLiveFollow = async () => {
1317
+ await page
1318
+ .evaluate(() => {
1319
+ if (window.__wgRingRaf) {
1320
+ cancelAnimationFrame(window.__wgRingRaf);
1321
+ window.__wgRingRaf = null;
1322
+ }
1323
+ if (window.__wgRingTrack) {
1324
+ window.removeEventListener("resize", window.__wgRingTrack);
1325
+ window.removeEventListener("scroll", window.__wgRingTrack, true);
1326
+ window.__wgRingTrack = null;
1327
+ }
1328
+ })
1329
+ .catch(() => {});
1330
+ };
1331
+
1268
1332
  for (let i = 0; i < list.length; i++) {
1269
1333
  const h = list[i];
1270
1334
  try {
1335
+ await stopLiveFollow();
1271
1336
  await ensureSelectorInView(page, h.selector);
1272
- await applyHighlightZoom(page, h.selector, h.zoom);
1273
1337
  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));
1338
+ if (!box) continue;
1339
+ await updateHlProgress(i);
1340
+ await applyCameraZoom(page, box, h.zoom);
1341
+ const useCursor = h.cursor !== false;
1342
+ if (useCursor) {
1343
+ const cx = box.x + box.width / 2;
1344
+ const cy = box.y + box.height / 2;
1345
+ await page
1346
+ .evaluate(({ cx, cy }) => {
1347
+ if (window.__wgMoveCursorTo) window.__wgMoveCursorTo(cx, cy, 500, false);
1348
+ }, { cx, cy })
1349
+ .catch(() => {});
1350
+ }
1351
+ await showRing(page, box, h.label, h.tone || "planned", {
1352
+ size: h.size,
1353
+ weight: h.weight,
1354
+ focus: !!h.focus,
1355
+ color: h.color || h.labelColor || h.textColor,
1356
+ });
1357
+ // Live follow while this ring is up (seamless, not taped).
1358
+ await page
1359
+ .evaluate(({ sel, label, tone, size, weight, focus, color, zoom }) => {
1360
+ const tick = () => {
1361
+ const el = document.querySelector(sel);
1362
+ if (!el) {
1363
+ window.__wgRingRaf = requestAnimationFrame(tick);
1364
+ return;
1365
+ }
1366
+ const r = el.getBoundingClientRect();
1367
+ const box = { x: r.left, y: r.top, width: r.width, height: r.height };
1368
+ if (window.__wgPositionRing) {
1369
+ window.__wgPositionRing(box, label, tone || "planned", { size, weight, focus, color });
1370
+ }
1371
+ if (zoom && zoom > 1 && window.__wgSetCamera) {
1372
+ // Prefer cursor tip when visible (Screen Studio follow-mouse);
1373
+ // else keep the ring target centered.
1374
+ let cx = box.x + box.width / 2;
1375
+ let cy = box.y + box.height / 2;
1376
+ const cur = document.getElementById("wg-cursor");
1377
+ if (cur && cur.style.opacity !== "0") {
1378
+ const m = /translate\(([-\d.]+)px,\s*([-\d.]+)px\)/.exec(cur.style.transform || "");
1379
+ if (m) {
1380
+ cx = Number(m[1]);
1381
+ cy = Number(m[2]);
1382
+ }
1383
+ }
1384
+ window.__wgSetCamera(cx, cy, zoom, 0);
1385
+ }
1386
+ window.__wgRingRaf = requestAnimationFrame(tick);
1387
+ };
1388
+ window.__wgRingRaf = requestAnimationFrame(tick);
1389
+ const onScroll = () => {};
1390
+ window.__wgRingTrack = onScroll;
1391
+ }, {
1392
+ sel: h.selector,
1393
+ label: h.label,
1394
+ tone: h.tone || "planned",
1395
+ size: h.size || "md",
1396
+ weight: h.weight || "normal",
1397
+ focus: !!h.focus,
1398
+ color: h.color || h.labelColor || h.textColor || "",
1399
+ zoom: h.zoom != null && h.zoom > 1 ? h.zoom : 0,
1400
+ })
1401
+ .catch(() => {});
1402
+
1403
+ if ((opts && opts.todos && opts.todos.length) || (h.todos && h.todos.length)) {
1404
+ const rows =
1405
+ opts && opts.todos && opts.todos.length
1406
+ ? opts.todos
1407
+ : normalizeTodos(h.todos, h.todoIndex);
1408
+ await page
1409
+ .evaluate((todos) => {
1410
+ const ul = document.getElementById("wg-todos");
1411
+ if (!ul) return;
1412
+ ul.innerHTML = todos
1413
+ .map((t) => {
1414
+ const cls = t.current
1415
+ ? "wg-todo-current"
1416
+ : t.done
1417
+ ? "wg-todo-done"
1418
+ : "wg-todo-pending";
1419
+ const mark = t.done ? "\\u2713" : t.current ? "\\u2192" : "\\u25CB";
1420
+ const esc = (s) =>
1421
+ String(s)
1422
+ .replace(/&/g, "&amp;")
1423
+ .replace(/</g, "&lt;")
1424
+ .replace(/>/g, "&gt;");
1425
+ return (
1426
+ "<li class=\\"" +
1427
+ cls +
1428
+ "\\"><span class=\\"wg-todo-mark\\">" +
1429
+ mark +
1430
+ "</span><span>" +
1431
+ esc(t.text || "") +
1432
+ "</span></li>"
1433
+ );
1434
+ })
1435
+ .join("");
1436
+ }, rows)
1437
+ .catch(() => {});
1320
1438
  }
1439
+
1440
+ // Appear settle
1441
+ await new Promise((res) => setTimeout(res, fadeMs));
1442
+ const authored = resolveFixtureDwellMs(h, { gatesFast, pace });
1443
+ const legacyMs = 1100;
1444
+ const holdMs =
1445
+ authored != null ? authored : defaultHoldMs != null ? defaultHoldMs : legacyMs;
1446
+ await new Promise((res) => setTimeout(res, holdMs));
1447
+ // Disappear before next (respect fade)
1448
+ await stopLiveFollow();
1449
+ await hideRing(page);
1450
+ await resetCameraZoom(page);
1451
+ await new Promise((res) => setTimeout(res, fadeMs));
1321
1452
  } catch {
1322
- // best-effort - a selector that doesn't resolve just gets skipped
1453
+ /* best-effort */
1323
1454
  }
1324
1455
  }
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 {
1456
+ await stopLiveFollow();
1457
+ await resetCameraZoom(page);
1458
+ if (list.length === 0) {
1348
1459
  await hideRing(page);
1349
1460
  }
1350
1461
  }
1351
1462
 
1463
+
1352
1464
  async function renderAfterStep(page, info) {
1353
1465
  await installOverlay(page, info.title);
1354
1466
  // Clear any tracker from a previous highlight before cycling through this
@@ -1698,14 +1810,16 @@ async function markStepRunning(page, _opts) {
1698
1810
  async function showRing(page, box, label, tone, style) {
1699
1811
  const size = normalizeHighlightSize(style && style.size);
1700
1812
  const weight = normalizeHighlightWeight(style && style.weight);
1813
+ const focus = !!(style && style.focus);
1814
+ const color = (style && (style.color || style.labelColor || style.textColor)) || "";
1701
1815
  await page
1702
1816
  .evaluate(
1703
- ({ box, label, tone, size, weight }) => {
1817
+ ({ box, label, tone, size, weight, focus, color }) => {
1704
1818
  if (window.__wgPositionRing) {
1705
- window.__wgPositionRing(box, label, tone || "planned", { size, weight });
1819
+ window.__wgPositionRing(box, label, tone || "planned", { size, weight, focus, color });
1706
1820
  }
1707
1821
  },
1708
- { box, label, tone: tone || "planned", size, weight },
1822
+ { box, label, tone: tone || "planned", size, weight, focus, color },
1709
1823
  )
1710
1824
  .catch(() => {});
1711
1825
  }
@@ -1795,7 +1909,10 @@ async function presentSlides(page, slides, _gate, opts) {
1795
1909
  if (s.selector) {
1796
1910
  try {
1797
1911
  await ensureSelectorInView(page, s.selector);
1798
- await applyHighlightZoom(page, s.selector, s.zoom);
1912
+ {
1913
+ const zb = await page.locator(s.selector).first().boundingBox().catch(() => null);
1914
+ await applyCameraZoom(page, zb, s.zoom);
1915
+ }
1799
1916
  const box = await page.locator(s.selector).first().boundingBox();
1800
1917
  if (box) await showRing(page, box, caption, slideTone, slideStyle);
1801
1918
  } catch {
@@ -2623,6 +2740,39 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2623
2740
  }
2624
2741
  }
2625
2742
  const recordingVideo = !!process.env.WAYGRAPH_VIDEO;
2743
+ // Sequential stubBefore queue (appear -> dwell -> fade) before act.
2744
+ if (stubBeforeRef.current && stubBeforeRef.current.length) {
2745
+ await setDemoBannerTitle(page, stubBeforePhase.title);
2746
+ const beforeRings = stubBeforeRef.current.map((h) => {
2747
+ const styled = applyHighlightStyleDefaults(h, flowStyle);
2748
+ return {
2749
+ selector: styled.selector,
2750
+ label: formatHighlightCaption(styled),
2751
+ duration: styled.duration,
2752
+ fastMode: styled.fastMode,
2753
+ tone: styled.tone,
2754
+ size: styled.size,
2755
+ weight: styled.weight,
2756
+ zoom: styled.zoom,
2757
+ focus: styled.focus,
2758
+ color: styled.color,
2759
+ cursor: styled.cursor,
2760
+ };
2761
+ });
2762
+ await cycleHighlightRings(page, beforeRings, !!pacing.gatesFast, {
2763
+ pace: pacing.demoPace,
2764
+ todos: stubBeforeTodos,
2765
+ stepLabel: miniStepLabel({
2766
+ index: i,
2767
+ total: resolved.length,
2768
+ blockName: r.block.name,
2769
+ episodeNumber: r.episodeNumber,
2770
+ paceBadge: paceSpeak.paceBadge,
2771
+ }),
2772
+ });
2773
+ } else if (stubBeforePhase && stubBeforePhase.title) {
2774
+ await setDemoBannerTitle(page, stubBeforePhase.title);
2775
+ }
2626
2776
  await markStepRunning(page, {});
2627
2777
  // NavBlock click-nav: label the upcoming Locator.click demo cursor as
2628
2778
  // "nav: <block>" so pia/demo watchers see cursor+pulse on click nav
@@ -2672,6 +2822,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2672
2822
  fixtures: fixturesOnError,
2673
2823
  error: err,
2674
2824
  });
2825
+ await setDemoBannerTitle(page, errPhase.title);
2675
2826
  const errHighlights = errPhase.highlights.map((h) => {
2676
2827
  const styled = applyHighlightStyleDefaults(h, r.highlightStyle);
2677
2828
  return {
@@ -2683,6 +2834,9 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2683
2834
  size: styled.size,
2684
2835
  weight: styled.weight,
2685
2836
  zoom: styled.zoom,
2837
+ focus: styled.focus,
2838
+ color: styled.color,
2839
+ cursor: styled.cursor,
2686
2840
  };
2687
2841
  });
2688
2842
  await cycleHighlightRings(page, errHighlights, !!pacing.gatesFast, {
@@ -2761,6 +2915,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2761
2915
  fixtures: fixturesAfter,
2762
2916
  });
2763
2917
  stubAfterTodos = afterPhase.todos || [];
2918
+ await setDemoBannerTitle(page, afterPhase.title);
2764
2919
  highlights = afterPhase.highlights.map((h) => {
2765
2920
  const styled = applyHighlightStyleDefaults(h, r.highlightStyle);
2766
2921
  return {
@@ -2772,6 +2927,9 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2772
2927
  size: styled.size,
2773
2928
  weight: styled.weight,
2774
2929
  zoom: styled.zoom,
2930
+ focus: styled.focus,
2931
+ color: styled.color,
2932
+ cursor: styled.cursor,
2775
2933
  };
2776
2934
  });
2777
2935
  } 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.28",
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
  },