@typeonce/effect-machine-devtools 0.23.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 +14 -8
  3. package/dist/DevServer.d.ts +2 -1
  4. package/dist/DevServer.d.ts.map +1 -1
  5. package/dist/DevServer.js.map +1 -1
  6. package/dist/DevToolsProtocol.d.ts +167 -17
  7. package/dist/DevToolsProtocol.d.ts.map +1 -1
  8. package/dist/DevToolsProtocol.js +1 -1
  9. package/dist/MachineDocument.d.ts +98 -2
  10. package/dist/MachineDocument.d.ts.map +1 -1
  11. package/dist/MachineDocument.js +35 -1
  12. package/dist/MachineDocument.js.map +1 -1
  13. package/dist/MachineRegistry.d.ts +56 -6
  14. package/dist/MachineRegistry.d.ts.map +1 -1
  15. package/dist/MachineWalkthrough.d.ts +171 -0
  16. package/dist/MachineWalkthrough.d.ts.map +1 -0
  17. package/dist/MachineWalkthrough.js +98 -0
  18. package/dist/MachineWalkthrough.js.map +1 -0
  19. package/dist/client/assets/index-BTOAhezX.js +37 -0
  20. package/dist/client/assets/index-Cg7xzSkz.css +1 -0
  21. package/dist/client/index.html +4 -4
  22. package/dist/index.d.ts +2 -2
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +2 -2
  25. package/dist/index.js.map +1 -1
  26. package/dist/internal/devServer.d.ts +2 -1
  27. package/dist/internal/devServer.d.ts.map +1 -1
  28. package/dist/internal/devServer.js +4 -3
  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 +28 -9
  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 +65 -2
  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 +15 -9
  42. package/dist/internal/projectInspector.js.map +1 -1
  43. package/index.html +2 -2
  44. package/package.json +4 -3
  45. package/src/DevServer.ts +6 -2
  46. package/src/DevToolsProtocol.ts +1 -1
  47. package/src/MachineDocument.ts +56 -1
  48. package/src/MachineWalkthrough.ts +199 -0
  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 +252 -0
  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 +145 -0
  58. package/src/internal/browser/protocol-events-example.ts +200 -0
  59. package/src/internal/browser/styles.css +959 -185
  60. package/src/internal/browser/transition-semantics-example.ts +236 -0
  61. package/src/internal/browser/visualizer-app.ts +818 -389
  62. package/src/internal/browser/visualizer-model.ts +104 -0
  63. package/src/internal/browser/visualizer.ts +1 -1
  64. package/src/internal/devServer.ts +9 -4
  65. package/src/internal/evaluationWorker.ts +28 -10
  66. package/src/internal/machineDocument.ts +77 -2
  67. package/src/internal/machineWalkthrough.ts +355 -0
  68. package/src/internal/projectInspector.ts +29 -14
  69. package/dist/MachineSimulator.d.ts +0 -169
  70. package/dist/MachineSimulator.d.ts.map +0 -1
  71. package/dist/MachineSimulator.js +0 -98
  72. package/dist/MachineSimulator.js.map +0 -1
  73. package/dist/client/assets/index-BGOhE3Ng.css +0 -1
  74. package/dist/client/assets/index-DiqGhsGR.js +0 -14
  75. package/dist/internal/machineSimulator.d.ts +0 -5
  76. package/dist/internal/machineSimulator.d.ts.map +0 -1
  77. package/dist/internal/machineSimulator.js +0 -156
  78. package/dist/internal/machineSimulator.js.map +0 -1
  79. package/src/MachineSimulator.ts +0 -154
  80. package/src/internal/machineSimulator.ts +0 -199
@@ -1,17 +1,29 @@
1
+ import * as Effect from "effect/Effect"
2
+ import * as Result from "effect/Result"
1
3
  import type { Diagnostic } from "../../DevToolsProtocol.js"
2
4
  import type {
3
5
  Activity as VisualizationActivity,
4
6
  Branch as VisualizationBranch,
7
+ InputSchema,
5
8
  MachineDocument as VisualizationDocument,
6
- Transition as VisualizationTransition
9
+ Transition as VisualizationTransition,
10
+ Trigger
7
11
  } from "../../MachineDocument.js"
8
- import * as MachineSimulator from "../../MachineSimulator.js"
12
+ import * as MachineWalkthrough from "../../MachineWalkthrough.js"
9
13
  import {
10
- type EventInspection,
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 {
23
+ branchTargetApi,
11
24
  type IncomingTransition,
12
25
  makeVisualizerModel,
13
26
  type StateInspection,
14
- type TopologyNode,
15
27
  triggerLabel
16
28
  } from "./visualizer-model.js"
17
29
 
@@ -26,16 +38,33 @@ const createElement = <Tag extends keyof HTMLElementTagNameMap>(
26
38
  return element
27
39
  }
28
40
 
29
- 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 => {
30
44
  const list = createElement("dl", "metadata")
31
45
  for (const [label, value] of items) {
32
- 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)
33
50
  }
34
51
  return list
35
52
  }
