@mantaq/viz 0.0.0 → 0.2.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.
@@ -0,0 +1,1332 @@
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
+ import { Either } from "@mantaq/utils";
3
+ import { Graph, layout } from "@dagrejs/dagre";
4
+ // src/viewer/dom.ts
5
+ const SVG_NS = "http://www.w3.org/2000/svg";
6
+ function append(parent, children) {
7
+ for (const child of children) {
8
+ if (child === null) continue;
9
+ parent.append(child);
10
+ }
11
+ }
12
+ function setAttributes(element, attrs) {
13
+ for (const [name, value] of Object.entries(attrs)) if (value !== null) element.setAttribute(name, String(value));
14
+ }
15
+ /** HTML element with attributes (null skips) and children. */
16
+ function html(doc, tag, attrs = {}, children = []) {
17
+ const element = doc.createElement(tag);
18
+ setAttributes(element, attrs);
19
+ append(element, children);
20
+ return element;
21
+ }
22
+ /** SVG element with attributes (null skips) and children. */
23
+ function svg(doc, tag, attrs = {}, children = []) {
24
+ const element = doc.createElementNS(SVG_NS, tag);
25
+ setAttributes(element, attrs);
26
+ append(element, children);
27
+ return element;
28
+ }
29
+ /** Make an element act as a button for mouse and keyboard (Enter / Space). */
30
+ function activatable(element, onActivate) {
31
+ element.setAttribute("tabindex", "0");
32
+ element.setAttribute("role", "button");
33
+ element.addEventListener("click", onActivate);
34
+ element.addEventListener("keydown", (event) => {
35
+ if (!("key" in event)) return;
36
+ if (event.key !== "Enter" && event.key !== " ") return;
37
+ event.preventDefault();
38
+ onActivate();
39
+ });
40
+ }
41
+ // src/viewer/coverage-mode.ts
42
+ function items(machine) {
43
+ return [...machine.states, ...machine.edges];
44
+ }
45
+ /** Modes this machine has data for, best first: tests answer "what is untested?". */
46
+ function availableModes(machine) {
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");
50
+ return modes;
51
+ }
52
+ function measure(mode, item) {
53
+ if (mode === "run") return item.coverage;
54
+ if (mode === "tests") return item.tested;
55
+ return null;
56
+ }
57
+ function modeLabel(mode, machine) {
58
+ if (mode === "tests") return "example tests";
59
+ if (mode === "run") return machine.fuzz === null ? "supplied run" : "fuzz runs";
60
+ return "off";
61
+ }
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
+ // 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(", ")}`;
78
+ }
79
+ // src/viewer/layout.ts
80
+ const CHAR_WIDTH = 7;
81
+ const NODE_HEIGHT = 36;
82
+ const LABEL_HEIGHT = 16;
83
+ const UNKNOWN_SIZE = 28;
84
+ function anyIds(machine) {
85
+ return [...new Set(machine.edges.map((edge) => edge.from).filter(isAnyState))].sort();
86
+ }
87
+ function unknownNodeId(edge) {
88
+ return `?:${edge.id}`;
89
+ }
90
+ function regionNodeId(region) {
91
+ return `region:${region}`;
92
+ }
93
+ function stateWidth(state) {
94
+ return Math.max(80, state.label.length * CHAR_WIDTH + 24);
95
+ }
96
+ function parentRegion(region) {
97
+ const cut = region.lastIndexOf(".");
98
+ return cut === -1 ? null : region.slice(0, cut);
99
+ }
100
+ function addRegions(graph, regions) {
101
+ for (const region of regions) graph.setNode(regionNodeId(region), {
102
+ width: 0,
103
+ height: 0
104
+ });
105
+ for (const region of regions) {
106
+ const parent = parentRegion(region);
107
+ if (parent !== null && regions.includes(parent)) graph.setParent(regionNodeId(region), regionNodeId(parent));
108
+ }
109
+ }
110
+ function addStates(graph, machine) {
111
+ for (const state of machine.states) {
112
+ graph.setNode(state.id, {
113
+ width: stateWidth(state),
114
+ height: NODE_HEIGHT
115
+ });
116
+ if (state.region !== null) graph.setParent(state.id, regionNodeId(state.region));
117
+ }
118
+ }
119
+ function addAnyNodes(graph, machine) {
120
+ for (const id of anyIds(machine)) {
121
+ graph.setNode(id, {
122
+ width: 87,
123
+ height: NODE_HEIGHT
124
+ });
125
+ const region = scopeOf(id);
126
+ if (region !== "" && machine.regions.includes(region)) graph.setParent(id, regionNodeId(region));
127
+ }
128
+ }
129
+ function addEdges(graph, machine) {
130
+ for (const edge of machine.edges) {
131
+ if (edge.kind === "initial" || edge.to === edge.from) continue;
132
+ const target = edge.to ?? unknownNodeId(edge);
133
+ if (edge.to === null) {
134
+ graph.setNode(target, {
135
+ width: UNKNOWN_SIZE,
136
+ height: UNKNOWN_SIZE
137
+ });
138
+ const region = machine.states.find((state) => state.id === edge.from)?.region ?? null;
139
+ if (region !== null) graph.setParent(target, regionNodeId(region));
140
+ }
141
+ const label = {
142
+ width: edgeText(edge, machine).length * CHAR_WIDTH + 8,
143
+ height: LABEL_HEIGHT,
144
+ labelpos: "c"
145
+ };
146
+ graph.setEdge(edge.from, target, label, edge.id);
147
+ }
148
+ }
149
+ function box(label) {
150
+ return {
151
+ x: label.x ?? 0,
152
+ y: label.y ?? 0,
153
+ width: label.width,
154
+ height: label.height
155
+ };
156
+ }
157
+ function placeNodes(graph, machine) {
158
+ const states = machine.states.map((state) => ({
159
+ kind: "state",
160
+ id: state.id,
161
+ state,
162
+ ...box(graph.node(state.id))
163
+ }));
164
+ const unknowns = machine.edges.filter((edge) => edge.kind !== "initial" && edge.to === null).map((edge) => ({
165
+ kind: "unknown",
166
+ id: unknownNodeId(edge),
167
+ edgeId: edge.id,
168
+ ...box(graph.node(unknownNodeId(edge)))
169
+ }));
170
+ const anys = anyIds(machine).map((id) => ({
171
+ kind: "any",
172
+ id,
173
+ ...box(graph.node(id))
174
+ }));
175
+ return [
176
+ ...states,
177
+ ...anys,
178
+ ...unknowns
179
+ ];
180
+ }
181
+ function polyline(points) {
182
+ return points.map((point, index) => `${index === 0 ? "M" : "L"}${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(" ");
183
+ }
184
+ /**
185
+ * Self-loops are drawn by hand above the node: dagre routes them as a flat
186
+ * line. The nth loop on a node is taller, so several loops stay readable.
187
+ */
188
+ function selfLoop(node, nth) {
189
+ const { x, y, width, height } = box(node);
190
+ const top = y - height / 2;
191
+ const rise = 26 + nth * 18;
192
+ const spread = width / 2;
193
+ return {
194
+ path: `M${(x - width / 4).toFixed(1)},${top.toFixed(1)} C${(x - spread).toFixed(1)},${(top - rise).toFixed(1)} ${(x + spread).toFixed(1)},${(top - rise).toFixed(1)} ${(x + width / 4).toFixed(1)},${top.toFixed(1)}`,
195
+ label: {
196
+ x,
197
+ y: top - rise * .75 - 4
198
+ }
199
+ };
200
+ }
201
+ function placeEdges(graph, machine) {
202
+ const loopsSeen = /* @__PURE__ */ new Map();
203
+ return machine.edges.filter((edge) => edge.kind !== "initial").map((edge) => {
204
+ if (edge.to === edge.from) {
205
+ const nth = loopsSeen.get(edge.from) ?? 0;
206
+ loopsSeen.set(edge.from, nth + 1);
207
+ return {
208
+ edge,
209
+ target: edge.from,
210
+ ...selfLoop(graph.node(edge.from), nth)
211
+ };
212
+ }
213
+ const target = edge.to ?? unknownNodeId(edge);
214
+ const label = graph.edge(edge.from, target, edge.id);
215
+ return {
216
+ edge,
217
+ target,
218
+ path: polyline(label.points ?? []),
219
+ label: {
220
+ x: label.x ?? 0,
221
+ y: label.y ?? 0
222
+ }
223
+ };
224
+ });
225
+ }
226
+ function placeEntry(graph, machine) {
227
+ if (machine.initial === null) return null;
228
+ const node = box(graph.node(machine.initial));
229
+ const left = node.x - node.width / 2;
230
+ return {
231
+ from: {
232
+ x: left - 28,
233
+ y: node.y
234
+ },
235
+ to: {
236
+ x: left,
237
+ y: node.y
238
+ }
239
+ };
240
+ }
241
+ /**
242
+ * Layered layout (dagre, left to right). Pure: same machine in, same
243
+ * coordinates out. The initial edge is drawn as an entry arrow, not a graph edge.
244
+ */
245
+ function layoutMachine(machine) {
246
+ const graph = new Graph({
247
+ multigraph: true,
248
+ compound: true
249
+ });
250
+ graph.setGraph({
251
+ rankdir: "LR",
252
+ nodesep: 28,
253
+ ranksep: 64,
254
+ edgesep: 14,
255
+ marginx: 48,
256
+ marginy: 64
257
+ });
258
+ addRegions(graph, machine.regions);
259
+ addStates(graph, machine);
260
+ addAnyNodes(graph, machine);
261
+ addEdges(graph, machine);
262
+ layout(graph);
263
+ const size = graph.graph();
264
+ return {
265
+ width: size.width ?? 0,
266
+ height: size.height ?? 0,
267
+ nodes: placeNodes(graph, machine),
268
+ edges: placeEdges(graph, machine),
269
+ regions: machine.regions.map((region) => ({
270
+ region,
271
+ ...box(graph.node(regionNodeId(region)))
272
+ })),
273
+ entry: placeEntry(graph, machine)
274
+ };
275
+ }
276
+ // src/viewer/graph.ts
277
+ function sourceText$1(from) {
278
+ return isAnyState(from) ? "any state" : from;
279
+ }
280
+ function isUnreached(coverage) {
281
+ return coverage !== null && coverage.hits === 0;
282
+ }
283
+ function classes(names) {
284
+ return Object.entries(names).filter(([, on]) => on).map(([name]) => name).join(" ");
285
+ }
286
+ function defs(doc) {
287
+ return svg(doc, "defs", {}, [svg(doc, "marker", {
288
+ id: "viz-arrow",
289
+ viewBox: "0 0 10 10",
290
+ refX: 9,
291
+ refY: 5,
292
+ markerWidth: 7,
293
+ markerHeight: 7,
294
+ orient: "auto-start-reverse"
295
+ }, [svg(doc, "path", {
296
+ d: "M0,0 L10,5 L0,10 z",
297
+ class: "arrow-head"
298
+ })])]);
299
+ }
300
+ function regionGroup(doc, placed) {
301
+ return svg(doc, "g", {
302
+ class: "region",
303
+ "data-region": placed.region
304
+ }, [svg(doc, "rect", {
305
+ x: placed.x - placed.width / 2,
306
+ y: placed.y - placed.height / 2,
307
+ width: placed.width,
308
+ height: placed.height,
309
+ rx: 10
310
+ }), svg(doc, "text", {
311
+ x: placed.x - placed.width / 2 + 8,
312
+ y: placed.y - placed.height / 2 + 14
313
+ }, [placed.region])]);
314
+ }
315
+ function stateGroup(doc, placed, callbacks) {
316
+ const { state } = placed;
317
+ const left = placed.x - placed.width / 2;
318
+ const top = placed.y - placed.height / 2;
319
+ const frame = {
320
+ x: left,
321
+ y: top,
322
+ width: placed.width,
323
+ height: placed.height,
324
+ rx: 8
325
+ };
326
+ const group = svg(doc, "g", {
327
+ class: classes({
328
+ state: true,
329
+ initial: state.initial,
330
+ final: state.final,
331
+ unreached: isUnreached(measure(callbacks.mode, state))
332
+ }),
333
+ "data-node-id": state.id,
334
+ "aria-label": `state ${state.id}${state.final ? " (final)" : ""}`
335
+ }, [
336
+ svg(doc, "rect", {
337
+ ...frame,
338
+ class: "frame"
339
+ }),
340
+ state.final ? svg(doc, "rect", {
341
+ x: left + 4,
342
+ y: top + 4,
343
+ width: placed.width - 8,
344
+ height: placed.height - 8,
345
+ rx: 5,
346
+ class: "frame inner"
347
+ }) : null,
348
+ svg(doc, "text", {
349
+ x: placed.x,
350
+ y: placed.y + 4,
351
+ "text-anchor": "middle"
352
+ }, [state.label])
353
+ ]);
354
+ activatable(group, () => callbacks.select({
355
+ kind: "state",
356
+ id: state.id
357
+ }));
358
+ return group;
359
+ }
360
+ function unknownGroup(doc, placed) {
361
+ return svg(doc, "g", {
362
+ class: "unknown-node",
363
+ "data-node-id": placed.id,
364
+ "aria-label": "undetermined target"
365
+ }, [svg(doc, "circle", {
366
+ cx: placed.x,
367
+ cy: placed.y,
368
+ r: placed.width / 2
369
+ }), svg(doc, "text", {
370
+ x: placed.x,
371
+ y: placed.y + 5,
372
+ "text-anchor": "middle"
373
+ }, ["?"])]);
374
+ }
375
+ function anyGroup(doc, placed) {
376
+ return svg(doc, "g", {
377
+ class: "any-node",
378
+ "data-node-id": placed.id,
379
+ "aria-label": "any non-final state (onAny handlers)"
380
+ }, [svg(doc, "rect", {
381
+ x: placed.x - placed.width / 2,
382
+ y: placed.y - placed.height / 2,
383
+ width: placed.width,
384
+ height: placed.height,
385
+ rx: placed.height / 2
386
+ }), svg(doc, "text", {
387
+ x: placed.x,
388
+ y: placed.y + 4,
389
+ "text-anchor": "middle"
390
+ }, ["any state"])]);
391
+ }
392
+ function nodeGroup(doc, node, callbacks) {
393
+ if (node.kind === "state") return stateGroup(doc, node, callbacks);
394
+ return node.kind === "any" ? anyGroup(doc, node) : unknownGroup(doc, node);
395
+ }
396
+ function edgeAria(edge, text) {
397
+ if (edge.kind === "effect") return `${edge.from} ${text} on entry`;
398
+ if (edge.kind === "stay") return `${sourceText$1(edge.from)} on ${text} stays`;
399
+ return `${sourceText$1(edge.from)} on ${text} to ${edge.to ?? "undetermined"}`;
400
+ }
401
+ function edgeGroup(doc, placed, context) {
402
+ const { edge } = placed;
403
+ const internal = context.machine.events.find((event) => event.id === edge.event)?.internal;
404
+ const text = edgeText(edge, context.machine);
405
+ const group = svg(doc, "g", {
406
+ class: classes({
407
+ edge: true,
408
+ effect: edge.kind === "effect",
409
+ stay: edge.kind === "stay",
410
+ fuzz: edge.origin === "fuzz",
411
+ undetermined: edge.to === null,
412
+ internal: internal === true,
413
+ unreached: isUnreached(measure(context.callbacks.mode, edge))
414
+ }),
415
+ "data-edge-id": edge.id,
416
+ "data-from": edge.from,
417
+ "data-to": placed.target,
418
+ "aria-label": edgeAria(edge, text)
419
+ }, [svg(doc, "path", {
420
+ d: placed.path,
421
+ "marker-end": "url(#viz-arrow)"
422
+ }), svg(doc, "text", {
423
+ x: placed.label.x,
424
+ y: placed.label.y + 4,
425
+ "text-anchor": "middle"
426
+ }, [text])]);
427
+ activatable(group, () => context.callbacks.select({
428
+ kind: "edge",
429
+ id: edge.id
430
+ }));
431
+ return group;
432
+ }
433
+ function entryArrow(doc, entry) {
434
+ return svg(doc, "g", {
435
+ class: "entry",
436
+ "aria-hidden": "true"
437
+ }, [svg(doc, "circle", {
438
+ cx: entry.from.x,
439
+ cy: entry.from.y,
440
+ r: 4
441
+ }), svg(doc, "path", {
442
+ d: `M${entry.from.x},${entry.from.y} L${entry.to.x},${entry.to.y}`,
443
+ "marker-end": "url(#viz-arrow)"
444
+ })]);
445
+ }
446
+ /** Toggle `highlight` on an edge and both of its endpoints while hovered. */
447
+ function wireHover(root) {
448
+ for (const edge of root.querySelectorAll(".edge")) {
449
+ const ends = /* @__PURE__ */ new Set([edge.dataset["from"], edge.dataset["to"]]);
450
+ const endpoints = () => [...root.querySelectorAll("[data-node-id]")].filter((node) => ends.has(node.dataset["nodeId"]));
451
+ edge.addEventListener("mouseenter", () => {
452
+ for (const element of [edge, ...endpoints()]) element.classList.add("highlight");
453
+ });
454
+ edge.addEventListener("mouseleave", () => {
455
+ for (const element of [edge, ...endpoints()]) element.classList.remove("highlight");
456
+ });
457
+ }
458
+ }
459
+ /** Static SVG for one machine. Regions first, then edges, then nodes, so nodes sit on top. */
460
+ function renderGraph(doc, machine, callbacks) {
461
+ const placed = layoutMachine(machine);
462
+ const root = svg(doc, "svg", {
463
+ class: "graph",
464
+ viewBox: `0 0 ${Math.ceil(placed.width)} ${Math.ceil(placed.height)}`,
465
+ width: Math.ceil(placed.width),
466
+ height: Math.ceil(placed.height),
467
+ style: `max-width: ${Math.ceil(placed.width)}px`,
468
+ role: "group",
469
+ "aria-label": `machine ${machine.id}`
470
+ }, [
471
+ defs(doc),
472
+ ...placed.regions.map((region) => regionGroup(doc, region)),
473
+ ...placed.edges.map((edge) => edgeGroup(doc, edge, {
474
+ machine,
475
+ callbacks
476
+ })),
477
+ placed.entry ? entryArrow(doc, placed.entry) : null,
478
+ ...placed.nodes.map((node) => nodeGroup(doc, node, callbacks))
479
+ ]);
480
+ wireHover(root);
481
+ return root;
482
+ }
483
+ // src/viewer/explanation.ts
484
+ function where(location) {
485
+ return location === null ? "unknown location" : `${location.file}:${location.line}:${location.column}`;
486
+ }
487
+ function targetText$2(branch) {
488
+ if (branch.target.kind === "state") return branch.target.state;
489
+ if (branch.target.kind === "stay") return "(stay)";
490
+ return `? ${branch.target.text}`;
491
+ }
492
+ /** Does the graph hold an edge for this branch from the selected edge's state? */
493
+ function inGraph(branch, context) {
494
+ const { edge, machine } = context;
495
+ const target = branch.target;
496
+ return machine.edges.some((candidate) => {
497
+ if (candidate.from !== edge.from || candidate.event !== edge.event) return false;
498
+ if (target.kind === "stay") return candidate.kind === "stay";
499
+ return target.kind === "state" && candidate.to === target.state;
500
+ });
501
+ }
502
+ function isThisEdge(branch, edge) {
503
+ if (branch.target.kind === "stay") return edge.kind === "stay";
504
+ return branch.target.kind === "state" && branch.target.state === edge.to;
505
+ }
506
+ function branchItem(doc, branch, context) {
507
+ const condition = branch.conditions.length === 0 ? "always" : branch.conditions.join(" && ");
508
+ const status = isThisEdge(branch, context.edge) ? "this edge" : inGraph(branch, context) ? "in graph" : "not in graph";
509
+ return html(doc, "li", {
510
+ class: "branch",
511
+ "data-branch-status": status
512
+ }, [
513
+ html(doc, "code", {}, [condition]),
514
+ ` → ${targetText$2(branch)} `,
515
+ html(doc, "span", { class: "branch-status" }, [`(${status})`])
516
+ ]);
517
+ }
518
+ function comparisonText(comparison) {
519
+ return `${comparison.source}.${comparison.path} ${comparison.operator} ${JSON.stringify(comparison.literal)}`;
520
+ }
521
+ function line(doc, label, value) {
522
+ return html(doc, "p", { "data-explain": label }, [html(doc, "span", { class: "muted" }, [`${label}: `]), value]);
523
+ }
524
+ function joined$1(values) {
525
+ return values.length === 0 ? "none" : values.join(", ");
526
+ }
527
+ /**
528
+ * Static explanation of an edge's handler. Branches the graph lacks are
529
+ * listed as "not in graph": the AST annotates, it never adds edges.
530
+ */
531
+ function renderExplanation(doc, context) {
532
+ const explanation = context.edge.explanation;
533
+ if (explanation === null) {
534
+ const kind = context.edge.kind;
535
+ return kind === "initial" || kind === "effect" ? "no handler behind this edge" : "not analyzed";
536
+ }
537
+ if (explanation.status === "not_analyzed") return html(doc, "div", { class: "explanation not-analyzed" }, [
538
+ line(doc, "not analyzed", explanation.reason),
539
+ line(doc, "at", where(explanation.location)),
540
+ explanation.snippet === null ? null : html(doc, "pre", {}, [explanation.snippet])
541
+ ]);
542
+ return html(doc, "div", { class: "explanation" }, [
543
+ line(doc, "at", `${where(explanation.location)} (m.${explanation.registration})`),
544
+ html(doc, "ul", { class: "branches" }, explanation.branches.map((branch) => branchItem(doc, branch, context))),
545
+ explanation.readsActorState ? line(doc, "note", "reads live actor state; probing cannot determine the target") : null,
546
+ line(doc, "payload reads", joined$1(explanation.payloadReads)),
547
+ line(doc, "context reads", joined$1(explanation.contextReads)),
548
+ line(doc, "context writes", joined$1(explanation.contextWrites)),
549
+ line(doc, "comparisons", joined$1(explanation.comparisons.map(comparisonText)))
550
+ ]);
551
+ }
552
+ // src/viewer/panel.ts
553
+ function list(doc, rows) {
554
+ return html(doc, "dl", {}, rows.flatMap(([label, value]) => [html(doc, "dt", {}, [label]), html(doc, "dd", { "data-field": label }, [value])]));
555
+ }
556
+ function joined(values) {
557
+ return values.length === 0 ? "none" : values.join(", ");
558
+ }
559
+ function hitsText(coverage) {
560
+ return coverage === null ? "not supplied" : String(coverage.hits);
561
+ }
562
+ const ORIGIN_LABELS = {
563
+ declared: "declared (builder `to`)",
564
+ probe: "probe",
565
+ fuzz: "fuzz (observed, not declared)"
566
+ };
567
+ function runLabel(machine) {
568
+ return machine.fuzz === null ? "run hits" : "fuzz hits";
569
+ }
570
+ /**
571
+ * A declared or probed edge no fuzz run produced. `to` lists what the handler
572
+ * may return, not what its guards allow; probing runs it with `{}` and a sample
573
+ * context. Either way the live machine may never take it.
574
+ */
575
+ function predictionNote(doc, edge, machine) {
576
+ const unobserved = edge.coverage !== null && edge.coverage.hits === 0;
577
+ if (machine.fuzz === null || edge.origin === "fuzz" || edge.kind !== "transition" || !unobserved) return null;
578
+ return html(doc, "p", {
579
+ class: "prediction-note",
580
+ role: "note"
581
+ }, [`${edge.origin === "declared" ? "The handler declares this target in `to`, but no fuzz run produced it. Its guards may never allow it." : "Probing predicted this edge, but no fuzz run produced it. Probing calls the handler with an empty payload and a sample context, so this edge may be impossible in a real run."} Fuzzing never proves that, so check the handler.`]);
582
+ }
583
+ function stepText(step) {
584
+ if (step.kind === "advance") return `advance ${step.ms}ms`;
585
+ return step.payload === null ? step.event : `${step.event} ${JSON.stringify(step.payload)}`;
586
+ }
587
+ function featureOf(edge) {
588
+ if (edge.kind === "stay") return `stay:${edge.from}>${edge.event}`;
589
+ if (edge.kind !== "transition" || edge.to === null) return null;
590
+ return `edge:${edge.from}>${edge.event}>${edge.to}`;
591
+ }
592
+ /** Shortest kept fuzz sequence that first reached this edge, as a numbered list. */
593
+ function reachedBy(doc, edge, machine) {
594
+ const feature = featureOf(edge);
595
+ if (machine.fuzz === null) return "not fuzzed";
596
+ const shortest = machine.fuzz.corpus.filter((sequence) => feature !== null && sequence.discovered.includes(feature)).sort((left, right) => left.steps.length - right.steps.length)[0];
597
+ if (shortest === void 0) return "no kept sequence";
598
+ if (shortest.steps.length === 0) return "initial state";
599
+ return html(doc, "ol", { class: "steps" }, shortest.steps.map((step) => html(doc, "li", {}, [stepText(step)])));
600
+ }
601
+ function targetText$1(edge) {
602
+ if (edge.kind === "stay") return `${edge.to ?? edge.from} (handled, no state change)`;
603
+ return edge.to ?? "undetermined";
604
+ }
605
+ function sourceText(from) {
606
+ return isAnyState(from) ? "any state" : from;
607
+ }
608
+ function routingNote(doc, edge, machine) {
609
+ if (!isAnyState(edge.from)) return null;
610
+ const warning = routingWarnings(machine).find((each) => each.event === edge.event);
611
+ return warning ? html(doc, "p", {
612
+ class: "routing-note",
613
+ role: "note"
614
+ }, [warning.message]) : null;
615
+ }
616
+ function edgeLink(doc, edge, select) {
617
+ const link = html(doc, "span", {
618
+ class: "link",
619
+ "data-edge-link": edge.id
620
+ }, [`${sourceText(edge.from)} —${edge.event || "initial"}→ ${edge.to ?? "?"}`]);
621
+ activatable(link, () => select({
622
+ kind: "edge",
623
+ id: edge.id
624
+ }));
625
+ return html(doc, "li", {}, [link]);
626
+ }
627
+ function edgeList(doc, edges, select) {
628
+ if (edges.length === 0) return "none";
629
+ return html(doc, "ul", {}, edges.map((edge) => edgeLink(doc, edge, select)));
630
+ }
631
+ function edgePanel(doc, edge, machine) {
632
+ const internal = machine.events.find((event) => event.id === edge.event)?.internal === true;
633
+ return html(doc, "section", {
634
+ class: "details",
635
+ "data-selected-edge": edge.id
636
+ }, [
637
+ html(doc, "h2", {}, [`Edge ${edge.id}`]),
638
+ routingNote(doc, edge, machine),
639
+ predictionNote(doc, edge, machine),
640
+ list(doc, [
641
+ ["source", isAnyState(edge.from) ? "any state (onAny)" : edge.from],
642
+ ["event", edge.kind === "initial" ? "(initial)" : `${edge.event}${internal ? " (internal)" : ""}`],
643
+ ["target", targetText$1(edge)],
644
+ ["doc", edge.doc ?? "none"],
645
+ ["kind", edge.kind],
646
+ ["origin", ORIGIN_LABELS[edge.origin]],
647
+ ["emits", joined(edge.emits)],
648
+ ["contexts", joined(edge.contexts)],
649
+ [runLabel(machine), hitsText(edge.coverage)],
650
+ ["test hits", hitsText(edge.tested)],
651
+ ["explanation", renderExplanation(doc, {
652
+ edge,
653
+ machine
654
+ })],
655
+ ["reached by", reachedBy(doc, edge, machine)]
656
+ ])
657
+ ]);
658
+ }
659
+ function statePanel(doc, state, context) {
660
+ const { machine, select } = context;
661
+ const flags = [state.initial ? "initial" : "", state.final ? "final" : ""].filter(Boolean);
662
+ const outgoing = machine.edges.filter((edge) => edge.from === state.id || !state.final && coveredByAny(edge.from, state.id));
663
+ const incoming = machine.edges.filter((edge) => edge.to === state.id && edge.kind !== "initial");
664
+ return html(doc, "section", {
665
+ class: "details",
666
+ "data-selected-state": state.id
667
+ }, [html(doc, "h2", {}, [`State ${state.id}`]), list(doc, [
668
+ ["id", state.id],
669
+ ["doc", state.doc ?? "none"],
670
+ ["region", state.region ?? "(root)"],
671
+ ["flags", joined(flags)],
672
+ ["effects on entry", joined(state.effects.map((effect) => effect.sends === null ? effect.name : `${effect.name} (sends ${effect.sends.join(", ") || "nothing"})`))],
673
+ [machine.fuzz === null ? "run visits" : "fuzz visits", hitsText(state.coverage)],
674
+ ["test visits", hitsText(state.tested)],
675
+ ["outgoing", edgeList(doc, outgoing, select)],
676
+ ["incoming", edgeList(doc, incoming, select)]
677
+ ])]);
678
+ }
679
+ function fuzzSummary(doc, machine) {
680
+ const { fuzz } = machine;
681
+ if (fuzz === null) return null;
682
+ const errors = fuzz.errors.length === 0 ? "none" : html(doc, "ul", {}, fuzz.errors.map((error) => html(doc, "li", { class: "fuzz-error" }, [`${error.from} on ${error.event}: ${error.message} — `, html(doc, "code", {}, [error.steps.map(stepText).join(" → ")])])));
683
+ return html(doc, "section", { class: "fuzz-summary" }, [html(doc, "h2", {}, ["Fuzz run"]), list(doc, [
684
+ ["seed", String(fuzz.seed)],
685
+ ["iterations", String(fuzz.iterations)],
686
+ ["kept sequences", String(fuzz.corpus.length)],
687
+ ["handler errors", errors]
688
+ ])]);
689
+ }
690
+ /** Side-panel body for the current selection, or a hint when nothing is selected. */
691
+ function renderPanel(doc, machine, context) {
692
+ const { selection } = context;
693
+ if (selection?.kind === "edge") {
694
+ const edge = machine.edges.find((candidate) => candidate.id === selection.id);
695
+ if (edge) return edgePanel(doc, edge, machine);
696
+ }
697
+ if (selection?.kind === "state") {
698
+ const state = machine.states.find((candidate) => candidate.id === selection.id);
699
+ if (state) return statePanel(doc, state, {
700
+ machine,
701
+ select: context.select
702
+ });
703
+ }
704
+ return html(doc, "div", {}, [html(doc, "p", { class: "hint" }, ["Select a state or edge."]), fuzzSummary(doc, machine)]);
705
+ }
706
+ // src/viewer/explore.ts
707
+ const GROUP_TITLES = {
708
+ input: "Send an input",
709
+ effect: "Effect reports back",
710
+ internal: "Other internal events"
711
+ };
712
+ function code(doc, text) {
713
+ return html(doc, "code", {}, [text]);
714
+ }
715
+ function button(doc, label, context) {
716
+ const element = html(doc, "button", {
717
+ type: "button",
718
+ disabled: context.disabled ? "" : null,
719
+ ...context.attrs
720
+ }, label);
721
+ element.addEventListener("click", context.onClick);
722
+ return element;
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
+ }
730
+ function targetText(move, target, current) {
731
+ return target === current && move.edge.kind === "stay" ? "stays" : `→ ${target}`;
732
+ }
733
+ function moveItem(doc, move, context) {
734
+ const { edge } = move;
735
+ const via = edge.from === context.current ? null : html(doc, "span", { class: "muted" }, [" (any state)"]);
736
+ if (move.targets.length === 0) return html(doc, "li", { "data-move": edge.id }, [
737
+ code(doc, edge.event),
738
+ via,
739
+ html(doc, "span", { class: "muted" }, [" → ? target depends on payload or context"]),
740
+ docNote(doc, {
741
+ text: edge.doc,
742
+ scope: "edge"
743
+ })
744
+ ]);
745
+ return html(doc, "li", { "data-move": edge.id }, [...move.targets.map((target) => button(doc, [
746
+ code(doc, edge.event),
747
+ ` ${targetText(move, target, context.current)}`,
748
+ ...via ? [via] : []
749
+ ], {
750
+ onClick: () => context.take({
751
+ edge: edge.id,
752
+ to: target
753
+ }),
754
+ attrs: {
755
+ "data-take": edge.id,
756
+ "data-target": target
757
+ }
758
+ })), docNote(doc, {
759
+ text: edge.doc,
760
+ scope: "edge"
761
+ })]);
762
+ }
763
+ function effectsSection(doc, machine, current) {
764
+ const effects = machine.states.find((state) => state.id === current)?.effects ?? [];
765
+ if (effects.length === 0) return null;
766
+ const unhandled = unhandledSends(machine, current);
767
+ const isUnhandled = (effect, event) => unhandled.some((each) => each.effect === effect && each.event === event);
768
+ return html(doc, "div", { class: "explore-effects" }, [
769
+ html(doc, "p", {}, [`Entering ${current} runs:`]),
770
+ html(doc, "ul", {}, effects.map((effect) => {
771
+ const sends = effect.sends ?? [];
772
+ const summary = effect.sends === null ? html(doc, "span", { class: "muted" }, [" (sends not declared)"]) : sends.length === 0 ? " sends nothing" : " sends ";
773
+ return html(doc, "li", { "data-effect": effect.name }, [
774
+ code(doc, effect.name),
775
+ summary,
776
+ ...sends.flatMap((event, index) => [index === 0 ? "" : ", ", html(doc, "span", { class: isUnhandled(effect.name, event) ? "unhandled" : null }, [code(doc, event)])])
777
+ ]);
778
+ })),
779
+ unhandled.length === 0 ? null : html(doc, "p", {
780
+ class: "explore-warning",
781
+ role: "note"
782
+ }, [
783
+ `No handler in ${current} for `,
784
+ ...unhandled.flatMap((each, index) => [index === 0 ? "" : ", ", code(doc, each.event)]),
785
+ ": the actor ignores it."
786
+ ])
787
+ ]);
788
+ }
789
+ function movesSection(doc, moves, context) {
790
+ if (context.final) return html(doc, "p", {
791
+ class: "explore-end explore-done",
792
+ role: "status"
793
+ }, [html(doc, "strong", {}, ["Done."]), ` ${context.current} is final: the actor has stopped.`]);
794
+ if (moves.length === 0) return html(doc, "p", { class: "explore-end" }, [`No handler in ${context.current}: stuck.`]);
795
+ return html(doc, "div", { class: "explore-moves" }, [
796
+ "input",
797
+ "effect",
798
+ "internal"
799
+ ].flatMap((group) => {
800
+ const inGroup = moves.filter((move) => move.group === group);
801
+ if (inGroup.length === 0) return [];
802
+ return [html(doc, "h3", {}, [GROUP_TITLES[group]]), html(doc, "ul", { "data-group": group }, inGroup.map((move) => moveItem(doc, move, context)))];
803
+ }));
804
+ }
805
+ function pathSection(doc, context) {
806
+ const { machine, path } = context;
807
+ const edges = new Map(machine.edges.map((edge) => [edge.id, edge]));
808
+ let from = machine.initial ?? "";
809
+ const items = path.map((step, index) => {
810
+ const event = edges.get(step.edge)?.event ?? step.edge;
811
+ const text = step.to === from ? `${event} in ${from}: stays` : `${event}: ${from} → ${step.to}`;
812
+ from = step.to;
813
+ return html(doc, "li", { "aria-current": index === path.length - 1 ? "step" : null }, [button(doc, [text], {
814
+ onClick: () => context.rewind(index + 1),
815
+ attrs: { "data-rewind": String(index + 1) }
816
+ })]);
817
+ });
818
+ return html(doc, "div", { class: "explore-path" }, [html(doc, "h3", {}, ["Path"]), html(doc, "ol", {}, [html(doc, "li", { "aria-current": path.length === 0 ? "step" : null }, [button(doc, [`start in ${machine.initial ?? "?"}`], {
819
+ onClick: () => context.rewind(0),
820
+ attrs: { "data-rewind": "0" }
821
+ })]), ...items])]);
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
+ }
981
+ /** Side-panel section: where the walk is, what can happen next, and how it got here. */
982
+ function explorePanel(doc, context) {
983
+ const { machine, path, live } = context;
984
+ const current = live?.state.state ?? stateAfter(machine, path) ?? "";
985
+ const final = machine.states.find((state) => state.id === current)?.final === true;
986
+ const length = live?.state.steps.length ?? path.length;
987
+ return html(doc, "section", {
988
+ class: "explore",
989
+ "data-current": current,
990
+ "data-done": final ? "" : null
991
+ }, [
992
+ html(doc, "h2", {}, ["Explore"]),
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), {
1009
+ current,
1010
+ final,
1011
+ take: context.take
1012
+ })],
1013
+ html(doc, "div", { class: "explore-controls" }, [button(doc, ["Back"], {
1014
+ onClick: () => context.rewind(length - 1),
1015
+ disabled: length === 0,
1016
+ attrs: { "data-explore-action": "back" }
1017
+ }), button(doc, ["Reset"], {
1018
+ onClick: () => context.rewind(0),
1019
+ disabled: length === 0,
1020
+ attrs: { "data-explore-action": "reset" }
1021
+ })]),
1022
+ live ? livePathSection(doc, context, live.state) : pathSection(doc, context)
1023
+ ]);
1024
+ }
1025
+ /**
1026
+ * Where a click on the diagram moves the walk, or null to just show details:
1027
+ * a next-step edge with one target, or a state exactly one next step reaches.
1028
+ */
1029
+ function clickStep(machine, context) {
1030
+ const current = context.live ?? stateAfter(machine, context.path);
1031
+ if (current === null) return null;
1032
+ const { selection } = context;
1033
+ const steps = movesFrom(machine, current).flatMap((move) => move.targets.map((to) => ({
1034
+ edge: move.edge.id,
1035
+ to
1036
+ })));
1037
+ const matching = selection.kind === "edge" ? steps.filter((step) => step.edge === selection.id) : steps.filter((step) => step.to === selection.id && step.to !== current);
1038
+ return matching.length === 1 ? matching[0] ?? null : null;
1039
+ }
1040
+ /** Mark the walk on the drawn graph: current state, path taken, possible next steps. */
1041
+ function markExplore(root, context) {
1042
+ const { machine, path } = context;
1043
+ const current = context.live ?? stateAfter(machine, path);
1044
+ const moves = current === null ? [] : movesFrom(machine, current);
1045
+ const next = new Set(moves.filter((move) => move.targets.length > 0).map((move) => move.edge.id));
1046
+ const reachable = new Set(moves.flatMap((move) => move.targets).filter((to) => to !== current));
1047
+ const taken = new Set(path.map((step) => step.edge));
1048
+ for (const element of root.querySelectorAll(".graph g[data-edge-id]")) {
1049
+ const id = element.dataset["edgeId"] ?? "";
1050
+ element.classList.toggle("taken", taken.has(id));
1051
+ element.classList.toggle("next", next.has(id));
1052
+ element.classList.toggle("running", element.classList.contains("effect") && element.dataset["from"] === current);
1053
+ }
1054
+ for (const element of root.querySelectorAll(".graph g[data-node-id]")) {
1055
+ const id = element.dataset["nodeId"];
1056
+ element.classList.toggle("current", id === current);
1057
+ element.classList.toggle("reachable", id !== void 0 && reachable.has(id));
1058
+ }
1059
+ }
1060
+ // src/viewer/viewer.ts
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.";
1063
+ const FUZZ_NOTE = "Green edges were observed while fuzzing but not declared. Fuzzing finds behaviour; it never proves an edge is impossible.";
1064
+ function effectiveMode(view, machine) {
1065
+ const modes = availableModes(machine);
1066
+ if (view.coverage === "off") return "off";
1067
+ if (view.coverage !== null && modes.includes(view.coverage)) return view.coverage;
1068
+ return modes[0] ?? "off";
1069
+ }
1070
+ function legend(doc, { mode, machine }) {
1071
+ const entries = [
1072
+ ["legend-final", "double border: final state"],
1073
+ ["legend-undetermined", "dashed edge to ?: target undetermined"],
1074
+ ["legend-internal", "dotted edge, italic label: internal event"],
1075
+ ["legend-effect", "faint self-loop: state runs effects on entry"],
1076
+ ["legend-current", "thick accent border: where the walk is"],
1077
+ ["legend-next", "pulsing: a possible next step"],
1078
+ ["legend-taken", "bold accent: path taken"]
1079
+ ];
1080
+ if (machine.fuzz !== null) entries.push(["legend-fuzz", "green: observed only while fuzzing"], ["legend-stay", "dotted self-loop: handled, no state change"]);
1081
+ if (mode !== "off") entries.push(["legend-unreached", unreachedText(mode, machine)]);
1082
+ return html(doc, "ul", { class: "legend" }, entries.map(([name, text]) => html(doc, "li", { class: name }, [text])));
1083
+ }
1084
+ function machineSelect(doc, viz, context) {
1085
+ if (viz.machines.length < 2) return null;
1086
+ const select = html(doc, "select", { class: "machine-select" }, viz.machines.map((machine) => html(doc, "option", {
1087
+ value: machine.id,
1088
+ selected: machine.id === context.view.machineId ? "" : null
1089
+ }, [machine.id])));
1090
+ select.addEventListener("change", () => context.onChange(select.value));
1091
+ return html(doc, "label", {}, ["Machine ", select]);
1092
+ }
1093
+ function isMode(value) {
1094
+ return value === "off" || value === "run" || value === "tests";
1095
+ }
1096
+ function coveragePicker(doc, context) {
1097
+ const modes = availableModes(context.machine);
1098
+ if (modes.length === 0) return null;
1099
+ const select = html(doc, "select", { class: "coverage-mode" }, [...modes, "off"].map((mode) => html(doc, "option", {
1100
+ value: mode,
1101
+ selected: mode === context.mode ? "" : null
1102
+ }, [modeLabel(mode, context.machine)])));
1103
+ select.addEventListener("change", () => {
1104
+ if (isMode(select.value)) context.onChange(select.value);
1105
+ });
1106
+ return html(doc, "label", {}, ["Coverage from ", select]);
1107
+ }
1108
+ /**
1109
+ * Mount a viewer for an viz document into `root`. Walking the graph moves a
1110
+ * cursor over the drawn edges; no actor runs. The document is the only input.
1111
+ */
1112
+ function mountViewer(root, viz, options = {}) {
1113
+ const doc = root.ownerDocument;
1114
+ const first = viz.machines.find((machine) => machine.id === options.machine) ?? viz.machines[0];
1115
+ const view = {
1116
+ machineId: first?.id ?? "",
1117
+ selection: null,
1118
+ coverage: null,
1119
+ path: first === void 0 || options.driver !== void 0 ? [] : decodePath(first, options.steps ?? []),
1120
+ live: null
1121
+ };
1122
+ const { driver } = options;
1123
+ if (driver !== void 0) view.live = replay(driver, decodeLiveSteps(options.steps ?? []));
1124
+ let back = () => void 0;
1125
+ root.addEventListener("keydown", (event) => {
1126
+ if (!("key" in event) || event.target instanceof HTMLSelectElement) return;
1127
+ if (event.key === "ArrowLeft") back();
1128
+ });
1129
+ function render() {
1130
+ const machine = viz.machines.find((candidate) => candidate.id === view.machineId);
1131
+ root.replaceChildren();
1132
+ root.classList.add("viz");
1133
+ if (!machine) {
1134
+ root.append(html(doc, "p", { class: "empty" }, ["Document has no machines."]));
1135
+ return;
1136
+ }
1137
+ const mode = effectiveMode(view, machine);
1138
+ root.classList.toggle("coverage-on", mode !== "off");
1139
+ const panel = html(doc, "aside", {
1140
+ class: "panel",
1141
+ "aria-live": "polite"
1142
+ });
1143
+ const refresh = () => {
1144
+ panel.replaceChildren(explorePanel(doc, {
1145
+ machine,
1146
+ path: view.path,
1147
+ take,
1148
+ rewind,
1149
+ ...view.live === null ? {} : { live: {
1150
+ state: view.live,
1151
+ send
1152
+ } }
1153
+ }), renderPanel(doc, machine, {
1154
+ selection: view.selection,
1155
+ select
1156
+ }));
1157
+ markExplore(root, {
1158
+ machine,
1159
+ path: view.path,
1160
+ ...liveState(view)
1161
+ });
1162
+ markSelection(root, view.selection);
1163
+ };
1164
+ const walked = () => {
1165
+ const steps = view.live === null ? view.path.map((step) => encodeStep(machine, step)) : view.live.steps.map(encodeLiveStep);
1166
+ options.onNavigate?.({
1167
+ machine: machine.id,
1168
+ steps
1169
+ });
1170
+ refresh();
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
+ };
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
+ }
1190
+ view.path = [...view.path, step];
1191
+ walked();
1192
+ };
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
+ }
1199
+ view.path = view.path.slice(0, Math.max(0, length));
1200
+ walked();
1201
+ };
1202
+ const select = (selection) => {
1203
+ const step = clickStep(machine, {
1204
+ path: view.path,
1205
+ selection,
1206
+ ...liveState(view)
1207
+ });
1208
+ view.selection = selection;
1209
+ if (step === null) refresh();
1210
+ else take(step);
1211
+ };
1212
+ back = () => rewind((view.live?.steps.length ?? view.path.length) - 1);
1213
+ root.append(headerOf(doc, {
1214
+ viz,
1215
+ machine,
1216
+ view,
1217
+ heading: options.heading ?? "h1",
1218
+ mode,
1219
+ render,
1220
+ onNavigate: options.onNavigate,
1221
+ live: driver !== void 0
1222
+ }), html(doc, "p", {
1223
+ class: "banner",
1224
+ role: "note"
1225
+ }, [driver === void 0 ? banner(machine) : LIVE_BANNER]), legend(doc, {
1226
+ mode,
1227
+ machine
1228
+ }), html(doc, "div", { class: "viz-main" }, [html(doc, "div", { class: "canvas" }, [renderGraph(doc, machine, {
1229
+ select,
1230
+ mode
1231
+ })]), panel]));
1232
+ refresh();
1233
+ }
1234
+ render();
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
+ }
1257
+ function headerOf(doc, context) {
1258
+ const { machine, view, render } = context;
1259
+ return html(doc, "header", { class: "viz-header" }, [
1260
+ html(doc, context.heading, {}, [`Viz: ${machine.id}`]),
1261
+ context.live ? null : machineSelect(doc, context.viz, {
1262
+ view,
1263
+ onChange: (id) => {
1264
+ view.machineId = id;
1265
+ view.selection = null;
1266
+ view.path = [];
1267
+ context.onNavigate?.({
1268
+ machine: id,
1269
+ steps: []
1270
+ });
1271
+ render();
1272
+ }
1273
+ }),
1274
+ coveragePicker(doc, {
1275
+ machine,
1276
+ mode: context.mode,
1277
+ onChange: (next) => {
1278
+ view.coverage = next;
1279
+ render();
1280
+ }
1281
+ })
1282
+ ]);
1283
+ }
1284
+ function banner(machine) {
1285
+ return machine.fuzz === null ? STRUCTURE_BANNER : `${STRUCTURE_BANNER} ${FUZZ_NOTE}`;
1286
+ }
1287
+ function markSelection(root, selection) {
1288
+ const attribute = selection?.kind === "edge" ? "edgeId" : "nodeId";
1289
+ for (const element of root.querySelectorAll(".graph g")) element.classList.toggle("selected", selection !== null && element.dataset[attribute] === selection.id);
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
+ }
1306
+ // src/viewer/embed.ts
1307
+ /**
1308
+ * Browser entry for embedding the viewer in another page (e.g. the docs site).
1309
+ * Kept apart from the root entry so bundles skip the fuzzer and the AST
1310
+ * annotator (TypeScript compiler).
1311
+ */
1312
+ /** Replace `root` with a list of reasons `source` cannot be shown. */
1313
+ function showIssues(root, context) {
1314
+ const doc = root.ownerDocument;
1315
+ root.replaceChildren(html(doc, "section", {
1316
+ class: "load-error",
1317
+ role: "alert"
1318
+ }, [html(doc, "h2", {}, [`Cannot show ${context.source}`]), html(doc, "ul", {}, context.issues.map((issue) => html(doc, "li", {}, [`${issue.path}: ${issue.message}`])))]));
1319
+ }
1320
+ /** Validate viz JSON text and mount it, or list what is wrong with it. */
1321
+ function mountVizText(root, context, options = {}) {
1322
+ const result = parseViz(context.text);
1323
+ if (Either.isLeft(result)) {
1324
+ showIssues(root, {
1325
+ source: context.source,
1326
+ issues: result[0]
1327
+ });
1328
+ return;
1329
+ }
1330
+ mountViewer(root, result[1], options);
1331
+ }
1332
+ export { mountViewer as a, STRUCTURE_BANNER as i, showIssues as n, FUZZ_NOTE as r, mountVizText as t };