@typeonce/effect-machine-devtools 0.23.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 (91) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +3 -0
  3. package/README.md +81 -0
  4. package/dist/DevServer.d.ts +42 -0
  5. package/dist/DevServer.d.ts.map +1 -0
  6. package/dist/DevServer.js +28 -0
  7. package/dist/DevServer.js.map +1 -0
  8. package/dist/DevToolsProtocol.d.ts +954 -0
  9. package/dist/DevToolsProtocol.d.ts.map +1 -0
  10. package/dist/DevToolsProtocol.js +96 -0
  11. package/dist/DevToolsProtocol.js.map +1 -0
  12. package/dist/MachineDocument.d.ts +396 -0
  13. package/dist/MachineDocument.d.ts.map +1 -0
  14. package/dist/MachineDocument.js +172 -0
  15. package/dist/MachineDocument.js.map +1 -0
  16. package/dist/MachineRegistry.d.ts +349 -0
  17. package/dist/MachineRegistry.d.ts.map +1 -0
  18. package/dist/MachineRegistry.js +46 -0
  19. package/dist/MachineRegistry.js.map +1 -0
  20. package/dist/MachineSimulator.d.ts +169 -0
  21. package/dist/MachineSimulator.d.ts.map +1 -0
  22. package/dist/MachineSimulator.js +98 -0
  23. package/dist/MachineSimulator.js.map +1 -0
  24. package/dist/ProjectInspector.d.ts +116 -0
  25. package/dist/ProjectInspector.d.ts.map +1 -0
  26. package/dist/ProjectInspector.js +82 -0
  27. package/dist/ProjectInspector.js.map +1 -0
  28. package/dist/bin.d.ts +3 -0
  29. package/dist/bin.d.ts.map +1 -0
  30. package/dist/bin.js +23 -0
  31. package/dist/bin.js.map +1 -0
  32. package/dist/client/assets/index-BGOhE3Ng.css +1 -0
  33. package/dist/client/assets/index-DiqGhsGR.js +14 -0
  34. package/dist/client/index.html +14 -0
  35. package/dist/index.d.ts +18 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +18 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/internal/devServer.d.ts +9 -0
  40. package/dist/internal/devServer.d.ts.map +1 -0
  41. package/dist/internal/devServer.js +119 -0
  42. package/dist/internal/devServer.js.map +1 -0
  43. package/dist/internal/evaluationWorker.d.ts +3 -0
  44. package/dist/internal/evaluationWorker.d.ts.map +1 -0
  45. package/dist/internal/evaluationWorker.js +69 -0
  46. package/dist/internal/evaluationWorker.js.map +1 -0
  47. package/dist/internal/evaluationWorkerBootstrap.d.ts +2 -0
  48. package/dist/internal/evaluationWorkerBootstrap.d.ts.map +1 -0
  49. package/dist/internal/evaluationWorkerBootstrap.js +21 -0
  50. package/dist/internal/evaluationWorkerBootstrap.js.map +1 -0
  51. package/dist/internal/machineDocument.d.ts +4 -0
  52. package/dist/internal/machineDocument.d.ts.map +1 -0
  53. package/dist/internal/machineDocument.js +122 -0
  54. package/dist/internal/machineDocument.js.map +1 -0
  55. package/dist/internal/machineRegistry.d.ts +12 -0
  56. package/dist/internal/machineRegistry.d.ts.map +1 -0
  57. package/dist/internal/machineRegistry.js +97 -0
  58. package/dist/internal/machineRegistry.js.map +1 -0
  59. package/dist/internal/machineSimulator.d.ts +5 -0
  60. package/dist/internal/machineSimulator.d.ts.map +1 -0
  61. package/dist/internal/machineSimulator.js +156 -0
  62. package/dist/internal/machineSimulator.js.map +1 -0
  63. package/dist/internal/projectInspector.d.ts +11 -0
  64. package/dist/internal/projectInspector.d.ts.map +1 -0
  65. package/dist/internal/projectInspector.js +150 -0
  66. package/dist/internal/projectInspector.js.map +1 -0
  67. package/index.html +13 -0
  68. package/package.json +69 -0
  69. package/src/DevServer.ts +45 -0
  70. package/src/DevToolsProtocol.ts +146 -0
  71. package/src/MachineDocument.ts +264 -0
  72. package/src/MachineRegistry.ts +69 -0
  73. package/src/MachineSimulator.ts +154 -0
  74. package/src/ProjectInspector.ts +140 -0
  75. package/src/bin.ts +56 -0
  76. package/src/index.ts +20 -0
  77. package/src/internal/browser/example-machine.ts +134 -0
  78. package/src/internal/browser/machine-index.ts +81 -0
  79. package/src/internal/browser/main.ts +42 -0
  80. package/src/internal/browser/styles.css +817 -0
  81. package/src/internal/browser/text-tree.ts +214 -0
  82. package/src/internal/browser/visualizer-app.ts +659 -0
  83. package/src/internal/browser/visualizer-model.ts +173 -0
  84. package/src/internal/browser/visualizer.ts +31 -0
  85. package/src/internal/devServer.ts +171 -0
  86. package/src/internal/evaluationWorker.ts +138 -0
  87. package/src/internal/evaluationWorkerBootstrap.js +21 -0
  88. package/src/internal/machineDocument.ts +138 -0
  89. package/src/internal/machineRegistry.ts +128 -0
  90. package/src/internal/machineSimulator.ts +199 -0
  91. package/src/internal/projectInspector.ts +226 -0
