diff --git a/.changeset/static-statechart-visualizer.md b/.changeset/static-statechart-visualizer.md
new file mode 100644
index 0000000..7cc5e42
--- /dev/null
+++ b/.changeset/static-statechart-visualizer.md
@@ -0,0 +1,11 @@
+---
+"@typeonce/effect-machine-devtools": minor
+---
+
+Replace the text-tree topology pane with a statically laid-out statechart. State cards expose value fields and invocations, while routed transition edges and compound regions make the machine topology readable without a draggable canvas. Directional colors distinguish incoming from outgoing relationships, and conditional branches with the same source and target share one topology edge while retaining their full details in the inspector. Machine tabs sit above the full-viewport chart, selection remains visible independently from the on-demand floating inspector, and corner zoom and fit controls provide a whole-machine overview.
+
+`MachineDocument.State` now retains each state's projected value and output schemas. Consumers of serialized documents must accept `schemaVersion: 3` and the new `valueSchema` and `outputSchema` fields.
+
+Replace planner-backed browser simulation and `MachineSimulator` with the side-effect-free `MachineWalkthrough` module. A walkthrough derives its initial and active configurations entirely from `MachineDocument`, exposes each documented transition branch as an explicit choice, preserves parallel regions, records shallow and deep history, and retains an immutable timeline with cursor-based time travel. Runtime-resolved targets and first-use history remain visible but unavailable instead of executing callbacks or inventing results.
+
+The browser now presents public machine and event schemas as read-only contracts and uses transition edges for simulation. Targetless transitions render as clickable self-loops, runtime-resolved targets terminate at disabled dashed placeholders, and state nodes remain read-only. Direct choices advance immediately while ambiguous or unavailable branches appear in a compact anchored picker. The bottom dock is reserved for the time-travel timeline. The browser no longer asks for payload values or evaluates initializers, resolvers, guards, updates, automatic callbacks, or invoke outcomes. Migrate programmatic document exploration from `MachineSimulator.start` and `MachineSimulator.send` to `MachineWalkthrough.start`, `MachineWalkthrough.choices`, and `MachineWalkthrough.take`.
diff --git a/packages/devtools/NOTICE b/packages/devtools/NOTICE
index b49e70e..91495a8 100644
--- a/packages/devtools/NOTICE
+++ b/packages/devtools/NOTICE
@@ -1,3 +1,7 @@
Portions are adapted from the Effect project, which is distributed under the
MIT License. See https://github.com/Effect-TS/effect and the source history for
authorship and provenance.
+
+This product includes Eclipse Layout Kernel for JavaScript (elkjs), which is
+distributed under the Eclipse Public License 2.0. See
+https://github.com/kieler/elkjs for source and license information.
diff --git a/packages/devtools/README.md b/packages/devtools/README.md
index fb51dd6..821988d 100644
--- a/packages/devtools/README.md
+++ b/packages/devtools/README.md
@@ -1,6 +1,6 @@
# Effect Machine devtools
-`@typeonce/effect-machine-devtools` scans a local project for exported Effect Machine `.handle(...)` results and serves a live text-tree visualizer.
+`@typeonce/effect-machine-devtools` scans a local project for exported Effect Machine `.handle(...)` results and serves a live, statically laid-out statechart.
The package is experimental and pre-1.0. Minor releases may change its command options, document schemas, and programmatic modules.
@@ -62,26 +62,26 @@ Discovery parses source files without executing them. Evaluation then loads cand
Run the devtools only against code you trust. The server has no authentication and binds to the loopback interface by default. Do not expose it on a public or untrusted network.
-## Inspection and simulation
+## Inspection and walkthroughs
-The visualizer shows topology, active initial paths, state annotations, events, transitions, branches, state updates, activities, source metadata, and diagnostics. The tree supports pointer and keyboard navigation, subtree expansion, related-state highlighting, and structured detail inspection.
+The visualizer shows topology as a read-only statechart with native horizontal and vertical scrolling, incremental zoom, and a fit-to-viewport overview. Machine tabs run across the top so the chart uses the rest of the viewport. States remain grouped inside their compound parents, while orthogonal routes connect each enabled transition without requiring a draggable canvas. State cards show projected value fields and invocations at a glance. A single click selects a state or transition, while a double click opens its dismissible inspector. State selection distinguishes incoming from outgoing relationships, and conditional branches with the same source and target share one topology edge while retaining their full details in the inspector.
-Simulation uses the same `Machine.planInitial` and `Machine.plan` semantics as the core package. Machine input and event payload controls are derived from their Effect schemas. Fields show their projected type, description, required or optional status, and constraints such as ranges, lengths, patterns, enum choices, and defaults. Supported controls include strings, numbers, booleans, enums, literals, nested objects, arrays, and unions. Browser constraints provide immediate feedback, then Effect Schema validates the complete value in the worker and reports failures beside the corresponding fields.
+The machine document includes projected value and output schemas for every state, plus the public machine and event input contracts. The browser renders those contracts as read-only field metadata: names, projected types, required or optional status, descriptions, ranges, lengths, patterns, and literal or enum values. It never asks for payload values merely to explore a static document.
-Start a session, send an enabled event, and inspect the resulting macrostep as structured microsteps. Events without payload fields run when clicked; events with input open a form first. The trace includes selected branches, before/after topology, exits, entries, state updates, raised events, emitted events, planned commands, completion, and output.
+Start a simulation to enter the document's captured configuration or its declared initial topology. Simulation mode turns transition edges into the control surface while state nodes remain read-only. Targetless transitions are rendered as self-loops, and runtime-resolved targets terminate at disabled dashed placeholders. Click an unambiguous available edge to advance directly; ambiguous branches open a compact picker at the click rather than guessing. Parallel regions stay active independently, compound states enter their declared initial child, and recorded shallow or deep history can be restored later in the same simulation.
-Each plan loads the exported machine in a fresh worker, decodes the portable session snapshot, and evaluates synchronous statechart callbacks. This supports conditional branches, parallel transitions, history, choices, state updates, reentry, and automatic stabilization. It also means synchronous code inside initial, transition, entry, exit, choice, history, and output callbacks runs during planning.
+Conditional branches, declinable transitions, automatic triggers, and invoke outcomes are shown as explicit choices rather than guessed. A runtime-resolved target or first use of an unrecorded history target remains visible but unavailable. Public event contracts are shown beside their choices, but values are not fabricated because no value can change a document-only decision reliably.
-The planner does not commit commands, start activities, invoke children, deliver `sendTo` events, or run returned Effects. Commands and emissions are shown in the trace instead. A worker is discarded after every request and a planning request is limited to ten seconds, but the devtools are still intended only for trusted projects.
+Every selected branch is retained in the immutable bottom timeline. Select an earlier step, then choose a different branch on the chart to truncate the old future and explore another path. The chart keeps candidate edges visible and reveals a new active configuration only when it falls outside the viewport.
-Simulation sessions use encoded snapshots and are tied to one source revision. A file change remounts the latest document; restart the simulation to use the new definition. Schema decoding and planning failures remain visible as diagnostics without discarding the topology.
+Simulations do not load project modules again, call resolvers or guards, apply state updates, run Effects, start invocations, deliver events, or commit commands. They are deliberately topology-only and side-effect-free. A file change remounts the latest document; restart the simulation to explore the new revision.
## Programmatic modules
-The first release publishes three programmatic modules:
+The package publishes three programmatic modules:
-- `DevToolsProtocol` defines the versioned worker, browser, and planner-session messages.
+- `DevToolsProtocol` defines the versioned discovery and browser registry messages.
- `MachineDocument` defines and constructs the serializable inspection document.
-- `MachineSimulator` provides the conservative, document-only simulator for consumers that cannot load project code.
+- `MachineWalkthrough` provides immutable, document-only topology exploration with explicit choices, history, and time travel.
The project inspector, registry, worker, and local server remain implementation modules. Their interfaces can change without becoming package-level compatibility commitments.
diff --git a/packages/devtools/index.html b/packages/devtools/index.html
index 9c802a9..d7f5711 100644
--- a/packages/devtools/index.html
+++ b/packages/devtools/index.html
@@ -3,8 +3,8 @@
-
- Effect Machine · Text visualizer
+
+ Effect Machine · Statechart
diff --git a/packages/devtools/package.json b/packages/devtools/package.json
index 274f001..9ace8d5 100644
--- a/packages/devtools/package.json
+++ b/packages/devtools/package.json
@@ -22,7 +22,7 @@
".": "./src/index.ts",
"./DevToolsProtocol": "./src/DevToolsProtocol.ts",
"./MachineDocument": "./src/MachineDocument.ts",
- "./MachineSimulator": "./src/MachineSimulator.ts",
+ "./MachineWalkthrough": "./src/MachineWalkthrough.ts",
"./package.json": "./package.json",
"./internal/*": null
},
@@ -36,6 +36,7 @@
"@effect/platform-node": "4.0.0-rc.111",
"@typeonce/effect-machine": "workspace:^",
"chokidar": "4.0.3",
+ "elkjs": "0.11.1",
"typescript": "6.0.3",
"vite": "8.1.5"
},
@@ -67,7 +68,7 @@
".": "./dist/index.js",
"./DevToolsProtocol": "./dist/DevToolsProtocol.js",
"./MachineDocument": "./dist/MachineDocument.js",
- "./MachineSimulator": "./dist/MachineSimulator.js",
+ "./MachineWalkthrough": "./dist/MachineWalkthrough.js",
"./internal/*": null
}
},
diff --git a/packages/devtools/src/DevToolsProtocol.ts b/packages/devtools/src/DevToolsProtocol.ts
index 12f5dcf..4ddc105 100644
--- a/packages/devtools/src/DevToolsProtocol.ts
+++ b/packages/devtools/src/DevToolsProtocol.ts
@@ -144,288 +144,3 @@ export const RegistrySnapshot = Schema.Struct({
* @since 0.23.0
*/
export type RegistrySnapshot = Schema.Schema.Type
-
-/**
- * JSON-safe representation of a persisted machine snapshot.
- *
- * @category schemas
- * @since 0.24.0
- */
-export const EncodedSnapshot = Schema.Struct({
- _tag: Schema.Literal("MachineSnapshot"),
- active: Schema.Array(Schema.Struct({
- path: Schema.String,
- value: Schema.optionalKey(Schema.Json)
- })),
- completed: Schema.optionalKey(Schema.Array(Schema.Struct({
- path: Schema.String,
- output: Schema.optionalKey(Schema.Json)
- }))),
- history: Schema.optionalKey(Schema.Record(
- Schema.String,
- Schema.Struct({
- mode: Schema.Literals(["shallow", "deep"]),
- active: Schema.Array(Schema.String),
- values: Schema.Record(Schema.String, Schema.Json)
- })
- ))
-})
-
-/**
- * @category models
- * @since 0.24.0
- */
-export type EncodedSnapshot = Schema.Schema.Type
-
-const SimulationRequestFields = {
- protocolVersion: Schema.Literal(protocolVersion),
- key: Schema.String,
- revision: Schema.Natural,
- source: MachineDocument.Source
-}
-
-/**
- * Starts an isolated planner session. Omitting `input` calls a machine that
- * declares no input; otherwise the JSON value is decoded by its input schema.
- *
- * @category schemas
- * @since 0.24.0
- */
-export const StartSimulation = Schema.Struct({
- ...SimulationRequestFields,
- _tag: Schema.tag("StartSimulation"),
- input: Schema.optionalKey(Schema.Json)
-})
-
-/**
- * @category models
- * @since 0.24.0
- */
-export type StartSimulation = Schema.Schema.Type
-
-/**
- * Plans one JSON event from an encoded session snapshot.
- *
- * @category schemas
- * @since 0.24.0
- */
-export const SendSimulationEvent = Schema.Struct({
- ...SimulationRequestFields,
- _tag: Schema.tag("SendSimulationEvent"),
- step: Schema.Natural,
- snapshot: EncodedSnapshot,
- event: Schema.Json
-})
-
-/**
- * @category models
- * @since 0.24.0
- */
-export type SendSimulationEvent = Schema.Schema.Type
-
-/**
- * Request accepted by the isolated machine planner.
- *
- * @category schemas
- * @since 0.24.0
- */
-export const SimulationRequest = Schema.Union([StartSimulation, SendSimulationEvent])
-
-/**
- * @category models
- * @since 0.24.0
- */
-export type SimulationRequest = Schema.Schema.Type
-
-/**
- * A compact view of one logical machine snapshot.
- *
- * @category schemas
- * @since 0.24.0
- */
-export const SimulationSnapshot = Schema.Struct({
- activePaths: Schema.Array(Schema.String),
- candidateEvents: Schema.Array(Schema.String)
-})
-
-/**
- * @category models
- * @since 0.24.0
- */
-export type SimulationSnapshot = Schema.Schema.Type
-
-/**
- * Transition selected by the planner after hierarchy and conflict resolution.
- *
- * @category schemas
- * @since 0.24.0
- */
-export const PlannedTransition = Schema.Struct({
- source: Schema.String,
- trigger: MachineDocument.Trigger,
- reenter: Schema.Boolean,
- branchIndex: Schema.Natural,
- branchKey: Schema.NullOr(Schema.String),
- target: Schema.NullOr(Schema.String),
- resolvedTarget: Schema.NullOr(Schema.String),
- updates: Schema.Array(Schema.String)
-})
-
-/**
- * @category models
- * @since 0.24.0
- */
-export type PlannedTransition = Schema.Schema.Type
-
-/**
- * Closed command produced by planning. Commands are displayed but never
- * committed by the visualizer.
- *
- * @category schemas
- * @since 0.24.0
- */
-export const PlannedCommand = Schema.Union([
- Schema.Struct({
- _tag: Schema.tag("SendTo"),
- target: Schema.String,
- event: Schema.Json
- }),
- Schema.Struct({
- _tag: Schema.tag("Stop"),
- target: Schema.String
- })
-])
-
-/**
- * @category models
- * @since 0.24.0
- */
-export type PlannedCommand = Schema.Schema.Type
-
-/**
- * One statechart microstep retained in a planned macrostep.
- *
- * @category schemas
- * @since 0.24.0
- */
-export const SimulationMicrostep = Schema.Struct({
- index: Schema.Natural,
- event: Schema.Json,
- transitions: Schema.Array(PlannedTransition),
- raisedEvents: Schema.Array(Schema.Json),
- emittedEvents: Schema.Array(Schema.Json),
- commands: Schema.Array(PlannedCommand),
- exitPaths: Schema.Array(Schema.String),
- entryPaths: Schema.Array(Schema.String),
- activePaths: Schema.Array(Schema.String),
- changed: Schema.Boolean
-})
-
-/**
- * @category models
- * @since 0.24.0
- */
-export type SimulationMicrostep = Schema.Schema.Type
-
-/**
- * Structured trace for an initial plan or one received event.
- *
- * @category schemas
- * @since 0.24.0
- */
-export const SimulationFrame = Schema.Struct({
- step: Schema.Natural,
- trigger: Schema.Union([
- Schema.Struct({ _tag: Schema.tag("Initial"), input: Schema.optionalKey(Schema.Json) }),
- Schema.Struct({ _tag: Schema.tag("Event"), event: Schema.Json })
- ]),
- before: SimulationSnapshot,
- after: SimulationSnapshot,
- microsteps: Schema.Array(SimulationMicrostep),
- commands: Schema.Array(PlannedCommand),
- emittedEvents: Schema.Array(Schema.Json),
- done: Schema.Boolean,
- output: Schema.optionalKey(Schema.Json)
-})
-
-/**
- * @category models
- * @since 0.24.0
- */
-export type SimulationFrame = Schema.Schema.Type
-
-/**
- * Successful planner response containing the next portable session state.
- *
- * @category schemas
- * @since 0.24.0
- */
-export const SimulationReady = Schema.Struct({
- protocolVersion: Schema.Literal(protocolVersion),
- _tag: Schema.tag("SimulationReady"),
- key: Schema.String,
- revision: Schema.Natural,
- step: Schema.Natural,
- snapshot: EncodedSnapshot,
- current: SimulationSnapshot,
- frame: SimulationFrame
-})
-
-/**
- * @category models
- * @since 0.24.0
- */
-export type SimulationReady = Schema.Schema.Type
-
-/**
- * One authoritative Effect Schema issue associated with a machine or event
- * input path.
- *
- * @category schemas
- * @since 0.24.0
- */
-export const InputIssue = Schema.Struct({
- path: Schema.Array(Schema.Union([Schema.String, Schema.Number])),
- message: Schema.String
-})
-
-/**
- * @category models
- * @since 0.24.0
- */
-export type InputIssue = Schema.Schema.Type
-
-/**
- * Recoverable failure produced while loading, decoding, or planning a session.
- *
- * @category schemas
- * @since 0.24.0
- */
-export const SimulationFailed = Schema.Struct({
- protocolVersion: Schema.Literal(protocolVersion),
- _tag: Schema.tag("SimulationFailed"),
- key: Schema.String,
- revision: Schema.Natural,
- diagnostics: Schema.Array(Diagnostic),
- inputIssues: Schema.Array(InputIssue)
-})
-
-/**
- * @category models
- * @since 0.24.0
- */
-export type SimulationFailed = Schema.Schema.Type
-
-/**
- * Result returned by the isolated machine planner.
- *
- * @category schemas
- * @since 0.24.0
- */
-export const SimulationResult = Schema.Union([SimulationReady, SimulationFailed])
-
-/**
- * @category models
- * @since 0.24.0
- */
-export type SimulationResult = Schema.Schema.Type
diff --git a/packages/devtools/src/MachineDocument.ts b/packages/devtools/src/MachineDocument.ts
index 8e26da3..f495d63 100644
--- a/packages/devtools/src/MachineDocument.ts
+++ b/packages/devtools/src/MachineDocument.ts
@@ -13,7 +13,7 @@ import * as internal from "./internal/machineDocument.js"
* @category models
* @since 0.23.0
*/
-export const schemaVersion = 2 as const
+export const schemaVersion = 3 as const
/**
* Source module and export that produced a machine.
@@ -65,6 +65,24 @@ export const Initial = Schema.Struct({
*/
export type Initial = Schema.Schema.Type
+/**
+ * Canonical JSON Schema retained for machine contracts.
+ *
+ * @category schemas
+ * @since 0.24.0
+ */
+export const InputSchema = Schema.Struct({
+ dialect: Schema.Literal("draft-2020-12"),
+ schema: Schema.Json,
+ definitions: Schema.Record(Schema.String, Schema.Json)
+})
+
+/**
+ * @category models
+ * @since 0.24.0
+ */
+export type InputSchema = Schema.Schema.Type
+
/**
* @category schemas
* @since 0.23.0
@@ -81,6 +99,8 @@ export const State = Schema.Struct({
parent: Schema.NullOr(Schema.String),
children: Schema.Array(Schema.String),
initial: Schema.NullOr(Schema.String),
+ valueSchema: Schema.NullOr(InputSchema),
+ outputSchema: Schema.NullOr(InputSchema),
transitionIds: Schema.Array(Schema.String),
activityIds: Schema.Array(Schema.String)
})
@@ -216,24 +236,6 @@ export const Snapshot = Schema.Struct({
*/
export type Snapshot = Schema.Schema.Type
-/**
- * Canonical JSON Schema used to render one machine input form.
- *
- * @category schemas
- * @since 0.24.0
- */
-export const InputSchema = Schema.Struct({
- dialect: Schema.Literal("draft-2020-12"),
- schema: Schema.Json,
- definitions: Schema.Record(Schema.String, Schema.Json)
-})
-
-/**
- * @category models
- * @since 0.24.0
- */
-export type InputSchema = Schema.Schema.Type
-
/**
* Public event input paired with the schema for its payload.
*
diff --git a/packages/devtools/src/MachineSimulator.ts b/packages/devtools/src/MachineSimulator.ts
deleted file mode 100644
index a4a569f..0000000
--- a/packages/devtools/src/MachineSimulator.ts
+++ /dev/null
@@ -1,154 +0,0 @@
-/**
- * Side-effect-free, best-effort simulation over a machine document.
- *
- * @since 0.23.0
- */
-import * as Schema from "effect/Schema"
-import * as internal from "./internal/machineSimulator.js"
-import type * as MachineDocument from "./MachineDocument.js"
-
-/**
- * Serializable state of a simulation session.
- *
- * @category schemas
- * @since 0.23.0
- */
-export const Snapshot = Schema.Struct({
- step: Schema.Natural,
- activePaths: Schema.Array(Schema.String),
- candidateEvents: Schema.Array(Schema.String)
-})
-
-/**
- * @category models
- * @since 0.23.0
- */
-export type Snapshot = Schema.Schema.Type
-
-/**
- * Opaque simulation state paired with its source document.
- *
- * @category models
- * @since 0.23.0
- */
-export interface Session {
- readonly document: MachineDocument.MachineDocument
- readonly snapshot: Snapshot
-}
-
-/**
- * Runtime behavior deliberately skipped by a best-effort step.
- *
- * @category schemas
- * @since 0.23.0
- */
-export const Note = Schema.Literals([
- "runtime-effects-skipped",
- "state-updates-skipped",
- "reentry-lifecycles-skipped",
- "automatic-transitions-skipped"
-])
-
-/**
- * @category models
- * @since 0.23.0
- */
-export type Note = Schema.Schema.Type
-
-const ResultFields = {
- event: Schema.String,
- transitionIds: Schema.Array(Schema.String),
- session: Snapshot
-}
-
-/**
- * A topologically deterministic step.
- *
- * @category schemas
- * @since 0.23.0
- */
-export const Applied = Schema.Struct({
- ...ResultFields,
- _tag: Schema.tag("Applied"),
- notes: Schema.Array(Note)
-})
-
-/**
- * @category models
- * @since 0.23.0
- */
-export type Applied = Schema.Schema.Type
-
-/**
- * An event with no registration in the current active configuration.
- *
- * @category schemas
- * @since 0.23.0
- */
-export const Blocked = Schema.Struct({
- ...ResultFields,
- _tag: Schema.tag("Blocked"),
- reason: Schema.Literal("event-not-enabled")
-})
-
-/**
- * @category models
- * @since 0.23.0
- */
-export type Blocked = Schema.Schema.Type
-
-/**
- * A step whose topology depends on runtime behavior the document cannot safely
- * evaluate.
- *
- * @category schemas
- * @since 0.23.0
- */
-export const Indeterminate = Schema.Struct({
- ...ResultFields,
- _tag: Schema.tag("Indeterminate"),
- reason: Schema.Literals([
- "multiple-transitions",
- "declinable-transition",
- "conditional-branches",
- "history-target",
- "choice-target",
- "missing-target"
- ])
-})
-
-/**
- * @category models
- * @since 0.23.0
- */
-export type Indeterminate = Schema.Schema.Type
-
-/**
- * @category schemas
- * @since 0.23.0
- */
-export const StepResult = Schema.Union([Applied, Blocked, Indeterminate])
-
-/**
- * @category models
- * @since 0.23.0
- */
-export type StepResult = Schema.Schema.Type
-
-/**
- * Starts from the captured snapshot when present, otherwise from the static
- * initial topology.
- *
- * @category constructors
- * @since 0.23.0
- */
-export const start: (document: MachineDocument.MachineDocument) => Session = internal.start
-
-/**
- * Sends an event without running user code. `Applied` means the active topology
- * is statically known; its notes list runtime behavior that was skipped.
- *
- * @category combinators
- * @since 0.23.0
- */
-export const send: (session: Session, event: string) => StepResult = internal.send
diff --git a/packages/devtools/src/MachineWalkthrough.ts b/packages/devtools/src/MachineWalkthrough.ts
new file mode 100644
index 0000000..eafc250
--- /dev/null
+++ b/packages/devtools/src/MachineWalkthrough.ts
@@ -0,0 +1,199 @@
+/**
+ * Side-effect-free topology walkthroughs over machine documents.
+ *
+ * @since 0.25.0
+ */
+import { dual } from "effect/Function"
+import * as Result from "effect/Result"
+import * as Schema from "effect/Schema"
+import * as internal from "./internal/machineWalkthrough.js"
+import type * as MachineDocument from "./MachineDocument.js"
+
+const TypeId = internal.SessionTypeId
+
+/**
+ * An immutable walkthrough session. Its timeline can be inspected and its
+ * cursor can move without evaluating machine callbacks.
+ *
+ * @category models
+ * @since 0.25.0
+ */
+export interface Session {
+ readonly [TypeId]: typeof TypeId
+}
+
+/**
+ * Why taking a documented branch requires an explicit human decision.
+ *
+ * @category models
+ * @since 0.25.0
+ */
+export type Decision =
+ | "conditional-branch"
+ | "declinable-transition"
+ | "automatic-trigger"
+ | "invoke-outcome"
+
+/**
+ * Why a documented branch cannot advance topology without runtime data.
+ *
+ * @category models
+ * @since 0.25.0
+ */
+export type UnavailableReason = "history-unavailable" | "runtime-target"
+
+/**
+ * One branch that can be explored from the current active configuration.
+ *
+ * @category models
+ * @since 0.25.0
+ */
+export interface Choice {
+ readonly id: string
+ readonly transitionId: string
+ readonly branchId: string
+ readonly branchIndex: number
+ readonly branchKey: string | null
+ readonly title: string | null
+ readonly source: string
+ readonly trigger: MachineDocument.Trigger
+ readonly target: string | null
+ readonly selection: MachineDocument.Selection
+ readonly updates: ReadonlyArray
+ readonly decisions: ReadonlyArray
+ readonly unavailableReason: UnavailableReason | null
+ readonly input: MachineDocument.InputSchema | null
+}
+
+/**
+ * The active state paths at one point in a walkthrough.
+ *
+ * @category models
+ * @since 0.25.0
+ */
+export interface Snapshot {
+ readonly activePaths: ReadonlyArray
+}
+
+/**
+ * One initial or manually selected topology step.
+ *
+ * @category models
+ * @since 0.25.0
+ */
+export interface Frame {
+ readonly step: number
+ readonly choice: Choice | null
+ readonly before: Snapshot
+ readonly after: Snapshot
+ readonly exitPaths: ReadonlyArray
+ readonly entryPaths: ReadonlyArray
+ readonly changed: boolean
+}
+
+/**
+ * A requested choice is not available from the cursor's configuration.
+ *
+ * @category errors
+ * @since 0.25.0
+ */
+export class ChoiceNotFound extends Schema.Error(
+ "@typeonce/effect-machine-devtools/MachineWalkthrough/ChoiceNotFound"
+)({
+ _tag: Schema.tag("ChoiceNotFound"),
+ choiceId: Schema.String
+}) {}
+
+/**
+ * A choice depends on runtime information absent from the machine document.
+ *
+ * @category errors
+ * @since 0.25.0
+ */
+export class ChoiceUnavailable extends Schema.Error(
+ "@typeonce/effect-machine-devtools/MachineWalkthrough/ChoiceUnavailable"
+)({
+ _tag: Schema.tag("ChoiceUnavailable"),
+ choiceId: Schema.String,
+ reason: Schema.Literals(["history-unavailable", "runtime-target"])
+}) {}
+
+/**
+ * A requested timeline step does not exist.
+ *
+ * @category errors
+ * @since 0.25.0
+ */
+export class StepNotFound extends Schema.Error(
+ "@typeonce/effect-machine-devtools/MachineWalkthrough/StepNotFound"
+)({
+ _tag: Schema.tag("StepNotFound"),
+ step: Schema.Number
+}) {}
+
+const api = { ChoiceNotFound, ChoiceUnavailable, StepNotFound }
+
+/**
+ * Starts at the document's captured snapshot when present, otherwise at its
+ * statically declared initial configuration.
+ *
+ * @category constructors
+ * @since 0.25.0
+ */
+export const start: (document: MachineDocument.MachineDocument) => Session = internal.start
+
+/**
+ * Returns the frame selected by the session cursor.
+ *
+ * @category getters
+ * @since 0.25.0
+ */
+export const current: (self: Session) => Frame = internal.current
+
+/**
+ * Returns every retained frame, including frames after the current cursor.
+ *
+ * @category getters
+ * @since 0.25.0
+ */
+export const timeline: (self: Session) => ReadonlyArray = internal.timeline
+
+/**
+ * Returns the current zero-based timeline position.
+ *
+ * @category getters
+ * @since 0.25.0
+ */
+export const cursor: (self: Session) => number = internal.cursor
+
+/**
+ * Lists documented branches whose sources are active at the current cursor.
+ * Runtime-dependent choices remain visible with an unavailable reason.
+ *
+ * @category getters
+ * @since 0.25.0
+ */
+export const choices: (self: Session) => ReadonlyArray = internal.choices
+
+/**
+ * Takes one documented branch. Taking a branch from the past truncates the
+ * future timeline before appending the new frame.
+ *
+ * @category combinators
+ * @since 0.25.0
+ */
+export const take: {
+ (choiceId: string): (self: Session) => Result.Result
+ (self: Session, choiceId: string): Result.Result
+} = dual(2, internal.take(api))
+
+/**
+ * Moves the cursor to an existing frame without changing the timeline.
+ *
+ * @category combinators
+ * @since 0.25.0
+ */
+export const seek: {
+ (step: number): (self: Session) => Result.Result
+ (self: Session, step: number): Result.Result
+} = dual(2, internal.seek(api))
diff --git a/packages/devtools/src/ProjectInspector.ts b/packages/devtools/src/ProjectInspector.ts
index 2697d17..933b2ee 100644
--- a/packages/devtools/src/ProjectInspector.ts
+++ b/packages/devtools/src/ProjectInspector.ts
@@ -92,11 +92,6 @@ export class ProjectInspector extends Context.Service Effect.Effect, DiscoveryError | EvaluationError>
- /** @internal */
- readonly simulate: (
- request: DevToolsProtocol.SimulationRequest,
- options: Pick
- ) => Effect.Effect
}>()("@typeonce/effect-machine-devtools/ProjectInspector") {}
/**
diff --git a/packages/devtools/src/index.ts b/packages/devtools/src/index.ts
index 91df872..d72bb13 100644
--- a/packages/devtools/src/index.ts
+++ b/packages/devtools/src/index.ts
@@ -15,6 +15,6 @@ export * as DevToolsProtocol from "./DevToolsProtocol.js"
export * as MachineDocument from "./MachineDocument.js"
/**
- * @since 0.23.0
+ * @since 0.25.0
*/
-export * as MachineSimulator from "./MachineSimulator.js"
+export * as MachineWalkthrough from "./MachineWalkthrough.js"
diff --git a/packages/devtools/src/internal/browser/chart-layout.ts b/packages/devtools/src/internal/browser/chart-layout.ts
new file mode 100644
index 0000000..f44d25f
--- /dev/null
+++ b/packages/devtools/src/internal/browser/chart-layout.ts
@@ -0,0 +1,433 @@
+import * as Data from "effect/Data"
+import * as Effect from "effect/Effect"
+import type {
+ ELK as ElkApi,
+ ELKConstructorArguments,
+ ElkExtendedEdge,
+ ElkNode,
+ ElkPoint,
+ ElkPort
+} from "elkjs/lib/elk-api.js"
+import ELKBundle from "elkjs/lib/elk.bundled.js"
+import type { ChartEdge, ChartInitial, ChartModel, ChartNode, ChartRuntimeTarget } from "./chart-model.js"
+
+export const maxVisibleFields = 4
+export const maxVisibleActivities = 3
+
+export interface ChartPoint {
+ readonly x: number
+ readonly y: number
+}
+
+export interface LaidOutChartNode {
+ readonly node: ChartNode
+ readonly x: number
+ readonly y: number
+ readonly width: number
+ readonly height: number
+ readonly headerHeight: number
+}
+
+export interface LaidOutChartInitial {
+ readonly initial: ChartInitial
+ readonly x: number
+ readonly y: number
+ readonly width: number
+ readonly height: number
+}
+
+export interface LaidOutChartRuntimeTarget {
+ readonly target: ChartRuntimeTarget
+ readonly x: number
+ readonly y: number
+ readonly width: number
+ readonly height: number
+}
+
+export interface LaidOutChartTransition {
+ readonly kind: "transition"
+ readonly edge: ChartEdge
+ readonly points: ReadonlyArray
+ readonly label: ChartPoint
+ readonly labelWidth: number
+ readonly labelHeight: number
+}
+
+export interface LaidOutChartInitialEdge {
+ readonly kind: "initial"
+ readonly initial: ChartInitial
+ readonly points: ReadonlyArray
+}
+
+export interface LaidOutChart {
+ readonly width: number
+ readonly height: number
+ readonly nodes: ReadonlyArray
+ readonly initials: ReadonlyArray
+ readonly runtimeTargets: ReadonlyArray
+ readonly edges: ReadonlyArray
+}
+
+export class ChartLayoutError extends Data.TaggedError("ChartLayoutError")<{
+ readonly cause: unknown
+}> {}
+
+interface NodeMetric {
+ readonly width: number
+ readonly height: number
+ readonly headerHeight: number
+}
+
+const sectionHeight = (length: number, limit: number): number => {
+ if (length === 0) return 0
+ const visible = Math.min(length, limit)
+ return 24 + visible * 22 + (length > limit ? 18 : 0)
+}
+
+const nodeMetric = (node: ChartNode): NodeMetric => {
+ const headerHeight = Math.max(
+ 78,
+ 76 +
+ sectionHeight(node.fields.length, maxVisibleFields) +
+ sectionHeight(node.activities.length, maxVisibleActivities)
+ )
+ const width = node.type === "choice" || node.type === "history" ? 176 : node.children.length > 0 ? 340 : 276
+ return {
+ width,
+ height: node.children.length === 0 ? headerHeight : Math.max(240, headerHeight + 104),
+ headerHeight
+ }
+}
+
+const ELK = ELKBundle as unknown as new(args?: ELKConstructorArguments) => ElkApi
+const elk = new ELK()
+
+const sourcePortId = (edge: ChartEdge): string => `port:${edge.id}:source`
+const targetPortId = (edge: ChartEdge): string => `port:${edge.id}:target`
+const initialNodeId = (initial: ChartInitial): string => `node:${initial.id}`
+const initialTargetPortId = (initial: ChartInitial): string => `port:${initial.id}:target`
+const runtimeNodeId = (target: ChartRuntimeTarget): string => `node:${target.id}`
+const runtimeTargetPortId = (target: ChartRuntimeTarget): string => `port:${target.edgeId}:target`
+
+const portsByState = (model: ChartModel): ReadonlyMap> => {
+ const ports = new Map>()
+ const add = (path: string, id: string, side: "EAST" | "WEST"): void => {
+ const statePorts = ports.get(path) ?? []
+ statePorts.push({
+ id,
+ width: 6,
+ height: 6,
+ layoutOptions: {
+ "elk.port.side": side,
+ "elk.port.index": String(statePorts.length)
+ }
+ })
+ ports.set(path, statePorts)
+ }
+
+ for (const edge of model.edges) {
+ add(edge.source, sourcePortId(edge), "EAST")
+ if (edge.kind === "target" && edge.target !== null) {
+ add(edge.target, targetPortId(edge), "WEST")
+ } else if (edge.kind === "targetless") {
+ add(edge.source, targetPortId(edge), "EAST")
+ }
+ }
+ for (const initial of model.initials) add(initial.target, initialTargetPortId(initial), "WEST")
+ return ports
+}
+
+const labelMetric = (label: string): { readonly width: number; readonly height: number } => ({
+ width: Math.min(230, Math.max(72, label.length * 7 + 20)),
+ height: 26
+})
+
+const makeGraph = (model: ChartModel): ElkNode => {
+ const nodesByParent = new Map>()
+ for (const node of model.nodes) {
+ const siblings = nodesByParent.get(node.parent) ?? []
+ siblings.push(node)
+ nodesByParent.set(node.parent, siblings)
+ }
+ const initialsByParent = new Map>()
+ for (const initial of model.initials) {
+ const siblings = initialsByParent.get(initial.parent) ?? []
+ siblings.push(initial)
+ initialsByParent.set(initial.parent, siblings)
+ }
+ const runtimeTargetsByParent = new Map>()
+ for (const target of model.runtimeTargets) {
+ const siblings = runtimeTargetsByParent.get(target.parent) ?? []
+ siblings.push(target)
+ runtimeTargetsByParent.set(target.parent, siblings)
+ }
+ const ports = portsByState(model)
+
+ const children = (parent: string | null): Array => [
+ ...(initialsByParent.get(parent) ?? []).map((initial): ElkNode => ({
+ id: initialNodeId(initial),
+ width: 14,
+ height: 14
+ })),
+ ...(nodesByParent.get(parent) ?? []).map((node): ElkNode => {
+ const metric = nodeMetric(node)
+ const descendants = children(node.path)
+ const common = {
+ id: node.path,
+ ports: [...ports.get(node.path) ?? []]
+ }
+ if (descendants.length === 0) {
+ return {
+ ...common,
+ width: metric.width,
+ height: metric.height,
+ layoutOptions: {
+ "elk.portConstraints": "FIXED_ORDER",
+ "elk.spacing.portPort": "22"
+ }
+ }
+ }
+ return {
+ ...common,
+ children: descendants,
+ layoutOptions: {
+ "elk.algorithm": "layered",
+ "elk.direction": "RIGHT",
+ "elk.padding": `[top=${metric.headerHeight + 28},left=28,bottom=28,right=28]`,
+ "elk.nodeSize.constraints": "MINIMUM_SIZE",
+ "elk.nodeSize.minimum": `(${metric.width}, ${metric.height})`,
+ "elk.portConstraints": "FIXED_ORDER",
+ "elk.spacing.portPort": "22",
+ "elk.spacing.nodeNode": "44",
+ "elk.layered.spacing.nodeNodeBetweenLayers": "108"
+ }
+ }
+ }),
+ ...(runtimeTargetsByParent.get(parent) ?? []).map((target): ElkNode => ({
+ id: runtimeNodeId(target),
+ width: 118,
+ height: 34,
+ ports: [{
+ id: runtimeTargetPortId(target),
+ width: 6,
+ height: 6,
+ layoutOptions: { "elk.port.side": "WEST" }
+ }],
+ layoutOptions: { "elk.portConstraints": "FIXED_SIDE" }
+ }))
+ ]
+
+ return {
+ id: "chart-root",
+ children: children(null),
+ edges: [
+ ...model.edges.map((edge): ElkExtendedEdge => {
+ const label = labelMetric(edge.label)
+ return {
+ id: edge.id,
+ sources: [sourcePortId(edge)],
+ targets: [targetPortId(edge)],
+ labels: [{ text: edge.label, width: label.width, height: label.height }]
+ }
+ }),
+ ...model.initials.map((initial): ElkExtendedEdge => ({
+ id: initial.id,
+ sources: [initialNodeId(initial)],
+ targets: [initialTargetPortId(initial)]
+ }))
+ ],
+ layoutOptions: {
+ "elk.algorithm": "layered",
+ "elk.direction": "RIGHT",
+ "elk.hierarchyHandling": "INCLUDE_CHILDREN",
+ "elk.edgeRouting": "ORTHOGONAL",
+ "elk.padding": "[top=44,left=44,bottom=44,right=44]",
+ "elk.spacing.nodeNode": "64",
+ "elk.layered.spacing.nodeNodeBetweenLayers": "148",
+ "elk.layered.spacing.edgeNodeBetweenLayers": "42",
+ "elk.layered.spacing.edgeEdgeBetweenLayers": "26",
+ "elk.spacing.edgeNode": "28",
+ "elk.spacing.edgeEdge": "20",
+ "elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
+ "elk.layered.crossingMinimization.greedySwitchHierarchical.type": "TWO_SIDED",
+ "elk.layered.nodePlacement.favorStraightEdges": "true",
+ "elk.layered.mergeHierarchyEdges": "false",
+ "elk.layered.thoroughness": "12",
+ "elk.randomSeed": "1"
+ }
+ }
+}
+
+const add = (left: ChartPoint, right: ChartPoint): ChartPoint => ({
+ x: left.x + right.x,
+ y: left.y + right.y
+})
+
+const compactPoints = (points: ReadonlyArray): ReadonlyArray => {
+ const result: Array = []
+ for (const point of points) {
+ const previous = result.at(-1)
+ if (previous !== undefined && previous.x === point.x && previous.y === point.y) continue
+ const beforePrevious = result.at(-2)
+ if (
+ beforePrevious !== undefined && previous !== undefined &&
+ (beforePrevious.x === previous.x && previous.x === point.x ||
+ beforePrevious.y === previous.y && previous.y === point.y)
+ ) {
+ result[result.length - 1] = point
+ } else {
+ result.push(point)
+ }
+ }
+ return result
+}
+
+const edgePoints = (edge: ElkExtendedEdge, offset: ChartPoint): ReadonlyArray | undefined => {
+ const sections = edge.sections
+ if (sections === undefined || sections.length === 0) return undefined
+ const byId = new Map(sections.map((section) => [section.id, section]))
+ let section =
+ sections.find((candidate) =>
+ candidate.incomingShape !== undefined || candidate.incomingSections === undefined ||
+ candidate.incomingSections.length === 0
+ ) ?? sections[0]
+ const points: Array = []
+ const visited = new Set()
+ while (section !== undefined && !visited.has(section.id)) {
+ visited.add(section.id)
+ if (points.length === 0) points.push(section.startPoint)
+ points.push(...(section.bendPoints ?? []), section.endPoint)
+ const next = section.outgoingSections?.[0]
+ section = next === undefined ? undefined : byId.get(next)
+ }
+ return points.length < 2 ? undefined : compactPoints(points.map((point) => add(point, offset)))
+}
+
+const midpoint = (points: ReadonlyArray): ChartPoint => {
+ const lengths = points.slice(1).map((point, index) => {
+ const previous = points[index]!
+ return Math.abs(point.x - previous.x) + Math.abs(point.y - previous.y)
+ })
+ const total = lengths.reduce((sum, length) => sum + length, 0)
+ let remaining = total / 2
+ for (let index = 0; index < lengths.length; index++) {
+ const length = lengths[index]!
+ const start = points[index]!
+ const end = points[index + 1]!
+ if (remaining <= length) {
+ const ratio = length === 0 ? 0 : remaining / length
+ return {
+ x: start.x + (end.x - start.x) * ratio,
+ y: start.y + (end.y - start.y) * ratio
+ }
+ }
+ remaining -= length
+ }
+ return points.at(-1)!
+}
+
+const expandTargetlessLoop = (points: ReadonlyArray): ReadonlyArray => {
+ const start = points[0]
+ const end = points.at(-1)
+ if (start === undefined || end === undefined) return points
+ const outerX = Math.max(...points.map(({ x }) => x)) + 30
+ return compactPoints([
+ start,
+ { x: outerX, y: start.y },
+ { x: outerX, y: end.y },
+ end
+ ])
+}
+
+const collectLayout = (model: ChartModel, graph: ElkNode): LaidOutChart => {
+ const chartNodes = new Map(model.nodes.map((node) => [node.path, node]))
+ const chartInitials = new Map(model.initials.map((initial) => [initialNodeId(initial), initial]))
+ const chartRuntimeTargets = new Map(model.runtimeTargets.map((target) => [runtimeNodeId(target), target]))
+ const offsets = new Map([[graph.id, { x: 0, y: 0 }]])
+ const nodes: Array = []
+ const initials: Array = []
+ const runtimeTargets: Array = []
+
+ const visit = (node: ElkNode, parentOffset: ChartPoint): void => {
+ const absolute = add(parentOffset, { x: node.x ?? 0, y: node.y ?? 0 })
+ offsets.set(node.id, absolute)
+ const chartNode = chartNodes.get(node.id)
+ if (chartNode !== undefined) {
+ const metric = nodeMetric(chartNode)
+ nodes.push({
+ node: chartNode,
+ x: absolute.x,
+ y: absolute.y,
+ width: node.width ?? metric.width,
+ height: node.height ?? metric.height,
+ headerHeight: metric.headerHeight
+ })
+ }
+ const initial = chartInitials.get(node.id)
+ if (initial !== undefined) {
+ initials.push({
+ initial,
+ x: absolute.x,
+ y: absolute.y,
+ width: node.width ?? 14,
+ height: node.height ?? 14
+ })
+ }
+ const runtimeTarget = chartRuntimeTargets.get(node.id)
+ if (runtimeTarget !== undefined) {
+ runtimeTargets.push({
+ target: runtimeTarget,
+ x: absolute.x,
+ y: absolute.y,
+ width: node.width ?? 118,
+ height: node.height ?? 34
+ })
+ }
+ for (const child of node.children ?? []) visit(child, absolute)
+ }
+ for (const child of graph.children ?? []) visit(child, { x: 0, y: 0 })
+
+ const chartEdges = new Map(model.edges.map((edge) => [edge.id, edge]))
+ const initialEdges = new Map(model.initials.map((initial) => [initial.id, initial]))
+ const edges = (graph.edges ?? []).flatMap((edge): ReadonlyArray => {
+ const offset = offsets.get(edge.container ?? graph.id) ?? { x: 0, y: 0 }
+ const points = edgePoints(edge, offset)
+ if (points === undefined) return []
+ const chartEdge = chartEdges.get(edge.id)
+ if (chartEdge !== undefined) {
+ const metric = labelMetric(chartEdge.label)
+ const label = edge.labels?.[0]
+ const transitionPoints = chartEdge.kind === "targetless" ? expandTargetlessLoop(points) : points
+ return [{
+ kind: "transition",
+ edge: chartEdge,
+ points: transitionPoints,
+ label: label?.x === undefined || label.y === undefined
+ ? midpoint(transitionPoints)
+ : add(offset, {
+ x: label.x + (label.width ?? metric.width) / 2,
+ y: label.y + (label.height ?? metric.height) / 2
+ }),
+ labelWidth: label?.width ?? metric.width,
+ labelHeight: label?.height ?? metric.height
+ }]
+ }
+ const initial = initialEdges.get(edge.id)
+ return initial === undefined ? [] : [{ kind: "initial", initial, points }]
+ })
+
+ return {
+ width: Math.max(360, graph.width ?? 0),
+ height: Math.max(280, graph.height ?? 0),
+ nodes,
+ initials,
+ runtimeTargets,
+ edges
+ }
+}
+
+export const layoutChart = (model: ChartModel): Effect.Effect =>
+ Effect.tryPromise({
+ try: () => elk.layout(makeGraph(model)),
+ catch: (cause) => new ChartLayoutError({ cause })
+ }).pipe(Effect.map((graph) => collectLayout(model, graph)))
diff --git a/packages/devtools/src/internal/browser/chart-model.ts b/packages/devtools/src/internal/browser/chart-model.ts
new file mode 100644
index 0000000..d669df0
--- /dev/null
+++ b/packages/devtools/src/internal/browser/chart-model.ts
@@ -0,0 +1,234 @@
+import type {
+ Activity as VisualizationActivity,
+ Branch as VisualizationBranch,
+ MachineDocument as VisualizationDocument,
+ State as VisualizationState,
+ Transition as VisualizationTransition
+} from "../../MachineDocument.js"
+import { type InputField, projectInputSchema } from "./input-form.js"
+import { stateLabel, triggerLabel } from "./visualizer-model.js"
+
+export interface ChartField {
+ readonly key: string
+ readonly label: string
+ readonly type: string
+ readonly required: boolean
+}
+
+export interface ChartActivity {
+ readonly id: string
+ readonly kind: VisualizationActivity["type"]
+ readonly label: string
+}
+
+export interface ChartNode {
+ readonly path: string
+ readonly label: string
+ readonly type: VisualizationState["type"]
+ readonly parent: string | null
+ readonly children: ReadonlyArray
+ readonly active: boolean
+ readonly initial: boolean
+ readonly fields: ReadonlyArray
+ readonly activities: ReadonlyArray
+}
+
+export interface ChartEdge {
+ readonly id: string
+ readonly transitionId: string
+ readonly branchIds: ReadonlyArray
+ readonly kind: "target" | "targetless" | "runtime"
+ readonly source: string
+ readonly target: string | null
+ readonly label: string
+ readonly trigger: VisualizationTransition["trigger"]
+ readonly reenter: boolean
+ readonly acceptance: VisualizationTransition["acceptance"]
+}
+
+export interface ChartRuntimeTarget {
+ readonly id: string
+ readonly edgeId: string
+ readonly parent: string | null
+ readonly label: string
+}
+
+export interface ChartInitial {
+ readonly id: string
+ readonly target: string
+ readonly parent: string | null
+}
+
+export interface ChartModel {
+ readonly machineId: string
+ readonly roots: ReadonlyArray
+ readonly nodes: ReadonlyArray
+ readonly edges: ReadonlyArray
+ readonly runtimeTargets: ReadonlyArray
+ readonly initials: ReadonlyArray
+}
+
+const literalType = (value: string | number | boolean | null): string =>
+ typeof value === "string" ? JSON.stringify(value) : String(value)
+
+const fieldType = (field: InputField): string => {
+ switch (field._tag) {
+ case "String":
+ return field.format ?? "string"
+ case "Number":
+ return field.integer ? "integer" : "number"
+ case "Boolean":
+ return "boolean"
+ case "Enum":
+ return field.values.map(literalType).join(" | ")
+ case "Literal":
+ return literalType(field.value)
+ case "Object":
+ return "object"
+ case "Array":
+ return `${fieldType(field.item)}[]`
+ case "Union":
+ return field.alternatives.map(fieldType).join(" | ")
+ case "Unsupported":
+ return "unknown"
+ }
+}
+
+const stateFields = (state: VisualizationState): ReadonlyArray => {
+ if (state.valueSchema === null) return []
+ const projected = projectInputSchema(state.valueSchema)
+ if (projected._tag !== "Object") {
+ return [{
+ key: "value",
+ label: projected.title ?? "value",
+ type: fieldType(projected),
+ required: true
+ }]
+ }
+ return projected.fields
+ .filter(({ key }) => key !== "_tag")
+ .map(({ field, key, required }) => ({
+ key,
+ label: field.title ?? key,
+ type: fieldType(field),
+ required
+ }))
+}
+
+const activityLabel = (activity: VisualizationActivity): string => {
+ switch (activity.type) {
+ case "machine":
+ return `${activity.lifecycleId} → ${activity.child.machineId ?? activity.child.id}`
+ case "timer":
+ return `${activity.lifecycleId} · ${activity.duration}`
+ case "process":
+ case "effect":
+ case "stream":
+ return activity.lifecycleId
+ }
+}
+
+const transitionLabel = (transition: VisualizationTransition, branch: VisualizationBranch): string => {
+ const trigger = triggerLabel(transition)
+ return branch.type === "branch" ? `${trigger} · ${branch.title}` : trigger
+}
+
+interface EdgeGroup {
+ readonly kind: ChartEdge["kind"]
+ readonly target: string | null
+ readonly branches: Array
+}
+
+const edgeGroup = (
+ branch: VisualizationBranch,
+ states: ReadonlyMap
+): { readonly key: string; readonly kind: ChartEdge["kind"]; readonly target: string | null } => {
+ if (branch.target !== null && states.has(branch.target)) {
+ return { key: `target:${branch.target}`, kind: "target", target: branch.target }
+ }
+ if (branch.selection.kind === "none" || branch.selection.kind === "update") {
+ return { key: "targetless", kind: "targetless", target: null }
+ }
+ return { key: "runtime", kind: "runtime", target: null }
+}
+
+export const makeChartModel = (document: VisualizationDocument): ChartModel => {
+ const active = new Set(document.snapshot?.activePaths ?? [])
+ const initialPaths = new Set([document.initial.target])
+ const activities = new Map(document.activities.map((activity) => [activity.id, activity]))
+ const states = new Map(document.states.map((state) => [state.path, state]))
+
+ for (const state of document.states) {
+ if (state.initial !== null) initialPaths.add(state.initial)
+ }
+
+ const nodes = document.states.map((state): ChartNode => ({
+ path: state.path,
+ label: stateLabel(state),
+ type: state.type,
+ parent: state.parent,
+ children: [...state.children],
+ active: active.has(state.path),
+ initial: initialPaths.has(state.path),
+ fields: stateFields(state),
+ activities: state.activityIds.flatMap((id): ReadonlyArray => {
+ const activity = activities.get(id)
+ return activity === undefined ? [] : [{ id, kind: activity.type, label: activityLabel(activity) }]
+ })
+ }))
+
+ const edges = document.transitions.flatMap((transition): ReadonlyArray => {
+ if (!states.has(transition.source)) return []
+ const groups = new Map()
+ for (const branch of transition.branches) {
+ const group = edgeGroup(branch, states)
+ const current = groups.get(group.key)
+ if (current === undefined) {
+ groups.set(group.key, { kind: group.kind, target: group.target, branches: [branch] })
+ } else {
+ current.branches.push(branch)
+ }
+ }
+ return [...groups].map(([key, { branches, kind, target }]): ChartEdge => ({
+ id: branches.length === 1 ? branches[0]!.id : `${transition.id}:${key}`,
+ transitionId: transition.id,
+ branchIds: branches.map(({ id }) => id),
+ kind,
+ source: transition.source,
+ target,
+ label: branches.length === 1
+ ? transitionLabel(transition, branches[0]!)
+ : `${triggerLabel(transition)} · ${branches.length} branches`,
+ trigger: transition.trigger,
+ reenter: transition.reenter,
+ acceptance: transition.acceptance
+ }))
+ })
+
+ const runtimeTargets = edges.flatMap((edge): ReadonlyArray => {
+ if (edge.kind !== "runtime") return []
+ const source = states.get(edge.source)
+ return source === undefined
+ ? []
+ : [{
+ id: `runtime:${edge.id}`,
+ edgeId: edge.id,
+ parent: source.parent,
+ label: "runtime target"
+ }]
+ })
+
+ const initials = [...initialPaths].flatMap((target): ReadonlyArray => {
+ const state = states.get(target)
+ return state === undefined ? [] : [{ id: `initial:${target}`, target, parent: state.parent }]
+ })
+
+ return {
+ machineId: document.machineId,
+ roots: [...document.roots],
+ nodes,
+ edges,
+ runtimeTargets,
+ initials
+ }
+}
diff --git a/packages/devtools/src/internal/browser/chart-renderer.ts b/packages/devtools/src/internal/browser/chart-renderer.ts
new file mode 100644
index 0000000..b0b5e70
--- /dev/null
+++ b/packages/devtools/src/internal/browser/chart-renderer.ts
@@ -0,0 +1,661 @@
+import * as Effect from "effect/Effect"
+import type { MachineDocument as VisualizationDocument } from "../../MachineDocument.js"
+import {
+ type ChartLayoutError,
+ type ChartPoint,
+ type LaidOutChart,
+ type LaidOutChartNode,
+ layoutChart,
+ maxVisibleActivities,
+ maxVisibleFields
+} from "./chart-layout.js"
+import { makeChartModel } from "./chart-model.js"
+
+export interface ChartHandlers {
+ readonly selectState: (path: string, anchor: ChartInteractionAnchor) => void
+ readonly openStateDetails: (path: string) => void
+ readonly selectTransition: (
+ transitionId: string,
+ branchIds: ReadonlyArray,
+ anchor: ChartInteractionAnchor
+ ) => void
+ readonly openTransitionDetails: (transitionId: string) => void
+ readonly clearSelection: () => void
+ readonly zoomChanged: (zoom: number) => void
+}
+
+export interface ChartPresentation {
+ readonly simulationMode: boolean
+ readonly activePaths: ReadonlyArray
+ readonly selectedState: string | null
+ readonly selectedTransition: string | null
+ readonly fromPaths: ReadonlyArray
+ readonly toPaths: ReadonlyArray
+ readonly incomingTransitionIds: ReadonlyArray
+ readonly outgoingTransitionIds: ReadonlyArray
+ readonly availableBranchIds: ReadonlyArray
+ readonly unavailableBranchIds: ReadonlyArray
+}
+
+export interface ChartView {
+ readonly update: (presentation: ChartPresentation) => void
+ readonly focusState: (path: string) => void
+ readonly revealState: (path: string) => void
+ readonly revealStates: (paths: ReadonlyArray) => void
+ readonly getZoom: () => number
+ readonly setZoom: (zoom: number, anchor?: ChartZoomAnchor) => number
+ readonly fit: () => number
+}
+
+export interface ChartZoomAnchor {
+ readonly x: number
+ readonly y: number
+}
+
+export interface ChartInteractionAnchor {
+ readonly x: number
+ readonly y: number
+}
+
+export const minimumChartZoom = 0.2
+export const maximumChartZoom = 1.6
+export const chartPanThreshold = 5
+
+const clampZoom = (zoom: number): number => Math.min(maximumChartZoom, Math.max(minimumChartZoom, zoom))
+
+export const chartZoomScrollPosition = (
+ currentZoom: number,
+ nextZoom: number,
+ scrollLeft: number,
+ scrollTop: number,
+ anchor: ChartZoomAnchor
+): ChartZoomAnchor => ({
+ x: Math.max(0, (scrollLeft + anchor.x) / currentZoom * nextZoom - anchor.x),
+ y: Math.max(0, (scrollTop + anchor.y) / currentZoom * nextZoom - anchor.y)
+})
+
+export const chartWheelZoom = (
+ currentZoom: number,
+ deltaY: number,
+ deltaMode: number,
+ viewportHeight: number
+): number => {
+ const normalizedDelta = deltaY * (deltaMode === 1 ? 16 : deltaMode === 2 ? viewportHeight : 1)
+ return clampZoom(currentZoom * Math.exp(-normalizedDelta * 0.0025))
+}
+
+export const isChartPan = (
+ start: ChartZoomAnchor,
+ current: ChartZoomAnchor,
+ threshold = chartPanThreshold
+): boolean => Math.hypot(current.x - start.x, current.y - start.y) >= threshold
+
+const element = (
+ tag: Tag,
+ className?: string,
+ text?: string
+): HTMLElementTagNameMap[Tag] => {
+ const node = document.createElement(tag)
+ if (className !== undefined) node.className = className
+ if (text !== undefined) node.textContent = text
+ return node
+}
+
+const svgElement = (
+ tag: Tag,
+ className?: string
+): SVGElementTagNameMap[Tag] => {
+ const node = document.createElementNS("http://www.w3.org/2000/svg", tag)
+ if (className !== undefined) node.setAttribute("class", className)
+ return node
+}
+
+const position = (
+ target: HTMLElement,
+ bounds: { readonly x: number; readonly y: number; readonly width: number; readonly height: number }
+): void => {
+ target.style.left = `${bounds.x}px`
+ target.style.top = `${bounds.y}px`
+ target.style.width = `${bounds.width}px`
+ target.style.height = `${bounds.height}px`
+}
+
+const statusLabel = (active: boolean, initial: boolean): string => {
+ if (active && initial) return "active, initial state"
+ if (active) return "active"
+ if (initial) return "initial state"
+ return "inactive"
+}
+
+const status = (active: boolean, initial: boolean): HTMLSpanElement => {
+ const dot = element(
+ "span",
+ `chart-state-status${active ? " is-active" : ""}${initial ? " is-initial" : ""}`
+ )
+ dot.dataset.initial = String(initial)
+ dot.setAttribute("aria-label", statusLabel(active, initial))
+ return dot
+}
+
+const more = (count: number): HTMLElement => element("div", "chart-more", `+${count} more`)
+
+const stateContent = (layout: LaidOutChartNode, stateStatus: HTMLElement): DocumentFragment => {
+ const fragment = document.createDocumentFragment()
+ const heading = element("div", "chart-state-heading")
+ const identity = element("div", "chart-state-identity")
+ identity.append(stateStatus, element("strong", "chart-state-name", layout.node.label))
+ heading.append(identity)
+ fragment.append(heading)
+
+ if (layout.node.fields.length > 0) {
+ const fields = element("div", "chart-state-section")
+ fields.append(element("div", "chart-section-label", "value"))
+ layout.node.fields.slice(0, maxVisibleFields).forEach((field) => {
+ const row = element("div", "chart-field-row")
+ row.append(
+ element("span", "chart-field-name", `${field.label}${field.required ? "" : "?"}`),
+ element("span", "chart-field-type", field.type)
+ )
+ fields.append(row)
+ })
+ if (layout.node.fields.length > maxVisibleFields) {
+ fields.append(more(layout.node.fields.length - maxVisibleFields))
+ }
+ fragment.append(fields)
+ }
+
+ if (layout.node.activities.length > 0) {
+ const activities = element("div", "chart-state-section")
+ activities.append(element("div", "chart-section-label", "invokes"))
+ layout.node.activities.slice(0, maxVisibleActivities).forEach((activity) => {
+ const row = element("div", "chart-activity-row")
+ row.append(
+ element("span", "chart-activity-kind", activity.kind),
+ element("span", "chart-activity-name", activity.label)
+ )
+ activities.append(row)
+ })
+ if (layout.node.activities.length > maxVisibleActivities) {
+ activities.append(more(layout.node.activities.length - maxVisibleActivities))
+ }
+ fragment.append(activities)
+ }
+
+ return fragment
+}
+
+const pathData = (points: ReadonlyArray): string => {
+ const first = points[0]
+ if (first === undefined) return ""
+ return points.slice(1).reduce((path, point) => `${path} L ${point.x} ${point.y}`, `M ${first.x} ${first.y}`)
+}
+
+const setStateClass = (
+ elements: ReadonlyMap>,
+ paths: ReadonlyArray,
+ className: string
+): void => {
+ for (const path of paths) elements.get(path)?.forEach((node) => node.classList.add(className))
+}
+
+const render = (
+ host: HTMLElement,
+ layout: LaidOutChart,
+ handlers: ChartHandlers
+): ChartView => {
+ const viewport = element("div", "chart-viewport")
+ const stage = element("div", "chart-stage")
+ const canvas = element("div", "chart-canvas")
+ stage.style.width = `${layout.width}px`
+ stage.style.height = `${layout.height}px`
+ canvas.style.width = `${layout.width}px`
+ canvas.style.height = `${layout.height}px`
+ const regions = element("div", "chart-regions")
+ const svg = svgElement("svg", "chart-edges")
+ svg.setAttribute("width", String(layout.width))
+ svg.setAttribute("height", String(layout.height))
+ svg.setAttribute("viewBox", `0 0 ${layout.width} ${layout.height}`)
+ svg.setAttribute("aria-hidden", "true")
+ const definitions = svgElement("defs")
+ const marker = svgElement("marker")
+ marker.id = "chart-arrow"
+ marker.setAttribute("viewBox", "0 0 10 10")
+ marker.setAttribute("refX", "9")
+ marker.setAttribute("refY", "5")
+ marker.setAttribute("markerWidth", "7")
+ marker.setAttribute("markerHeight", "7")
+ marker.setAttribute("orient", "auto-start-reverse")
+ const arrow = svgElement("path")
+ arrow.setAttribute("d", "M 0 0 L 10 5 L 0 10 z")
+ marker.append(arrow)
+ definitions.append(marker)
+ svg.append(definitions)
+ const nodesLayer = element("div", "chart-nodes")
+ const labelsLayer = element("div", "chart-labels")
+ canvas.append(regions, svg, nodesLayer, labelsLayer)
+ stage.append(canvas)
+ viewport.append(stage)
+ host.replaceChildren(viewport)
+
+ let zoom = 1
+ let pan: {
+ readonly pointerId: number
+ readonly start: ChartZoomAnchor
+ readonly scrollLeft: number
+ readonly scrollTop: number
+ dragging: boolean
+ } | undefined
+ let suppressClick = false
+ let suppressDoubleClickUntil = 0
+
+ const stateElements = new Map>()
+ const stateControls = new Map()
+ const stateStatuses = new Map()
+ const transitionElements = new Map>()
+ const edgeElements = new Map>()
+ const transitionControls = new Map>()
+ const chartEdges = new Map(
+ layout.edges.flatMap((laidOut) => laidOut.kind === "transition" ? [[laidOut.edge.id, laidOut.edge] as const] : [])
+ )
+
+ const registerStateElement = (path: string, node: HTMLElement): void => {
+ const registered = stateElements.get(path) ?? []
+ registered.push(node)
+ stateElements.set(path, registered)
+ }
+ const registerTransitionElement = (id: string, node: Element): void => {
+ const registered = transitionElements.get(id) ?? []
+ registered.push(node)
+ transitionElements.set(id, registered)
+ }
+ const registerEdgeElement = (id: string, node: Element): void => {
+ const registered = edgeElements.get(id) ?? []
+ registered.push(node)
+ edgeElements.set(id, registered)
+ }
+
+ for (const laidOut of layout.nodes) {
+ if (laidOut.node.children.length > 0) {
+ const region = element("div", `chart-compound chart-compound-${laidOut.node.type}`)
+ region.dataset.statePath = laidOut.node.path
+ position(region, laidOut)
+ regions.append(region)
+ registerStateElement(laidOut.node.path, region)
+ }
+
+ const card = element("button", `chart-state chart-state-${laidOut.node.type}`)
+ card.type = "button"
+ card.dataset.statePath = laidOut.node.path
+ card.setAttribute("aria-label", `${laidOut.node.label}, ${laidOut.node.type} state`)
+ position(card, {
+ x: laidOut.x,
+ y: laidOut.y,
+ width: laidOut.width,
+ height: laidOut.node.children.length > 0 ? laidOut.headerHeight : laidOut.height
+ })
+ const stateStatus = status(laidOut.node.active, laidOut.node.initial)
+ card.append(stateContent(laidOut, stateStatus))
+ card.addEventListener(
+ "click",
+ (event) => handlers.selectState(laidOut.node.path, { x: event.clientX, y: event.clientY })
+ )
+ card.addEventListener("dblclick", () => handlers.openStateDetails(laidOut.node.path))
+ nodesLayer.append(card)
+ registerStateElement(laidOut.node.path, card)
+ stateControls.set(laidOut.node.path, card)
+ stateStatuses.set(laidOut.node.path, stateStatus)
+ }
+
+ for (const initial of layout.initials) {
+ const dot = element("span", "chart-initial")
+ dot.setAttribute("aria-hidden", "true")
+ position(dot, initial)
+ nodesLayer.append(dot)
+ }
+
+ for (const runtime of layout.runtimeTargets) {
+ const target = element("span", "chart-runtime-target", runtime.target.label)
+ target.setAttribute("aria-hidden", "true")
+ position(target, runtime)
+ nodesLayer.append(target)
+ }
+
+ const hoverTransition = (
+ transitionId: string,
+ source: string,
+ target: string | null,
+ hovered: boolean
+ ): void => {
+ transitionElements.get(transitionId)?.forEach((node) => node.classList.toggle("is-hovered", hovered))
+ stateElements.get(source)?.forEach((node) => node.classList.toggle("is-hover-source", hovered))
+ if (target !== null) {
+ stateElements.get(target)?.forEach((node) => node.classList.toggle("is-hover-target", hovered))
+ }
+ }
+
+ for (const laidOut of layout.edges) {
+ const group = svgElement(
+ "g",
+ `chart-edge-group chart-edge-${laidOut.kind}${
+ laidOut.kind === "transition" ? ` chart-transition-${laidOut.edge.kind}` : ""
+ }`
+ )
+ const visible = svgElement("path", "chart-edge-line")
+ const route = pathData(laidOut.points)
+ visible.setAttribute("d", route)
+ visible.setAttribute("marker-end", "url(#chart-arrow)")
+ const hit = svgElement("path", "chart-edge-hit")
+ hit.setAttribute("d", route)
+ group.append(visible, hit)
+ svg.append(group)
+ if (laidOut.kind === "initial") continue
+
+ registerTransitionElement(laidOut.edge.transitionId, group)
+ registerEdgeElement(laidOut.edge.id, group)
+ const label = element(
+ "button",
+ `chart-edge-label chart-edge-label-${laidOut.edge.trigger.type}`,
+ laidOut.edge.label
+ )
+ label.type = "button"
+ label.dataset.transitionId = laidOut.edge.transitionId
+ position(label, {
+ x: laidOut.label.x - laidOut.labelWidth / 2,
+ y: laidOut.label.y - laidOut.labelHeight / 2,
+ width: laidOut.labelWidth,
+ height: laidOut.labelHeight
+ })
+ label.addEventListener(
+ "click",
+ (event) =>
+ handlers.selectTransition(
+ laidOut.edge.transitionId,
+ laidOut.edge.branchIds,
+ { x: event.clientX, y: event.clientY }
+ )
+ )
+ label.addEventListener("dblclick", () => handlers.openTransitionDetails(laidOut.edge.transitionId))
+ label.addEventListener(
+ "mouseenter",
+ () => hoverTransition(laidOut.edge.transitionId, laidOut.edge.source, laidOut.edge.target, true)
+ )
+ label.addEventListener(
+ "mouseleave",
+ () => hoverTransition(laidOut.edge.transitionId, laidOut.edge.source, laidOut.edge.target, false)
+ )
+ hit.addEventListener(
+ "click",
+ (event) =>
+ handlers.selectTransition(
+ laidOut.edge.transitionId,
+ laidOut.edge.branchIds,
+ { x: event.clientX, y: event.clientY }
+ )
+ )
+ hit.addEventListener("dblclick", () => handlers.openTransitionDetails(laidOut.edge.transitionId))
+ hit.addEventListener(
+ "mouseenter",
+ () => hoverTransition(laidOut.edge.transitionId, laidOut.edge.source, laidOut.edge.target, true)
+ )
+ hit.addEventListener(
+ "mouseleave",
+ () => hoverTransition(laidOut.edge.transitionId, laidOut.edge.source, laidOut.edge.target, false)
+ )
+ labelsLayer.append(label)
+ registerTransitionElement(laidOut.edge.transitionId, label)
+ registerEdgeElement(laidOut.edge.id, label)
+ const controls = transitionControls.get(laidOut.edge.id) ?? []
+ controls.push(label)
+ transitionControls.set(laidOut.edge.id, controls)
+ }
+
+ viewport.addEventListener("keydown", (event) => {
+ if (event.key !== "Escape") return
+ event.preventDefault()
+ handlers.clearSelection()
+ })
+
+ viewport.addEventListener("pointerdown", (event) => {
+ if (!event.isPrimary || event.button !== 0 || pan !== undefined) return
+ pan = {
+ pointerId: event.pointerId,
+ start: { x: event.clientX, y: event.clientY },
+ scrollLeft: viewport.scrollLeft,
+ scrollTop: viewport.scrollTop,
+ dragging: false
+ }
+ })
+
+ viewport.addEventListener("pointermove", (event) => {
+ if (pan === undefined || event.pointerId !== pan.pointerId) return
+ const current = { x: event.clientX, y: event.clientY }
+ if (!pan.dragging) {
+ if (!isChartPan(pan.start, current)) return
+ pan.dragging = true
+ viewport.setPointerCapture(event.pointerId)
+ viewport.classList.add("is-panning")
+ const focused = document.activeElement
+ if (focused instanceof HTMLElement && viewport.contains(focused)) focused.blur()
+ }
+ event.preventDefault()
+ viewport.scrollTo({
+ left: pan.scrollLeft - (current.x - pan.start.x),
+ top: pan.scrollTop - (current.y - pan.start.y)
+ })
+ })
+
+ const finishPan = (event: PointerEvent, cancelled: boolean): void => {
+ if (pan === undefined || event.pointerId !== pan.pointerId) return
+ const dragged = pan.dragging
+ if (viewport.hasPointerCapture(event.pointerId)) viewport.releasePointerCapture(event.pointerId)
+ viewport.classList.remove("is-panning")
+ pan = undefined
+ if (!dragged || cancelled) return
+ event.preventDefault()
+ suppressClick = true
+ suppressDoubleClickUntil = performance.now() + 400
+ setTimeout(() => {
+ suppressClick = false
+ }, 0)
+ }
+
+ viewport.addEventListener("pointerup", (event) => finishPan(event, false))
+ viewport.addEventListener("pointercancel", (event) => finishPan(event, true))
+ viewport.addEventListener("click", (event) => {
+ if (!suppressClick) return
+ suppressClick = false
+ event.preventDefault()
+ event.stopImmediatePropagation()
+ }, true)
+ viewport.addEventListener("dblclick", (event) => {
+ if (performance.now() > suppressDoubleClickUntil) return
+ event.preventDefault()
+ event.stopImmediatePropagation()
+ }, true)
+
+ const revealState = (path: string): void => {
+ const control = stateControls.get(path)
+ if (control === undefined) return
+ const left = Number.parseFloat(control.style.left) * zoom
+ const top = Number.parseFloat(control.style.top) * zoom
+ const width = control.offsetWidth * zoom
+ const height = control.offsetHeight * zoom
+ viewport.scrollTo({
+ left: Math.max(0, left - Math.max(28, (viewport.clientWidth - width) / 2)),
+ top: Math.max(0, top - Math.max(28, (viewport.clientHeight - height) / 2))
+ })
+ }
+
+ const revealStates = (paths: ReadonlyArray): void => {
+ const leaves = paths.filter((path) => !paths.some((candidate) => candidate.startsWith(`${path}.`)))
+ const bounds = leaves
+ .map((path) => layout.nodes.find(({ node }) => node.path === path))
+ .filter((node): node is LaidOutChartNode => node !== undefined)
+ if (bounds.length === 0) return
+ const left = Math.min(...bounds.map(({ x }) => x)) * zoom
+ const top = Math.min(...bounds.map(({ y }) => y)) * zoom
+ const right = Math.max(...bounds.map(({ x, width }) => x + width)) * zoom
+ const bottom = Math.max(...bounds.map(({ y, height }) => y + height)) * zoom
+ const margin = 32
+ const visible = left >= viewport.scrollLeft + margin &&
+ top >= viewport.scrollTop + margin &&
+ right <= viewport.scrollLeft + viewport.clientWidth - margin &&
+ bottom <= viewport.scrollTop + viewport.clientHeight - margin
+ if (visible) return
+ viewport.scrollTo({
+ left: Math.max(0, (left + right - viewport.clientWidth) / 2),
+ top: Math.max(0, (top + bottom - viewport.clientHeight) / 2)
+ })
+ }
+
+ const setZoom = (requested: number, anchor?: ChartZoomAnchor): number => {
+ const next = clampZoom(requested)
+ if (next === zoom) return zoom
+ const zoomAnchor = anchor ?? { x: viewport.clientWidth / 2, y: viewport.clientHeight / 2 }
+ const scroll = chartZoomScrollPosition(
+ zoom,
+ next,
+ viewport.scrollLeft,
+ viewport.scrollTop,
+ zoomAnchor
+ )
+ zoom = next
+ canvas.style.transform = `scale(${zoom})`
+ stage.style.width = `${layout.width * zoom}px`
+ stage.style.height = `${layout.height * zoom}px`
+ viewport.scrollTo({
+ left: scroll.x,
+ top: scroll.y
+ })
+ handlers.zoomChanged(zoom)
+ return zoom
+ }
+
+ viewport.addEventListener("wheel", (event) => {
+ if (!event.ctrlKey) return
+ event.preventDefault()
+ const bounds = viewport.getBoundingClientRect()
+ setZoom(
+ chartWheelZoom(zoom, event.deltaY, event.deltaMode, viewport.clientHeight),
+ { x: event.clientX - bounds.left, y: event.clientY - bounds.top }
+ )
+ }, { passive: false })
+
+ const fit = (): number => {
+ const availableWidth = Math.max(1, viewport.clientWidth - 48)
+ const availableHeight = Math.max(1, viewport.clientHeight - 48)
+ const fitted = setZoom(Math.min(1, availableWidth / layout.width, availableHeight / layout.height))
+ viewport.scrollTo({ left: 0, top: 0 })
+ return fitted
+ }
+
+ return {
+ update: (presentation) => {
+ const active = new Set(presentation.activePaths)
+ const availableBranches = new Set(presentation.availableBranchIds)
+ const unavailableBranches = new Set(presentation.unavailableBranchIds)
+ stateControls.forEach((control) => {
+ control.disabled = presentation.simulationMode
+ })
+ transitionControls.forEach((controls, edgeId) => {
+ const edge = chartEdges.get(edgeId)
+ const interactive = edge?.branchIds.some((branchId) =>
+ availableBranches.has(branchId) || unavailableBranches.has(branchId)
+ )
+ controls.forEach((control) => {
+ control.disabled = presentation.simulationMode && !interactive
+ })
+ })
+ stateElements.forEach((elements) =>
+ elements.forEach((node) => {
+ node.classList.remove(
+ "is-selected",
+ "is-related-from",
+ "is-related-to"
+ )
+ })
+ )
+ transitionElements.forEach((elements) =>
+ elements.forEach((node) => {
+ node.classList.remove(
+ "is-selected",
+ "is-incoming",
+ "is-outgoing",
+ "is-walkthrough-available",
+ "is-walkthrough-unavailable"
+ )
+ })
+ )
+ stateStatuses.forEach((node, path) => {
+ const isActive = active.has(path)
+ const isInitial = node.dataset.initial === "true"
+ node.classList.toggle("is-active", isActive)
+ node.setAttribute("aria-label", statusLabel(isActive, isInitial))
+ })
+ viewport.classList.toggle("is-simulating", presentation.simulationMode)
+ if (presentation.selectedState !== null) {
+ stateElements.get(presentation.selectedState)?.forEach((node) => node.classList.add("is-selected"))
+ }
+ if (presentation.selectedTransition !== null) {
+ transitionElements.get(presentation.selectedTransition)?.forEach((node) => node.classList.add("is-selected"))
+ }
+ setStateClass(stateElements, presentation.fromPaths, "is-related-from")
+ setStateClass(stateElements, presentation.toPaths, "is-related-to")
+ presentation.incomingTransitionIds.forEach((id) =>
+ transitionElements.get(id)?.forEach((node) => node.classList.add("is-incoming"))
+ )
+ presentation.outgoingTransitionIds.forEach((id) =>
+ transitionElements.get(id)?.forEach((node) => node.classList.add("is-outgoing"))
+ )
+ layout.edges.forEach((laidOut) => {
+ if (laidOut.kind !== "transition") return
+ const available = laidOut.edge.branchIds.some((branchId) => availableBranches.has(branchId))
+ const unavailable = laidOut.edge.branchIds.some((branchId) => unavailableBranches.has(branchId))
+ const className = available
+ ? "is-walkthrough-available"
+ : unavailable
+ ? "is-walkthrough-unavailable"
+ : undefined
+ if (className !== undefined) {
+ edgeElements.get(laidOut.edge.id)?.forEach((node) => node.classList.add(className))
+ }
+ })
+ },
+ focusState: (path) => {
+ const control = stateControls.get(path)
+ control?.focus({ preventScroll: true })
+ revealState(path)
+ },
+ revealState,
+ revealStates,
+ getZoom: () => zoom,
+ setZoom,
+ fit
+ }
+}
+
+export const renderChart = (
+ host: HTMLElement,
+ document: VisualizationDocument,
+ handlers: ChartHandlers
+): Effect.Effect => {
+ const statesByPath = new Map(document.states.map((state) => [state.path, state]))
+ const visited = new Set()
+ let initialFocus = document.initial.target
+ while (!visited.has(initialFocus)) {
+ visited.add(initialFocus)
+ const state = statesByPath.get(initialFocus)
+ if (state === undefined) break
+ const next = state.type === "parallel" ? state.children[0] : state.initial
+ if (next === null || next === undefined) break
+ initialFocus = next
+ }
+
+ return layoutChart(makeChartModel(document)).pipe(Effect.map((layout) => {
+ const view = render(host, layout, handlers)
+ setTimeout(() => {
+ if (host.isConnected) view.revealState(initialFocus)
+ }, 0)
+ return view
+ }))
+}
diff --git a/packages/devtools/src/internal/browser/example-machine.ts b/packages/devtools/src/internal/browser/example-machine.ts
index 4508740..61d4e43 100644
--- a/packages/devtools/src/internal/browser/example-machine.ts
+++ b/packages/devtools/src/internal/browser/example-machine.ts
@@ -3,8 +3,14 @@ import { Schema } from "effect"
// Shared by the live browser UI and its project-inspection fixture.
-class Application extends Schema.TaggedClass("Application")("Application", {}) {}
-class Workflow extends Schema.TaggedClass("Workflow")("Workflow", {}) {}
+class Application extends Schema.TaggedClass("Application")("Application", {
+ workspace: Schema.String,
+ revision: Schema.Number
+}) {}
+class Workflow extends Schema.TaggedClass("Workflow")("Workflow", {
+ document: Schema.String,
+ unsavedChanges: Schema.Number
+}) {}
class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {}
class Running extends Schema.TaggedClass("Running")("Running", {}) {}
class Editing extends Schema.TaggedClass("Editing")("Editing", {}) {}
@@ -59,11 +65,11 @@ const States = Machine.states({
export const snapshot = {
path: "application" as const,
- value: new Application({}),
+ value: new Application({ workspace: "effect-machine", revision: 7 }),
states: {
workflow: {
path: "application.workflow" as const,
- value: new Workflow({}),
+ value: new Workflow({ document: "Machine.ts", unsavedChanges: 2 }),
state: { path: "application.workflow.idle" as const, value: new Idle({}) }
},
connection: {
@@ -103,9 +109,12 @@ export const machine = Machine.make({
target.decoded(
new Running({}),
(running) => running.editing.decoded(new Editing({}))
- ).update(owner.decoded(new Workflow({})))
+ ).update(owner.decoded(new Workflow({ document: "Machine.ts", unsavedChanges: 3 })))
),
- Refresh: (to) => to.local.update(({ owner }) => owner.decoded(new Workflow({})))
+ Refresh: (to) =>
+ to.local.update(({ owner }) =>
+ owner.decoded(new Workflow({ document: "Machine.ts", unsavedChanges: 0 }))
+ )
}
},
running: {
diff --git a/packages/devtools/src/internal/browser/input-form.ts b/packages/devtools/src/internal/browser/input-form.ts
index 1b2c16a..6639e9d 100644
--- a/packages/devtools/src/internal/browser/input-form.ts
+++ b/packages/devtools/src/internal/browser/input-form.ts
@@ -1,4 +1,3 @@
-import type { InputIssue } from "../../DevToolsProtocol.js"
import type { InputSchema } from "../../MachineDocument.js"
type JsonPrimitive = string | number | boolean | null
@@ -217,9 +216,7 @@ const project = (
case "null":
return { _tag: "Literal", ...common, value: null }
case "object": {
- if (!isRecord(resolved.properties)) {
- return { _tag: "Object", ...common, fields: [] }
- }
+ if (!isRecord(resolved.properties)) return { _tag: "Object", ...common, fields: [] }
const required = new Set(
Array.isArray(resolved.required)
? resolved.required.filter((item): item is string => typeof item === "string")
@@ -253,417 +250,3 @@ const project = (
}
export const projectInputSchema = (document: InputSchema): InputField => project(document.schema, document.definitions)
-
-export interface InputFormResult {
- readonly ok: boolean
- readonly value?: unknown
-}
-
-export interface InputForm {
- readonly element: HTMLFormElement
- readonly hasFields: boolean
- readonly supported: boolean
- readonly read: () => InputFormResult
- readonly clearIssues: () => void
- readonly setIssues: (issues: ReadonlyArray) => void
- readonly setPending: (pending: boolean) => void
-}
-
-interface Control {
- readonly element: HTMLElement
- readonly interactive: boolean
- readonly supported: boolean
- readonly read: () => unknown
-}
-
-interface RenderContext {
- readonly issueTargets: Map
-}
-
-let nextControlId = 0
-
-const element = (
- tag: Tag,
- className?: string,
- text?: string
-): HTMLElementTagNameMap[Tag] => {
- const node = document.createElement(tag)
- if (className !== undefined) node.className = className
- if (text !== undefined) node.textContent = text
- return node
-}
-
-const valueKey = (value: JsonPrimitive): string => JSON.stringify(value)
-
-const labelText = (field: InputField, fallback: string): string => field.title ?? fallback
-
-const description = (field: InputField): HTMLElement | undefined =>
- field.description === undefined ? undefined : element("p", "input-description", field.description)
-
-const fieldType = (field: InputField): string => {
- switch (field._tag) {
- case "String":
- return field.format ?? "string"
- case "Number":
- return field.integer ? "integer" : "number"
- case "Boolean":
- return "boolean"
- case "Enum":
- return "enum"
- case "Literal":
- return "literal"
- case "Object":
- return "object"
- case "Array":
- return `${fieldType(field.item)}[]`
- case "Union":
- return "union"
- case "Unsupported":
- return "unsupported"
- }
-}
-
-const fieldConstraints = (field: InputField): ReadonlyArray => {
- switch (field._tag) {
- case "String":
- return [
- field.minLength === undefined ? undefined : `min ${field.minLength} characters`,
- field.maxLength === undefined ? undefined : `max ${field.maxLength} characters`,
- field.pattern === undefined ? undefined : `pattern ${field.pattern}`,
- field.defaultValue === undefined ? undefined : `default ${field.defaultValue}`
- ].filter((value): value is string => value !== undefined)
- case "Number":
- return [
- field.minimum === undefined ? undefined : `min ${field.minimum}`,
- field.maximum === undefined ? undefined : `max ${field.maximum}`,
- field.defaultValue === undefined ? undefined : `default ${field.defaultValue}`
- ].filter((value): value is string => value !== undefined)
- case "Enum":
- return [
- `${field.values.length} options`,
- field.defaultValue === undefined ? undefined : `default ${String(field.defaultValue)}`
- ].filter((value): value is string => value !== undefined)
- case "Literal":
- return [`fixed ${String(field.value)}`]
- case "Object":
- return [`${field.fields.length} fields`]
- case "Array":
- return [
- field.minItems === 0 ? undefined : `min ${field.minItems} items`,
- field.maxItems === undefined ? undefined : `max ${field.maxItems} items`
- ].filter((value): value is string => value !== undefined)
- case "Union":
- return [`${field.alternatives.length} variants`]
- case "Boolean":
- case "Unsupported":
- return []
- }
-}
-
-const renderControl = (
- field: InputField,
- name: string,
- context: RenderContext,
- path: ReadonlyArray
-): Control => {
- switch (field._tag) {
- case "String": {
- const input = element("input", "input-control")
- input.type = field.format === "date-time"
- ? "datetime-local"
- : field.format === "date"
- ? "date"
- : field.format === "email"
- ? "email"
- : field.format === "uri"
- ? "url"
- : "text"
- input.name = name
- input.value = field.defaultValue ?? ""
- if (field.minLength !== undefined) input.minLength = field.minLength
- if (field.maxLength !== undefined) input.maxLength = field.maxLength
- if (field.pattern !== undefined) input.pattern = field.pattern
- return { element: input, interactive: true, supported: true, read: () => input.value }
- }
- case "Number": {
- const input = element("input", "input-control")
- input.type = "number"
- input.name = name
- input.step = field.integer ? "1" : "any"
- if (field.defaultValue !== undefined) input.value = String(field.defaultValue)
- if (field.minimum !== undefined) input.min = String(field.minimum)
- if (field.maximum !== undefined) input.max = String(field.maximum)
- return {
- element: input,
- interactive: true,
- supported: true,
- read: () => input.value === "" ? undefined : Number(input.value)
- }
- }
- case "Boolean": {
- const wrapper = element("label", "boolean-control")
- const input = element("input")
- input.type = "checkbox"
- input.name = name
- input.checked = field.defaultValue ?? false
- wrapper.append(input, element("span", undefined, "Enabled"))
- return { element: wrapper, interactive: true, supported: true, read: () => input.checked }
- }
- case "Enum": {
- const select = element("select", "input-control")
- select.name = name
- field.values.forEach((value) => {
- const option = element("option", undefined, String(value))
- option.value = valueKey(value)
- option.selected = Object.is(value, field.defaultValue)
- select.append(option)
- })
- return {
- element: select,
- interactive: true,
- supported: true,
- read: () => JSON.parse(select.value) as JsonPrimitive
- }
- }
- case "Literal": {
- const output = element("output", "literal-control", String(field.value))
- return { element: output, interactive: false, supported: true, read: () => field.value }
- }
- case "Object": {
- const group = element("fieldset", "input-object")
- const legend = element("legend", undefined, labelText(field, name))
- group.append(legend)
- const controls: Array<{
- readonly key: string
- readonly included: HTMLInputElement | undefined
- readonly control: Control
- }> = []
- for (const property of field.fields) {
- const row = element("div", "input-field")
- const heading = element("div", "input-field-heading")
- const label = element("label", "input-label", labelText(property.field, property.key))
- const identity = element("div", "input-field-identity")
- identity.append(label, element("span", "input-field-type", fieldType(property.field)))
- const required = property.required ? element("span", "input-required", "required") : undefined
- let included: HTMLInputElement | undefined
- const propertyPath = [...path, property.key]
- const control = renderControl(property.field, `${name}.${property.key}`, context, propertyPath)
- const labelled = control.element instanceof HTMLInputElement || control.element instanceof HTMLSelectElement
- ? control.element
- : control.element.querySelector(":scope > input, :scope > select")
- if (labelled !== null) {
- const id = `machine-input-${nextControlId++}`
- labelled.id = id
- label.htmlFor = id
- }
- if (property.required) {
- if (control.element instanceof HTMLSelectElement) {
- control.element.required = true
- } else if (control.element instanceof HTMLInputElement) {
- control.element.required = property.field._tag === "Number" ||
- (property.field._tag === "String" &&
- (property.field.format !== undefined || (property.field.minLength ?? 0) > 0))
- }
- heading.append(identity, required!)
- } else {
- const optional = element("label", "input-optional")
- included = element("input")
- included.type = "checkbox"
- optional.append(included, element("span", undefined, "include"))
- heading.append(identity, optional)
- control.element.toggleAttribute("inert", true)
- control.element.classList.add("is-disabled")
- control.element.querySelectorAll(
- "input, select, button"
- ).forEach((item) => item.disabled = true)
- if (
- control.element instanceof HTMLInputElement ||
- control.element instanceof HTMLSelectElement ||
- control.element instanceof HTMLButtonElement
- ) control.element.disabled = true
- included.addEventListener("change", () => {
- control.element.toggleAttribute("inert", !included!.checked)
- control.element.classList.toggle("is-disabled", !included!.checked)
- control.element.querySelectorAll(
- "input, select, button"
- ).forEach((item) => item.disabled = !included!.checked)
- if (
- control.element instanceof HTMLInputElement ||
- control.element instanceof HTMLSelectElement ||
- control.element instanceof HTMLButtonElement
- ) control.element.disabled = !included!.checked
- })
- }
- const issues = element("div", "input-errors")
- issues.hidden = true
- context.issueTargets.set(JSON.stringify(propertyPath), issues)
- row.append(heading, control.element)
- const constraints = fieldConstraints(property.field)
- if (constraints.length > 0) {
- const metadata = element("div", "input-constraints")
- constraints.forEach((constraint) => metadata.append(element("span", undefined, constraint)))
- row.append(metadata)
- }
- const details = description(property.field)
- if (details !== undefined) row.append(details)
- row.append(issues)
- group.append(row)
- controls.push({ key: property.key, included, control })
- }
- return {
- element: group,
- interactive: controls.some(({ control }) => control.interactive),
- supported: controls.every(({ control }) => control.supported),
- read: () =>
- Object.fromEntries(
- controls
- .filter(({ included }) => included === undefined || included.checked)
- .map(({ key, control }) => [key, control.read()])
- .filter((entry) => entry[1] !== undefined)
- )
- }
- }
- case "Array": {
- const group = element("fieldset", "input-array")
- group.append(element("legend", undefined, labelText(field, name)))
- const items = element("div", "input-array-items")
- const controls: Array<{
- readonly row: HTMLElement
- readonly control: Control
- readonly remove: HTMLButtonElement
- }> = []
- const add = element("button", "input-array-add", "Add item")
- add.type = "button"
- const refreshActions = (): void => {
- add.disabled = field.maxItems !== undefined && controls.length >= field.maxItems
- controls.forEach(({ remove }) => remove.disabled = controls.length <= field.minItems)
- }
- const addItem = (): void => {
- if (field.maxItems !== undefined && controls.length >= field.maxItems) return
- const row = element("div", "input-array-item")
- const control = renderControl(field.item, `${name}.${controls.length}`, context, [...path, controls.length])
- const remove = element("button", "input-array-remove", "Remove")
- remove.type = "button"
- remove.addEventListener("click", () => {
- row.remove()
- const index = controls.findIndex((item) => item.row === row)
- if (index >= 0) controls.splice(index, 1)
- refreshActions()
- })
- row.append(control.element, remove)
- items.append(row)
- controls.push({ row, control, remove })
- refreshActions()
- }
- for (let index = 0; index < field.minItems; index++) addItem()
- add.addEventListener("click", addItem)
- group.append(items, add)
- return {
- element: group,
- interactive: true,
- supported: controls.every(({ control }) => control.supported) && field.item._tag !== "Unsupported",
- read: () => controls.map(({ control }) => control.read())
- }
- }
- case "Union": {
- const group = element("fieldset", "input-union")
- group.append(element("legend", undefined, labelText(field, name)))
- const select = element("select", "input-control")
- const body = element("div", "input-union-body")
- let selected = 0
- const controls = field.alternatives.map((alternative, index) => {
- const control = renderControl(alternative, `${name}.${index}`, context, path)
- const option = element("option", undefined, labelText(alternative, `Option ${index + 1}`))
- option.value = String(index)
- select.append(option)
- return control
- })
- const update = (): void => {
- selected = Number(select.value)
- body.replaceChildren(controls[selected]?.element ?? element("div"))
- }
- select.addEventListener("change", update)
- group.append(select, body)
- update()
- return {
- element: group,
- interactive: true,
- supported: controls.every(({ supported }) => supported),
- read: () => controls[selected]?.read()
- }
- }
- case "Unsupported": {
- const message = element("p", "input-unsupported", field.reason)
- return { element: message, interactive: false, supported: false, read: () => undefined }
- }
- }
-}
-
-export const renderInputForm = (
- schema: InputSchema,
- options: {
- readonly name: string
- readonly fixed?: Readonly>
- readonly omit?: ReadonlyArray
- }
-): InputForm => {
- const form = element("form", "schema-form")
- const formIssues = element("div", "input-errors input-errors-form")
- formIssues.hidden = true
- const context: RenderContext = { issueTargets: new Map() }
- const projected = projectInputSchema(schema)
- const visible = projected._tag === "Object" && options.omit !== undefined
- ? { ...projected, fields: projected.fields.filter(({ key }) => !options.omit!.includes(key)) }
- : projected
- const control = renderControl(visible, options.name, context, [])
- form.append(formIssues, control.element)
- const clearIssues = (): void => {
- formIssues.replaceChildren()
- formIssues.hidden = true
- context.issueTargets.forEach((target) => {
- target.replaceChildren()
- target.hidden = true
- })
- }
- const setIssues = (issues: ReadonlyArray): void => {
- clearIssues()
- const grouped = new Map>()
- for (const issue of issues) {
- let target: HTMLElement = formIssues
- for (let length = issue.path.length; length > 0; length--) {
- const candidate = context.issueTargets.get(JSON.stringify(issue.path.slice(0, length)))
- if (candidate !== undefined) {
- target = candidate
- break
- }
- }
- const messages = grouped.get(target) ?? []
- if (!messages.includes(issue.message)) messages.push(issue.message)
- grouped.set(target, messages)
- }
- grouped.forEach((messages, target) => {
- messages.forEach((message) => target.append(element("div", undefined, message)))
- target.hidden = false
- })
- }
- return {
- element: form,
- hasFields: control.interactive,
- supported: control.supported,
- read: () => {
- clearIssues()
- if (!form.reportValidity() || !control.supported) return { ok: false }
- const value = control.read()
- return {
- ok: true,
- value: isRecord(value) && options.fixed !== undefined ? { ...value, ...options.fixed } : value
- }
- },
- clearIssues,
- setIssues,
- setPending: (pending) => {
- form.toggleAttribute("inert", pending)
- form.setAttribute("aria-busy", String(pending))
- }
- }
-}
diff --git a/packages/devtools/src/internal/browser/invoke-outcomes-example.ts b/packages/devtools/src/internal/browser/invoke-outcomes-example.ts
new file mode 100644
index 0000000..be6b527
--- /dev/null
+++ b/packages/devtools/src/internal/browser/invoke-outcomes-example.ts
@@ -0,0 +1,231 @@
+import { Machine } from "@typeonce/effect-machine"
+import { Effect, Schema, Stream } from "effect"
+
+class ChildWorking extends Schema.TaggedClass("InvokeGalleryChildWorking")("Working", {
+ task: Schema.String
+}) {}
+class ChildDone extends Schema.TaggedClass("InvokeGalleryChildDone")("Done", {
+ result: Schema.String
+}) {}
+class FinishChild extends Schema.TaggedClass("InvokeGalleryFinishChild")("FinishChild", {}) {}
+
+const ChildStates = Machine.states({
+ Working: ChildWorking,
+ Done: { schema: ChildDone, type: "final", output: Schema.String }
+})
+
+export const invokeGalleryChildMachine = Machine.make({
+ id: "invoke-gallery-child",
+ states: ChildStates.states,
+ events: Machine.events(FinishChild),
+ initial: (to) => to.Working().resolve(({ target }) => target.decoded(new ChildWorking({ task: "render-preview" })))
+}).handle({
+ Working: {
+ on: {
+ FinishChild: (to) =>
+ to.full.Done().resolve(({ state, target }) =>
+ target.decoded(new ChildDone({ result: `${state.task}:complete` }))
+ )
+ }
+ },
+ Done: {
+ output: ({ state }) => state.result
+ }
+})
+
+const InvokedChild = Machine.child("preview-worker", invokeGalleryChildMachine)
+
+class Gallery extends Schema.TaggedClass("InvokeGallery")("Gallery", {
+ selectedDemo: Schema.NullOr(Schema.String)
+}) {}
+class Choose extends Schema.TaggedClass("InvokeGalleryChoose")("Choose", {}) {}
+class LoadingDocument extends Schema.TaggedClass("InvokeGalleryLoadingDocument")(
+ "LoadingDocument",
+ { request: Schema.String }
+) {}
+class StreamingUpdates extends Schema.TaggedClass("InvokeGalleryStreamingUpdates")(
+ "StreamingUpdates",
+ { values: Schema.Array(Schema.Number) }
+) {}
+class WaitingForTimeout extends Schema.TaggedClass("InvokeGalleryWaitingForTimeout")(
+ "WaitingForTimeout",
+ { delay: Schema.Literal("2 seconds") }
+) {}
+class WatchingProcess extends Schema.TaggedClass("InvokeGalleryWatchingProcess")(
+ "WatchingProcess",
+ { revision: Schema.Number }
+) {}
+class RunningChild extends Schema.TaggedClass("InvokeGalleryRunningChild")("RunningChild", {}) {}
+class Completed extends Schema.TaggedClass("InvokeGalleryCompleted")("Completed", {
+ source: Schema.String,
+ result: Schema.String
+}) {}
+class Failed extends Schema.TaggedClass("InvokeGalleryFailed")("Failed", {
+ source: Schema.String,
+ message: Schema.String
+}) {}
+
+class RunEffect extends Schema.TaggedClass("InvokeGalleryRunEffect")("RunEffect", {
+ request: Schema.String
+}) {}
+class RunStream extends Schema.TaggedClass("InvokeGalleryRunStream")("RunStream", {}) {}
+class RunTimer extends Schema.TaggedClass("InvokeGalleryRunTimer")("RunTimer", {}) {}
+class RunProcess extends Schema.TaggedClass("InvokeGalleryRunProcess")("RunProcess", {}) {}
+class RunChild extends Schema.TaggedClass("InvokeGalleryRunChild")("RunChild", {}) {}
+class Reset extends Schema.TaggedClass("InvokeGalleryReset")("Reset", {}) {}
+class StreamValue extends Schema.TaggedClass("InvokeGalleryStreamValue")("StreamValue", {
+ value: Schema.Number
+}) {}
+
+const GalleryEvents = Machine.events(RunEffect, RunStream, RunTimer, RunProcess, RunChild, Reset)
+const GalleryInternalEvents = Machine.internalEvents(StreamValue)
+const processLogic = Machine.logic({
+ initial: "starting" as "starting" | "ready",
+ run: () => Effect.fail("process stopped")
+})
+
+const GalleryStates = Machine.states({
+ Gallery: {
+ schema: Gallery,
+ initial: "Choose",
+ states: {
+ Choose,
+ LoadingDocument,
+ StreamingUpdates,
+ WaitingForTimeout,
+ WatchingProcess,
+ RunningChild
+ }
+ },
+ Completed,
+ Failed
+})
+
+export const invokeOutcomesMachine = Machine.make({
+ id: "invoke-outcomes",
+ states: GalleryStates.states,
+ events: GalleryEvents,
+ internalEvents: GalleryInternalEvents,
+ initial: (to) =>
+ to.Gallery.initial.resolve(({ target }) =>
+ target.decoded(
+ new Gallery({ selectedDemo: null }),
+ (gallery) => gallery.Choose.decoded(new Choose({}))
+ )
+ )
+}).handle({
+ Gallery: {
+ initialize: ({ builder }) => builder.decoded(new Choose({})),
+ on: {
+ Reset: (to) => to.local.Choose().resolve(({ target }) => target.decoded(new Choose({})))
+ },
+ states: {
+ Choose: {
+ on: {
+ RunEffect: (to) =>
+ to.local.LoadingDocument().resolve(({ event, target }) =>
+ target.decoded(new LoadingDocument({ request: event.request }))
+ ),
+ RunStream: (to) =>
+ to.local.StreamingUpdates().resolve(({ target }) => target.decoded(new StreamingUpdates({ values: [] }))),
+ RunTimer: (to) =>
+ to.local.WaitingForTimeout().resolve(({ target }) =>
+ target.decoded(new WaitingForTimeout({ delay: "2 seconds" }))
+ ),
+ RunProcess: (to) =>
+ to.local.WatchingProcess().resolve(({ target }) => target.decoded(new WatchingProcess({ revision: 1 }))),
+ RunChild: (to) => to.local.RunningChild().resolve(({ target }) => target.decoded(new RunningChild({})))
+ }
+ },
+ LoadingDocument: {
+ invoke: (from) =>
+ from.effect(
+ "load-document",
+ ({ state }) =>
+ state.request === "fail" ? Effect.fail("document unavailable") : Effect.succeed("document loaded")
+ ).onDone((to) =>
+ to.full.Completed().resolve(({ output, target }) =>
+ target.decoded(new Completed({ source: "effect", result: output }))
+ )
+ ).onFailure((to) =>
+ to.full.Failed().resolve(({ error, target }) =>
+ target.decoded(new Failed({ source: "effect", message: error }))
+ )
+ )
+ },
+ StreamingUpdates: {
+ invoke: (from) =>
+ from.stream(
+ "document-updates",
+ () => Stream.fromIterable([1, 2, 3]).pipe(Stream.concat(Stream.fail("stream disconnected")))
+ ).onElement((to) =>
+ to.none.resolve(({ element }, enqueue) => {
+ enqueue.raise(GalleryInternalEvents.StreamValue({ value: element }))
+ })
+ ).onDone((to) =>
+ to.full.Completed().resolve(({ state, target }) =>
+ target.decoded(new Completed({ source: "stream", result: state.values.join(", ") }))
+ )
+ ).onFailure((to) =>
+ to.full.Failed().resolve(({ error, target }) =>
+ target.decoded(new Failed({ source: "stream", message: error }))
+ )
+ ),
+ on: {
+ StreamValue: (to) =>
+ to.local.StreamingUpdates().resolve(({ event, state, target }) =>
+ target.decoded(new StreamingUpdates({ values: [...state.values, event.value] }))
+ )
+ }
+ },
+ WaitingForTimeout: {
+ invoke: (from) =>
+ from.timer("request-timeout", "2 seconds").onDone((to) =>
+ to.full.Completed().resolve(({ target }) =>
+ target.decoded(new Completed({ source: "timer", result: "timeout elapsed" }))
+ )
+ )
+ },
+ WatchingProcess: {
+ invoke: (from) =>
+ from.logic("status-worker", {
+ address: Machine.childAddress("status-worker"),
+ logic: processLogic
+ }).onFailure((to) =>
+ to.full.Failed().resolve(({ error, target }) =>
+ target.decoded(new Failed({ source: "process", message: String(error) }))
+ )
+ ).onSnapshot((to) =>
+ to.branches({
+ ready: { title: "Worker reports ready", target: to.full.Completed() },
+ waiting: { title: "Worker is still starting", target: to.none }
+ }).resolve(({ select, snapshot }) =>
+ snapshot.state === "ready"
+ ? select.ready.decoded(new Completed({ source: "process", result: "ready" }))
+ : select.waiting()
+ )
+ )
+ },
+ RunningChild: {
+ invoke: (from) =>
+ from.child(InvokedChild).onDone((to) =>
+ to.full.Completed().resolve(({ output, target }) =>
+ target.decoded(new Completed({ source: "child machine", result: output }))
+ )
+ )
+ }
+ }
+ },
+ Completed: {
+ on: {
+ Reset: (to) =>
+ to.full.Gallery.initial.resolve(({ target }) => target.decoded(new Gallery({ selectedDemo: null })))
+ }
+ },
+ Failed: {
+ on: {
+ Reset: (to) =>
+ to.full.Gallery.initial.resolve(({ target }) => target.decoded(new Gallery({ selectedDemo: null })))
+ }
+ }
+})
diff --git a/packages/devtools/src/internal/browser/parallel-completion-example.ts b/packages/devtools/src/internal/browser/parallel-completion-example.ts
new file mode 100644
index 0000000..c70b93b
--- /dev/null
+++ b/packages/devtools/src/internal/browser/parallel-completion-example.ts
@@ -0,0 +1,199 @@
+import { Machine } from "@typeonce/effect-machine"
+import { Schema } from "effect"
+
+class Cart extends Schema.TaggedClass("ParallelCart")("Cart", {
+ items: Schema.Number
+}) {}
+class Order extends Schema.TaggedClass("ParallelOrder")("Order", {
+ orderId: Schema.String,
+ total: Schema.Number
+}) {}
+class Payment extends Schema.TaggedClass("ParallelPayment")("Payment", {
+ attempts: Schema.Number
+}) {}
+class AwaitingAuthorization extends Schema.TaggedClass("ParallelAwaitingAuthorization")(
+ "AwaitingAuthorization",
+ {}
+) {}
+class Authorized extends Schema.TaggedClass("ParallelAuthorized")("Authorized", {
+ authorizationId: Schema.String
+}) {}
+class Fulfillment extends Schema.TaggedClass("ParallelFulfillment")("Fulfillment", {
+ warehouse: Schema.String
+}) {}
+class WaitingForPayment extends Schema.TaggedClass("ParallelWaitingForPayment")(
+ "WaitingForPayment",
+ {}
+) {}
+class Packing extends Schema.TaggedClass("ParallelPacking")("Packing", {
+ packageCount: Schema.Number
+}) {}
+class Shipped extends Schema.TaggedClass("ParallelShipped")("Shipped", {
+ trackingCode: Schema.String
+}) {}
+class OrderComplete extends Schema.TaggedClass("ParallelOrderComplete")("OrderComplete", {
+ orderId: Schema.String
+}) {}
+class OrderCancelled extends Schema.TaggedClass("ParallelOrderCancelled")(
+ "OrderCancelled",
+ { reason: Schema.String }
+) {}
+
+class Checkout extends Schema.TaggedClass("ParallelCheckout")("Checkout", {
+ orderId: Schema.String,
+ total: Schema.Number
+}) {}
+class Authorize extends Schema.TaggedClass("ParallelAuthorize")("Authorize", {
+ authorizationId: Schema.String
+}) {}
+class DeclinePayment extends Schema.TaggedClass("ParallelDeclinePayment")(
+ "DeclinePayment",
+ { reason: Schema.String }
+) {}
+class Pack extends Schema.TaggedClass("ParallelPack")("Pack", { packages: Schema.Number }) {}
+class Ship extends Schema.TaggedClass("ParallelShip")("Ship", { trackingCode: Schema.String }) {}
+class CompleteAll extends Schema.TaggedClass("ParallelCompleteAll")("CompleteAll", {
+ authorizationId: Schema.String,
+ trackingCode: Schema.String
+}) {}
+class CancelOrder extends Schema.TaggedClass("ParallelCancelOrder")("CancelOrder", {
+ reason: Schema.String
+}) {}
+class RetryOrder extends Schema.TaggedClass("ParallelRetryOrder")("RetryOrder", {}) {}
+class AutoShip extends Schema.TaggedClass("ParallelAutoShip")("AutoShip", {}) {}
+
+const ParallelInternalEvents = Machine.internalEvents(AutoShip)
+const ParallelStates = Machine.states({
+ Cart,
+ Order: {
+ schema: Order,
+ type: "parallel",
+ states: {
+ payment: {
+ schema: Payment,
+ initial: "AwaitingAuthorization",
+ states: {
+ AwaitingAuthorization,
+ Authorized: { schema: Authorized, type: "final" }
+ }
+ },
+ fulfillment: {
+ schema: Fulfillment,
+ initial: "WaitingForPayment",
+ states: {
+ WaitingForPayment,
+ Packing,
+ Shipped: { schema: Shipped, type: "final" }
+ }
+ }
+ }
+ },
+ Complete: { schema: OrderComplete, type: "final", output: Schema.String },
+ Cancelled: OrderCancelled
+})
+
+export const parallelCompletionMachine = Machine.make({
+ id: "parallel-completion",
+ states: ParallelStates.states,
+ events: Machine.events(
+ Checkout,
+ Authorize,
+ DeclinePayment,
+ Pack,
+ Ship,
+ CompleteAll,
+ CancelOrder,
+ RetryOrder
+ ),
+ internalEvents: ParallelInternalEvents,
+ initial: (to) => to.Cart().resolve(({ target }) => target.decoded(new Cart({ items: 2 })))
+}).handle({
+ Cart: {
+ on: {
+ Checkout: (to) =>
+ to.full.Order.initial.resolve(({ event, target }) =>
+ target.decoded(new Order({ orderId: event.orderId, total: event.total }))
+ )
+ }
+ },
+ Order: {
+ initialize: ({ builder }) => builder.payment.from({ attempts: 0 }).fulfillment.from({ warehouse: "north" }),
+ on: {
+ CancelOrder: (to) =>
+ to.full.Cancelled().resolve(({ event, target }) => target.decoded(new OrderCancelled({ reason: event.reason })))
+ },
+ onDone: (to) =>
+ to.full.Complete().resolve(({ target }) => target.decoded(new OrderComplete({ orderId: "completed-order" }))),
+ states: {
+ payment: {
+ initialize: ({ builder }) => builder.from(),
+ states: {
+ AwaitingAuthorization: {
+ on: {
+ Authorize: (to) =>
+ to.local.Authorized().resolve(({ event, target }) =>
+ target.decoded(new Authorized({ authorizationId: event.authorizationId }))
+ ),
+ CompleteAll: (to) =>
+ to.local.Authorized().resolve(({ event, target }) =>
+ target.decoded(new Authorized({ authorizationId: event.authorizationId }))
+ ),
+ DeclinePayment: (to) =>
+ to.full.Cancelled().resolve(({ event, target }) =>
+ target.decoded(new OrderCancelled({ reason: event.reason }))
+ )
+ }
+ }
+ }
+ },
+ fulfillment: {
+ initialize: ({ builder }) => builder.from(),
+ states: {
+ WaitingForPayment: {
+ on: {
+ Authorize: (to) =>
+ to.local.Packing().resolve(({ target }) => target.decoded(new Packing({ packageCount: 1 }))),
+ Pack: (to) =>
+ to.local.Packing().resolve(({ event, target }) =>
+ target.decoded(new Packing({ packageCount: event.packages }))
+ ),
+ CompleteAll: (to) =>
+ to.local.Shipped().resolve(({ event, target }) =>
+ target.decoded(new Shipped({ trackingCode: event.trackingCode }))
+ )
+ }
+ },
+ Packing: {
+ invoke: (from) =>
+ from.timer("packing-sla", "5 seconds").onDone((to) =>
+ to.none.resolve((_, enqueue) => {
+ enqueue.raise(ParallelInternalEvents.AutoShip())
+ })
+ ),
+ on: {
+ AutoShip: (to) =>
+ to.local.Shipped().resolve(({ target }) => target.decoded(new Shipped({ trackingCode: "automatic" }))),
+ Ship: (to) =>
+ to.local.Shipped().resolve(({ event, target }) =>
+ target.decoded(new Shipped({ trackingCode: event.trackingCode }))
+ ),
+ CompleteAll: (to) =>
+ to.local.Shipped().resolve(({ event, target }) =>
+ target.decoded(new Shipped({ trackingCode: event.trackingCode }))
+ )
+ }
+ }
+ }
+ }
+ }
+ },
+ Complete: {
+ output: ({ state }) => state.orderId
+ },
+ Cancelled: {
+ on: {
+ RetryOrder: (to) =>
+ to.full.Order.initial.resolve(({ target }) => target.decoded(new Order({ orderId: "retry", total: 0 })))
+ }
+ }
+})
diff --git a/packages/devtools/src/internal/browser/planner-example.ts b/packages/devtools/src/internal/browser/planner-example.ts
index 5add2dd..b04a5e2 100644
--- a/packages/devtools/src/internal/browser/planner-example.ts
+++ b/packages/devtools/src/internal/browser/planner-example.ts
@@ -1,5 +1,6 @@
import { Machine } from "@typeonce/effect-machine"
import { Schema } from "effect"
+import * as Effect from "effect/Effect"
class Idle extends Schema.TaggedClass("PlannerIdle")("Idle", { owner: Schema.String }) {}
class Working extends Schema.TaggedClass("PlannerWorking")("Working", {
@@ -128,6 +129,7 @@ export const plannerMachine = Machine.make({
}
},
Working: {
+ invoke: (from) => from.effect("monitor-job", () => Effect.never),
on: {
AutoFinish: (to) =>
to.full.Finished().resolve(({ state, target }) => target.decoded(new Finished({ job: state.job }))),
diff --git a/packages/devtools/src/internal/browser/protocol-events-example.ts b/packages/devtools/src/internal/browser/protocol-events-example.ts
new file mode 100644
index 0000000..b00cedf
--- /dev/null
+++ b/packages/devtools/src/internal/browser/protocol-events-example.ts
@@ -0,0 +1,200 @@
+import { Machine } from "@typeonce/effect-machine"
+import { Schema } from "effect"
+
+class ChildProgress extends Schema.TaggedClass("ProtocolChildProgress")("ChildProgress", {
+ percent: Schema.Number
+}) {}
+class ChildFinished extends Schema.TaggedClass("ProtocolChildFinished")("ChildFinished", {
+ result: Schema.String
+}) {}
+class ChildProblem extends Schema.TaggedClass("ProtocolChildProblem")("ChildProblem", {
+ message: Schema.String
+}) {}
+
+const ParentEvents = Machine.events(ChildProgress, ChildFinished, ChildProblem)
+
+class ChildIdle extends Schema.TaggedClass("ProtocolChildIdle")("Idle", {}) {}
+class ChildWorking extends Schema.TaggedClass("ProtocolChildWorking")("Working", {
+ job: Schema.String,
+ progress: Schema.Number
+}) {}
+class ChildDone extends Schema.TaggedClass("ProtocolChildDone")("Done", {
+ result: Schema.String
+}) {}
+class ChildCancelled extends Schema.TaggedClass("ProtocolChildCancelled")("Cancelled", {
+ reason: Schema.String
+}) {}
+
+class BeginChildWork extends Schema.TaggedClass("ProtocolBeginChildWork")("BeginChildWork", {
+ job: Schema.String
+}) {}
+class CancelChildWork extends Schema.TaggedClass("ProtocolCancelChildWork")(
+ "CancelChildWork",
+ { reason: Schema.String }
+) {}
+class Heartbeat extends Schema.TaggedClass("ProtocolHeartbeat")("Heartbeat", {
+ percent: Schema.Number
+}) {}
+class CommitChildWork extends Schema.TaggedClass("ProtocolCommitChildWork")(
+ "CommitChildWork",
+ {}
+) {}
+class ChildTrace extends Schema.TaggedClass("ProtocolChildTrace")("ChildTrace", {
+ message: Schema.String
+}) {}
+
+const ChildEvents = Machine.events(BeginChildWork, CancelChildWork)
+const ChildInternalEvents = Machine.internalEvents(Heartbeat, CommitChildWork)
+const ChildEmissions = Machine.emittedEvents(ChildTrace)
+const ChildStates = Machine.states({
+ Idle: ChildIdle,
+ Working: ChildWorking,
+ Done: { schema: ChildDone, type: "final", output: Schema.String },
+ Cancelled: { schema: ChildCancelled, type: "final", output: Schema.String }
+})
+
+export const requiredParentChildMachine = Machine.make({
+ id: "required-parent-child",
+ states: ChildStates.states,
+ events: ChildEvents,
+ internalEvents: ChildInternalEvents,
+ emittedEvents: ChildEmissions,
+ parent: Machine.parent(ParentEvents),
+ initial: (to) => to.Idle().resolve(({ target }) => target.decoded(new ChildIdle({})))
+}).handle({
+ Idle: {
+ on: {
+ BeginChildWork: (to) =>
+ to.full.Working().resolve(({ event, target }, enqueue) => {
+ enqueue.raise(ChildInternalEvents.Heartbeat({ percent: 25 }))
+ enqueue.emit(ChildEmissions.ChildTrace({ message: `started ${event.job}` }))
+ return target.decoded(new ChildWorking({ job: event.job, progress: 0 }))
+ })
+ }
+ },
+ Working: {
+ invoke: (from) =>
+ from.effect(
+ "report-progress-to-parent",
+ ({ parent, state }) => parent.send(ParentEvents.ChildProgress({ percent: state.progress }))
+ ).onDone((to) => to.none).onFailure((to) =>
+ to.full.Cancelled().resolve(({ error, target }) =>
+ target.decoded(new ChildCancelled({ reason: String(error) }))
+ )
+ ),
+ on: {
+ Heartbeat: (to) =>
+ to.full.Working().resolve(({ event, state, target }, enqueue) => {
+ enqueue.raise(ChildInternalEvents.CommitChildWork())
+ return target.decoded(new ChildWorking({ job: state.job, progress: event.percent }))
+ }),
+ CommitChildWork: (to) =>
+ to.full.Done().resolve(({ state, target }) =>
+ target.decoded(new ChildDone({ result: `${state.job}:complete` }))
+ ),
+ CancelChildWork: (to) =>
+ to.full.Cancelled().resolve(({ event, target }) => target.decoded(new ChildCancelled({ reason: event.reason })))
+ }
+ },
+ Done: {
+ output: ({ state }) => state.result
+ },
+ Cancelled: {
+ output: ({ state }) => state.reason
+ }
+})
+
+const ProtocolChild = Machine.child("protocol-child", requiredParentChildMachine)
+
+class ParentIdle extends Schema.TaggedClass("ProtocolParentIdle")("Idle", {}) {}
+class Supervising extends Schema.TaggedClass("ProtocolSupervising")("Supervising", {
+ latestProgress: Schema.Number
+}) {}
+class ParentComplete extends Schema.TaggedClass("ProtocolParentComplete")("Complete", {
+ result: Schema.String
+}) {}
+class ParentFailed extends Schema.TaggedClass("ProtocolParentFailed")("Failed", {
+ message: Schema.String
+}) {}
+class LaunchChild extends Schema.TaggedClass("ProtocolLaunchChild")("LaunchChild", {}) {}
+class ResetParent extends Schema.TaggedClass("ProtocolResetParent")("ResetParent", {}) {}
+
+const ParentStates = Machine.states({
+ Idle: ParentIdle,
+ Supervising,
+ Complete: { schema: ParentComplete, type: "final", output: Schema.String },
+ Failed: ParentFailed
+})
+
+export const parentProtocolMachine = Machine.make({
+ id: "parent-child-protocol",
+ states: ParentStates.states,
+ events: Machine.events(LaunchChild, ResetParent, ParentEvents),
+ initial: (to) => to.Idle().resolve(({ target }) => target.decoded(new ParentIdle({})))
+}).handle({
+ Idle: {
+ on: {
+ LaunchChild: (to) =>
+ to.full.Supervising().resolve(({ target }) => target.decoded(new Supervising({ latestProgress: 0 })))
+ }
+ },
+ Supervising: {
+ invoke: (from) =>
+ from.child(ProtocolChild).onDone((to) =>
+ to.full.Complete().resolve(({ output, target }) => target.decoded(new ParentComplete({ result: output })))
+ ).onFailure((to) =>
+ to.full.Failed().resolve(({ error, target }) => target.decoded(new ParentFailed({ message: String(error) })))
+ ),
+ on: {
+ ChildProgress: (to) =>
+ to.full.Supervising().resolve(({ event, target }) =>
+ target.decoded(new Supervising({ latestProgress: event.percent }))
+ ),
+ ChildFinished: (to) =>
+ to.full.Complete().resolve(({ event, target }) => target.decoded(new ParentComplete({ result: event.result }))),
+ ChildProblem: (to) =>
+ to.full.Failed().resolve(({ event, target }) => target.decoded(new ParentFailed({ message: event.message }))),
+ ResetParent: (to) => to.full.Idle().resolve(({ target }) => target.decoded(new ParentIdle({})))
+ }
+ },
+ Complete: {
+ output: ({ state }) => state.result
+ },
+ Failed: {
+ on: {
+ ResetParent: (to) => to.full.Idle().resolve(({ target }) => target.decoded(new ParentIdle({})))
+ }
+ }
+})
+
+class Detached extends Schema.TaggedClass("OptionalParentDetached")("Detached", {}) {}
+class Published extends Schema.TaggedClass("OptionalParentPublished")("Published", {
+ deliveredToParent: Schema.Boolean
+}) {}
+class PublishOutside extends Schema.TaggedClass("OptionalParentPublishOutside")(
+ "PublishOutside",
+ { result: Schema.String }
+) {}
+
+const OptionalParentStates = Machine.states({ Detached, Published })
+
+export const optionalParentMachine = Machine.make({
+ id: "optional-parent-protocol",
+ states: OptionalParentStates.states,
+ events: Machine.events(PublishOutside),
+ parent: Machine.optionalParent(ParentEvents),
+ initial: (to) => to.Detached().resolve(({ target }) => target.decoded(new Detached({})))
+}).handle({
+ Detached: {
+ on: {
+ PublishOutside: (to) =>
+ to.full.Published().resolve(({ event, parent, target }, enqueue) => {
+ if (parent !== undefined) {
+ enqueue.sendTo(parent, ParentEvents.ChildFinished({ result: event.result }))
+ }
+ return target.decoded(new Published({ deliveredToParent: parent !== undefined }))
+ })
+ }
+ },
+ Published: {}
+})
diff --git a/packages/devtools/src/internal/browser/simulation-client.ts b/packages/devtools/src/internal/browser/simulation-client.ts
deleted file mode 100644
index 5ca4c39..0000000
--- a/packages/devtools/src/internal/browser/simulation-client.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import * as Schema from "effect/Schema"
-import * as DevToolsProtocol from "../../DevToolsProtocol.js"
-
-export const requestSimulation = async (
- request: DevToolsProtocol.SimulationRequest
-): Promise => {
- const response = await fetch("/api/simulations", {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(request)
- })
- const body: unknown = await response.json()
- if (!response.ok) {
- const message = typeof body === "object" && body !== null && "message" in body
- ? String(body.message)
- : `Simulation request failed with status ${response.status}`
- throw new Error(message)
- }
- return Schema.decodeUnknownSync(DevToolsProtocol.SimulationResult)(body)
-}
diff --git a/packages/devtools/src/internal/browser/styles.css b/packages/devtools/src/internal/browser/styles.css
index 44d4a67..cb4705f 100644
--- a/packages/devtools/src/internal/browser/styles.css
+++ b/packages/devtools/src/internal/browser/styles.css
@@ -23,7 +23,7 @@ html,
body,
#app {
min-width: 320px;
- min-height: 100%;
+ height: 100%;
margin: 0;
}
@@ -32,27 +32,40 @@ body {
}
.devtools-shell {
- display: grid;
- min-height: 100vh;
- grid-template-columns: 220px minmax(0, 1fr);
+ display: flex;
+ width: 100vw;
+ height: 100vh;
+ flex-direction: column;
+ overflow: hidden;
}
.machine-index {
+ display: flex;
+ width: 100%;
min-width: 0;
- height: 100vh;
- padding: 8px 0;
- border-right: 1px solid var(--line);
+ min-height: 48px;
+ flex: 0 0 auto;
+ padding: 0;
+ border-bottom: 1px solid var(--line);
background: #0d0f12;
- overflow: auto;
+ overflow-x: auto;
+ overflow-y: hidden;
+ scrollbar-width: thin;
}
.machine-row {
- display: grid;
- width: 100%;
- grid-template-columns: 8px minmax(0, 1fr);
- gap: 3px 8px;
- padding: 10px 12px;
+ position: relative;
+ display: flex;
+ width: auto;
+ max-width: 280px;
+ min-width: 148px;
+ min-height: 48px;
+ flex: 0 0 auto;
+ align-items: center;
+ gap: 8px;
+ padding: 0 16px;
border: 0;
+ border-right: 1px solid var(--line-soft);
color: #c9cdd2;
background: transparent;
text-align: left;
@@ -69,14 +82,23 @@ body {
.machine-row.is-selected {
color: #fff;
- background: rgb(75 125 255 / 18%);
+ background: rgb(75 125 255 / 12%);
+}
+
+.machine-row.is-selected::after {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ height: 2px;
+ background: var(--accent);
+ content: "";
}
.machine-row-status {
width: 6px;
height: 6px;
- align-self: center;
- grid-row: 1;
+ flex: 0 0 auto;
border-radius: 50%;
background: #646b75;
}
@@ -102,24 +124,24 @@ body {
}
.machine-row-label {
- grid-column: 2;
font: 600 12px/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.machine-row-file {
- grid-column: 2;
- color: #727983;
- font: 10px/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ display: none;
}
.machine-index-empty {
- padding: 12px;
+ padding: 16px;
color: #646b75;
font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.machine-view {
min-width: 0;
+ min-height: 0;
+ flex: 1;
+ overflow: hidden;
}
.registry-empty,
@@ -148,21 +170,22 @@ button {
.app-shell,
.workspace {
- min-height: 100vh;
+ width: 100%;
+ height: 100%;
+ min-height: 0;
}
.workspace {
- display: grid;
- height: 100vh;
- grid-template-columns: minmax(420px, 1fr) minmax(380px, 46%);
+ position: relative;
}
-.tree-panel {
+.chart-panel {
+ position: relative;
display: flex;
+ width: 100%;
min-width: 0;
- height: 100vh;
+ height: 100%;
flex-direction: column;
- border-right: 1px solid var(--line);
overflow: hidden;
}
@@ -207,6 +230,7 @@ button {
}
.toolbar-actions,
+.zoom-controls,
.runtime-summary {
display: flex;
align-items: center;
@@ -216,6 +240,35 @@ button {
gap: 2px;
}
+.zoom-controls {
+ position: absolute;
+ z-index: 5;
+ bottom: 14px;
+ left: 14px;
+ flex: 0 0 auto;
+ gap: 1px;
+ padding: 2px;
+ border: 1px solid var(--line-soft);
+ border-radius: 4px;
+ background: rgb(13 15 18 / 94%);
+ box-shadow: 0 8px 28px rgb(0 0 0 / 32%);
+}
+
+.chart-panel.has-walkthrough .zoom-controls {
+ bottom: 88px;
+}
+
+.zoom-button {
+ min-width: 28px;
+ padding-inline: 7px;
+}
+
+.zoom-value {
+ min-width: 46px;
+ color: #aeb4bd;
+ font: 10px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+
.runtime-summary {
gap: 7px;
color: #7f8690;
@@ -255,175 +308,483 @@ button {
cursor: default;
}
-.topology-tree {
+.topology-chart {
+ display: flex;
flex: 1;
min-height: 0;
- padding: 23px 18px 34px;
- overflow: auto;
+ flex-direction: column;
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
-.machine-id {
- margin: 0 8px 16px;
- color: #fff;
- font-weight: 700;
+.topology-empty {
+ display: grid;
+ max-width: 420px;
+ gap: 7px;
+ margin: 26px 8px;
+ color: #7f8690;
+ font: 12px/1.6 Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
-.enabled-events {
- display: flex;
- min-height: 32px;
- flex-wrap: wrap;
- align-items: center;
- gap: 6px;
- margin: 0 8px 14px;
- padding-bottom: 13px;
- border-bottom: 1px solid var(--line-soft);
+.topology-empty strong {
+ color: #d4d7dc;
+ font: 600 13px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
-.enabled-events-label,
-.enabled-events-empty {
- color: #6f7680;
- font-size: 10px;
+.chart-host,
+.chart-viewport {
+ width: 100%;
+ height: 100%;
}
-.enabled-events-label {
- margin-right: 3px;
- text-transform: uppercase;
+.chart-host {
+ flex: 1;
+ min-height: 0;
+ position: relative;
}
-.simulation-feedback {
- margin: -5px 8px 14px;
- color: #7f99c4;
- font: 10px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+.chart-viewport {
+ overflow: auto;
+ overscroll-behavior: contain;
+ cursor: grab;
+ touch-action: pan-x pan-y;
}
-.simulation-feedback[data-status="pending"] {
- color: #d9ae7e;
+.chart-viewport.is-panning,
+.chart-viewport.is-panning .chart-state,
+.chart-viewport.is-panning .chart-edge-hit,
+.chart-viewport.is-panning .chart-edge-label {
+ cursor: grabbing;
+ user-select: none;
}
-.simulation-feedback[data-status="error"] {
- color: #d58d89;
+.chart-stage {
+ position: relative;
+ min-width: 100%;
+ min-height: 100%;
}
-.enabled-events[hidden],
-.simulation-feedback[hidden] {
- display: none;
+.chart-canvas {
+ position: absolute;
+ top: 0;
+ left: 0;
+ transform-origin: top left;
}
-.event-button {
- padding: 4px 7px;
- border: 0;
- border-radius: 3px;
- color: #aeb5bf;
- background: #1b1f25;
- font: 11px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
- cursor: pointer;
+.chart-regions,
+.chart-edges,
+.chart-nodes,
+.chart-labels {
+ position: absolute;
+ inset: 0;
}
-.event-button:hover,
-.event-button:focus-visible,
-.event-button.is-selected {
- color: #d9e6ff;
- background: rgb(75 125 255 / 22%);
- outline: none;
+.chart-regions {
+ z-index: 0;
+ pointer-events: none;
}
-.event-button:disabled {
- color: #666d76;
- cursor: wait;
+.chart-edges {
+ z-index: 1;
+ overflow: visible;
+ pointer-events: none;
}
-.topology-node {
- background: transparent;
+.chart-nodes {
+ z-index: 2;
+ pointer-events: none;
}
-.topology-empty {
- display: grid;
- max-width: 420px;
- gap: 7px;
- margin: 26px 8px;
- color: #7f8690;
- font: 12px/1.6 Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+.chart-labels {
+ z-index: 3;
+ pointer-events: none;
}
-.topology-empty strong {
- color: #d4d7dc;
- font: 600 13px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+.chart-compound {
+ position: absolute;
+ border: 1px solid #272c33;
+ border-radius: 5px;
+ background: rgb(18 21 25 / 72%);
}
-.topology-node.is-selected {
- background: var(--accent-bg);
+.chart-compound-parallel {
+ border-color: #303845;
+ background: rgb(20 24 30 / 74%);
}
-.state-row {
- display: grid;
- width: 100%;
- min-height: 38px;
- grid-template-columns: 14px 10px minmax(90px, auto) 1fr;
- align-items: center;
- gap: 8px;
- padding: 6px 8px 6px calc(8px + var(--depth) * 22px);
- border: 0;
- color: #d7d9dc;
- background: transparent;
+.chart-state {
+ position: absolute;
+ display: block;
+ padding: 16px;
+ border: 1px solid #30363f;
+ border-radius: 4px;
+ color: #d9dce1;
+ background: #14181d;
+ box-shadow: 0 4px 14px rgb(0 0 0 / 18%);
+ overflow: hidden;
text-align: left;
cursor: pointer;
+ pointer-events: auto;
user-select: none;
}
-.state-row:hover,
-.state-row:focus-visible {
- color: #fff;
- background: #1b1e23;
- outline: none;
+.chart-state-compound,
+.chart-state-parallel {
+ border-color: #353c47;
+ background: #171b21;
}
-.state-row[aria-selected="true"] {
- color: #fff;
+.chart-state-final {
+ border-color: #445066;
}
-.topology-node.is-selected > .state-row {
- color: #fff;
+.chart-state-history,
+.chart-state-choice {
+ background: #18171d;
}
-.topology-node.is-related-target > .state-row {
- background: rgb(75 125 255 / 15%);
+.chart-state:not(:disabled):hover,
+.chart-state:focus-visible {
+ border-color: #596475;
+ color: #fff;
+ outline: none;
}
-.topology-node.is-related-source > .state-row {
- background: rgb(172 104 224 / 8%);
+.chart-state-heading,
+.chart-state-identity,
+.chart-field-row,
+.chart-activity-row {
+ display: flex;
+ min-width: 0;
+ align-items: center;
}
-.topology-node.is-related-update > .state-row {
- background: rgb(240 163 91 / 8%);
+.chart-state-heading {
+ justify-content: space-between;
+ gap: 12px;
}
-.state-disclosure {
- color: #737a84;
- font-size: 11px;
+.chart-state-identity {
+ gap: 8px;
}
-.state-status {
+.chart-state-status {
width: 7px;
height: 7px;
+ flex: 0 0 auto;
border: 1px solid #686f78;
border-radius: 50%;
}
-.state-status.is-active {
+.chart-state-status.is-active {
border-color: var(--accent);
background: var(--accent);
box-shadow: 0 0 0 3px rgb(117 167 255 / 10%);
}
-.state-label {
+.chart-state-status.is-initial {
+ border-color: #d9a441;
+ background: #d9a441;
+ box-shadow: 0 0 0 3px rgb(217 164 65 / 12%);
+}
+
+.chart-state-status.is-active.is-initial {
+ box-shadow: 0 0 0 2px #14181d, 0 0 0 4px var(--accent);
+}
+
+.chart-state-name,
+.chart-field-name,
+.chart-activity-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
-.state-markers,
+.chart-state-name {
+ font-size: 12px;
+}
+
+.chart-section-label,
+.chart-field-type,
+.chart-activity-kind,
+.chart-more {
+ color: #7e8792;
+ font-size: 9px;
+ line-height: 1.3;
+}
+
+.chart-section-label,
+.chart-activity-kind {
+ text-transform: uppercase;
+}
+
+.chart-state-section {
+ width: min(300px, 100%);
+ margin-top: 11px;
+ padding-top: 8px;
+ border-top: 1px solid #242a31;
+}
+
+.chart-section-label {
+ margin-bottom: 4px;
+ letter-spacing: 0.06em;
+}
+
+.chart-field-row,
+.chart-activity-row {
+ display: grid;
+ min-height: 22px;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 10px;
+}
+
+.chart-activity-row {
+ grid-template-columns: auto minmax(0, 1fr);
+}
+
+.chart-field-name,
+.chart-activity-name {
+ color: #c4c9d0;
+ font-size: 10px;
+}
+
+.chart-field-type,
+.chart-activity-kind {
+ flex: 0 1 auto;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.chart-activity-kind {
+ min-width: max-content;
+ color: #77a990;
+ overflow: visible;
+ text-overflow: clip;
+}
+
+.chart-more {
+ min-height: 18px;
+ padding-top: 3px;
+}
+
+.chart-initial {
+ position: absolute;
+ border-radius: 50%;
+ background: #9ba3ae;
+ box-shadow: 0 0 0 3px rgb(155 163 174 / 8%);
+ pointer-events: none;
+}
+
+.chart-runtime-target {
+ position: absolute;
+ display: grid;
+ place-items: center;
+ border: 1px dashed #555d68;
+ border-radius: 3px;
+ color: #858d98;
+ background: #111419;
+ font: 9px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ pointer-events: none;
+ user-select: none;
+}
+
+.chart-edge-line,
+.chart-edge-hit {
+ fill: none;
+}
+
+.chart-edge-line {
+ stroke: #626b77;
+ stroke-width: 1.4;
+ vector-effect: non-scaling-stroke;
+}
+
+.chart-edge-initial .chart-edge-line {
+ stroke: #89929e;
+}
+
+.chart-edge-hit {
+ stroke: transparent;
+ stroke-width: 14;
+ cursor: pointer;
+ pointer-events: stroke;
+}
+
+#chart-arrow path {
+ fill: context-stroke;
+}
+
+.chart-edge-label {
+ position: absolute;
+ padding: 4px 7px;
+ border: 1px solid #2c333c;
+ border-radius: 3px;
+ color: #c0c7d0;
+ background: #101318;
+ font: 10px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ cursor: pointer;
+ pointer-events: auto;
+ user-select: none;
+}
+
+.chart-edge-label:not(:disabled):hover,
+.chart-edge-label:focus-visible,
+.chart-edge-group.is-hovered + .chart-edge-label {
+ border-color: #53657e;
+ color: #fff;
+ outline: none;
+}
+
+.chart-edge-label-always,
+.chart-edge-label-choice,
+.chart-edge-label-done {
+ color: #d4b4e9;
+}
+
+.chart-edge-group.is-hovered .chart-edge-line {
+ stroke: #7c9ed2;
+ stroke-width: 1.8;
+}
+
+.chart-edge-group.is-walkthrough-unavailable .chart-edge-line {
+ stroke: #6e675b;
+ stroke-dasharray: 5 5;
+}
+
+.chart-edge-group.chart-transition-runtime .chart-edge-line {
+ stroke-dasharray: 5 5;
+}
+
+.chart-edge-group.is-incoming .chart-edge-line {
+ stroke: #ed6a70;
+ stroke-width: 2;
+}
+
+.chart-edge-group.is-outgoing .chart-edge-line {
+ stroke: #6eb6dc;
+ stroke-width: 2;
+}
+
+.chart-edge-group.is-selected .chart-edge-line {
+ stroke: #8ab5ff;
+ stroke-width: 2.5;
+}
+
+.chart-edge-group.is-walkthrough-available .chart-edge-line {
+ stroke: #d5aa57;
+ stroke-width: 2;
+}
+
+.chart-edge-label.is-hovered {
+ border-color: #526783;
+ color: #dbe8ff;
+}
+
+.chart-edge-label.is-walkthrough-unavailable {
+ border-color: #4e4a43;
+ color: #8d8679;
+ background: #151412;
+}
+
+.chart-edge-label.is-incoming {
+ border-color: #a9484e;
+ color: #ffd0d2;
+ background: #251416;
+}
+
+.chart-edge-label.is-outgoing {
+ border-color: #47758d;
+ color: #c5e8f7;
+ background: #111c22;
+}
+
+.chart-edge-label.is-selected {
+ border-color: #719ce0;
+ color: #fff;
+ background: #18253a;
+}
+
+.chart-edge-label.is-walkthrough-available {
+ border-color: #8d713c;
+ color: #f0d39a;
+ background: #211b12;
+}
+
+.chart-state.is-selected,
+.chart-compound.is-selected {
+ border-color: #5f88ca;
+ background: rgb(44 75 122 / 42%);
+}
+
+.chart-state.is-related-to,
+.chart-compound.is-related-to,
+.chart-state.is-hover-target,
+.chart-compound.is-hover-target {
+ border-color: #4f829a;
+ background: rgb(48 112 140 / 24%);
+}
+
+.chart-state.is-related-from,
+.chart-compound.is-related-from,
+.chart-state.is-hover-source,
+.chart-compound.is-hover-source {
+ border-color: #a94b52;
+ background: rgb(151 48 57 / 26%);
+}
+
+.chart-state.is-related-from.is-related-to,
+.chart-compound.is-related-from.is-related-to {
+ border-color: #6e708f;
+ background: linear-gradient(135deg, rgb(151 48 57 / 28%) 0 50%, rgb(48 112 140 / 26%) 50% 100%);
+}
+
+.chart-viewport.is-simulating .chart-state:disabled,
+.chart-viewport.is-simulating .chart-edge-label:disabled,
+.chart-viewport.is-simulating .chart-edge-hit {
+ cursor: default;
+}
+
+.chart-viewport.is-simulating .chart-edge-label:disabled,
+.chart-viewport.is-simulating .chart-edge-hit {
+ pointer-events: none;
+}
+
+.chart-viewport.is-simulating .chart-edge-label.is-walkthrough-available,
+.chart-viewport.is-simulating .chart-edge-label.is-walkthrough-unavailable,
+.chart-viewport.is-simulating .chart-edge-group.is-walkthrough-available .chart-edge-hit,
+.chart-viewport.is-simulating .chart-edge-group.is-walkthrough-unavailable .chart-edge-hit {
+ cursor: pointer;
+}
+
+.chart-viewport.is-simulating .chart-edge-group.is-walkthrough-available .chart-edge-hit,
+.chart-viewport.is-simulating .chart-edge-group.is-walkthrough-unavailable .chart-edge-hit {
+ pointer-events: stroke;
+}
+
+.chart-loading,
+.chart-layout-error {
+ display: grid;
+ max-width: 440px;
+ gap: 7px;
+ margin: 28px;
+ color: #7f8690;
+ font: 11px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+
+.chart-layout-error {
+ color: #d58d89;
+}
+
+.chart-layout-error strong {
+ color: #efb0ac;
+}
+
.inspector-eyebrow,
.card-title,
.card-flags,
@@ -433,10 +794,6 @@ button {
gap: 6px;
}
-.state-markers {
- justify-self: end;
-}
-
.badge {
display: inline-flex;
align-items: center;
@@ -455,11 +812,6 @@ button {
background: rgb(75 125 255 / 22%);
}
-.badge-initial {
- color: #e9c89f;
- background: rgb(240 163 91 / 13%);
-}
-
.badge-trigger {
color: #b9d2ff;
background: rgb(75 125 255 / 16%);
@@ -481,11 +833,51 @@ button {
}
.inspector {
+ position: absolute;
+ z-index: 10;
+ top: 14px;
+ right: 14px;
+ bottom: 14px;
+ width: min(460px, calc(100% - 28px));
min-width: 0;
- height: 100vh;
- padding: 28px;
+ padding: 0;
+ border: 1px solid #333943;
+ border-radius: 6px;
+ overflow: hidden;
+ background: rgb(15 17 20 / 97%);
+ box-shadow: 0 18px 60px rgb(0 0 0 / 48%);
+}
+
+.inspector[hidden] {
+ display: none;
+}
+
+.inspector-close {
+ position: absolute;
+ z-index: 1;
+ top: 11px;
+ right: 12px;
+ padding: 6px 9px;
+ border: 0;
+ border-radius: 3px;
+ color: #8e949e;
+ background: transparent;
+ font-size: 11px;
+ cursor: pointer;
+}
+
+.inspector-content {
+ width: 100%;
+ height: 100%;
+ padding: 48px 24px 24px;
overflow: auto;
- background: var(--surface);
+}
+
+.inspector-close:hover,
+.inspector-close:focus-visible {
+ color: #fff;
+ background: #20242a;
+ outline: none;
}
.failure-shell {
@@ -548,9 +940,29 @@ button {
overflow-wrap: anywhere;
}
+.inspector-title-row,
+.inspector-state-title {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+}
+
+.inspector-title-row {
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.inspector-state-title {
+ gap: 9px;
+}
+
+.inspector-title-row h2 {
+ min-width: 0;
+ margin: 0;
+}
+
.inspector-empty p,
-.section-empty,
-.empty-inline {
+.section-empty {
color: var(--muted);
font-size: 12px;
line-height: 1.6;
@@ -612,7 +1024,7 @@ button {
.metadata {
display: grid;
- grid-template-columns: minmax(74px, auto) minmax(0, 1fr);
+ grid-template-columns: minmax(74px, max-content) minmax(0, 1fr);
gap: 8px 16px;
margin: 20px 0 0;
font-size: 11px;
@@ -668,15 +1080,6 @@ button {
padding: 10px 12px;
}
-.transition-source {
- display: flex;
- align-items: baseline;
- gap: 12px;
- padding: 0 12px 10px;
- color: #727983;
- font-size: 11px;
-}
-
.card-title {
min-width: 0;
}
@@ -714,6 +1117,7 @@ button {
}
.branch-row .metadata,
+.transition-card .card-metadata,
.activity-card .metadata,
.incoming-card .metadata {
margin-top: 10px;
@@ -727,9 +1131,13 @@ button {
padding: 0 12px;
}
+.transition-card .card-metadata {
+ padding: 0 12px 10px;
+}
+
.branch-updates {
display: grid;
- grid-template-columns: minmax(74px, auto) minmax(0, 1fr);
+ grid-template-columns: minmax(74px, max-content) minmax(0, 1fr);
gap: 8px 16px;
margin-top: 9px;
font-size: 11px;
@@ -751,62 +1159,244 @@ button {
padding: 0 12px;
}
-.simulation-composer {
+.walkthrough-dock {
display: grid;
+ min-height: 0;
+ max-height: 76px;
+ flex: 0 0 auto;
+ grid-template-rows: auto auto;
+ gap: 6px;
+ padding: 8px 14px 10px;
+ border-top: 1px solid var(--line);
+ background: rgb(13 15 18 / 98%);
+ box-shadow: 0 -12px 32px rgb(0 0 0 / 20%);
+ font: 10px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+
+.walkthrough-dock[hidden] {
+ display: none;
+}
+
+.walkthrough-heading,
+.walkthrough-identity,
+.walkthrough-timeline {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+}
+
+.walkthrough-heading {
+ justify-content: space-between;
+ gap: 12px;
+ color: #d4d7dc;
+}
+
+.walkthrough-identity {
gap: 10px;
}
-.simulation-composer .section-heading {
- margin-bottom: 0;
+.walkthrough-status,
+.walkthrough-hint {
+ color: #777f8a;
+}
+
+.walkthrough-hint {
+ font-size: 9px;
}
-.schema-form,
-.input-object,
-.input-array,
-.input-union,
-.input-field,
-.input-array-items {
+.walkthrough-timeline {
+ gap: 5px;
+ overflow-x: auto;
+ scrollbar-width: thin;
+}
+
+.walkthrough-step {
+ flex: 0 0 auto;
+ border: 1px solid var(--line-soft);
+ border-radius: 3px;
+ color: #aeb4bd;
+ background: #14171b;
+ cursor: pointer;
+}
+
+.walkthrough-step {
+ padding: 5px 8px;
+ white-space: nowrap;
+}
+
+.walkthrough-step:hover,
+.walkthrough-step:focus-visible {
+ border-color: #53657e;
+ color: #fff;
+ outline: none;
+}
+
+.walkthrough-step.is-current {
+ border-color: #5f88ca;
+ color: #d8e6ff;
+ background: rgb(44 75 122 / 38%);
+}
+
+.transition-picker {
+ position: fixed;
+ z-index: 20;
display: grid;
- gap: 10px;
+ width: min(380px, calc(100vw - 20px));
+ max-height: min(420px, calc(100vh - 68px));
+ grid-template-rows: auto minmax(0, 1fr);
+ border: 1px solid #4e4535;
+ border-radius: 5px;
+ background: rgb(15 17 20 / 98%);
+ box-shadow: 0 18px 60px rgb(0 0 0 / 52%);
+ overflow: hidden;
}
-.schema-form {
- max-width: 520px;
+.transition-picker[hidden] {
+ display: none;
}
-.input-object,
-.input-array,
-.input-union {
+.transition-picker-header,
+.transition-picker-choice-main {
+ display: flex;
min-width: 0;
- margin: 0;
- padding: 0;
- border: 0;
+ align-items: center;
}
-.input-object > legend,
-.input-array > legend,
-.input-union > legend {
- margin-bottom: 8px;
- padding: 0;
- color: #d5d8dd;
+.transition-picker-header {
+ min-height: 38px;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 8px 10px 8px 12px;
+ border-bottom: 1px solid var(--line-soft);
+}
+
+.transition-picker-header strong {
+ color: #d8dadd;
font: 600 11px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.transition-picker-close {
+ padding: 4px 6px;
+ border: 0;
+ border-radius: 3px;
+ color: #7f8690;
+ background: transparent;
+ font-size: 10px;
+ cursor: pointer;
+}
+
+.transition-picker-close:hover,
+.transition-picker-close:focus-visible {
+ color: #fff;
+ background: #20242a;
+ outline: none;
+}
+
+.transition-picker-content {
+ display: grid;
+ gap: 5px;
+ padding: 7px;
+ overflow: auto;
}
-.input-object .input-object,
-.input-object .input-array,
-.input-object .input-union {
- padding: 11px;
+.transition-picker-choice {
+ display: grid;
+ min-width: 0;
+ gap: 4px;
+ padding: 8px 9px;
border: 1px solid var(--line-soft);
- background: #101216;
+ border-radius: 3px;
+ color: #aeb4bd;
+ background: #14171b;
+ font: 10px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ text-align: left;
+ cursor: pointer;
}
-.input-field {
- gap: 6px;
+.transition-picker-choice:hover,
+.transition-picker-choice:focus-visible {
+ border-color: #8d713c;
+ background: #1d1912;
+ outline: none;
+}
+
+.transition-picker-choice-main {
+ gap: 7px;
+}
+
+.transition-picker-choice-main strong,
+.transition-picker-choice-title,
+.transition-picker-choice-route,
+.transition-picker-choice-contract,
+.transition-picker-choice-decision,
+.transition-picker-choice-unavailable {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.transition-picker-choice-main strong {
+ color: #efd18f;
+}
+
+.transition-picker-choice-title {
+ color: #d9b8ef;
+}
+
+.transition-picker-choice-route {
+ color: #777f8a;
+}
+
+.transition-picker-choice-contract {
+ color: #9ebce9;
+}
+
+.transition-picker-choice-decision {
+ color: #b69ac9;
+}
+
+.transition-picker-choice-unavailable {
+ color: #a38c6f;
+}
+
+.transition-picker-choice.is-unavailable {
+ opacity: 0.68;
+ cursor: default;
+}
+
+.input-contract-fields,
+.input-contract-field,
+.input-contract-children,
+.input-contract-alternatives {
+ display: grid;
+ gap: 9px;
+}
+
+.input-contract-fields {
+ padding: 11px 12px;
+ border: 1px solid var(--line-soft);
+ background: var(--surface-raised);
+}
+
+.input-contract-field + .input-contract-field {
+ padding-top: 9px;
+ border-top: 1px solid var(--line-soft);
}
-.input-field + .input-field {
- padding-top: 10px;
+.input-contract-children,
+.input-contract-alternatives {
+ margin-top: 2px;
+ padding: 9px 0 0 12px;
border-top: 1px solid var(--line-soft);
+ border-left: 1px solid var(--line-soft);
+}
+
+.input-contract .section-empty {
+ margin: 0;
}
.input-field-heading {
@@ -827,8 +1417,7 @@ button {
.input-label,
.input-required,
-.input-optional,
-.boolean-control {
+.input-optional {
font-size: 11px;
}
@@ -863,33 +1452,15 @@ button {
color: #737b86;
}
-.input-optional,
-.boolean-control {
+.input-optional {
display: inline-flex;
align-items: center;
gap: 6px;
color: #9199a4;
}
-.input-control {
- width: 100%;
- min-height: 34px;
- padding: 7px 9px;
- border: 1px solid var(--line);
- border-radius: 3px;
- color: #dfe7f5;
- background: #0b0d10;
- font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
- outline: none;
-}
-
-.input-control:focus {
- border-color: #557db8;
-}
-
.input-description,
-.input-unsupported,
-.input-errors {
+.input-unsupported {
margin: 0;
font-size: 11px;
line-height: 1.55;
@@ -903,104 +1474,6 @@ button {
color: #d7a5a2;
}
-.input-errors {
- color: #efaaa6;
-}
-
-.input-errors-form {
- padding: 8px 10px;
- border: 1px solid rgb(224 89 84 / 30%);
- background: rgb(224 89 84 / 8%);
-}
-
-.literal-control {
- color: #aeb6c1;
- font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
-}
-
-.is-disabled {
- opacity: 0.45;
-}
-
-.input-array-item {
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
- align-items: start;
- gap: 7px;
-}
-
-.input-array-add,
-.input-array-remove {
- padding: 6px 8px;
- border: 0;
- border-radius: 3px;
- color: #9ebce9;
- background: #1b2431;
- font-size: 10px;
- cursor: pointer;
-}
-
-.input-array-add {
- justify-self: start;
-}
-
-.simulation-action {
- justify-self: start;
- padding: 7px 10px;
- border: 0;
- border-radius: 3px;
- color: #e4edff;
- background: #315d9f;
- font-size: 11px;
- cursor: pointer;
-}
-
-.simulation-action:hover,
-.simulation-action:focus-visible {
- background: #3b6db8;
- outline: none;
-}
-
-.simulation-action:disabled {
- color: #7e8796;
- background: #252a32;
- cursor: wait;
-}
-
-.simulation-note,
-.editor-error {
- margin: 0;
- font-size: 11px;
- line-height: 1.55;
-}
-
-.simulation-note {
- color: #777f8a;
-}
-
-.editor-error,
-.trace-error {
- color: #eca6a2;
-}
-
-.trace-header .json-value {
- margin-top: 18px;
-}
-
-.json-value,
-.trace-error {
- max-width: 100%;
- margin: 8px 0 0;
- padding: 10px 11px;
- border: 1px solid var(--line-soft);
- color: #bac8dc;
- background: #0d0f12;
- font: 10px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
- overflow: auto;
- white-space: pre-wrap;
- overflow-wrap: anywhere;
-}
-
.trace-topology {
display: grid;
gap: 10px;
@@ -1067,40 +1540,16 @@ button {
}
@media (max-width: 900px) {
- .devtools-shell {
- grid-template-columns: 1fr;
- }
-
- .machine-index {
- display: flex;
- height: auto;
- min-height: 54px;
- padding: 0;
- border-right: 0;
- border-bottom: 1px solid var(--line);
- overflow-x: auto;
- }
-
.machine-row {
- width: min(220px, 70vw);
- flex: 0 0 auto;
- }
-
- .workspace {
- height: auto;
- grid-template-columns: 1fr;
- }
-
- .tree-panel {
- height: 58vh;
- min-height: 58vh;
- border-right: 0;
- border-bottom: 1px solid var(--line);
+ max-width: 220px;
+ min-width: 132px;
}
.inspector {
- height: auto;
- min-height: 42vh;
+ top: 8px;
+ right: 8px;
+ bottom: 8px;
+ width: min(440px, calc(100% - 16px));
}
}
@@ -1119,19 +1568,24 @@ button {
padding-top: 8px;
}
- .topology-tree,
- .inspector {
- padding-inline: 14px;
+ .inspector-content {
+ padding: 48px 14px 18px;
+ }
+
+ .zoom-controls {
+ bottom: 8px;
+ left: 8px;
}
- .state-markers .badge-count {
- display: none;
+ .chart-panel.has-walkthrough .zoom-controls {
+ bottom: 88px;
}
}
@media (prefers-reduced-motion: no-preference) {
- .state-row,
+ .chart-state,
+ .chart-edge-label,
.toolbar-button {
- transition: color 120ms ease, background-color 120ms ease;
+ transition: color 120ms ease, background-color 120ms ease, border-color 120ms ease;
}
}
diff --git a/packages/devtools/src/internal/browser/transition-semantics-example.ts b/packages/devtools/src/internal/browser/transition-semantics-example.ts
new file mode 100644
index 0000000..6378c5c
--- /dev/null
+++ b/packages/devtools/src/internal/browser/transition-semantics-example.ts
@@ -0,0 +1,236 @@
+import { Machine } from "@typeonce/effect-machine"
+import { Schema } from "effect"
+
+class Workspace extends Schema.TaggedClass("TransitionWorkspace")("Workspace", {
+ revision: Schema.Number,
+ preferredRoute: Schema.Literals(["draft", "review"])
+}) {}
+class Draft extends Schema.TaggedClass("TransitionDraft")("Draft", {
+ text: Schema.String,
+ autosaves: Schema.Number
+}) {}
+class AutoSaving extends Schema.TaggedClass("TransitionAutoSaving")("AutoSaving", {}) {}
+class Review extends Schema.TaggedClass("TransitionReview")("Review", {
+ requestedBy: Schema.String
+}) {}
+class Checking extends Schema.TaggedClass("TransitionChecking")("Checking", {
+ checks: Schema.Array(Schema.String)
+}) {}
+class ChangesRequested extends Schema.TaggedClass("TransitionChangesRequested")(
+ "ChangesRequested",
+ { reason: Schema.String }
+) {}
+class Approved extends Schema.TaggedClass("TransitionApproved")("Approved", {
+ reviewer: Schema.String
+}) {}
+class WorkspaceFinished extends Schema.TaggedClass("TransitionWorkspaceFinished")(
+ "Finished",
+ { result: Schema.String }
+) {}
+class Paused extends Schema.TaggedClass("TransitionPaused")("Paused", {
+ reason: Schema.String
+}) {}
+class Published extends Schema.TaggedClass("TransitionPublished")("Published", {
+ result: Schema.String
+}) {}
+
+class Create extends Schema.TaggedClass("TransitionCreate")("Create", {
+ text: Schema.String,
+ route: Schema.Literals(["draft", "review"])
+}) {}
+class Edit extends Schema.TaggedClass("TransitionEdit")("Edit", { text: Schema.String }) {}
+class Save extends Schema.TaggedClass("TransitionSave")("Save", {}) {}
+class Submit extends Schema.TaggedClass("TransitionSubmit")("Submit", {
+ mode: Schema.Literals(["review", "publish"]),
+ requestedBy: Schema.String
+}) {}
+class Approve extends Schema.TaggedClass("TransitionApprove")("Approve", {
+ reviewer: Schema.String
+}) {}
+class Reject extends Schema.TaggedClass("TransitionReject")("Reject", {
+ reason: Schema.String
+}) {}
+class Revise extends Schema.TaggedClass("TransitionRevise")("Revise", {}) {}
+class Pause extends Schema.TaggedClass("TransitionPause")("Pause", { reason: Schema.String }) {}
+class ResumeShallow extends Schema.TaggedClass("TransitionResumeShallow")(
+ "ResumeShallow",
+ {}
+) {}
+class ResumeDeep extends Schema.TaggedClass("TransitionResumeDeep")("ResumeDeep", {}) {}
+class Restart extends Schema.TaggedClass("TransitionRestart")("Restart", {}) {}
+class Refresh extends Schema.TaggedClass("TransitionRefresh")("Refresh", {}) {}
+class Ignore extends Schema.TaggedClass("TransitionIgnore")("Ignore", {}) {}
+class MaybeHandle extends Schema.TaggedClass("TransitionMaybeHandle")("MaybeHandle", {
+ accept: Schema.Boolean
+}) {}
+class BumpWorkspace extends Schema.TaggedClass("TransitionBumpWorkspace")(
+ "BumpWorkspace",
+ {}
+) {}
+
+const TransitionStates = Machine.states({
+ Workspace: {
+ schema: Workspace,
+ initial: "Routing",
+ states: {
+ Routing: { type: "choice" },
+ Draft,
+ AutoSaving,
+ Review: {
+ schema: Review,
+ initial: "Checking",
+ states: {
+ Checking,
+ ChangesRequested,
+ Approved: { schema: Approved, type: "final" }
+ }
+ },
+ Finished: { schema: WorkspaceFinished, type: "final" },
+ recent: { type: "history" },
+ exact: { type: "history", history: "deep" }
+ }
+ },
+ Paused,
+ Published: { schema: Published, type: "final", output: Schema.String }
+})
+
+const defaultWorkspaceSnapshot = () => ({
+ path: "Workspace" as const,
+ value: new Workspace({ revision: 0, preferredRoute: "draft" as const }),
+ state: {
+ path: "Workspace.Draft" as const,
+ value: new Draft({ text: "Recovered draft", autosaves: 0 })
+ }
+})
+
+export const transitionSemanticsMachine = Machine.make({
+ id: "transition-semantics",
+ states: TransitionStates.states,
+ events: Machine.events(
+ Create,
+ Edit,
+ Save,
+ Submit,
+ Approve,
+ Reject,
+ Revise,
+ Pause,
+ ResumeShallow,
+ ResumeDeep,
+ Restart,
+ Refresh,
+ Ignore,
+ MaybeHandle,
+ BumpWorkspace
+ ),
+ initial: (to) => to.Paused().resolve(({ target }) => target.decoded(new Paused({ reason: "not started" })))
+}).handle({
+ Workspace: {
+ history: {
+ recent: { default: defaultWorkspaceSnapshot },
+ exact: { default: defaultWorkspaceSnapshot }
+ },
+ on: {
+ Pause: (to) =>
+ to.full.Paused().resolve(({ event, target }) => target.decoded(new Paused({ reason: event.reason }))),
+ BumpWorkspace: (to) =>
+ to.branch.Workspace.update(({ current, owner }) =>
+ owner.decoded(
+ new Workspace({
+ revision: current.revision + 1,
+ preferredRoute: current.preferredRoute
+ })
+ )
+ )
+ },
+ onDone: (to) =>
+ to.full.Published().resolve(({ target }) => target.decoded(new Published({ result: "workspace published" }))),
+ states: {
+ Routing: {
+ choice: (to) =>
+ to.branches({
+ draft: { title: "Preferred route is draft", target: to.local.Draft() },
+ review: { title: "Preferred route is review", target: to.local.Review.initial }
+ }).resolve(({ containingState, select }) =>
+ containingState.preferredRoute === "review"
+ ? select.review.decoded(new Review({ requestedBy: "initial route" }))
+ : select.draft.decoded(new Draft({ text: "", autosaves: 0 }))
+ )
+ },
+ Draft: {
+ on: {
+ Edit: (to) =>
+ to.local.Draft().resolve(({ event, state, target }) =>
+ target.decoded(new Draft({ text: event.text, autosaves: state.autosaves }))
+ ),
+ Save: (to) => to.local.AutoSaving().resolve(({ target }) => target.decoded(new AutoSaving({}))),
+ Submit: (to) =>
+ to.branches({
+ review: { title: "Enter the review flow", target: to.local.Review.initial },
+ publish: { title: "Publish without review", target: to.local.Finished() }
+ }).resolve(({ event, select }) =>
+ event.mode === "publish"
+ ? select.publish.decoded(new WorkspaceFinished({ result: "published directly" }))
+ : select.review.decoded(new Review({ requestedBy: event.requestedBy }))
+ ),
+ Refresh: (to) => to.none.resolve(() => undefined, { reenter: true }),
+ Ignore: (to) => to.none,
+ MaybeHandle: (to) =>
+ to.none.resolve(({ decline, event }) => event.accept ? undefined : decline(), {
+ declinable: true
+ })
+ }
+ },
+ AutoSaving: {
+ always: (to) =>
+ to.local.Draft().resolve(({ target }) => target.decoded(new Draft({ text: "Autosaved draft", autosaves: 1 })))
+ },
+ Review: {
+ initialize: ({ builder }) => builder.decoded(new Checking({ checks: ["types", "tests"] })),
+ onDone: (to) =>
+ to.branch.Workspace.Finished().resolve(({ target }) =>
+ target.decoded(new WorkspaceFinished({ result: "approved review" }))
+ ),
+ states: {
+ Checking: {
+ on: {
+ Approve: (to) =>
+ to.local.Approved().resolve(({ event, target }) =>
+ target.decoded(new Approved({ reviewer: event.reviewer }))
+ ),
+ Reject: (to) =>
+ to.local.ChangesRequested().resolve(({ event, target }) =>
+ target.decoded(new ChangesRequested({ reason: event.reason }))
+ )
+ }
+ },
+ ChangesRequested: {
+ on: {
+ Revise: (to) =>
+ to.branch.Workspace.Draft().resolve(({ target }) =>
+ target.decoded(new Draft({ text: "Revised draft", autosaves: 0 }))
+ )
+ }
+ }
+ }
+ }
+ }
+ },
+ Paused: {
+ on: {
+ Create: (to) =>
+ to.full.Workspace.initial.resolve(({ event, target }) =>
+ target.decoded(new Workspace({ revision: 0, preferredRoute: event.route }))
+ ),
+ ResumeShallow: (to) => to.history.Workspace.recent.resolve(({ target }) => target()),
+ ResumeDeep: (to) => to.history.Workspace.exact.resolve(({ target }) => target()),
+ Restart: (to) =>
+ to.full.Workspace.initial.resolve(({ target }) =>
+ target.decoded(new Workspace({ revision: 0, preferredRoute: "draft" }))
+ )
+ }
+ },
+ Published: {
+ output: ({ state }) => state.result
+ }
+})
diff --git a/packages/devtools/src/internal/browser/visualizer-app.ts b/packages/devtools/src/internal/browser/visualizer-app.ts
index 47140bf..465e37b 100644
--- a/packages/devtools/src/internal/browser/visualizer-app.ts
+++ b/packages/devtools/src/internal/browser/visualizer-app.ts
@@ -1,24 +1,29 @@
-import {
- type Diagnostic,
- protocolVersion,
- type SimulationFrame,
- type SimulationReady,
- type SimulationRequest
-} from "../../DevToolsProtocol.js"
+import * as Effect from "effect/Effect"
+import * as Result from "effect/Result"
+import type { Diagnostic } from "../../DevToolsProtocol.js"
import type {
Activity as VisualizationActivity,
Branch as VisualizationBranch,
+ InputSchema,
MachineDocument as VisualizationDocument,
- Transition as VisualizationTransition
+ Transition as VisualizationTransition,
+ Trigger
} from "../../MachineDocument.js"
-import { type InputForm, renderInputForm } from "./input-form.js"
-import { requestSimulation } from "./simulation-client.js"
+import * as MachineWalkthrough from "../../MachineWalkthrough.js"
+import {
+ type ChartInteractionAnchor,
+ type ChartPresentation,
+ type ChartView,
+ maximumChartZoom,
+ minimumChartZoom,
+ renderChart
+} from "./chart-renderer.js"
+import { type InputField, projectInputSchema } from "./input-form.js"
import {
- type EventInspection,
+ branchTargetApi,
type IncomingTransition,
makeVisualizerModel,
type StateInspection,
- type TopologyNode,
triggerLabel
} from "./visualizer-model.js"
@@ -33,16 +38,33 @@ const createElement = (
return element
}
-const metadata = (items: ReadonlyArray): HTMLDListElement => {
+type MetadataValue = string | Node | null | undefined
+
+const metadata = (items: ReadonlyArray): HTMLDListElement => {
const list = createElement("dl", "metadata")
for (const [label, value] of items) {
- list.append(createElement("dt", undefined, label), createElement("dd", undefined, value))
+ if (value === null || value === undefined || value === "" || value === "none") continue
+ const description = createElement("dd")
+ description.append(value)
+ list.append(createElement("dt", undefined, label), description)
}
return list
}
const badge = (text: string, kind = "neutral"): HTMLSpanElement => createElement("span", `badge badge-${kind}`, text)
+const stateStatus = (active: boolean, initial: boolean): HTMLSpanElement => {
+ const status = createElement(
+ "span",
+ `chart-state-status${active ? " is-active" : ""}${initial ? " is-initial" : ""}`
+ )
+ status.setAttribute(
+ "aria-label",
+ active && initial ? "active, initial state" : active ? "active" : initial ? "initial state" : "inactive"
+ )
+ return status
+}
+
type StateNavigator = (path: string) => void
const stateLink = (path: string, label: string, navigate: StateNavigator): HTMLButtonElement => {
@@ -52,26 +74,35 @@ const stateLink = (path: string, label: string, navigate: StateNavigator): HTMLB
return link
}
-const renderBranch = (branch: VisualizationBranch, navigate: StateNavigator): HTMLElement => {
+const renderBranch = (
+ document: VisualizationDocument,
+ source: string,
+ branch: VisualizationBranch,
+ navigate: StateNavigator
+): HTMLElement => {
const row = createElement("div", "branch-row")
const main = createElement("div", "branch-main")
if (branch.type === "branch") main.append(badge(branch.title, "condition"))
- main.append(createElement("span", "branch-arrow", "→"))
if (branch.target !== null) {
- main.append(stateLink(branch.target, branch.target, navigate))
+ main.append(createElement("span", "branch-arrow", "→"), stateLink(branch.target, branch.target, navigate))
+ } else if (branch.selection.kind === "update") {
+ main.append(
+ badge(branch.selection.scope === "local" ? "to.local.update" : "value update", "update"),
+ createElement("span", "branch-target", "Updates the owner value")
+ )
} else {
- main.append(createElement("span", "branch-target", branch.updates.length > 0 ? "Remain in state" : "No target"))
+ main.append(createElement("span", "branch-target", "No target state"))
}
row.append(main)
- const details: Array = [
- ["Selection", branch.selection.kind],
- ["Scope", branch.selection.scope ?? "none"]
- ]
+ const api = branchTargetApi(document, source, branch)
+ const details: Array = api === undefined
+ ? [["Selection", branch.selection.kind], ["Scope", branch.selection.scope]]
+ : [["API", api]]
row.append(metadata(details))
if (branch.updates.length > 0) {
const updates = createElement("div", "branch-updates")
- updates.append(createElement("span", "branch-updates-label", "Updates"))
+ updates.append(createElement("span", "branch-updates-label", "Value owner"))
branch.updates.forEach((path) => updates.append(stateLink(path, path, navigate)))
row.append(updates)
}
@@ -79,6 +110,7 @@ const renderBranch = (branch: VisualizationBranch, navigate: StateNavigator): HT
}
const renderTransition = (
+ document: VisualizationDocument,
transition: VisualizationTransition,
navigate: StateNavigator,
showSource = false
@@ -86,44 +118,52 @@ const renderTransition = (
const card = createElement("article", "inspection-card transition-card")
const header = createElement("div", "card-header")
const title = createElement("div", "card-title")
- title.append(badge(transition.trigger.type, "trigger"), createElement("strong", undefined, triggerLabel(transition)))
+ title.append(createElement("strong", undefined, triggerLabel(transition)))
const flags = createElement("div", "card-flags")
+ flags.append(badge(transition.trigger.type, "trigger"))
if (transition.reenter) flags.append(badge("reenter"))
if (transition.acceptance === "declinable") flags.append(badge("declinable"))
header.append(title, flags)
card.append(header)
if (showSource) {
- const source = createElement("div", "transition-source")
- source.append(createElement("span", undefined, "From"), stateLink(transition.source, transition.source, navigate))
+ const source = metadata([["Source", stateLink(transition.source, transition.source, navigate)]])
+ source.classList.add("card-metadata")
card.append(source)
}
- const branches = createElement("div", "branch-list")
- if (transition.branches.length === 0) {
- branches.append(createElement("div", "empty-inline", "No transition branches"))
- } else {
- transition.branches.forEach((branch) => branches.append(renderBranch(branch, navigate)))
+ if (transition.branches.length > 0) {
+ const branches = createElement("div", "branch-list")
+ transition.branches.forEach((branch) =>
+ branches.append(renderBranch(document, transition.source, branch, navigate))
+ )
+ card.append(branches)
}
- card.append(branches)
return card
}
-const renderIncomingTransition = (incoming: IncomingTransition, navigate: StateNavigator): HTMLElement => {
+const renderIncomingTransition = (
+ document: VisualizationDocument,
+ incoming: IncomingTransition,
+ navigate: StateNavigator
+): HTMLElement => {
const card = createElement("article", "inspection-card incoming-card")
const header = createElement("div", "card-header")
const title = createElement("div", "card-title")
- title.append(
- badge(incoming.transition.trigger.type, "trigger"),
- createElement("strong", undefined, triggerLabel(incoming.transition))
- )
- header.append(title, stateLink(incoming.transition.source, incoming.transition.source, navigate))
+ title.append(createElement("strong", undefined, triggerLabel(incoming.transition)))
+ const flags = createElement("div", "card-flags")
+ flags.append(badge(incoming.transition.trigger.type, "trigger"))
+ header.append(title, flags)
card.append(header)
- const details: Array = [
- ["Selection", incoming.branch.selection.kind],
- ["Scope", incoming.branch.selection.scope ?? "none"]
+ const api = branchTargetApi(document, incoming.transition.source, incoming.branch)
+ const details: Array = [
+ ["Source", stateLink(incoming.transition.source, incoming.transition.source, navigate)],
+ ["API", api]
]
- if (incoming.branch.type === "branch") details.unshift(["Branch", incoming.branch.title])
+ if (api === undefined) {
+ details.push(["Selection", incoming.branch.selection.kind], ["Scope", incoming.branch.selection.scope])
+ }
+ if (incoming.branch.type === "branch") details.unshift(["Title", incoming.branch.title])
card.append(metadata(details))
return card
}
@@ -140,15 +180,19 @@ const activityTitle = (activity: VisualizationActivity): string => {
}
}
-const renderActivity = (activity: VisualizationActivity): HTMLElement => {
+const renderActivity = (activity: VisualizationActivity, navigate: StateNavigator): HTMLElement => {
const card = createElement("article", "inspection-card activity-card")
const header = createElement("div", "card-header")
const title = createElement("div", "card-title")
- title.append(badge(activity.type, "activity"), createElement("strong", undefined, activityTitle(activity)))
- header.append(title)
+ title.append(createElement("strong", undefined, activityTitle(activity)))
+ const flags = createElement("div", "card-flags")
+ flags.append(badge(activity.type, "activity"))
+ header.append(title, flags)
card.append(header)
- const details: Array = [["Owner", activity.source]]
+ const details: Array = [
+ ["Owner", stateLink(activity.source, activity.source, navigate)]
+ ]
if (activity.type === "timer") details.push(["Duration", activity.duration])
if (activity.type === "effect") {
details.push(["Success", activity.outcomes.success], ["Failure", activity.outcomes.failure])
@@ -160,24 +204,183 @@ const renderActivity = (activity: VisualizationActivity): HTMLElement => {
return card
}
-const inspectionSection = (title: string, count: number): HTMLElement => {
+const inspectionSection = (title: string, count?: number): HTMLElement => {
const header = createElement("div", "section-heading")
- header.append(createElement("h3", undefined, title), createElement("span", "section-count", String(count)))
+ header.append(createElement("h3", undefined, title))
+ if (count !== undefined) header.append(createElement("span", "section-count", String(count)))
return header
}
-const formSection = (title: string): HTMLElement => {
- const header = createElement("div", "section-heading")
- header.append(createElement("h3", undefined, title))
- return header
+const inputType = (field: InputField): string => {
+ switch (field._tag) {
+ case "String":
+ return field.format ?? "string"
+ case "Number":
+ return field.integer ? "integer" : "number"
+ case "Boolean":
+ return "boolean"
+ case "Enum":
+ return "enum"
+ case "Literal":
+ return "literal"
+ case "Object":
+ return "object"
+ case "Array":
+ return `${inputType(field.item)}[]`
+ case "Union":
+ return "union"
+ case "Unsupported":
+ return "unknown"
+ }
}
-const prettyJson = (value: unknown): string => JSON.stringify(value, null, 2)
+const inputConstraints = (field: InputField): ReadonlyArray => {
+ switch (field._tag) {
+ case "String":
+ return [
+ field.minLength === undefined ? undefined : `min ${field.minLength}`,
+ field.maxLength === undefined ? undefined : `max ${field.maxLength}`,
+ field.pattern === undefined ? undefined : `pattern ${field.pattern}`
+ ].filter((value): value is string => value !== undefined)
+ case "Number":
+ return [
+ field.minimum === undefined ? undefined : `≥ ${field.minimum}`,
+ field.maximum === undefined ? undefined : `≤ ${field.maximum}`
+ ].filter((value): value is string => value !== undefined)
+ case "Enum":
+ return field.values.map((value) => JSON.stringify(value))
+ case "Literal":
+ return [JSON.stringify(field.value)]
+ case "Array":
+ return [
+ field.minItems > 0 ? `min ${field.minItems}` : undefined,
+ field.maxItems === undefined ? undefined : `max ${field.maxItems}`
+ ].filter((value): value is string => value !== undefined)
+ default:
+ return []
+ }
+}
-const jsonBlock = (value: unknown): HTMLElement => createElement("pre", "json-value", prettyJson(value))
+const renderContractField = (
+ label: string,
+ required: boolean,
+ field: InputField,
+ omit: ReadonlySet
+): HTMLElement | null => {
+ if (omit.has(label)) return null
+ const row = createElement("div", "input-contract-field")
+ const heading = createElement("div", "input-field-heading")
+ const identity = createElement("div", "input-field-identity")
+ identity.append(
+ createElement("span", "input-label", label),
+ createElement("span", "input-field-type", inputType(field)),
+ createElement("span", required ? "input-required" : "input-optional", required ? "required" : "optional")
+ )
+ heading.append(identity)
+ row.append(heading)
+ const constraints = inputConstraints(field)
+ if (constraints.length > 0) {
+ const values = createElement("div", "input-constraints")
+ constraints.forEach((value) => values.append(createElement("span", undefined, value)))
+ row.append(values)
+ }
+ if (field.description !== undefined) row.append(createElement("p", "input-description", field.description))
+ if (field._tag === "Object") {
+ const children = createElement("div", "input-contract-children")
+ field.fields.forEach(({ key, required, field }) => {
+ const child = renderContractField(key, required, field, omit)
+ if (child !== null) children.append(child)
+ })
+ if (children.childElementCount > 0) row.append(children)
+ } else if (field._tag === "Array" && field.item._tag === "Object") {
+ const children = createElement("div", "input-contract-children")
+ field.item.fields.forEach(({ key, required, field }) => {
+ const child = renderContractField(key, required, field, omit)
+ if (child !== null) children.append(child)
+ })
+ if (children.childElementCount > 0) row.append(children)
+ } else if (field._tag === "Union") {
+ const alternatives = createElement("div", "input-contract-alternatives")
+ field.alternatives.forEach((alternative, index) => {
+ const item = renderContractField(`Option ${index + 1}`, true, alternative, omit)
+ if (item !== null) alternatives.append(item)
+ })
+ row.append(alternatives)
+ } else if (field._tag === "Unsupported") {
+ row.append(createElement("p", "input-unsupported", field.reason))
+ }
+ return row
+}
-const eventName = (value: unknown): string =>
- typeof value === "object" && value !== null && "_tag" in value ? String(value._tag) : "event"
+const renderInputContract = (
+ title: string,
+ schema: InputSchema,
+ omit: ReadonlyArray = []
+): HTMLElement => {
+ const section = createElement("section", "inspector-section input-contract")
+ section.append(inspectionSection(title))
+ const projected = projectInputSchema(schema)
+ const fields = createElement("div", "input-contract-fields")
+ const omitted = new Set(omit)
+ if (projected._tag === "Object") {
+ projected.fields.forEach(({ key, required, field }) => {
+ const child = renderContractField(key, required, field, omitted)
+ if (child !== null) fields.append(child)
+ })
+ } else {
+ const child = renderContractField("value", true, projected, omitted)
+ if (child !== null) fields.append(child)
+ }
+ if (fields.childElementCount === 0) {
+ fields.append(createElement("p", "section-empty", "No additional data fields."))
+ }
+ section.append(fields)
+ return section
+}
+
+const contractSummary = (schema: InputSchema | null): string | null => {
+ if (schema === null) return null
+ const field = projectInputSchema(schema)
+ if (field._tag !== "Object") return inputType(field)
+ const fields = field.fields.filter(({ key }) => key !== "_tag")
+ if (fields.length === 0) return null
+ return fields.slice(0, 3).map(({ key, required, field }) => `${key}${required ? "" : "?"}: ${inputType(field)}`).join(
+ " · "
+ ) + (fields.length > 3 ? ` · +${fields.length - 3}` : "")
+}
+
+const triggerName = (trigger: Trigger): string => {
+ switch (trigger.type) {
+ case "event":
+ return trigger.event
+ case "always":
+ return "Always"
+ case "done":
+ return "Completion"
+ case "choice":
+ return "Choice"
+ case "invoke":
+ return `${trigger.id} · ${trigger.outcome}`
+ }
+}
+
+const decisionLabel = (decision: MachineWalkthrough.Decision): string => {
+ switch (decision) {
+ case "conditional-branch":
+ return "Choose branch"
+ case "declinable-transition":
+ return "Assume accepted"
+ case "automatic-trigger":
+ return "Advance automatic trigger"
+ case "invoke-outcome":
+ return "Choose invoke outcome"
+ }
+}
+
+const unavailableLabel = (reason: MachineWalkthrough.UnavailableReason): string =>
+ reason === "history-unavailable"
+ ? "No history has been recorded for this target yet"
+ : "The target is resolved only at runtime"
const activeTopology = (paths: ReadonlyArray): string => {
const leaves = paths.filter((path) => !paths.some((candidate) => candidate.startsWith(`${path}.`)))
@@ -186,68 +389,85 @@ const activeTopology = (paths: ReadonlyArray): string => {
export const renderVisualizer = (
root: HTMLElement,
- machineKey: string,
+ _machineKey: string,
visualization: VisualizationDocument,
diagnostics: ReadonlyArray = []
): void => {
const model = makeVisualizerModel(visualization)
- const rows = new Map()
- const nodes = new Map()
- const statuses = new Map()
- const eventButtons = new Map()
+ const transitionsById = new Map(visualization.transitions.map((transition) => [transition.id, transition]))
const eventSchemas = new Map(visualization.inputs.events.map(({ event, schema }) => [event, schema]))
- const relatedPaths = new Set()
+ const relatedFrom = new Set()
+ const relatedTo = new Set()
+ const incomingTransitions = new Set()
+ const outgoingTransitions = new Set()
let selectedPath: string | undefined
- let selectedEvent: string | undefined
- let selectedFrame: SimulationFrame | undefined
- let simulation: SimulationReady | undefined
- let simulationPending = false
- let activeInputForm: InputForm | undefined
+ let selectedTransition: string | undefined
+ let selectedFrame: MachineWalkthrough.Frame | undefined
+ let walkthrough: MachineWalkthrough.Session | undefined
+ let chartView: ChartView | undefined
- const activePaths = (): ReadonlyArray => simulation?.current.activePaths ?? model.activePaths
- const candidateEvents = (): ReadonlyArray => simulation?.current.candidateEvents ?? model.candidateEvents
+ const activePaths = (): ReadonlyArray =>
+ walkthrough === undefined ? model.activePaths : MachineWalkthrough.current(walkthrough).after.activePaths
+ const availableChoices = (): ReadonlyArray =>
+ walkthrough === undefined ? [] : MachineWalkthrough.choices(walkthrough)
const shell = createElement("main", "app-shell")
const workspace = createElement("section", "workspace")
- const treePanel = createElement("section", "tree-panel")
- treePanel.setAttribute("aria-label", `${model.machineId} topology`)
+ const chartPanel = createElement("section", "chart-panel")
+ chartPanel.setAttribute("aria-label", `${model.machineId} topology`)
const inspector = createElement("aside", "inspector")
inspector.setAttribute("aria-live", "polite")
+ inspector.setAttribute("aria-label", "Selection details")
+ inspector.hidden = true
+ const inspectorClose = createElement("button", "inspector-close", "Close")
+ inspectorClose.type = "button"
+ inspectorClose.setAttribute("aria-label", "Close details")
+ const inspectorContent = createElement("div", "inspector-content")
+ inspector.append(inspectorClose, inspectorContent)
+ const choicePicker = createElement("section", "transition-picker")
+ choicePicker.setAttribute("role", "dialog")
+ choicePicker.setAttribute("aria-label", "Available transitions")
+ choicePicker.hidden = true
+ const choicePickerHeader = createElement("div", "transition-picker-header")
+ const choicePickerTitle = createElement("strong")
+ const choicePickerClose = createElement("button", "transition-picker-close", "Close")
+ choicePickerClose.type = "button"
+ const choicePickerContent = createElement("div", "transition-picker-content")
+ choicePickerHeader.append(choicePickerTitle, choicePickerClose)
+ choicePicker.append(choicePickerHeader, choicePickerContent)
const clearButton = createElement("button", "toolbar-button", "Clear selection")
clearButton.type = "button"
clearButton.disabled = true
- const expandButton = createElement("button", "toolbar-button", "Expand all")
- expandButton.type = "button"
- const collapseButton = createElement("button", "toolbar-button", "Collapse all")
- collapseButton.type = "button"
+ const detailsButton = createElement("button", "toolbar-button", "View details")
+ detailsButton.type = "button"
+ detailsButton.disabled = true
const revealActiveButton = createElement("button", "toolbar-button", "Reveal active")
revealActiveButton.type = "button"
revealActiveButton.disabled = model.activePaths.length === 0
- const simulationButton = createElement("button", "toolbar-button", "Start simulation")
- simulationButton.type = "button"
- simulationButton.disabled = model.roots.length === 0
-
- const renderEmptyInspector = (): void => {
- activeInputForm = undefined
- inspector.replaceChildren()
- const summary = createElement("div", "inspector-empty")
- summary.append(createElement("span", "inspector-empty-kind", "Machine"))
- summary.append(createElement("h2", undefined, visualization.machineId))
- summary.append(metadata([
- ["Source", visualization.source?.file ?? "in memory"],
- ["Export", visualization.source?.exportName ?? "none"],
- ["Initial", visualization.initial.target],
- ["Selection", visualization.initial.selection.kind],
- ["Revision", String(visualization.revision)]
- ]))
- summary.append(createElement("p", undefined, "Select a state to inspect its transitions and activities."))
- inspector.append(summary)
+ const walkthroughButton = createElement("button", "toolbar-button", "Start simulation")
+ walkthroughButton.type = "button"
+ walkthroughButton.disabled = model.roots.length === 0
+
+ const hideInspector = (): void => {
+ inspectorContent.replaceChildren()
+ inspector.hidden = true
+ detailsButton.textContent = "View details"
+ }
+
+ const showInspector = (): void => {
+ inspector.hidden = false
+ detailsButton.textContent = "Hide details"
+ }
+
+ const closeChoicePicker = (): void => {
+ choicePicker.hidden = true
+ choicePickerContent.replaceChildren()
}
const renderInspection = (inspection: StateInspection): void => {
- activeInputForm = undefined
- inspector.replaceChildren()
+ inspectorContent.replaceChildren()
+ showInspector()
const header = createElement("header", "inspector-header")
const breadcrumbs = createElement("nav", "breadcrumbs")
breadcrumbs.setAttribute("aria-label", "State path")
@@ -255,17 +475,20 @@ export const renderVisualizer = (
if (index > 0) breadcrumbs.append(createElement("span", "breadcrumb-separator", "/"))
breadcrumbs.append(stateLink(item.path, item.label, navigateToState))
})
- const eyebrow = createElement("div", "inspector-eyebrow")
- eyebrow.append(badge(inspection.state.type, "state"))
- if (activePaths().includes(inspection.state.path)) eyebrow.append(badge("active", "active"))
- if (inspection.initial) eyebrow.append(badge("initial", "initial"))
- header.append(breadcrumbs, eyebrow, createElement("h2", undefined, inspection.label))
+ const titleRow = createElement("div", "inspector-title-row")
+ const title = createElement("div", "inspector-state-title")
+ title.append(
+ stateStatus(activePaths().includes(inspection.state.path), inspection.initial),
+ createElement("h2", undefined, inspection.label)
+ )
+ titleRow.append(title, badge(inspection.state.type, "state"))
+ header.append(breadcrumbs, titleRow)
header.append(metadata([
["Path", inspection.state.path],
- ["Parent", inspection.state.parent ?? "root"],
- ["Children", String(inspection.state.children.length)],
- ["Initial child", inspection.state.initial ?? "none"],
- ["History", inspection.state.history ?? "none"]
+ ["Parent", inspection.state.parent],
+ ["Children", inspection.state.children.length > 0 ? String(inspection.state.children.length) : null],
+ ["Initial child", inspection.state.initial],
+ ["History", inspection.state.history]
]))
if (inspection.state.description !== null || inspection.state.documentation !== null) {
const annotations = createElement("div", "state-annotations")
@@ -277,401 +500,200 @@ export const renderVisualizer = (
}
header.append(annotations)
}
- inspector.append(header)
+ inspectorContent.append(header)
- const transitions = createElement("section", "inspector-section")
- transitions.append(inspectionSection("Transitions", inspection.outgoing.length))
- if (inspection.outgoing.length === 0) {
- transitions.append(createElement("p", "section-empty", "No transitions leave this state."))
- } else {
- inspection.outgoing.forEach((transition) => transitions.append(renderTransition(transition, navigateToState)))
+ if (inspection.outgoing.length > 0) {
+ const transitions = createElement("section", "inspector-section")
+ transitions.append(inspectionSection("Transitions", inspection.outgoing.length))
+ inspection.outgoing.forEach((transition) =>
+ transitions.append(renderTransition(visualization, transition, navigateToState))
+ )
+ inspectorContent.append(transitions)
}
- inspector.append(transitions)
-
- const incoming = createElement("section", "inspector-section")
- incoming.append(inspectionSection("Entered by", inspection.incoming.length))
- if (inspection.incoming.length === 0) {
- incoming.append(createElement("p", "section-empty", "No transitions target this state."))
- } else {
+ if (inspection.incoming.length > 0) {
+ const incoming = createElement("section", "inspector-section")
+ incoming.append(inspectionSection("Entered by", inspection.incoming.length))
inspection.incoming.forEach((transition) =>
- incoming.append(renderIncomingTransition(transition, navigateToState))
+ incoming.append(renderIncomingTransition(visualization, transition, navigateToState))
)
+ inspectorContent.append(incoming)
}
- inspector.append(incoming)
-
if (inspection.activities.length > 0) {
const activities = createElement("section", "inspector-section")
- activities.append(inspectionSection("Activities", inspection.activities.length))
- inspection.activities.forEach((activity) => activities.append(renderActivity(activity)))
- inspector.append(activities)
+ activities.append(inspectionSection("Invoked", inspection.activities.length))
+ inspection.activities.forEach((activity) => activities.append(renderActivity(activity, navigateToState)))
+ inspectorContent.append(activities)
}
}
- const renderEventInspection = (inspection: EventInspection): void => {
- activeInputForm = undefined
- inspector.replaceChildren()
+ const renderTransitionInspection = (transition: VisualizationTransition): void => {
+ inspectorContent.replaceChildren()
+ showInspector()
const header = createElement("header", "inspector-header")
- const eyebrow = createElement("div", "inspector-eyebrow")
- eyebrow.append(badge("event", "trigger"))
- const candidate = candidateEvents().includes(inspection.event)
- if (candidate) eyebrow.append(badge("enabled", "active"))
- header.append(eyebrow, createElement("h2", undefined, inspection.event))
+ const titleRow = createElement("div", "inspector-title-row")
+ const flags = createElement("div", "card-flags")
+ flags.append(badge(transition.trigger.type, "trigger"))
+ if (transition.reenter) flags.append(badge("reenter"))
+ if (transition.acceptance === "declinable") flags.append(badge("declinable"))
+ titleRow.append(createElement("h2", undefined, triggerLabel(transition)), flags)
+ header.append(titleRow)
header.append(metadata([
- ["Status", candidate ? "enabled" : "not enabled"],
- ["Registrations", String(inspection.transitions.length)]
+ ["Source", transition.source],
+ ["Branches", transition.branches.length > 1 ? String(transition.branches.length) : null],
+ ["Acceptance", transition.acceptance === "declinable" ? transition.acceptance : null],
+ ["Reenter", transition.reenter ? "yes" : null]
]))
- inspector.append(header)
-
- if (simulation !== undefined && candidate) {
- const schema = eventSchemas.get(inspection.event)
- const composer = createElement("section", "inspector-section simulation-composer")
- composer.append(formSection("Event input"))
- if (schema === undefined) {
- composer.append(createElement(
- "p",
- "input-unsupported",
- "No public input schema is available for this event."
- ))
- } else {
- const input = renderInputForm(schema, {
- name: inspection.event,
- fixed: { _tag: inspection.event },
- omit: ["_tag"]
- })
- activeInputForm = input
- const send = createElement("button", "simulation-action", `Send ${inspection.event}`)
- send.type = "submit"
- send.disabled = simulationPending || !input.supported
- input.element.addEventListener("submit", (event) => {
- event.preventDefault()
- const result = input.read()
- const source = visualization.source
- if (!result.ok || source === null || simulation === undefined) return
- void runSimulation({
- _tag: "SendSimulationEvent",
- protocolVersion,
- key: machineKey,
- revision: visualization.revision,
- source,
- step: simulation.step,
- snapshot: simulation.snapshot,
- event: result.value as never
- })
- })
- input.element.append(send)
- composer.append(
- input.element,
- createElement(
- "p",
- "simulation-note",
- "The real planner validates this event and shows every synchronous transition it selects."
- )
- )
- }
- inspector.append(composer)
+ inspectorContent.append(header)
+ if (transition.trigger.type === "event") {
+ const schema = eventSchemas.get(transition.trigger.event)
+ if (schema !== undefined) inspectorContent.append(renderInputContract("Data contract", schema, ["_tag"]))
+ }
+ if (transition.branches.length > 0) {
+ const details = createElement("section", "inspector-section")
+ details.append(inspectionSection("Branches", transition.branches.length))
+ const branches = createElement("div", "inspection-card branch-list")
+ transition.branches.forEach((branch) =>
+ branches.append(renderBranch(visualization, transition.source, branch, navigateToState))
+ )
+ details.append(branches)
+ inspectorContent.append(details)
}
-
- const transitions = createElement("section", "inspector-section")
- transitions.append(inspectionSection("Transitions", inspection.transitions.length))
- inspection.transitions.forEach((transition) =>
- transitions.append(renderTransition(transition, navigateToState, true))
- )
- inspector.append(transitions)
- }
-
- const renderSimulationFailure = (failureDiagnostics: ReadonlyArray): void => {
- activeInputForm = undefined
- inspector.replaceChildren()
- const header = createElement("header", "inspector-header")
- const eyebrow = createElement("div", "inspector-eyebrow")
- eyebrow.append(badge("planner error", "error"))
- header.append(eyebrow, createElement("h2", undefined, "Simulation could not continue"))
- inspector.append(header)
- const list = createElement("section", "inspector-section trace-list")
- failureDiagnostics.forEach((item) => {
- const card = createElement("article", "inspection-card trace-card")
- card.append(createElement("strong", undefined, item.code), createElement("pre", "trace-error", item.message))
- list.append(card)
- })
- inspector.append(list)
}
const renderPathGroup = (label: string, paths: ReadonlyArray): HTMLElement => {
const group = createElement("div", "trace-path-group")
group.append(createElement("span", "trace-label", label))
const values = createElement("div", "trace-paths")
- if (paths.length === 0) values.append(createElement("span", "section-empty", "none"))
paths.forEach((path) => values.append(stateLink(path, path, navigateToState)))
+ if (paths.length === 0) values.append(createElement("span", "section-empty", "none"))
group.append(values)
return group
}
- const renderTraceValues = (label: string, values: ReadonlyArray): HTMLElement => {
- const section = createElement("div", "trace-values")
- section.append(createElement("span", "trace-label", label))
- if (values.length === 0) {
- section.append(createElement("span", "section-empty", "none"))
- } else {
- values.forEach((value) => section.append(jsonBlock(value)))
- }
- return section
- }
-
- const renderSimulationTrace = (frame: SimulationFrame): void => {
- activeInputForm = undefined
- inspector.replaceChildren()
+ const renderWalkthroughTrace = (frame: MachineWalkthrough.Frame): void => {
+ inspectorContent.replaceChildren()
+ showInspector()
const header = createElement("header", "inspector-header trace-header")
const eyebrow = createElement("div", "inspector-eyebrow")
- eyebrow.append(badge("planned", "active"))
- if (frame.done) eyebrow.append(badge("done", "state"))
- const title = frame.trigger._tag === "Initial"
- ? "Initial plan"
- : `Event · ${eventName(frame.trigger.event)}`
+ eyebrow.append(badge("walkthrough", "active"))
+ const title = frame.choice === null
+ ? "Initial configuration"
+ : frame.choice.title ?? triggerName(frame.choice.trigger)
header.append(eyebrow, createElement("h2", undefined, title))
header.append(metadata([
["Step", String(frame.step)],
- ["Microsteps", String(frame.microsteps.length)],
- ["Active before", String(frame.before.activePaths.length)],
- ["Active after", String(frame.after.activePaths.length)]
+ [
+ "Result",
+ frame.changed
+ ? `${activeTopology(frame.before.activePaths)} → ${activeTopology(frame.after.activePaths)}`
+ : "Topology unchanged"
+ ]
]))
- const received = frame.trigger._tag === "Initial" ? frame.trigger.input : frame.trigger.event
- if (received !== undefined) header.append(jsonBlock(received))
- inspector.append(header)
+ inspectorContent.append(header)
const topology = createElement("section", "inspector-section trace-topology")
- topology.append(inspectionSection("Topology change", frame.microsteps.length))
topology.append(
+ inspectionSection("Topology"),
renderPathGroup("Before", frame.before.activePaths),
+ renderPathGroup("Exit", frame.exitPaths),
+ renderPathGroup("Entry", frame.entryPaths),
renderPathGroup("After", frame.after.activePaths)
)
- inspector.append(topology)
-
- const steps = createElement("section", "inspector-section trace-list")
- steps.append(inspectionSection("Microsteps", frame.microsteps.length))
- if (frame.microsteps.length === 0) {
- steps.append(createElement(
- "p",
- "section-empty",
- frame.trigger._tag === "Initial"
- ? "No automatic microsteps were needed."
- : "No transition accepted this event."
- ))
- }
- frame.microsteps.forEach((step) => {
- const card = createElement("article", "inspection-card trace-card")
- const cardHeader = createElement("div", "card-header")
- const cardTitle = createElement("div", "card-title")
- cardTitle.append(
- badge(`#${step.index + 1}`, "count"),
- createElement("strong", undefined, eventName(step.event))
- )
- cardHeader.append(cardTitle, badge(step.changed ? "changed" : "unchanged", step.changed ? "active" : "neutral"))
- card.append(cardHeader)
- if (step.transitions.length > 0) {
- const selected = createElement("div", "trace-transitions")
- selected.append(createElement("span", "trace-label", "Selected transitions"))
- step.transitions.forEach((transition) => {
- const row = createElement("div", "trace-transition")
- row.append(stateLink(transition.source, transition.source, navigateToState))
- row.append(createElement("span", "branch-arrow", "→"))
- const target = transition.resolvedTarget ?? transition.target
- if (target === null) row.append(createElement("span", "branch-target", "No target"))
- else row.append(stateLink(target, target, navigateToState))
- if (transition.branchKey !== null) row.append(badge(transition.branchKey, "condition"))
- if (transition.reenter) row.append(badge("reenter"))
- selected.append(row)
- if (transition.updates.length > 0) selected.append(renderPathGroup("Updates", transition.updates))
- })
- card.append(selected)
- }
- card.append(renderPathGroup("Exit", step.exitPaths), renderPathGroup("Entry", step.entryPaths))
- if (step.raisedEvents.length > 0) card.append(renderTraceValues("Raised", step.raisedEvents))
- if (step.emittedEvents.length > 0) card.append(renderTraceValues("Emitted", step.emittedEvents))
- if (step.commands.length > 0) card.append(renderTraceValues("Commands", step.commands))
- card.append(renderPathGroup("Active after", step.activePaths))
- steps.append(card)
- })
- inspector.append(steps)
-
- if (frame.commands.length > 0 || frame.emittedEvents.length > 0 || frame.output !== undefined) {
- const results = createElement("section", "inspector-section trace-results")
- results.append(inspectionSection("Plan result", frame.commands.length + frame.emittedEvents.length))
- if (frame.commands.length > 0) results.append(renderTraceValues("Planned commands", frame.commands))
- if (frame.emittedEvents.length > 0) results.append(renderTraceValues("Emitted events", frame.emittedEvents))
- if (frame.output !== undefined) results.append(renderTraceValues("Output", [frame.output]))
- inspector.append(results)
- }
- }
+ inspectorContent.append(topology)
- const renderStartSimulation = (): void => {
- activeInputForm = undefined
- inspector.replaceChildren()
- const header = createElement("header", "inspector-header")
- const eyebrow = createElement("div", "inspector-eyebrow")
- eyebrow.append(badge("planner", "active"))
- header.append(eyebrow, createElement("h2", undefined, "Start simulation"))
- inspector.append(header)
- const composer = createElement("section", "inspector-section simulation-composer")
- composer.append(formSection("Machine input"))
- const input = visualization.inputs.machine
- if (input === null) {
- composer.append(createElement("p", "section-empty", "This machine does not declare startup input."))
- inspector.append(composer)
- return
- }
- const form = renderInputForm(input, { name: "Machine input" })
- activeInputForm = form
- const start = createElement("button", "simulation-action", "Start simulation")
- start.type = "submit"
- start.disabled = simulationPending || visualization.source === null || !form.supported
- form.element.addEventListener("submit", (event) => {
- event.preventDefault()
- const source = visualization.source
- if (source === null) return
- const result = form.read()
- if (!result.ok) return
- const request: SimulationRequest = {
- _tag: "StartSimulation",
- protocolVersion,
- key: machineKey,
- revision: visualization.revision,
- source,
- input: result.value as never
- }
- void runSimulation(request)
- })
- form.element.append(start)
- composer.append(
- form.element,
- createElement(
- "p",
- "simulation-note",
- "Initialization and synchronous callbacks run in isolation. Runtime activities and planned commands are not started."
- )
- )
- inspector.append(composer)
- }
-
- async function runSimulation(request: SimulationRequest): Promise {
- if (simulationPending) return
- simulationPending = true
- activeInputForm?.clearIssues()
- activeInputForm?.setPending(true)
- simulationFeedback.textContent = "Planning in an isolated worker…"
- simulationFeedback.dataset.status = "pending"
- updateSimulationUi()
- try {
- const result = await requestSimulation(request)
- if (result._tag === "SimulationFailed") {
- if (result.inputIssues.length > 0 && activeInputForm !== undefined) {
- simulationFeedback.textContent = "Some input fields are invalid"
- simulationFeedback.dataset.status = "error"
- activeInputForm.setIssues(result.inputIssues)
- return
- }
- selectedFrame = undefined
- if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected")
- selectedEvent = undefined
- simulationFeedback.textContent = result.diagnostics[0]?.message ?? "Simulation failed"
- simulationFeedback.dataset.status = "error"
- renderSimulationFailure(result.diagnostics)
- return
- }
- simulation = result
- activeInputForm = undefined
- if (selectedPath !== undefined) {
- nodes.get(selectedPath)?.classList.remove("is-selected")
- rows.get(selectedPath)?.setAttribute("aria-selected", "false")
- }
- if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected")
- selectedPath = undefined
- selectedEvent = undefined
- selectedFrame = result.frame
- clearRelations()
- result.frame.microsteps.forEach((step) => {
- step.transitions.forEach((transition) => {
- relatedPaths.add(transition.source)
- nodes.get(transition.source)?.classList.add("is-related-source")
- })
- step.entryPaths.forEach((path) => {
- relatedPaths.add(path)
- nodes.get(path)?.classList.add("is-related-target")
- })
- step.transitions.flatMap((transition) => transition.updates).forEach((path) => {
- relatedPaths.add(path)
- nodes.get(path)?.classList.add("is-related-update")
- })
- })
- clearButton.disabled = false
- const before = activeTopology(result.frame.before.activePaths)
- const after = activeTopology(result.frame.after.activePaths)
- simulationFeedback.textContent = result.frame.trigger._tag === "Initial"
- ? `Started in ${after}`
- : result.frame.microsteps.length === 0
- ? `${eventName(result.frame.trigger.event)} was not accepted · remained in ${after}`
- : before === after
- ? `${eventName(result.frame.trigger.event)} handled · remained in ${after}`
- : `${before} → ${after} · ${result.frame.microsteps.length} microstep${
- result.frame.microsteps.length === 1 ? "" : "s"
- }`
- simulationFeedback.dataset.status = "applied"
- } catch (cause) {
- selectedFrame = undefined
- if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected")
- selectedEvent = undefined
- const failure: Diagnostic = {
- severity: "error",
- code: "simulation-request-failed",
- message: cause instanceof Error ? cause.message : String(cause),
- location: visualization.source === null
- ? null
- : { file: visualization.source.file, line: null, column: null },
- statePath: null
+ if (frame.choice === null) {
+ if (visualization.inputs.machine !== null) {
+ inspectorContent.append(renderInputContract("Machine input contract", visualization.inputs.machine))
}
- simulationFeedback.textContent = failure.message
- simulationFeedback.dataset.status = "error"
- renderSimulationFailure([failure])
- } finally {
- simulationPending = false
- activeInputForm?.setPending(false)
- updateSimulationUi()
+ return
}
+
+ const choice = frame.choice
+ const transition = transitionsById.get(choice.transitionId)
+ const branch = transition?.branches.find(({ id }) => id === choice.branchId)
+ const api = branch === undefined ? undefined : branchTargetApi(visualization, choice.source, branch)
+ const selection = createElement("section", "inspector-section")
+ selection.append(inspectionSection("Selected branch"))
+ const card = createElement("article", "inspection-card trace-card")
+ const cardHeader = createElement("div", "card-header")
+ const cardTitle = createElement("div", "card-title")
+ cardTitle.append(createElement("strong", undefined, triggerName(choice.trigger)))
+ const flags = createElement("div", "card-flags")
+ choice.decisions.forEach((decision) => flags.append(badge(decisionLabel(decision), "condition")))
+ cardHeader.append(cardTitle, flags)
+ card.append(cardHeader)
+ card.append(metadata([
+ ["Title", choice.title],
+ ["Source", stateLink(choice.source, choice.source, navigateToState)],
+ ["Target", choice.target === null ? "No target" : stateLink(choice.target, choice.target, navigateToState)],
+ ["API", api]
+ ]))
+ if (choice.updates.length > 0) card.append(renderPathGroup("Updates", choice.updates))
+ selection.append(card)
+ inspectorContent.append(selection)
+ if (choice.input !== null) inspectorContent.append(renderInputContract("Data contract", choice.input, ["_tag"]))
}
const clearRelations = (): void => {
- for (const path of relatedPaths) {
- nodes.get(path)?.classList.remove("is-related-source", "is-related-target", "is-related-update")
+ relatedFrom.clear()
+ relatedTo.clear()
+ incomingTransitions.clear()
+ outgoingTransitions.clear()
+ }
+
+ const markFrame = (frame: MachineWalkthrough.Frame): void => {
+ clearRelations()
+ if (frame.choice === null) return
+ relatedFrom.add(frame.choice.source)
+ outgoingTransitions.add(frame.choice.transitionId)
+ if (frame.choice.target !== null) relatedTo.add(frame.choice.target)
+ }
+
+ const chartPresentation = (): ChartPresentation => {
+ const choices = availableChoices()
+ const usable = choices.filter(({ unavailableReason }) => unavailableReason === null)
+ return {
+ simulationMode: walkthrough !== undefined,
+ activePaths: activePaths(),
+ selectedState: selectedPath ?? null,
+ selectedTransition: selectedTransition ?? null,
+ fromPaths: [...relatedFrom],
+ toPaths: [...relatedTo],
+ incomingTransitionIds: [...incomingTransitions],
+ outgoingTransitionIds: [...outgoingTransitions],
+ availableBranchIds: usable.map(({ branchId }) => branchId),
+ unavailableBranchIds: choices.filter(({ unavailableReason }) => unavailableReason !== null).map((
+ { branchId }
+ ) => branchId)
}
- relatedPaths.clear()
}
+ const updateChartPresentation = (): void => chartView?.update(chartPresentation())
+
const clearSelection = (): void => {
- if (selectedPath !== undefined) {
- nodes.get(selectedPath)?.classList.remove("is-selected")
- rows.get(selectedPath)?.setAttribute("aria-selected", "false")
+ if (walkthrough !== undefined) {
+ closeChoicePicker()
+ return
}
- if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected")
clearRelations()
selectedPath = undefined
- selectedEvent = undefined
+ selectedTransition = undefined
selectedFrame = undefined
clearButton.disabled = true
- renderEmptyInspector()
+ detailsButton.disabled = true
+ hideInspector()
+ updateChartPresentation()
}
- const markTransitions = (transitions: ReadonlyArray): void => {
+ const markTransitions = (
+ transitions: ReadonlyArray,
+ includeSource = true
+ ): void => {
for (const transition of transitions) {
- relatedPaths.add(transition.source)
- nodes.get(transition.source)?.classList.add("is-related-source")
+ if (includeSource) relatedFrom.add(transition.source)
+ outgoingTransitions.add(transition.id)
for (const branch of transition.branches) {
- if (branch.target !== null) {
- relatedPaths.add(branch.target)
- nodes.get(branch.target)?.classList.add("is-related-target")
- }
- for (const update of branch.updates) {
- relatedPaths.add(update)
- nodes.get(update)?.classList.add("is-related-update")
- }
+ if (branch.target !== null) relatedTo.add(branch.target)
}
}
}
@@ -679,160 +701,269 @@ export const renderVisualizer = (
const markRelatedStates = (inspection: StateInspection): void => {
clearRelations()
for (const incoming of inspection.incoming) {
- relatedPaths.add(incoming.transition.source)
- nodes.get(incoming.transition.source)?.classList.add("is-related-source")
- }
- markTransitions(inspection.outgoing)
- }
-
- const expandAncestors = (inspection: StateInspection): void => {
- for (const ancestor of inspection.breadcrumbs.slice(0, -1)) {
- const row = rows.get(ancestor.path)
- const children = nodes.get(ancestor.path)?.querySelector(":scope > .topology-children")
- if (row === undefined || children === null || children === undefined) continue
- row.setAttribute("aria-expanded", "true")
- children.hidden = false
- const disclosure = row.querySelector(".state-disclosure")
- if (disclosure !== null) disclosure.textContent = "▾"
+ relatedFrom.add(incoming.transition.source)
+ incomingTransitions.add(incoming.transition.id)
}
+ markTransitions(inspection.outgoing, false)
}
const selectState = (path: string, focus: boolean): void => {
+ if (walkthrough !== undefined) return
const inspection = model.inspectState(path)
if (inspection === undefined) return
- expandAncestors(inspection)
- if (selectedPath !== undefined) {
- nodes.get(selectedPath)?.classList.remove("is-selected")
- rows.get(selectedPath)?.setAttribute("aria-selected", "false")
- }
- if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected")
selectedPath = path
- selectedEvent = undefined
+ selectedTransition = undefined
selectedFrame = undefined
- nodes.get(path)?.classList.add("is-selected")
- rows.get(path)?.setAttribute("aria-selected", "true")
markRelatedStates(inspection)
clearButton.disabled = false
- renderInspection(inspection)
- if (focus) {
- rows.get(path)?.focus({ preventScroll: true })
- rows.get(path)?.scrollIntoView({ block: "nearest" })
- }
+ detailsButton.disabled = false
+ if (!inspector.hidden) renderInspection(inspection)
+ updateChartPresentation()
+ if (focus) chartView?.focusState(path)
}
function navigateToState(path: string): void {
selectState(path, true)
}
- const selectEvent = (event: string): void => {
- const inspection = model.inspectEvent(event)
- if (selectedPath !== undefined) {
- nodes.get(selectedPath)?.classList.remove("is-selected")
- rows.get(selectedPath)?.setAttribute("aria-selected", "false")
- }
- if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected")
+ const selectTransition = (transitionId: string): void => {
+ if (walkthrough !== undefined) return
+ const transition = transitionsById.get(transitionId)
+ if (transition === undefined) return
selectedPath = undefined
- selectedEvent = event
+ selectedTransition = transitionId
selectedFrame = undefined
- eventButtons.get(event)?.classList.add("is-selected")
clearRelations()
- markTransitions(inspection.transitions)
+ markTransitions([transition])
clearButton.disabled = false
- const schema = eventSchemas.get(event)
- if (simulation !== undefined && candidateEvents().includes(event) && schema !== undefined) {
- const input = renderInputForm(schema, { name: event, fixed: { _tag: event }, omit: ["_tag"] })
- if (!input.hasFields && input.supported) {
- const result = input.read()
- const source = visualization.source
- if (result.ok && source !== null) {
- void runSimulation({
- _tag: "SendSimulationEvent",
- protocolVersion,
- key: machineKey,
- revision: visualization.revision,
- source,
- step: simulation.step,
- snapshot: simulation.snapshot,
- event: result.value as never
- })
- return
- }
- }
+ detailsButton.disabled = false
+ if (!inspector.hidden) renderTransitionInspection(transition)
+ updateChartPresentation()
+ }
+
+ const openStateDetails = (path: string): void => {
+ if (walkthrough !== undefined) return
+ selectState(path, false)
+ const inspection = model.inspectState(path)
+ if (inspection !== undefined) renderInspection(inspection)
+ }
+
+ const openTransitionDetails = (transitionId: string): void => {
+ if (walkthrough !== undefined) return
+ selectTransition(transitionId)
+ const transition = transitionsById.get(transitionId)
+ if (transition !== undefined) renderTransitionInspection(transition)
+ }
+
+ const walkthroughDock = createElement("section", "walkthrough-dock")
+ walkthroughDock.hidden = true
+ const walkthroughHeading = createElement("div", "walkthrough-heading")
+ const walkthroughIdentity = createElement("div", "walkthrough-identity")
+ const walkthroughHint = createElement("span", "walkthrough-hint")
+ const walkthroughStatus = createElement("span", "walkthrough-status")
+ const walkthroughTimeline = createElement("div", "walkthrough-timeline")
+ walkthroughTimeline.setAttribute("aria-label", "Simulation timeline")
+ walkthroughIdentity.append(createElement("strong", undefined, "Simulation"), walkthroughHint)
+ walkthroughHeading.append(walkthroughIdentity, walkthroughStatus)
+ walkthroughDock.append(walkthroughHeading, walkthroughTimeline)
+
+ const selectFrame = (frame: MachineWalkthrough.Frame): void => {
+ selectedPath = undefined
+ selectedTransition = undefined
+ selectedFrame = frame
+ markFrame(frame)
+ clearButton.disabled = false
+ detailsButton.disabled = false
+ }
+
+ const choiceRoute = (choice: MachineWalkthrough.Choice): string =>
+ `${choice.source} → ${choice.target ?? "no target"}`
+
+ const takeChoice = (choice: MachineWalkthrough.Choice): void => {
+ if (walkthrough === undefined || choice.unavailableReason !== null) return
+ const result = MachineWalkthrough.take(walkthrough, choice.id)
+ if (Result.isFailure(result)) return
+ walkthrough = result.success
+ selectFrame(MachineWalkthrough.current(walkthrough))
+ closeChoicePicker()
+ updateWalkthroughUi()
+ chartView?.revealStates(activePaths())
+ }
+
+ const renderPickerChoice = (choice: MachineWalkthrough.Choice): HTMLButtonElement => {
+ const button = createElement(
+ "button",
+ `transition-picker-choice${choice.unavailableReason === null ? "" : " is-unavailable"}`
+ )
+ button.type = "button"
+ button.disabled = choice.unavailableReason !== null
+ const main = createElement("span", "transition-picker-choice-main")
+ main.append(createElement("strong", undefined, triggerName(choice.trigger)))
+ if (choice.title !== null) main.append(createElement("span", "transition-picker-choice-title", choice.title))
+ button.append(main, createElement("span", "transition-picker-choice-route", choiceRoute(choice)))
+ const contract = contractSummary(choice.input)
+ if (contract !== null) button.append(createElement("span", "transition-picker-choice-contract", contract))
+ if (choice.decisions.length > 0) {
+ button.append(createElement(
+ "span",
+ "transition-picker-choice-decision",
+ choice.decisions.map(decisionLabel).join(" · ")
+ ))
}
- renderEventInspection(inspection)
- }
-
- const setExpanded = (row: HTMLElement, expanded: boolean): void => {
- const node = row.closest(".topology-node")
- const children = node?.querySelector(":scope > .topology-children")
- if (children === null || children === undefined) return
- row.setAttribute("aria-expanded", String(expanded))
- children.hidden = !expanded
- const disclosure = row.querySelector