36
53
 
37
54
  const badge = (text: string, kind = "neutral"): HTMLSpanElement => createElement("span", `badge badge-${kind}`, text)
38
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
+
39
68
  type StateNavigator = (path: string) => void
40
69
 
41
70
  const stateLink = (path: string, label: string, navigate: StateNavigator): HTMLButtonElement => {
@@ -45,26 +74,35 @@ const stateLink = (path: string, label: string, navigate: StateNavigator): HTMLB
45
74
  return link
46
75
  }
47
76
 
48
- const renderBranch = (branch: VisualizationBranch, navigate: StateNavigator): HTMLElement => {
77
+ const renderBranch = (
78
+ document: VisualizationDocument,
79
+ source: string,
80
+ branch: VisualizationBranch,
81
+ navigate: StateNavigator
82
+ ): HTMLElement => {
49
83
  const row = createElement("div", "branch-row")
50
84
  const main = createElement("div", "branch-main")
51
85
  if (branch.type === "branch") main.append(badge(branch.title, "condition"))
52
- main.append(createElement("span", "branch-arrow", "→"))
53
86
  if (branch.target !== null) {
54
- 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
+ )
55
93
  } else {
56
- 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"))
57
95
  }
58
96
  row.append(main)
59
97
 
60
- const details: Array<readonly [string, string]> = [
61
- ["Selection", branch.selection.kind],
62
- ["Scope", branch.selection.scope ?? "none"]
63
- ]
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]]
64
102
  row.append(metadata(details))
65
103
  if (branch.updates.length > 0) {
66
104
  const updates = createElement("div", "branch-updates")
67
- updates.append(createElement("span", "branch-updates-label", "Updates"))
105
+ updates.append(createElement("span", "branch-updates-label", "Value owner"))
68
106
  branch.updates.forEach((path) => updates.append(stateLink(path, path, navigate)))
69
107
  row.append(updates)
70
108
  }
@@ -72,6 +110,7 @@ const renderBranch = (branch: VisualizationBranch, navigate: StateNavigator): HT
72
110
  }
73
111
 
74
112
  const renderTransition = (
113
+ document: VisualizationDocument,
75
114
  transition: VisualizationTransition,
76
115
  navigate: StateNavigator,
77
116
  showSource = false
@@ -79,44 +118,52 @@ const renderTransition = (
79
118
  const card = createElement("article", "inspection-card transition-card")
80
119
  const header = createElement("div", "card-header")
81
120
  const title = createElement("div", "card-title")
82
- title.append(badge(transition.trigger.type, "trigger"), createElement("strong", undefined, triggerLabel(transition)))
121
+ title.append(createElement("strong", undefined, triggerLabel(transition)))
83
122
  const flags = createElement("div", "card-flags")
123
+ flags.append(badge(transition.trigger.type, "trigger"))
84
124
  if (transition.reenter) flags.append(badge("reenter"))
85
125
  if (transition.acceptance === "declinable") flags.append(badge("declinable"))
86
126
  header.append(title, flags)
87
127
  card.append(header)
88
128
  if (showSource) {
89
- const source = createElement("div", "transition-source")
90
- 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")
91
131
  card.append(source)
92
132
  }
93
133
 
94
- const branches = createElement("div", "branch-list")
95
- if (transition.branches.length === 0) {
96
- branches.append(createElement("div", "empty-inline", "No transition branches"))
97
- } else {
98
- 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)
99
140
  }
100
- card.append(branches)
101
141
  return card
102
142
  }
103
143
 
104
- const renderIncomingTransition = (incoming: IncomingTransition, navigate: StateNavigator): HTMLElement => {
144
+ const renderIncomingTransition = (
145
+ document: VisualizationDocument,
146
+ incoming: IncomingTransition,
147
+ navigate: StateNavigator
148
+ ): HTMLElement => {
105
149
  const card = createElement("article", "inspection-card incoming-card")
106
150
  const header = createElement("div", "card-header")
107
151
  const title = createElement("div", "card-title")
108
- title.append(
109
- badge(incoming.transition.trigger.type, "trigger"),
110
- createElement("strong", undefined, triggerLabel(incoming.transition))
111
- )
112
- 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)
113
156
  card.append(header)
114
157
 
115
- const details: Array<readonly [string, string]> = [
116
- ["Selection", incoming.branch.selection.kind],
117
- ["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]
118
162
  ]
119
- 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])
120
167
  card.append(metadata(details))
121
168
  return card
122
169
  }
@@ -133,15 +180,19 @@ const activityTitle = (activity: VisualizationActivity): string => {
133
180
  }
134
181
  }
135
182
 
136
- const renderActivity = (activity: VisualizationActivity): HTMLElement => {
183
+ const renderActivity = (activity: VisualizationActivity, navigate: StateNavigator): HTMLElement => {
137
184
  const card = createElement("article", "inspection-card activity-card")
138
185
  const header = createElement("div", "card-header")
139
186
  const title = createElement("div", "card-title")
140
- title.append(badge(activity.type, "activity"), createElement("strong", undefined, activityTitle(activity)))
141
- 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)
142
191
  card.append(header)
143
192
 
144
- 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
+ ]
145
196
  if (activity.type === "timer") details.push(["Duration", activity.duration])
