@mantaq/viz 0.2.3 → 0.3.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/README.md CHANGED
@@ -4,7 +4,7 @@ Draws a Mantaq machine in the browser and lets you walk it. Input is a viz docum
4
4
 
5
5
  - `@mantaq/viz`: the document. Types, `parseViz` / `validateViz` (untrusted JSON → `Either<VizIssue[], VizDocument>`, every issue with its path), `serializeViz`, and the walk (`movesFrom`, `decodePath`, …). No DOM.
6
6
  - `@mantaq/viz/viewer`: `mountViewer(root, document)` / `mountVizText(root, { source, text })` to embed it in a page, plus `@mantaq/viz/viewer.css`.
7
- - `@mantaq/viz/standalone`, `@mantaq/viz/live`: whole-page entries used by `mantaq-inspect` (one HTML file) and the Vite plugin (live page).
7
+ - `@mantaq/viz/standalone`, `@mantaq/viz/live`: whole-page entries used by the Vite plugin (live page).
8
8
 
9
9
  Dev page with the bundled examples: `vp run viz:export`, then `cd packages/viz && vp dev`. It loads `examples/examples.json`, `?src=<url>`, or a picked file.
10
10
 
@@ -18,7 +18,7 @@ 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
- **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`.
21
+ **Static walk** (standalone page): 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
22
 
23
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
24
 
@@ -44,5 +44,6 @@ Bump `VIZ_SCHEMA_VERSION` on any breaking change. The validator accepts only the
44
44
 
45
45
  The viewer's "Coverage from" picker dims elements with zero hits. What dimmed means depends on the source:
46
46
 
47
- - **example tests** (`tested`): no test exercises this. It is the classic "what is untested" view, and the default when tests are supplied. Supply the event sequences your tests send (`inspect({ tests })`).
47
+ - **examples + fuzz** (`reached`): neither an example nor fuzzing reached this. The default when both sources are present, and the same merge `expectReached` uses. Fix a miss by adding an example to `tests`.
48
+ - **example tests** (`tested`): no test exercises this. It is the classic "what is untested" view. Supply the event sequences your tests send (`inspect({ tests })`).
48
49
  - **fuzz runs** (`coverage`): no fuzz run produced this. Fuzzing reaches almost everything, so little is dimmed. What does stay dimmed is usually a **declared but unreached edge**: the handler lists the target in `to`, but its guards may never allow it. Saga's `compensatingRefund -REFUND_DONE-> failed` is an example. The edge panel calls these out. Fuzzing is not proof, so check the handler.
@@ -45,42 +45,42 @@ function items(machine) {
45
45
  /** Modes this machine has data for, best first: tests answer "what is untested?". */
46
46
  function availableModes(machine) {
47
47
  const modes = [];
48
- if (items(machine).some((item) => item.tested !== null)) modes.push("tests");
49
- if (items(machine).some((item) => item.coverage !== null)) modes.push("run");
48
+ const tested = items(machine).some((item) => item.tested !== null);
49
+ const run = items(machine).some((item) => item.coverage !== null);
50
+ if (tested && run) modes.push("reached");
51
+ if (tested) modes.push("tests");
52
+ if (run) modes.push("run");
50
53
  return modes;
51
54
  }
52
55
  function measure(mode, item) {
53
56
  if (mode === "run") return item.coverage;
54
57
  if (mode === "tests") return item.tested;
58
+ if (mode === "reached") {
59
+ if (item.coverage === null && item.tested === null) return null;
60
+ return { hits: (item.coverage?.hits ?? 0) + (item.tested?.hits ?? 0) };
61
+ }
55
62
  return null;
56
63
  }
57
64
  function modeLabel(mode, machine) {
58
65
  if (mode === "tests") return "example tests";
66
+ if (mode === "reached") return "examples + fuzz";
59
67
  if (mode === "run") return machine.fuzz === null ? "supplied run" : "fuzz runs";
60
68
  return "off";
61
69
  }
62
- /** Legend text for a dimmed element: what "unreached" means for this source. */
63
- function unreachedText(mode, machine) {
64
- if (mode === "tests") return "dimmed and dashed: no test exercises this";
65
- if (machine.fuzz === null) return "dimmed and dashed: never taken in the supplied run";
66
- return "dimmed and dashed: never observed while fuzzing (declared edges here may be unreachable)";
67
- }
68
70
  // src/viewer/labels.ts
69
- /** Names of the effects the state behind an effect edge runs on entry. */
70
- function effectNames(edge, machine) {
71
- return (machine.states.find((state) => state.id === edge.from)?.effects ?? []).map((effect) => effect.name);
72
- }
73
- /** Text drawn on an edge: the event, or for an effect loop the effects it runs. */
74
- function edgeText(edge, machine) {
75
- if (edge.kind !== "effect") return edge.event;
76
- const names = effectNames(edge, machine);
77
- return names.length === 0 ? "effect" : `runs ${names.join(", ")}`;
71
+ /** Text drawn on an edge: the event it carries. */
72
+ function edgeText(edge) {
73
+ return edge.event;
78
74
  }
79
75
  // src/viewer/layout.ts
80
76
  const CHAR_WIDTH = 7;
81
77
  const NODE_HEIGHT = 36;
78
+ const CHIP_ROW_HEIGHT = 20;
82
79
  const LABEL_HEIGHT = 16;
83
80
  const UNKNOWN_SIZE = 28;
81
+ const MIN_DOT_GAP = 3;
82
+ const MAX_DOT_GAP = 12;
83
+ const DOT_GAP_PER_LENGTH = 1 / 24;
84
84
  function anyIds(machine) {
85
85
  return [...new Set(machine.edges.map((edge) => edge.from).filter(isAnyState))].sort();
86
86
  }
@@ -90,8 +90,13 @@ function unknownNodeId(edge) {
90
90
  function regionNodeId(region) {
91
91
  return `region:${region}`;
92
92
  }
93
+ const HINT_WIDTH = 14;
93
94
  function stateWidth(state) {
94
- return Math.max(80, state.label.length * CHAR_WIDTH + 24);
95
+ const chips = state.effects.map((effect) => effect.name.length * CHAR_WIDTH + 24 + (effect.sends === null ? HINT_WIDTH : 0));
96
+ return Math.max(80, state.label.length * CHAR_WIDTH + 24, ...chips);
97
+ }
98
+ function stateHeight(state) {
99
+ return NODE_HEIGHT + state.effects.length * CHIP_ROW_HEIGHT;
95
100
  }
96
101
  function parentRegion(region) {
97
102
  const cut = region.lastIndexOf(".");
@@ -111,7 +116,7 @@ function addStates(graph, machine) {
111
116
  for (const state of machine.states) {
112
117
  graph.setNode(state.id, {
113
118
  width: stateWidth(state),
114
- height: NODE_HEIGHT
119
+ height: stateHeight(state)
115
120
  });
116
121
  if (state.region !== null) graph.setParent(state.id, regionNodeId(state.region));
117
122
  }
@@ -128,7 +133,7 @@ function addAnyNodes(graph, machine) {
128
133
  }
129
134
  function addEdges(graph, machine) {
130
135
  for (const edge of machine.edges) {
131
- if (edge.kind === "initial" || edge.to === edge.from) continue;
136
+ if (edge.kind === "initial" || edge.kind === "effect" || edge.to === edge.from) continue;
132
137
  const target = edge.to ?? unknownNodeId(edge);
133
138
  if (edge.to === null) {
134
139
  graph.setNode(target, {
@@ -139,7 +144,7 @@ function addEdges(graph, machine) {
139
144
  if (region !== null) graph.setParent(target, regionNodeId(region));
140
145
  }
141
146
  const label = {
142
- width: edgeText(edge, machine).length * CHAR_WIDTH + 8,
147
+ width: edgeText(edge).length * CHAR_WIDTH + 8,
143
148
  height: LABEL_HEIGHT,
144
149
  labelpos: "c"
145
150
  };
@@ -161,7 +166,7 @@ function placeNodes(graph, machine) {
161
166
  state,
162
167
  ...box(graph.node(state.id))
163
168
  }));
164
- const unknowns = machine.edges.filter((edge) => edge.kind !== "initial" && edge.to === null).map((edge) => ({
169
+ const unknowns = machine.edges.filter((edge) => edge.kind !== "initial" && edge.kind !== "effect" && edge.to === null).map((edge) => ({
165
170
  kind: "unknown",
166
171
  id: unknownNodeId(edge),
167
172
  edgeId: edge.id,
@@ -198,15 +203,66 @@ function selfLoop(node, nth) {
198
203
  }
199
204
  };
200
205
  }
206
+ function pathLength(points) {
207
+ let total = 0;
208
+ for (let index = 1; index < points.length; index++) {
209
+ const from = points[index - 1];
210
+ const to = points[index];
211
+ if (from === void 0 || to === void 0) continue;
212
+ total += Math.hypot(to.x - from.x, to.y - from.y);
213
+ }
214
+ return total;
215
+ }
216
+ function dotGapFor(length) {
217
+ return Math.min(MAX_DOT_GAP, Math.max(MIN_DOT_GAP, length * DOT_GAP_PER_LENGTH));
218
+ }
219
+ const LABEL_GAP = 2;
220
+ function collides(a, b) {
221
+ return Math.abs(a.x - b.x) < (a.width + b.width) / 2 + LABEL_GAP && Math.abs(a.y - b.y) < (a.height + b.height) / 2 + LABEL_GAP;
222
+ }
223
+ /**
224
+ * Moves each label vertically (0, +1, -1, +2, -2 steps) to the first spot that
225
+ * clears every label placed before it. Pure and order-stable.
226
+ */
227
+ function separateLabels(boxes) {
228
+ const placed = [];
229
+ for (const labelBox of boxes) {
230
+ let candidate = labelBox;
231
+ for (let step = 1; placed.some((other) => collides(candidate, other)); step++) {
232
+ const shift = 18 * Math.ceil(step / 2);
233
+ candidate = {
234
+ ...labelBox,
235
+ y: labelBox.y + (step % 2 === 1 ? shift : -shift)
236
+ };
237
+ }
238
+ placed.push(candidate);
239
+ }
240
+ return placed;
241
+ }
242
+ function avoidLabelCollisions(edges) {
243
+ const moved = separateLabels(edges.map((placed) => ({
244
+ ...placed.label,
245
+ width: edgeText(placed.edge).length * CHAR_WIDTH + 8,
246
+ height: LABEL_HEIGHT
247
+ })));
248
+ return edges.map((placed, index) => ({
249
+ ...placed,
250
+ label: {
251
+ x: placed.label.x,
252
+ y: moved[index]?.y ?? placed.label.y
253
+ }
254
+ }));
255
+ }
201
256
  function placeEdges(graph, machine) {
202
257
  const loopsSeen = /* @__PURE__ */ new Map();
203
- return machine.edges.filter((edge) => edge.kind !== "initial").map((edge) => {
258
+ return machine.edges.filter((edge) => edge.kind !== "initial" && edge.kind !== "effect").map((edge) => {
204
259
  if (edge.to === edge.from) {
205
260
  const nth = loopsSeen.get(edge.from) ?? 0;
206
261
  loopsSeen.set(edge.from, nth + 1);
207
262
  return {
208
263
  edge,
209
264
  target: edge.from,
265
+ dotGap: dotGapFor(72),
210
266
  ...selfLoop(graph.node(edge.from), nth)
211
267
  };
212
268
  }
@@ -219,7 +275,8 @@ function placeEdges(graph, machine) {
219
275
  label: {
220
276
  x: label.x ?? 0,
221
277
  y: label.y ?? 0
222
- }
278
+ },
279
+ dotGap: dotGapFor(pathLength(label.points ?? []))
223
280
  };
224
281
  });
225
282
  }
@@ -249,8 +306,8 @@ function layoutMachine(machine) {
249
306
  });
250
307
  graph.setGraph({
251
308
  rankdir: "LR",
252
- nodesep: 28,
253
- ranksep: 64,
309
+ nodesep: 36,
310
+ ranksep: 72,
254
311
  edgesep: 14,
255
312
  marginx: 48,
256
313
  marginy: 64
@@ -265,7 +322,7 @@ function layoutMachine(machine) {
265
322
  width: size.width ?? 0,
266
323
  height: size.height ?? 0,
267
324
  nodes: placeNodes(graph, machine),
268
- edges: placeEdges(graph, machine),
325
+ edges: avoidLabelCollisions(placeEdges(graph, machine)),
269
326
  regions: machine.regions.map((region) => ({
270
327
  region,
271
328
  ...box(graph.node(regionNodeId(region)))
@@ -312,6 +369,39 @@ function regionGroup(doc, placed) {
312
369
  y: placed.y - placed.height / 2 + 14
313
370
  }, [placed.region])]);
314
371
  }
372
+ function chipGroups(doc, placed) {
373
+ const top = placed.y - placed.height / 2;
374
+ const width = placed.width - 16;
375
+ return placed.state.effects.map((effect, index) => {
376
+ const y = top + 36 + index * 20 - 8;
377
+ return svg(doc, "g", {
378
+ class: "chip",
379
+ "data-effect": effect.name,
380
+ "data-state": placed.state.id,
381
+ tabindex: 0,
382
+ "aria-label": effect.sends === null ? `effect ${effect.name} (declare sends to see links)` : `effect ${effect.name}`
383
+ }, [
384
+ svg(doc, "rect", {
385
+ x: placed.x - width / 2,
386
+ y,
387
+ width,
388
+ height: 16,
389
+ rx: 8
390
+ }),
391
+ svg(doc, "text", {
392
+ x: placed.x,
393
+ y: y + 12,
394
+ "text-anchor": "middle"
395
+ }, [effect.name]),
396
+ effect.sends === null ? svg(doc, "text", {
397
+ class: "hint",
398
+ x: placed.x + width / 2 - 8,
399
+ y: y + 12,
400
+ "text-anchor": "middle"
401
+ }, ["?", svg(doc, "title", {}, ["declare `sends` to see links"])]) : null
402
+ ]);
403
+ });
404
+ }
315
405
  function stateGroup(doc, placed, callbacks) {
316
406
  const { state } = placed;
317
407
  const left = placed.x - placed.width / 2;
@@ -347,9 +437,10 @@ function stateGroup(doc, placed, callbacks) {
347
437
  }) : null,
348
438
  svg(doc, "text", {
349
439
  x: placed.x,
350
- y: placed.y + 4,
440
+ y: top + 22,
351
441
  "text-anchor": "middle"
352
- }, [state.label])
442
+ }, [state.label]),
443
+ ...chipGroups(doc, placed)
353
444
  ]);
354
445
  activatable(group, () => callbacks.select({
355
446
  kind: "state",
@@ -394,18 +485,16 @@ function nodeGroup(doc, node, callbacks) {
394
485
  return node.kind === "any" ? anyGroup(doc, node) : unknownGroup(doc, node);
395
486
  }
396
487
  function edgeAria(edge, text) {
397
- if (edge.kind === "effect") return `${edge.from} ${text} on entry`;
398
488
  if (edge.kind === "stay") return `${sourceText$1(edge.from)} on ${text} stays`;
399
489
  return `${sourceText$1(edge.from)} on ${text} to ${edge.to ?? "undetermined"}`;
400
490
  }
401
491
  function edgeGroup(doc, placed, context) {
402
492
  const { edge } = placed;
403
493
  const internal = context.machine.events.find((event) => event.id === edge.event)?.internal;
404
- const text = edgeText(edge, context.machine);
494
+ const text = edgeText(edge);
405
495
  const group = svg(doc, "g", {
406
496
  class: classes({
407
497
  edge: true,
408
- effect: edge.kind === "effect",
409
498
  stay: edge.kind === "stay",
410
499
  fuzz: edge.origin === "fuzz",
411
500
  undetermined: edge.to === null,
@@ -418,7 +507,8 @@ function edgeGroup(doc, placed, context) {
418
507
  "aria-label": edgeAria(edge, text)
419
508
  }, [svg(doc, "path", {
420
509
  d: placed.path,
421
- "marker-end": "url(#viz-arrow)"
510
+ "marker-end": "url(#viz-arrow)",
511
+ style: internal === true || edge.kind === "stay" ? `--viz-dot-gap: ${placed.dotGap.toFixed(1)}` : null
422
512
  }), svg(doc, "text", {
423
513
  x: placed.label.x,
424
514
  y: placed.label.y + 4,
@@ -456,6 +546,35 @@ function wireHover(root) {
456
546
  });
457
547
  }
458
548
  }
549
+ /** Edges a chip's effect sends, including `onAny` handlers, by edge id. */
550
+ function sentEdges(root, machine, chip) {
551
+ const effect = chip.dataset["effect"] ?? "";
552
+ const ids = new Set(movesFrom(machine, chip.dataset["state"] ?? "").filter((move) => move.sentBy.includes(effect)).map((move) => move.edge.id));
553
+ return [...root.querySelectorAll(".edge")].filter((edge) => ids.has(edge.dataset["edgeId"] ?? ""));
554
+ }
555
+ /** Hovering or focusing a chip highlights the edges for its `sends` and dims the rest. */
556
+ function wireChips(root, doc, machine) {
557
+ const emitters = /* @__PURE__ */ new Map();
558
+ for (const chip of root.querySelectorAll(".chip")) {
559
+ const edges = sentEdges(root, machine, chip);
560
+ const effect = chip.dataset["effect"] ?? "";
561
+ for (const edge of edges) emitters.set(edge, [...emitters.get(edge) ?? [], effect]);
562
+ const on = () => {
563
+ if (edges.length === 0) return;
564
+ root.classList.add("chip-active");
565
+ for (const edge of edges) edge.classList.add("sent");
566
+ };
567
+ const off = () => {
568
+ root.classList.remove("chip-active");
569
+ for (const edge of edges) edge.classList.remove("sent");
570
+ };
571
+ chip.addEventListener("mouseenter", on);
572
+ chip.addEventListener("mouseleave", off);
573
+ chip.addEventListener("focusin", on);
574
+ chip.addEventListener("focusout", off);
575
+ }
576
+ for (const [edge, names] of emitters) edge.prepend(svg(doc, "title", {}, [`emitted by ${names.join(", ")}`]));
577
+ }
459
578
  /** Static SVG for one machine. Regions first, then edges, then nodes, so nodes sit on top. */
460
579
  function renderGraph(doc, machine, callbacks) {
461
580
  const placed = layoutMachine(machine);
@@ -478,6 +597,7 @@ function renderGraph(doc, machine, callbacks) {
478
597
  ...placed.nodes.map((node) => nodeGroup(doc, node, callbacks))
479
598
  ]);
480
599
  wireHover(root);
600
+ wireChips(root, doc, machine);
481
601
  return root;
482
602
  }
483
603
  // src/viewer/explanation.ts
@@ -703,6 +823,59 @@ function renderPanel(doc, machine, context) {
703
823
  }
704
824
  return html(doc, "div", {}, [html(doc, "p", { class: "hint" }, ["Select a state or edge."]), fuzzSummary(doc, machine)]);
705
825
  }
826
+ // src/viewer/context-diff.ts
827
+ function same(left, right) {
828
+ return JSON.stringify(left) === JSON.stringify(right);
829
+ }
830
+ function childOf(value, key) {
831
+ if (value === null || typeof value !== "object") return void 0;
832
+ if (Array.isArray(value)) return /^\d+$/.test(key) ? value[Number(key)] : void 0;
833
+ return Object.hasOwn(value, key) ? value[key] : void 0;
834
+ }
835
+ function lines(value, context) {
836
+ const { before, depth, label, comma } = context;
837
+ const pad = " ".repeat(depth);
838
+ const entries = Array.isArray(value) ? value.map((item, index) => [String(index), item]) : value !== null && typeof value === "object" ? Object.entries(value) : [];
839
+ if (entries.length === 0) return [{
840
+ text: `${pad}${label}${JSON.stringify(value) ?? "undefined"}${comma}`,
841
+ changed: !same(value, before)
842
+ }];
843
+ const [open, close] = Array.isArray(value) ? ["[", "]"] : ["{", "}"];
844
+ const body = entries.flatMap(([key, item], index) => lines(item, {
845
+ before: childOf(before, key),
846
+ depth: depth + 1,
847
+ label: Array.isArray(value) ? "" : `${JSON.stringify(key)}: `,
848
+ comma: index === entries.length - 1 ? "" : ","
849
+ }));
850
+ return [
851
+ {
852
+ text: `${pad}${label}${open}`,
853
+ changed: false
854
+ },
855
+ ...body,
856
+ {
857
+ text: `${pad}${close}${comma}`,
858
+ changed: false
859
+ }
860
+ ];
861
+ }
862
+ /**
863
+ * The context pretty-printed like `JSON.stringify(value, null, 2)`, one entry per
864
+ * line, with the lines whose value is new or different from `before` marked.
865
+ * `before` of undefined marks nothing.
866
+ */
867
+ function contextLines(value, before) {
868
+ const marked = lines(value, {
869
+ before,
870
+ depth: 0,
871
+ label: "",
872
+ comma: ""
873
+ });
874
+ return before === void 0 ? marked.map((line) => ({
875
+ ...line,
876
+ changed: false
877
+ })) : marked;
878
+ }
706
879
  // src/viewer/explore.ts
707
880
  const GROUP_TITLES = {
708
881
  input: "Send an input",
@@ -948,8 +1121,26 @@ function outcomeText(last) {
948
1121
  if (last.outcome === "stay") return ` handled in ${last.from}: stays`;
949
1122
  return ` ignored in ${last.from}: no handler`;
950
1123
  }
951
- function liveResultSection(doc, state) {
952
- const { last } = state;
1124
+ const collapsedContexts = /* @__PURE__ */ new WeakSet();
1125
+ /** Collapsible, pretty-printed context. The fold survives the panel redraw after each step. */
1126
+ function contextSection(doc, value, context) {
1127
+ const { viewer, previous } = context;
1128
+ const section = html(doc, "details", {
1129
+ class: "explore-context",
1130
+ "data-live-context": ""
1131
+ }, [html(doc, "summary", {}, ["Context"]), html(doc, "pre", {}, contextLines(value, previous).flatMap((line, index) => [index === 0 ? "" : "\n", line.changed ? html(doc, "mark", {
1132
+ class: "context-changed",
1133
+ "data-context-changed": ""
1134
+ }, [line.text]) : line.text]))]);
1135
+ section.open = !collapsedContexts.has(viewer);
1136
+ section.addEventListener("toggle", () => {
1137
+ if (section.open) collapsedContexts.delete(viewer);
1138
+ else collapsedContexts.add(viewer);
1139
+ });
1140
+ return section;
1141
+ }
1142
+ function liveResultSection(doc, live) {
1143
+ const { last } = live.state;
953
1144
  return html(doc, "div", { class: "explore-live" }, [last === null ? html(doc, "p", { class: "muted" }, ["Nothing has run yet."]) : html(doc, "div", {
954
1145
  class: "explore-outcome",
955
1146
  "data-outcome": last.outcome
@@ -961,7 +1152,10 @@ function liveResultSection(doc, state) {
961
1152
  role: "alert",
962
1153
  "data-live-error": ""
963
1154
  }, [`Handler threw: ${last.error}`])
964
- ]), html(doc, "p", { "data-live-context": "" }, ["Context: ", code(doc, JSON.stringify(state.context))])]);
1155
+ ]), contextSection(doc, live.state.context, {
1156
+ viewer: live.send,
1157
+ previous: live.previous
1158
+ })]);
965
1159
  }
966
1160
  function livePathSection(doc, context, state) {
967
1161
  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"], {
@@ -1000,7 +1194,7 @@ function explorePanel(doc, context) {
1000
1194
  final
1001
1195
  }),
1002
1196
  ...live ? [
1003
- liveResultSection(doc, live.state),
1197
+ liveResultSection(doc, live),
1004
1198
  liveMovesSection(doc, machine, {
1005
1199
  state: live.state,
1006
1200
  send: live.send,
@@ -1054,8 +1248,8 @@ function markExplore(root, context) {
1054
1248
  const id = element.dataset["edgeId"] ?? "";
1055
1249
  element.classList.toggle("taken", taken.has(id));
1056
1250
  element.classList.toggle("next", next.has(id));
1057
- element.classList.toggle("running", element.classList.contains("effect") && element.dataset["from"] === current);
1058
1251
  }
1252
+ for (const element of root.querySelectorAll(".graph g.chip")) element.classList.toggle("running", element.dataset["state"] === current);
1059
1253
  for (const element of root.querySelectorAll(".graph g[data-node-id]")) {
1060
1254
  const id = element.dataset["nodeId"];
1061
1255
  element.classList.toggle("current", id === current);
@@ -1063,29 +1257,13 @@ function markExplore(root, context) {
1063
1257
  }
1064
1258
  }
1065
1259
  // src/viewer/viewer.ts
1066
- 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.";
1067
- const LIVE_BANNER = "Live: the real machine runs. Send an input and the cursor follows its actual state; guards and effects run.";
1068
- const FUZZ_NOTE = "Green edges were observed while fuzzing but not declared. Fuzzing finds behaviour; it never proves an edge is impossible.";
1260
+ const DOCS_URL = "https://anderscan.github.io/mantaq/guides/visualizer/";
1069
1261
  function effectiveMode(view, machine) {
1070
1262
  const modes = availableModes(machine);
1071
1263
  if (view.coverage === "off") return "off";
1072
1264
  if (view.coverage !== null && modes.includes(view.coverage)) return view.coverage;
1073
1265
  return modes[0] ?? "off";
1074
1266
  }
1075
- function legend(doc, { mode, machine }) {
1076
- const entries = [
1077
- ["legend-final", "double border: final state"],
1078
- ["legend-undetermined", "dashed edge to ?: target undetermined"],
1079
- ["legend-internal", "dotted edge, italic label: internal event"],
1080
- ["legend-effect", "faint self-loop: state runs effects on entry"],
1081
- ["legend-current", "thick accent border: where the walk is"],
1082
- ["legend-next", "pulsing: a possible next step"],
1083
- ["legend-taken", "bold accent: path taken"]
1084
- ];
1085
- if (machine.fuzz !== null) entries.push(["legend-fuzz", "green: observed only while fuzzing"], ["legend-stay", "dotted self-loop: handled, no state change"]);
1086
- if (mode !== "off") entries.push(["legend-unreached", unreachedText(mode, machine)]);
1087
- return html(doc, "ul", { class: "legend" }, entries.map(([name, text]) => html(doc, "li", { class: name }, [text])));
1088
- }
1089
1267
  function machineSelect(doc, viz, context) {
1090
1268
  if (viz.machines.length < 2) return null;
1091
1269
  const select = html(doc, "select", { class: "machine-select" }, viz.machines.map((machine) => html(doc, "option", {
@@ -1096,7 +1274,7 @@ function machineSelect(doc, viz, context) {
1096
1274
  return html(doc, "label", {}, ["Machine ", select]);
1097
1275
  }
1098
1276
  function isMode(value) {
1099
- return value === "off" || value === "run" || value === "tests";
1277
+ return value === "off" || value === "run" || value === "tests" || value === "reached";
1100
1278
  }
1101
1279
  function coveragePicker(doc, context) {
1102
1280
  const modes = availableModes(context.machine);
@@ -1122,10 +1300,11 @@ function mountViewer(root, viz, options = {}) {
1122
1300
  selection: null,
1123
1301
  coverage: null,
1124
1302
  path: first === void 0 || options.driver !== void 0 ? [] : decodePath(first, options.steps ?? []),
1125
- live: null
1303
+ live: null,
1304
+ previous: void 0
1126
1305
  };
1127
1306
  const { driver } = options;
1128
- if (driver !== void 0) view.live = replay(driver, decodeLiveSteps(options.steps ?? []));
1307
+ if (driver !== void 0) replayInto(view, driver, decodeLiveSteps(options.steps ?? []));
1129
1308
  let back = () => void 0;
1130
1309
  root.addEventListener("keydown", (event) => {
1131
1310
  if (!("key" in event) || event.target instanceof HTMLSelectElement) return;
@@ -1153,7 +1332,8 @@ function mountViewer(root, viz, options = {}) {
1153
1332
  rewind,
1154
1333
  ...view.live === null ? {} : { live: {
1155
1334
  state: view.live,
1156
- send
1335
+ send,
1336
+ previous: view.previous
1157
1337
  } }
1158
1338
  }), renderPanel(doc, machine, {
1159
1339
  selection: view.selection,
@@ -1176,6 +1356,7 @@ function mountViewer(root, viz, options = {}) {
1176
1356
  };
1177
1357
  const send = (step) => {
1178
1358
  if (driver === void 0) return;
1359
+ view.previous = view.live?.context;
1179
1360
  view.live = stepSafely(driver, {
1180
1361
  before: view.live,
1181
1362
  step
@@ -1197,7 +1378,7 @@ function mountViewer(root, viz, options = {}) {
1197
1378
  };
1198
1379
  const rewind = (length) => {
1199
1380
  if (driver !== void 0 && view.live !== null) {
1200
- view.live = replay(driver, view.live.steps.slice(0, Math.max(0, length)));
1381
+ replayInto(view, driver, view.live.steps.slice(0, Math.max(0, length)));
1201
1382
  walked();
1202
1383
  return;
1203
1384
  }
@@ -1224,12 +1405,6 @@ function mountViewer(root, viz, options = {}) {
1224
1405
  render,
1225
1406
  onNavigate: options.onNavigate,
1226
1407
  live: driver !== void 0
1227
- }), html(doc, "p", {
1228
- class: "banner",
1229
- role: "note"
1230
- }, [driver === void 0 ? banner(machine) : LIVE_BANNER]), legend(doc, {
1231
- mode,
1232
- machine
1233
1408
  }), html(doc, "div", { class: "viz-main" }, [html(doc, "div", { class: "canvas" }, [renderGraph(doc, machine, {
1234
1409
  select,
1235
1410
  mode
@@ -1239,6 +1414,11 @@ function mountViewer(root, viz, options = {}) {
1239
1414
  render();
1240
1415
  }
1241
1416
  /** A driver that throws leaves the state where it was and shows the message as a handler error. */
1417
+ /** Sets `view.live` to the state after `steps` and `view.previous` to the context before the last of them. */
1418
+ function replayInto(view, driver, steps) {
1419
+ view.previous = steps.length === 0 ? void 0 : replay(driver, steps.slice(0, -1)).context;
1420
+ view.live = replay(driver, steps);
1421
+ }
1242
1422
  function stepSafely(driver, context) {
1243
1423
  const { before, step } = context;
1244
1424
  try {
@@ -1283,12 +1463,15 @@ function headerOf(doc, context) {
1283
1463
  view.coverage = next;
1284
1464
  render();
1285
1465
  }
1286
- })
1466
+ }),
1467
+ html(doc, "a", {
1468
+ class: "help-link",
1469
+ href: DOCS_URL,
1470
+ target: "_blank",
1471
+ rel: "noopener noreferrer"
1472
+ }, ["How this works"])
1287
1473
  ]);
1288
1474
  }
1289
- function banner(machine) {
1290
- return machine.fuzz === null ? STRUCTURE_BANNER : `${STRUCTURE_BANNER} ${FUZZ_NOTE}`;
1291
- }
1292
1475
  function markSelection(root, selection) {
1293
1476
  const attribute = selection?.kind === "edge" ? "edgeId" : "nodeId";
1294
1477
  for (const element of root.querySelectorAll(".graph g")) element.classList.toggle("selected", selection !== null && element.dataset[attribute] === selection.id);
@@ -1334,4 +1517,4 @@ function mountVizText(root, context, options = {}) {
1334
1517
  }
1335
1518
  mountViewer(root, result[1], options);
1336
1519
  }
1337
- export { mountViewer as a, STRUCTURE_BANNER as i, showIssues as n, FUZZ_NOTE as r, mountVizText as t };
1520
+ export { showIssues as n, mountViewer as r, mountVizText as t };
@@ -1,6 +1,4 @@
1
1
  import { d as VizDriver, r as VizIssue, u as VizDocument } from "./validate-C_EXBtem.mjs";
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
- declare const FUZZ_NOTE = "Green edges were observed while fuzzing but not declared. Fuzzing finds behaviour; it never proves an edge is impossible.";
4
2
  interface ViewerOptions {
5
3
  /** Title element. Embedded viewers use `h2`/`h3` to fit the host page's outline. */
6
4
  heading?: "h1" | "h2" | "h3";
@@ -34,4 +32,4 @@ declare function mountVizText(root: HTMLElement, context: {
34
32
  source: string;
35
33
  text: string;
36
34
  }, options?: ViewerOptions): void;
37
- export { ViewerOptions as a, STRUCTURE_BANNER as i, showIssues as n, mountViewer as o, FUZZ_NOTE as r, mountVizText as t };
35
+ export { mountViewer as i, showIssues as n, ViewerOptions as r, mountVizText as t };
package/dist/page.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as ViewerOptions } from "./embed-CISoaFIK.mjs";
1
+ import { r as ViewerOptions } from "./embed-OmMnILen.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-D3r1M511.mjs";
2
+ import { n as showIssues, t as mountVizText } from "./embed-Dpjn47Qx.mjs";
3
3
  // src/viewer/page.ts
4
4
  /**
5
5
  * Whole-page entries: a self-contained HTML file (`mountEmbedded`) and a live
@@ -1,6 +1,6 @@
1
1
  import { mountEmbedded } from "./page.mjs";
2
2
  // src/viewer/standalone.ts
3
- /** Entry of the self-contained HTML page `mantaq-inspect` writes. */
3
+ /** Entry of the self-contained static page. */
4
4
  const root = document.querySelector("#app");
5
5
  if (root) mountEmbedded(root);
6
6
  export {};
package/dist/style.css CHANGED
@@ -46,21 +46,10 @@
46
46
  font-size: 18px;
47
47
  }
48
48
 
49
- .viz .banner {
50
- background: var(--banner-bg);
51
- color: var(--banner-fg);
52
- border-radius: 6px;
53
- padding: 8px 12px;
54
- }
55
-
56
- .viz .legend {
49
+ .viz .help-link {
57
50
  color: var(--muted);
58
- flex-wrap: wrap;
59
- gap: 4px 18px;
60
- padding: 0;
51
+ margin-left: auto;
61
52
  font-size: 12px;
62
- list-style: none;
63
- display: flex;
64
53
  }
65
54
 
66
55
  .viz .viz-main {
@@ -158,7 +147,7 @@
158
147
  }
159
148
 
160
149
  .viz .edge.internal path {
161
- stroke-dasharray: 1 3;
150
+ stroke-dasharray: 1 var(--viz-dot-gap, 3);
162
151
  stroke-linecap: round;
163
152
  }
164
153
 
@@ -166,8 +155,20 @@
166
155
  font-style: italic;
167
156
  }
168
157
 
169
- .viz .edge.effect {
170
- opacity: .45;
158
+ .viz .chip rect {
159
+ fill: var(--accent);
160
+ fill-opacity: .12;
161
+ stroke: var(--accent);
162
+ }
163
+
164
+ .viz .chip text {
165
+ font-size: 11px;
166
+ }
167
+
168
+ .viz .chip .hint {
169
+ fill: var(--unknown);
170
+ cursor: help;
171
+ font-weight: bold;
171
172
  }
172
173
 
173
174
  .viz .edge.undetermined path {
@@ -184,7 +185,7 @@
184
185
  }
185
186
 
186
187
  .viz .edge.stay path {
187
- stroke-dasharray: 1 3;
188
+ stroke-dasharray: 1 var(--viz-dot-gap, 3);
188
189
  stroke-linecap: round;
189
190
  }
190
191
 
@@ -240,6 +241,19 @@
240
241
  stroke-width: 2.5px;
241
242
  }
242
243
 
244
+ .viz .chip {
245
+ cursor: help;
246
+ }
247
+
248
+ .viz .graph.chip-active .edge:not(.sent) {
249
+ opacity: .2;
250
+ }
251
+
252
+ .viz .edge.sent path {
253
+ stroke: var(--accent);
254
+ stroke-width: 2.5px;
255
+ }
256
+
243
257
  .viz .state:focus-visible .frame, .viz .edge:focus-visible path {
244
258
  stroke: var(--accent);
245
259
  stroke-width: 3px;
@@ -306,8 +320,8 @@
306
320
  font-weight: 700;
307
321
  }
308
322
 
309
- .viz .edge.effect.running {
310
- opacity: 1;
323
+ .viz .chip.running rect {
324
+ fill-opacity: .35;
311
325
  }
312
326
 
313
327
  .viz .running path {
@@ -393,6 +407,25 @@
393
407
  stroke: var(--accent);
394
408
  stroke-dasharray: 4 3;
395
409
  }
410
+
411
+ .viz .explore-context summary {
412
+ cursor: pointer;
413
+ font-weight: 600;
414
+ }
415
+
416
+ .viz .explore-context pre {
417
+ background: var(--region-bg);
418
+ max-height: 40vh;
419
+ margin: 4px 0 0;
420
+ font-size: 12px;
421
+ overflow: auto;
422
+ }
423
+
424
+ .viz .explore-context mark.context-changed {
425
+ background: var(--banner-bg);
426
+ color: var(--banner-fg);
427
+ font-weight: 600;
428
+ }
396
429
  body {
397
430
  margin: 0;
398
431
  padding: 16px;
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-CISoaFIK.mjs";
2
- export { FUZZ_NOTE, STRUCTURE_BANNER, type ViewerOptions, mountViewer, mountVizText, showIssues };
1
+ import { i as mountViewer, n as showIssues, r as ViewerOptions, t as mountVizText } from "./embed-OmMnILen.mjs";
2
+ export { 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-D3r1M511.mjs";
1
+ export { r as mountViewer, t as mountVizText, n as showIssues } from "./embed-Dpjn47Qx.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mantaq/viz",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
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": {
@@ -46,19 +46,8 @@
46
46
  margin: 0;
47
47
  }
48
48
 
49
- .viz .banner {
50
- background: var(--banner-bg);
51
- color: var(--banner-fg);
52
- padding: 8px 12px;
53
- border-radius: 6px;
54
- }
55
-
56
- .viz .legend {
57
- display: flex;
58
- flex-wrap: wrap;
59
- gap: 4px 18px;
60
- padding: 0;
61
- list-style: none;
49
+ .viz .help-link {
50
+ margin-left: auto;
62
51
  color: var(--muted);
63
52
  font-size: 12px;
64
53
  }
@@ -159,7 +148,7 @@
159
148
  }
160
149
 
161
150
  .viz .edge.internal path {
162
- stroke-dasharray: 1 3;
151
+ stroke-dasharray: 1 var(--viz-dot-gap, 3);
163
152
  stroke-linecap: round;
164
153
  }
165
154
 
@@ -167,8 +156,20 @@
167
156
  font-style: italic;
168
157
  }
169
158
 
170
- .viz .edge.effect {
171
- opacity: 0.45;
159
+ .viz .chip rect {
160
+ fill: var(--accent);
161
+ fill-opacity: 0.12;
162
+ stroke: var(--accent);
163
+ }
164
+
165
+ .viz .chip text {
166
+ font-size: 11px;
167
+ }
168
+
169
+ .viz .chip .hint {
170
+ font-weight: bold;
171
+ fill: var(--unknown);
172
+ cursor: help;
172
173
  }
173
174
 
174
175
  .viz .edge.undetermined path {
@@ -185,7 +186,7 @@
185
186
  }
186
187
 
187
188
  .viz .edge.stay path {
188
- stroke-dasharray: 1 3;
189
+ stroke-dasharray: 1 var(--viz-dot-gap, 3);
189
190
  stroke-linecap: round;
190
191
  }
191
192
 
@@ -246,6 +247,19 @@
246
247
  stroke-width: 2.5;
247
248
  }
248
249
 
250
+ .viz .chip {
251
+ cursor: help;
252
+ }
253
+
254
+ .viz .graph.chip-active .edge:not(.sent) {
255
+ opacity: 0.2;
256
+ }
257
+
258
+ .viz .edge.sent path {
259
+ stroke: var(--accent);
260
+ stroke-width: 2.5;
261
+ }
262
+
249
263
  .viz .state:focus-visible .frame,
250
264
  .viz .edge:focus-visible path {
251
265
  stroke: var(--accent);
@@ -316,8 +330,8 @@
316
330
  font-weight: 700;
317
331
  }
318
332
 
319
- .viz .edge.effect.running {
320
- opacity: 1;
333
+ .viz .chip.running rect {
334
+ fill-opacity: 0.35;
321
335
  }
322
336
 
323
337
  .viz .running path {
@@ -404,3 +418,22 @@
404
418
  stroke: var(--accent);
405
419
  stroke-dasharray: 4 3;
406
420
  }
421
+
422
+ .viz .explore-context summary {
423
+ cursor: pointer;
424
+ font-weight: 600;
425
+ }
426
+
427
+ .viz .explore-context pre {
428
+ margin: 4px 0 0;
429
+ overflow: auto;
430
+ max-height: 40vh;
431
+ font-size: 12px;
432
+ background: var(--region-bg);
433
+ }
434
+
435
+ .viz .explore-context mark.context-changed {
436
+ background: var(--banner-bg);
437
+ color: var(--banner-fg);
438
+ font-weight: 600;
439
+ }