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