146
197
  if (activity.type === "effect") {
147
198
  details.push(["Success", activity.outcomes.success], ["Failure", activity.outcomes.failure])
@@ -153,83 +204,270 @@ const renderActivity = (activity: VisualizationActivity): HTMLElement => {
153
204
  return card
154
205
  }
155
206
 
156
- const inspectionSection = (title: string, count: number): HTMLElement => {
207
+ const inspectionSection = (title: string, count?: number): HTMLElement => {
157
208
  const header = createElement("div", "section-heading")
158
- 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)))
159
211
  return header
160
212
  }
161
213
 
162
- const simulationResultMessage = (result: MachineSimulator.StepResult): string => {
163
- if (result._tag === "Applied") return `${result.event} applied · runtime code was skipped`
164
- if (result._tag === "Blocked") return `${result.event} is not enabled in the current topology`
165
- const reasons: Record<MachineSimulator.Indeterminate["reason"], string> = {
166
- "multiple-transitions": "multiple active transitions",
167
- "declinable-transition": "acceptance depends on runtime code",
168
- "conditional-branches": "the selected branch depends on runtime code",
169
- "history-target": "history resolution needs runtime state",
170
- "choice-target": "choice resolution needs runtime code",
171
- "missing-target": "the target is not present in the document"
172
- }
173
- return `${result.event} was not applied · ${reasons[result.reason]}`
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
+ }
235
+ }
236
+
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
+ }
263
+
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
+ }
314
+
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"
384
+
385
+ const activeTopology = (paths: ReadonlyArray<string>): string => {
386
+ const leaves = paths.filter((path) => !paths.some((candidate) => candidate.startsWith(`${path}.`)))
387
+ return leaves.map((path) => path.split(".").at(-1) ?? path).join(" + ") || "none"
174
388
  }
175
389
 
176
390
  export const renderVisualizer = (
177
391
  root: HTMLElement,
392
+ _machineKey: string,
178
393
  visualization: VisualizationDocument,
179
394
  diagnostics: ReadonlyArray<Diagnostic> = []
180
395
  ): void => {
181
396
  const model = makeVisualizerModel(visualization)
182
- const rows = new Map<string, HTMLElement>()
183
- const nodes = new Map<string, HTMLElement>()
184
- const statuses = new Map<string, HTMLElement>()
185
- const eventButtons = new Map<string, HTMLElement>()
186
- const relatedPaths = new Set<string>()
397
+ const transitionsById = new Map(visualization.transitions.map((transition) => [transition.id, transition]))
398
+ const eventSchemas = new Map(visualization.inputs.events.map(({ event, schema }) => [event, schema]))
399
+ const relatedFrom = new Set<string>()
400
+ const relatedTo = new Set<string>()
401
+ const incomingTransitions = new Set<string>()
402
+ const outgoingTransitions = new Set<string>()
187
403
  let selectedPath: string | undefined
188
- let selectedEvent: string | undefined
189
- let simulation: MachineSimulator.Session | undefined
404
+ let selectedTransition: string | undefined
405
+ let selectedFrame: MachineWalkthrough.Frame | undefined
406
+ let walkthrough: MachineWalkthrough.Session | undefined
407
+ let chartView: ChartView | undefined
190
408
 
191
- const activePaths = (): ReadonlyArray<string> => simulation?.snapshot.activePaths ?? model.activePaths
192
- const candidateEvents = (): ReadonlyArray<string> => simulation?.snapshot.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)
193
413
 
194
414
  const shell = createElement("main", "app-shell")
195
415
  const workspace = createElement("section", "workspace")
196
- const treePanel = createElement("section", "tree-panel")
197
- treePanel.setAttribute("aria-label", `${model.machineId} topology`)
416
+ const chartPanel = createElement("section", "chart-panel")
417
+ chartPanel.setAttribute("aria-label", `${model.machineId} topology`)
198
418
  const inspector = createElement("aside", "inspector")
199
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)
200
438
 
201
439
  const clearButton = createElement("button", "toolbar-button", "Clear selection")
202
440
  clearButton.type = "button"
203
441
  clearButton.disabled = true
204
- const expandButton = createElement("button", "toolbar-button", "Expand all")
205
- expandButton.type = "button"
206
- const collapseButton = createElement("button", "toolbar-button", "Collapse all")
207
- collapseButton.type = "button"
442
+ const detailsButton = createElement("button", "toolbar-button", "View details")
443
+ detailsButton.type = "button"
444
+ detailsButton.disabled = true
208
445
  const revealActiveButton = createElement("button", "toolbar-button", "Reveal active")
209
446
  revealActiveButton.type = "button"
210
447
  revealActiveButton.disabled = model.activePaths.length === 0