@@ -0,0 +1,659 @@
1
+ import type { Diagnostic } from "../../DevToolsProtocol.js"
2
+ import type {
3
+ Activity as VisualizationActivity,
4
+ Branch as VisualizationBranch,
5
+ MachineDocument as VisualizationDocument,
6
+ Transition as VisualizationTransition
7
+ } from "../../MachineDocument.js"
8
+ import * as MachineSimulator from "../../MachineSimulator.js"
9
+ import {
10
+ type EventInspection,
11
+ type IncomingTransition,
12
+ makeVisualizerModel,
13
+ type StateInspection,
14
+ type TopologyNode,
15
+ triggerLabel
16
+ } from "./visualizer-model.js"
17
+
18
+ const createElement = <Tag extends keyof HTMLElementTagNameMap>(
19
+ tag: Tag,
20
+ className?: string,
21
+ text?: string
22
+ ): HTMLElementTagNameMap[Tag] => {
23
+ const element = document.createElement(tag)
24
+ if (className !== undefined) element.className = className
25
+ if (text !== undefined) element.textContent = text
26
+ return element
27
+ }
28
+
29
+ const metadata = (items: ReadonlyArray<readonly [string, string]>): HTMLDListElement => {
30
+ const list = createElement("dl", "metadata")
31
+ for (const [label, value] of items) {
32
+ list.append(createElement("dt", undefined, label), createElement("dd", undefined, value))
33
+ }
34
+ return list
35
+ }
36
+
37
+ const badge = (text: string, kind = "neutral"): HTMLSpanElement => createElement("span", `badge badge-${kind}`, text)
38
+
39
+ type StateNavigator = (path: string) => void
40
+
41
+ const stateLink = (path: string, label: string, navigate: StateNavigator): HTMLButtonElement => {
42
+ const link = createElement("button", "state-link", label)
43
+ link.type = "button"
44
+ link.addEventListener("click", () => navigate(path))
45
+ return link
46
+ }
47
+
48
+ const renderBranch = (branch: VisualizationBranch, navigate: StateNavigator): HTMLElement => {
49
+ const row = createElement("div", "branch-row")
50
+ const main = createElement("div", "branch-main")
51
+ if (branch.type === "branch") main.append(badge(branch.title, "condition"))
52
+ main.append(createElement("span", "branch-arrow", "→"))
53
+ if (branch.target !== null) {
54
+ main.append(stateLink(branch.target, branch.target, navigate))
55
+ } else {
56
+ main.append(createElement("span", "branch-target", branch.updates.length > 0 ? "Remain in state" : "No target"))
57
+ }
58
+ row.append(main)
59
+
60
+ const details: Array<readonly [string, string]> = [
61
+ ["Selection", branch.selection.kind],
62
+ ["Scope", branch.selection.scope ?? "none"]
63
+ ]
64
+ row.append(metadata(details))
65
+ if (branch.updates.length > 0) {
66
+ const updates = createElement("div", "branch-updates")
67
+ updates.append(createElement("span", "branch-updates-label", "Updates"))
68
+ branch.updates.forEach((path) => updates.append(stateLink(path, path, navigate)))
69
+ row.append(updates)
70
+ }
71
+ return row
72
+ }
73
+
74
+ const renderTransition = (
75
+ transition: VisualizationTransition,
76
+ navigate: StateNavigator,
77
+ showSource = false
78
+ ): HTMLElement => {
79
+ const card = createElement("article", "inspection-card transition-card")
80
+ const header = createElement("div", "card-header")
81
+ const title = createElement("div", "card-title")
82
+ title.append(badge(transition.trigger.type, "trigger"), createElement("strong", undefined, triggerLabel(transition)))
83
+ const flags = createElement("div", "card-flags")
84
+ if (transition.reenter) flags.append(badge("reenter"))
85
+ if (transition.acceptance === "declinable") flags.append(badge("declinable"))
86
+ header.append(title, flags)
87
+ card.append(header)
88
+ if (showSource) {
89
+ const source = createElement("div", "transition-source")
90
+ source.append(createElement("span", undefined, "From"), stateLink(transition.source, transition.source, navigate))
91
+ card.append(source)
92
+ }
93
+
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)))
99
+ }
100
+ card.append(branches)
101
+ return card
102
+ }
103
+
104
+ const renderIncomingTransition = (incoming: IncomingTransition, navigate: StateNavigator): HTMLElement => {
105
+ const card = createElement("article", "inspection-card incoming-card")
106
+ const header = createElement("div", "card-header")
107
+ 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))
113
+ card.append(header)
114
+
115
+ const details: Array<readonly [string, string]> = [
116
+ ["Selection", incoming.branch.selection.kind],
117
+ ["Scope", incoming.branch.selection.scope ?? "none"]
118
+ ]
119
+ if (incoming.branch.type === "branch") details.unshift(["Branch", incoming.branch.title])
120
+ card.append(metadata(details))
121
+ return card
122
+ }
123
+
124
+ const activityTitle = (activity: VisualizationActivity): string => {
125
+ switch (activity.type) {
126
+ case "process":
127
+ case "effect":
128
+ case "timer":
129
+ case "stream":
130
+ return activity.lifecycleId
131
+ case "machine":
132
+ return `${activity.lifecycleId} → ${activity.child.machineId ?? activity.child.id}`
133
+ }
134
+ }
135
+
136
+ const renderActivity = (activity: VisualizationActivity): HTMLElement => {
137
+ const card = createElement("article", "inspection-card activity-card")
138
+ const header = createElement("div", "card-header")
139
+ const title = createElement("div", "card-title")
140
+ title.append(badge(activity.type, "activity"), createElement("strong", undefined, activityTitle(activity)))
141
+ header.append(title)
142
+ card.append(header)
143
+
144
+ const details: Array<readonly [string, string]> = [["Owner", activity.source]]
145
+ if (activity.type === "timer") details.push(["Duration", activity.duration])
146
+ if (activity.type === "effect") {
147
+ details.push(["Success", activity.outcomes.success], ["Failure", activity.outcomes.failure])
148
+ }
149
+ if (activity.type === "machine") {
150
+ details.push(["Child address", activity.child.id], ["Machine", activity.child.machineId ?? "dynamic"])
151
+ }
152
+ card.append(metadata(details))
153
+ return card
154
+ }
155
+
156
+ const inspectionSection = (title: string, count: number): HTMLElement => {
157
+ const header = createElement("div", "section-heading")
158
+ header.append(createElement("h3", undefined, title), createElement("span", "section-count", String(count)))
159
+ return header
160
+ }
161
+
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]}`
174
+ }
175
+
176
+ export const renderVisualizer = (
177
+ root: HTMLElement,
178
+ visualization: VisualizationDocument,
179
+ diagnostics: ReadonlyArray<Diagnostic> = []
180
+ ): void => {
181
+ 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>()
187
+ let selectedPath: string | undefined
188
+ let selectedEvent: string | undefined
189
+ let simulation: MachineSimulator.Session | undefined
190
+
191
+ const activePaths = (): ReadonlyArray<string> => simulation?.snapshot.activePaths ?? model.activePaths
192
+ const candidateEvents = (): ReadonlyArray<string> => simulation?.snapshot.candidateEvents ?? model.candidateEvents
193
+
194
+ const shell = createElement("main", "app-shell")
195
+ const workspace = createElement("section", "workspace")
196
+ const treePanel = createElement("section", "tree-panel")
197
+ treePanel.setAttribute("aria-label", `${model.machineId} topology`)
198
+ const inspector = createElement("aside", "inspector")
199
+ inspector.setAttribute("aria-live", "polite")
200
+
201
+ const clearButton = createElement("button", "toolbar-button", "Clear selection")
202
+ clearButton.type = "button"
203
+ 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"
208
+ const revealActiveButton = createElement("button", "toolbar-button", "Reveal active")
209
+ revealActiveButton.type = "button"
210
+ 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)
229
+ }
230
+
231
+ const renderInspection = (inspection: StateInspection): void => {
232
+ inspector.replaceChildren()
233
+ const header = createElement("header", "inspector-header")
234
+ const breadcrumbs = createElement("nav", "breadcrumbs")
235
+ breadcrumbs.setAttribute("aria-label", "State path")
236
+ inspection.breadcrumbs.forEach((item, index) => {
237
+ if (index > 0) breadcrumbs.append(createElement("span", "breadcrumb-separator", "/"))
238
+ breadcrumbs.append(stateLink(item.path, item.label, navigateToState))
239
+ })
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))
245
+ header.append(metadata([
246
+ ["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"]
251
+ ]))
252
+ if (inspection.state.description !== null || inspection.state.documentation !== null) {
253
+ const annotations = createElement("div", "state-annotations")
254
+ if (inspection.state.description !== null) {
255
+ annotations.append(createElement("p", "state-description", inspection.state.description))
256
+ }
257
+ if (inspection.state.documentation !== null) {
258
+ annotations.append(createElement("p", "state-documentation", inspection.state.documentation))
259
+ }
260
+ header.append(annotations)
261
+ }
262
+ inspector.append(header)
263
+
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)))
270
+ }
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 {
278
+ inspection.incoming.forEach((transition) =>
279
+ incoming.append(renderIncomingTransition(transition, navigateToState))
280
+ )
281
+ }
282
+ inspector.append(incoming)
283
+
284
+ if (inspection.activities.length > 0) {
285
+ 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)
289
+ }
290
+ }
291
+
292
+ const renderEventInspection = (inspection: EventInspection): void => {
293
+ inspector.replaceChildren()
294
+ const header = createElement("header", "inspector-header")
295
+ 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))
300
+ header.append(metadata([
301
+ ["Status", candidate ? "enabled" : "not enabled"],
302
+ ["Registrations", String(inspection.transitions.length)]
303
+ ]))
304
+ inspector.append(header)
305
+
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))
310
+ )
311
+ inspector.append(transitions)
312
+ }
313
+
314
+ const clearRelations = (): void => {
315
+ for (const path of relatedPaths) {
316
+ nodes.get(path)?.classList.remove("is-related-source", "is-related-target", "is-related-update")
317
+ }
318
+ relatedPaths.clear()
319
+ }
320
+
321
+ const clearSelection = (): void => {
322
+ if (selectedPath !== undefined) {
323
+ nodes.get(selectedPath)?.classList.remove("is-selected")
324
+ rows.get(selectedPath)?.setAttribute("aria-selected", "false")
325
+ }
326
+ if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected")
327
+ clearRelations()
328
+ selectedPath = undefined
329
+ selectedEvent = undefined
330
+ clearButton.disabled = true
331
+ renderEmptyInspector()
332
+ }
333
+
334
+ const markTransitions = (transitions: ReadonlyArray<VisualizationTransition>): void => {
335
+ for (const transition of transitions) {
336
+ relatedPaths.add(transition.source)
337
+ nodes.get(transition.source)?.classList.add("is-related-source")
338
+ 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
+ }
347
+ }
348
+ }
349
+ }
350
+
351
+ const markRelatedStates = (inspection: StateInspection): void => {
352
+ clearRelations()
353
+ 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 = "▾"
369
+ }
370
+ }
371
+
372
+ const selectState = (path: string, focus: boolean): void => {
373
+ const inspection = model.inspectState(path)
374
+ 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
+ selectedPath = path
382
+ selectedEvent = undefined
383
+ nodes.get(path)?.classList.add("is-selected")
384
+ rows.get(path)?.setAttribute("aria-selected", "true")
385
+ markRelatedStates(inspection)
386
+ clearButton.disabled = false
387
+ renderInspection(inspection)
388
+ if (focus) {
389
+ rows.get(path)?.focus({ preventScroll: true })
390
+ rows.get(path)?.scrollIntoView({ block: "nearest" })
391
+ }
392
+ }
393
+
394
+ function navigateToState(path: string): void {
395
+ selectState(path, true)
396
+ }
397
+
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")
405
+ selectedPath = undefined
406
+ selectedEvent = event
407
+ eventButtons.get(event)?.classList.add("is-selected")
408
+ clearRelations()
409
+ markTransitions(inspection.transitions)
410
+ 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
+ })
454
+
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
+ })
466
+ } else {
467
+ row.addEventListener("click", () => selectState(node.path, false))
468
+ }
469
+ return container
470
+ }
471
+
472
+ const setAllExpanded = (expanded: boolean): void => {
473
+ treePanel.querySelectorAll<HTMLElement>(".state-row[aria-expanded]").forEach((row) => {
474
+ setExpanded(row, expanded)
475
+ })
476
+ }
477
+
478
+ 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)
484
+ })
485
+
486
+ const toolbar = createElement("div", "toolbar")
487
+ const runtime = createElement("div", "runtime-summary")
488
+ const runtimeDot = createElement("span", "runtime-dot")
489
+ const runtimeText = createElement("span")
490
+ runtime.append(runtimeDot, runtimeText)
491
+ const toolbarActions = createElement("div", "toolbar-actions")
492
+ toolbarActions.append(clearButton, simulationButton, revealActiveButton, expandButton, collapseButton)
493
+ 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
+ }
528
+
529
+ const updateSimulationUi = (): void => {
530
+ 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
537
+ runtimeDot.classList.toggle("has-snapshot", hasRuntimeState)
538
+ runtimeText.textContent = simulation !== undefined
539
+ ? `${active.size} active · step ${simulation.snapshot.step}`
540
+ : diagnostics.length > 0
541
+ ? "Partial"
542
+ : model.hasSnapshot
543
+ ? `${active.size} active`
544
+ : "Structure only"
545
+ simulationButton.textContent = simulation === undefined ? "Start simulation" : "Reset simulation"
546
+ 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))
555
+ }
556
+ }
557
+
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"
563
+ } else {
564
+ simulation = undefined
565
+ simulationFeedback.textContent = ""
566
+ delete simulationFeedback.dataset.status
567
+ }
568
+ updateSimulationUi()
569
+ })
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)
640
+ if (diagnostics.length > 0) {
641
+ const diagnosticList = createElement("div", "diagnostics")
642
+ diagnosticList.setAttribute("role", "status")
643
+ diagnostics.forEach((diagnostic) => {
644
+ const item = createElement("div", `diagnostic diagnostic-${diagnostic.severity}`)
645
+ item.append(badge(diagnostic.severity, diagnostic.severity), createElement("span", undefined, diagnostic.message))
646
+ if (diagnostic.statePath !== null && model.inspectState(diagnostic.statePath) !== undefined) {
647
+ item.append(stateLink(diagnostic.statePath, diagnostic.statePath, navigateToState))
648
+ }
649
+ diagnosticList.append(item)
650
+ })
651
+ treePanel.append(diagnosticList)
652
+ }
653
+ treePanel.append(tree)
654
+
655
+ renderEmptyInspector()
656
+ workspace.append(treePanel, inspector)
657
+ shell.append(workspace)
658
+ root.replaceChildren(shell)
659
+ }