waygraph 0.13.4 → 0.13.5

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.
Files changed (21) hide show
  1. package/dist/cli.js +1707 -139
  2. package/dist/highlights.d.ts +35 -3
  3. package/dist/highlights.js +55 -17
  4. package/dist/index.d.ts +1 -1
  5. package/examples/saucedemo/.tmp/device-showcase/page@01821f1be3025a1f33af7aeaa7c31453.webm +0 -0
  6. package/examples/saucedemo/.tmp/device-showcase/page@112894bf514e729c25426effd0465db0.webm +0 -0
  7. package/examples/saucedemo/.tmp/device-showcase/page@254d2f8bb34167fb9f128efc2aa9831f.webm +0 -0
  8. package/examples/saucedemo/.tmp/device-showcase/page@2fee43e36341108e0ee6e6a1360b14f9.webm +0 -0
  9. package/examples/saucedemo/.tmp/device-showcase/page@648144d90fdf2c65bd7b03faeadc2aeb.webm +0 -0
  10. package/examples/saucedemo/.tmp/device-showcase/page@a360145174b3bccffd6516db7d084a9c.webm +0 -0
  11. package/examples/saucedemo/.tmp/device-showcase/page@d045914b97b35d17147a940965a7a93f.webm +0 -0
  12. package/examples/saucedemo/.tmp/ts/page@6145fa8fc23d19870af85e0e111c0e30.webm +0 -0
  13. package/examples/saucedemo/.tmp/ts-sample/page@e2995031b7eef8fc0e3c1bdb60f58254.webm +0 -0
  14. package/examples/saucedemo/.tmp/ts2/page@1a450773bef74c91a68a89093fc41838.webm +0 -0
  15. package/examples/saucedemo/package.json +1 -1
  16. package/examples/saucedemo/src/blocks/saucedemo-web/cart/nav-cart.block.ts +16 -13
  17. package/examples/saucedemo/src/blocks/saucedemo-web/inventory/methods/add-to-cart.effect.block.ts +24 -16
  18. package/examples/saucedemo/src/blocks/saucedemo-web/methods/submit-login.method.block.ts +36 -26
  19. package/package.json +1 -1
  20. package/examples/saucedemo/.tmp/device-showcase/device-showcase.webm +0 -0
  21. package/examples/saucedemo/.tmp/device-showcase/page@10a295d8efbf9eae139eb931ef00363c.webm +0 -0
package/dist/cli.js CHANGED
@@ -479,6 +479,26 @@ const RING_CSS =
479
479
  "#wg-click-pulse[data-tone=success]{border-color:#22C55E;background:rgba(34,197,94,.28);}" +
480
480
  "#wg-click-pulse.wg-pulse{animation:wg-pulse .5s ease-out;}" +
481
481
  "@keyframes wg-pulse{0%{opacity:.9;transform:scale(.4);}100%{opacity:0;transform:scale(2.4);}}" +
482
+ // Spotlight: dim everything except the highlight target (focus: true).
483
+ "#wg-focus-veil{position:fixed;z-index:2147483644;pointer-events:none;" +
484
+ "border-radius:12px;box-shadow:0 0 0 9999px rgba(8,4,20,.62);" +
485
+ "opacity:0;transition:opacity .3s ease,left .3s ease,top .3s ease,width .3s ease,height .3s ease;}" +
486
+ "#wg-focus-veil.wg-in{opacity:1;}" +
487
+ // Touch swipe trail (mobile/tablet orientation + device theater).
488
+ "#wg-swipe-layer{position:fixed;inset:0;z-index:2147483646;pointer-events:none;" +
489
+ "overflow:hidden;}" +
490
+ "#wg-swipe-layer .wg-swipe-dot{position:absolute;width:14px;height:14px;margin:-7px 0 0 -7px;" +
491
+ "border-radius:50%;background:rgba(147,197,253,.55);border:2px solid rgba(255,255,255,.85);" +
492
+ "box-shadow:0 0 12px rgba(59,130,246,.55);opacity:0;}" +
493
+ "#wg-swipe-layer .wg-swipe-finger{position:absolute;width:28px;height:28px;margin:-14px 0 0 -14px;" +
494
+ "border-radius:50%;background:rgba(15,23,42,.92);border:2px solid #fff;" +
495
+ "box-shadow:0 4px 16px rgba(0,0,0,.4);opacity:0;" +
496
+ "transition:left .05s linear,top .05s linear,opacity .15s ease;}" +
497
+ "#wg-swipe-layer .wg-swipe-label{position:absolute;left:50%;top:18%;transform:translateX(-50%);" +
498
+ "padding:6px 12px;border-radius:999px;background:rgba(15,23,42,.88);color:#e0f2fe;" +
499
+ "border:1px solid rgba(56,189,248,.55);font:700 12px/1.2 system-ui,sans-serif;" +
500
+ "letter-spacing:.04em;text-transform:uppercase;opacity:0;transition:opacity .25s ease;}" +
501
+ "#wg-swipe-layer.wg-in .wg-swipe-label{opacity:1;}" +
482
502
  "#wg-panel{position:fixed;z-index:2147483647;left:50%;bottom:12px;transform:translateX(-50%);" +
483
503
  "max-width:min(92vw,640px);max-height:calc(100vh - 24px);overflow-y:auto;box-sizing:border-box;" +
484
504
  "background:rgba(20,10,40,.94);color:#fff;border-radius:14px;" +
@@ -520,18 +540,57 @@ const RING_CSS =
520
540
  "#wg-panel .wg-pace[data-pace-kind=blitz] .wg-pace-badge{background:#22C55E;color:#052e16;}" +
521
541
  "#wg-panel .wg-pace[data-pace-kind=ms] .wg-pace-badge{background:#3B82F6;color:#fff;}" +
522
542
  "#wg-panel .wg-narration{margin:0 0 12px;font:italic 14px/1.4 system-ui,sans-serif;color:#f0e8ff;}" +
523
- // Todo checklist lives in #wg-todo-dock (fixed, outside #wg-panel) so
524
- // --mini / Hide collapse never hides it (.wg-body {display:none} would).
525
- // Click dock to slide left <-> right (WAYGRAPH_TODO_POS / --todo-left|right).
526
- "#wg-todo-dock{position:fixed;z-index:2147483646;top:72px;left:14px;" +
543
+ // Todo checklist(s) float outside #wg-panel so --mini / Hide never hide them.
544
+ // Multiple docks (one per todoId) are supported - stacked on the same side.
545
+ // Click a dock to slide left <-> right (WAYGRAPH_TODO_POS / --todo-left|right).
546
+ ".wg-todo-dock,#wg-todo-dock{position:fixed;z-index:2147483646;top:72px;left:14px;" +
527
547
  "width:min(280px,42vw);max-height:calc(100vh - 100px);overflow:auto;box-sizing:border-box;" +
528
548
  "padding:10px 12px;background:rgba(20,10,40,.94);color:#fff;border-radius:12px;" +
529
549
  "border:1px solid rgba(124,58,237,.45);box-shadow:0 8px 24px rgba(0,0,0,.35);" +
530
550
  "pointer-events:auto;cursor:pointer;" +
531
- "transition:transform .4s cubic-bezier(.22,1,.36,1);transform:translateX(0);}" +
532
- "#wg-todo-dock[data-pos=right]{transform:translateX(calc(100vw - 100% - 28px));}" +
533
- "#wg-todo-dock[data-pos=left]{transform:translateX(0);}" +
534
- "#wg-todo-dock #wg-todos{list-style:none;margin:0;padding:0;background:transparent;border:none;}" +
551
+ "transition:transform .4s cubic-bezier(.22,1,.36,1),top .35s ease;transform:translateX(0);}" +
552
+ ".wg-todo-dock[data-pos=right],#wg-todo-dock[data-pos=right]{transform:translateX(calc(100vw - 100% - 28px));}" +
553
+ ".wg-todo-dock[data-pos=left],#wg-todo-dock[data-pos=left]{transform:translateX(0);}" +
554
+ ".wg-todo-dock .wg-todos-list,#wg-todo-dock #wg-todos,.wg-todo-dock #wg-todos{list-style:none;margin:0;padding:0;background:transparent;border:none;}" +
555
+ // Video device stage: keep recordVideo size fixed; center a device-sized shell.
556
+ "html.wg-video-device-stage{background:#0b1220 !important;}" +
557
+ "html.wg-video-device-stage body{margin:0 !important;min-height:100vh !important;" +
558
+ "display:flex !important;align-items:center !important;justify-content:center !important;" +
559
+ "background:#0b1220 !important;overflow:hidden !important;}" +
560
+ "#wg-device-shell{flex-shrink:0;overflow:auto;background:#fff;" +
561
+ "border-radius:16px;box-shadow:0 24px 80px rgba(0,0,0,.55),0 0 0 1px rgba(255,255,255,.08);" +
562
+ "transform-origin:center center;" +
563
+ /* Force radius clip on all corners (Chrome + transform/scale). */
564
+ "-webkit-mask-image:-webkit-radial-gradient(white,black);" +
565
+ "isolation:isolate;" +
566
+ "transition:border-radius .55s cubic-bezier(.22,1,.36,1),box-shadow .55s ease," +
567
+ "transform .55s cubic-bezier(.22,1,.36,1),width .5s ease,height .5s ease,max-width .5s ease,max-height .5s ease;}" +
568
+ "#wg-device-shell.wg-shell-enter{border-radius:0;box-shadow:none;}" +
569
+ "#wg-device-shell.wg-shell-shutter-out{border-radius:0 !important;" +
570
+ "box-shadow:none !important;outline:none !important;border:none !important;}" +
571
+ "#wg-device-shell.wg-shell-desktop-flat{border-radius:0 !important;box-shadow:none !important;" +
572
+ "outline:none !important;border:none !important;-webkit-mask-image:none;}" +
573
+ // Always-on zoom HUD - bottom-right (top-left is crowded: todos + banners).
574
+ "#wg-zoom-badge{position:fixed;z-index:2147483646;bottom:14px;right:14px;" +
575
+ "display:flex;align-items:center;gap:7px;padding:6px 11px 6px 8px;" +
576
+ "border-radius:999px;background:rgba(20,10,40,.94);color:#f0e8ff;" +
577
+ "border:1px solid rgba(250,204,21,.6);box-shadow:0 6px 18px rgba(0,0,0,.4);" +
578
+ "font:700 12px/1.2 system-ui,sans-serif;pointer-events:none;" +
579
+ "opacity:1;transform:translateY(0);}" +
580
+ "#wg-zoom-badge .wg-zoom-ico{width:18px;height:18px;display:flex;align-items:center;justify-content:center;" +
581
+ "border-radius:6px;background:rgba(250,204,21,.22);}" +
582
+ "#wg-zoom-badge .wg-zoom-ico svg{width:14px;height:14px;display:block;}" +
583
+ "#wg-zoom-badge .wg-zoom-val{color:#fde68a;font-variant-numeric:tabular-nums;font-weight:800;min-width:3.2em;}" +
584
+ "#wg-zoom-badge[data-zoomed=\\"1\\"]{border-color:#fbbf24;}" +
585
+ // Typing chip (method fills: username / password / etc.)
586
+ "#wg-typing-badge{position:fixed;z-index:2147483646;bottom:88px;left:50%;transform:translateX(-50%);" +
587
+ "display:flex;align-items:center;gap:8px;padding:8px 14px;" +
588
+ "border-radius:999px;background:rgba(20,10,40,.95);color:#e0f2fe;" +
589
+ "border:1px solid rgba(56,189,248,.55);box-shadow:0 8px 22px rgba(0,0,0,.4);" +
590
+ "font:700 12px/1.2 system-ui,sans-serif;pointer-events:none;" +
591
+ "opacity:0;transition:opacity .2s ease;}" +
592
+ "#wg-typing-badge.wg-in{opacity:1;}" +
593
+ "#wg-typing-badge .wg-ty-dots{letter-spacing:.15em;color:#7dd3fc;}" +
535
594
  // Device toast + chip (0.13.2+) - icon toast on fixture change, then compact chip.
536
595
  "#wg-device-toast{position:fixed;z-index:2147483647;top:14px;right:14px;" +
537
596
  "display:flex;align-items:center;gap:10px;min-width:200px;max-width:min(92vw,320px);" +
@@ -570,17 +629,20 @@ const RING_CSS =
570
629
  "#wg-device-badge .wg-dev-icon svg{width:14px;height:14px;display:block;}" +
571
630
  "#wg-device-badge .wg-dev-label{white-space:nowrap;}" +
572
631
  "#wg-cursor[data-touch=1]{width:28px;height:28px;}" +
573
- "#wg-todos{list-style:none;margin:0 0 12px;padding:8px 10px;background:#0f0620;border-radius:8px;" +
632
+ "#wg-todos,.wg-todos-list{list-style:none;margin:0 0 12px;padding:8px 10px;background:#0f0620;border-radius:8px;" +
574
633
  "border:1px solid #3a2a60;}" +
575
- "#wg-todos li{display:flex;gap:8px;align-items:flex-start;margin:0 0 6px;font:600 12.5px/1.35 system-ui,sans-serif;}" +
576
- "#wg-todos li:last-child{margin-bottom:0;}" +
577
- "#wg-todos .wg-todo-mark{flex:0 0 auto;width:1.1em;text-align:center;}" +
578
- "#wg-todos .wg-todo-done{color:#6ee7b7;text-decoration:line-through;opacity:.85;}" +
579
- "#wg-todos .wg-todo-current{color:#fff;}" +
580
- "#wg-todos .wg-todo-pending{color:#9a7ad1;}" +
634
+ "#wg-todos li,.wg-todos-list li{display:flex;gap:8px;align-items:flex-start;margin:0 0 6px;font:600 12.5px/1.35 system-ui,sans-serif;}" +
635
+ "#wg-todos li:last-child,.wg-todos-list li:last-child{margin-bottom:0;}" +
636
+ "#wg-todos .wg-todo-mark,.wg-todos-list .wg-todo-mark{flex:0 0 auto;width:1.1em;text-align:center;}" +
637
+ "#wg-todos .wg-todo-done,.wg-todos-list .wg-todo-done{color:#6ee7b7;text-decoration:line-through;opacity:.85;}" +
638
+ "#wg-todos .wg-todo-current,.wg-todos-list .wg-todo-current{color:#fff;}" +
639
+ "#wg-todos .wg-todo-pending,.wg-todos-list .wg-todo-pending{color:#9a7ad1;}" +
581
640
  "#wg-todos[data-wg-todo-style=bullets] .wg-todo-pending," +
582
- "#wg-todos[data-wg-todo-style=bullets] .wg-todo-current{color:#e8dcff;}" +
583
- "#wg-todos[data-wg-todo-style=bullets] .wg-todo-mark{color:#c9a6ff;}" +
641
+ "#wg-todos[data-wg-todo-style=bullets] .wg-todo-current," +
642
+ ".wg-todos-list[data-wg-todo-style=bullets] .wg-todo-pending," +
643
+ ".wg-todos-list[data-wg-todo-style=bullets] .wg-todo-current{color:#e8dcff;}" +
644
+ "#wg-todos[data-wg-todo-style=bullets] .wg-todo-mark," +
645
+ ".wg-todos-list[data-wg-todo-style=bullets] .wg-todo-mark{color:#c9a6ff;}" +
584
646
  "#wg-progress{height:4px;background:#2a1650;border-radius:2px;margin:0 0 12px;overflow:hidden;}" +
585
647
  "#wg-progress-bar{height:100%;background:#7C3AED;border-radius:2px;transition:width .3s ease;}" +
586
648
  // A real tab bar for episodes - the currently-active episode reads as
