@typeonce/effect-machine-devtools 0.24.0 → 0.26.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.
Files changed (82) hide show
  1. package/NOTICE +4 -0
  2. package/README.md +13 -13
  3. package/dist/DevToolsProtocol.d.ts +66 -630
  4. package/dist/DevToolsProtocol.d.ts.map +1 -1
  5. package/dist/DevToolsProtocol.js +0 -190
  6. package/dist/DevToolsProtocol.js.map +1 -1
  7. package/dist/MachineDocument.d.ts +38 -18
  8. package/dist/MachineDocument.d.ts.map +1 -1
  9. package/dist/MachineDocument.js +14 -12
  10. package/dist/MachineDocument.js.map +1 -1
  11. package/dist/MachineRegistry.d.ts +22 -2
  12. package/dist/MachineRegistry.d.ts.map +1 -1
  13. package/dist/MachineWalkthrough.d.ts +171 -0
  14. package/dist/MachineWalkthrough.d.ts.map +1 -0
  15. package/dist/MachineWalkthrough.js +98 -0
  16. package/dist/MachineWalkthrough.js.map +1 -0
  17. package/dist/ProjectInspector.d.ts +0 -2
  18. package/dist/ProjectInspector.d.ts.map +1 -1
  19. package/dist/ProjectInspector.js.map +1 -1
  20. package/dist/client/assets/index-BsorPSDS.css +1 -0
  21. package/dist/client/assets/index-eiptehRd.js +37 -0
  22. package/dist/client/index.html +4 -4
  23. package/dist/index.d.ts +2 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +2 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/internal/devServer.d.ts.map +1 -1
  28. package/dist/internal/devServer.js +4 -64
  29. package/dist/internal/devServer.js.map +1 -1
  30. package/dist/internal/evaluationWorker.d.ts.map +1 -1
  31. package/dist/internal/evaluationWorker.js +7 -263
  32. package/dist/internal/evaluationWorker.js.map +1 -1
  33. package/dist/internal/machineDocument.d.ts.map +1 -1
  34. package/dist/internal/machineDocument.js +3 -1
  35. package/dist/internal/machineDocument.js.map +1 -1
  36. package/dist/internal/machineWalkthrough.d.ts +26 -0
  37. package/dist/internal/machineWalkthrough.d.ts.map +1 -0
  38. package/dist/internal/machineWalkthrough.js +256 -0
  39. package/dist/internal/machineWalkthrough.js.map +1 -0
  40. package/dist/internal/projectInspector.d.ts.map +1 -1
  41. package/dist/internal/projectInspector.js +1 -11
  42. package/dist/internal/projectInspector.js.map +1 -1
  43. package/index.html +2 -2
  44. package/package.json +8 -7
  45. package/src/DevToolsProtocol.ts +0 -285
  46. package/src/MachineDocument.ts +21 -19
  47. package/src/MachineWalkthrough.ts +199 -0
  48. package/src/ProjectInspector.ts +0 -5
  49. package/src/index.ts +2 -2
  50. package/src/internal/browser/chart-layout-policy.ts +206 -0
  51. package/src/internal/browser/chart-layout.ts +686 -0
  52. package/src/internal/browser/chart-model.ts +244 -0
  53. package/src/internal/browser/chart-renderer.ts +774 -0
  54. package/src/internal/browser/example-machine.ts +15 -6
  55. package/src/internal/browser/input-form.ts +1 -418
  56. package/src/internal/browser/invoke-outcomes-example.ts +231 -0
  57. package/src/internal/browser/parallel-completion-example.ts +199 -0
  58. package/src/internal/browser/planner-example.ts +2 -0
  59. package/src/internal/browser/protocol-events-example.ts +200 -0
  60. package/src/internal/browser/styles.css +974 -342
  61. package/src/internal/browser/transition-semantics-example.ts +245 -0
  62. package/src/internal/browser/visualizer-analysis.ts +52 -0
  63. package/src/internal/browser/visualizer-app.ts +874 -706
  64. package/src/internal/browser/visualizer-model.ts +104 -0
  65. package/src/internal/devServer.ts +4 -82
  66. package/src/internal/evaluationWorker.ts +7 -371
  67. package/src/internal/machineDocument.ts +3 -1
  68. package/src/internal/machineWalkthrough.ts +355 -0
  69. package/src/internal/projectInspector.ts +3 -31
  70. package/dist/MachineSimulator.d.ts +0 -169
  71. package/dist/MachineSimulator.d.ts.map +0 -1
  72. package/dist/MachineSimulator.js +0 -98
  73. package/dist/MachineSimulator.js.map +0 -1
  74. package/dist/client/assets/index-B49Tjawc.js +0 -14
  75. package/dist/client/assets/index-BKH0cOG2.css +0 -1
  76. package/dist/internal/machineSimulator.d.ts +0 -5
  77. package/dist/internal/machineSimulator.d.ts.map +0 -1
  78. package/dist/internal/machineSimulator.js +0 -156
  79. package/dist/internal/machineSimulator.js.map +0 -1
  80. package/src/MachineSimulator.ts +0 -154
  81. package/src/internal/browser/simulation-client.ts +0 -20
  82. package/src/internal/machineSimulator.ts +0 -199
@@ -1,24 +1,30 @@
1
- import {
2
- type Diagnostic,
3
- protocolVersion,
4
- type SimulationFrame,
5
- type SimulationReady,
6
- type SimulationRequest
7
- } from "../../DevToolsProtocol.js"
1
+ import * as Effect from "effect/Effect"
2
+ import * as Result from "effect/Result"
3
+ import type { Diagnostic } from "../../DevToolsProtocol.js"
8
4
  import type {
9
5
  Activity as VisualizationActivity,
10
6
  Branch as VisualizationBranch,
7
+ InputSchema,
11
8
  MachineDocument as VisualizationDocument,
12
- Transition as VisualizationTransition
9
+ Transition as VisualizationTransition,
10
+ Trigger
13
11
  } from "../../MachineDocument.js"
14
- import { type InputForm, renderInputForm } from "./input-form.js"
15
- import { requestSimulation } from "./simulation-client.js"
12
+ import * as MachineWalkthrough from "../../MachineWalkthrough.js"
13
+ import {
14
+ type ChartInteractionAnchor,
15
+ type ChartPresentation,
16
+ type ChartView,
17
+ maximumChartZoom,
18
+ minimumChartZoom,
19
+ renderChart
20
+ } from "./chart-renderer.js"
21
+ import { type InputField, projectInputSchema } from "./input-form.js"
22
+ import { analyzeVisualization, type VisualizerAnalysis } from "./visualizer-analysis.js"
16
23
  import {
17
- type EventInspection,
24
+ branchTargetApi,
18
25
  type IncomingTransition,
19
26
  makeVisualizerModel,
20
27
  type StateInspection,
21
- type TopologyNode,
22
28
  triggerLabel
23
29
  } from "./visualizer-model.js"
24
30
 
@@ -33,16 +39,33 @@ const createElement = <Tag extends keyof HTMLElementTagNameMap>(
33
39
  return element
34
40
  }
35
41
 