211
- const simulationButton = createElement("button", "toolbar-button", "Start simulation")
212
- simulationButton.type = "button"
213
- simulationButton.disabled = model.roots.length === 0
214
-
215
- const renderEmptyInspector = (): void => {
216
- inspector.replaceChildren()
217
- const summary = createElement("div", "inspector-empty")
218
- summary.append(createElement("span", "inspector-empty-kind", "Machine"))
219
- summary.append(createElement("h2", undefined, visualization.machineId))
220
- summary.append(metadata([
221
- ["Source", visualization.source?.file ?? "in memory"],
222
- ["Export", visualization.source?.exportName ?? "none"],
223
- ["Initial", visualization.initial.target],
224
- ["Selection", visualization.initial.selection.kind],
225
- ["Revision", String(visualization.revision)]
226
- ]))
227
- summary.append(createElement("p", undefined, "Select a state to inspect its transitions and activities."))
228
- 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()
229
466
  }
230
467
 
231
468
  const renderInspection = (inspection: StateInspection): void => {
232
- inspector.replaceChildren()
469
+ inspectorContent.replaceChildren()
470
+ showInspector()
233
471
  const header = createElement("header", "inspector-header")
234
472
  const breadcrumbs = createElement("nav", "breadcrumbs")
235
473
  breadcrumbs.setAttribute("aria-label", "State path")
@@ -237,17 +475,20 @@ export const renderVisualizer = (
237
475
  if (index > 0) breadcrumbs.append(createElement("span", "breadcrumb-separator", "/"))
238
476
  breadcrumbs.append(stateLink(item.path, item.label, navigateToState))
239
477
  })
240
- const eyebrow = createElement("div", "inspector-eyebrow")
241
- eyebrow.append(badge(inspection.state.type, "state"))
242
- if (activePaths().includes(inspection.state.path)) eyebrow.append(badge("active", "active"))
243
- if (inspection.initial) eyebrow.append(badge("initial", "initial"))
244
- 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)
245
486
  header.append(metadata([
246
487
  ["Path", inspection.state.path],
247
- ["Parent", inspection.state.parent ?? "root"],
248
- ["Children", String(inspection.state.children.length)],
249
- ["Initial child", inspection.state.initial ?? "none"],
250
- ["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]
251
492
  ]))
252
493
  if (inspection.state.description !== null || inspection.state.documentation !== null) {
253
494
  const annotations = createElement("div", "state-annotations")
@@ -259,91 +500,200 @@ export const renderVisualizer = (
259
500
  }
260
501
  header.append(annotations)
261
502
  }
262
- inspector.append(header)
503
+ inspectorContent.append(header)
263
504
 
264
- const transitions = createElement("section", "inspector-section")
265
- transitions.append(inspectionSection("Transitions", inspection.outgoing.length))
266
- if (inspection.outgoing.length === 0) {
267
- transitions.append(createElement("p", "section-empty", "No transitions leave this state."))
268
- } else {
269
- 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)
270
512
  }
271
- inspector.append(transitions)
272
-
273
- const incoming = createElement("section", "inspector-section")
274
- incoming.append(inspectionSection("Entered by", inspection.incoming.length))
275
- if (inspection.incoming.length === 0) {
276
- incoming.append(createElement("p", "section-empty", "No transitions target this state."))
277
- } else {
513
+ if (inspection.incoming.length > 0) {
514
+ const incoming = createElement("section", "inspector-section")
515
+ incoming.append(inspectionSection("Entered by", inspection.incoming.length))
278
516
  inspection.incoming.forEach((transition) =>
279
- incoming.append(renderIncomingTransition(transition, navigateToState))
517
+ incoming.append(renderIncomingTransition(visualization, transition, navigateToState))
280
518
  )
519
+ inspectorContent.append(incoming)
281
520
  }
282
- inspector.append(incoming)
283
-
284
521
  if (inspection.activities.length > 0) {
285
522
  const activities = createElement("section", "inspector-section")
286
- activities.append(inspectionSection("Activities", inspection.activities.length))
287
- inspection.activities.forEach((activity) => activities.append(renderActivity(activity)))
288
- 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)
289
526
  }
290
527
  }
291
528
 