@@ -776,7 +838,7 @@ async function installOverlay(page, title) {
776
838
  window.__wgPositionRing = (box, label, tone, style) => {
777
839
  const ring = document.getElementById("wg-ring");
778
840
  const ringLabel = document.getElementById("wg-ring-label");
779
- if (!ring || !ringLabel) return;
841
+ if (!ring || !ringLabel || !box) return;
780
842
  const raw = (tone || "planned") + "";
781
843
  const t =
782
844
  raw === "auto" || raw === "info" || raw === "warning" || raw === "danger" || raw === "success"
@@ -797,34 +859,59 @@ async function installOverlay(page, title) {
797
859
  const margin = 6;
798
860
  const vw = window.innerWidth;
799
861
  const vh = window.innerHeight;
800
- let left = box.x - pad;
801
- let top = box.y - pad;
802
- const width = box.width + pad * 2;
803
- const height = box.height + pad * 2;
804
- if (left < margin) left = margin;
805
- if (top < margin) top = margin;
806
- if (left + width > vw - margin) left = Math.max(margin, vw - margin - width);
807
- if (top + height > vh - margin) top = Math.max(margin, vh - margin - height);
862
+ // Pin ring EXACTLY to the element - never clamp the ring away from
863
+ // the target (that looked "strapped on" the viewport).
864
+ const left = box.x - pad;
865
+ const top = box.y - pad;
866
+ const width = Math.max(4, box.width + pad * 2);
867
+ const height = Math.max(4, box.height + pad * 2);
808
868
  ring.style.left = left + "px";
809
869
  ring.style.top = top + "px";
810
870
  ring.style.width = width + "px";
811
871
  ring.style.height = height + "px";
812
872
  ring.style.opacity = "1";
813
- ringLabel.textContent = label;
873
+ ringLabel.textContent = label || "";
814
874
  ringLabel.style.opacity = "1";
815
- // Measure the label's own natural width/height (its real
816
- // rendered size, unchanged) before deciding where it fits.
817
875
  const lw = ringLabel.offsetWidth;
818
876
  const lh = ringLabel.offsetHeight;
819
877
  let labelLeft = left;
820
878
  let labelTop = top + height + 8;
821
- if (labelTop + lh > vh - margin) labelTop = top - lh - 8; // flip above if it'd overflow the bottom
879
+ if (labelTop + lh > vh - margin) labelTop = top - lh - 8;
822
880
  if (labelTop < margin) labelTop = margin;
823
881
  if (labelLeft + lw > vw - margin) labelLeft = Math.max(margin, vw - margin - lw);
824
882
  if (labelLeft < margin) labelLeft = margin;
825
883
  ringLabel.style.left = labelLeft + "px";
826
884
  ringLabel.style.top = labelTop + "px";
827
885
  };
886
+ /** Live-follow a selector with rAF (accurate under device shell scale). */
887
+ window.__wgStopRingFollow = () => {
888
+ if (window.__wgRingFollowRaf) {
889
+ cancelAnimationFrame(window.__wgRingFollowRaf);
890
+ window.__wgRingFollowRaf = 0;
891
+ }
892
+ window.__wgRingFollowSel = "";
893
+ };
894
+ window.__wgFollowRing = (sel, label, tone, style, focus) => {
895
+ window.__wgStopRingFollow();
896
+ if (!sel) return;
897
+ window.__wgRingFollowSel = sel;
898
+ const tick = () => {
899
+ if (window.__wgRingFollowSel !== sel) return;
900
+ const el = document.querySelector(sel);
901
+ if (!el) {
902
+ window.__wgRingFollowRaf = requestAnimationFrame(tick);
903
+ return;
904
+ }
905
+ const r = el.getBoundingClientRect();
906
+ const box = { x: r.x, y: r.y, width: r.width, height: r.height };
907
+ if (window.__wgPositionRing) {
908
+ window.__wgPositionRing(box, label, tone || "planned", style || {});
909
+ }
910
+ if (focus && window.__wgApplyFocus) window.__wgApplyFocus(box);
911
+ window.__wgRingFollowRaf = requestAnimationFrame(tick);
912
+ };
913
+ tick();
914
+ };
828
915
  window.__wgClickPulse = (x, y, tone) => {
829
916
  const pulse = document.getElementById("wg-click-pulse");
830
917
  if (!pulse) return;
@@ -839,19 +926,155 @@ async function installOverlay(page, title) {
839
926
  void pulse.offsetWidth;
840
927
  pulse.classList.add("wg-pulse");
841
928
  };
842
- window.__wgHideRing = () => {
929
+ window.__wgHideRing = (opts) => {
843
930
  // While a narrate() call owns the ring (mid multi-step action),
844
931
  // the auto-highlight click/fill patches' own end-of-step hide is
845
932
  // a no-op - narrate() itself clears it once the WHOLE wrapped
846
933
  // action finishes, not just its first sub-step.
847
934
  if (window.__wgNarrateOwnsRing) return;
935
+ if (window.__wgStopRingFollow) window.__wgStopRingFollow();
848
936
  const ring = document.getElementById("wg-ring");
849
937
  const ringLabel = document.getElementById("wg-ring-label");
850
938
  if (ring) ring.style.opacity = "0";
851
939
  if (ringLabel) ringLabel.style.opacity = "0";
852
- if (window.__wgClearZoom) window.__wgClearZoom();
940
+ if (window.__wgClearFocus) window.__wgClearFocus();
941
+ // Honor zoomOut:false - keep camera until next zoom / desktop clear.
942
+ const forceClear = opts && opts.clearZoom === true;
943
+ const skipClear = opts && opts.clearZoom === false;
944
+ if (
945
+ !skipClear &&
946
+ (forceClear || window.__wgZoomOutOnHide !== false) &&
947
+ window.__wgClearZoom
948
+ ) {
949
+ window.__wgClearZoom();
950
+ }
951
+ };
952
+ window.__wgClearFocus = () => {
953
+ const veil = document.getElementById("wg-focus-veil");
954
+ if (!veil) return;
955
+ veil.classList.remove("wg-in");
956
+ setTimeout(() => {
957
+ const v = document.getElementById("wg-focus-veil");
958
+ if (v && !v.classList.contains("wg-in")) v.remove();
959
+ }, 320);
960
+ };
961
+ window.__wgApplyFocus = (box) => {
962
+ if (!box) {
963
+ if (window.__wgClearFocus) window.__wgClearFocus();
964
+ return;
965
+ }
966
+ let veil = document.getElementById("wg-focus-veil");
967
+ if (!veil) {
968
+ veil = document.createElement("div");
969
+ veil.id = "wg-focus-veil";
970
+ veil.setAttribute("data-wg-ui", "1");
971
+ document.documentElement.appendChild(veil);
972
+ }
973
+ const pad = 10;
974
+ veil.style.left = Math.max(0, box.x - pad) + "px";
975
+ veil.style.top = Math.max(0, box.y - pad) + "px";
976
+ veil.style.width = Math.max(8, box.width + pad * 2) + "px";
977
+ veil.style.height = Math.max(8, box.height + pad * 2) + "px";
978
+ void veil.offsetWidth;
979
+ veil.classList.add("wg-in");
980
+ };
981
+ /**
982
+ * Finger swipe trail for touch theater (orientation rotate / device change).
983
+ * opts: { dir: 'left'|'right'|'up'|'down', label?, ms? }
984
+ * Returns duration waited (ms) via Promise when called from page.evaluate async.
985
+ */
986
+ window.__wgSwipeTrail = (opts) => {
987
+ const o = opts || {};
988
+ const dir = o.dir === "right" || o.dir === "up" || o.dir === "down" ? o.dir : "left";
989
+ const ms = Math.max(280, Math.min(1400, Number(o.ms) || 720));
990
+ const label =
991
+ o.label ||
992
+ (dir === "left" || dir === "right" ? "swipe" : "swipe") +
993
+ (o.hint ? " \\u00b7 " + o.hint : "");
994
+ let layer = document.getElementById("wg-swipe-layer");
995
+ if (layer && layer._wgTimer) {
996
+ clearTimeout(layer._wgTimer);
997
+ layer._wgTimer = null;
998
+ }
999
+ if (layer) layer.remove();
1000
+ layer = document.createElement("div");
1001
+ layer.id = "wg-swipe-layer";
1002
+ layer.setAttribute("data-wg-ui", "1");
1003
+ const finger = document.createElement("div");
1004
+ finger.className = "wg-swipe-finger";
1005
+ const lab = document.createElement("div");
1006
+ lab.className = "wg-swipe-label";
1007
+ lab.textContent = label;
1008
+ layer.appendChild(lab);
1009
+ layer.appendChild(finger);
1010
+ document.documentElement.appendChild(layer);
1011
+ const vw = window.innerWidth || 390;
1012
+ const vh = window.innerHeight || 844;
1013
+ const pad = Math.min(vw, vh) * 0.18;
1014
+ let x0;
1015
+ let y0;
1016
+ let x1;
1017
+ let y1;
1018
+ if (dir === "left") {
1019
+ x0 = vw - pad;
1020
+ x1 = pad;
1021
+ y0 = y1 = vh * 0.52;
1022
+ } else if (dir === "right") {
1023
+ x0 = pad;
1024
+ x1 = vw - pad;
1025
+ y0 = y1 = vh * 0.52;
1026
+ } else if (dir === "up") {
1027
+ x0 = x1 = vw * 0.5;
1028
+ y0 = vh - pad;
1029
+ y1 = pad;
1030
+ } else {
1031
+ x0 = x1 = vw * 0.5;
1032
+ y0 = pad;
1033
+ y1 = vh - pad;
1034
+ }
1035
+ finger.style.left = x0 + "px";
1036
+ finger.style.top = y0 + "px";
1037
+ finger.style.opacity = "1";
1038
+ void layer.offsetWidth;
1039
+ layer.classList.add("wg-in");
1040
+ const steps = 12;
1041
+ const stepMs = Math.floor(ms / steps);
1042
+ let i = 0;
1043
+ const tick = () => {
1044
+ i += 1;
1045
+ const t = Math.min(1, i / steps);
1046
+ const e = t * t * (3 - 2 * t);
1047
+ const x = x0 + (x1 - x0) * e;
1048
+ const y = y0 + (y1 - y0) * e;
1049
+ finger.style.left = x + "px";
1050
+ finger.style.top = y + "px";
1051
+ const dot = document.createElement("div");
1052
+ dot.className = "wg-swipe-dot";
1053
+ dot.style.left = x + "px";
1054
+ dot.style.top = y + "px";
1055
+ dot.style.opacity = String(0.85 - t * 0.55);
1056
+ layer.appendChild(dot);
1057
+ requestAnimationFrame(() => {
1058
+ dot.style.transition = "opacity .45s ease, transform .45s ease";
1059
+ dot.style.opacity = "0";
1060
+ dot.style.transform = "scale(1.8)";
1061
+ });
1062
+ if (i < steps) {
1063
+ layer._wgTimer = setTimeout(tick, stepMs);
1064
+ } else {
1065
+ finger.style.opacity = "0";
1066
+ layer.classList.remove("wg-in");
1067
+ layer._wgTimer = setTimeout(() => {
1068
+ const el = document.getElementById("wg-swipe-layer");
1069
+ if (el) el.remove();
1070
+ }, 380);
1071
+ }
1072
+ };
1073
+ layer._wgTimer = setTimeout(tick, 40);
1074
+ return ms + 120;
853
1075
  };
854
1076
  window.__wgClearZoom = () => {
1077
+ // Strip leftover element transforms from older zoom path.
855
1078
  document.querySelectorAll("[data-wg-zoomed=\\"1\\"]").forEach((el) => {
856
1079
  el.style.removeProperty("transform");
857
1080
  el.style.removeProperty("transform-origin");
@@ -860,21 +1083,116 @@ async function installOverlay(page, title) {
860
1083
  el.style.removeProperty("position");
861
1084
  delete el.dataset.wgZoomed;
862
1085
  });
1086
+ // Undo any prior shell-camera experiment - restore device fit scale only.
1087
+ const shell = document.getElementById("wg-device-shell");
1088
+ if (shell && shell.dataset.wgCamBase != null) {
1089
+ shell.style.transform = shell.dataset.wgCamBase;
1090
+ if (shell.dataset.wgCamOrigin) {
1091
+ shell.style.transformOrigin = shell.dataset.wgCamOrigin;
1092
+ } else {
1093
+ shell.style.transformOrigin = "center center";
1094
+ }
1095
+ delete shell.dataset.wgCamBase;
1096
+ delete shell.dataset.wgCamOrigin;
1097
+ delete shell.dataset.wgCamZoom;
1098
+ }
1099
+ window.__wgZoomLevel = 1;
1100
+ window.__wgZoomSel = "";
1101
+ if (window.__wgSetZoomBadge) window.__wgSetZoomBadge(1, "");
863
1102
  };
1103
+ const ZOOM_ICO =
1104
+ '<svg viewBox="0 0 24 24" fill="none" stroke="#fde68a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
1105
+ '<circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>';
1106
+ window.__wgSetZoomBadge = (scale, sel) => {
1107
+ const n = Number(scale);
1108
+ const level = Number.isFinite(n) && n > 0 ? n : 1;
1109
+ let badge = document.getElementById("wg-zoom-badge");
1110
+ if (!badge) {
1111
+ badge = document.createElement("div");
1112
+ badge.id = "wg-zoom-badge";
1113
+ badge.setAttribute("data-wg-ui", "1");
1114
+ document.documentElement.appendChild(badge);
1115
+ }
1116
+ const zoomed = level > 1.001 || level < 0.999;
1117
+ badge.dataset.zoomed = zoomed ? "1" : "0";
1118
+ const txt = level.toFixed(2) + "\\u00d7";
1119
+ badge.innerHTML =
1120
+ '<span class="wg-zoom-ico">' +
1121
+ ZOOM_ICO +
1122
+ '</span><span class="wg-zoom-val">' +
1123
+ txt +
1124
+ "</span>";
1125
+ badge.classList.add("wg-in");
1126
+ window.__wgZoomLevel = level;
1127
+ window.__wgZoomSel = sel || "";
1128
+ };
1129
+ window.__wgEnsureZoomBadge = () => {
1130
+ if (window.__wgSetZoomBadge) {
1131
+ window.__wgSetZoomBadge(window.__wgZoomLevel || 1, window.__wgZoomSel || "");
1132
+ }
1133
+ };
1134
+ window.__wgSetTypingBadge = (on, label) => {
1135
+ let badge = document.getElementById("wg-typing-badge");
1136
+ if (!on) {
1137
+ if (badge) {
1138
+ badge.classList.remove("wg-in");
1139
+ setTimeout(() => {
1140
+ const b = document.getElementById("wg-typing-badge");
1141
+ if (b && !b.classList.contains("wg-in")) b.remove();
1142
+ }, 220);
1143
+ }
1144
+ return;
1145
+ }
1146
+ if (!badge) {
1147
+ badge = document.createElement("div");
1148
+ badge.id = "wg-typing-badge";
1149
+ badge.setAttribute("data-wg-ui", "1");
1150
+ document.documentElement.appendChild(badge);
1151
+ }
1152
+ const text = label ? String(label).slice(0, 40) : "typing";
1153
+ badge.innerHTML =
1154
+ '<span class="wg-ty-dots">\\u25cf\\u25cf\\u25cf</span><span>typing \\u00b7 ' +
1155
+ text.replace(/</g, "&lt;") +
1156
+ "</span>";
1157
+ void badge.offsetWidth;
1158
+ badge.classList.add("wg-in");
1159
+ };
1160
+ /**
1161
+ * Zoom HUD only - never CSS-scale the target or the device shell.
1162
+ * Shell camera zoom fought device-theater fit-scale (broke ring follow
1163
+ * + uneven border-radius). Element scale mangled cart rows. Emphasis
1164
+ * is ring + focus veil; badge still shows authored zoom level.
1165
+ */
864
1166
  window.__wgApplyZoom = (sel, scale) => {
865
- if (window.__wgClearZoom) window.__wgClearZoom();
1167
+ document.querySelectorAll("[data-wg-zoomed=\\"1\\"]").forEach((el) => {
1168
+ el.style.removeProperty("transform");
1169
+ el.style.removeProperty("transform-origin");
1170
+ el.style.removeProperty("transition");
1171
+ el.style.removeProperty("z-index");
1172
+ el.style.removeProperty("position");
1173
+ delete el.dataset.wgZoomed;
1174
+ });
1175
+ const shell = document.getElementById("wg-device-shell");
1176
+ if (shell && shell.dataset.wgCamBase != null) {
1177
+ shell.style.transform = shell.dataset.wgCamBase;
1178
+ shell.style.transformOrigin = shell.dataset.wgCamOrigin || "center center";
1179
+ delete shell.dataset.wgCamBase;
1180
+ delete shell.dataset.wgCamOrigin;
1181
+ delete shell.dataset.wgCamZoom;
1182
+ }
866
1183
  const n = Number(scale);
867
- if (!Number.isFinite(n) || n <= 1.001) return;
868
- const el = document.querySelector(sel);
869
- if (!el) return;
870
- const st = getComputedStyle(el);
871
- if (st.position === "static") el.style.position = "relative";
872
- el.style.zIndex = "2147483645";
873
- el.style.transition = "transform .35s ease";
874
- el.style.transformOrigin = "center center";
875
- el.style.transform = "scale(" + n + ")";
876
- el.dataset.wgZoomed = "1";
1184
+ if (!Number.isFinite(n) || n <= 1.001) {
1185
+ window.__wgZoomLevel = 1;
1186
+ window.__wgZoomSel = "";
1187
+ if (window.__wgSetZoomBadge) window.__wgSetZoomBadge(1, "");
1188
+ return;
1189
+ }
1190
+ window.__wgZoomLevel = n;
1191
+ window.__wgZoomSel = sel || "";
1192
+ if (window.__wgSetZoomBadge) window.__wgSetZoomBadge(n, sel || "");
877
1193
  };
1194
+ // Mount zoom HUD immediately so demos always show 1.00x.
1195
+ if (window.__wgEnsureZoomBadge) window.__wgEnsureZoomBadge();
878
1196
  const POSITIONS = ["left", "center", "right"];
879
1197
  const applyPos = (el, pos) => {
880
1198
  el.dataset.pos = pos;
@@ -1160,25 +1478,85 @@ async function installOverlay(page, title) {
1160
1478
  sync = "clear";
1161
1479
  }
1162
1480
 
1163
- let dock = document.getElementById("wg-todo-dock");
1481
+ let dockKey =
1482
+ (dockState && dockState.id && String(dockState.id).trim()) ||
1483
+ (payload && !Array.isArray(payload) && payload.todoId && String(payload.todoId).trim()) ||
1484
+ "_default";
1485
+
1164
1486
  // Drop any in-panel leftover (old builds put #wg-todos in .wg-body).
1165
1487
  document.querySelectorAll("#wg-panel #wg-todos").forEach((el) => el.remove());
1166
1488
 
1167
- // keep + no new payload: do not remove an existing dock (PIA #10).
1489
+ const allDocks = () =>
1490
+ [...document.querySelectorAll(".wg-todo-dock, #wg-todo-dock")].filter(
1491
+ (el, i, arr) => arr.indexOf(el) === i,
1492
+ );
1493
+ const relayoutTodoDocks = () => {
1494
+ const docks = allDocks();
1495
+ let top = 72;
1496
+ for (const el of docks) {
1497
+ el.style.top = top + "px";
1498
+ top += Math.max(48, el.getBoundingClientRect().height) + 10;
1499
+ }
1500
+ };
1501
+ const findDock = (key) => {
1502
+ if (key === "_default") {
1503
+ return (
1504
+ document.querySelector('.wg-todo-dock[data-wg-todo-key="_default"]') ||
1505
+ document.getElementById("wg-todo-dock")
1506
+ );
1507
+ }
1508
+ const all = document.querySelectorAll(".wg-todo-dock");
1509
+ for (let i = 0; i < all.length; i++) {
1510
+ if (all[i].getAttribute("data-wg-todo-key") === String(key)) return all[i];
1511
+ }
1512
+ return null;
1513
+ };
1514
+
1515
+ // keep + no new payload: do not remove existing docks (PIA #10).
1168
1516
  if (sync === "keep") {
1169
1517
  if (!dockState && !list.length) return;
1170
1518
  // After navigation the DOM may be gone - recreate from carried dock.
1171
1519
  sync = "set";
1172
1520
  }
1173
- if (sync === "clear" || (sync === "set" && !dockState && !list.length)) {
1174
- if (dock) dock.remove();
1521
+ // Default replace=true: one checklist. parallel docks only when replace:false.
1522
+ const replace =
1523
+ !(payload && !Array.isArray(payload) && payload.replace === false) &&
1524
+ !(payload && !Array.isArray(payload) && payload.parallel === true);
1525
+ const hasDock =
1526
+ !!(dockState && Array.isArray(dockState.groups) && dockState.groups.length);
1527
+ // Explicit clear only - never treat "set + dock + empty list" as clear
1528
+ // (that thrash was marks -> blank in a few ms).
1529
+ if (sync === "clear" || (sync === "set" && !hasDock && !list.length)) {
1530
+ const clearId =
1531
+ (payload && !Array.isArray(payload) && payload.todoId && String(payload.todoId).trim()) ||
1532
+ (dockState && dockState.id && String(dockState.id).trim()) ||
1533
+ "";
1534
+ if (clearId && !replace) {
1535
+ const el = findDock(clearId);
1536
+ if (el) el.remove();
1537
+ } else {
1538
+ allDocks().forEach((el) => el.remove());
1539
+ }
1540
+ relayoutTodoDocks();
1175
1541
  return;
1176
1542
  }
1177
1543
 
1544
+ if (sync === "set" && replace) {
1545
+ // Drop sibling docks so we never stack duplicates.
1546
+ for (const el of allDocks()) {
1547
+ const key = el.getAttribute("data-wg-todo-key") || "_default";
1548
+ if (key !== dockKey) el.remove();
1549
+ }
1550
+ }
1551
+
1178
1552
  const POS = ["left", "right"];
1553
+ let dock = findDock(dockKey);
1179
1554
  if (!dock) {
1180
1555
  dock = document.createElement("div");
1181
- dock.id = "wg-todo-dock";
1556
+ dock.className = "wg-todo-dock";
1557
+ dock.setAttribute("data-wg-todo-key", dockKey);
1558
+ if (dockKey === "_default") dock.id = "wg-todo-dock";
1559
+ else dock.id = "wg-todo-dock--" + dockKey.replace(/[^a-zA-Z0-9_-]/g, "-");
1182
1560
  dock.setAttribute("data-wg-ui", "1");
1183
1561
  dock.setAttribute("data-wg-modal", "todos");
1184
1562
  dock.setAttribute("data-wg-ready", "1");
@@ -1238,7 +1616,7 @@ async function installOverlay(page, title) {
1238
1616
  ? "bullets"
1239
1617
  : "sequential";
1240
1618
  return (
1241
- '<ul id="wg-todos" data-wg-todo-style="' +
1619
+ '<ul class="wg-todos-list" data-wg-todo-style="' +
1242
1620
  st +
1243
1621
  '">' +
1244
1622
  (items || [])
@@ -1279,11 +1657,7 @@ async function installOverlay(page, title) {
1279
1657
  );
1280
1658
  };
1281
1659
 
1282
- const dockId =
1283
- (dockState && dockState.id) ||
1284
- (payload && !Array.isArray(payload) && payload.todoId) ||
1285
- "";
1286
- if (dockId) dock.setAttribute("data-wg-todo-id", String(dockId));
1660
+ if (dockKey !== "_default") dock.setAttribute("data-wg-todo-id", dockKey);
1287
1661
  else dock.removeAttribute("data-wg-todo-id");
1288
1662
 
1289
1663
  let html = "";
@@ -1309,6 +1683,7 @@ async function installOverlay(page, title) {
1309
1683
  html = renderItems(list, "sequential");
1310
1684
  }
1311
1685
  dock.innerHTML = html;
1686
+ relayoutTodoDocks();
1312
1687
  if (window.__wgStampModal) {
1313
1688
  window.__wgStampModal(dock, "todos", { ready: true });
1314
1689
  }
@@ -1464,6 +1839,9 @@ async function installOverlay(page, title) {
1464
1839
  }
1465
1840
  window.__wgDevicePreset = "desktop";
1466
1841
  window.__wgDeviceOrientation = "landscape";
1842
+ window.__wgDeviceTouch = false;
1843
+ document.documentElement.dataset.wgTouch = "0";
1844
+ if (window.__wgEnsureZoomBadge) window.__wgEnsureZoomBadge();
1467
1845
  return;
1468
1846
  }
1469
1847
 
@@ -1486,6 +1864,9 @@ async function installOverlay(page, title) {
1486
1864
  setCursor(touch);
1487
1865
  window.__wgDevicePreset = preset;
1488
1866
  window.__wgDeviceOrientation = orient;
1867
+ window.__wgDeviceTouch = touch;
1868
+ document.documentElement.dataset.wgTouch = touch ? "1" : "0";
1869
+ if (window.__wgEnsureZoomBadge) window.__wgEnsureZoomBadge();
1489
1870
 
1490
1871
  const announce =
1491
1872
  payload.announce === true ||
@@ -1517,6 +1898,20 @@ async function installOverlay(page, title) {
1517
1898
  { title, favicon: WAYGRAPH_FAVICON, bannerPos, todoPos, envAutoplay },
1518
1899
  )
1519
1900
  .catch(() => {});
1901
+ // Navigations wipe #wg-device-shell - rebuild when video stage + device are live.
1902
+ // If we already shuttered to desktop-flat, re-apply FLAT (never re-add bezel).
1903
+ if (
1904
+ page.__wgVideoViewport &&
1905
+ page.__wgDeviceShell &&
1906
+ page.__wgDeviceShell.width &&
1907
+ page.__wgDeviceShell.height
1908
+ ) {
1909
+ await applyVideoDeviceStage(page, page.__wgDeviceShell, page.__wgVideoViewport, {
1910
+ clear: false,
1911
+ shutterOut: !!page.__wgDesktopFlat,
1912
+ desktopFlat: !!page.__wgDesktopFlat,
1913
+ });
1914
+ }
1520
1915
  }
1521
1916
 
1522
1917
  async function renderBeforeStep(page, info) {
@@ -1653,13 +2048,22 @@ async function renderBeforeStep(page, info) {
1653
2048
  }
1654
2049
  if (window.__wgSyncTodos) {
1655
2050
  const sync = info.todoSync || (info.todos && info.todos.length ? "set" : "keep");
1656
- window.__wgSyncTodos({
1657
- sync: sync,
1658
- dock: info.todoDock || null,
1659
- list: info.todos || [],
1660
- pos: info.todoPos || null,
1661
- todoId: info.todoId || (info.todoDock && info.todoDock.id) || null,
1662
- });
2051
+ // Prefer dock-only payload. Passing list:[] with a dock used to clear
2052
+ // marks a frame later (empty-list clear path).
2053
+ if (sync === "keep" && !info.todoDock) {
2054
+ /* leave floating dock alone */
2055
+ } else {
2056
+ const payload = {
2057
+ sync: sync,
2058
+ dock: info.todoDock || null,
2059
+ pos: info.todoPos || null,
2060
+ todoId: info.todoId || (info.todoDock && info.todoDock.id) || null,
2061
+ };
2062
+ if (!info.todoDock && info.todos && info.todos.length) {
2063
+ payload.list = info.todos;
2064
+ }
2065
+ window.__wgSyncTodos(payload);
2066
+ }
1663
2067
  }
1664
2068
  if (window.__wgStampModal) {
1665
2069
  window.__wgStampModal(panel, "panel", {
@@ -1775,21 +2179,73 @@ async function renderBeforeStep(page, info) {
1775
2179
  }
1776
2180
  }
1777
2181
 
1778
- async function ensureSelectorInView(page, selector) {
2182
+ /**
2183
+ * Scroll a locator into view for demo theater.
2184
+ * Prefer CSS smooth scroll + scrollend wait. Do NOT call Playwright's
2185
+ * scrollIntoViewIfNeeded first - that jumps instantly and makes the
2186
+ * following smooth scroll a no-op (looks like a teleport), especially
2187
+ * noticeable under --fast when dwell is shorter.
2188
+ * Pass opts.instant=true only for skipTheater / blitz.
2189
+ */
2190
+ async function ensureLocatorInView(locator, opts) {
2191
+ const instant = !!(opts && opts.instant);
1779
2192
  try {
1780
- const loc = page.locator(selector).first();
1781
- await loc.scrollIntoViewIfNeeded().catch(() => {});
1782
- await page
1783
- .evaluate(async (sel) => {
1784
- const el = document.querySelector(sel);
1785
- if (!el) return;
2193
+ if (instant) {
2194
+ await locator.scrollIntoViewIfNeeded().catch(() => {});
2195
+ return;
2196
+ }
2197
+ // Touch mode: if the target is off-screen, show a swipe trail in the
2198
+ // finger direction that would scroll it into view (not device morph).
2199
+ const scrollPlan = await locator
2200
+ .evaluate((el) => {
2201
+ if (!el || typeof el.getBoundingClientRect !== "function") return null;
2202
+ const touch =
2203
+ window.__wgDeviceTouch === true ||
2204
+ document.documentElement.dataset.wgTouch === "1" ||
2205
+ window.__wgDevicePreset === "mobile" ||
2206
+ window.__wgDevicePreset === "tablet";
2207
+ if (!touch) return null;
2208
+ const r = el.getBoundingClientRect();
2209
+ const vw = window.innerWidth || 390;
2210
+ const vh = window.innerHeight || 844;
2211
+ const margin = 48;
2212
+ const below = r.top > vh - margin;
2213
+ const above = r.bottom < margin;
2214
+ const right = r.left > vw - margin;
2215
+ const left = r.right < margin;
2216
+ if (!below && !above && !left && !right) return null;
2217
+ // Finger direction to reveal the target (swipe up => content moves up).
2218
+ let dir = "up";
2219
+ if (below) dir = "up";
2220
+ else if (above) dir = "down";
2221
+ else if (right) dir = "left";
2222
+ else if (left) dir = "right";
2223
+ return { dir, hint: "scroll" };
2224
+ })
2225
+ .catch(() => null);
2226
+ if (scrollPlan && scrollPlan.dir) {
2227
+ demoLog(" swipe trail dir=" + scrollPlan.dir + " hint=scroll");
2228
+ await playSwipeTrail(locator.page(), {
2229
+ dir: scrollPlan.dir,
2230
+ label: "swipe \\u00b7 scroll",
2231
+ hint: "scroll",
2232
+ ms: Number(process.env.WAYGRAPH_SWIPE_MS || 650),
2233
+ });
2234
+ }
2235
+ await locator
2236
+ .evaluate(async (el) => {
2237
+ if (!el || typeof el.scrollIntoView !== "function") return;
1786
2238
  const waitScroll = (node, ms) =>
1787
2239
  new Promise((resolve) => {
1788
2240
  let done = false;
1789
2241
  const finish = () => {
1790
2242
  if (done) return;
1791
2243
  done = true;
1792
- node.removeEventListener("scrollend", finish);
2244
+ try {
2245
+ node.removeEventListener("scrollend", finish);
2246
+ } catch {
2247
+ /* ignore */
2248
+ }
1793
2249
  clearTimeout(t);
1794
2250
  resolve();
1795
2251
  };
@@ -1813,7 +2269,7 @@ async function ensureSelectorInView(page, selector) {
1813
2269
  }
1814
2270
  }
1815
2271
  }
1816
- await waitScroll(document.scrollingElement || document.documentElement, 500);
2272
+ await waitScroll(document.scrollingElement || document.documentElement, 650);
1817
2273
  let p = el.parentElement;
1818
2274
  while (p && p !== document.documentElement && p !== document.body) {
1819
2275
  const st = getComputedStyle(p);
@@ -1831,36 +2287,66 @@ async function ensureSelectorInView(page, selector) {
1831
2287
  p.scrollLeft = left;
1832
2288
  p.scrollTop = top;
1833
2289
  }
1834
- await waitScroll(p, 500);
2290
+ await waitScroll(p, 650);
1835
2291
  }
1836
2292
  }
1837
2293
  p = p.parentElement;
1838
2294
  }
1839
- }, selector)
2295
+ })
1840
2296
  .catch(() => {});
1841
2297
  } catch {
1842
2298
  /* best-effort */
1843
2299
  }
1844
2300
  }
1845
2301
 
1846
- async function applyHighlightZoom(page, selector, zoom) {
2302
+ async function ensureSelectorInView(page, selector, opts) {
2303
+ await ensureLocatorInView(page.locator(selector).first(), opts);
2304
+ }
2305
+
2306
+ async function applyHighlightZoom(page, selector, zoom, zoomOut) {
1847
2307
  const z = Number(zoom);
1848
2308
  if (!Number.isFinite(z) || z <= 1.001) {
1849
2309
  await page.evaluate(() => {
2310
+ window.__wgZoomOutOnHide = true;
1850
2311
  if (window.__wgClearZoom) window.__wgClearZoom();
1851
2312
  }).catch(() => {});
1852
2313
  return;
1853
2314
  }
2315
+ demoFixture("zoom", "ctx/ring zoom(" + z + ") zoomOut=" + (zoomOut !== false), selector);
1854
2316
  await page
1855
2317
  .evaluate(
1856
- ({ sel, scale }) => {
2318
+ ({ sel, scale, zoomOut }) => {
2319
+ // false = keep camera after ring hide (ctx.zoomOut(false) / ring.zoomOut).
2320
+ window.__wgZoomOutOnHide = zoomOut !== false;
1857
2321
  if (window.__wgApplyZoom) window.__wgApplyZoom(sel, scale);
1858
2322
  },
1859
- { sel: selector, scale: z },
2323
+ { sel: selector, scale: z, zoomOut: zoomOut !== false },
1860
2324
  )
1861
2325
  .catch(() => {});
1862
2326
  }
1863
2327
 
2328
+ async function applyHighlightFocus(page, box, focus) {
2329
+ if (!focus || !box) {
2330
+ await page
2331
+ .evaluate(() => {
2332
+ if (window.__wgClearFocus) window.__wgClearFocus();
2333
+ })
2334
+ .catch(() => {});
2335
+ return;
2336
+ }
2337
+ demoFixture("focus", "ctx/ring focus(true)", {
2338
+ x: Math.round(box.x),
2339
+ y: Math.round(box.y),
2340
+ w: Math.round(box.width),
2341
+ h: Math.round(box.height),
2342
+ });
2343
+ await page
2344
+ .evaluate((b) => {
2345
+ if (window.__wgApplyFocus) window.__wgApplyFocus(b);
2346
+ }, box)
2347
+ .catch(() => {});
2348
+ }
2349
+
1864
2350
  function todosHtmlFromInfo(info, escFn) {
1865
2351
  const list = info.todos || [];
1866
2352
  if (!list.length) return "";
@@ -1953,6 +2439,11 @@ async function presentFailPanel(page, opts) {
1953
2439
  size: styled.size,
1954
2440
  weight: styled.weight,
1955
2441
  zoom: styled.zoom,
2442
+ zoomOut: styled.zoomOut,
2443
+ focus: !!styled.focus,
2444
+ tone: styled.tone,
2445
+ color: styled.color,
2446
+ detail: styled.detail,
1956
2447
  };
1957
2448
  });
1958
2449
  await cycleHighlightRings(page, errHighlights, !!gatesFast, {
@@ -2002,17 +2493,30 @@ async function cycleHighlightRings(page, highlights, gatesFast, opts) {
2002
2493
  })
2003
2494
  .catch(() => {});
2004
2495
  }
2496
+ let lastRingEndedAt = null;
2005
2497
  for (let i = 0; i < list.length; i++) {
2006
2498
  const h = list[i];
2499
+ const ringStarted = Date.now();
2007
2500
  try {
2008
2501
  await ensureSelectorInView(page, h.selector);
2009
- await applyHighlightZoom(page, h.selector, h.zoom);
2010
- const box = await page.locator(h.selector).first().boundingBox();
2011
- if (box) {
2502
+ await applyHighlightZoom(page, h.selector, h.zoom, h.zoomOut);
2503
+ // Prefer live in-page rect (honors device-shell scale) over Playwright box.
2504
+ const box = await page
2505
+ .evaluate((sel) => {
2506
+ const el = document.querySelector(sel);
2507
+ if (!el) return null;
2508
+ const r = el.getBoundingClientRect();
2509
+ return { x: r.x, y: r.y, width: r.width, height: r.height };
2510
+ }, h.selector)
2511
+ .catch(() => null);
2512
+ if (box && box.width > 0 && box.height > 0) {
2012
2513
  await showRing(page, box, h.label, h.tone || "planned", {
2013
2514
  size: h.size,
2014
2515
  weight: h.weight,
2516
+ selector: h.selector,
2517
+ focus: !!h.focus,
2015
2518
  });
2519
+ // focus is applied by FollowRing / showRing - no second apply needed
2016
2520
  // Do NOT advanceTodoDock by ring index after stubAfter already set
2017
2521
  // progress - that reset current back to 0 and the next block looked
2018
2522
  // blank. Mid-act Method advance still uses syncTodoDockAdvance.
@@ -2069,27 +2573,76 @@ async function cycleHighlightRings(page, highlights, gatesFast, opts) {
2069
2573
  const legacyMs = i === list.length - 1 ? 200 : 900;
2070
2574
  const holdMs =
2071
2575
  authored != null ? authored : defaultHoldMs != null ? defaultHoldMs : legacyMs;
2576
+ const gap = lastRingEndedAt != null ? ringStarted - lastRingEndedAt : null;
2577
+ const holdExtra =
2578
+ "hold=" +
2579
+ holdMs +
2580
+ "ms" +
2581
+ (authored != null ? " authored" : " default") +
2582
+ " box=" +
2583
+ Math.round(box.x) +
2584
+ "," +
2585
+ Math.round(box.y) +
2586
+ " " +
2587
+ Math.round(box.width) +
2588
+ "x" +
2589
+ Math.round(box.height) +
2590
+ (gap != null ? " gap=" + gap + "ms" : "");
2591
+ demoHighlight(h, holdExtra);
2592
+ demoLog(
2593
+ " highlight " +
2594
+ (i + 1) +
2595
+ "/" +
2596
+ list.length +
2597
+ (gap != null && gap < 120 ? ansiPaint(ANSI.yellow, " WARN gap<120ms") : "") +
2598
+ (holdMs < 250 && list.length > 1 ? ansiPaint(ANSI.yellow, " WARN short hold") : ""),
2599
+ );
2072
2600
  await new Promise((res) => setTimeout(res, holdMs));
2601
+ lastRingEndedAt = Date.now();
2602
+ } else {
2603
+ demoHighlight(h, "SKIP no boundingBox");
2073
2604
  }
2074
- } catch {
2075
- // best-effort - a selector that doesn't resolve just gets skipped
2605
+ } catch (err) {
2606
+ demoHighlight(
2607
+ h || { selector: h && h.selector, label: "(error)" },
2608
+ "SKIP " + String(err && err.message ? err.message : err).slice(0, 80),
2609
+ );
2076
2610
  }
2077
2611
  }
2078
2612
  if (list.length > 0) {
2079
2613
  const last = list[list.length - 1];
2080
2614
  await page
2081
2615
  .evaluate((h) => {
2082
- if (!window.__wgPositionRing) return;
2616
+ if (!window.__wgFollowRing && !window.__wgPositionRing) return;
2617
+ if (window.__wgFollowRing) {
2618
+ window.__wgFollowRing(
2619
+ h.selector,
2620
+ h.label,
2621
+ h.tone || "planned",
2622
+ { size: h.size || "md", weight: h.weight || "normal" },
2623
+ !!h.focus,
2624
+ );
2625
+ return;
2626
+ }
2083
2627
  const reposition = () => {
2084
2628
  const el = document.querySelector(h.selector);
2085
2629
  if (!el) {
2086
2630
  if (window.__wgHideRing) window.__wgHideRing();
2087
2631
  return;
2088
2632
  }
2089
- window.__wgPositionRing(el.getBoundingClientRect(), h.label, h.tone || "planned", {
2633
+ const box = el.getBoundingClientRect();
2634
+ window.__wgPositionRing(box, h.label, h.tone || "planned", {
2090
2635
  size: h.size || "md",
2091
2636
  weight: h.weight || "normal",
2092
2637
  });
2638
+ if (h.focus && window.__wgApplyFocus) {
2639
+ window.__wgApplyFocus({
2640
+ x: box.x,
2641
+ y: box.y,
2642
+ width: box.width,
2643
+ height: box.height,
2644
+ });
2645
+ }
2093
2646
  };
2094
2647
  reposition();
2095
2648
  window.__wgRingTrack = reposition;
@@ -2238,13 +2791,22 @@ async function renderAfterStep(page, info) {
2238
2791
  }
2239
2792
  if (window.__wgSyncTodos) {
2240
2793
  const sync = info.todoSync || (info.todos && info.todos.length ? "set" : "keep");
2241
- window.__wgSyncTodos({
2242
- sync: sync,
2243
- dock: info.todoDock || null,
2244
- list: info.todos || [],
2245
- pos: info.todoPos || null,
2246
- todoId: info.todoId || (info.todoDock && info.todoDock.id) || null,
2247
- });
2794
+ // Prefer dock-only payload. Passing list:[] with a dock used to clear
2795
+ // marks a frame later (empty-list clear path).
2796
+ if (sync === "keep" && !info.todoDock) {
2797
+ /* leave floating dock alone */
2798
+ } else {
2799
+ const payload = {
2800
+ sync: sync,
2801
+ dock: info.todoDock || null,
2802
+ pos: info.todoPos || null,
2803
+ todoId: info.todoId || (info.todoDock && info.todoDock.id) || null,
2804
+ };
2805
+ if (!info.todoDock && info.todos && info.todos.length) {
2806
+ payload.list = info.todos;
2807
+ }
2808
+ window.__wgSyncTodos(payload);
2809
+ }
2248
2810
  }
2249
2811
  if (window.__wgStampModal) {
2250
2812
  window.__wgStampModal(panel, "panel", {
@@ -2510,14 +3072,23 @@ async function markStepRunning(page, _opts) {
2510
3072
  async function showRing(page, box, label, tone, style) {
2511
3073
  const size = normalizeHighlightSize(style && style.size);
2512
3074
  const weight = normalizeHighlightWeight(style && style.weight);
3075
+ const selector = style && style.selector ? String(style.selector) : "";
3076
+ const focus = !!(style && style.focus);
2513
3077
  await page
2514
3078
  .evaluate(
2515
- ({ box, label, tone, size, weight }) => {
3079
+ ({ box, label, tone, size, weight, selector, focus }) => {
3080
+ if (selector && window.__wgFollowRing) {
3081
+ window.__wgFollowRing(selector, label, tone || "planned", { size, weight }, focus);
3082
+ return;
3083
+ }
3084
+ if (window.__wgStopRingFollow) window.__wgStopRingFollow();
2516
3085
  if (window.__wgPositionRing) {
2517
3086
  window.__wgPositionRing(box, label, tone || "planned", { size, weight });
2518
3087
  }
3088
+ if (focus && box && window.__wgApplyFocus) window.__wgApplyFocus(box);
3089
+ else if (!focus && window.__wgClearFocus) window.__wgClearFocus();
2519
3090
  },
2520
- { box, label, tone: tone || "planned", size, weight },
3091
+ { box, label, tone: tone || "planned", size, weight, selector, focus },
2521
3092
  )
2522
3093
  .catch(() => {});
2523
3094
  }
@@ -2569,13 +3140,78 @@ async function matchStubIndexForLocator(page, locator, stubs) {
2569
3140
  return -1;
2570
3141
  }
2571
3142
 
3143
+ /** Flatten dock items in order (sequential groups). */
3144
+ function flattenTodoDockItems(dock) {
3145
+ const items = [];
3146
+ if (!dock || !dock.groups) return items;
3147
+ for (const g of dock.groups) {
3148
+ for (const t of g.items || []) items.push(t);
3149
+ }
3150
+ return items;
3151
+ }
3152
+
3153
+ /**
3154
+ * Resolve which todo index a matched stub should advance to.
3155
+ * Prefer stub.todo / stub.slotId / stub.id against item.id; never jump backward.
3156
+ */
3157
+ function resolveTodoAdvanceIndex(dock, stub, stubIndex) {
3158
+ const items = flattenTodoDockItems(dock);
3159
+ if (!items.length) return stubIndex;
3160
+ const curIdx = items.findIndex((t) => t.current);
3161
+ let idx = stubIndex;
3162
+ const key = stub && (stub.todo || stub.slotId || stub.id);
3163
+ if (key) {
3164
+ const k = String(key).trim().toLowerCase();
3165
+ let found = items.findIndex((t) => t.id && String(t.id).toLowerCase() === k);
3166
+ if (found < 0) {
3167
+ // username -> login-user, password -> login-pass, submit -> login-submit
3168
+ const aliases = {
3169
+ username: ["login-user", "user", "email"],
3170
+ password: ["login-pass", "pass"],
3171
+ submit: ["login-submit", "login", "tap-login"],
3172
+ user: ["login-user", "username"],
3173
+ pass: ["login-pass", "password"],
3174
+ };
3175
+ const alts = aliases[k] || [];
3176
+ for (const a of alts) {
3177
+ found = items.findIndex((t) => t.id && String(t.id).toLowerCase() === a);
3178
+ if (found >= 0) break;
3179
+ }
3180
+ }
3181
+ if (found < 0) {
3182
+ found = items.findIndex(
3183
+ (t) =>
3184
+ (t.id && String(t.id).toLowerCase().includes(k)) ||
3185
+ (t.text && String(t.text).toLowerCase().includes(k)),
3186
+ );
3187
+ }
3188
+ if (found >= 0) idx = found;
3189
+ }
3190
+ // Monotonic: never retreat the walkthrough.
3191
+ if (curIdx >= 0 && idx < curIdx) idx = curIdx;
3192
+ return idx;
3193
+ }
3194
+
2572
3195
  /** Advance sequential todo dock to stubIndex and push to the page (Method act). */
2573
- async function syncTodoDockAdvance(page, todoDockRef, stubIndex) {
3196
+ async function syncTodoDockAdvance(page, todoDockRef, stubIndex, stub) {
2574
3197
  if (!todoDockRef || stubIndex < 0) return;
2575
3198
  const prev = todoDockRef.current;
2576
3199
  if (!prev) return;
2577
- const advanced = advanceTodoDock(prev, stubIndex);
3200
+ const idx = resolveTodoAdvanceIndex(prev, stub || null, stubIndex);
3201
+ const advanced = advanceTodoDock(prev, idx);
2578
3202
  todoDockRef.current = advanced;
3203
+ demoLog(
3204
+ " todo advance -> idx=" +
3205
+ idx +
3206
+ (stub && (stub.slotId || stub.todo)
3207
+ ? " slot=" + (stub.todo || stub.slotId)
3208
+ : " stub#" + stubIndex) +
3209
+ ' current="' +
3210
+ String(
3211
+ (flattenTodoDockItems(advanced).find((t) => t.current) || {}).text || "",
3212
+ ).slice(0, 40) +
3213
+ '"',
3214
+ );
2579
3215
  await page
2580
3216
  .evaluate((dock) => {
2581
3217
  if (window.__wgSyncTodos) {
@@ -2647,9 +3283,22 @@ async function presentSlides(page, slides, _gate, opts) {
2647
3283
  if (s.selector) {
2648
3284
  try {
2649
3285
  await ensureSelectorInView(page, s.selector);
2650
- await applyHighlightZoom(page, s.selector, s.zoom);
2651
- const box = await page.locator(s.selector).first().boundingBox();
2652
- if (box) await showRing(page, box, caption, slideTone, slideStyle);
3286
+ await applyHighlightZoom(page, s.selector, s.zoom, s.zoomOut);
3287
+ const box = await page
3288
+ .evaluate((sel) => {
3289
+ const el = document.querySelector(sel);
3290
+ if (!el) return null;
3291
+ const r = el.getBoundingClientRect();
3292
+ return { x: r.x, y: r.y, width: r.width, height: r.height };
3293
+ }, s.selector)
3294
+ .catch(() => null);
3295
+ if (box && box.width > 0 && box.height > 0) {
3296
+ await showRing(page, box, caption, slideTone, {
3297
+ ...slideStyle,
3298
+ selector: s.selector,
3299
+ focus: !!s.focus,
3300
+ });
3301
+ }
2653
3302
  } catch {
2654
3303
  await hideRing(page);
2655
3304
  }
@@ -2882,6 +3531,307 @@ function paceSpeakFields(pace, baseAutoplayMs) {
2882
3531
  };
2883
3532
  }
2884
3533
 
3534
+ /** Demo CLI chatter (agents use this to ballpark timing / todo layout). Off for WAYGRAPH_JSON. */
3535
+ function demoQuiet() {
3536
+ return process.env.WAYGRAPH_JSON === "1";
3537
+ }
3538
+ function demoColorEnabled() {
3539
+ // FORCE_COLOR wins (agents/CI often set NO_COLOR=1).
3540
+ if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") return true;
3541
+ if (process.env.NO_COLOR === "1" || process.env.NO_COLOR === "true") return false;
3542
+ if (process.env.FORCE_COLOR === "0") return false;
3543
+ return !!(
3544
+ (process.stderr && process.stderr.isTTY) ||
3545
+ (process.stdout && process.stdout.isTTY)
3546
+ );
3547
+ }
3548
+ const ESC = String.fromCharCode(27);
3549
+ const ANSI = {
3550
+ reset: ESC + "[0m",
3551
+ bold: ESC + "[1m",
3552
+ dim: ESC + "[2m",
3553
+ purple: ESC + "[38;5;141m",
3554
+ blue: ESC + "[38;5;75m",
3555
+ yellow: ESC + "[38;5;220m",
3556
+ red: ESC + "[38;5;203m",
3557
+ green: ESC + "[38;5;114m",
3558
+ gray: ESC + "[38;5;246m",
3559
+ cyan: ESC + "[38;5;87m",
3560
+ magenta: ESC + "[38;5;213m",
3561
+ white: ESC + "[37m",
3562
+ };
3563
+ function ansiPaint(code, text) {
3564
+ if (!demoColorEnabled() || text == null || text === "") return String(text ?? "");
3565
+ return code + String(text) + ANSI.reset;
3566
+ }
3567
+ function toneAnsi(tone) {
3568
+ const t = normalizeHighlightTone(tone);
3569
+ if (t === "info") return ANSI.blue;
3570
+ if (t === "warning") return ANSI.yellow;
3571
+ if (t === "danger") return ANSI.red;
3572
+ if (t === "success") return ANSI.green;
3573
+ if (t === "auto") return ANSI.gray;
3574
+ return ANSI.purple;
3575
+ }
3576
+ function toneName(tone) {
3577
+ const t = normalizeHighlightTone(tone);
3578
+ if (t === "info") return "BLUE";
3579
+ if (t === "warning") return "YELLOW";
3580
+ if (t === "danger") return "RED";
3581
+ if (t === "success") return "GREEN";
3582
+ if (t === "auto") return "GRAY";
3583
+ return "PURPLE";
3584
+ }
3585
+ /** Optional authored CSS color (#rrggbb) -> truecolor ANSI when TTY. */
3586
+ function hexAnsi(hex) {
3587
+ if (!hex || typeof hex !== "string") return null;
3588
+ const m = String(hex).trim().match(/^#?([0-9a-fA-F]{6})$/);
3589
+ if (!m) return null;
3590
+ const n = parseInt(m[1], 16);
3591
+ const r = (n >> 16) & 255;
3592
+ const g = (n >> 8) & 255;
3593
+ const b = n & 255;
3594
+ return ESC + "[38;2;" + r + ";" + g + ";" + b + "m";
3595
+ }
3596
+ function demoTs() {
3597
+ const d = new Date();
3598
+ const p = (n) => String(n).padStart(2, "0");
3599
+ const ms = String(d.getMilliseconds()).padStart(3, "0");
3600
+ return (
3601
+ p(d.getHours()) +
3602
+ ":" +
3603
+ p(d.getMinutes()) +
3604
+ ":" +
3605
+ p(d.getSeconds()) +
3606
+ "." +
3607
+ ms
3608
+ );
3609
+ }
3610
+ function demoPrefix() {
3611
+ return ansiPaint(ANSI.dim, "[" + demoTs() + "]") + " waygraph demo: ";
3612
+ }
3613
+ function demoLog(msg) {
3614
+ if (demoQuiet()) return;
3615
+ console.log(demoPrefix() + msg);
3616
+ }
3617
+ /** Fixture catch-all: how it was called + value. */
3618
+ function demoFixture(kind, call, value) {
3619
+ if (demoQuiet()) return;
3620
+ const head = ansiPaint(ANSI.cyan, "FIXTURE") + " " + ansiPaint(ANSI.bold, kind);
3621
+ const callPart = call ? " " + ansiPaint(ANSI.dim, call) : "";
3622
+ let val = "";
3623
+ if (value !== undefined) {
3624
+ if (typeof value === "string") val = value;
3625
+ else {
3626
+ try {
3627
+ val = JSON.stringify(value);
3628
+ } catch {
3629
+ val = String(value);
3630
+ }
3631
+ }
3632
+ if (val.length > 220) val = val.slice(0, 217) + "...";
3633
+ val = " = " + ansiPaint(ANSI.white, val);
3634
+ }
3635
+ console.log(demoPrefix() + head + callPart + val);
3636
+ }
3637
+ /** Highlight trigger line: PURPLE · Cart items - Product landed in cart */
3638
+ function demoHighlight(h, extra) {
3639
+ if (demoQuiet()) return;
3640
+ const tone = h && h.tone != null ? h.tone : "planned";
3641
+ const paint = hexAnsi(h && h.color) || toneAnsi(tone);
3642
+ const name = toneName(tone);
3643
+ const label = String((h && h.label) || "").trim() || "(no label)";
3644
+ const sel = h && h.selector ? String(h.selector) : "";
3645
+ const bits = [];
3646
+ if (h && h.focus) bits.push("focus");
3647
+ if (h && h.zoom != null && Number(h.zoom) > 1) bits.push("zoom=" + h.zoom);
3648
+ if (h && h.zoomOut === false) bits.push("zoomOut=false");
3649
+ if (h && h.gesture) bits.push("gesture=" + h.gesture);
3650
+ if (h && h.weight && h.weight !== "normal") bits.push("weight=" + h.weight);
3651
+ if (h && h.size && h.size !== "md") bits.push("size=" + h.size);
3652
+ if (h && h.color) bits.push("color=" + h.color);
3653
+ if (extra) bits.push(extra);
3654
+ const line =
3655
+ ansiPaint(paint, name) +
3656
+ " · " +
3657
+ ansiPaint(paint, label) +
3658
+ (sel ? ansiPaint(ANSI.dim, " sel=" + sel) : "") +
3659
+ (bits.length ? ansiPaint(ANSI.dim, " [" + bits.join(" ") + "]") : "");
3660
+ console.log(demoPrefix() + ansiPaint(ANSI.magenta, "HIGHLIGHT") + " " + line);
3661
+ }
3662
+ function logStubPhaseFixtures(phaseName, phase) {
3663
+ if (demoQuiet() || !phase) return;
3664
+ const tag = "stub." + phaseName;
3665
+ if (phase.title) demoFixture(tag, "ctx.title(...)", JSON.stringify(phase.title));
3666
+ if (phase.banner) demoFixture(tag, "ctx.banner(...)", JSON.stringify(phase.banner));
3667
+ if (phase.deviceSync && phase.deviceSync !== "keep") {
3668
+ demoFixture(
3669
+ tag,
3670
+ phase.deviceSync === "clear" ? "ctx.clearDevice()" : "ctx.device(...)",
3671
+ phase.device
3672
+ ? {
3673
+ preset: phase.device.preset,
3674
+ w: phase.device.viewport && phase.device.viewport.width,
3675
+ h: phase.device.viewport && phase.device.viewport.height,
3676
+ orient: phase.device.orientation,
3677
+ touch: !!phase.device.touchMode,
3678
+ }
3679
+ : phase.deviceSync,
3680
+ );
3681
+ }
3682
+ if (phase.zoom != null) demoFixture(tag, "ctx.zoom(" + phase.zoom + ")", phase.zoom + "x");
3683
+ if (phase.zoomOut !== undefined) demoFixture(tag, "ctx.zoomOut(" + !!phase.zoomOut + ")", !!phase.zoomOut);
3684
+ if (phase.todoSync && phase.todoSync !== "keep") {
3685
+ demoFixture(
3686
+ tag,
3687
+ phase.todoSync === "clear" ? "ctx.hideTodos() / clear" : "ctx.todos(...)",
3688
+ phase.todoDock
3689
+ ? {
3690
+ id: phase.todoDock.id,
3691
+ pos: phase.todoDock.pos,
3692
+ title: phase.todoDock.title,
3693
+ n: (phase.todos && phase.todos.length) || 0,
3694
+ }
3695
+ : phase.todoSync,
3696
+ );
3697
+ }
3698
+ const rings = phase.highlights || [];
3699
+ for (let i = 0; i < rings.length; i++) {
3700
+ const h = rings[i];
3701
+ demoFixture(
3702
+ tag,
3703
+ "ctx.ring/highlights[" + i + "]",
3704
+ {
3705
+ selector: h.selector,
3706
+ label: h.label || h.caption,
3707
+ detail: h.detail,
3708
+ tone: h.tone || "planned",
3709
+ color: h.color,
3710
+ focus: !!h.focus,
3711
+ zoom: h.zoom,
3712
+ zoomOut: h.zoomOut,
3713
+ gesture: h.gesture,
3714
+ duration: h.duration,
3715
+ },
3716
+ );
3717
+ }
3718
+ if (phase.slides && phase.slides.length) {
3719
+ demoFixture(tag, "ctx.slides(...)", { n: phase.slides.length });
3720
+ }
3721
+ }
3722
+ function summarizeTodoDock(dock) {
3723
+ if (!dock) return "todos=none";
3724
+ const id = dock.id || "_default";
3725
+ const pos = dock.pos || "left";
3726
+ const style = dock.style || "sequential";
3727
+ const title = dock.title ? String(dock.title) : "";
3728
+ const items = [];
3729
+ for (const g of dock.groups || []) {
3730
+ for (const t of g.items || []) items.push(t);
3731
+ }
3732
+ const cur = items.find((t) => t.current);
3733
+ const done = items.filter((t) => t.done).length;
3734
+ return (
3735
+ "todos id=" +
3736
+ id +
3737
+ " pos=" +
3738
+ pos +
3739
+ " style=" +
3740
+ style +
3741
+ (title ? ' title="' + title + '"' : "") +
3742
+ " n=" +
3743
+ items.length +
3744
+ " done=" +
3745
+ done +
3746
+ (cur && cur.text ? ' current="' + String(cur.text).slice(0, 48) + '"' : "")
3747
+ );
3748
+ }
3749
+ /** Full todo dump for agents (every group/item id + flags). */
3750
+ function logTodoDockFull(dock, tag) {
3751
+ const prefix = tag ? tag + " " : "";
3752
+ if (!dock) {
3753
+ demoLog(prefix + "todos=none");
3754
+ return;
3755
+ }
3756
+ demoLog(prefix + summarizeTodoDock(dock));
3757
+ const groups = dock.groups || [];
3758
+ if (!groups.length) {
3759
+ demoLog(prefix + " (no groups)");
3760
+ return;
3761
+ }
3762
+ for (let gi = 0; gi < groups.length; gi++) {
3763
+ const g = groups[gi];
3764
+ const gLabel =
3765
+ "group[" +
3766
+ gi +
3767
+ "]" +
3768
+ (g.id ? " id=" + g.id : "") +
3769
+ (g.title ? ' title="' + String(g.title).slice(0, 40) + '"' : "") +
3770
+ (g.style ? " style=" + g.style : "");
3771
+ demoLog(prefix + " " + gLabel);
3772
+ const items = g.items || [];
3773
+ for (let ti = 0; ti < items.length; ti++) {
3774
+ const t = items[ti];
3775
+ const mark = t.done ? "done" : t.current ? "CURRENT" : "pending";
3776
+ demoLog(
3777
+ prefix +
3778
+ " [" +
3779
+ ti +
3780
+ "] " +
3781
+ mark +
3782
+ (t.id ? " id=" + t.id : "") +
3783
+ ' text="' +
3784
+ String(t.text || "").slice(0, 80) +
3785
+ '"' +
3786
+ (t.detail ? ' detail="' + String(t.detail).slice(0, 40) + '"' : ""),
3787
+ );
3788
+ }
3789
+ }
3790
+ }
3791
+ function summarizeDevice(d) {
3792
+ if (!d) return "device=none";
3793
+ const vp = d.viewport || {};
3794
+ return (
3795
+ "device=" +
3796
+ (d.preset || "?") +
3797
+ " " +
3798
+ (vp.width || "?") +
3799
+ "x" +
3800
+ (vp.height || "?") +
3801
+ " " +
3802
+ (d.orientation || "?") +
3803
+ (d.touchMode ? " touch" : "")
3804
+ );
3805
+ }
3806
+ async function probeTodoDocksOnPage(page) {
3807
+ return page
3808
+ .evaluate(() => {
3809
+ const docks = [...document.querySelectorAll(".wg-todo-dock, #wg-todo-dock")];
3810
+ return docks.map((el) => {
3811
+ const r = el.getBoundingClientRect();
3812
+ const items = el.querySelectorAll("li");
3813
+ let current = "";
3814
+ items.forEach((li) => {
3815
+ if (li.classList.contains("wg-todo-current")) {
3816
+ current = (li.textContent || "").replace(/\s+/g, " ").trim().slice(0, 48);
3817
+ }
3818
+ });
3819
+ return {
3820
+ key: el.getAttribute("data-wg-todo-key") || el.id || "?",
3821
+ pos: el.dataset.pos || "?",
3822
+ top: Math.round(r.top),
3823
+ left: Math.round(r.left),
3824
+ w: Math.round(r.width),
3825
+ h: Math.round(r.height),
3826
+ onScreen: r.bottom > 0 && r.right > 0 && r.top < innerHeight && r.left < innerWidth,
3827
+ n: items.length,
3828
+ current,
3829
+ };
3830
+ });
3831
+ })
3832
+ .catch(() => []);
3833
+ }
3834
+
2885
3835
  async function hideRing(page) {
2886
3836
  await page
2887
3837
  .evaluate(() => {
@@ -2908,10 +3858,23 @@ async function teardownOverlay(page) {
2908
3858
  window.__wgRingTrack = null;
2909
3859
  }
2910
3860
  window.__wgNarrateOwnsRing = false;
2911
- for (const id of ["wg-panel", "wg-ring", "wg-ring-label", "wg-cursor", "wg-click-pulse", "wg-banner", "wg-todo-dock"]) {
3861
+ for (const id of [
3862
+ "wg-panel",
3863
+ "wg-ring",
3864
+ "wg-ring-label",
3865
+ "wg-cursor",
3866
+ "wg-click-pulse",
3867
+ "wg-banner",
3868
+ "wg-todo-dock",
3869
+ "wg-device-shell",
3870
+ "wg-device-toast",
3871
+ "wg-device-badge",
3872
+ ]) {
2912
3873
  const el = document.getElementById(id);
2913
3874
  if (el) el.remove();
2914
3875
  }
3876
+ document.querySelectorAll(".wg-todo-dock").forEach((el) => el.remove());
3877
+ document.documentElement.classList.remove("wg-video-device-stage");
2915
3878
  })
2916
3879
  .catch(() => {});
2917
3880
  }
@@ -2942,6 +3905,116 @@ async function clickPulseAt(page, x, y, tone) {
2942
3905
  .catch(() => {});
2943
3906
  }
2944
3907
 
3908
+ /**
3909
+ * Touch swipe trail (mobile/tablet). dir: left|right|up|down.
3910
+ * Returns after the trail animation completes.
3911
+ * Self-contained when overlay helper is not installed yet (device runs before panel).
3912
+ */
3913
+ async function playSwipeTrail(page, opts) {
3914
+ const o = opts || {};
3915
+ const waitMs = await page
3916
+ .evaluate((payload) => {
3917
+ if (typeof window.__wgSwipeTrail === "function") {
3918
+ return window.__wgSwipeTrail(payload);
3919
+ }
3920
+ // Fallback: inject a one-shot trail without full overlay install.
3921
+ const dir =
3922
+ payload.dir === "right" || payload.dir === "up" || payload.dir === "down"
3923
+ ? payload.dir
3924
+ : "left";
3925
+ const ms = Math.max(280, Math.min(1400, Number(payload.ms) || 720));
3926
+ const label = payload.label || "swipe";
3927
+ let layer = document.getElementById("wg-swipe-layer");
3928
+ if (layer) layer.remove();
3929
+ layer = document.createElement("div");
3930
+ layer.id = "wg-swipe-layer";
3931
+ layer.setAttribute("data-wg-ui", "1");
3932
+ layer.style.cssText =
3933
+ "position:fixed;inset:0;z-index:2147483646;pointer-events:none;overflow:hidden;";
3934
+ const finger = document.createElement("div");
3935
+ finger.style.cssText =
3936
+ "position:absolute;width:28px;height:28px;margin:-14px 0 0 -14px;border-radius:50%;" +
3937
+ "background:rgba(15,23,42,.92);border:2px solid #fff;box-shadow:0 4px 16px rgba(0,0,0,.4);";
3938
+ const lab = document.createElement("div");
3939
+ lab.textContent = label;
3940
+ lab.style.cssText =
3941
+ "position:absolute;left:50%;top:18%;transform:translateX(-50%);padding:6px 12px;" +
3942
+ "border-radius:999px;background:rgba(15,23,42,.88);color:#e0f2fe;" +
3943
+ "border:1px solid rgba(56,189,248,.55);font:700 12px/1.2 system-ui,sans-serif;";
3944
+ layer.appendChild(lab);
3945
+ layer.appendChild(finger);
3946
+ document.documentElement.appendChild(layer);
3947
+ const vw = window.innerWidth || 390;
3948
+ const vh = window.innerHeight || 844;
3949
+ const pad = Math.min(vw, vh) * 0.18;
3950
+ let x0;
3951
+ let y0;
3952
+ let x1;
3953
+ let y1;
3954
+ if (dir === "left") {
3955
+ x0 = vw - pad;
3956
+ x1 = pad;
3957
+ y0 = y1 = vh * 0.52;
3958
+ } else if (dir === "right") {
3959
+ x0 = pad;
3960
+ x1 = vw - pad;
3961
+ y0 = y1 = vh * 0.52;
3962
+ } else if (dir === "up") {
3963
+ x0 = x1 = vw * 0.5;
3964
+ y0 = vh - pad;
3965
+ y1 = pad;
3966
+ } else {
3967
+ x0 = x1 = vw * 0.5;
3968
+ y0 = pad;
3969
+ y1 = vh - pad;
3970
+ }
3971
+ finger.style.left = x0 + "px";
3972
+ finger.style.top = y0 + "px";
3973
+ const steps = 12;
3974
+ const stepMs = Math.floor(ms / steps);
3975
+ for (let i = 1; i <= steps; i++) {
3976
+ const t = i / steps;
3977
+ const e = t * t * (3 - 2 * t);
3978
+ const x = x0 + (x1 - x0) * e;
3979
+ const y = y0 + (y1 - y0) * e;
3980
+ setTimeout(() => {
3981
+ finger.style.left = x + "px";
3982
+ finger.style.top = y + "px";
3983
+ const dot = document.createElement("div");
3984
+ dot.style.cssText =
3985
+ "position:absolute;width:14px;height:14px;margin:-7px 0 0 -7px;border-radius:50%;" +
3986
+ "background:rgba(147,197,253,.55);border:2px solid rgba(255,255,255,.85);left:" +
3987
+ x +
3988
+ "px;top:" +
3989
+ y +
3990
+ "px;opacity:" +
3991
+ (0.85 - t * 0.55) +
3992
+ ";transition:opacity .45s ease,transform .45s ease;";
3993
+ layer.appendChild(dot);
3994
+ requestAnimationFrame(() => {
3995
+ dot.style.opacity = "0";
3996
+ dot.style.transform = "scale(1.8)";
3997
+ });
3998
+ if (i === steps) {
3999
+ setTimeout(() => {
4000
+ const el = document.getElementById("wg-swipe-layer");
4001
+ if (el) el.remove();
4002
+ }, 400);
4003
+ }
4004
+ }, i * stepMs);
4005
+ }
4006
+ return ms + 120;
4007
+ }, {
4008
+ dir: o.dir || "left",
4009
+ label: o.label || "swipe",
4010
+ hint: o.hint || "",
4011
+ ms: o.ms || 720,
4012
+ })
4013
+ .catch(() => 0);
4014
+ const n = Number(waitMs) || 0;
4015
+ if (n > 0) await new Promise((r) => setTimeout(r, n));
4016
+ }
4017
+
2945
4018
  /**
2946
4019
  * Fit + center the OS browser window on the device viewport so mobile/tablet
2947
4020
  * is not stuck top-left inside a maximized frame (Dan 0.13.4).
@@ -3006,9 +4079,144 @@ async function fitWindowToDeviceViewport(page, viewport, opts) {
3006
4079
  }
3007
4080
  }
3008
4081
 
4082
+ /**
4083
+ * Video recording locks Playwright viewport to recordVideo.size. Shrinking
4084
+ * setViewportSize to mobile/tablet letterboxes the .webm top-left. Instead keep
4085
+ * the stage size and center a device-sized #wg-device-shell inside the frame.
4086
+ */
4087
+ async function applyVideoDeviceStage(page, target, stage, opts) {
4088
+ const clear = !!(opts && opts.clear);
4089
+ const shutterOut = !!(opts && opts.shutterOut);
4090
+ const enterIn = !!(opts && opts.enterIn);
4091
+ const desktopFlat = !!(opts && (opts.desktopFlat || opts.shutterOut));
4092
+ const result = await page
4093
+ .evaluate(
4094
+ ({ tw, th, sw, sh, clear, shutterOut, enterIn, desktopFlat }) => {
4095
+ const MATTE = "#0b1220";
4096
+ const unwrap = () => {
4097
+ const shell = document.getElementById("wg-device-shell");
4098
+ if (shell) {
4099
+ const parent = shell.parentNode;
4100
+ if (parent) {
4101
+ while (shell.firstChild) parent.insertBefore(shell.firstChild, shell);
4102
+ }
4103
+ shell.remove();
4104
+ }
4105
+ document.documentElement.classList.remove("wg-video-device-stage");
4106
+ document.documentElement.style.removeProperty("background");
4107
+ const b = document.body;
4108
+ if (b) {
4109
+ b.style.margin = "";
4110
+ b.style.minHeight = "";
4111
+ b.style.display = "";
4112
+ b.style.alignItems = "";
4113
+ b.style.justifyContent = "";
4114
+ b.style.background = "";
4115
+ b.style.overflow = "";
4116
+ }
4117
+ };
4118
+ if (clear) {
4119
+ unwrap();
4120
+ return { waitMs: 0 };
4121
+ }
4122
+ if (!document.body) return { waitMs: 0 };
4123
+ document.documentElement.classList.add("wg-video-device-stage");
4124
+ document.documentElement.style.background = MATTE;
4125
+ document.body.style.margin = "0";
4126
+ document.body.style.minHeight = "100vh";
4127
+ document.body.style.display = "flex";
4128
+ document.body.style.alignItems = "center";
4129
+ document.body.style.justifyContent = "center";
4130
+ document.body.style.background = MATTE;
4131
+ document.body.style.overflow = "hidden";
4132
+
4133
+ let shell = document.getElementById("wg-device-shell");
4134
+ const created = !shell;
4135
+ if (!shell) {
4136
+ shell = document.createElement("div");
4137
+ shell.id = "wg-device-shell";
4138
+ const move = [];
4139
+ for (const child of [...document.body.childNodes]) {
4140
+ if (
4141
+ child.nodeType === 1 &&
4142
+ child.getAttribute &&
4143
+ child.getAttribute("data-wg-ui") === "1"
4144
+ ) {
4145
+ continue;
4146
+ }
4147
+ if (child.nodeType === 1 && child.id === "wg-device-shell") continue;
4148
+ move.push(child);
4149
+ }
4150
+ for (const n of move) shell.appendChild(n);
4151
+ document.body.appendChild(shell);
4152
+ }
4153
+ // Desktop flat: always full-bleed, never reintroduce bezel on overlay rebuild.
4154
+ if (desktopFlat) {
4155
+ shell.classList.remove("wg-shell-enter");
4156
+ shell.classList.add("wg-shell-shutter-out");
4157
+ shell.classList.add("wg-shell-desktop-flat");
4158
+ shell.style.width = sw + "px";
4159
+ shell.style.height = sh + "px";
4160
+ shell.style.maxWidth = sw + "px";
4161
+ shell.style.maxHeight = sh + "px";
4162
+ shell.style.transform = "scale(1)";
4163
+ shell.style.transformOrigin = "center center";
4164
+ shell.style.borderRadius = "0";
4165
+ shell.style.boxShadow = "none";
4166
+ shell.style.border = "none";
4167
+ shell.style.outline = "none";
4168
+ return { waitMs: shutterOut ? 480 : 0 };
4169
+ }
4170
+ // Leaving desktop-flat: clear INLINE radius/shadow overrides so CSS
4171
+ // border-radius:16px applies evenly on all corners again.
4172
+ shell.style.removeProperty("border-radius");
4173
+ shell.style.removeProperty("box-shadow");
4174
+ shell.style.removeProperty("border");
4175
+ shell.style.removeProperty("outline");
4176
+ if (enterIn && (created || shell.classList.contains("wg-shell-shutter-out"))) {
4177
+ shell.classList.remove("wg-shell-shutter-out");
4178
+ shell.classList.remove("wg-shell-desktop-flat");
4179
+ shell.classList.add("wg-shell-enter");
4180
+ void shell.offsetWidth;
4181
+ }
4182
+ const scale = Math.min(sw / tw, sh / th) * 0.92;
4183
+ shell.style.width = tw + "px";
4184
+ shell.style.height = th + "px";
4185
+ shell.style.maxWidth = tw + "px";
4186
+ shell.style.maxHeight = th + "px";
4187
+ shell.style.transform = "scale(" + scale + ")";
4188
+ shell.style.transformOrigin = "center center";
4189
+ shell.classList.remove("wg-shell-shutter-out");
4190
+ shell.classList.remove("wg-shell-desktop-flat");
4191
+ if (enterIn && shell.classList.contains("wg-shell-enter")) {
4192
+ requestAnimationFrame(() => {
4193
+ shell.classList.remove("wg-shell-enter");
4194
+ });
4195
+ return { waitMs: 520 };
4196
+ }
4197
+ shell.classList.remove("wg-shell-enter");
4198
+ return { waitMs: 0 };
4199
+ },
4200
+ {
4201
+ tw: target.width,
4202
+ th: target.height,
4203
+ sw: stage.width,
4204
+ sh: stage.height,
4205
+ clear,
4206
+ shutterOut,
4207
+ enterIn,
4208
+ desktopFlat,
4209
+ },
4210
+ )
4211
+ .catch(() => ({ waitMs: 0 }));
4212
+ const waitMs = result && result.waitMs ? Number(result.waitMs) : 0;
4213
+ if (waitMs > 0) await new Promise((r) => setTimeout(r, waitMs));
4214
+ }
4215
+
3009
4216
  /**
3010
4217
  * Apply authored device fixture to the live page (0.13+).
3011
4218
  * Seamless viewport lerp + centered window + toast on set/clear.
4219
+ * With --video: keep recordVideo stage size and center a device shell in-frame.
3012
4220
  */
3013
4221
  async function applyDeviceToPage(page, device, sync) {
3014
4222
  const mode = sync || (device ? "set" : "keep");
@@ -3030,6 +4238,15 @@ async function applyDeviceToPage(page, device, sync) {
3030
4238
  width: Math.max(200, Math.floor(d.viewport.width)),
3031
4239
  height: Math.max(200, Math.floor(d.viewport.height)),
3032
4240
  };
4241
+ const videoStage =
4242
+ page.__wgVideoViewport &&
4243
+ page.__wgVideoViewport.width > 0 &&
4244
+ page.__wgVideoViewport.height > 0
4245
+ ? {
4246
+ width: Math.floor(page.__wgVideoViewport.width),
4247
+ height: Math.floor(page.__wgVideoViewport.height),
4248
+ }
4249
+ : null;
3033
4250
  let from = null;
3034
4251
  try {
3035
4252
  from = page.viewportSize();
@@ -3046,43 +4263,135 @@ async function applyDeviceToPage(page, device, sync) {
3046
4263
  from.height > 0 &&
3047
4264
  (from.width !== target.width || from.height !== target.height);
3048
4265
 
3049
- // Unmaximize + center early so the lerp does not sit in the corner of a
3050
- // maximized frame. Desktop clear maximizes at the end instead.
3051
- if (!toDesktop) {
3052
- await fitWindowToDeviceViewport(page, from && from.width ? from : target, {
3053
- maximize: false,
3054
- });
4266
+ // Always clear camera zoom when returning to desktop (video + headed).
4267
+ if (toDesktop) {
4268
+ page.__wgDesktopFlat = true;
4269
+ await page
4270
+ .evaluate(() => {
4271
+ window.__wgZoomOutOnHide = true;
4272
+ if (window.__wgClearZoom) window.__wgClearZoom();
4273
+ })
4274
+ .catch(() => {});
4275
+ } else if (announce) {
4276
+ page.__wgDesktopFlat = false;
3055
4277
  }
3056
4278
 
3057
- try {
3058
- if (shouldAnimate) {
3059
- const steps = 14;
3060
- const ms = Number(process.env.WAYGRAPH_DEVICE_TRANSITION_MS || 520);
3061
- const stepMs = Math.max(12, Math.floor(ms / steps));
3062
- for (let i = 1; i <= steps; i++) {
3063
- const t = i / steps;
3064
- const e = t * t * (3 - 2 * t); // smoothstep
3065
- const mid = {
3066
- width: Math.round(from.width + (target.width - from.width) * e),
3067
- height: Math.round(from.height + (target.height - from.height) * e),
3068
- };
3069
- await realSet(mid);
3070
- if (!toDesktop && (i === 1 || i === steps || i % 3 === 0)) {
3071
- await fitWindowToDeviceViewport(page, mid, { maximize: false });
3072
- }
3073
- if (i < steps) await new Promise((r) => setTimeout(r, stepMs));
4279
+ // Swipe trail is for scrolling (see ensureLocatorInView), not device morph.
4280
+ const nextOrient =
4281
+ d.orientation === "landscape" || d.orientation === "portrait"
4282
+ ? d.orientation
4283
+ : target.height > target.width
4284
+ ? "portrait"
4285
+ : "landscape";
4286
+ page.__wgDeviceOrient = nextOrient;
4287
+ page.__wgDevicePreset = toDesktop ? "desktop" : d.preset || "";
4288
+
4289
+ if (videoStage) {
4290
+ // Lock Playwright viewport to the recordVideo size so frames fill the
4291
+ // .webm; center the device shell inside (OS window centering alone does not).
4292
+ // Do NOT fitWindow to the device size - that shrinks the viewport below the
4293
+ // recordVideo canvas and Playwright pads the .webm with grey (top-left bias).
4294
+ try {
4295
+ await realSet(videoStage);
4296
+ } catch {
4297
+ /* ignore */
4298
+ }
4299
+ const shellFrom = page.__wgDeviceShell || target;
4300
+ if (toDesktop) {
4301
+ // Zoom already cleared above. Full-bleed desktop shell (no matte frame).
4302
+ // Shutter-out: soft rounded -> radius 0, then flat edge-to-edge.
4303
+ const desk = {
4304
+ width: videoStage.width,
4305
+ height: videoStage.height,
4306
+ };
4307
+ page.__wgDeviceShell = { ...desk };
4308
+ await applyVideoDeviceStage(page, desk, videoStage, {
4309
+ clear: false,
4310
+ shutterOut: true,
4311
+ desktopFlat: true,
4312
+ });
4313
+ try {
4314
+ await realSet(videoStage);
4315
+ } catch {
4316
+ /* ignore */
3074
4317
  }
3075
4318
  } else {
3076
- await realSet(target);
4319
+ // Lerp shell size when switching mobile <-> tablet mid-video.
4320
+ const firstDevice = !page.__wgDeviceShell;
4321
+ if (
4322
+ announce &&
4323
+ shellFrom &&
4324
+ (shellFrom.width !== target.width || shellFrom.height !== target.height)
4325
+ ) {
4326
+ const steps = 14;
4327
+ const ms = Number(process.env.WAYGRAPH_DEVICE_TRANSITION_MS || 520);
4328
+ const stepMs = Math.max(12, Math.floor(ms / steps));
4329
+ for (let i = 1; i <= steps; i++) {
4330
+ const t = i / steps;
4331
+ const e = t * t * (3 - 2 * t);
4332
+ const mid = {
4333
+ width: Math.round(shellFrom.width + (target.width - shellFrom.width) * e),
4334
+ height: Math.round(shellFrom.height + (target.height - shellFrom.height) * e),
4335
+ };
4336
+ await applyVideoDeviceStage(page, mid, videoStage, {
4337
+ clear: false,
4338
+ enterIn: i === 1 && firstDevice,
4339
+ });
4340
+ if (i < steps) await new Promise((r) => setTimeout(r, stepMs));
4341
+ }
4342
+ } else {
4343
+ await applyVideoDeviceStage(page, target, videoStage, {
4344
+ clear: false,
4345
+ enterIn: firstDevice || announce,
4346
+ });
4347
+ }
4348
+ page.__wgDeviceShell = { ...target };
4349
+ // Re-assert stage size after any prior non-video fitWindow.
4350
+ try {
4351
+ await realSet(videoStage);
4352
+ } catch {
4353
+ /* ignore */
4354
+ }
3077
4355
  }
3078
- } catch {
3079
- /* maximized / null viewport hosts may reject - toast still updates */
3080
- }
3081
-
3082
- if (toDesktop) {
3083
- await fitWindowToDeviceViewport(page, target, { maximize: true });
3084
4356
  } else {
3085
- await fitWindowToDeviceViewport(page, target, { maximize: false });
4357
+ // Unmaximize + center early so the lerp does not sit in the corner of a
4358
+ // maximized frame. Desktop clear maximizes at the end instead.
4359
+ if (!toDesktop) {
4360
+ await fitWindowToDeviceViewport(page, from && from.width ? from : target, {
4361
+ maximize: false,
4362
+ });
4363
+ }
4364
+
4365
+ try {
4366
+ if (shouldAnimate) {
4367
+ const steps = 14;
4368
+ const ms = Number(process.env.WAYGRAPH_DEVICE_TRANSITION_MS || 520);
4369
+ const stepMs = Math.max(12, Math.floor(ms / steps));
4370
+ for (let i = 1; i <= steps; i++) {
4371
+ const t = i / steps;
4372
+ const e = t * t * (3 - 2 * t); // smoothstep
4373
+ const mid = {
4374
+ width: Math.round(from.width + (target.width - from.width) * e),
4375
+ height: Math.round(from.height + (target.height - from.height) * e),
4376
+ };
4377
+ await realSet(mid);
4378
+ if (!toDesktop && (i === 1 || i === steps || i % 3 === 0)) {
4379
+ await fitWindowToDeviceViewport(page, mid, { maximize: false });
4380
+ }
4381
+ if (i < steps) await new Promise((r) => setTimeout(r, stepMs));
4382
+ }
4383
+ } else {
4384
+ await realSet(target);
4385
+ }
4386
+ } catch {
4387
+ /* maximized / null viewport hosts may reject - toast still updates */
4388
+ }
4389
+
4390
+ if (toDesktop) {
4391
+ await fitWindowToDeviceViewport(page, target, { maximize: true });
4392
+ } else {
4393
+ await fitWindowToDeviceViewport(page, target, { maximize: false });
4394
+ }
3086
4395
  }
3087
4396
 
3088
4397
  // Fire toast near the end of the lerp so it reads as one seamless beat.
@@ -3163,7 +4472,30 @@ function instrumentInteractionHighlighting(page, mem, slowMo, pacing, stubBefore
3163
4472
  proto.fill = async function (value, options) {
3164
4473
  try {
3165
4474
  await installOverlay(page);
4475
+ await this.waitFor({
4476
+ state: "visible",
4477
+ timeout: (options && options.timeout) || 30000,
4478
+ }).catch(() => {});
4479
+ // Smooth scroll before measuring - otherwise off-screen fields
4480
+ // teleport when Playwright's fill actionability scrolls.
4481
+ await ensureLocatorInView(this, { instant: skipTheater() });
3166
4482
  const box = await this.boundingBox();
4483
+ let typeLabel = "input";
4484
+ try {
4485
+ const meta = await this.evaluate((el) => {
4486
+ const id = (el.id || "").toLowerCase();
4487
+ const name = (el.getAttribute("name") || "").toLowerCase();
4488
+ const ph = (el.getAttribute("placeholder") || "").toLowerCase();
4489
+ const ty = (el.getAttribute("type") || "text").toLowerCase();
4490
+ if (ty === "password" || /pass/.test(id + name + ph)) return "password";
4491
+ if (/user|email|login/.test(id + name + ph)) return "username";
4492
+ if (ty && ty !== "text") return ty;
4493
+ return id || name || ph || "input";
4494
+ });
4495
+ if (meta) typeLabel = String(meta);
4496
+ } catch {
4497
+ /* ignore */
4498
+ }
3167
4499
  if (box && !(await wasJustNarrated(page))) {
3168
4500
  // Only trust the "last mem.get()" as THIS fill's source if it
3169
4501
  // happened recently - a stale read from several actions ago is
@@ -3174,19 +4506,46 @@ function instrumentInteractionHighlighting(page, mem, slowMo, pacing, stubBefore
3174
4506
  const stubs = (stubBeforeRef && stubBeforeRef.current) || [];
3175
4507
  const cap = await captionForLocator(page, this, stubs, fallback);
3176
4508
  const stubIdx = await matchStubIndexForLocator(page, this, stubs);
3177
- await syncTodoDockAdvance(page, todoDockRef, stubIdx);
4509
+ const stub = stubIdx >= 0 ? stubs[stubIdx] : null;
4510
+ await syncTodoDockAdvance(page, todoDockRef, stubIdx, stub);
3178
4511
  await moveCursorTo(page, box, cursorMs(500));
4512
+ if (stub) {
4513
+ await applyHighlightZoom(page, stub.selector, stub.zoom, stub.zoomOut);
4514
+ }
4515
+ demoHighlight(
4516
+ {
4517
+ selector: stub && stub.selector,
4518
+ label: cap.label,
4519
+ tone: cap.tone || (stub && stub.tone) || "planned",
4520
+ color: stub && stub.color,
4521
+ focus: !!(stub && stub.focus),
4522
+ zoom: stub && stub.zoom,
4523
+ zoomOut: stub && stub.zoomOut,
4524
+ gesture: stub && stub.gesture,
4525
+ weight: cap.weight,
4526
+ size: cap.size,
4527
+ },
4528
+ "fill",
4529
+ );
3179
4530
  await showRing(page, box, cap.label, cap.tone, {
3180
4531
  size: cap.size,
3181
4532
  weight: cap.weight,
4533
+ selector: stub && stub.selector ? stub.selector : undefined,
4534
+ focus: !!(stub && stub.focus),
3182
4535
  });
3183
4536
  await dwellMatchedStub(page, this, stubs, pacing);
3184
4537
  await new Promise((res) => setTimeout(res, skipTheater() ? 0 : 200));
4538
+ if (cap && cap.label) typeLabel = String(cap.label).slice(0, 40);
3185
4539
  } else if (box) {
3186
4540
  // Ring/caption already handled by narrate() - still move the
3187
4541
  // cursor there, just skip re-showing the ring with a generic label.
3188
4542
  await moveCursorTo(page, box, cursorMs(200));
3189
4543
  }
4544
+ await page
4545
+ .evaluate((label) => {
4546
+ if (window.__wgSetTypingBadge) window.__wgSetTypingBadge(true, label);
4547
+ }, typeLabel)
4548
+ .catch(() => {});
3190
4549
  } catch {
3191
4550
  // best-effort - element not visible/attached yet is not this
3192
4551
  // instrumentation's problem, the real fill below still runs
@@ -3205,6 +4564,11 @@ function instrumentInteractionHighlighting(page, mem, slowMo, pacing, stubBefore
3205
4564
  // handle specially) - fall back to the real, unpatched fill.
3206
4565
  result = await originalFill.call(this, value, options);
3207
4566
  }
4567
+ await page
4568
+ .evaluate(() => {
4569
+ if (window.__wgSetTypingBadge) window.__wgSetTypingBadge(false);
4570
+ })
4571
+ .catch(() => {});
3208
4572
  // Fade the ring back out once this field is actually done, instead
3209
4573
  // of leaving it lit until the next Block's own before-panel clears
3210
4574
  // it - it was sticking around through the whole rest of the step.
@@ -3229,6 +4593,9 @@ function instrumentInteractionHighlighting(page, mem, slowMo, pacing, stubBefore
3229
4593
  state: "visible",
3230
4594
  timeout: (options && options.timeout) || 30000,
3231
4595
  }).catch(() => {});
4596
+ // Smooth-scroll off-screen targets before cursor/ring. Playwright's
4597
+ // own click scroll is instant and reads as a teleport under --fast.
4598
+ await ensureLocatorInView(this, { instant: skipTheater() });
3232
4599
  const box = await this.boundingBox();
3233
4600
  const narrated = box ? await wasJustNarrated(page) : false;
3234
4601
  if (box && !narrated) {
@@ -3250,12 +4617,33 @@ function instrumentInteractionHighlighting(page, mem, slowMo, pacing, stubBefore
3250
4617
  const stubs = (stubBeforeRef && stubBeforeRef.current) || [];
3251
4618
  const cap = await captionForLocator(page, this, stubs, fallback);
3252
4619
  const stubIdx = await matchStubIndexForLocator(page, this, stubs);
3253
- await syncTodoDockAdvance(page, todoDockRef, stubIdx);
4620
+ const stub = stubIdx >= 0 ? stubs[stubIdx] : null;
4621
+ await syncTodoDockAdvance(page, todoDockRef, stubIdx, stub);
3254
4622
  clickTone = cap.tone;
3255
4623
  clickPoint = await moveCursorTo(page, box, cursorMs(600));
4624
+ if (stub) {
4625
+ await applyHighlightZoom(page, stub.selector, stub.zoom, stub.zoomOut);
4626
+ }
4627
+ demoHighlight(
4628
+ {
4629
+ selector: stub && stub.selector,
4630
+ label: cap.label,
4631
+ tone: cap.tone || (stub && stub.tone) || "planned",
4632
+ color: stub && stub.color,
4633
+ focus: !!(stub && stub.focus),
4634
+ zoom: stub && stub.zoom,
4635
+ zoomOut: stub && stub.zoomOut,
4636
+ gesture: stub && stub.gesture,
4637
+ weight: cap.weight,
4638
+ size: cap.size,
4639
+ },
4640
+ "click",
4641
+ );
3256
4642
  await showRing(page, box, cap.label, cap.tone, {
3257
4643
  size: cap.size,
3258
4644
  weight: cap.weight,
4645
+ selector: stub && stub.selector ? stub.selector : undefined,
4646
+ focus: !!(stub && stub.focus),
3259
4647
  });
3260
4648
  await dwellMatchedStub(page, this, stubs, pacing);
3261
4649
  // "pop for a few seconds" - Dan's own phrase, matching the
@@ -3552,6 +4940,52 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
3552
4940
  let result;
3553
4941
  /** Carry floating todo dock across steps (empty stubBefore must not wipe). */
3554
4942
  let lastTodoDock = undefined;
4943
+ /** Multi-todo: every dock keyed by todoId (or "_default"), survives navigation. */
4944
+ const todoDockById = new Map();
4945
+ const dockRegistryKey = (dock) =>
4946
+ dock && dock.id && String(dock.id).trim() ? String(dock.id).trim() : "_default";
4947
+ const rememberTodoDock = (dock, sync, parallel) => {
4948
+ if (sync === "clear") {
4949
+ todoDockById.clear();
4950
+ return;
4951
+ }
4952
+ if (sync === "set" && dock) {
4953
+ if (!parallel) todoDockById.clear();
4954
+ todoDockById.set(dockRegistryKey(dock), dock);
4955
+ }
4956
+ };
4957
+ const syncAllTodoDocks = async (opts) => {
4958
+ const docks = [...todoDockById.values()];
4959
+ const parallel = !!(opts && opts.parallel);
4960
+ if (!docks.length) {
4961
+ await page
4962
+ .evaluate(() => {
4963
+ if (window.__wgSyncTodos) window.__wgSyncTodos({ sync: "clear" });
4964
+ })
4965
+ .catch(() => {});
4966
+ return;
4967
+ }
4968
+ await page
4969
+ .evaluate(
4970
+ ({ list, parallel }) => {
4971
+ if (!window.__wgSyncTodos) return;
4972
+ for (let i = 0; i < list.length; i++) {
4973
+ const d = list[i];
4974
+ window.__wgSyncTodos({
4975
+ sync: "set",
4976
+ dock: d,
4977
+ todoId: (d && d.id) || null,
4978
+ pos: (d && d.pos) || null,
4979
+ // First dock replaces; later ones keep siblings when parallel.
4980
+ replace: !(parallel && i > 0),
4981
+ parallel: parallel && i > 0,
4982
+ });
4983
+ }
4984
+ },
4985
+ { list: docks, parallel },
4986
+ )
4987
+ .catch(() => {});
4988
+ };
3555
4989
  /** Carry device / touch fixture across steps (omit = keep, like todos). */
3556
4990
  let lastDevice = undefined;
3557
4991
  for (let i = 0; i < resolved.length; i++) {
@@ -3639,6 +5073,13 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
3639
5073
  stubBeforeRef.current = stubBeforePhase.highlights.map((h) =>
3640
5074
  applyHighlightStyleDefaults(h, flowStyle),
3641
5075
  );
5076
+ logStubPhaseFixtures("before", {
5077
+ ...stubBeforePhase,
5078
+ highlights: stubBeforeRef.current.map((h) => ({
5079
+ ...h,
5080
+ label: formatHighlightCaption(h),
5081
+ })),
5082
+ });
3642
5083
  const appliedBefore = applyTodoPhase(lastTodoDock, {
3643
5084
  todoSync: stubBeforePhase.todoSync,
3644
5085
  todoDock: stubBeforePhase.todoDock,
@@ -3647,6 +5088,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
3647
5088
  });
3648
5089
  lastTodoDock = appliedBefore.dock;
3649
5090
  todoDockRef.current = lastTodoDock;
5091
+ rememberTodoDock(appliedBefore.dock, appliedBefore.sync, !!stubBeforePhase.todoParallel);
3650
5092
  const appliedDeviceBefore = applyDevicePhase(lastDevice, {
3651
5093
  deviceSync: stubBeforePhase.deviceSync,
3652
5094
  device: stubBeforePhase.device,
@@ -3688,8 +5130,8 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
3688
5130
  });
3689
5131
  const paceSpeak = paceSpeakFields(pacing.demoPace, autoplayMs);
3690
5132
  if (process.env.WAYGRAPH_JSON !== "1") {
3691
- console.log(
3692
- "waygraph demo: step " +
5133
+ demoLog(
5134
+ "step " +
3693
5135
  (i + 1) +
3694
5136
  "/" +
3695
5137
  resolved.length +
@@ -3699,6 +5141,29 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
3699
5141
  paceSpeak.paceLabel +
3700
5142
  (r.episodeNumber ? " · episode " + r.episodeNumber : ""),
3701
5143
  );
5144
+ demoLog(
5145
+ " gate~" +
5146
+ autoplayMs +
5147
+ "ms skipTheater=" +
5148
+ !!pacing.skipTheater +
5149
+ " gatesFast=" +
5150
+ !!pacing.gatesFast,
5151
+ );
5152
+ demoLog(" " + summarizeDevice(lastDevice));
5153
+ const phaseZoom =
5154
+ stubBeforePhase.zoom != null && Number(stubBeforePhase.zoom) > 0
5155
+ ? Number(stubBeforePhase.zoom)
5156
+ : null;
5157
+ const phaseZoomOut =
5158
+ stubBeforePhase.zoomOut !== undefined ? !!stubBeforePhase.zoomOut : null;
5159
+ demoLog(
5160
+ " zoom=" +
5161
+ (phaseZoom != null ? phaseZoom + "x" : "default") +
5162
+ (phaseZoomOut != null ? " zoomOut=" + phaseZoomOut : "") +
5163
+ " (StubCtx / API; live chip top-left when ring zoom >1)",
5164
+ );
5165
+ logTodoDockFull(lastTodoDock, " before");
5166
+ demoLog(" todoSync=" + appliedBefore.sync);
3702
5167
  }
3703
5168
  await renderBeforeStep(page, {
3704
5169
  index: i,
@@ -3723,6 +5188,38 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
3723
5188
  todoId: lastTodoDock && lastTodoDock.id,
3724
5189
  ...paceSpeak,
3725
5190
  });
5191
+ // Re-paint dock(s) - renderBeforeStep syncs the active one; this enforces replace.
5192
+ await syncAllTodoDocks({ parallel: !!stubBeforePhase.todoParallel });
5193
+ if (process.env.WAYGRAPH_JSON !== "1") {
5194
+ const docks = await probeTodoDocksOnPage(page);
5195
+ if (!docks.length && lastTodoDock) {
5196
+ demoLog(" WARN todos authored but dock-dom empty (not painted?)");
5197
+ } else if (docks.length) {
5198
+ demoLog(
5199
+ " dock-dom " +
5200
+ docks
5201
+ .map(
5202
+ (d) =>
5203
+ d.key +
5204
+ "@" +
5205
+ d.pos +
5206
+ " " +
5207
+ d.left +
5208
+ "," +
5209
+ d.top +
5210
+ " " +
5211
+ d.w +
5212
+ "x" +
5213
+ d.h +
5214
+ (d.onScreen ? "" : " OFFSCREEN") +
5215
+ " n=" +
5216
+ d.n +
5217
+ (d.current ? ' cur="' + d.current + '"' : ""),
5218
+ )
5219
+ .join(" | "),
5220
+ );
5221
+ }
5222
+ }
3726
5223
  const edits = await gate();
3727
5224
  for (const k of requires) {
3728
5225
  if (edits[k.name] !== undefined) {
@@ -3882,11 +5379,20 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
3882
5379
  let afterTodoPos = overlayTodoPos;
3883
5380
  let appliedAfter = { dock: lastTodoDock, sync: "keep" };
3884
5381
  let appliedDeviceAfter = { device: lastDevice, sync: "keep" };
5382
+ let afterTodoParallel = false;
3885
5383
  if (hasAuthoredStubAfter(r.block, result, fixturesAfter)) {
3886
5384
  const afterPhase = await runStubPhase(r.block, "stubAfter", {
3887
5385
  out: result,
3888
5386
  fixtures: fixturesAfter,
3889
5387
  });
5388
+ logStubPhaseFixtures("after", {
5389
+ ...afterPhase,
5390
+ highlights: (afterPhase.highlights || []).map((h) => {
5391
+ const styled = applyHighlightStyleDefaults(h, r.highlightStyle);
5392
+ return { ...styled, label: formatHighlightCaption(styled) };
5393
+ }),
5394
+ });
5395
+ afterTodoParallel = !!afterPhase.todoParallel;
3890
5396
  appliedAfter = applyTodoPhase(lastTodoDock, {
3891
5397
  todoSync: afterPhase.todoSync,
3892
5398
  todoDock: afterPhase.todoDock,
@@ -3895,6 +5401,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
3895
5401
  });
3896
5402
  lastTodoDock = appliedAfter.dock;
3897
5403
  todoDockRef.current = lastTodoDock;
5404
+ rememberTodoDock(appliedAfter.dock, appliedAfter.sync, afterTodoParallel);
3898
5405
  appliedDeviceAfter = applyDevicePhase(lastDevice, {
3899
5406
  deviceSync: afterPhase.deviceSync,
3900
5407
  device: afterPhase.device,
@@ -3924,6 +5431,11 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
3924
5431
  size: styled.size,
3925
5432
  weight: styled.weight,
3926
5433
  zoom: styled.zoom,
5434
+ zoomOut: styled.zoomOut,
5435
+ focus: !!styled.focus,
5436
+ tone: styled.tone,
5437
+ color: styled.color,
5438
+ detail: styled.detail,
3927
5439
  };
3928
5440
  });
3929
5441
  } else {
@@ -3963,8 +5475,52 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
3963
5475
  ? true
3964
5476
  : undefined,
3965
5477
  });
5478
+ await syncAllTodoDocks({ parallel: afterTodoParallel });
3966
5479
  // Ring cycle / stubAfter may have advanced the dock - persist for next block.
3967
- if (todoDockRef.current) lastTodoDock = todoDockRef.current;
5480
+ if (todoDockRef.current) {
5481
+ lastTodoDock = todoDockRef.current;
5482
+ rememberTodoDock(todoDockRef.current, "set", afterTodoParallel);
5483
+ }
5484
+ if (process.env.WAYGRAPH_JSON !== "1") {
5485
+ demoLog(
5486
+ " after " +
5487
+ summarizeDevice(lastDevice) +
5488
+ " deviceSync=" +
5489
+ appliedDeviceAfter.sync,
5490
+ );
5491
+ logTodoDockFull(lastTodoDock, " after");
5492
+ demoLog(" after todoSync=" + appliedAfter.sync);
5493
+ const docksAfter = await probeTodoDocksOnPage(page);
5494
+ if (!docksAfter.length && lastTodoDock && appliedAfter.sync !== "clear") {
5495
+ demoLog(" WARN after: todos authored but dock-dom empty");
5496
+ } else if (docksAfter.length) {
5497
+ demoLog(
5498
+ " after dock-dom " +
5499
+ docksAfter
5500
+ .map(
5501
+ (d) =>
5502
+ d.key +
5503
+ "@" +
5504
+ d.pos +
5505
+ " " +
5506
+ d.left +
5507
+ "," +
5508
+ d.top +
5509
+ " " +
5510
+ d.w +
5511
+ "x" +
5512
+ d.h +
5513
+ (d.onScreen ? "" : " OFFSCREEN") +
5514
+ " n=" +
5515
+ d.n +
5516
+ (d.current ? ' cur="' + d.current + '"' : ""),
5517
+ )
5518
+ .join(" | "),
5519
+ );
5520
+ } else if (appliedAfter.sync === "clear") {
5521
+ demoLog(" after dock-dom cleared (expected)");
5522
+ }
5523
+ }
3968
5524
  await gate();
3969
5525
  }
3970
5526
  await teardownOverlay(page);
@@ -4338,12 +5894,12 @@ async function main() {
4338
5894
  // Without this, STEP mode always launched a different-looking browser than
4339
5895
  // headed Engine runs / waygraph-demo.mjs (which already resolve Flatpak).
4340
5896
  const executablePath = process.env.CHROME_PATH || process.env.CHROMIUM_PATH || undefined;
5897
+ // Maximized for interactive step, and for fixed video viewport so the
5898
+ // locked record size can actually fit (otherwise .webm gets grey pad).
4341
5899
  const browser = await chromium.launch({
4342
5900
  headless: !headed,
4343
5901
  slowMo,
4344
- // Maximized only for interactive step mode WITHOUT a fixed video viewport -
4345
- // recording uses a locked 16:9 viewport so .webm has no gray letterboxing.
4346
- args: step && !fixedVideoViewport ? ["--start-maximized"] : [],
5902
+ args: step || fixedVideoViewport ? ["--start-maximized"] : [],
4347
5903
  ...(executablePath ? { executablePath } : {}),
4348
5904
  });
4349
5905
  const contextOpts = fixedVideoViewport
@@ -4369,6 +5925,7 @@ async function main() {
4369
5925
  if (step) {
4370
5926
  const page = await context.newPage();
4371
5927
  pageForVideo = page;
5928
+ if (fixedVideoViewport) page.__wgVideoViewport = fixedVideoViewport;
4372
5929
  // Step 1's "before" panel used to sit over a blank about:blank page
4373
5930
  // until the human clicked Run - show the real destination first.
4374
5931
  if (baseURL) {
@@ -4378,6 +5935,7 @@ async function main() {
4378
5935
  } else if (jsonReport) {
4379
5936
  const page = await context.newPage();
4380
5937
  pageForVideo = page;
5938
+ if (fixedVideoViewport) page.__wgVideoViewport = fixedVideoViewport;
4381
5939
  if (baseURL) {
4382
5940
  await page.goto(baseURL).catch(() => {});
4383
5941
  }
@@ -4388,6 +5946,7 @@ async function main() {
4388
5946
  } else {
4389
5947
  const page = await context.newPage();
4390
5948
  pageForVideo = page;
5949
+ if (fixedVideoViewport) page.__wgVideoViewport = fixedVideoViewport;
4391
5950
  if (baseURL) {
4392
5951
  await page.goto(baseURL).catch(() => {});
4393
5952
  }
@@ -5128,6 +6687,10 @@ function applyRunFlags(flags, opts) {
5128
6687
  if (flags.nonHeadless) {
5129
6688
  process.env.WAYGRAPH_HEADED = "1";
5130
6689
  }
6690
+ // Any --video on demo/run: headless unless --non-headless already won.
6691
+ if (flags.video !== undefined && !flags.nonHeadless && process.env.WAYGRAPH_HEADED === undefined) {
6692
+ process.env.WAYGRAPH_HEADED = "0";
6693
+ }
5131
6694
  if (flags.baseUrl !== undefined) {
5132
6695
  process.env.WAYGRAPH_BASE_URL = flags.baseUrl;
5133
6696
  }
@@ -5199,14 +6762,17 @@ function resolveBaseUrl(projectDir) {
5199
6762
  return undefined;
5200
6763
  }
5201
6764
  /**
5202
- * Friendly demo defaults: STEP+headed on, BASE_URL from package/env/playwright.
6765
+ * Friendly demo defaults: STEP on; headed only for live watch (no --video).
6766
+ * --video / --auto-play-video => headless unless --non-headless.
5203
6767
  * Call after applyRunFlags so explicit flags already won.
5204
6768
  */
5205
6769
  function applyDemoDefaults(projectDir) {
5206
6770
  if (process.env.WAYGRAPH_STEP === undefined)
5207
6771
  process.env.WAYGRAPH_STEP = "1";
5208
6772
  if (process.env.WAYGRAPH_STEP === "1" && process.env.WAYGRAPH_HEADED === undefined) {
5209
- process.env.WAYGRAPH_HEADED = "1";
6773
+ // Recording locks headless - a live window fights fixed video viewport /
6774
+ // device shell centering. Watch live only with --non-headless.
6775
+ process.env.WAYGRAPH_HEADED = process.env.WAYGRAPH_VIDEO ? "0" : "1";
5210
6776
  }
5211
6777
  // Manual Next by default. --auto-next / --auto-play-video flip this.
5212
6778
  if (process.env.WAYGRAPH_AUTOPLAY === undefined) {
@@ -5327,8 +6893,9 @@ Primary (less is more):
5327
6893
  --todo-left|--todo-right Floating checklist dock side (also ctx.todoPos / WAYGRAPH_TODO_POS)
5328
6894
  --ff-expand Expand fastForwardComposeBlock inners as separate steps
5329
6895
  --ff-disabled Dispute: expand FF (alias --no-ff); blitz kept on those inners
5330
- --auto-play-video Unattended + recorded: --auto-next + --video (+ step); headless by default
6896
+ --auto-play-video Unattended + recorded: --auto-next + --video (+ step); headless
5331
6897
  --auto-play-video-head Same, but keep the browser visible (--non-headless)
6898
+ --video [dir] Record .webm (demo: headless unless --non-headless)
5332
6899
  --video-viewport WxH Recording size (default demo: 1920x1080)
5333
6900
  --title / --base-url
5334
6901
  waygraph run [--blocks <flow|file|spec>] Execute (no overlay unless --step)
@@ -5735,6 +7302,7 @@ Agents shipped: waygraph-planner, waygraph-author, waygraph-healer.
5735
7302
  applyDemoDefaults(proj);
5736
7303
  console.error(`waygraph demo: STEP=${process.env.WAYGRAPH_STEP === "1" ? "on" : "off"}` +
5737
7304
  ` AUTO_NEXT=${process.env.WAYGRAPH_AUTOPLAY === "1" ? "on" : "off"}` +
7305
+ ` HEADED=${process.env.WAYGRAPH_HEADED === "0" ? "off" : "on"}` +
5738
7306
  ` BASE_URL=${process.env.WAYGRAPH_BASE_URL ?? "(unset)"}` +
5739
7307
  (process.env.WAYGRAPH_VIDEO ? ` VIDEO=${process.env.WAYGRAPH_VIDEO}` : "") +
5740
7308
  (process.env.WAYGRAPH_TITLE ? ` TITLE=${JSON.stringify(process.env.WAYGRAPH_TITLE)}` : ""));