36
- const metadata = (items: ReadonlyArray<readonly [string, string]>): HTMLDListElement => {
42
+ type MetadataValue = string | Node | null | undefined
43
+
44
+ const metadata = (items: ReadonlyArray<readonly [string, MetadataValue]>): HTMLDListElement => {
37
45
  const list = createElement("dl", "metadata")
38
46
  for (const [label, value] of items) {
39
- list.append(createElement("dt", undefined, label), createElement("dd", undefined, value))
47
+ if (value === null || value === undefined || value === "" || value === "none") continue
48
+ const description = createElement("dd")
49
+ description.append(value)
50
+ list.append(createElement("dt", undefined, label), description)
40
51
  }
41
52
  return list
42
53
  }
43
54
 
44
55
  const badge = (text: string, kind = "neutral"): HTMLSpanElement => createElement("span", `badge badge-${kind}`, text)
45
56
 
57
+ const stateStatus = (active: boolean, initial: boolean): HTMLSpanElement => {
58
+ const status = createElement(
59
+ "span",
60
+ `chart-state-status${active ? " is-active" : ""}${initial ? " is-initial" : ""}`
61
+ )
62
+ status.setAttribute(
63
+ "aria-label",
64
+ active && initial ? "active, initial state" : active ? "active" : initial ? "initial state" : "inactive"
65
+ )
66
+ return status
67
+ }
68
+
46
69
  type StateNavigator = (path: string) => void
47
70
 
48
71
  const stateLink = (path: string, label: string, navigate: StateNavigator): HTMLButtonElement => {
@@ -52,26 +75,35 @@ const stateLink = (path: string, label: string, navigate: StateNavigator): HTMLB
52
75
  return link
53
76
  }
54
77
 
55
- const renderBranch = (branch: VisualizationBranch, navigate: StateNavigator): HTMLElement => {
78
+ const renderBranch = (
79
+ document: VisualizationDocument,
80
+ source: string,
81
+ branch: VisualizationBranch,
82
+ navigate: StateNavigator
83
+ ): HTMLElement => {
56
84
  const row = createElement("div", "branch-row")
57
85
  const main = createElement("div", "branch-main")
58
86
  if (branch.type === "branch") main.append(badge(branch.title, "condition"))
59
- main.append(createElement("span", "branch-arrow", "→"))
60
87
  if (branch.target !== null) {
61
- main.append(stateLink(branch.target, branch.target, navigate))
88
+ main.append(createElement("span", "branch-arrow", "→"), stateLink(branch.target, branch.target, navigate))
89
+ } else if (branch.selection.kind === "update") {
90
+ main.append(
91
+ badge(branch.selection.scope === "local" ? "to.local.update" : "value update", "update"),
92
+ createElement("span", "branch-target", "Updates the owner value")
93
+ )
62
94
  } else {
63
- main.append(createElement("span", "branch-target", branch.updates.length > 0 ? "Remain in state" : "No target"))
95
+ main.append(createElement("span", "branch-target", "No target state"))
64
96
  }
65
97
  row.append(main)
66
98
 
67
- const details: Array<readonly [string, string]> = [
68
- ["Selection", branch.selection.kind],
69
- ["Scope", branch.selection.scope ?? "none"]
70
- ]
99
+ const api = branchTargetApi(document, source, branch)
100
+ const details: Array<readonly [string, MetadataValue]> = api === undefined
101
+ ? [["Selection", branch.selection.kind], ["Scope", branch.selection.scope]]
102
+ : [["API", api]]
71
103
  row.append(metadata(details))
72
104
  if (branch.updates.length > 0) {
73
105
  const updates = createElement("div", "branch-updates")
74
- updates.append(createElement("span", "branch-updates-label", "Updates"))
106
+ updates.append(createElement("span", "branch-updates-label", "Value owner"))
75
107
  branch.updates.forEach((path) => updates.append(stateLink(path, path, navigate)))
76
108
  row.append(updates)
77
109
  }
@@ -79,6 +111,7 @@ const renderBranch = (branch: VisualizationBranch, navigate: StateNavigator): HT
79
111
  }
80
112
 
81
113
  const renderTransition = (
114
+ document: VisualizationDocument,
82
115
  transition: VisualizationTransition,
83
116
  navigate: StateNavigator,
84
117
  showSource = false
@@ -86,44 +119,52 @@ const renderTransition = (
86
119
  const card = createElement("article", "inspection-card transition-card")
87
120
  const header = createElement("div", "card-header")
88
121
  const title = createElement("div", "card-title")
89
- title.append(badge(transition.trigger.type, "trigger"), createElement("strong", undefined, triggerLabel(transition)))
122
+ title.append(createElement("strong", undefined, triggerLabel(transition)))
90
123
  const flags = createElement("div", "card-flags")
124
+ flags.append(badge(transition.trigger.type, "trigger"))
91
125
  if (transition.reenter) flags.append(badge("reenter"))
92
126
  if (transition.acceptance === "declinable") flags.append(badge("declinable"))
93
127
  header.append(title, flags)
94
128
  card.append(header)
95
129
  if (showSource) {
96
- const source = createElement("div", "transition-source")
97
- source.append(createElement("span", undefined, "From"), stateLink(transition.source, transition.source, navigate))
130
+ const source = metadata([["Source", stateLink(transition.source, transition.source, navigate)]])
131
+ source.classList.add("card-metadata")
98
132
  card.append(source)
99
133
  }
100
134
 
101
- const branches = createElement("div", "branch-list")
102
- if (transition.branches.length === 0) {
103
- branches.append(createElement("div", "empty-inline", "No transition branches"))
104
- } else {
105
- transition.branches.forEach((branch) => branches.append(renderBranch(branch, navigate)))
135
+ if (transition.branches.length > 0) {
136
+ const branches = createElement("div", "branch-list")
137
+ transition.branches.forEach((branch) =>
138
+ branches.append(renderBranch(document, transition.source, branch, navigate))
139
+ )
140
+ card.append(branches)
106
141
  }
107
- card.append(branches)
108
142
  return card
109
143
  }
110
144
 
111
- const renderIncomingTransition = (incoming: IncomingTransition, navigate: StateNavigator): HTMLElement => {
145
+ const renderIncomingTransition = (
146
+ document: VisualizationDocument,
147
+ incoming: IncomingTransition,
148
+ navigate: StateNavigator
149
+ ): HTMLElement => {
112
150
  const card = createElement("article", "inspection-card incoming-card")
113
151
  const header = createElement("div", "card-header")
114
152
  const title = createElement("div", "card-title")
115
- title.append(
116
- badge(incoming.transition.trigger.type, "trigger"),
117
- createElement("strong", undefined, triggerLabel(incoming.transition))
118
- )
119
- header.append(title, stateLink(incoming.transition.source, incoming.transition.source, navigate))
153
+ title.append(createElement("strong", undefined, triggerLabel(incoming.transition)))
154
+ const flags = createElement("div", "card-flags")
155
+ flags.append(badge(incoming.transition.trigger.type, "trigger"))
156
+ header.append(title, flags)
120
157
  card.append(header)
121
158
 
122
- const details: Array<readonly [string, string]> = [
123
- ["Selection", incoming.branch.selection.kind],
124
- ["Scope", incoming.branch.selection.scope ?? "none"]
159
+ const api = branchTargetApi(document, incoming.transition.source, incoming.branch)
160
+ const details: Array<readonly [string, MetadataValue]> = [
161
+ ["Source", stateLink(incoming.transition.source, incoming.transition.source, navigate)],
162
+ ["API", api]
125
163
  ]
126
- if (incoming.branch.type === "branch") details.unshift(["Branch", incoming.branch.title])
164
+ if (api === undefined) {
165
+ details.push(["Selection", incoming.branch.selection.kind], ["Scope", incoming.branch.selection.scope])
166
+ }
167
+ if (incoming.branch.type === "branch") details.unshift(["Title", incoming.branch.title])
127
168
  card.append(metadata(details))
128
169
  return card
129
170
  }
@@ -140,15 +181,19 @@ const activityTitle = (activity: VisualizationActivity): string => {
140
181
  }
141
182
  }
142
183
 
143
- const renderActivity = (activity: VisualizationActivity): HTMLElement => {
184
+ const renderActivity = (activity: VisualizationActivity, navigate: StateNavigator): HTMLElement => {
144
185
  const card = createElement("article", "inspection-card activity-card")
145
186
  const header = createElement("div", "card-header")
146
187
  const title = createElement("div", "card-title")
147
- title.append(badge(activity.type, "activity"), createElement("strong", undefined, activityTitle(activity)))
148
- header.append(title)
188
+ title.append(createElement("strong", undefined, activityTitle(activity)))
189
+ const flags = createElement("div", "card-flags")
190
+ flags.append(badge(activity.type, `activity badge-activity-${activity.type}`))
191
+ header.append(title, flags)
149
192
  card.append(header)
150
193
 
151
- const details: Array<readonly [string, string]> = [["Owner", activity.source]]
194
+ const details: Array<readonly [string, MetadataValue]> = [
195
+ ["Owner", stateLink(activity.source, activity.source, navigate)]
196
+ ]
152
197
  if (activity.type === "timer") details.push(["Duration", activity.duration])
153
198
  if (activity.type === "effect") {
154
199
  details.push(["Success", activity.outcomes.success], ["Failure", activity.outcomes.failure])
@@ -160,24 +205,183 @@ const renderActivity = (activity: VisualizationActivity): HTMLElement => {
160
205
  return card
161
206
  }
162
207
 
163
- const inspectionSection = (title: string, count: number): HTMLElement => {
208
+ const inspectionSection = (title: string, count?: number): HTMLElement => {
164
209
  const header = createElement("div", "section-heading")
165
- header.append(createElement("h3", undefined, title), createElement("span", "section-count", String(count)))
210
+ header.append(createElement("h3", undefined, title))
211
+ if (count !== undefined) header.append(createElement("span", "section-count", String(count)))
166
212
  return header
167
213
  }
168
214
 
169
- const formSection = (title: string): HTMLElement => {
170
- const header = createElement("div", "section-heading")
171
- header.append(createElement("h3", undefined, title))
172
- return header
215
+ const inputType = (field: InputField): string => {
216
+ switch (field._tag) {
217
+ case "String":
218
+ return field.format ?? "string"
219
+ case "Number":
220
+ return field.integer ? "integer" : "number"
221
+ case "Boolean":
222
+ return "boolean"
223
+ case "Enum":
224
+ return "enum"
225
+ case "Literal":
226
+ return "literal"
227
+ case "Object":
228
+ return "object"
229
+ case "Array":
230
+ return `${inputType(field.item)}[]`
231
+ case "Union":
232
+ return "union"
233
+ case "Unsupported":
234
+ return "unknown"
235
+ }
236
+ }
237
+
238
+ const inputConstraints = (field: InputField): ReadonlyArray<string> => {
239
+ switch (field._tag) {
240
+ case "String":
241
+ return [
242
+ field.minLength === undefined ? undefined : `min ${field.minLength}`,
243
+ field.maxLength === undefined ? undefined : `max ${field.maxLength}`,
244
+ field.pattern === undefined ? undefined : `pattern ${field.pattern}`
245
+ ].filter((value): value is string => value !== undefined)
246
+ case "Number":
247
+ return [
248
+ field.minimum === undefined ? undefined : `≥ ${field.minimum}`,
249
+ field.maximum === undefined ? undefined : `≤ ${field.maximum}`
250
+ ].filter((value): value is string => value !== undefined)
251
+ case "Enum":
252
+ return field.values.map((value) => JSON.stringify(value))
253
+ case "Literal":
254
+ return [JSON.stringify(field.value)]
255
+ case "Array":
256
+ return [
257
+ field.minItems > 0 ? `min ${field.minItems}` : undefined,
258
+ field.maxItems === undefined ? undefined : `max ${field.maxItems}`
259
+ ].filter((value): value is string => value !== undefined)
260
+ default:
261
+ return []
262
+ }
173
263
  }
174
264
 
175
- const prettyJson = (value: unknown): string => JSON.stringify(value, null, 2)
265
+ const renderContractField = (
266
+ label: string,
267
+ required: boolean,
268
+ field: InputField,
269
+ omit: ReadonlySet<string>
270
+ ): HTMLElement | null => {
271
+ if (omit.has(label)) return null
272
+ const row = createElement("div", "input-contract-field")
273
+ const heading = createElement("div", "input-field-heading")
274
+ const identity = createElement("div", "input-field-identity")
275
+ identity.append(
276
+ createElement("span", "input-label", label),
277
+ createElement("span", "input-field-type", inputType(field)),
278
+ createElement("span", required ? "input-required" : "input-optional", required ? "required" : "optional")
279
+ )
280
+ heading.append(identity)
281
+ row.append(heading)
282
+ const constraints = inputConstraints(field)
283
+ if (constraints.length > 0) {
284
+ const values = createElement("div", "input-constraints")
285
+ constraints.forEach((value) => values.append(createElement("span", undefined, value)))
286
+ row.append(values)
287
+ }
288
+ if (field.description !== undefined) row.append(createElement("p", "input-description", field.description))
289
+ if (field._tag === "Object") {
290
+ const children = createElement("div", "input-contract-children")
291
+ field.fields.forEach(({ key, required, field }) => {
292
+ const child = renderContractField(key, required, field, omit)
293
+ if (child !== null) children.append(child)
294
+ })
295
+ if (children.childElementCount > 0) row.append(children)
296
+ } else if (field._tag === "Array" && field.item._tag === "Object") {
297
+ const children = createElement("div", "input-contract-children")
298
+ field.item.fields.forEach(({ key, required, field }) => {
299
+ const child = renderContractField(key, required, field, omit)
300
+ if (child !== null) children.append(child)
301
+ })
302
+ if (children.childElementCount > 0) row.append(children)
303
+ } else if (field._tag === "Union") {
304
+ const alternatives = createElement("div", "input-contract-alternatives")
305
+ field.alternatives.forEach((alternative, index) => {
306
+ const item = renderContractField(`Option ${index + 1}`, true, alternative, omit)
307
+ if (item !== null) alternatives.append(item)
308
+ })
309
+ row.append(alternatives)
310
+ } else if (field._tag === "Unsupported") {
311
+ row.append(createElement("p", "input-unsupported", field.reason))
312
+ }
313
+ return row
314
+ }
315
+
316
+ const renderInputContract = (
317
+ title: string,
318
+ schema: InputSchema,
319
+ omit: ReadonlyArray<string> = []
320
+ ): HTMLElement => {
321
+ const section = createElement("section", "inspector-section input-contract")
322
+ section.append(inspectionSection(title))
323
+ const projected = projectInputSchema(schema)
324
+ const fields = createElement("div", "input-contract-fields")
325
+ const omitted = new Set(omit)
326
+ if (projected._tag === "Object") {
327
+ projected.fields.forEach(({ key, required, field }) => {
328
+ const child = renderContractField(key, required, field, omitted)
329
+ if (child !== null) fields.append(child)
330
+ })
331
+ } else {
332
+ const child = renderContractField("value", true, projected, omitted)
333
+ if (child !== null) fields.append(child)
334
+ }
335
+ if (fields.childElementCount === 0) {
336
+ fields.append(createElement("p", "section-empty", "No additional data fields."))
337
+ }
338
+ section.append(fields)
339
+ return section
340
+ }
341
+
342
+ const contractSummary = (schema: InputSchema | null): string | null => {
343
+ if (schema === null) return null
344
+ const field = projectInputSchema(schema)
345
+ if (field._tag !== "Object") return inputType(field)
346
+ const fields = field.fields.filter(({ key }) => key !== "_tag")
347
+ if (fields.length === 0) return null
348
+ return fields.slice(0, 3).map(({ key, required, field }) => `${key}${required ? "" : "?"}: ${inputType(field)}`).join(
349
+ " · "
350
+ ) + (fields.length > 3 ? ` · +${fields.length - 3}` : "")
351
+ }
176
352
 
177
- const jsonBlock = (value: unknown): HTMLElement => createElement("pre", "json-value", prettyJson(value))
353
+ const triggerName = (trigger: Trigger): string => {
354
+ switch (trigger.type) {
355
+ case "event":
356
+ return trigger.event
357
+ case "always":
358
+ return "Always"
359
+ case "done":
360
+ return "Completion"
361
+ case "choice":
362
+ return "Choice"
363
+ case "invoke":
364
+ return `${trigger.id} · ${trigger.outcome}`
365
+ }
366
+ }
367
+
368
+ const decisionLabel = (decision: MachineWalkthrough.Decision): string => {
369
+ switch (decision) {
370
+ case "conditional-branch":
371
+ return "Choose branch"
372
+ case "declinable-transition":
373
+ return "Assume accepted"
374
+ case "automatic-trigger":
375
+ return "Advance automatic trigger"
376
+ case "invoke-outcome":
377
+ return "Choose invoke outcome"
378
+ }
379
+ }
178
380
 
179
- const eventName = (value: unknown): string =>
180
- typeof value === "object" && value !== null && "_tag" in value ? String(value._tag) : "event"
381
+ const unavailableLabel = (reason: MachineWalkthrough.UnavailableReason): string =>
382
+ reason === "history-unavailable"
383
+ ? "No history has been recorded for this target yet"
384
+ : "The target is resolved only at runtime"
181
385
 
182
386
  const activeTopology = (paths: ReadonlyArray<string>): string => {
183
387
  const leaves = paths.filter((path) => !paths.some((candidate) => candidate.startsWith(`${path}.`)))
@@ -186,68 +390,100 @@ const activeTopology = (paths: ReadonlyArray<string>): string => {
186
390
 
187
391
  export const renderVisualizer = (
188
392
  root: HTMLElement,
189
- machineKey: string,
393
+ _machineKey: string,
190
394
  visualization: VisualizationDocument,
191
395
  diagnostics: ReadonlyArray<Diagnostic> = []
192
396
  ): void => {
193
397
  const model = makeVisualizerModel(visualization)
194
- const rows = new Map<string, HTMLElement>()
195
- const nodes = new Map<string, HTMLElement>()
196
- const statuses = new Map<string, HTMLElement>()
197
- const eventButtons = new Map<string, HTMLElement>()
398
+ const analysis = analyzeVisualization(visualization)
399
+ const analysisCount = analysis.warnings.length + analysis.topologyNotes.length
400
+ const transitionsById = new Map(visualization.transitions.map((transition) => [transition.id, transition]))
198
401
  const eventSchemas = new Map(visualization.inputs.events.map(({ event, schema }) => [event, schema]))
199
- const relatedPaths = new Set<string>()
402
+ const relatedFrom = new Set<string>()
403
+ const relatedTo = new Set<string>()
404
+ const incomingTransitions = new Set<string>()
405
+ const outgoingTransitions = new Set<string>()
200
406
  let selectedPath: string | undefined
201
- let selectedEvent: string | undefined
202
- let selectedFrame: SimulationFrame | undefined
203
- let simulation: SimulationReady | undefined
204
- let simulationPending = false
205
- let activeInputForm: InputForm | undefined
407
+ let selectedTransition: string | undefined
408
+ let selectedFrame: MachineWalkthrough.Frame | undefined
409
+ let walkthrough: MachineWalkthrough.Session | undefined
410
+ let chartView: ChartView | undefined
411
+ let inspectorView: "selection" | "analysis" | undefined
206
412
 
207
- const activePaths = (): ReadonlyArray<string> => simulation?.current.activePaths ?? model.activePaths
208
- const candidateEvents = (): ReadonlyArray<string> => simulation?.current.candidateEvents ?? model.candidateEvents
413
+ const activePaths = (): ReadonlyArray<string> =>
414
+ walkthrough === undefined ? model.activePaths : MachineWalkthrough.current(walkthrough).after.activePaths
415
+ const availableChoices = (): ReadonlyArray<MachineWalkthrough.Choice> =>
416
+ walkthrough === undefined ? [] : MachineWalkthrough.choices(walkthrough)
209
417
 
210
418
  const shell = createElement("main", "app-shell")
211
419
  const workspace = createElement("section", "workspace")
212
- const treePanel = createElement("section", "tree-panel")
213
- treePanel.setAttribute("aria-label", `${model.machineId} topology`)
420
+ const chartPanel = createElement("section", "chart-panel")
421
+ chartPanel.setAttribute("aria-label", `${model.machineId} topology`)
214
422
  const inspector = createElement("aside", "inspector")
215
423
  inspector.setAttribute("aria-live", "polite")
424
+ inspector.setAttribute("aria-label", "Machine inspector")
425
+ inspector.hidden = true
426
+ const inspectorClose = createElement("button", "inspector-close", "Close")
427
+ inspectorClose.type = "button"
428
+ inspectorClose.setAttribute("aria-label", "Close details")
429
+ const inspectorContent = createElement("div", "inspector-content")
430
+ inspector.append(inspectorClose, inspectorContent)
431
+ const choicePicker = createElement("section", "transition-picker")
432
+ choicePicker.setAttribute("role", "dialog")
433
+ choicePicker.setAttribute("aria-label", "Available transitions")
434
+ choicePicker.hidden = true
435
+ const choicePickerHeader = createElement("div", "transition-picker-header")
436
+ const choicePickerTitle = createElement("strong")
437
+ const choicePickerClose = createElement("button", "transition-picker-close", "Close")
438
+ choicePickerClose.type = "button"
439
+ const choicePickerContent = createElement("div", "transition-picker-content")
440
+ choicePickerHeader.append(choicePickerTitle, choicePickerClose)
441
+ choicePicker.append(choicePickerHeader, choicePickerContent)
216
442
 
217
443
  const clearButton = createElement("button", "toolbar-button", "Clear selection")
218
444
  clearButton.type = "button"
219
445
  clearButton.disabled = true
220
- const expandButton = createElement("button", "toolbar-button", "Expand all")
221
- expandButton.type = "button"
222
- const collapseButton = createElement("button", "toolbar-button", "Collapse all")
223
- collapseButton.type = "button"
446
+ const detailsButton = createElement("button", "toolbar-button", "View details")
447
+ detailsButton.type = "button"
448
+ detailsButton.disabled = true
224
449
  const revealActiveButton = createElement("button", "toolbar-button", "Reveal active")
225
450
  revealActiveButton.type = "button"
226
451
  revealActiveButton.disabled = model.activePaths.length === 0
227
- const simulationButton = createElement("button", "toolbar-button", "Start simulation")
228
- simulationButton.type = "button"
229
- simulationButton.disabled = model.roots.length === 0
230
-
231
- const renderEmptyInspector = (): void => {
232
- activeInputForm = undefined
233
- inspector.replaceChildren()
234
- const summary = createElement("div", "inspector-empty")
235
- summary.append(createElement("span", "inspector-empty-kind", "Machine"))
236
- summary.append(createElement("h2", undefined, visualization.machineId))
237
- summary.append(metadata([
238
- ["Source", visualization.source?.file ?? "in memory"],
239
- ["Export", visualization.source?.exportName ?? "none"],
240
- ["Initial", visualization.initial.target],
241
- ["Selection", visualization.initial.selection.kind],
242
- ["Revision", String(visualization.revision)]
243
- ]))
244
- summary.append(createElement("p", undefined, "Select a state to inspect its transitions and activities."))
245
- inspector.append(summary)
452
+ const walkthroughButton = createElement("button", "toolbar-button", "Start simulation")
453
+ walkthroughButton.type = "button"
454
+ walkthroughButton.disabled = model.roots.length === 0
455
+ const analysisButton = createElement("button", "toolbar-button analysis-button")
456
+ analysisButton.type = "button"
457
+ analysisButton.hidden = analysisCount === 0
458
+ analysisButton.setAttribute("aria-pressed", "false")
459
+ analysisButton.append(
460
+ createElement("span", undefined, "Analysis"),
461
+ createElement("span", "analysis-button-count", String(analysisCount))
462
+ )
463
+
464
+ const hideInspector = (): void => {
465
+ inspectorContent.replaceChildren()
466
+ inspector.hidden = true
467
+ inspectorView = undefined
468
+ detailsButton.textContent = "View details"
469
+ analysisButton.setAttribute("aria-pressed", "false")
470
+ }
471
+
472
+ const showInspector = (view: "selection" | "analysis"): void => {
473
+ inspector.hidden = false
474
+ inspectorView = view
475
+ detailsButton.textContent = view === "selection" ? "Hide details" : "View details"
476
+ analysisButton.setAttribute("aria-pressed", view === "analysis" ? "true" : "false")
477
+ }
478
+
479
+ const closeChoicePicker = (): void => {
480
+ choicePicker.hidden = true
481
+ choicePickerContent.replaceChildren()
246
482
  }
247
483
 
248
484
  const renderInspection = (inspection: StateInspection): void => {
249
- activeInputForm = undefined
250
- inspector.replaceChildren()
485
+ inspectorContent.replaceChildren()
486
+ showInspector("selection")
251
487
  const header = createElement("header", "inspector-header")
252
488
  const breadcrumbs = createElement("nav", "breadcrumbs")
253
489
  breadcrumbs.setAttribute("aria-label", "State path")
@@ -255,17 +491,20 @@ export const renderVisualizer = (
255
491
  if (index > 0) breadcrumbs.append(createElement("span", "breadcrumb-separator", "/"))
256
492
  breadcrumbs.append(stateLink(item.path, item.label, navigateToState))
257
493
  })
258
- const eyebrow = createElement("div", "inspector-eyebrow")
259
- eyebrow.append(badge(inspection.state.type, "state"))
260
- if (activePaths().includes(inspection.state.path)) eyebrow.append(badge("active", "active"))
261
- if (inspection.initial) eyebrow.append(badge("initial", "initial"))
262
- header.append(breadcrumbs, eyebrow, createElement("h2", undefined, inspection.label))
494
+ const titleRow = createElement("div", "inspector-title-row")
495
+ const title = createElement("div", "inspector-state-title")
496
+ title.append(
497
+ stateStatus(activePaths().includes(inspection.state.path), inspection.initial),
498
+ createElement("h2", undefined, inspection.label)
499
+ )
500
+ titleRow.append(title, badge(inspection.state.type, "state"))
501
+ header.append(breadcrumbs, titleRow)
263
502
  header.append(metadata([
264
503
  ["Path", inspection.state.path],
265
- ["Parent", inspection.state.parent ?? "root"],
266
- ["Children", String(inspection.state.children.length)],
267
- ["Initial child", inspection.state.initial ?? "none"],
268
- ["History", inspection.state.history ?? "none"]
504
+ ["Parent", inspection.state.parent],
505
+ ["Children", inspection.state.children.length > 0 ? String(inspection.state.children.length) : null],
506
+ ["Initial child", inspection.state.initial],
507
+ ["History", inspection.state.history]
269
508
  ]))
270
509
  if (inspection.state.description !== null || inspection.state.documentation !== null) {
271
510
  const annotations = createElement("div", "state-annotations")
@@ -277,401 +516,276 @@ export const renderVisualizer = (
277
516
  }
278
517
  header.append(annotations)
279
518
  }
280
- inspector.append(header)
519
+ inspectorContent.append(header)
281
520
 
282
- const transitions = createElement("section", "inspector-section")
283
- transitions.append(inspectionSection("Transitions", inspection.outgoing.length))
284
- if (inspection.outgoing.length === 0) {
285
- transitions.append(createElement("p", "section-empty", "No transitions leave this state."))
286
- } else {
287
- inspection.outgoing.forEach((transition) => transitions.append(renderTransition(transition, navigateToState)))
521
+ if (inspection.outgoing.length > 0) {
522
+ const transitions = createElement("section", "inspector-section")
523
+ transitions.append(inspectionSection("Transitions", inspection.outgoing.length))
524
+ inspection.outgoing.forEach((transition) =>
525
+ transitions.append(renderTransition(visualization, transition, navigateToState))
526
+ )
527
+ inspectorContent.append(transitions)
288
528
  }
289
- inspector.append(transitions)
290
-
291
- const incoming = createElement("section", "inspector-section")
292
- incoming.append(inspectionSection("Entered by", inspection.incoming.length))
293
- if (inspection.incoming.length === 0) {
294
- incoming.append(createElement("p", "section-empty", "No transitions target this state."))
295
- } else {
529
+ if (inspection.incoming.length > 0) {
530
+ const incoming = createElement("section", "inspector-section")
531
+ incoming.append(inspectionSection("Entered by", inspection.incoming.length))
296
532
  inspection.incoming.forEach((transition) =>
297
- incoming.append(renderIncomingTransition(transition, navigateToState))
533
+ incoming.append(renderIncomingTransition(visualization, transition, navigateToState))
298
534
  )
535
+ inspectorContent.append(incoming)
299
536
  }
300
- inspector.append(incoming)
301
-
302
537
  if (inspection.activities.length > 0) {
303
538
  const activities = createElement("section", "inspector-section")
304
- activities.append(inspectionSection("Activities", inspection.activities.length))
305
- inspection.activities.forEach((activity) => activities.append(renderActivity(activity)))
306
- inspector.append(activities)
539
+ activities.append(inspectionSection("Invoked", inspection.activities.length))
540
+ inspection.activities.forEach((activity) => activities.append(renderActivity(activity, navigateToState)))
541
+ inspectorContent.append(activities)
307
542
  }
308
543
  }
309
544
 
310
- const renderEventInspection = (inspection: EventInspection): void => {
311
- activeInputForm = undefined
312
- inspector.replaceChildren()
545
+ const renderTransitionInspection = (transition: VisualizationTransition): void => {
546
+ inspectorContent.replaceChildren()
547
+ showInspector("selection")
313
548
  const header = createElement("header", "inspector-header")
314
- const eyebrow = createElement("div", "inspector-eyebrow")
315
- eyebrow.append(badge("event", "trigger"))
316
- const candidate = candidateEvents().includes(inspection.event)
317
- if (candidate) eyebrow.append(badge("enabled", "active"))
318
- header.append(eyebrow, createElement("h2", undefined, inspection.event))
549
+ const titleRow = createElement("div", "inspector-title-row")
550
+ const flags = createElement("div", "card-flags")
551
+ flags.append(badge(transition.trigger.type, "trigger"))
552
+ if (transition.reenter) flags.append(badge("reenter"))
553
+ if (transition.acceptance === "declinable") flags.append(badge("declinable"))
554
+ titleRow.append(createElement("h2", undefined, triggerLabel(transition)), flags)
555
+ header.append(titleRow)
319
556
  header.append(metadata([
320
- ["Status", candidate ? "enabled" : "not enabled"],
321
- ["Registrations", String(inspection.transitions.length)]
557
+ ["Source", transition.source],
558
+ ["Branches", transition.branches.length > 1 ? String(transition.branches.length) : null],
559
+ ["Acceptance", transition.acceptance === "declinable" ? transition.acceptance : null],
560
+ ["Reenter", transition.reenter ? "yes" : null]
322
561
  ]))
323
- inspector.append(header)
324
-
325
- if (simulation !== undefined && candidate) {
326
- const schema = eventSchemas.get(inspection.event)
327
- const composer = createElement("section", "inspector-section simulation-composer")
328
- composer.append(formSection("Event input"))
329
- if (schema === undefined) {
330
- composer.append(createElement(
331
- "p",
332
- "input-unsupported",
333
- "No public input schema is available for this event."
334
- ))
335
- } else {
336
- const input = renderInputForm(schema, {
337
- name: inspection.event,
338
- fixed: { _tag: inspection.event },
339
- omit: ["_tag"]
340
- })
341
- activeInputForm = input
342
- const send = createElement("button", "simulation-action", `Send ${inspection.event}`)
343
- send.type = "submit"
344
- send.disabled = simulationPending || !input.supported
345
- input.element.addEventListener("submit", (event) => {
346
- event.preventDefault()
347
- const result = input.read()
348
- const source = visualization.source
349
- if (!result.ok || source === null || simulation === undefined) return
350
- void runSimulation({
351
- _tag: "SendSimulationEvent",
352
- protocolVersion,
353
- key: machineKey,
354
- revision: visualization.revision,
355
- source,
356
- step: simulation.step,
357
- snapshot: simulation.snapshot,
358
- event: result.value as never
359
- })
360
- })
361
- input.element.append(send)
362
- composer.append(
363
- input.element,
562
+ inspectorContent.append(header)
563
+ if (transition.trigger.type === "event") {
564
+ const schema = eventSchemas.get(transition.trigger.event)
565
+ if (schema !== undefined) inspectorContent.append(renderInputContract("Data contract", schema, ["_tag"]))
566
+ }
567
+ if (transition.branches.length > 0) {
568
+ const details = createElement("section", "inspector-section")
569
+ details.append(inspectionSection("Branches", transition.branches.length))
570
+ const branches = createElement("div", "inspection-card branch-list")
571
+ transition.branches.forEach((branch) =>
572
+ branches.append(renderBranch(visualization, transition.source, branch, navigateToState))
573
+ )
574
+ details.append(branches)
575
+ inspectorContent.append(details)
576
+ }
577
+ }
578
+
579
+ const renderAnalysis = (findings: VisualizerAnalysis): void => {
580
+ inspectorContent.replaceChildren()
581
+ showInspector("analysis")
582
+
583
+ const header = createElement("header", "inspector-header analysis-header")
584
+ const titleRow = createElement("div", "inspector-title-row")
585
+ titleRow.append(
586
+ createElement("h2", undefined, "Analysis"),
587
+ badge(String(findings.warnings.length + findings.topologyNotes.length), "count")
588
+ )
589
+ header.append(
590
+ titleRow,
591
+ createElement(
592
+ "p",
593
+ "analysis-summary",
594
+ "Static checks from the captured machine definition. Machine callbacks are not executed."
595
+ )
596
+ )
597
+ inspectorContent.append(header)
598
+
599
+ const warnings = createElement("section", "inspector-section analysis-section")
600
+ warnings.append(inspectionSection("Unhandled events", findings.warnings.length))
601
+ if (findings.warnings.length === 0) {
602
+ warnings.append(createElement("p", "section-empty", "Every declared public event has a registered handler."))
603
+ } else {
604
+ for (const warning of findings.warnings) {
605
+ const card = createElement("article", "inspection-card analysis-card")
606
+ const cardHeader = createElement("div", "card-header")
607
+ const title = createElement("div", "card-title")
608
+ title.append(createElement("strong", undefined, warning.event))
609
+ cardHeader.append(title, badge("no handler", "warning"))
610
+ card.append(
611
+ cardHeader,
364
612
  createElement(
365
613
  "p",
366
- "simulation-note",
367
- "The real planner validates this event and shows every synchronous transition it selects."
614
+ "analysis-message",
615
+ "This public event is declared, but no state registers a handler for it."
368
616
  )
369
617
  )
618
+ warnings.append(card)
370
619
  }
371
- inspector.append(composer)
372
620
  }
621
+ inspectorContent.append(warnings)
373
622
 
374
- const transitions = createElement("section", "inspector-section")
375
- transitions.append(inspectionSection("Transitions", inspection.transitions.length))
376
- inspection.transitions.forEach((transition) =>
377
- transitions.append(renderTransition(transition, navigateToState, true))
378
- )
379
- inspector.append(transitions)
380
- }
381
-
382
- const renderSimulationFailure = (failureDiagnostics: ReadonlyArray<Diagnostic>): void => {
383
- activeInputForm = undefined
384
- inspector.replaceChildren()
385
- const header = createElement("header", "inspector-header")
386
- const eyebrow = createElement("div", "inspector-eyebrow")
387
- eyebrow.append(badge("planner error", "error"))
388
- header.append(eyebrow, createElement("h2", undefined, "Simulation could not continue"))
389
- inspector.append(header)
390
- const list = createElement("section", "inspector-section trace-list")
391
- failureDiagnostics.forEach((item) => {
392
- const card = createElement("article", "inspection-card trace-card")
393
- card.append(createElement("strong", undefined, item.code), createElement("pre", "trace-error", item.message))
394
- list.append(card)
395
- })
396
- inspector.append(list)
623
+ const topology = createElement("section", "inspector-section analysis-section")
624
+ topology.append(inspectionSection("Topology notes", findings.topologyNotes.length))
625
+ if (findings.topologyNotes.length === 0) {
626
+ topology.append(createElement("p", "section-empty", "Every state appears on a static path from initial."))
627
+ } else {
628
+ for (const note of findings.topologyNotes) {
629
+ const card = createElement("article", "inspection-card analysis-card")
630
+ const cardHeader = createElement("div", "card-header")
631
+ const title = createElement("div", "card-title")
632
+ title.append(stateLink(note.path, note.label, navigateToState))
633
+ cardHeader.append(title, badge(note.type, "state"))
634
+ card.append(
635
+ cardHeader,
636
+ createElement(
637
+ "p",
638
+ "analysis-message",
639
+ note.descendantCount === 0
640
+ ? "No statically known path reaches this state from the machine's initial configuration."
641
+ : `No statically known path reaches this state or its ${note.descendantCount} descendants from the machine's initial configuration.`
642
+ ),
643
+ createElement(
644
+ "p",
645
+ "analysis-qualification",
646
+ "Runtime-resolved targets or a resumed snapshot may still reach it."
647
+ )
648
+ )
649
+ topology.append(card)
650
+ }
651
+ }
652
+ inspectorContent.append(topology)
397
653
  }
398
654
 
399
655
  const renderPathGroup = (label: string, paths: ReadonlyArray<string>): HTMLElement => {
400
656
  const group = createElement("div", "trace-path-group")
401
657
  group.append(createElement("span", "trace-label", label))
402
658
  const values = createElement("div", "trace-paths")
403
- if (paths.length === 0) values.append(createElement("span", "section-empty", "none"))
404
659
  paths.forEach((path) => values.append(stateLink(path, path, navigateToState)))
660
+ if (paths.length === 0) values.append(createElement("span", "section-empty", "none"))
405
661
  group.append(values)
406
662
  return group
407
663
  }
408
664
 
409
- const renderTraceValues = (label: string, values: ReadonlyArray<unknown>): HTMLElement => {
410
- const section = createElement("div", "trace-values")
411
- section.append(createElement("span", "trace-label", label))
412
- if (values.length === 0) {
413
- section.append(createElement("span", "section-empty", "none"))
414
- } else {
415
- values.forEach((value) => section.append(jsonBlock(value)))
416
- }
417
- return section
418
- }
419
-
420
- const renderSimulationTrace = (frame: SimulationFrame): void => {
421
- activeInputForm = undefined
422
- inspector.replaceChildren()
665
+ const renderWalkthroughTrace = (frame: MachineWalkthrough.Frame): void => {
666
+ inspectorContent.replaceChildren()
667
+ showInspector("selection")
423
668
  const header = createElement("header", "inspector-header trace-header")
424
669
  const eyebrow = createElement("div", "inspector-eyebrow")
425
- eyebrow.append(badge("planned", "active"))
426
- if (frame.done) eyebrow.append(badge("done", "state"))
427
- const title = frame.trigger._tag === "Initial"
428
- ? "Initial plan"
429
- : `Event · ${eventName(frame.trigger.event)}`
670
+ eyebrow.append(badge("walkthrough", "active"))
671
+ const title = frame.choice === null
672
+ ? "Initial configuration"
673
+ : frame.choice.title ?? triggerName(frame.choice.trigger)
430
674
  header.append(eyebrow, createElement("h2", undefined, title))
431
675
  header.append(metadata([
432
676
  ["Step", String(frame.step)],
433
- ["Microsteps", String(frame.microsteps.length)],
434
- ["Active before", String(frame.before.activePaths.length)],
435
- ["Active after", String(frame.after.activePaths.length)]
677
+ [
678
+ "Result",
679
+ frame.changed
680
+ ? `${activeTopology(frame.before.activePaths)} → ${activeTopology(frame.after.activePaths)}`
681
+ : "Topology unchanged"
682
+ ]
436
683
  ]))
437
- const received = frame.trigger._tag === "Initial" ? frame.trigger.input : frame.trigger.event
438
- if (received !== undefined) header.append(jsonBlock(received))
439
- inspector.append(header)
684
+ inspectorContent.append(header)
440
685
 
441
686
  const topology = createElement("section", "inspector-section trace-topology")
442
- topology.append(inspectionSection("Topology change", frame.microsteps.length))
443
687
  topology.append(
688
+ inspectionSection("Topology"),
444
689
  renderPathGroup("Before", frame.before.activePaths),
690
+ renderPathGroup("Exit", frame.exitPaths),
691
+ renderPathGroup("Entry", frame.entryPaths),
445
692
  renderPathGroup("After", frame.after.activePaths)
446
693
  )
447
- inspector.append(topology)
694
+ inspectorContent.append(topology)
448
695
 
449
- const steps = createElement("section", "inspector-section trace-list")
450
- steps.append(inspectionSection("Microsteps", frame.microsteps.length))
451
- if (frame.microsteps.length === 0) {
452
- steps.append(createElement(
453
- "p",
454
- "section-empty",
455
- frame.trigger._tag === "Initial"
456
- ? "No automatic microsteps were needed."
457
- : "No transition accepted this event."
458
- ))
459
- }
460
- frame.microsteps.forEach((step) => {
461
- const card = createElement("article", "inspection-card trace-card")
462
- const cardHeader = createElement("div", "card-header")
463
- const cardTitle = createElement("div", "card-title")
464
- cardTitle.append(
465
- badge(`#${step.index + 1}`, "count"),
466
- createElement("strong", undefined, eventName(step.event))
467
- )
468
- cardHeader.append(cardTitle, badge(step.changed ? "changed" : "unchanged", step.changed ? "active" : "neutral"))
469
- card.append(cardHeader)
470
- if (step.transitions.length > 0) {
471
- const selected = createElement("div", "trace-transitions")
472
- selected.append(createElement("span", "trace-label", "Selected transitions"))
473
- step.transitions.forEach((transition) => {
474
- const row = createElement("div", "trace-transition")
475
- row.append(stateLink(transition.source, transition.source, navigateToState))
476
- row.append(createElement("span", "branch-arrow", "→"))
477
- const target = transition.resolvedTarget ?? transition.target
478
- if (target === null) row.append(createElement("span", "branch-target", "No target"))
479
- else row.append(stateLink(target, target, navigateToState))
480
- if (transition.branchKey !== null) row.append(badge(transition.branchKey, "condition"))
481
- if (transition.reenter) row.append(badge("reenter"))
482
- selected.append(row)
483
- if (transition.updates.length > 0) selected.append(renderPathGroup("Updates", transition.updates))
484
- })
485
- card.append(selected)
696
+ if (frame.choice === null) {
697
+ if (visualization.inputs.machine !== null) {
698
+ inspectorContent.append(renderInputContract("Machine input contract", visualization.inputs.machine))
486
699
  }
487
- card.append(renderPathGroup("Exit", step.exitPaths), renderPathGroup("Entry", step.entryPaths))
488
- if (step.raisedEvents.length > 0) card.append(renderTraceValues("Raised", step.raisedEvents))
489
- if (step.emittedEvents.length > 0) card.append(renderTraceValues("Emitted", step.emittedEvents))
490
- if (step.commands.length > 0) card.append(renderTraceValues("Commands", step.commands))
491
- card.append(renderPathGroup("Active after", step.activePaths))
492
- steps.append(card)
493
- })
494
- inspector.append(steps)
495
-
496
- if (frame.commands.length > 0 || frame.emittedEvents.length > 0 || frame.output !== undefined) {
497
- const results = createElement("section", "inspector-section trace-results")
498
- results.append(inspectionSection("Plan result", frame.commands.length + frame.emittedEvents.length))
499
- if (frame.commands.length > 0) results.append(renderTraceValues("Planned commands", frame.commands))
500
- if (frame.emittedEvents.length > 0) results.append(renderTraceValues("Emitted events", frame.emittedEvents))
501
- if (frame.output !== undefined) results.append(renderTraceValues("Output", [frame.output]))
502
- inspector.append(results)
503
- }
504
- }
505
-
506
- const renderStartSimulation = (): void => {
507
- activeInputForm = undefined
508
- inspector.replaceChildren()
509
- const header = createElement("header", "inspector-header")
510
- const eyebrow = createElement("div", "inspector-eyebrow")
511
- eyebrow.append(badge("planner", "active"))
512
- header.append(eyebrow, createElement("h2", undefined, "Start simulation"))
513
- inspector.append(header)
514
- const composer = createElement("section", "inspector-section simulation-composer")
515
- composer.append(formSection("Machine input"))
516
- const input = visualization.inputs.machine
517
- if (input === null) {
518
- composer.append(createElement("p", "section-empty", "This machine does not declare startup input."))
519
- inspector.append(composer)
520
700
  return
521
701
  }
522
- const form = renderInputForm(input, { name: "Machine input" })
523
- activeInputForm = form
524
- const start = createElement("button", "simulation-action", "Start simulation")
525
- start.type = "submit"
526
- start.disabled = simulationPending || visualization.source === null || !form.supported
527
- form.element.addEventListener("submit", (event) => {
528
- event.preventDefault()
529
- const source = visualization.source
530
- if (source === null) return
531
- const result = form.read()
532
- if (!result.ok) return
533
- const request: SimulationRequest = {
534
- _tag: "StartSimulation",
535
- protocolVersion,
536
- key: machineKey,
537
- revision: visualization.revision,
538
- source,
539
- input: result.value as never
540
- }
541
- void runSimulation(request)
542
- })
543
- form.element.append(start)
544
- composer.append(
545
- form.element,
546
- createElement(
547
- "p",
548
- "simulation-note",
549
- "Initialization and synchronous callbacks run in isolation. Runtime activities and planned commands are not started."
550
- )
551
- )
552
- inspector.append(composer)
553
- }
554
-
555
- async function runSimulation(request: SimulationRequest): Promise<void> {
556
- if (simulationPending) return
557
- simulationPending = true
558
- activeInputForm?.clearIssues()
559
- activeInputForm?.setPending(true)
560
- simulationFeedback.textContent = "Planning in an isolated worker…"
561
- simulationFeedback.dataset.status = "pending"
562
- updateSimulationUi()
563
- try {
564
- const result = await requestSimulation(request)
565
- if (result._tag === "SimulationFailed") {
566
- if (result.inputIssues.length > 0 && activeInputForm !== undefined) {
567
- simulationFeedback.textContent = "Some input fields are invalid"
568
- simulationFeedback.dataset.status = "error"
569
- activeInputForm.setIssues(result.inputIssues)
570
- return
571
- }
572
- selectedFrame = undefined
573
- if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected")
574
- selectedEvent = undefined
575
- simulationFeedback.textContent = result.diagnostics[0]?.message ?? "Simulation failed"
576
- simulationFeedback.dataset.status = "error"
577
- renderSimulationFailure(result.diagnostics)
578
- return
579
- }
580
- simulation = result
581
- activeInputForm = undefined
582
- if (selectedPath !== undefined) {
583
- nodes.get(selectedPath)?.classList.remove("is-selected")
584
- rows.get(selectedPath)?.setAttribute("aria-selected", "false")
585
- }
586
- if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected")
587
- selectedPath = undefined
588
- selectedEvent = undefined
589
- selectedFrame = result.frame
590
- clearRelations()
591
- result.frame.microsteps.forEach((step) => {
592
- step.transitions.forEach((transition) => {
593
- relatedPaths.add(transition.source)
594
- nodes.get(transition.source)?.classList.add("is-related-source")
595
- })
596
- step.entryPaths.forEach((path) => {
597
- relatedPaths.add(path)
598
- nodes.get(path)?.classList.add("is-related-target")
599
- })
600
- step.transitions.flatMap((transition) => transition.updates).forEach((path) => {
601
- relatedPaths.add(path)
602
- nodes.get(path)?.classList.add("is-related-update")
603
- })
604
- })
605
- clearButton.disabled = false
606
- const before = activeTopology(result.frame.before.activePaths)
607
- const after = activeTopology(result.frame.after.activePaths)
608
- simulationFeedback.textContent = result.frame.trigger._tag === "Initial"
609
- ? `Started in ${after}`
610
- : result.frame.microsteps.length === 0
611
- ? `${eventName(result.frame.trigger.event)} was not accepted · remained in ${after}`
612
- : before === after
613
- ? `${eventName(result.frame.trigger.event)} handled · remained in ${after}`
614
- : `${before} → ${after} · ${result.frame.microsteps.length} microstep${
615
- result.frame.microsteps.length === 1 ? "" : "s"
616
- }`
617
- simulationFeedback.dataset.status = "applied"
618
- } catch (cause) {
619
- selectedFrame = undefined
620
- if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected")
621
- selectedEvent = undefined
622
- const failure: Diagnostic = {
623
- severity: "error",
624
- code: "simulation-request-failed",
625
- message: cause instanceof Error ? cause.message : String(cause),
626
- location: visualization.source === null
627
- ? null
628
- : { file: visualization.source.file, line: null, column: null },
629
- statePath: null
630
- }
631
- simulationFeedback.textContent = failure.message
632
- simulationFeedback.dataset.status = "error"
633
- renderSimulationFailure([failure])
634
- } finally {
635
- simulationPending = false
636
- activeInputForm?.setPending(false)
637
- updateSimulationUi()
638
- }
702
+
703
+ const choice = frame.choice
704
+ const transition = transitionsById.get(choice.transitionId)
705
+ const branch = transition?.branches.find(({ id }) => id === choice.branchId)
706
+ const api = branch === undefined ? undefined : branchTargetApi(visualization, choice.source, branch)
707
+ const selection = createElement("section", "inspector-section")
708
+ selection.append(inspectionSection("Selected branch"))
709
+ const card = createElement("article", "inspection-card trace-card")
710
+ const cardHeader = createElement("div", "card-header")
711
+ const cardTitle = createElement("div", "card-title")
712
+ cardTitle.append(createElement("strong", undefined, triggerName(choice.trigger)))
713
+ const flags = createElement("div", "card-flags")
714
+ choice.decisions.forEach((decision) => flags.append(badge(decisionLabel(decision), "condition")))
715
+ cardHeader.append(cardTitle, flags)
716
+ card.append(cardHeader)
717
+ card.append(metadata([
718
+ ["Title", choice.title],
719
+ ["Source", stateLink(choice.source, choice.source, navigateToState)],
720
+ ["Target", choice.target === null ? "No target" : stateLink(choice.target, choice.target, navigateToState)],
721
+ ["API", api]
722
+ ]))
723
+ if (choice.updates.length > 0) card.append(renderPathGroup("Updates", choice.updates))
724
+ selection.append(card)
725
+ inspectorContent.append(selection)
726
+ if (choice.input !== null) inspectorContent.append(renderInputContract("Data contract", choice.input, ["_tag"]))
639
727
  }
640
728
 
641
729
  const clearRelations = (): void => {
642
- for (const path of relatedPaths) {
643
- nodes.get(path)?.classList.remove("is-related-source", "is-related-target", "is-related-update")
730
+ relatedFrom.clear()
731
+ relatedTo.clear()
732
+ incomingTransitions.clear()
733
+ outgoingTransitions.clear()
734
+ }
735
+
736
+ const markFrame = (frame: MachineWalkthrough.Frame): void => {
737
+ clearRelations()
738
+ if (frame.choice === null) return
739
+ relatedFrom.add(frame.choice.source)
740
+ outgoingTransitions.add(frame.choice.transitionId)
741
+ if (frame.choice.target !== null) relatedTo.add(frame.choice.target)
742
+ }
743
+
744
+ const chartPresentation = (): ChartPresentation => {
745
+ const choices = availableChoices()
746
+ const usable = choices.filter(({ unavailableReason }) => unavailableReason === null)
747
+ return {
748
+ simulationMode: walkthrough !== undefined,
749
+ activePaths: activePaths(),
750
+ selectedState: selectedPath ?? null,
751
+ selectedTransition: selectedTransition ?? null,
752
+ fromPaths: [...relatedFrom],
753
+ toPaths: [...relatedTo],
754
+ incomingTransitionIds: [...incomingTransitions],
755
+ outgoingTransitionIds: [...outgoingTransitions],
756
+ availableBranchIds: usable.map(({ branchId }) => branchId),
757
+ unavailableBranchIds: choices.filter(({ unavailableReason }) => unavailableReason !== null).map((
758
+ { branchId }
759
+ ) => branchId)
644
760
  }
645
- relatedPaths.clear()
646
761
  }
647
762
 
763
+ const updateChartPresentation = (): void => chartView?.update(chartPresentation())
764
+
648
765
  const clearSelection = (): void => {
649
- if (selectedPath !== undefined) {
650
- nodes.get(selectedPath)?.classList.remove("is-selected")
651
- rows.get(selectedPath)?.setAttribute("aria-selected", "false")
766
+ if (walkthrough !== undefined) {
767
+ closeChoicePicker()
768
+ return
652
769
  }
653
- if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected")
654
770
  clearRelations()
655
771
  selectedPath = undefined
656
- selectedEvent = undefined
772
+ selectedTransition = undefined
657
773
  selectedFrame = undefined
658
774
  clearButton.disabled = true
659
- renderEmptyInspector()
775
+ detailsButton.disabled = true
776
+ hideInspector()
777
+ updateChartPresentation()
660
778
  }
661
779
 
662
- const markTransitions = (transitions: ReadonlyArray<VisualizationTransition>): void => {
780
+ const markTransitions = (
781
+ transitions: ReadonlyArray<VisualizationTransition>,
782
+ includeSource = true
783
+ ): void => {
663
784
  for (const transition of transitions) {
664
- relatedPaths.add(transition.source)
665
- nodes.get(transition.source)?.classList.add("is-related-source")
785
+ if (includeSource) relatedFrom.add(transition.source)
786
+ outgoingTransitions.add(transition.id)
666
787
  for (const branch of transition.branches) {
667
- if (branch.target !== null) {
668
- relatedPaths.add(branch.target)
669
- nodes.get(branch.target)?.classList.add("is-related-target")
670
- }
671
- for (const update of branch.updates) {
672
- relatedPaths.add(update)
673
- nodes.get(update)?.classList.add("is-related-update")
674
- }
788
+ if (branch.target !== null) relatedTo.add(branch.target)
675
789
  }
676
790
  }
677
791
  }
@@ -679,160 +793,276 @@ export const renderVisualizer = (
679
793
  const markRelatedStates = (inspection: StateInspection): void => {
680
794
  clearRelations()
681
795
  for (const incoming of inspection.incoming) {
682
- relatedPaths.add(incoming.transition.source)
683
- nodes.get(incoming.transition.source)?.classList.add("is-related-source")
684
- }
685
- markTransitions(inspection.outgoing)
686
- }
687
-
688
- const expandAncestors = (inspection: StateInspection): void => {
689
- for (const ancestor of inspection.breadcrumbs.slice(0, -1)) {
690
- const row = rows.get(ancestor.path)
691
- const children = nodes.get(ancestor.path)?.querySelector<HTMLElement>(":scope > .topology-children")
692
- if (row === undefined || children === null || children === undefined) continue
693
- row.setAttribute("aria-expanded", "true")
694
- children.hidden = false
695
- const disclosure = row.querySelector<HTMLElement>(".state-disclosure")
696
- if (disclosure !== null) disclosure.textContent = "▾"
796
+ relatedFrom.add(incoming.transition.source)
797
+ incomingTransitions.add(incoming.transition.id)
697
798
  }
799
+ markTransitions(inspection.outgoing, false)
698
800
  }
699
801
 
700
802
  const selectState = (path: string, focus: boolean): void => {
803
+ if (walkthrough !== undefined) return
701
804
  const inspection = model.inspectState(path)
702
805
  if (inspection === undefined) return
703
- expandAncestors(inspection)
704
- if (selectedPath !== undefined) {
705
- nodes.get(selectedPath)?.classList.remove("is-selected")
706
- rows.get(selectedPath)?.setAttribute("aria-selected", "false")
707
- }
708
- if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected")
709
806
  selectedPath = path
710
- selectedEvent = undefined
807
+ selectedTransition = undefined
711
808
  selectedFrame = undefined
712
- nodes.get(path)?.classList.add("is-selected")
713
- rows.get(path)?.setAttribute("aria-selected", "true")
714
809
  markRelatedStates(inspection)
715
810
  clearButton.disabled = false
716
- renderInspection(inspection)
717
- if (focus) {
718
- rows.get(path)?.focus({ preventScroll: true })
719
- rows.get(path)?.scrollIntoView({ block: "nearest" })
720
- }
811
+ detailsButton.disabled = false
812
+ if (!inspector.hidden) renderInspection(inspection)
813
+ updateChartPresentation()
814
+ if (focus) chartView?.focusState(path)
721
815
  }
722
816
 
723
817
  function navigateToState(path: string): void {
724
818
  selectState(path, true)
725
819
  }
726
820
 
727
- const selectEvent = (event: string): void => {
728
- const inspection = model.inspectEvent(event)
729
- if (selectedPath !== undefined) {
730
- nodes.get(selectedPath)?.classList.remove("is-selected")
731
- rows.get(selectedPath)?.setAttribute("aria-selected", "false")
732
- }
733
- if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected")
821
+ const selectTransition = (transitionId: string): void => {
822
+ if (walkthrough !== undefined) return
823
+ const transition = transitionsById.get(transitionId)
824
+ if (transition === undefined) return
734
825
  selectedPath = undefined
735
- selectedEvent = event
826
+ selectedTransition = transitionId
736
827
  selectedFrame = undefined
737
- eventButtons.get(event)?.classList.add("is-selected")
738
828
  clearRelations()
739
- markTransitions(inspection.transitions)
829
+ markTransitions([transition])
740
830
  clearButton.disabled = false
741
- const schema = eventSchemas.get(event)
742
- if (simulation !== undefined && candidateEvents().includes(event) && schema !== undefined) {
743
- const input = renderInputForm(schema, { name: event, fixed: { _tag: event }, omit: ["_tag"] })
744
- if (!input.hasFields && input.supported) {
745
- const result = input.read()
746
- const source = visualization.source
747
- if (result.ok && source !== null) {
748
- void runSimulation({
749
- _tag: "SendSimulationEvent",
750
- protocolVersion,
751
- key: machineKey,
752
- revision: visualization.revision,
753
- source,
754
- step: simulation.step,
755
- snapshot: simulation.snapshot,
756
- event: result.value as never
757
- })
758
- return
759
- }
760
- }
831
+ detailsButton.disabled = false
832
+ if (!inspector.hidden) renderTransitionInspection(transition)
833
+ updateChartPresentation()
834
+ }
835
+
836
+ const openStateDetails = (path: string): void => {
837
+ if (walkthrough !== undefined) return
838
+ selectState(path, false)
839
+ const inspection = model.inspectState(path)
840
+ if (inspection !== undefined) renderInspection(inspection)
841
+ }
842
+
843
+ const openTransitionDetails = (transitionId: string): void => {
844
+ if (walkthrough !== undefined) return
845
+ selectTransition(transitionId)
846
+ const transition = transitionsById.get(transitionId)
847
+ if (transition !== undefined) renderTransitionInspection(transition)
848
+ }
849
+
850
+ const walkthroughDock = createElement("section", "walkthrough-dock")
851
+ walkthroughDock.hidden = true
852
+ const walkthroughHeading = createElement("div", "walkthrough-heading")
853
+ const walkthroughIdentity = createElement("div", "walkthrough-identity")
854
+ const walkthroughHint = createElement("span", "walkthrough-hint")
855
+ const walkthroughStatus = createElement("span", "walkthrough-status")
856
+ const walkthroughTimeline = createElement("div", "walkthrough-timeline")
857
+ walkthroughTimeline.setAttribute("aria-label", "Simulation timeline")
858
+ walkthroughIdentity.append(createElement("strong", undefined, "Simulation"), walkthroughHint)
859
+ walkthroughHeading.append(walkthroughIdentity, walkthroughStatus)
860
+ walkthroughDock.append(walkthroughHeading, walkthroughTimeline)
861
+
862
+ const selectFrame = (frame: MachineWalkthrough.Frame): void => {
863
+ selectedPath = undefined
864
+ selectedTransition = undefined
865
+ selectedFrame = frame
866
+ markFrame(frame)
867
+ clearButton.disabled = false
868
+ detailsButton.disabled = false
869
+ }
870
+
871
+ const choiceRoute = (choice: MachineWalkthrough.Choice): string =>
872
+ `${choice.source} → ${choice.target ?? "no target"}`
873
+
874
+ const takeChoice = (choice: MachineWalkthrough.Choice): void => {
875
+ if (walkthrough === undefined || choice.unavailableReason !== null) return
876
+ const result = MachineWalkthrough.take(walkthrough, choice.id)
877
+ if (Result.isFailure(result)) return
878
+ walkthrough = result.success
879
+ selectFrame(MachineWalkthrough.current(walkthrough))
880
+ closeChoicePicker()
881
+ updateWalkthroughUi()
882
+ chartView?.revealStates(activePaths())
883
+ }
884
+
885
+ const renderPickerChoice = (choice: MachineWalkthrough.Choice): HTMLButtonElement => {
886
+ const button = createElement(
887
+ "button",
888
+ `transition-picker-choice${choice.unavailableReason === null ? "" : " is-unavailable"}`
889
+ )
890
+ button.type = "button"
891
+ button.disabled = choice.unavailableReason !== null
892
+ const main = createElement("span", "transition-picker-choice-main")
893
+ main.append(createElement("strong", undefined, triggerName(choice.trigger)))
894
+ if (choice.title !== null) main.append(createElement("span", "transition-picker-choice-title", choice.title))
895
+ button.append(main, createElement("span", "transition-picker-choice-route", choiceRoute(choice)))
896
+ const contract = contractSummary(choice.input)
897
+ if (contract !== null) button.append(createElement("span", "transition-picker-choice-contract", contract))
898
+ if (choice.decisions.length > 0) {
899
+ button.append(createElement(
900
+ "span",
901
+ "transition-picker-choice-decision",
902
+ choice.decisions.map(decisionLabel).join(" · ")
903
+ ))
904
+ }
905
+ if (choice.unavailableReason !== null) {
906
+ button.title = unavailableLabel(choice.unavailableReason)
907
+ button.append(createElement(
908
+ "span",
909
+ "transition-picker-choice-unavailable",
910
+ unavailableLabel(choice.unavailableReason)
911
+ ))
912
+ } else {
913
+ button.title = choiceRoute(choice)
914
+ button.addEventListener("click", () => takeChoice(choice))
761
915
  }
762
- renderEventInspection(inspection)
763
- }
764
-
765
- const setExpanded = (row: HTMLElement, expanded: boolean): void => {
766
- const node = row.closest<HTMLElement>(".topology-node")
767
- const children = node?.querySelector<HTMLElement>(":scope > .topology-children")
768
- if (children === null || children === undefined) return
769
- row.setAttribute("aria-expanded", String(expanded))
770
- children.hidden = !expanded
771
- const disclosure = row.querySelector<HTMLElement>(".state-disclosure")
772
- if (disclosure !== null) disclosure.textContent = expanded ? "▾" : "▸"
773
- }
774
-
775
- const renderNode = (node: TopologyNode, depth: number): HTMLElement => {
776
- const container = createElement("div", "topology-node")
777
- container.dataset.statePath = node.path
778
- nodes.set(node.path, container)
779
-
780
- const row = createElement("button", "state-row")
781
- row.type = "button"
782
- row.tabIndex = -1
783
- row.setAttribute("role", "treeitem")
784
- row.setAttribute("aria-level", String(depth + 1))
785
- row.setAttribute("aria-selected", "false")
786
- row.style.setProperty("--depth", String(depth))
787
- row.dataset.statePath = node.path
788
- rows.set(node.path, row)
789
-
790
- const disclosure = createElement("span", "state-disclosure", node.children.length === 0 ? "" : "▾")
791
- const status = createElement("span", `state-status${node.active ? " is-active" : ""}`)
792
- status.setAttribute("aria-label", node.active ? "active" : "inactive")
793
- statuses.set(node.path, status)
794
- const label = createElement("span", "state-label", node.label)
795
- const markers = createElement("span", "state-markers")
796
- if (node.initial) markers.append(badge("initial", "initial"))
797
- if (node.type !== "atomic") markers.append(badge(node.type, "state"))
798
- if (node.transitionCount > 0) markers.append(badge(`${node.transitionCount}t`, "count"))
799
- if (node.activityCount > 0) markers.append(badge(`${node.activityCount}a`, "count"))
800
- row.append(disclosure, status, label, markers)
801
- container.append(row)
802
- row.addEventListener("focus", () => {
803
- rows.forEach((candidate) => candidate.tabIndex = candidate === row ? 0 : -1)
916
+ return button
917
+ }
918
+
919
+ const showChoicePicker = (
920
+ title: string,
921
+ choices: ReadonlyArray<MachineWalkthrough.Choice>,
922
+ anchor: ChartInteractionAnchor
923
+ ): void => {
924
+ if (choices.length === 1 && choices[0]?.unavailableReason === null) {
925
+ takeChoice(choices[0])
926
+ return
927
+ }
928
+ if (choices.length === 0) {
929
+ closeChoicePicker()
930
+ return
931
+ }
932
+ choicePickerTitle.textContent = title
933
+ choicePickerContent.replaceChildren(...choices.map(renderPickerChoice))
934
+ choicePicker.style.left = `${anchor.x + 12}px`
935
+ choicePicker.style.top = `${anchor.y + 12}px`
936
+ choicePicker.hidden = false
937
+ requestAnimationFrame(() => {
938
+ const bounds = choicePicker.getBoundingClientRect()
939
+ choicePicker.style.left = `${Math.max(10, Math.min(anchor.x + 12, window.innerWidth - bounds.width - 10))}px`
940
+ choicePicker.style.top = `${Math.max(58, Math.min(anchor.y + 12, window.innerHeight - bounds.height - 10))}px`
804
941
  })
942
+ }
805
943
 
806
- if (node.children.length > 0) {
807
- const children = createElement("div", "topology-children")
808
- children.setAttribute("role", "group")
809
- node.children.forEach((child) => children.append(renderNode(child, depth + 1)))
810
- container.append(children)
811
- row.setAttribute("aria-expanded", "true")
812
- row.addEventListener("click", () => {
813
- const expanded = row.getAttribute("aria-expanded") === "true"
814
- setExpanded(row, !expanded)
815
- selectState(node.path, false)
816
- })
817
- } else {
818
- row.addEventListener("click", () => selectState(node.path, false))
944
+ const handleStateClick = (path: string, _anchor: ChartInteractionAnchor): void => {
945
+ if (walkthrough === undefined) {
946
+ selectState(path, false)
947
+ return
948
+ }
949
+ closeChoicePicker()
950
+ }
951
+
952
+ const handleTransitionClick = (
953
+ transitionId: string,
954
+ branchIds: ReadonlyArray<string>,
955
+ anchor: ChartInteractionAnchor
956
+ ): void => {
957
+ if (walkthrough === undefined) {
958
+ selectTransition(transitionId)
959
+ return
819
960
  }
820
- return container
961
+ const transition = transitionsById.get(transitionId)
962
+ if (transition === undefined) return
963
+ showChoicePicker(
964
+ triggerLabel(transition),
965
+ MachineWalkthrough.choices(walkthrough).filter((choice) => branchIds.includes(choice.branchId)),
966
+ anchor
967
+ )
821
968
  }
822
969
 
823
- const setAllExpanded = (expanded: boolean): void => {
824
- treePanel.querySelectorAll<HTMLElement>(".state-row[aria-expanded]").forEach((row) => {
825
- setExpanded(row, expanded)
970
+ const renderWalkthroughDock = (): void => {
971
+ walkthroughDock.hidden = walkthrough === undefined
972
+ chartPanel.classList.toggle("has-walkthrough", walkthrough !== undefined)
973
+ if (walkthrough === undefined) {
974
+ walkthroughTimeline.replaceChildren()
975
+ return
976
+ }
977
+ const cursor = MachineWalkthrough.cursor(walkthrough)
978
+ const timeline = MachineWalkthrough.timeline(walkthrough)
979
+ const choices = MachineWalkthrough.choices(walkthrough)
980
+ walkthroughStatus.textContent = `Step ${cursor} · ${activeTopology(activePaths())}`
981
+ walkthroughHint.textContent = choices.length === 0
982
+ ? "No outgoing transitions"
983
+ : "Click a highlighted transition to advance"
984
+ walkthroughTimeline.replaceChildren()
985
+ timeline.forEach((frame) => {
986
+ const label = frame.choice === null ? "Initial" : frame.choice.title ?? triggerName(frame.choice.trigger)
987
+ const button = createElement(
988
+ "button",
989
+ `walkthrough-step${frame.step === cursor ? " is-current" : ""}`,
990
+ `${frame.step} · ${label}`
991
+ )
992
+ button.type = "button"
993
+ button.addEventListener("click", () => {
994
+ if (walkthrough === undefined) return
995
+ const result = MachineWalkthrough.seek(walkthrough, frame.step)
996
+ if (Result.isFailure(result)) return
997
+ walkthrough = result.success
998
+ selectFrame(MachineWalkthrough.current(walkthrough))
999
+ closeChoicePicker()
1000
+ updateWalkthroughUi()
1001
+ chartView?.revealStates(activePaths())
1002
+ })
1003
+ walkthroughTimeline.append(button)
826
1004
  })
827
1005
  }
828
1006
 
829
1007
  clearButton.addEventListener("click", clearSelection)
830
- expandButton.addEventListener("click", () => setAllExpanded(true))
831
- collapseButton.addEventListener("click", () => setAllExpanded(false))
832
- revealActiveButton.addEventListener("click", () => {
833
- const deepest = [...activePaths()].sort((left, right) => right.split(".").length - left.split(".").length)[0]
834
- if (deepest !== undefined) navigateToState(deepest)
1008
+ inspectorClose.addEventListener("click", hideInspector)
1009
+ choicePickerClose.addEventListener("click", closeChoicePicker)
1010
+ detailsButton.addEventListener("click", () => {
1011
+ if (!inspector.hidden && inspectorView === "selection") {
1012
+ hideInspector()
1013
+ } else if (selectedFrame !== undefined) {
1014
+ renderWalkthroughTrace(selectedFrame)
1015
+ } else if (selectedPath !== undefined) {
1016
+ const inspection = model.inspectState(selectedPath)
1017
+ if (inspection !== undefined) renderInspection(inspection)
1018
+ } else if (selectedTransition !== undefined) {
1019
+ const transition = transitionsById.get(selectedTransition)
1020
+ if (transition !== undefined) renderTransitionInspection(transition)
1021
+ }
835
1022
  })
1023
+ analysisButton.addEventListener("click", () => {
1024
+ if (!inspector.hidden && inspectorView === "analysis") {
1025
+ hideInspector()
1026
+ } else {
1027
+ renderAnalysis(analysis)
1028
+ }
1029
+ })
1030
+ revealActiveButton.addEventListener("click", () => chartView?.revealStates(activePaths()))
1031
+
1032
+ const zoomOutButton = createElement("button", "toolbar-button zoom-button", "−")
1033
+ zoomOutButton.type = "button"
1034
+ zoomOutButton.setAttribute("aria-label", "Zoom out")
1035
+ const zoomResetButton = createElement("button", "toolbar-button zoom-value", "100%")
1036
+ zoomResetButton.type = "button"
1037
+ zoomResetButton.setAttribute("aria-label", "Reset zoom")
1038
+ const zoomInButton = createElement("button", "toolbar-button zoom-button", "+")
1039
+ zoomInButton.type = "button"
1040
+ zoomInButton.setAttribute("aria-label", "Zoom in")
1041
+ const zoomFitButton = createElement("button", "toolbar-button zoom-button", "Fit")
1042
+ zoomFitButton.type = "button"
1043
+ zoomFitButton.setAttribute("aria-label", "Fit chart")
1044
+ const zoomButtons = [zoomOutButton, zoomResetButton, zoomInButton, zoomFitButton]
1045
+ zoomButtons.forEach((button) => button.disabled = true)
1046
+
1047
+ const updateZoomControls = (): void => {
1048
+ const zoom = chartView?.getZoom()
1049
+ zoomResetButton.textContent = `${Math.round((zoom ?? 1) * 100)}%`
1050
+ zoomOutButton.disabled = zoom === undefined || zoom <= minimumChartZoom
1051
+ zoomResetButton.disabled = zoom === undefined || zoom === 1
1052
+ zoomInButton.disabled = zoom === undefined || zoom >= maximumChartZoom
1053
+ zoomFitButton.disabled = zoom === undefined
1054
+ }
1055
+
1056
+ const changeZoom = (next: (view: ChartView) => number): void => {
1057
+ if (chartView === undefined) return
1058
+ next(chartView)
1059
+ updateZoomControls()
1060
+ }
1061
+
1062
+ zoomOutButton.addEventListener("click", () => changeZoom((view) => view.setZoom(view.getZoom() - 0.1)))
1063
+ zoomResetButton.addEventListener("click", () => changeZoom((view) => view.setZoom(1)))
1064
+ zoomInButton.addEventListener("click", () => changeZoom((view) => view.setZoom(view.getZoom() + 0.1)))
1065
+ zoomFitButton.addEventListener("click", () => changeZoom((view) => view.fit()))
836
1066
 
837
1067
  const toolbar = createElement("div", "toolbar")
838
1068
  const runtime = createElement("div", "runtime-summary")
@@ -840,164 +1070,70 @@ export const renderVisualizer = (
840
1070
  const runtimeText = createElement("span")
841
1071
  runtime.append(runtimeDot, runtimeText)
842
1072
  const toolbarActions = createElement("div", "toolbar-actions")
843
- toolbarActions.append(clearButton, simulationButton, revealActiveButton, expandButton, collapseButton)
1073
+ toolbarActions.append(clearButton, detailsButton, walkthroughButton, revealActiveButton, analysisButton)
1074
+ const zoomControls = createElement("div", "zoom-controls")
1075
+ zoomControls.setAttribute("role", "group")
1076
+ zoomControls.setAttribute("aria-label", "Chart zoom")
1077
+ zoomControls.append(zoomOutButton, zoomResetButton, zoomInButton, zoomFitButton)
844
1078
  toolbar.append(runtime, toolbarActions)
845
- const tree = createElement("div", "topology-tree")
846
- tree.setAttribute("role", "tree")
847
- tree.setAttribute("aria-label", `${model.machineId} states`)
848
- tree.append(createElement("div", "machine-id", model.machineId))
849
- const events = createElement("div", "enabled-events")
850
- const simulationFeedback = createElement("div", "simulation-feedback")
851
- simulationFeedback.setAttribute("role", "status")
852
- tree.append(events, simulationFeedback)
853
-
854
- const renderEventButtons = (): void => {
855
- events.replaceChildren(createElement("span", "enabled-events-label", "Enabled"))
856
- eventButtons.clear()
857
- const candidates = candidateEvents()
858
- if (candidates.length === 0) {
859
- events.append(createElement("span", "enabled-events-empty", "none"))
860
- return
861
- }
862
- candidates.forEach((event) => {
863
- const button = createElement("button", `event-button${event === selectedEvent ? " is-selected" : ""}`, event)
864
- button.type = "button"
865
- button.disabled = simulationPending
866
- button.addEventListener("click", () => selectEvent(event))
867
- eventButtons.set(event, button)
868
- events.append(button)
869
- })
870
- }
1079
+ const chart = createElement("div", "topology-chart")
1080
+ chart.setAttribute("role", "region")
1081
+ chart.setAttribute("aria-label", `${model.machineId} states`)
1082
+ const chartHost = createElement("div", "chart-host")
1083
+ chartHost.append(createElement("div", "chart-loading", "Computing layout…"))
1084
+ chart.append(chartHost)
871
1085
 
872
- const updateSimulationUi = (): void => {
1086
+ const updateWalkthroughUi = (): void => {
873
1087
  const active = new Set(activePaths())
874
- statuses.forEach((status, path) => {
875
- const isActive = active.has(path)
876
- status.classList.toggle("is-active", isActive)
877
- status.setAttribute("aria-label", isActive ? "active" : "inactive")
878
- })
879
- const hasRuntimeState = simulation !== undefined || model.hasSnapshot
1088
+ const simulating = walkthrough !== undefined
1089
+ updateChartPresentation()
1090
+ const hasRuntimeState = simulating || model.hasSnapshot
880
1091
  runtimeDot.classList.toggle("has-snapshot", hasRuntimeState)
881
- runtimeText.textContent = simulation !== undefined
882
- ? `${active.size} active · step ${simulation.step}`
1092
+ runtimeText.textContent = walkthrough !== undefined
1093
+ ? `Simulation · ${active.size} active · step ${MachineWalkthrough.cursor(walkthrough)}`
883
1094
  : diagnostics.length > 0
884
1095
  ? "Partial"
885
1096
  : model.hasSnapshot
886
1097
  ? `${active.size} active`
887
1098
  : "Structure only"
888
- simulationButton.textContent = simulation === undefined ? "Start simulation" : "Reset simulation"
889
- simulationButton.disabled = simulationPending || model.roots.length === 0 || visualization.source === null
1099
+ walkthroughButton.textContent = simulating ? "Exit simulation" : "Start simulation"
1100
+ walkthroughButton.disabled = model.roots.length === 0
890
1101
  revealActiveButton.disabled = active.size === 0
891
- events.hidden = !hasRuntimeState
892
- simulationFeedback.hidden = simulation === undefined && !simulationPending && simulationFeedback.textContent === ""
893
- renderEventButtons()
894
- if (selectedFrame !== undefined) {
895
- renderSimulationTrace(selectedFrame)
896
- } else if (selectedPath !== undefined) {
897
- const inspection = model.inspectState(selectedPath)
898
- if (inspection !== undefined) renderInspection(inspection)
899
- } else if (selectedEvent !== undefined && activeInputForm === undefined) {
900
- renderEventInspection(model.inspectEvent(selectedEvent))
1102
+ clearButton.hidden = simulating
1103
+ detailsButton.hidden = simulating
1104
+ analysisButton.hidden = simulating || analysisCount === 0
1105
+ chartPanel.classList.toggle("is-simulating", simulating)
1106
+ if (simulating) hideInspector()
1107
+ renderWalkthroughDock()
1108
+ if (!simulating && !inspector.hidden && inspectorView === "selection") {
1109
+ if (selectedFrame !== undefined) {
1110
+ renderWalkthroughTrace(selectedFrame)
1111
+ } else if (selectedPath !== undefined) {
1112
+ const inspection = model.inspectState(selectedPath)
1113
+ if (inspection !== undefined) renderInspection(inspection)
1114
+ } else if (selectedTransition !== undefined) {
1115
+ const transition = transitionsById.get(selectedTransition)
1116
+ if (transition !== undefined) renderTransitionInspection(transition)
1117
+ }
901
1118
  }
902
1119
  }
903
1120
 
904
- simulationButton.addEventListener("click", () => {
905
- if (simulation === undefined) {
906
- selectedPath = undefined
907
- selectedEvent = undefined
908
- selectedFrame = undefined
909
- clearRelations()
910
- clearButton.disabled = false
911
- if (visualization.inputs.machine === null && visualization.source !== null) {
912
- void runSimulation({
913
- _tag: "StartSimulation",
914
- protocolVersion,
915
- key: machineKey,
916
- revision: visualization.revision,
917
- source: visualization.source
918
- })
919
- } else {
920
- renderStartSimulation()
921
- }
1121
+ walkthroughButton.addEventListener("click", () => {
1122
+ if (walkthrough === undefined) {
1123
+ walkthrough = MachineWalkthrough.start(visualization)
1124
+ selectFrame(MachineWalkthrough.current(walkthrough))
1125
+ hideInspector()
1126
+ closeChoicePicker()
1127
+ requestAnimationFrame(() => chartView?.revealStates(activePaths()))
922
1128
  } else {
923
- simulation = undefined
924
- selectedFrame = undefined
925
- simulationFeedback.textContent = ""
926
- delete simulationFeedback.dataset.status
1129
+ walkthrough = undefined
1130
+ closeChoicePicker()
927
1131
  clearSelection()
928
1132
  }
929
- updateSimulationUi()
930
- })
931
- if (model.roots.length === 0) {
932
- const empty = createElement("div", "topology-empty")
933
- empty.append(
934
- createElement("strong", undefined, "No states yet"),
935
- createElement("span", undefined, "The topology will appear as the machine definition becomes available.")
936
- )
937
- tree.append(empty)
938
- } else {
939
- model.roots.forEach((node) => tree.append(renderNode(node, 0)))
940
- }
941
- updateSimulationUi()
942
- rows.values().next().value?.setAttribute("tabindex", "0")
943
- tree.addEventListener("keydown", (event) => {
944
- const current = event.target instanceof HTMLElement ? event.target.closest<HTMLElement>(".state-row") : null
945
- if (current === null) return
946
- const visible = [...rows.values()].filter((row) => row.getClientRects().length > 0)
947
- const index = visible.indexOf(current)
948
- const focus = (row: HTMLElement | undefined): void => {
949
- if (row === undefined) return
950
- event.preventDefault()
951
- row.focus()
952
- }
953
- switch (event.key) {
954
- case "ArrowDown":
955
- focus(visible[index + 1])
956
- break
957
- case "ArrowUp":
958
- focus(visible[index - 1])
959
- break
960
- case "Home":
961
- focus(visible[0])
962
- break
963
- case "End":
964
- focus(visible.at(-1))
965
- break
966
- case "ArrowRight": {
967
- if (current.getAttribute("aria-expanded") === "false") {
968
- event.preventDefault()
969
- setExpanded(current, true)
970
- } else {
971
- const child = current.closest<HTMLElement>(".topology-node")
972
- ?.querySelector<HTMLElement>(":scope > .topology-children > .topology-node > .state-row")
973
- focus(child ?? undefined)
974
- }
975
- break
976
- }
977
- case "ArrowLeft": {
978
- if (current.getAttribute("aria-expanded") === "true") {
979
- event.preventDefault()
980
- setExpanded(current, false)
981
- } else {
982
- const parent = current.closest<HTMLElement>(".topology-children")
983
- ?.closest<HTMLElement>(".topology-node")
984
- ?.querySelector<HTMLElement>(":scope > .state-row")
985
- focus(parent ?? undefined)
986
- }
987
- break
988
- }
989
- case "Enter":
990
- case " ":
991
- event.preventDefault()
992
- current.click()
993
- break
994
- case "Escape":
995
- event.preventDefault()
996
- clearSelection()
997
- break
998
- }
1133
+ updateWalkthroughUi()
999
1134
  })
1000
- treePanel.append(toolbar)
1135
+
1136
+ chartPanel.append(toolbar)
1001
1137
  if (diagnostics.length > 0) {
1002
1138
  const diagnosticList = createElement("div", "diagnostics")
1003
1139
  diagnosticList.setAttribute("role", "status")
@@ -1009,12 +1145,44 @@ export const renderVisualizer = (
1009
1145
  }
1010
1146
  diagnosticList.append(item)
1011
1147
  })
1012
- treePanel.append(diagnosticList)
1148
+ chartPanel.append(diagnosticList)
1013
1149
  }
1014
- treePanel.append(tree)
1015
-
1016
- renderEmptyInspector()
1017
- workspace.append(treePanel, inspector)
1150
+ chartPanel.append(chart, walkthroughDock, zoomControls)
1151
+ hideInspector()
1152
+ workspace.append(chartPanel, inspector, choicePicker)
1018
1153
  shell.append(workspace)
1019
1154
  root.replaceChildren(shell)
1155
+
1156
+ if (model.roots.length === 0) {
1157
+ const empty = createElement("div", "topology-empty")
1158
+ empty.append(
1159
+ createElement("strong", undefined, "No states yet"),
1160
+ createElement("span", undefined, "The topology will appear as the machine definition becomes available.")
1161
+ )
1162
+ chartHost.replaceChildren(empty)
1163
+ } else {
1164
+ void Effect.runPromise(renderChart(chartHost, visualization, {
1165
+ selectState: handleStateClick,
1166
+ openStateDetails,
1167
+ selectTransition: handleTransitionClick,
1168
+ openTransitionDetails,
1169
+ clearSelection,
1170
+ zoomChanged: updateZoomControls
1171
+ })).then(
1172
+ (view) => {
1173
+ chartView = view
1174
+ updateChartPresentation()
1175
+ updateZoomControls()
1176
+ },
1177
+ (cause) => {
1178
+ const failure = createElement("div", "chart-layout-error")
1179
+ failure.append(
1180
+ createElement("strong", undefined, "The chart could not be laid out"),
1181
+ createElement("span", undefined, cause instanceof Error ? cause.message : String(cause))
1182
+ )
1183
+ chartHost.replaceChildren(failure)
1184
+ }
1185
+ )
1186
+ }
1187
+ updateWalkthroughUi()
1020
1188
  }