@mantaq/viz 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,22 +18,25 @@ The viewer keeps a cursor on one state, starting at the initial one. The panel l
18
18
 
19
19
  Take a step with a panel button, by clicking a pulsing edge, or by clicking a state that exactly one next step reaches. An undetermined edge (`to: null`) offers each target its AST explanation names. Without one, it cannot be taken. A final state ends the walk. **Back** (or ←), **Reset** and the path list undo steps. The standalone page keeps the walk in the URL: `?step=` holds one edge id per step, plus `~target` for an undetermined edge.
20
20
 
21
- Nothing runs. The walk follows declarations, so guards are not evaluated, and a path may not happen at runtime. The logic is in `src/moves.ts`: `movesFrom`, `unhandledSends`, `decodePath`.
22
-
23
- ## Schema (v7)
24
-
25
- `VizDocument { schemaVersion: 7, machines: VizMachine[] }`, keys sorted, two-space indent. Types in `src/schema.ts`.
26
-
27
- | field | meaning |
28
- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
29
- | `machine.states[]` | `id` (region-prefixed `region.state`), `region`, `initial`, `final`, `effects` (`[{ name, sends }]` run on entry, registration order; `sends`: declared event ids, `null` = not declared), `coverage` (`null` = no run, else `{ hits }`) |
30
- | `edge.from` | a state id, `__initial__`, or `*` (`region.*` in a region): an `onAny` registration, meaning any non-final state. `*` is never in `states`; a `stay` from `*` has `to: "*"` |
31
- | `machine.events[]` | `id`, `internal`, `payloadType` (always `null`: erased at runtime) |
32
- | `edge.kind` | `initial` · `transition` · `effect` (state runs effects on entry) · `stay` (handled, no state change: a registration without `to`, or observed while fuzzing) |
33
- | `edge.to` | `null` = **undetermined**: only for actors that carry no `to` declarations |
34
- | `edge.origin` | `declared` (builder `to`, vouched for by the type checker) · `probe` (synthetic traversal, undeclared actors) · `fuzz` (observed only while fuzzing) |
35
- | `edge.explanation` | `null` (no handler: initial/effect, or AST not run) · `{ status: "analyzed", location, registration, branches, payloadReads, contextReads, contextWrites, readsActorState, comparisons, literals }` · `{ status: "not_analyzed", location, reason, snippet }` |
36
- | `machine.fuzz` | `null` or `{ seed, iterations, corpus: [{ steps, discovered }], errors: [{ from, event, message, steps }] }`; every sequence is shrunk and replayable from a fresh actor |
21
+ **Static walk** (standalone HTML, `mantaq-inspect --out`): nothing runs. The walk follows declarations, so guards are not evaluated, and a path may not happen at runtime. The logic is in `src/moves.ts`: `movesFrom`, `unhandledSends`, `decodePath`.
22
+
23
+ **Live** (the Vite plugin page, or `mountViewer(root, doc, { driver })`): steps run the real actor through `createDriver` from `@mantaq/inspect/drive`, so guards are evaluated. Timers advance by button, and **Back** replays the path from a fresh actor.
24
+
25
+ ## Schema (v8)
26
+
27
+ `VizDocument { schemaVersion: 8, machines: VizMachine[] }`, keys sorted, two-space indent. Types in `src/schema.ts`.
28
+
29
+ | field | meaning |
30
+ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
31
+ | `machine.states[]` | `id` (region-prefixed `region.state`), `region`, `initial`, `final`, `effects` (`[{ name, sends }]` run on entry, registration order; `sends`: declared event ids, `null` = not declared), `coverage` (`null` = no run, else `{ hits }`), `doc` (leading JSDoc of the `state(...)` declaration, `null` = undocumented or AST not run) |
32
+ | `edge.from` | a state id, `__initial__`, or `*` (`region.*` in a region): an `onAny` registration, meaning any non-final state. `*` is never in `states`; a `stay` from `*` has `to: "*"` |
33
+ | `machine.events[]` | `id`, `internal`, `payloadType` (always `null`: erased at runtime), `doc` (leading JSDoc of the `event(...)` declaration, `null` = none) |
34
+ | `edge.kind` | `initial` · `transition` · `effect` (state runs effects on entry) · `stay` (handled, no state change: a registration without `to`, or observed while fuzzing) |
35
+ | `edge.to` | `null` = **undetermined**: only for actors that carry no `to` declarations |
36
+ | `edge.origin` | `declared` (builder `to`, vouched for by the type checker) · `probe` (synthetic traversal, undeclared actors) · `fuzz` (observed only while fuzzing) |
37
+ | `edge.explanation` | `null` (no handler: initial/effect, or AST not run) · `{ status: "analyzed", location, registration, branches, payloadReads, contextReads, contextWrites, readsActorState, comparisons, literals }` · `{ status: "not_analyzed", location, reason, snippet }` |
38
+ | `edge.doc` | leading JSDoc of the `m.on` / `m.onAny` call behind the edge; `null` = undocumented, no handler, or AST not run |
39
+ | `machine.fuzz` | `null` or `{ seed, iterations, corpus: [{ steps, discovered }], errors: [{ from, event, message, steps }] }`; every sequence is shrunk and replayable from a fresh actor |
37
40
 
38
41
  Bump `VIZ_SCHEMA_VERSION` on any breaking change. The validator accepts only the current version.
39
42
 
@@ -1,4 +1,4 @@
1
- import { r as VizIssue, u as VizDocument } from "./validate-3D5LrtlE.mjs";
1
+ import { d as VizDriver, r as VizIssue, u as VizDocument } from "./validate-C_EXBtem.mjs";
2
2
  declare const STRUCTURE_BANNER = "Click a pulsing edge, or a state it leads to, to walk the machine. Edges come from each handler's declared `to` list and effects' `sends`; guards are not evaluated, so a path here may not happen at runtime.";
3
3
  declare const FUZZ_NOTE = "Green edges were observed while fuzzing but not declared. Fuzzing finds behaviour; it never proves an edge is impossible.";