292
- const renderEventInspection = (inspection: EventInspection): void => {
293
- inspector.replaceChildren()
529
+ const renderTransitionInspection = (transition: VisualizationTransition): void => {
530
+ inspectorContent.replaceChildren()
531
+ showInspector()
294
532
  const header = createElement("header", "inspector-header")
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)
540
+ header.append(metadata([
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]
545
+ ]))
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)
560
+ }
561
+ }
562
+
563
+ const renderPathGroup = (label: string, paths: ReadonlyArray<string>): HTMLElement => {
564
+ const group = createElement("div", "trace-path-group")
565
+ group.append(createElement("span", "trace-label", label))
566
+ const values = createElement("div", "trace-paths")
567
+ paths.forEach((path) => values.append(stateLink(path, path, navigateToState)))
568
+ if (paths.length === 0) values.append(createElement("span", "section-empty", "none"))
569
+ group.append(values)
570
+ return group
571
+ }
572
+
573
+ const renderWalkthroughTrace = (frame: MachineWalkthrough.Frame): void => {
574
+ inspectorContent.replaceChildren()
575
+ showInspector()
576
+ const header = createElement("header", "inspector-header trace-header")
295
577
  const eyebrow = createElement("div", "inspector-eyebrow")
296
- eyebrow.append(badge("event", "trigger"))
297
- const candidate = candidateEvents().includes(inspection.event)
298
- if (candidate) eyebrow.append(badge("enabled", "active"))
299
- header.append(eyebrow, createElement("h2", undefined, inspection.event))
578
+ eyebrow.append(badge("walkthrough", "active"))
579
+ const title = frame.choice === null
580
+ ? "Initial configuration"
581
+ : frame.choice.title ?? triggerName(frame.choice.trigger)
582
+ header.append(eyebrow, createElement("h2", undefined, title))
300
583
  header.append(metadata([
301
- ["Status", candidate ? "enabled" : "not enabled"],
302
- ["Registrations", String(inspection.transitions.length)]
584
+ ["Step", String(frame.step)],
585
+ [
586
+ "Result",
587
+ frame.changed
588
+ ? `${activeTopology(frame.before.activePaths)} → ${activeTopology(frame.after.activePaths)}`
589
+ : "Topology unchanged"
590
+ ]
303
591
  ]))
304
- inspector.append(header)
592
+ inspectorContent.append(header)
305
593
 
306
- const transitions = createElement("section", "inspector-section")
307
- transitions.append(inspectionSection("Transitions", inspection.transitions.length))
308
- inspection.transitions.forEach((transition) =>
309
- transitions.append(renderTransition(transition, navigateToState, true))
594
+ const topology = createElement("section", "inspector-section trace-topology")
595
+ topology.append(
596
+ inspectionSection("Topology"),
597
+ renderPathGroup("Before", frame.before.activePaths),
598
+ renderPathGroup("Exit", frame.exitPaths),
599
+ renderPathGroup("Entry", frame.entryPaths),
600
+ renderPathGroup("After", frame.after.activePaths)
310
601
  )
311
- inspector.append(transitions)
602
+ inspectorContent.append(topology)
603
+
604
+ if (frame.choice === null) {
605
+ if (visualization.inputs.machine !== null) {
606
+ inspectorContent.append(renderInputContract("Machine input contract", visualization.inputs.machine))
607
+ }
608
+ return
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"]))
312
635
  }
313
636
 
314
637
  const clearRelations = (): void => {
315
- for (const path of relatedPaths) {
316
- 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)
317
668
  }
318
- relatedPaths.clear()
319
669
  }
320
670
 
671
+ const updateChartPresentation = (): void => chartView?.update(chartPresentation())
672
+
321
673
  const clearSelection = (): void => {
322
- if (selectedPath !== undefined) {
323
- nodes.get(selectedPath)?.classList.remove("is-selected")
324
- rows.get(selectedPath)?.setAttribute("aria-selected", "false")
674
+ if (walkthrough !== undefined) {
675
+ closeChoicePicker()
676
+ return
325
677
  }
326
- if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected")
327
678
  clearRelations()
328
679
  selectedPath = undefined
329
- selectedEvent = undefined
680
+ selectedTransition = undefined
681
+ selectedFrame = undefined
330
682
  clearButton.disabled = true
331
- renderEmptyInspector()
683
+ detailsButton.disabled = true
684
+ hideInspector()
685
+ updateChartPresentation()
332
686
  }
333
687
 
334
- const markTransitions = (transitions: ReadonlyArray<VisualizationTransition>): void => {
688
+ const markTransitions = (
689
+ transitions: ReadonlyArray<VisualizationTransition>,
690
+ includeSource = true
691
+ ): void => {
335
692
  for (const transition of transitions) {
336
- relatedPaths.add(transition.source)
337
- nodes.get(transition.source)?.classList.add("is-related-source")
693
+ if (includeSource) relatedFrom.add(transition.source)
694
+ outgoingTransitions.add(transition.id)
338
695
  for (const branch of transition.branches) {
339
- if (branch.target !== null) {
340
- relatedPaths.add(branch.target)
341
- nodes.get(branch.target)?.classList.add("is-related-target")
342
- }
343
- for (const update of branch.updates) {
344
- relatedPaths.add(update)
345
- nodes.get(update)?.classList.add("is-related-update")
346
- }
696
+ if (branch.target !== null) relatedTo.add(branch.target)
347
697
  }
348
698
  }
349
699
  }
@@ -351,137 +701,269 @@ export const renderVisualizer = (
351
701
  const markRelatedStates = (inspection: StateInspection): void => {
352
702
  clearRelations()
353
703
  for (const incoming of inspection.incoming) {
354
- relatedPaths.add(incoming.transition.source)
355
- nodes.get(incoming.transition.source)?.classList.add("is-related-source")
356
- }
357
- markTransitions(inspection.outgoing)
358
- }
359
-
360
- const expandAncestors = (inspection: StateInspection): void => {
361
- for (const ancestor of inspection.breadcrumbs.slice(0, -1)) {
362
- const row = rows.get(ancestor.path)
363
- const children = nodes.get(ancestor.path)?.querySelector<HTMLElement>(":scope > .topology-children")
364
- if (row === undefined || children === null || children === undefined) continue
365
- row.setAttribute("aria-expanded", "true")
366
- children.hidden = false
367
- const disclosure = row.querySelector<HTMLElement>(".state-disclosure")
368
- if (disclosure !== null) disclosure.textContent = "▾"
704
+ relatedFrom.add(incoming.transition.source)
705
+ incomingTransitions.add(incoming.transition.id)
369
706
  }
707
+ markTransitions(inspection.outgoing, false)
370
708
  }
371
709
 
372
710
  const selectState = (path: string, focus: boolean): void => {
711
+ if (walkthrough !== undefined) return
373
712
  const inspection = model.inspectState(path)
374
713
  if (inspection === undefined) return
375
- expandAncestors(inspection)
376
- if (selectedPath !== undefined) {
377
- nodes.get(selectedPath)?.classList.remove("is-selected")
378
- rows.get(selectedPath)?.setAttribute("aria-selected", "false")
379
- }
380
- if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected")
381
714
  selectedPath = path
382
- selectedEvent = undefined
383
- nodes.get(path)?.classList.add("is-selected")
384
- rows.get(path)?.setAttribute("aria-selected", "true")
715
+ selectedTransition = undefined
716
+ selectedFrame = undefined
385
717
  markRelatedStates(inspection)
386
718
  clearButton.disabled = false
387
- renderInspection(inspection)
388
- if (focus) {
389
- rows.get(path)?.focus({ preventScroll: true })
390
- rows.get(path)?.scrollIntoView({ block: "nearest" })
391
- }
719
+ detailsButton.disabled = false
720
+ if (!inspector.hidden) renderInspection(inspection)
721
+ updateChartPresentation()
722
+ if (focus) chartView?.focusState(path)
392
723
  }
393
724
 
394
725
  function navigateToState(path: string): void {
395
726
  selectState(path, true)
396
727
  }
397
728
 
398
- const selectEvent = (event: string): void => {
399
- const inspection = model.inspectEvent(event)
400
- if (selectedPath !== undefined) {
401
- nodes.get(selectedPath)?.classList.remove("is-selected")
402
- rows.get(selectedPath)?.setAttribute("aria-selected", "false")
403
- }
404
- 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
405
733
  selectedPath = undefined
406
- selectedEvent = event
407
- eventButtons.get(event)?.classList.add("is-selected")
734
+ selectedTransition = transitionId
735
+ selectedFrame = undefined
408
736
  clearRelations()
409
- markTransitions(inspection.transitions)
737
+ markTransitions([transition])
410
738
  clearButton.disabled = false
411
- renderEventInspection(inspection)
412
- }
413
-
414
- const setExpanded = (row: HTMLElement, expanded: boolean): void => {
415
- const node = row.closest<HTMLElement>(".topology-node")
416
- const children = node?.querySelector<HTMLElement>(":scope > .topology-children")
417
- if (children === null || children === undefined) return
418
- row.setAttribute("aria-expanded", String(expanded))
419
- children.hidden = !expanded
420
- const disclosure = row.querySelector<HTMLElement>(".state-disclosure")
421
- if (disclosure !== null) disclosure.textContent = expanded ? "▾" : "▸"
422
- }
423
-
424
- const renderNode = (node: TopologyNode, depth: number): HTMLElement => {
425
- const container = createElement("div", "topology-node")
426
- container.dataset.statePath = node.path
427
- nodes.set(node.path, container)
428
-
429
- const row = createElement("button", "state-row")
430
- row.type = "button"
431
- row.tabIndex = -1
432
- row.setAttribute("role", "treeitem")
433
- row.setAttribute("aria-level", String(depth + 1))
434
- row.setAttribute("aria-selected", "false")
435
- row.style.setProperty("--depth", String(depth))
436
- row.dataset.statePath = node.path
437
- rows.set(node.path, row)
438
-
439
- const disclosure = createElement("span", "state-disclosure", node.children.length === 0 ? "" : "▾")
440
- const status = createElement("span", `state-status${node.active ? " is-active" : ""}`)
441
- status.setAttribute("aria-label", node.active ? "active" : "inactive")
442
- statuses.set(node.path, status)
443
- const label = createElement("span", "state-label", node.label)
444
- const markers = createElement("span", "state-markers")
445
- if (node.initial) markers.append(badge("initial", "initial"))
446
- if (node.type !== "atomic") markers.append(badge(node.type, "state"))
447
- if (node.transitionCount > 0) markers.append(badge(`${node.transitionCount}t`, "count"))
448
- if (node.activityCount > 0) markers.append(badge(`${node.activityCount}a`, "count"))
449
- row.append(disclosure, status, label, markers)
450
- container.append(row)
451
- row.addEventListener("focus", () => {
452
- rows.forEach((candidate) => candidate.tabIndex = candidate === row ? 0 : -1)
453
- })
739
+ detailsButton.disabled = false
740
+ if (!inspector.hidden) renderTransitionInspection(transition)
741
+ updateChartPresentation()
742
+ }
454
743
 
455
- if (node.children.length > 0) {
456
- const children = createElement("div", "topology-children")
457
- children.setAttribute("role", "group")
458
- node.children.forEach((child) => children.append(renderNode(child, depth + 1)))
459
- container.append(children)
460
- row.setAttribute("aria-expanded", "true")
461
- row.addEventListener("click", () => {
462
- const expanded = row.getAttribute("aria-expanded") === "true"
463
- setExpanded(row, !expanded)
464
- selectState(node.path, false)
465
- })
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
+ ))
812
+ }
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
+ ))
466
820
  } else {
467
- row.addEventListener("click", () => selectState(node.path, false))
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`
849
+ })
850
+ }
851
+
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
468
868
  }
469
- 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
+ )
470
876
  }
471
877
 
472
- const setAllExpanded = (expanded: boolean): void => {
473
- treePanel.querySelectorAll<HTMLElement>(".state-row[aria-expanded]").forEach((row) => {
474
- 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)
475
912
  })
476
913
  }
477
914
 
478
915
  clearButton.addEventListener("click", clearSelection)
479
- expandButton.addEventListener("click", () => setAllExpanded(true))
480
- collapseButton.addEventListener("click", () => setAllExpanded(false))
481
- revealActiveButton.addEventListener("click", () => {
482
- const deepest = [...activePaths()].sort((left, right) => right.split(".").length - left.split(".").length)[0]
483
- 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
+ }
484
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()))
485
967
 
486
968
  const toolbar = createElement("div", "toolbar")
487
969
  const runtime = createElement("div", "runtime-summary")
@@ -489,154 +971,69 @@ export const renderVisualizer = (
489
971
  const runtimeText = createElement("span")
490
972
  runtime.append(runtimeDot, runtimeText)
491
973
  const toolbarActions = createElement("div", "toolbar-actions")
492
- 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)
493
979
  toolbar.append(runtime, toolbarActions)
494
- const tree = createElement("div", "topology-tree")
495
- tree.setAttribute("role", "tree")
496
- tree.setAttribute("aria-label", `${model.machineId} states`)
497
- tree.append(createElement("div", "machine-id", model.machineId))
498
- const events = createElement("div", "enabled-events")
499
- const simulationFeedback = createElement("div", "simulation-feedback")
500
- simulationFeedback.setAttribute("role", "status")
501
- tree.append(events, simulationFeedback)
502
-
503
- const renderEventButtons = (): void => {
504
- events.replaceChildren(createElement("span", "enabled-events-label", "Enabled"))
505
- eventButtons.clear()
506
- const candidates = candidateEvents()
507
- if (candidates.length === 0) {
508
- events.append(createElement("span", "enabled-events-empty", "none"))
509
- return
510
- }
511
- candidates.forEach((event) => {
512
- const button = createElement("button", `event-button${event === selectedEvent ? " is-selected" : ""}`, event)
513
- button.type = "button"
514
- button.addEventListener("click", () => {
515
- if (simulation !== undefined) {
516
- const result = MachineSimulator.send(simulation, event)
517
- if (result._tag === "Applied") simulation = { document: visualization, snapshot: result.session }
518
- simulationFeedback.textContent = simulationResultMessage(result)
519
- simulationFeedback.dataset.status = result._tag.toLowerCase()
520
- updateSimulationUi()
521
- }
522
- selectEvent(event)
523
- })
524
- eventButtons.set(event, button)
525
- events.append(button)
526
- })
527
- }
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)
528
986
 
529
- const updateSimulationUi = (): void => {
987
+ const updateWalkthroughUi = (): void => {
530
988
  const active = new Set(activePaths())
531
- statuses.forEach((status, path) => {
532
- const isActive = active.has(path)
533
- status.classList.toggle("is-active", isActive)
534
- status.setAttribute("aria-label", isActive ? "active" : "inactive")
535
- })
536
- const hasRuntimeState = simulation !== undefined || model.hasSnapshot
989
+ const simulating = walkthrough !== undefined
990
+ updateChartPresentation()
991
+ const hasRuntimeState = simulating || model.hasSnapshot
537
992
  runtimeDot.classList.toggle("has-snapshot", hasRuntimeState)
538
- runtimeText.textContent = simulation !== undefined
539
- ? `${active.size} active · step ${simulation.snapshot.step}`
993
+ runtimeText.textContent = walkthrough !== undefined
994
+ ? `Simulation · ${active.size} active · step ${MachineWalkthrough.cursor(walkthrough)}`
540
995
  : diagnostics.length > 0
541
996
  ? "Partial"
542
997
  : model.hasSnapshot
543
998
  ? `${active.size} active`
544
999
  : "Structure only"
545
- simulationButton.textContent = simulation === undefined ? "Start simulation" : "Reset simulation"
1000
+ walkthroughButton.textContent = simulating ? "Exit simulation" : "Start simulation"
1001
+ walkthroughButton.disabled = model.roots.length === 0
546
1002
  revealActiveButton.disabled = active.size === 0
547
- events.hidden = !hasRuntimeState
548
- simulationFeedback.hidden = simulation === undefined
549
- renderEventButtons()
550
- if (selectedPath !== undefined) {
551
- const inspection = model.inspectState(selectedPath)
552
- if (inspection !== undefined) renderInspection(inspection)
553
- } else if (selectedEvent !== undefined) {
554
- 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
+ }
555
1018
  }
556
1019
  }
557
1020
 
558
- simulationButton.addEventListener("click", () => {
559
- if (simulation === undefined) {
560
- simulation = MachineSimulator.start(visualization)
561
- simulationFeedback.textContent = "Best-effort simulation started · user code will not run"
562
- simulationFeedback.dataset.status = "applied"
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()))
563
1028
  } else {
564
- simulation = undefined
565
- simulationFeedback.textContent = ""
566
- delete simulationFeedback.dataset.status
1029
+ walkthrough = undefined
1030
+ closeChoicePicker()
1031
+ clearSelection()
567
1032
  }
568
- updateSimulationUi()
1033
+ updateWalkthroughUi()
569
1034
  })
570
- if (model.roots.length === 0) {
571
- const empty = createElement("div", "topology-empty")
572
- empty.append(
573
- createElement("strong", undefined, "No states yet"),
574
- createElement("span", undefined, "The topology will appear as the machine definition becomes available.")
575
- )
576
- tree.append(empty)
577
- } else {
578
- model.roots.forEach((node) => tree.append(renderNode(node, 0)))
579
- }
580
- updateSimulationUi()
581
- rows.values().next().value?.setAttribute("tabindex", "0")
582
- tree.addEventListener("keydown", (event) => {
583
- const current = event.target instanceof HTMLElement ? event.target.closest<HTMLElement>(".state-row") : null
584
- if (current === null) return
585
- const visible = [...rows.values()].filter((row) => row.getClientRects().length > 0)
586
- const index = visible.indexOf(current)
587
- const focus = (row: HTMLElement | undefined): void => {
588
- if (row === undefined) return
589
- event.preventDefault()
590
- row.focus()
591
- }
592
- switch (event.key) {
593
- case "ArrowDown":
594
- focus(visible[index + 1])
595
- break
596
- case "ArrowUp":
597
- focus(visible[index - 1])
598
- break
599
- case "Home":
600
- focus(visible[0])
601
- break
602
- case "End":
603
- focus(visible.at(-1))
604
- break
605
- case "ArrowRight": {
606
- if (current.getAttribute("aria-expanded") === "false") {
607
- event.preventDefault()
608
- setExpanded(current, true)
609
- } else {
610
- const child = current.closest<HTMLElement>(".topology-node")
611
- ?.querySelector<HTMLElement>(":scope > .topology-children > .topology-node > .state-row")
612
- focus(child ?? undefined)
613
- }
614
- break
615
- }
616
- case "ArrowLeft": {
617
- if (current.getAttribute("aria-expanded") === "true") {
618
- event.preventDefault()
619
- setExpanded(current, false)
620
- } else {
621
- const parent = current.closest<HTMLElement>(".topology-children")
622
- ?.closest<HTMLElement>(".topology-node")
623
- ?.querySelector<HTMLElement>(":scope > .state-row")
624
- focus(parent ?? undefined)
625
- }
626
- break
627
- }
628
- case "Enter":
629
- case " ":
630
- event.preventDefault()
631
- current.click()
632
- break
633
- case "Escape":
634
- event.preventDefault()
635
- clearSelection()
636
- break
637
- }
638
- })
639
- treePanel.append(toolbar)
1035
+
1036
+ chartPanel.append(toolbar)
640
1037
  if (diagnostics.length > 0) {
641
1038
  const diagnosticList = createElement("div", "diagnostics")
642
1039
  diagnosticList.setAttribute("role", "status")
@@ -648,12 +1045,44 @@ export const renderVisualizer = (
648
1045
  }
649
1046
  diagnosticList.append(item)
650
1047
  })
651
- treePanel.append(diagnosticList)
1048
+ chartPanel.append(diagnosticList)
652
1049
  }
653
- treePanel.append(tree)
654
-
655
- renderEmptyInspector()
656
- workspace.append(treePanel, inspector)
1050
+ chartPanel.append(chart, walkthroughDock, zoomControls)
1051
+ hideInspector()
1052
+ workspace.append(chartPanel, inspector, choicePicker)
657
1053
  shell.append(workspace)
658
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()
659
1088
  }