waygraph 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -12,8 +12,9 @@
12
12
  * "project" defaults to the current directory.
13
13
  * A project is any directory containing *.flow.ts files (typically under src/flows/).
14
14
  */
15
- import { existsSync, readdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
16
- import { resolve, relative, join, basename } from "node:path";
15
+ import { existsSync, readdirSync, readFileSync, writeFileSync, rmSync, cpSync } from "node:fs";
16
+ import { resolve, relative, join, basename, dirname } from "node:path";
17
+ import { fileURLToPath } from "node:url";
17
18
  import { spawn } from "node:child_process";
18
19
  import { MemPage } from "./mem-page.js";
19
20
  // ---------------------------------------------------------------------------
@@ -401,7 +402,15 @@ const RING_CSS =
401
402
  "max-width:min(92vw,640px);max-height:calc(100vh - 24px);overflow-y:auto;box-sizing:border-box;" +
402
403
  "background:rgba(20,10,40,.94);color:#fff;border-radius:14px;" +
403
404
  "padding:16px 20px;font:14px/1.4 system-ui,sans-serif;box-shadow:0 12px 30px rgba(0,0,0,.35);" +
404
- "opacity:0;transition:opacity .06s ease;}" +
405
+ // .06s was tuned back when the panel was removed and recreated on
406
+ // EVERY step - fast was the only way to avoid feeling laggy. Now that
407
+ // routine per-step updates reuse the same element (no fade at all - see
408
+ // the panel-reuse fix), this only ever fires for a genuinely fresh DOM
409
+ // (first load, or a real navigation - which is exactly what an episode
410
+ // boundary is). Slower and smoother reads as a real, comfortable
411
+ // transition instead of an instant pop - "eyes friendly," not laggy,
412
+ // since it no longer costs anything on the common case.
413
+ "opacity:0;transition:opacity .35s ease;}" +
405
414
  "#wg-panel.wg-in{opacity:1;}" +
406
415
  "#wg-panel .wg-auto{margin-top:10px;font:600 13px system-ui,sans-serif;color:#c9a6ff;}" +
407
416
  "#wg-panel .wg-autoplay-row{margin-top:8px;}" +
@@ -420,6 +429,32 @@ const RING_CSS =
420
429
  "#wg-panel .wg-narration{margin:0 0 12px;font:italic 14px/1.4 system-ui,sans-serif;color:#f0e8ff;}" +
421
430
  "#wg-progress{height:4px;background:#2a1650;border-radius:2px;margin:0 0 12px;overflow:hidden;}" +
422
431
  "#wg-progress-bar{height:100%;background:#7C3AED;border-radius:2px;transition:width .3s ease;}" +
432
+ // A real tab bar for episodes - the currently-active episode reads as
433
+ // active (filled underline, full-brightness text), every other episode
434
+ // reads as inactive (dimmed, no underline) - Dan's own ask: "if episode
435
+ // 1 is active, the episode 2 tab is inactive." Only rendered when the
436
+ // chain spec actually named real Flows (chainFlow tags each via
437
+ // BlockInfo - same gate the "Episode N:" heading already uses); an
438
+ // ad hoc block chain with no episodes shows no tab bar at all.
439
+ "#wg-episodes{display:flex;gap:4px;margin:0 0 10px;border-bottom:1px solid #3a2a60;}" +
440
+ "#wg-episodes .wg-ep-tab{padding:6px 14px 8px;font:600 12px system-ui,sans-serif;" +
441
+ "border-bottom:2px solid transparent;margin-bottom:-1px;white-space:nowrap;}" +
442
+ "#wg-episodes .wg-ep-current{color:#fff;border-bottom-color:#7C3AED;}" +
443
+ "#wg-episodes .wg-ep-done{color:#9a7ad1;}" +
444
+ "#wg-episodes .wg-ep-upcoming{color:#5a4a80;}" +
445
+ // A one-time, gentle signal for "you just entered this episode" - Dan:
446
+ // "i want a clear ui eyes friendly to remind me that i am on next
447
+ // episode." A slow outward glow, not an opacity blink/strobe (that's
448
+ // exactly the kind of flashing that already caused the original
449
+ // complaint) - plays once (no loop), 1.1s, only on the FIRST block of a
450
+ // newly-entered episode, not on every step within it.
451
+ "@keyframes wg-ep-enter{0%{box-shadow:0 0 0 0 rgba(124,58,237,.55);}" +
452
+ "100%{box-shadow:0 0 0 10px rgba(124,58,237,0);}}" +
453
+ "#wg-episodes .wg-ep-entered{border-radius:6px;animation:wg-ep-enter 1.1s ease-out;}" +
454
+ // The block breadcrumb below it is scoped to the CURRENT episode's own
455
+ // blocks only, not the whole chain - "in episode 2 it shows 2nd to the
456
+ // last... it should start from the first block" (Dan). An ad hoc block
457
+ // chain with no episodes still shows the whole chain here, unchanged.
423
458
  "#wg-modules{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 10px;}" +
424
459
  "#wg-modules .wg-mod{padding:3px 9px;border-radius:6px;font:600 11px system-ui,sans-serif;}" +
425
460
  "#wg-modules .wg-mod-done{background:#2a1650;color:#9a7ad1;}" +
@@ -435,12 +470,23 @@ const RING_CSS =
435
470
  "#wg-banner .wg-banner-tag{display:block;font-size:10px;font-weight:700;color:#c9a6ff;" +
436
471
  "letter-spacing:.05em;text-transform:uppercase;margin-bottom:2px;}" +
437
472
  "#wg-panel .wg-key{margin:8px 0;}" +
473
+ // A live, human-readable preview of what's about to be written for this
474
+ // MemKey - Dan: "i want pretty to exist in memkeys too... it shows what
475
+ // input its gonig to be written." Same global Pretty/JSON preference the
476
+ // result display already has (window.__wgPretty) - hidden entirely in
477
+ // JSON mode, since the raw textarea already speaks for itself there.
478
+ "#wg-panel .wg-key-pretty{margin-top:4px;font:12px/1.5 system-ui,sans-serif;" +
479
+ "background:#0f0620;border-radius:6px;padding:6px 8px;}" +
480
+ "#wg-panel .wg-key-pretty-row{color:#f0e8ff;}" +
481
+ "#wg-panel .wg-key-pretty-label{color:#9a7ad1;font-weight:600;}" +
438
482
  "#wg-panel label{display:block;font-size:12px;color:#d8c8ff;margin-bottom:3px;}" +
439
483
  "#wg-panel textarea{width:100%;box-sizing:border-box;background:#0f0620;color:#fff;" +
440
484
  "border:1px solid #4b2a80;border-radius:8px;padding:6px 8px;font:12px/1.3 monospace;resize:vertical;}" +
441
485
  "#wg-panel button{margin-top:10px;background:#7C3AED;color:#fff;border:none;border-radius:8px;" +
442
486
  "padding:8px 16px;font:600 13px system-ui,sans-serif;cursor:pointer;}" +
443
487
  "#wg-panel button:hover{background:#6b2fd6;}" +
488
+ "#wg-panel button:disabled{background:#4b2a80;cursor:default;opacity:.7;}" +
489
+ "#wg-panel textarea:disabled{opacity:.6;}" +
444
490
  "#wg-panel .wg-result{font:12px/1.4 monospace;background:#0f0620;border-radius:8px;padding:8px;" +
445
491
  "margin:8px 0;white-space:pre-wrap;}" +
446
492
  "#wg-panel .wg-result-pretty{font:600 14px/1.4 system-ui,sans-serif;}" +
@@ -452,8 +498,15 @@ const RING_CSS =
452
498
  "#wg-panel .wg-error-heading{color:#ff8fa0;}" +
453
499
  "#wg-panel .wg-error-msg{font:12px/1.5 monospace;background:#2a0f16;color:#ffc7cf;" +
454
500
  "border-radius:8px;padding:10px;margin:0 0 12px;white-space:pre-wrap;max-height:200px;overflow-y:auto;}" +
455
- "#wg-panel .wg-error-stop{background:#e0475c;}" +
456
- "#wg-panel .wg-error-stop:hover{background:#c33a4c;}";
501
+ "#wg-panel .wg-error-actions{display:flex;gap:8px;}" +
502
+ "#wg-panel .wg-error-stop{background:#e0475c;margin-top:0;}" +
503
+ "#wg-panel .wg-error-stop:hover{background:#c33a4c;}" +
504
+ "#wg-panel .wg-error-retry{background:#2a9d6f;margin-top:0;}" +
505
+ "#wg-panel .wg-error-retry:hover{background:#22855e;}" +
506
+ "#wg-panel.wg-expected{border:1.5px solid #e0a53e;}" +
507
+ "#wg-panel .wg-expected-heading{color:#ffcf7a;}" +
508
+ "#wg-panel .wg-expected-reason{font:600 12.5px/1.5 system-ui,sans-serif;background:#2a2410;" +
509
+ "color:#ffe6ae;border-radius:8px;padding:10px;margin:0 0 8px;}";
457
510
 
458
511
  // Purple dot favicon (matches the overlay's own theme color) - the tab-bar
459
512
  // signal that "this Chromium window is a waygraph run," even at a glance
@@ -668,15 +721,51 @@ async function renderBeforeStep(page, info) {
668
721
  .catch(() => {});
669
722
  await page
670
723
  .evaluate((info) => {
671
- const old = document.getElementById("wg-panel");
672
- if (old) old.remove();
673
- const panel = document.createElement("div");
674
- panel.id = "wg-panel";
724
+ // Reused in place, not removed + recreated, every step - the old
725
+ // remove()-then-fade-back-in cycle was a real flash on every single
726
+ // transition (Dan: "the appear and disappear... hurts eyes and
727
+ // dizzy"). Updating one persistent element's content has nothing to
728
+ // flash - it only ever fades in ONCE, the first time this page
729
+ // genuinely has no panel yet (a real navigation wiped the whole
730
+ // document, or this is the very first step).
731
+ let panel = document.getElementById("wg-panel");
732
+ const isNewPanel = !panel;
733
+ if (!panel) {
734
+ panel = document.createElement("div");
735
+ panel.id = "wg-panel";
736
+ }
675
737
  const pct = Math.round((info.index / info.total) * 100);
676
738
  const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
739
+ // A real tab bar, not a single text line - every episode this chain
740
+ // touches, the current one reading as active (Dan: "if episode 1 is
741
+ // active, the episode 2 tab is inactive"). Absent entirely for an ad
742
+ // hoc block chain with no named Flows.
743
+ const episodesHtml = info.allEpisodes && info.allEpisodes.length > 0
744
+ ? "<div id=\\"wg-episodes\\">" +
745
+ info.allEpisodes
746
+ .map((e) => {
747
+ const cls = e.episodeNumber < info.episodeNumber ? "wg-ep-done"
748
+ : e.episodeNumber === info.episodeNumber ? "wg-ep-current"
749
+ : "wg-ep-upcoming";
750
+ // The gentle one-time glow only plays on the tab actually
751
+ // being entered right now, not on every render of it.
752
+ const enteredCls = info.justEnteredEpisode && e.episodeNumber === info.episodeNumber
753
+ ? " wg-ep-entered"
754
+ : "";
755
+ return "<span class=\\"wg-ep-tab " + cls + enteredCls + "\\">Episode " + e.episodeNumber + ": " +
756
+ esc(e.episodeTitle || "") + "</span>";
757
+ })
758
+ .join("") +
759
+ "</div>"
760
+ : "";
761
+ // Scoped to THIS episode's own blocks (info.allNames is already the
762
+ // episode-local subset the caller computed) - moduleIndex is this
763
+ // step's position WITHIN that subset, not the whole chain's index -
764
+ // "in episode 2 it shows 2nd to the last... it should start from the
765
+ // first block" (Dan).
677
766
  const modulesHtml = info.allNames
678
767
  .map((name, idx) => {
679
- const cls = idx < info.index ? "wg-mod-done" : idx === info.index ? "wg-mod-current" : "wg-mod-upcoming";
768
+ const cls = idx < info.moduleIndex ? "wg-mod-done" : idx === info.moduleIndex ? "wg-mod-current" : "wg-mod-upcoming";
680
769
  const desc = info.allDescriptions && info.allDescriptions[idx];
681
770
  const titleAttr = desc ? " title=\\"" + esc(desc) + "\\"" : "";
682
771
  return "<span class=\\"wg-mod " + cls + "\\"" + titleAttr + ">" + name + "</span>";
@@ -685,13 +774,10 @@ async function renderBeforeStep(page, info) {
685
774
  const narrationHtml = info.description
686
775
  ? "<div class=\\"wg-narration\\">" + esc(info.description) + "</div>"
687
776
  : "";
688
- const episodeHtml = info.episodeNumber
689
- ? "<div class=\\"wg-episode\\">Episode " + info.episodeNumber + ": " + esc(info.episodeTitle || "") + "</div>"
690
- : "";
691
777
  let html =
692
778
  "<div id=\\"wg-progress\\"><div id=\\"wg-progress-bar\\" style=\\"width:" + pct + "%\\"></div></div>" +
779
+ episodesHtml +
693
780
  "<div id=\\"wg-modules\\">" + modulesHtml + "</div>" +
694
- episodeHtml +
695
781
  "<h3>Step " + (info.index + 1) + " / " + info.total + " - " + info.blockName + "</h3>" +
696
782
  narrationHtml;
697
783
  if (info.keys.length === 0) {
@@ -701,7 +787,9 @@ async function renderBeforeStep(page, info) {
701
787
  html +=
702
788
  "<div class=\\"wg-key\\"><label>" + k.name + "</label>" +
703
789
  "<textarea data-key=\\"" + k.name + "\\" rows=\\"2\\">" +
704
- k.value.replace(/</g, "&lt;") + "</textarea></div>";
790
+ k.value.replace(/</g, "&lt;") + "</textarea>" +
791
+ "<div class=\\"wg-key-pretty\\" data-pretty-for=\\"" + esc(k.name) + "\\"></div>" +
792
+ "</div>";
705
793
  }
706
794
  let autoNow = false;
707
795
  try {
@@ -718,8 +806,49 @@ async function renderBeforeStep(page, info) {
718
806
  "<div id=\\"wg-gate-auto\\" class=\\"wg-auto\\"" + (autoNow ? "" : " style=\\"display:none\\"") +
719
807
  ">Auto-advancing...</div>";
720
808
  panel.innerHTML = html;
721
- document.documentElement.appendChild(panel);
722
- requestAnimationFrame(() => panel.classList.add("wg-in"));
809
+ if (isNewPanel) {
810
+ document.documentElement.appendChild(panel);
811
+ requestAnimationFrame(() => panel.classList.add("wg-in"));
812
+ }
813
+ // Live, human-readable preview of what a MemKey's raw JSON will
814
+ // actually write - object fields become "Field: value" lines
815
+ // (camelCase split the same way state tags already are); a
816
+ // non-object payload (a plain string/number/array) is shown as-is.
817
+ // Hidden entirely in JSON mode - shares the SAME global toggle the
818
+ // result display's own Pretty/JSON buttons already set, not a
819
+ // second, separate preference.
820
+ const prettyMemValue = (raw) => {
821
+ let parsed;
822
+ try {
823
+ parsed = JSON.parse(raw);
824
+ } catch {
825
+ return "<em>(invalid JSON)</em>";
826
+ }
827
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
828
+ return esc(JSON.stringify(parsed));
829
+ }
830
+ return Object.entries(parsed)
831
+ .map(([field, value]) => {
832
+ const label = field.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/^./, (c) => c.toUpperCase());
833
+ return "<div class=\\"wg-key-pretty-row\\"><span class=\\"wg-key-pretty-label\\">" +
834
+ esc(label) + ":</span> " + esc(String(value)) + "</div>";
835
+ })
836
+ .join("");
837
+ };
838
+ const applyKeyPretty = () => {
839
+ const prettyOn = window.__wgPretty !== false;
840
+ panel.querySelectorAll("textarea[data-key]").forEach((ta) => {
841
+ const key = ta.getAttribute("data-key");
842
+ const previewEl = panel.querySelector(".wg-key-pretty[data-pretty-for=\\"" + key + "\\"]");
843
+ if (!previewEl) return;
844
+ previewEl.style.display = prettyOn ? "" : "none";
845
+ if (prettyOn) previewEl.innerHTML = prettyMemValue(ta.value);
846
+ });
847
+ };
848
+ applyKeyPretty();
849
+ panel.querySelectorAll("textarea[data-key]").forEach((ta) => {
850
+ ta.addEventListener("input", applyKeyPretty);
851
+ });
723
852
  const runBtn = document.getElementById("wg-run");
724
853
  if (runBtn) {
725
854
  runBtn.addEventListener("click", () => {
@@ -810,10 +939,13 @@ async function renderAfterStep(page, info) {
810
939
  }
811
940
  await page
812
941
  .evaluate((info) => {
813
- const old = document.getElementById("wg-panel");
814
- if (old) old.remove();
815
- const panel = document.createElement("div");
816
- panel.id = "wg-panel";
942
+ // Reused in place - see renderBeforeStep's own comment on this.
943
+ let panel = document.getElementById("wg-panel");
944
+ const isNewPanel = !panel;
945
+ if (!panel) {
946
+ panel = document.createElement("div");
947
+ panel.id = "wg-panel";
948
+ }
817
949
  const pct = Math.round(((info.index + 1) / info.total) * 100);
818
950
  const heading = info.isLast
819
951
  ? "End of chain - " + info.total + " / " + info.total + " blocks covered - " + info.blockName + " done"
@@ -822,7 +954,7 @@ async function renderAfterStep(page, info) {
822
954
  const escA = (s) => String(s).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
823
955
  const modulesHtml = info.allNames
824
956
  .map((name, idx) => {
825
- const cls = idx <= info.index ? "wg-mod-done" : "wg-mod-upcoming";
957
+ const cls = idx <= info.moduleIndex ? "wg-mod-done" : "wg-mod-upcoming";
826
958
  const desc = info.allDescriptions && info.allDescriptions[idx];
827
959
  const titleAttr = desc ? " title=\\"" + escA(desc) + "\\"" : "";
828
960
  return "<span class=\\"wg-mod " + cls + "\\"" + titleAttr + ">" + name + "</span>";
@@ -858,18 +990,30 @@ async function renderAfterStep(page, info) {
858
990
  "><button id=\\"wg-run\\">" + buttonLabel + "</button></div>" +
859
991
  "<div id=\\"wg-gate-auto\\" class=\\"wg-auto\\"" + (autoNow ? "" : " style=\\"display:none\\"") +
860
992
  ">Auto-advancing...</div>";
861
- const episodeHtml = info.episodeNumber
862
- ? "<div class=\\"wg-episode\\">Episode " + info.episodeNumber + ": " + escA(info.episodeTitle || "") + "</div>"
993
+ const episodesHtml = info.allEpisodes && info.allEpisodes.length > 0
994
+ ? "<div id=\\"wg-episodes\\">" +
995
+ info.allEpisodes
996
+ .map((e) => {
997
+ const cls = e.episodeNumber < info.episodeNumber ? "wg-ep-done"
998
+ : e.episodeNumber === info.episodeNumber ? "wg-ep-current"
999
+ : "wg-ep-upcoming";
1000
+ return "<span class=\\"wg-ep-tab " + cls + "\\">Episode " + e.episodeNumber + ": " +
1001
+ escA(e.episodeTitle || "") + "</span>";
1002
+ })
1003
+ .join("") +
1004
+ "</div>"
863
1005
  : "";
864
1006
  panel.innerHTML =
865
1007
  "<div id=\\"wg-progress\\"><div id=\\"wg-progress-bar\\" style=\\"width:" + pct + "%\\"></div></div>" +
1008
+ episodesHtml +
866
1009
  "<div id=\\"wg-modules\\">" + modulesHtml + "</div>" +
867
- episodeHtml +
868
1010
  "<h3>" + heading + "</h3>" +
869
1011
  resultHtml +
870
1012
  gateHtml;
871
- document.documentElement.appendChild(panel);
872
- requestAnimationFrame(() => panel.classList.add("wg-in"));
1013
+ if (isNewPanel) {
1014
+ document.documentElement.appendChild(panel);
1015
+ requestAnimationFrame(() => panel.classList.add("wg-in"));
1016
+ }
873
1017
  panel.querySelectorAll(".wg-toggle-btn").forEach((btn) => {
874
1018
  btn.addEventListener("click", () => {
875
1019
  const wantPretty = btn.getAttribute("data-mode") === "pretty";
@@ -910,30 +1054,63 @@ async function renderStepError(page, info) {
910
1054
  await installOverlay(page, info.title);
911
1055
  await page
912
1056
  .evaluate((info) => {
913
- const old = document.getElementById("wg-panel");
914
- if (old) old.remove();
915
- const panel = document.createElement("div");
916
- panel.id = "wg-panel";
917
- panel.className = "wg-error";
1057
+ // Reused in place - see renderBeforeStep's own comment on this.
1058
+ let panel = document.getElementById("wg-panel");
1059
+ const isNewPanel = !panel;
1060
+ if (!panel) {
1061
+ panel = document.createElement("div");
1062
+ panel.id = "wg-panel";
1063
+ }
1064
+ const isExpected = !!info.expectedFailureReason;
1065
+ const errClass = isExpected ? "wg-expected" : "wg-error";
1066
+ panel.className = isNewPanel ? errClass : errClass + " wg-in";
918
1067
  const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
1068
+ const episodesHtml = info.allEpisodes && info.allEpisodes.length > 0
1069
+ ? "<div id=\\"wg-episodes\\">" +
1070
+ info.allEpisodes
1071
+ .map((e) => {
1072
+ const cls = e.episodeNumber < info.episodeNumber ? "wg-ep-done"
1073
+ : e.episodeNumber === info.episodeNumber ? "wg-ep-current"
1074
+ : "wg-ep-upcoming";
1075
+ return "<span class=\\"wg-ep-tab " + cls + "\\">Episode " + e.episodeNumber + ": " +
1076
+ esc(e.episodeTitle || "") + "</span>";
1077
+ })
1078
+ .join("") +
1079
+ "</div>"
1080
+ : "";
919
1081
  const modulesHtml = info.allNames
920
1082
  .map((name, idx) => {
921
- const cls = idx === info.index ? "wg-mod-current" : idx < info.index ? "wg-mod-done" : "wg-mod-upcoming";
1083
+ const cls = idx === info.moduleIndex ? "wg-mod-current" : idx < info.moduleIndex ? "wg-mod-done" : "wg-mod-upcoming";
922
1084
  const desc = info.allDescriptions && info.allDescriptions[idx];
923
1085
  const titleAttr = desc ? " title=\\"" + esc(desc) + "\\"" : "";
924
1086
  return "<span class=\\"wg-mod " + cls + "\\"" + titleAttr + ">" + name + "</span>";
925
1087
  })
926
1088
  .join("");
1089
+ const retryLabel = info.episodeNumber !== undefined ? "Retry Episode " + info.episodeNumber : "Retry";
1090
+ const headingText = isExpected
1091
+ ? "Expected outcome - Step " + (info.index + 1) + " / " + info.total + " - " + info.blockName + " failed as intended"
1092
+ : "Step " + (info.index + 1) + " / " + info.total + " - " + info.blockName + " threw";
1093
+ const reasonHtml = isExpected
1094
+ ? "<div class=\\"wg-expected-reason\\">" + esc(info.expectedFailureReason) + "</div>"
1095
+ : "";
927
1096
  panel.innerHTML =
1097
+ episodesHtml +
928
1098
  "<div id=\\"wg-modules\\">" + modulesHtml + "</div>" +
929
- "<h3 class=\\"wg-error-heading\\">Step " + (info.index + 1) + " / " + info.total + " - " +
930
- info.blockName + " threw</h3>" +
1099
+ "<h3 class=\\"" + (isExpected ? "wg-expected-heading" : "wg-error-heading") + "\\">" + headingText + "</h3>" +
1100
+ reasonHtml +
931
1101
  "<div class=\\"wg-error-msg\\">" + esc(info.message) + "</div>" +
932
- "<button id=\\"wg-run\\" class=\\"wg-error-stop\\">Stop</button>";
933
- document.documentElement.appendChild(panel);
934
- requestAnimationFrame(() => panel.classList.add("wg-in"));
1102
+ "<div class=\\"wg-error-actions\\">" +
1103
+ "<button id=\\"wg-error-retry\\" class=\\"wg-error-retry\\">" + esc(retryLabel) + "</button>" +
1104
+ "<button id=\\"wg-run\\" class=\\"wg-error-stop\\">Stop</button>" +
1105
+ "</div>";
1106
+ if (isNewPanel) {
1107
+ document.documentElement.appendChild(panel);
1108
+ requestAnimationFrame(() => panel.classList.add("wg-in"));
1109
+ }
935
1110
  const runBtn = document.getElementById("wg-run");
936
1111
  if (runBtn) runBtn.addEventListener("click", () => window.__wgNext({}));
1112
+ const retryBtn = document.getElementById("wg-error-retry");
1113
+ if (retryBtn) retryBtn.addEventListener("click", () => window.__wgNext({ __wgRetry: "1" }));
937
1114
  }, info)
938
1115
  .catch(() => {});
939
1116
  }
@@ -1006,6 +1183,35 @@ function extractVerifyHighlights(block, resultTag) {
1006
1183
  * highlight then slowly input." One-time patch (idempotent - guarded so a
1007
1184
  * multi-step chain doesn't re-wrap an already-wrapped fill).
1008
1185
  */
1186
+ /**
1187
+ * Called once the gate resolves - manual click OR autoplay timeout, both
1188
+ * covered from here rather than duplicating this in the in-page click
1189
+ * handler - right before the Block's own act() actually starts. Dan: "i
1190
+ * want the button, step is running thing. so i wont be able to interrupt
1191
+ * the playwright automation" - the "Run this step" button used to sit
1192
+ * there still looking clickable for the entire multi-second duration a
1193
+ * real act()/observe() takes, inviting a confusing extra click (harmless -
1194
+ * __wgNext no-ops once already consumed - but looked live when it wasn't).
1195
+ * Disables the button and mem-key textareas, and swaps whichever gate
1196
+ * text was showing (manual button or "Auto-advancing...") to "Running...".
1197
+ */
1198
+ async function markStepRunning(page) {
1199
+ await page
1200
+ .evaluate(() => {
1201
+ const runBtn = document.getElementById("wg-run");
1202
+ if (runBtn) {
1203
+ runBtn.disabled = true;
1204
+ runBtn.textContent = "Running \\u25B6";
1205
+ }
1206
+ const autoEl = document.getElementById("wg-gate-auto");
1207
+ if (autoEl) autoEl.textContent = "Running...";
1208
+ document.querySelectorAll("#wg-panel textarea[data-key]").forEach((ta) => {
1209
+ ta.disabled = true;
1210
+ });
1211
+ })
1212
+ .catch(() => {});
1213
+ }
1214
+
1009
1215
  async function showRing(page, box, label) {
1010
1216
  await page
1011
1217
  .evaluate(
@@ -1204,7 +1410,16 @@ function instrumentInteractionHighlighting(page, mem, slowMo, pacing) {
1204
1410
  } catch {
1205
1411
  // best-effort - the real click below still runs either way
1206
1412
  }
1207
- if (clickPoint) await clickPulseAt(page, clickPoint.x, clickPoint.y);
1413
+ if (clickPoint) {
1414
+ await clickPulseAt(page, clickPoint.x, clickPoint.y);
1415
+ // clickPulseAt only triggers the CSS animation class - it doesn't
1416
+ // wait for it. Without a pause here, the real click (and any
1417
+ // resulting navigation/DOM change) fires while the ripple is still
1418
+ // mid-animation, sometimes cutting it off before it's even visible.
1419
+ // Wait out the same .5s the "@keyframes wg-pulse" rule uses, so the
1420
+ // mock click visually completes before the real one fires.
1421
+ await new Promise((res) => setTimeout(res, pacing.fast ? 0 : 500));
1422
+ }
1208
1423
  const result = await originalClick.call(this, options);
1209
1424
  await new Promise((res) => setTimeout(res, clickPostPop()));
1210
1425
  await hideRing(page);
@@ -1275,6 +1490,28 @@ function instrumentInteractionHighlighting(page, mem, slowMo, pacing) {
1275
1490
  }
1276
1491
  }
1277
1492
 
1493
+ /**
1494
+ * Clears cookies + storage and re-navigates to a known-fresh page. Shared by
1495
+ * the per-block session-reset check below and by the error panel's own
1496
+ * "Retry Episode" button - a retry needs the exact same clean slate a normal
1497
+ * episode boundary gets, not just re-running the failed block against
1498
+ * whatever broken/half-navigated state it left the page in.
1499
+ */
1500
+ async function resetPageState(context, page, baseURL) {
1501
+ await context.clearCookies().catch(() => {});
1502
+ await page
1503
+ .evaluate(() => {
1504
+ try {
1505
+ localStorage.clear();
1506
+ sessionStorage.clear();
1507
+ } catch {
1508
+ /* storage blocked (e.g. about:blank) - nothing to clear anyway */
1509
+ }
1510
+ })
1511
+ .catch(() => {});
1512
+ await page.goto(baseURL || "about:blank").catch(() => {});
1513
+ }
1514
+
1278
1515
  async function runStepMode(engine, start, end, context, page, mem, resolved, slowMo, title, fastBlockNames, clearSession, baseURL) {
1279
1516
  // A shared, mutable pacing knob the interaction patches read live (per
1280
1517
  // call, not once at setup) - flipped per-block below so one block (e.g.
@@ -1351,10 +1588,46 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
1351
1588
  };
1352
1589
  const allNames = resolved.map((r) => r.block.name);
1353
1590
  const allDescriptions = resolved.map((r) => r.block.description || "");
1591
+ // Deduped, in-order list of every real episode this chain touches - for
1592
+ // the episode tab bar. Ad hoc block segments (no named Flow) carry no
1593
+ // episodeNumber and never appear here, same gate the "Episode N:"
1594
+ // heading already used.
1595
+ const allEpisodes = [];
1596
+ for (const r of resolved) {
1597
+ if (r.episodeNumber && !allEpisodes.some((e) => e.episodeNumber === r.episodeNumber)) {
1598
+ allEpisodes.push({ episodeNumber: r.episodeNumber, episodeTitle: r.episodeTitle });
1599
+ }
1600
+ }
1354
1601
 
1355
1602
  let result;
1356
1603
  for (let i = 0; i < resolved.length; i++) {
1357
1604
  const r = resolved[i];
1605
+ // The block breadcrumb is scoped to THIS step's own episode, not the
1606
+ // whole chain - within episode 2, step 1 should read as "1st of 2,"
1607
+ // not "6th of 7." Falls back to the whole chain when this block has no
1608
+ // episode (an ad hoc chain with no named Flows), unchanged from before.
1609
+ const episodeBlockIndices = r.episodeNumber
1610
+ ? resolved.reduce((acc, x, idx) => {
1611
+ if (x.episodeNumber === r.episodeNumber) acc.push(idx);
1612
+ return acc;
1613
+ }, [])
1614
+ : resolved.map((_x, idx) => idx);
1615
+ const moduleNames = episodeBlockIndices.map((idx) => allNames[idx]);
1616
+ const moduleDescriptions = episodeBlockIndices.map((idx) => allDescriptions[idx]);
1617
+ const moduleIndex = episodeBlockIndices.indexOf(i);
1618
+ // Where a "Retry Episode" click (see the catch block below) rewinds to -
1619
+ // the current episode's own first block, or the whole chain's first
1620
+ // block when there's no real episode (episodeBlockIndices degrades to
1621
+ // the full resolved array in that case, so this is just 0).
1622
+ const episodeStartIndex = episodeBlockIndices[0];
1623
+ // True only on an episode's own first block - the one moment worth a
1624
+ // gentle "you're here now" signal, not every step inside it.
1625
+ const justEnteredEpisode = moduleIndex === 0 && r.episodeNumber !== undefined;
1626
+ // Seed THIS segment's own json payload now, not earlier - see the long
1627
+ // comment where seedMem closures are built, in main()'s resolution
1628
+ // loop. Must run before "keys" below reads mem.get() for the panel
1629
+ // display, and before this segment's own first block executes.
1630
+ if (r.seedMem) r.seedMem();
1358
1631
  // Reset session state before this block if EITHER: the blanket
1359
1632
  // WAYGRAPH_CLEAR_SESSION=1 override is set (a manual, always-on-after-
1360
1633
  // the-first-block stopgap for ad hoc block chains with no real Flow
@@ -1373,22 +1646,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
1373
1646
  // look fresh again - clearing storage alone doesn't change what's
1374
1647
  // still rendered on screen.
1375
1648
  if ((clearSession || r.resetSession) && i > 0) {
1376
- await context.clearCookies().catch(() => {});
1377
- await page
1378
- .evaluate(() => {
1379
- try {
1380
- localStorage.clear();
1381
- sessionStorage.clear();
1382
- } catch {
1383
- /* storage blocked (e.g. about:blank) - nothing to clear anyway */
1384
- }
1385
- })
1386
- .catch(() => {});
1387
- if (baseURL) {
1388
- await page.goto(baseURL).catch(() => {});
1389
- } else {
1390
- await page.goto("about:blank").catch(() => {});
1391
- }
1649
+ await resetPageState(context, page, baseURL);
1392
1650
  }
1393
1651
  pacing.fast = fastBlockNames.has(r.block.name);
1394
1652
  const requires = r.block.requires ?? [];
@@ -1407,8 +1665,11 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
1407
1665
  blockName: r.block.name,
1408
1666
  description: r.block.description || "",
1409
1667
  keys,
1410
- allNames,
1411
- allDescriptions,
1668
+ allNames: moduleNames,
1669
+ allDescriptions: moduleDescriptions,
1670
+ moduleIndex,
1671
+ allEpisodes,
1672
+ justEnteredEpisode,
1412
1673
  title,
1413
1674
  episodeNumber: r.episodeNumber,
1414
1675
  episodeTitle: r.episodeTitle,
@@ -1423,6 +1684,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
1423
1684
  }
1424
1685
  }
1425
1686
  }
1687
+ await markStepRunning(page);
1426
1688
  const stepFlow = engine.defineFlow([start, r.block, end]);
1427
1689
  // { closeOnFinish: false } makes Flow.run return { result, page }, not
1428
1690
  // the plain checkpoint - destructure it, don't treat the wrapper as the
@@ -1441,11 +1703,24 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
1441
1703
  total: resolved.length,
1442
1704
  blockName: r.block.name,
1443
1705
  message: err && err.message ? err.message : String(err),
1444
- allNames,
1445
- allDescriptions,
1706
+ allNames: moduleNames,
1707
+ allDescriptions: moduleDescriptions,
1708
+ moduleIndex,
1709
+ allEpisodes,
1446
1710
  title,
1711
+ episodeNumber: r.episodeNumber,
1712
+ episodeTitle: r.episodeTitle,
1713
+ expectedFailureReason: r.expectedFailureReason,
1447
1714
  });
1448
- await gate();
1715
+ const errEdits = await gate();
1716
+ if (errEdits && errEdits.__wgRetry) {
1717
+ // Same clean slate a normal episode boundary gets - not just
1718
+ // re-running the failed block against whatever broken/half-
1719
+ // navigated state it left the page in.
1720
+ await resetPageState(context, page, baseURL);
1721
+ i = episodeStartIndex - 1;
1722
+ continue;
1723
+ }
1449
1724
  throw err;
1450
1725
  }
1451
1726
  result = stepOutcome.result;
@@ -1458,8 +1733,10 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
1458
1733
  resultTag: JSON.stringify(result),
1459
1734
  highlights,
1460
1735
  isLast: i === resolved.length - 1,
1461
- allNames,
1462
- allDescriptions,
1736
+ allNames: moduleNames,
1737
+ allDescriptions: moduleDescriptions,
1738
+ moduleIndex,
1739
+ allEpisodes,
1463
1740
  title,
1464
1741
  episodeNumber: r.episodeNumber,
1465
1742
  episodeTitle: r.episodeTitle,
@@ -1484,7 +1761,11 @@ async function main() {
1484
1761
  if (bareRef) {
1485
1762
  const flow = await findFlow(projectDir, bareRef);
1486
1763
  if (flow && typeof flow.blocks === "function") {
1487
- resolved = flow.blocks().map((bi) => ({ block: bi.block, exportName: bi.name }));
1764
+ resolved = flow.blocks().map((bi) => ({
1765
+ block: bi.block,
1766
+ exportName: bi.name,
1767
+ expectedFailureReason: flow.expectedFailureReason,
1768
+ }));
1488
1769
  console.log(
1489
1770
  "waygraph: running existing flow \\"" + bareRef + "\\" - " +
1490
1771
  resolved.map((r) => r.block.name).join(" -> ") + " (" + resolved.length + " block" +
@@ -1517,37 +1798,62 @@ async function main() {
1517
1798
  // "Episode 2" labels on things that were never episodes to begin with.
1518
1799
  const flowMeta = [];
1519
1800
  let episodeCounter = 0;
1801
+ // seedMem is a closure, NOT called here - seeding every segment's own
1802
+ // json payload eagerly, all upfront during resolution, was a real bug:
1803
+ // mem is one shared object, so when two segments require the SAME key
1804
+ // (e.g. two segments both requiring LoginCreds, each with a DIFFERENT
1805
+ // payload - "login as standard_user then login as locked_out_user"),
1806
+ // the LAST segment resolved silently overwrote the first's value
1807
+ // before either segment had even started running - so the FIRST
1808
+ // segment's own blocks would run against the SECOND segment's
1809
+ // credentials. Deferred to execution time instead (runStepMode calls
1810
+ // this exactly once, right as each segment's first block is reached -
1811
+ // see resolved[i].seedMem below), same moment session-reset already
1812
+ // happens for the same reason.
1520
1813
  for (const seg of segments) {
1521
1814
  const flow = await findFlow(projectDir, seg.ref);
1522
1815
  if (flow && typeof flow.blocks === "function") {
1523
- seedMemForFlow(mem, flow.blocks(), seg.json, seg.ref);
1524
1816
  flows.push(flow);
1525
1817
  episodeCounter += 1;
1526
- flowMeta.push({ episodeNumber: episodeCounter, episodeTitle: flow.title || seg.ref });
1818
+ flowMeta.push({
1819
+ episodeNumber: episodeCounter,
1820
+ episodeTitle: flow.title || seg.ref,
1821
+ expectedFailureReason: flow.expectedFailureReason,
1822
+ seedMem: () => seedMemForFlow(mem, flow.blocks(), seg.json, seg.ref),
1823
+ });
1527
1824
  } else {
1528
1825
  const r = await findBlock(projectDir, seg.ref);
1529
- seedMemForBlock(mem, r, seg.json);
1530
1826
  flows.push(wrapEngine.defineFlow([start, r.block, end]));
1531
- flowMeta.push(null);
1827
+ flowMeta.push({
1828
+ episodeNumber: undefined,
1829
+ episodeTitle: undefined,
1830
+ seedMem: () => seedMemForBlock(mem, r, seg.json),
1831
+ });
1532
1832
  }
1533
1833
  }
1534
1834
  const combinedBlocks = chainFlow(...flows).blocks();
1535
1835
  let fi = 0;
1536
1836
  let remainingInFlow = flows[0].blocks().length;
1837
+ let isFirstOfSegment = true;
1537
1838
  resolved = combinedBlocks.map((bi) => {
1538
1839
  while (remainingInFlow === 0) {
1539
1840
  fi += 1;
1540
1841
  remainingInFlow = flows[fi].blocks().length;
1842
+ isFirstOfSegment = true;
1541
1843
  }
1542
- remainingInFlow -= 1;
1543
1844
  const meta = flowMeta[fi];
1544
- return {
1845
+ const entry = {
1545
1846
  block: bi.block,
1546
1847
  exportName: bi.name,
1547
1848
  resetSession: bi.resetSessionBefore === true,
1548
- episodeNumber: meta ? meta.episodeNumber : undefined,
1549
- episodeTitle: meta ? meta.episodeTitle : undefined,
1849
+ episodeNumber: meta.episodeNumber,
1850
+ episodeTitle: meta.episodeTitle,
1851
+ expectedFailureReason: meta.expectedFailureReason,
1852
+ seedMem: isFirstOfSegment ? meta.seedMem : undefined,
1550
1853
  };
1854
+ remainingInFlow -= 1;
1855
+ isFirstOfSegment = false;
1856
+ return entry;
1551
1857
  });
1552
1858
  console.log(
1553
1859
  "waygraph: chaining " + resolved.map((r) => r.block.name).join(" -> ") +
@@ -1586,6 +1892,19 @@ async function main() {
1586
1892
  const clearSession = process.env.WAYGRAPH_CLEAR_SESSION === "1";
1587
1893
  const engine = new Engine({ headless: !headed, slowMo });
1588
1894
  let result;
1895
+ // Non-interactive execution (no --step) runs the whole chain as one
1896
+ // composed flow with no per-block loop of its own to defer seeding
1897
+ // into - it can't support two segments needing DIFFERENT values for the
1898
+ // SAME mem key (e.g. two segments both requiring LoginCreds, each with
1899
+ // its own payload). Fall back to seeding every segment eagerly, upfront -
1900
+ // the same limitation "chain" always had; only --step's own runStepMode
1901
+ // loop actually needs (and correctly supports, via resolved[i].seedMem)
1902
+ // per-segment differentiated values.
1903
+ if (!step) {
1904
+ for (const r of resolved) {
1905
+ if (r.seedMem) r.seedMem();
1906
+ }
1907
+ }
1589
1908
  if (step || baseURL) {
1590
1909
  const { chromium } = await import("playwright");
1591
1910
  // --start-maximized (step mode only): viewport: null alone only made
@@ -1667,6 +1986,111 @@ async function runChain(projectDir, spec) {
1667
1986
  rmSync(scriptPath, { force: true });
1668
1987
  }
1669
1988
  }
1989
+ // ---------------------------------------------------------------------------
1990
+ // `try` - a bundled, self-contained showcase. No .flow.ts to write, no
1991
+ // scenario to design - copies a small real Flow (nav/action Blocks, Trait
1992
+ // verify, narrate()) into the target project and runs it in step mode
1993
+ // against a public demo site, so a curious dev sees the real overlay/panel
1994
+ // experience in under a minute, then gets pointed at the plain waygraph
1995
+ // source that produced it.
1996
+ // ---------------------------------------------------------------------------
1997
+ /** This package's own installed root - dist/cli.js -> .. */
1998
+ function packageRoot() {
1999
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..");
2000
+ }
2001
+ /**
2002
+ * Runs a throwaway probe script rooted at `projectDir` - the exact same
2003
+ * directory the real demo files get copied into, so it resolves "waygraph"
2004
+ * and "@playwright/test" exactly as they will - and actually launches (then
2005
+ * immediately closes) chromium, a real check rather than a guess at whether
2006
+ * the browser's installed. Returns null when everything's ready, or a
2007
+ * friendly, actionable message naming exactly what to install when it
2008
+ * isn't. Never leaves the probe script behind.
2009
+ */
2010
+ async function checkTryPrereqs(projectDir) {
2011
+ const tsxEsm = import.meta.resolve("tsx/esm");
2012
+ const probeScript = [
2013
+ "try {",
2014
+ ' await import("waygraph");',
2015
+ "} catch {",
2016
+ ' console.error("WAYGRAPH_TRY_MISSING_WAYGRAPH");',
2017
+ " process.exit(1);",
2018
+ "}",
2019
+ "let mod;",
2020
+ "try {",
2021
+ ' mod = await import("@playwright/test");',
2022
+ "} catch {",
2023
+ ' console.error("WAYGRAPH_TRY_MISSING_PLAYWRIGHT_TEST");',
2024
+ " process.exit(1);",
2025
+ "}",
2026
+ "try {",
2027
+ " const browser = await mod.chromium.launch({ headless: true });",
2028
+ " await browser.close();",
2029
+ "} catch (err) {",
2030
+ " if (String(err && err.message).includes(\"Executable doesn't exist\")) {",
2031
+ ' console.error("WAYGRAPH_TRY_MISSING_BROWSER");',
2032
+ " process.exit(1);",
2033
+ " }",
2034
+ " console.error(String((err && err.stack) || err));",
2035
+ " process.exit(1);",
2036
+ "}",
2037
+ ].join("\n");
2038
+ const probePath = join(projectDir, `.waygraph-try-probe-${process.pid}.mjs`);
2039
+ writeFileSync(probePath, probeScript);
2040
+ try {
2041
+ const result = await new Promise((res, rej) => {
2042
+ const child = spawn(process.execPath, ["--import", tsxEsm, probePath], {
2043
+ cwd: projectDir,
2044
+ stdio: ["ignore", "ignore", "pipe"],
2045
+ env: process.env,
2046
+ });
2047
+ let stderrBuf = "";
2048
+ child.stderr?.on("data", (d) => { stderrBuf += d.toString(); });
2049
+ child.on("error", rej);
2050
+ child.on("exit", (code) => res({ code: code ?? 1, stderr: stderrBuf }));
2051
+ });
2052
+ if (result.code === 0)
2053
+ return null;
2054
+ const stderr = result.stderr;
2055
+ if (stderr.includes("WAYGRAPH_TRY_MISSING_WAYGRAPH")) {
2056
+ return `"waygraph" isn't resolvable as a dependency from ${projectDir}.\nRun this first, inside that directory:\n npm install waygraph\n npm install -D @playwright/test\n npx playwright install chromium`;
2057
+ }
2058
+ if (stderr.includes("WAYGRAPH_TRY_MISSING_PLAYWRIGHT_TEST")) {
2059
+ return `"@playwright/test" isn't resolvable as a dependency from ${projectDir}.\nRun this first, inside that directory:\n npm install -D @playwright/test\n npx playwright install chromium`;
2060
+ }
2061
+ if (stderr.includes("WAYGRAPH_TRY_MISSING_BROWSER")) {
2062
+ return "Playwright's chromium browser isn't installed yet.\nRun this first:\n npx playwright install chromium";
2063
+ }
2064
+ return `couldn't verify prerequisites:\n${stderr.trim()}`;
2065
+ }
2066
+ finally {
2067
+ rmSync(probePath, { force: true });
2068
+ }
2069
+ }
2070
+ async function runTry(projectDir) {
2071
+ const problem = await checkTryPrereqs(projectDir);
2072
+ if (problem) {
2073
+ console.error(`waygraph try: not ready yet.\n${problem}`);
2074
+ process.exitCode = 1;
2075
+ return;
2076
+ }
2077
+ const destDir = join(projectDir, ".waygraph-quickstart");
2078
+ // Regenerated fresh every run (like chain's own .waygraph-chain-<pid>.mjs)
2079
+ // so an updated waygraph version's demo never goes stale - not meant to be
2080
+ // hand-edited and kept, just a real, readable copy to point people at.
2081
+ cpSync(join(packageRoot(), "templates", "quickstart"), destDir, { recursive: true });
2082
+ process.env.WAYGRAPH_HEADED ??= "1";
2083
+ process.env.WAYGRAPH_STEP ??= "1";
2084
+ process.env.WAYGRAPH_AUTOPLAY ??= "1";
2085
+ process.env.WAYGRAPH_BASE_URL ??= "https://www.saucedemo.com";
2086
+ await runChain(destDir, "quickstartFlow");
2087
+ if (!process.exitCode) {
2088
+ console.log("\nwaygraph try: that's plain waygraph - defineNavBlock, defineBlock, Trait, connect() - nothing hidden.\n" +
2089
+ "See the code that just ran:\n" +
2090
+ " " + join(destDir, "src", "flows", "quickstart.flow.ts") + "\n" +
2091
+ " " + join(destDir, "src", "blocks"));
2092
+ }
2093
+ }
1670
2094
  async function validateFlows(projectDir) {
1671
2095
  const files = discoverFlows(projectDir);
1672
2096
  const results = [];
@@ -1687,6 +2111,64 @@ async function validateFlows(projectDir) {
1687
2111
  return results;
1688
2112
  }
1689
2113
  // ---------------------------------------------------------------------------
2114
+ // `check` - secondary/complementary to the ActionPage @deprecated warning
2115
+ // (src/types.ts). That's the primary, always-on signal an editor shows the
2116
+ // instant a regular Block's act() calls a navigation method; this command
2117
+ // sweeps a whole project in one shot for contexts with no editor watching
2118
+ // (CI, an autonomous agent writing Block files without a language server).
2119
+ // See openspec/changes/nav-block-and-check/ for the full rationale.
2120
+ // ---------------------------------------------------------------------------
2121
+ function discoverBlocks(projectDir) {
2122
+ return walkDir(projectDir, /\.block\.ts$/);
2123
+ }
2124
+ function isBlockLike(val) {
2125
+ if (val === null || typeof val !== "object")
2126
+ return false;
2127
+ const obj = val;
2128
+ if (typeof obj.name !== "string")
2129
+ return false;
2130
+ const instruction = obj.instruction;
2131
+ return instruction !== null && typeof instruction === "object" && typeof instruction?.act === "function";
2132
+ }
2133
+ /** Set by `defineNavBlock` (non-enumerable) - see src/engine.ts. */
2134
+ function isNavBlockMarked(val) {
2135
+ return val.__waygraphKind === "nav";
2136
+ }
2137
+ const NAV_METHOD_CALLS = ["page.goto(", "page.reload(", "page.goBack(", "page.goForward("];
2138
+ /**
2139
+ * Walks every `*.block.ts` file under `projectDir`, imports each to find its
2140
+ * exported Blocks, and for any Block NOT built via `defineNavBlock`, scans
2141
+ * that file's own source text for a navigation call. Warning only - never
2142
+ * throws, never changes the caller's exit code on its own account (a Block
2143
+ * file that fails to import for unrelated reasons is silently skipped here;
2144
+ * `validate` is the command that reports import failures).
2145
+ */
2146
+ async function checkCommand(projectDir) {
2147
+ const files = discoverBlocks(projectDir);
2148
+ const warnings = [];
2149
+ for (const file of files) {
2150
+ let mod;
2151
+ try {
2152
+ mod = await importModule(file);
2153
+ }
2154
+ catch {
2155
+ continue;
2156
+ }
2157
+ let src;
2158
+ for (const [exportName, exported] of Object.entries(mod)) {
2159
+ if (!isBlockLike(exported))
2160
+ continue;
2161
+ if (isNavBlockMarked(exported))
2162
+ continue;
2163
+ src ??= readFileSync(file, "utf-8");
2164
+ if (NAV_METHOD_CALLS.some((needle) => src.includes(needle))) {
2165
+ warnings.push({ file, blockName: exported.name, exportName });
2166
+ }
2167
+ }
2168
+ }
2169
+ return warnings;
2170
+ }
2171
+ // ---------------------------------------------------------------------------
1690
2172
  // `run`
1691
2173
  // ---------------------------------------------------------------------------
1692
2174
  async function runFlow(projectDir, flowName) {
@@ -1725,6 +2207,28 @@ Usage:
1725
2207
  waygraph validate [project] Import and validate all flows
1726
2208
  waygraph run <flow> [project] Run a named flow
1727
2209
  waygraph chain <spec> [project] Run one or more Blocks by name, ad hoc
2210
+ waygraph check [project] Warn about navigation outside a NavBlock
2211
+ waygraph try [project] Quickstart showcase - no setup beyond
2212
+ "waygraph" + "@playwright/test" installed
2213
+ in [project] (default: cwd) and its
2214
+ chromium browser downloaded. Checks both
2215
+ for real (a real launch+close, not a
2216
+ guess) and tells you exactly what to run
2217
+ if either's missing, instead of a raw
2218
+ Playwright stack trace. If ready, copies a
2219
+ small real Flow (nav/action Blocks, Trait
2220
+ verify, narrate()) into
2221
+ [project]/.waygraph-quickstart/ and runs
2222
+ it in step mode against a public demo
2223
+ site - then prints the absolute path to
2224
+ that plain waygraph source, so whoever's
2225
+ watching can go read exactly how it works.
2226
+
2227
+ "check" is a secondary sweep, complementary to the always-on TypeScript
2228
+ warning every regular Block's act() already gets (a struck-through
2229
+ page.goto/reload/goBack/goForward, pointing at defineNavBlock) - useful
2230
+ where nothing is watching in an editor (CI, generated code). Warning
2231
+ only, never fails - see openspec/changes/nav-block-and-check/.
1728
2232
 
1729
2233
  "chain" needs no .flow.ts file - reference Blocks (by their own
1730
2234
  runtime .name, or their export identifier) straight from the command
@@ -1844,6 +2348,32 @@ async function main() {
1844
2348
  await runChain(proj, spec);
1845
2349
  break;
1846
2350
  }
2351
+ case "try": {
2352
+ const proj = resolve(args[1] ?? process.cwd());
2353
+ if (!existsSync(proj)) {
2354
+ console.error(`waygraph: no such directory: ${proj}`);
2355
+ process.exit(1);
2356
+ }
2357
+ await runTry(proj);
2358
+ break;
2359
+ }
2360
+ case "check": {
2361
+ const proj = resolve(args[1] ?? process.cwd());
2362
+ const warnings = await checkCommand(proj);
2363
+ if (warnings.length === 0) {
2364
+ console.log(`waygraph check: no navigation found outside NavBlocks under ${proj}`);
2365
+ }
2366
+ else {
2367
+ for (const w of warnings) {
2368
+ const rel = relative(proj, w.file);
2369
+ console.warn(`waygraph check: ${rel} (${w.blockName}) calls page.goto/reload/goBack/goForward outside a NavBlock - consider defineNavBlock instead`);
2370
+ }
2371
+ console.log(`waygraph check: ${warnings.length} warning${warnings.length === 1 ? "" : "s"}`);
2372
+ }
2373
+ // Warning only - never fails the command, matching the spec's
2374
+ // "never a build failure" requirement.
2375
+ break;
2376
+ }
1847
2377
  default:
1848
2378
  console.error(`waygraph: unknown command "${command}"`);
1849
2379
  process.exit(1);