4
4
  interface ViewerOptions {
@@ -6,8 +6,13 @@ interface ViewerOptions {
6
6
  heading?: "h1" | "h2" | "h3";
7
7
  /** Machine to show first; defaults to the first one. */
8
8
  machine?: string;
9
- /** Encoded steps to open on (from `?step=`), in the shown machine. */
9
+ /** Encoded steps to open on (from `?step=`), in the shown machine. With a `driver`, live steps replayed through it. */
10
10
  steps?: ReadonlyArray<string>;
11
+ /**
12
+ * Runs the machine for real: steps go to the driver and the cursor follows its state.
13
+ * The driver runs one machine, so the machine picker is hidden.
14
+ */
15
+ driver?: VizDriver;
11
16
  /** Called after every walk or machine change, e.g. to keep the URL shareable. */
12
17
  onNavigate?: (where: {
13
18
  machine: string;
@@ -1,4 +1,4 @@
1
- import { a as stateAfter, d as coveredByAny, f as isAnyState, i as movesFrom, n as decodePath, o as unhandledSends, p as scopeOf, r as encodeStep, s as parseViz, t as routingWarnings } from "./routing-BNJN5Oiu.mjs";
1
+ import { a as encodeStep, c as stateAfter, g as scopeOf, h as isAnyState, i as encodeLiveStep, l as unhandledSends, m as coveredByAny, n as decodeLiveSteps, o as movesFrom, r as decodePath, s as payloadSamples, t as routingWarnings, u as parseViz } from "./routing-xXYo05xv.mjs";
2
2
  import { Either } from "@mantaq/utils";
3
3
  import { Graph, layout } from "@dagrejs/dagre";
4
4
  // src/viewer/dom.ts
@@ -641,6 +641,7 @@ function edgePanel(doc, edge, machine) {
641
641
  ["source", isAnyState(edge.from) ? "any state (onAny)" : edge.from],
642
642
  ["event", edge.kind === "initial" ? "(initial)" : `${edge.event}${internal ? " (internal)" : ""}`],
643
643
  ["target", targetText$1(edge)],
644
+ ["doc", edge.doc ?? "none"],
644
645
  ["kind", edge.kind],
645
646
  ["origin", ORIGIN_LABELS[edge.origin]],
646
647
  ["emits", joined(edge.emits)],
@@ -665,6 +666,7 @@ function statePanel(doc, state, context) {
665
666
  "data-selected-state": state.id
666
667
  }, [html(doc, "h2", {}, [`State ${state.id}`]), list(doc, [
667
668
  ["id", state.id],
669
+ ["doc", state.doc ?? "none"],
668
670
  ["region", state.region ?? "(root)"],
669
671
  ["flags", joined(flags)],
670
672
  ["effects on entry", joined(state.effects.map((effect) => effect.sends === null ? effect.name : `${effect.name} (sends ${effect.sends.join(", ") || "nothing"})`))],
@@ -719,6 +721,12 @@ function button(doc, label, context) {
719
721
  element.addEventListener("click", context.onClick);
720
722
  return element;
721
723
  }
724
+ function docNote(doc, note) {
725
+ return note.text === null ? null : html(doc, "p", {
726
+ class: "doc",
727
+ "data-doc": note.scope
728
+ }, [note.text]);
729
+ }
722
730
  function targetText(move, target, current) {
723
731
  return target === current && move.edge.kind === "stay" ? "stays" : `→ ${target}`;
724
732
  }
@@ -728,9 +736,13 @@ function moveItem(doc, move, context) {
728
736
  if (move.targets.length === 0) return html(doc, "li", { "data-move": edge.id }, [
729
737
  code(doc, edge.event),
730
738
  via,
731
- html(doc, "span", { class: "muted" }, [" → ? target depends on payload or context"])
739
+ html(doc, "span", { class: "muted" }, [" → ? target depends on payload or context"]),
740
+ docNote(doc, {
741
+ text: edge.doc,
742
+ scope: "edge"
743
+ })
732
744
  ]);
733
- return html(doc, "li", { "data-move": edge.id }, move.targets.map((target) => button(doc, [
745
+ return html(doc, "li", { "data-move": edge.id }, [...move.targets.map((target) => button(doc, [
734
746
  code(doc, edge.event),
735
747
  ` ${targetText(move, target, context.current)}`,
736
748
  ...via ? [via] : []
@@ -743,7 +755,10 @@ function moveItem(doc, move, context) {
743
755
  "data-take": edge.id,
744
756
  "data-target": target
745
757
  }
746
- })));
758
+ })), docNote(doc, {
759
+ text: edge.doc,
760
+ scope: "edge"
761
+ })]);
747
762
  }
748
763
  function effectsSection(doc, machine, current) {
749
764
  const effects = machine.states.find((state) => state.id === current)?.effects ?? [];
@@ -805,39 +820,206 @@ function pathSection(doc, context) {
805
820
  attrs: { "data-rewind": "0" }
806
821
  })]), ...items])]);
807
822
  }
823
+ function describeStep(step) {
824
+ if (step.kind === "advance") return `advance ${step.ms} ms`;
825
+ return step.payload === null ? step.event : `${step.event} ${JSON.stringify(step.payload)}`;
826
+ }
827
+ /** The clock starts at 0 and only `advance` steps move it. */
828
+ function elapsed(steps) {
829
+ return steps.reduce((sum, step) => sum + (step.kind === "advance" ? step.ms : 0), 0);
830
+ }
831
+ function timersSection(doc, context) {
832
+ const now = elapsed(context.state.steps);
833
+ const input = html(doc, "input", {
834
+ type: "number",
835
+ min: "0",
836
+ value: "100",
837
+ "aria-label": "milliseconds",
838
+ "data-advance-ms": ""
839
+ });
840
+ const problem = html(doc, "p", {
841
+ class: "explore-warning",
842
+ role: "alert",
843
+ hidden: ""
844
+ }, ["Enter a number of milliseconds, 0 or more."]);
845
+ const advance = () => {
846
+ const ms = Number(input.value);
847
+ const valid = input.value.trim() !== "" && Number.isFinite(ms) && ms >= 0;
848
+ problem.toggleAttribute("hidden", valid);
849
+ if (valid) context.send({
850
+ kind: "advance",
851
+ ms
852
+ });
853
+ };
854
+ return html(doc, "div", { class: "explore-time" }, [
855
+ html(doc, "h3", {}, ["Time"]),
856
+ html(doc, "ul", { "data-group": "timers" }, context.state.timers.map((timer) => {
857
+ const remaining = Math.max(0, timer.deadline - now);
858
+ return html(doc, "li", { "data-timer": timer.id }, [button(doc, [`advance ${remaining} ms → ${timer.eventName ?? timer.id}`], {
859
+ onClick: () => context.send({
860
+ kind: "advance",
861
+ ms: remaining
862
+ }),
863
+ attrs: { "data-advance-timer": timer.id }
864
+ })]);
865
+ })),
866
+ context.state.timers.length === 0 ? html(doc, "p", {
867
+ class: "muted",
868
+ "data-no-timers": ""
869
+ }, ["No timers pending."]) : null,
870
+ html(doc, "div", {}, [
871
+ input,
872
+ " ms ",
873
+ button(doc, ["advance"], {
874
+ onClick: advance,
875
+ attrs: { "data-advance": "" }
876
+ })
877
+ ]),
878
+ problem
879
+ ]);
880
+ }
881
+ function liveMovesSection(doc, machine, context) {
882
+ const current = context.state.state;
883
+ if (context.final) return html(doc, "p", {
884
+ class: "explore-end explore-done",
885
+ role: "status"
886
+ }, [html(doc, "strong", {}, ["Done."]), ` ${current} is final: the actor has stopped.`]);
887
+ const handled = new Set(movesFrom(machine, current).map((move) => move.edge.event));
888
+ const inputs = machine.events.filter((event) => !event.internal).map((event) => event.id);
889
+ const events = [.../* @__PURE__ */ new Set([...handled, ...inputs])].sort();
890
+ const json = html(doc, "textarea", {
891
+ rows: "2",
892
+ placeholder: "payload as JSON (empty: the sample, or none)",
893
+ "aria-label": "payload JSON",
894
+ "data-payload-json": ""
895
+ });
896
+ const problem = html(doc, "p", {
897
+ class: "explore-warning",
898
+ role: "alert",
899
+ hidden: ""
900
+ }, ["Payload is not valid JSON."]);
901
+ const payloadFor = (sample) => {
902
+ try {
903
+ if (json.value.trim() !== "") return { value: JSON.parse(json.value) };
904
+ return { value: sample === null || sample.value === "" ? null : JSON.parse(sample.value) };
905
+ } catch {
906
+ return null;
907
+ }
908
+ };
909
+ return html(doc, "div", { class: "explore-moves" }, [
910
+ html(doc, "h3", {}, ["Send"]),
911
+ json,
912
+ problem,
913
+ html(doc, "ul", { "data-group": "live" }, events.map((event) => {
914
+ const samples = payloadSamples(machine, event);
915
+ const sample = samples.length === 0 ? null : html(doc, "select", {
916
+ "aria-label": `${event} payload sample`,
917
+ "data-sample": event
918
+ }, [html(doc, "option", { value: "" }, ["no payload"]), ...samples.map((each) => html(doc, "option", { value: JSON.stringify(each) }, [JSON.stringify(each)]))]);
919
+ return html(doc, "li", { "data-send-item": event }, [
920
+ button(doc, [code(doc, event)], {
921
+ onClick: () => {
922
+ const payload = payloadFor(sample);
923
+ problem.toggleAttribute("hidden", payload !== null);
924
+ if (payload !== null) context.send({
925
+ kind: "event",
926
+ event,
927
+ payload: payload.value
928
+ });
929
+ },
930
+ attrs: { "data-send": event }
931
+ }),
932
+ sample,
933
+ handled.has(event) ? null : html(doc, "span", {
934
+ class: "muted",
935
+ "data-ignored": ""
936
+ }, [` ignored in ${current}`])
937
+ ]);
938
+ }))
939
+ ]);
940
+ }
941
+ function outcomeText(last) {
942
+ if (last.outcome === "transition") return ` ${last.from} → ${last.to}`;
943
+ if (last.outcome === "stay") return ` handled in ${last.from}: stays`;
944
+ return ` ignored in ${last.from}: no handler`;
945
+ }
946
+ function liveResultSection(doc, state) {
947
+ const { last } = state;
948
+ return html(doc, "div", { class: "explore-live" }, [last === null ? html(doc, "p", { class: "muted" }, ["Nothing has run yet."]) : html(doc, "div", {
949
+ class: "explore-outcome",
950
+ "data-outcome": last.outcome
951
+ }, [
952
+ html(doc, "p", {}, [code(doc, last.event), outcomeText(last)]),
953
+ last.effects.length === 0 ? null : html(doc, "p", { "data-live-effects": "" }, ["Effects run: ", ...last.effects.flatMap((name, index) => [index === 0 ? "" : ", ", code(doc, name)])]),
954
+ last.error === null ? null : html(doc, "p", {
955
+ class: "explore-warning",
956
+ role: "alert",
957
+ "data-live-error": ""
958
+ }, [`Handler threw: ${last.error}`])
959
+ ]), html(doc, "p", { "data-live-context": "" }, ["Context: ", code(doc, JSON.stringify(state.context))])]);
960
+ }
961
+ function livePathSection(doc, context, state) {
962
+ return html(doc, "div", { class: "explore-path" }, [html(doc, "h3", {}, ["Path"]), html(doc, "ol", {}, [html(doc, "li", { "aria-current": state.steps.length === 0 ? "step" : null }, [button(doc, ["start"], {
963
+ onClick: () => context.rewind(0),
964
+ attrs: { "data-rewind": "0" }
965
+ })]), ...state.steps.map((step, index) => html(doc, "li", { "aria-current": index === state.steps.length - 1 ? "step" : null }, [button(doc, [describeStep(step)], {
966
+ onClick: () => context.rewind(index + 1),
967
+ attrs: { "data-rewind": String(index + 1) }
968
+ })]))])]);
969
+ }
970
+ function nowSection(doc, machine, context) {
971
+ const { current, final } = context;
972
+ return [html(doc, "p", { class: "explore-now" }, [
973
+ "In ",
974
+ html(doc, "strong", {}, [current]),
975
+ final ? " (final)" : ""
976
+ ]), docNote(doc, {
977
+ text: machine.states.find((state) => state.id === current)?.doc ?? null,
978
+ scope: "state"
979
+ })];
980
+ }
808
981
  /** Side-panel section: where the walk is, what can happen next, and how it got here. */
809
982
  function explorePanel(doc, context) {
810
- const { machine, path } = context;
811
- const current = stateAfter(machine, path) ?? "";
983
+ const { machine, path, live } = context;
984
+ const current = live?.state.state ?? stateAfter(machine, path) ?? "";
812
985
  const final = machine.states.find((state) => state.id === current)?.final === true;
813
- const moves = movesFrom(machine, current);
986
+ const length = live?.state.steps.length ?? path.length;
814
987
  return html(doc, "section", {
815
988
  class: "explore",
816
989
  "data-current": current,
817
990
  "data-done": final ? "" : null
818
991
  }, [
819
992
  html(doc, "h2", {}, ["Explore"]),
820
- html(doc, "p", { class: "explore-now" }, [
821
- "In ",
822
- html(doc, "strong", {}, [current]),
823
- final ? " (final)" : ""
824
- ]),
825
- effectsSection(doc, machine, current),
826
- movesSection(doc, moves, {
993
+ ...nowSection(doc, machine, {
994
+ current,
995
+ final
996
+ }),
997
+ ...live ? [
998
+ liveResultSection(doc, live.state),
999
+ liveMovesSection(doc, machine, {
1000
+ state: live.state,
1001
+ send: live.send,
1002
+ final
1003
+ }),
1004
+ final ? null : timersSection(doc, {
1005
+ state: live.state,
1006
+ send: live.send
1007
+ })
1008
+ ] : [effectsSection(doc, machine, current), movesSection(doc, movesFrom(machine, current), {
827
1009
  current,
828
1010
  final,
829
1011
  take: context.take
830
- }),
1012
+ })],
831
1013
  html(doc, "div", { class: "explore-controls" }, [button(doc, ["Back"], {
832
- onClick: () => context.rewind(path.length - 1),
833
- disabled: path.length === 0,
1014
+ onClick: () => context.rewind(length - 1),
1015
+ disabled: length === 0,
834
1016
  attrs: { "data-explore-action": "back" }
835
1017
  }), button(doc, ["Reset"], {
836
1018
  onClick: () => context.rewind(0),
837
- disabled: path.length === 0,
1019
+ disabled: length === 0,
838
1020
  attrs: { "data-explore-action": "reset" }
839
1021
  })]),
840
- pathSection(doc, context)
1022
+ live ? livePathSection(doc, context, live.state) : pathSection(doc, context)
841
1023
  ]);
842
1024
  }
843
1025
  /**
@@ -845,7 +1027,7 @@ function explorePanel(doc, context) {
845
1027
  * a next-step edge with one target, or a state exactly one next step reaches.
846
1028
  */
847
1029
  function clickStep(machine, context) {
848
- const current = stateAfter(machine, context.path);
1030
+ const current = context.live ?? stateAfter(machine, context.path);
849
1031
  if (current === null) return null;
850
1032
  const { selection } = context;
851
1033
  const steps = movesFrom(machine, current).flatMap((move) => move.targets.map((to) => ({
@@ -858,7 +1040,7 @@ function clickStep(machine, context) {
858
1040
  /** Mark the walk on the drawn graph: current state, path taken, possible next steps. */
859
1041
  function markExplore(root, context) {
860
1042
  const { machine, path } = context;
861
- const current = stateAfter(machine, path);
1043
+ const current = context.live ?? stateAfter(machine, path);
862
1044
  const moves = current === null ? [] : movesFrom(machine, current);
863
1045
  const next = new Set(moves.filter((move) => move.targets.length > 0).map((move) => move.edge.id));
864
1046
  const reachable = new Set(moves.flatMap((move) => move.targets).filter((to) => to !== current));
@@ -877,6 +1059,7 @@ function markExplore(root, context) {
877
1059
  }
878
1060
  // src/viewer/viewer.ts
879
1061
  const STRUCTURE_BANNER = "Click a pulsing edge, or a state it leads to, to walk the machine. Edges come from each handler's declared `to` list and effects' `sends`; guards are not evaluated, so a path here may not happen at runtime.";
1062
+ const LIVE_BANNER = "Live: the real machine runs. Send an input and the cursor follows its actual state; guards and effects run.";
880
1063
  const FUZZ_NOTE = "Green edges were observed while fuzzing but not declared. Fuzzing finds behaviour; it never proves an edge is impossible.";
881
1064
  function effectiveMode(view, machine) {
882
1065
  const modes = availableModes(machine);
@@ -933,8 +1116,11 @@ function mountViewer(root, viz, options = {}) {
933
1116
  machineId: first?.id ?? "",
934
1117
  selection: null,
935
1118
  coverage: null,
936
- path: first === void 0 ? [] : decodePath(first, options.steps ?? [])
1119
+ path: first === void 0 || options.driver !== void 0 ? [] : decodePath(first, options.steps ?? []),
1120
+ live: null
937
1121
  };
1122
+ const { driver } = options;
1123
+ if (driver !== void 0) view.live = replay(driver, decodeLiveSteps(options.steps ?? []));
938
1124
  let back = () => void 0;
939
1125
  root.addEventListener("keydown", (event) => {
940
1126
  if (!("key" in event) || event.target instanceof HTMLSelectElement) return;
@@ -959,42 +1145,71 @@ function mountViewer(root, viz, options = {}) {
959
1145
  machine,
960
1146
  path: view.path,
961
1147
  take,
962
- rewind
1148
+ rewind,
1149
+ ...view.live === null ? {} : { live: {
1150
+ state: view.live,
1151
+ send
1152
+ } }
963
1153
  }), renderPanel(doc, machine, {
964
1154
  selection: view.selection,
965
1155
  select
966
1156
  }));
967
1157
  markExplore(root, {
968
1158
  machine,
969
- path: view.path
1159
+ path: view.path,
1160
+ ...liveState(view)
970
1161
  });
971
1162
  markSelection(root, view.selection);
972
1163
  };
973
1164
  const walked = () => {
1165
+ const steps = view.live === null ? view.path.map((step) => encodeStep(machine, step)) : view.live.steps.map(encodeLiveStep);
974
1166
  options.onNavigate?.({
975
1167
  machine: machine.id,
976
- steps: view.path.map((step) => encodeStep(machine, step))
1168
+ steps
977
1169
  });
978
1170
  refresh();
979
1171
  };
1172
+ const send = (step) => {
1173
+ if (driver === void 0) return;
1174
+ view.live = stepSafely(driver, {
1175
+ before: view.live,
1176
+ step
1177
+ });
1178
+ walked();
1179
+ };
980
1180
  const take = (step) => {
1181
+ if (driver !== void 0) {
1182
+ const event = machine.edges.find((edge) => edge.id === step.edge)?.event;
1183
+ if (event !== void 0) send({
1184
+ kind: "event",
1185
+ event,
1186
+ payload: null
1187
+ });
1188
+ return;
1189
+ }
981
1190
  view.path = [...view.path, step];
982
1191
  walked();
983
1192
  };
984
1193
  const rewind = (length) => {
1194
+ if (driver !== void 0 && view.live !== null) {
1195
+ view.live = replay(driver, view.live.steps.slice(0, Math.max(0, length)));
1196
+ walked();
1197
+ return;
1198
+ }
985
1199
  view.path = view.path.slice(0, Math.max(0, length));
986
1200
  walked();
987
1201
  };
988
1202
  const select = (selection) => {
989
1203
  const step = clickStep(machine, {
990
1204
  path: view.path,
991
- selection
1205
+ selection,
1206
+ ...liveState(view)
992
1207
  });
993
1208
  view.selection = selection;
994
1209
  if (step === null) refresh();
995
1210
  else take(step);
996
1211
  };
997
- back = () => rewind(view.path.length - 1);
1212
+ back = () => rewind((view.live?.steps.length ?? view.path.length) - 1);
998
1213
  root.append(headerOf(doc, {
999
1214
  viz,
1000
1215
  machine,
@@ -1002,11 +1217,12 @@ function mountViewer(root, viz, options = {}) {
1002
1217
  heading: options.heading ?? "h1",
1003
1218
  mode,
1004
1219
  render,
1005
- onNavigate: options.onNavigate
1220
+ onNavigate: options.onNavigate,
1221
+ live: driver !== void 0
1006
1222
  }), html(doc, "p", {
1007
1223
  class: "banner",
1008
1224
  role: "note"
1009
- }, [banner(machine)]), legend(doc, {
1225
+ }, [driver === void 0 ? banner(machine) : LIVE_BANNER]), legend(doc, {
1010
1226
  mode,
1011
1227
  machine
1012
1228
  }), html(doc, "div", { class: "viz-main" }, [html(doc, "div", { class: "canvas" }, [renderGraph(doc, machine, {
@@ -1017,11 +1233,32 @@ function mountViewer(root, viz, options = {}) {
1017
1233
  }
1018
1234
  render();
1019
1235
  }
1236
+ /** A driver that throws leaves the state where it was and shows the message as a handler error. */
1237
+ function stepSafely(driver, context) {
1238
+ const { before, step } = context;
1239
+ try {
1240
+ return driver.step(step);
1241
+ } catch (error) {
1242
+ if (before === null) throw error;
1243
+ return {
1244
+ ...before,
1245
+ last: {
1246
+ from: before.state,
1247
+ event: step.kind === "event" ? step.event : step.kind,
1248
+ to: before.state,
1249
+ outcome: "stay",
1250
+ effects: [],
1251
+ context: before.context,
1252
+ error: error instanceof Error ? error.message : String(error)
1253
+ }
1254
+ };
1255
+ }
1256
+ }
1020
1257
  function headerOf(doc, context) {
1021
1258
  const { machine, view, render } = context;
1022
1259
  return html(doc, "header", { class: "viz-header" }, [
1023
1260
  html(doc, context.heading, {}, [`Viz: ${machine.id}`]),
1024
- machineSelect(doc, context.viz, {
1261
+ context.live ? null : machineSelect(doc, context.viz, {
1025
1262
  view,
1026
1263
  onChange: (id) => {
1027
1264
  view.machineId = id;
@@ -1051,6 +1288,21 @@ function markSelection(root, selection) {
1051
1288
  const attribute = selection?.kind === "edge" ? "edgeId" : "nodeId";
1052
1289
  for (const element of root.querySelectorAll(".graph g")) element.classList.toggle("selected", selection !== null && element.dataset[attribute] === selection.id);
1053
1290
  }
1291
+ function liveState(view) {
1292
+ return view.live === null ? {} : { live: view.live.state };
1293
+ }
1294
+ /** Back is reset plus replay: the driver keeps no history. Stops at the first step the driver throws on. */
1295
+ function replay(driver, steps) {
1296
+ let live = driver.reset();
1297
+ for (const step of steps) {
1298
+ live = stepSafely(driver, {
1299
+ before: live,
1300
+ step
1301
+ });
1302
+ if (live.last?.error) return live;
1303
+ }
1304
+ return live;
1305
+ }
1054
1306
  // src/viewer/embed.ts
1055
1307
  /**
1056
1308
  * Browser entry for embedding the viewer in another page (e.g. the docs site).
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { C as VizState, D as scopeOf, E as isAnyState, S as VizMachine, T as coveredByAny, _ as VizFuzz, a as VIZ_SCHEMA_VERSION, b as VizJson, c as VizComparison, d as VizEdge, f as VizEdgeKind, g as VizExplanation, h as VizEvent, i as ANY_STATE, l as VizCoverage, m as VizEffect, n as validateViz, o as VizBranch, p as VizEdgeOrigin, r as VizIssue, s as VizBranchTarget, t as parseViz, u as VizDocument, v as VizFuzzError, w as VizStep, x as VizLocation, y as VizFuzzSequence } from "./validate-3D5LrtlE.mjs";
1
+ import { A as coveredByAny, C as VizLiveState, D as VizMachine, E as VizLocation, M as scopeOf, O as VizState, S as VizLiveOutcome, T as VizLiveTimer, _ as VizExplanation, a as VIZ_SCHEMA_VERSION, b as VizFuzzSequence, c as VizComparison, d as VizDriver, f as VizEdge, g as VizEvent, h as VizEffect, i as ANY_STATE, j as isAnyState, k as VizStep, l as VizCoverage, m as VizEdgeOrigin, n as validateViz, o as VizBranch, p as VizEdgeKind, r as VizIssue, s as VizBranchTarget, t as parseViz, u as VizDocument, v as VizFuzz, w as VizLiveStep, x as VizJson, y as VizFuzzError } from "./validate-C_EXBtem.mjs";
2
2
  /** Byte-stable JSON: sorted object keys, two-space indent, trailing newline. */
3
3
  export declare function serializeViz(document: VizDocument): string;
4
4
  /**
@@ -79,4 +79,4 @@ export declare function livePageHtml(parts: {
79
79
  /** Stylesheet text, inlined: the built entry carries no CSS of its own. */
80
80
  css: string;
81
81
  }): string;
82
- export { ANY_STATE, type ExploreStep, type Move, type MoveGroup, type RoutingWarning, VIZ_SCHEMA_VERSION, VizBranch, VizBranchTarget, VizComparison, VizCoverage, VizDocument, VizEdge, VizEdgeKind, VizEdgeOrigin, VizEffect, VizEvent, VizExplanation, VizFuzz, VizFuzzError, VizFuzzSequence, type VizIssue, VizJson, VizLocation, VizMachine, VizState, VizStep, coveredByAny, isAnyState, parseViz, scopeOf, validateViz };
82
+ export { ANY_STATE, type ExploreStep, type Move, type MoveGroup, type RoutingWarning, VIZ_SCHEMA_VERSION, VizBranch, VizBranchTarget, VizComparison, VizCoverage, VizDocument, VizDriver, VizEdge, VizEdgeKind, VizEdgeOrigin, VizEffect, VizEvent, VizExplanation, VizFuzz, VizFuzzError, VizFuzzSequence, type VizIssue, VizJson, VizLiveOutcome, VizLiveState, VizLiveStep, VizLiveTimer, VizLocation, VizMachine, VizState, VizStep, coveredByAny, isAnyState, parseViz, scopeOf, validateViz };
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { a as stateAfter, c as validateViz, d as coveredByAny, f as isAnyState, i as movesFrom, l as ANY_STATE, n as decodePath, o as unhandledSends, p as scopeOf, r as encodeStep, s as parseViz, t as routingWarnings, u as VIZ_SCHEMA_VERSION } from "./routing-BNJN5Oiu.mjs";
1
+ import { a as encodeStep, c as stateAfter, d as validateViz, f as ANY_STATE, g as scopeOf, h as isAnyState, l as unhandledSends, m as coveredByAny, o as movesFrom, p as VIZ_SCHEMA_VERSION, r as decodePath, t as routingWarnings, u as parseViz } from "./routing-xXYo05xv.mjs";
2
2
  import { i as standaloneHtml, n as UPDATE_EVENT, r as livePageHtml, t as DOCUMENT_ELEMENT_ID } from "./html-BfvuzJs6.mjs";
3
3
  // src/serialize.ts
4
4
  function sortKeys(value) {
package/dist/page.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as ViewerOptions } from "./embed-BRQxeM4W.mjs";
1
+ import { a as ViewerOptions } from "./embed-CISoaFIK.mjs";
2
2
  /** Options that open the walk named in the address bar and keep it there. */
3
3
  export declare function linkOptions(): ViewerOptions;
4
4
  /** Mount the document embedded in the page itself. */
package/dist/page.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import "./html-BfvuzJs6.mjs";
2
- import { n as showIssues, t as mountVizText } from "./embed-BtlbyzmZ.mjs";
2
+ import { n as showIssues, t as mountVizText } from "./embed-QX7vRXoL.mjs";
3
3
  // src/viewer/page.ts
4
4
  /**
5
5
  * Whole-page entries: a self-contained HTML file (`mountEmbedded`) and a live
@@ -4,7 +4,7 @@ import { Either } from "@mantaq/utils";
4
4
  * Viz document schema. Bump `VIZ_SCHEMA_VERSION` on any breaking change.
5
5
  * Everything not observable at runtime is `null`, never guessed.
6
6
  */
7
- const VIZ_SCHEMA_VERSION = 7;
7
+ const VIZ_SCHEMA_VERSION = 8;
8
8
  /**
9
9
  * Edge source of an `onAny` registration: any non-final state of the machine,
10
10
  * or `region.*` inside a region. Never listed in `states`.
@@ -485,6 +485,11 @@ function readState(reader, record, path) {
485
485
  record,
486
486
  key: "tested",
487
487
  path
488
+ }),
489
+ doc: readNullableString(reader, {
490
+ record,
491
+ key: "doc",
492
+ path
488
493
  })
489
494
  };
490
495
  }
@@ -504,6 +509,11 @@ function readEvent(reader, record, path) {
504
509
  key: "internal",
505
510
  path
506
511
  }),
512
+ doc: readNullableString(reader, {
513
+ record,
514
+ key: "doc",
515
+ path
516
+ }),
507
517
  payloadType: null
508
518
  };
509
519
  }
@@ -553,6 +563,11 @@ function readEdge(reader, record, path) {
553
563
  record,
554
564
  path
555
565
  }),
566
+ doc: readNullableString(reader, {
567
+ record,
568
+ key: "doc",
569
+ path
570
+ }),
556
571
  coverage: readCoverage(reader, {
557
572
  record,
558
573
  key: "coverage",
@@ -635,9 +650,9 @@ function validateViz(value) {
635
650
  path: "$",
636
651
  message: "expected object"
637
652
  }]);
638
- if (value["schemaVersion"] !== 7) return Either.left([{
653
+ if (value["schemaVersion"] !== 8) return Either.left([{
639
654
  path: "$.schemaVersion",
640
- message: `expected 7, got ${JSON.stringify(value["schemaVersion"])}`
655
+ message: `expected 8, got ${JSON.stringify(value["schemaVersion"])}`
641
656
  }]);
642
657
  const machines = readList(reader, {
643
658
  record: value,
@@ -646,7 +661,7 @@ function validateViz(value) {
646
661
  }, (item, at) => readMachine(reader, item, at));
647
662
  if (reader.issues.length > 0) return Either.left(reader.issues);
648
663
  const document = {
649
- schemaVersion: 7,
664
+ schemaVersion: 8,
650
665
  machines
651
666
  };
652
667
  return Either.right(document);
@@ -743,6 +758,65 @@ function decodePath(machine, encoded) {
743
758
  }
744
759
  return path;
745
760
  }
761
+ /**
762
+ * A live step as text for a URL: `@800` advances the clock 800 ms, `go` sends
763
+ * `go` with no payload, `pay={"amount":5}` sends `pay` with a JSON payload.
764
+ */
765
+ function encodeLiveStep(step) {
766
+ if (step.kind === "advance") return `@${step.ms}`;
767
+ return step.payload === null ? step.event : `${step.event}=${JSON.stringify(step.payload)}`;
768
+ }
769
+ function parseJson(text) {
770
+ try {
771
+ return { value: JSON.parse(text) };
772
+ } catch {
773
+ return null;
774
+ }
775
+ }
776
+ /** Inverse of `encodeLiveStep`; null when the text is not a step. */
777
+ function decodeLiveStep(text) {
778
+ if (text.startsWith("@")) {
779
+ const ms = Number(text.slice(1));
780
+ return text.length > 1 && Number.isFinite(ms) && ms >= 0 ? {
781
+ kind: "advance",
782
+ ms
783
+ } : null;
784
+ }
785
+ const cut = text.indexOf("=");
786
+ const event = cut === -1 ? text : text.slice(0, cut);
787
+ if (event === "") return null;
788
+ if (cut === -1) return {
789
+ kind: "event",
790
+ event,
791
+ payload: null
792
+ };
793
+ const payload = parseJson(text.slice(cut + 1));
794
+ return payload === null ? null : {
795
+ kind: "event",
796
+ event,
797
+ payload: payload.value
798
+ };
799
+ }
800
+ /** Steps from a shared link, up to the first one that does not parse. */
801
+ function decodeLiveSteps(encoded) {
802
+ const steps = [];
803
+ for (const text of encoded) {
804
+ const step = decodeLiveStep(text);
805
+ if (step === null) break;
806
+ steps.push(step);
807
+ }
808
+ return steps;
809
+ }
810
+ /** Distinct payloads the fuzzer sent with `event`, in first-seen order. */
811
+ function payloadSamples(machine, event) {
812
+ const steps = [...machine.fuzz?.corpus.flatMap((sequence) => sequence.steps) ?? [], ...machine.fuzz?.errors.flatMap((error) => error.steps) ?? []];
813
+ const seen = /* @__PURE__ */ new Map();
814
+ for (const step of steps) {
815
+ if (step.kind !== "event" || step.event !== event || step.payload === null) continue;
816
+ seen.set(JSON.stringify(step.payload), step.payload);
817
+ }
818
+ return [...seen.values()];
819
+ }
746
820
  // src/routing.ts
747
821
  /**
748
822
  * `onAny` handlers that pick their target from the current state. Drawn from
@@ -756,4 +830,4 @@ function routingWarnings(machine) {
756
830
  message: `onAny(${event}) reads actor state to pick its target, so the diagram cannot tell which state leads where. Register m.on per state instead.`
757
831
  }));
758
832
  }
759
- export { stateAfter as a, validateViz as c, coveredByAny as d, isAnyState as f, movesFrom as i, ANY_STATE as l, decodePath as n, unhandledSends as o, scopeOf as p, encodeStep as r, parseViz as s, routingWarnings as t, VIZ_SCHEMA_VERSION as u };
833
+ export { encodeStep as a, stateAfter as c, validateViz as d, ANY_STATE as f, scopeOf as g, isAnyState as h, encodeLiveStep as i, unhandledSends as l, coveredByAny as m, decodeLiveSteps as n, movesFrom as o, VIZ_SCHEMA_VERSION as p, decodePath as r, payloadSamples as s, routingWarnings as t, parseViz as u };
@@ -3,7 +3,7 @@ import { Either } from "@mantaq/utils";
3
3
  * Viz document schema. Bump `VIZ_SCHEMA_VERSION` on any breaking change.
4
4
  * Everything not observable at runtime is `null`, never guessed.
5
5
  */
6
- declare const VIZ_SCHEMA_VERSION = 7;
6
+ declare const VIZ_SCHEMA_VERSION = 8;
7
7
  /**
8
8
  * Edge source of an `onAny` registration: any non-final state of the machine,
9
9
  * or `region.*` inside a region. Never listed in `states`.
@@ -37,10 +37,14 @@ interface VizState {
37
37
  coverage: VizCoverage | null;
38
38
  /** Visits by the supplied test sequences. null = no tests supplied. */
39
39
  tested: VizCoverage | null;
40
+ /** Leading JSDoc of the `state(...)` declaration. null = undocumented or AST not run. */
41
+ doc: string | null;
40
42
  }
41
43
  interface VizEvent {
42
44
  id: string;
43
45
  internal: boolean;
46
+ /** Leading JSDoc of the `event(...)` declaration. null = undocumented or AST not run. */
47
+ doc: string | null;
44
48
  /** Payload types are erased at runtime; always null. */
45
49
  payloadType: null;
46
50
  }
@@ -123,6 +127,8 @@ interface VizEdge {
123
127
  contexts: string[];
124
128
  /** null = no handler behind this edge (initial, effect) or analysis not run. */
125
129
  explanation: VizExplanation | null;
130
+ /** Leading JSDoc of the `m.on` / `m.onAny` call. null = undocumented, no handler, or AST not run. */
131
+ doc: string | null;
126
132
  /** Times taken in the supplied run (history or fuzz corpus replay). null = no run. */
127
133
  coverage: VizCoverage | null;
128
134
  /** Times taken by the supplied test sequences. null = no tests supplied. */
@@ -151,6 +157,41 @@ type VizStep = {
151
157
  kind: "advance";
152
158
  ms: number;
153
159
  };
160
+ /** What one step did: the actor moved, handled it without moving, or had no handler. */
161
+ type VizLiveOutcome = "transition" | "stay" | "ignored";
162
+ interface VizLiveStep {
163
+ from: string;
164
+ event: string;
165
+ to: string;
166
+ outcome: VizLiveOutcome;
167
+ /** Names of the effects the step ran. */
168
+ effects: string[];
169
+ context: VizJson;
170
+ /** Message of a handler that threw; null when none did. */
171
+ error: string | null;
172
+ }
173
+ interface VizLiveTimer {
174
+ id: string;
175
+ ms: number;
176
+ deadline: number;
177
+ eventName: string | null;
178
+ }
179
+ /** Where a running actor is, after the steps in `steps`. */
180
+ interface VizLiveState {
181
+ state: string;
182
+ steps: VizStep[];
183
+ last: VizLiveStep | null;
184
+ timers: VizLiveTimer[];
185
+ context: VizJson;
186
+ }
187
+ /**
188
+ * A running actor behind the viewer. JSON in, JSON out, so viz needs no core:
189
+ * `@mantaq/inspect` implements it on the step function the fuzzer uses.
190
+ */
191
+ interface VizDriver {
192
+ reset(): VizLiveState;
193
+ step(step: VizStep): VizLiveState;
194
+ }
154
195
  interface VizFuzzSequence {
155
196
  /** Shrunk replayable sequence, starting from a fresh actor. */
156
197
  steps: VizStep[];
@@ -186,4 +227,4 @@ interface VizIssue {
186
227
  declare function validateViz(value: unknown): Either<VizIssue[], VizDocument>;
187
228
  /** Parse JSON text and validate it. JSON syntax errors become an issue at `$`. */
188
229
  declare function parseViz(text: string): Either<VizIssue[], VizDocument>;
189
- export { VizState as C, scopeOf as D, isAnyState as E, VizMachine as S, coveredByAny as T, VizFuzz as _, VIZ_SCHEMA_VERSION as a, VizJson as b, VizComparison as c, VizEdge as d, VizEdgeKind as f, VizExplanation as g, VizEvent as h, ANY_STATE as i, VizCoverage as l, VizEffect as m, validateViz as n, VizBranch as o, VizEdgeOrigin as p, VizIssue as r, VizBranchTarget as s, parseViz as t, VizDocument as u, VizFuzzError as v, VizStep as w, VizLocation as x, VizFuzzSequence as y };
230
+ export { coveredByAny as A, VizLiveState as C, VizMachine as D, VizLocation as E, scopeOf as M, VizState as O, VizLiveOutcome as S, VizLiveTimer as T, VizExplanation as _, VIZ_SCHEMA_VERSION as a, VizFuzzSequence as b, VizComparison as c, VizDriver as d, VizEdge as f, VizEvent as g, VizEffect as h, ANY_STATE as i, isAnyState as j, VizStep as k, VizCoverage as l, VizEdgeOrigin as m, validateViz as n, VizBranch as o, VizEdgeKind as p, VizIssue as r, VizBranchTarget as s, parseViz as t, VizDocument as u, VizFuzz as v, VizLiveStep as w, VizJson as x, VizFuzzError as y };
package/dist/viewer.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { a as ViewerOptions, i as STRUCTURE_BANNER, n as showIssues, o as mountViewer, r as FUZZ_NOTE, t as mountVizText } from "./embed-BRQxeM4W.mjs";
1
+ import { a as ViewerOptions, i as STRUCTURE_BANNER, n as showIssues, o as mountViewer, r as FUZZ_NOTE, t as mountVizText } from "./embed-CISoaFIK.mjs";
2
2
  export { FUZZ_NOTE, STRUCTURE_BANNER, type ViewerOptions, mountViewer, mountVizText, showIssues };
package/dist/viewer.mjs CHANGED
@@ -1 +1 @@
1
- export { r as FUZZ_NOTE, i as STRUCTURE_BANNER, a as mountViewer, t as mountVizText, n as showIssues } from "./embed-BtlbyzmZ.mjs";
1
+ export { r as FUZZ_NOTE, i as STRUCTURE_BANNER, a as mountViewer, t as mountVizText, n as showIssues } from "./embed-QX7vRXoL.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mantaq/viz",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Draw and walk a Mantaq machine in the browser, from a versioned, deterministic JSON document.",
5
5
  "license": "MIT",
6
6
  "repository": {