@statorjs/stator 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +104 -0
  3. package/package.json +99 -0
  4. package/src/build/build.ts +244 -0
  5. package/src/build/head.ts +43 -0
  6. package/src/build/index.ts +10 -0
  7. package/src/build/sync.ts +65 -0
  8. package/src/client/bind.ts +47 -0
  9. package/src/client/client-id.ts +10 -0
  10. package/src/client/dispatch.ts +62 -0
  11. package/src/client/element.ts +156 -0
  12. package/src/client/index.ts +13 -0
  13. package/src/client/inspector.ts +237 -0
  14. package/src/client/machine.ts +39 -0
  15. package/src/client/runtime.ts +246 -0
  16. package/src/client/use.ts +119 -0
  17. package/src/compiler/client-emit.ts +180 -0
  18. package/src/compiler/client-script.ts +256 -0
  19. package/src/compiler/compile.ts +459 -0
  20. package/src/compiler/diagnostics.ts +65 -0
  21. package/src/compiler/dts.ts +87 -0
  22. package/src/compiler/hash.ts +8 -0
  23. package/src/compiler/index.ts +48 -0
  24. package/src/compiler/lower.ts +0 -0
  25. package/src/compiler/regions.ts +79 -0
  26. package/src/compiler/split.ts +168 -0
  27. package/src/compiler/styles.ts +200 -0
  28. package/src/compiler/virtual-code.ts +184 -0
  29. package/src/components/index.ts +2 -0
  30. package/src/components/json-ld.ts +85 -0
  31. package/src/engine/actor.ts +248 -0
  32. package/src/engine/define-machine.ts +113 -0
  33. package/src/engine/index.ts +37 -0
  34. package/src/engine/types.ts +236 -0
  35. package/src/server/api-route.ts +149 -0
  36. package/src/server/app-dispatch.ts +52 -0
  37. package/src/server/app-store.ts +35 -0
  38. package/src/server/cached-store.ts +117 -0
  39. package/src/server/create-app.ts +83 -0
  40. package/src/server/define-machine.ts +10 -0
  41. package/src/server/dev.ts +255 -0
  42. package/src/server/discovery.ts +111 -0
  43. package/src/server/dispatch-context.ts +39 -0
  44. package/src/server/effects.ts +120 -0
  45. package/src/server/http.ts +475 -0
  46. package/src/server/index.ts +101 -0
  47. package/src/server/instance-proxy.ts +95 -0
  48. package/src/server/logger.ts +52 -0
  49. package/src/server/machine-store.ts +355 -0
  50. package/src/server/reads-helpers.ts +40 -0
  51. package/src/server/recompute.ts +287 -0
  52. package/src/server/redis-store.ts +116 -0
  53. package/src/server/render-context.ts +228 -0
  54. package/src/server/render.ts +111 -0
  55. package/src/server/route-discovery.ts +226 -0
  56. package/src/server/route-request.ts +36 -0
  57. package/src/server/routing.ts +175 -0
  58. package/src/server/session-lock.ts +33 -0
  59. package/src/server/session-runtime.ts +242 -0
  60. package/src/server/session.ts +29 -0
  61. package/src/server/sse.ts +175 -0
  62. package/src/server/store.ts +95 -0
  63. package/src/stator-modules.d.ts +12 -0
  64. package/src/template/client-shell.ts +65 -0
  65. package/src/template/conditional.ts +166 -0
  66. package/src/template/directives/core.ts +58 -0
  67. package/src/template/directives/list-attr.ts +183 -0
  68. package/src/template/directives/on.ts +22 -0
  69. package/src/template/each.ts +295 -0
  70. package/src/template/html.ts +180 -0
  71. package/src/template/index.ts +29 -0
  72. package/src/template/parser.ts +341 -0
  73. package/src/template/read.ts +50 -0
  74. package/src/template/types.ts +33 -0
  75. package/src/vite/index.ts +6 -0
  76. package/src/vite/plugin.ts +151 -0
  77. package/src/vite/stub.ts +79 -0
  78. package/src/wire/apply.ts +103 -0
  79. package/src/wire/index.ts +67 -0
@@ -0,0 +1,62 @@
1
+ import type { AnyMachineDef, EventOf } from '../engine/index.ts'
2
+ import { applyDirectives, applyPatches } from '../wire/apply.ts'
3
+ import type { WireEnvelope } from '../wire/index.ts'
4
+ import { clientId } from './client-id.ts'
5
+
6
+ export interface DispatchResult {
7
+ /** The POST reached the server and returned 200. */
8
+ ok: boolean
9
+ /** The event committed a transition. `ok && !committed` means a guard
10
+ * dropped it — the UI should not pretend something happened. */
11
+ committed: boolean
12
+ /** Patches applied to THIS page (a committed event may patch zero slots
13
+ * here if the touched machines aren't bound on the current route). */
14
+ patchCount: number
15
+ }
16
+
17
+ /**
18
+ * Commit an event to a SERVER machine over the existing `/__events` wire — the
19
+ * one visible boundary crossing from a client island. Addressed by the imported
20
+ * machine def (not a magic string); the event is typed against that machine's
21
+ * event union. Applies the returned patches and reports what actually
22
+ * happened: transport success, commit, and patch count are three different
23
+ * facts, and buttons that say "done" should be looking at `committed`.
24
+ *
25
+ * This is the client half of [[typed-events-and-machine-mediated-dispatch]].
26
+ * The compiler resolves a server-machine identity import into a stub carrying
27
+ * `{ name }`; this helper reads the name and posts.
28
+ */
29
+ export async function dispatch<D extends AnyMachineDef>(
30
+ machine: D,
31
+ event: EventOf<D>,
32
+ ): Promise<DispatchResult> {
33
+ let res: Response
34
+ try {
35
+ res = await fetch('/__events', {
36
+ method: 'POST',
37
+ headers: {
38
+ 'Content-Type': 'application/json',
39
+ Accept: 'application/json',
40
+ 'X-Stator-Route': `GET ${location.pathname}${location.search}`,
41
+ 'X-Stator-Client': clientId,
42
+ },
43
+ credentials: 'same-origin',
44
+ body: JSON.stringify({ machine: machine.name, event }),
45
+ })
46
+ } catch (err) {
47
+ console.error('stator: dispatch network error', err)
48
+ return { ok: false, committed: false, patchCount: 0 }
49
+ }
50
+ if (!res.ok) {
51
+ console.error('stator: dispatch failed', res.status)
52
+ return { ok: false, committed: false, patchCount: 0 }
53
+ }
54
+ const data = (await res.json()) as WireEnvelope
55
+ applyPatches(data.patches ?? [])
56
+ applyDirectives(data.directives ?? [])
57
+ return {
58
+ ok: true,
59
+ committed: data.committed ?? true,
60
+ patchCount: data.patches?.length ?? 0,
61
+ }
62
+ }
@@ -0,0 +1,156 @@
1
+ import { type CollectedActor, popCollector, pushCollector } from './use.ts'
2
+
3
+ const ACTORS = Symbol('stator.actors')
4
+ const DISPOSERS = Symbol('stator.disposers')
5
+
6
+ /** camelCase author name → kebab DOM attribute (`unitPrice` → `unit-price`). */
7
+ function camelToKebab(name: string): string {
8
+ return name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)
9
+ }
10
+
11
+ /** kebab DOM attribute → camelCase author name (`unit-price` → `unitPrice`). */
12
+ function kebabToCamel(name: string): string {
13
+ return name.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase())
14
+ }
15
+
16
+ /**
17
+ * Base class for a client island. The author writes
18
+ * `export class QuantityStepper extends StatorElement { ... }`; the compiler
19
+ * registers it (via `defineElement`) against the kebab-case tag.
20
+ *
21
+ * Lifecycle: on connect, start the actors created by `use()` during
22
+ * construction and run the element's `setup()` (where the compiler emits the
23
+ * `bind()` / `on:` wiring); on disconnect, dispose bindings and stop actors.
24
+ * Element lifetime owns actor lifetime — full-page navigation resets client
25
+ * state, which is the intended default for ephemeral UI.
26
+ */
27
+ export class StatorElement extends HTMLElement {
28
+ /** @internal — actors (+ deferred seed thunks) collected during construction
29
+ * (set by `defineElement`). */
30
+ [ACTORS]: CollectedActor[] = [];
31
+ /** @internal — binding disposers registered during setup. */
32
+ [DISPOSERS]: Array<() => void> = []
33
+
34
+ /** Named handles to `ref:`-marked elements within this island, resolved
35
+ * lazily by `data-ref`. `this.refs.btn` → the nearest `[data-ref="btn"]`. */
36
+ get refs(): Record<string, HTMLElement> {
37
+ const self = this
38
+ return new Proxy(
39
+ {},
40
+ {
41
+ get(_t, name: string) {
42
+ return self.querySelector(`[data-ref="${name}"]`) as HTMLElement | null
43
+ },
44
+ },
45
+ ) as Record<string, HTMLElement>
46
+ }
47
+
48
+ /** Read + coerce a single attribute by its literal name (raw escape hatch for
49
+ * dynamic / undeclared attributes). */
50
+ attr<T = string>(name: string, coerce?: (raw: string) => T): T | undefined {
51
+ const raw = this.getAttribute(name)
52
+ if (raw === null) return undefined
53
+ return coerce ? coerce(raw) : (raw as unknown as T)
54
+ }
55
+
56
+ /** Typed, coerced view of the element's declared attributes.
57
+ *
58
+ * `static attrs = { unitPrice: Number, selected: Boolean }` declares the
59
+ * surface; `this.attrs.unitPrice` reads the kebab DOM attribute (`unit-price`)
60
+ * and coerces it. `Number`/parse coercers run on the string; `Boolean` is
61
+ * treated as a presence flag (attribute present → true). camelCase author name
62
+ * ↔ kebab DOM attribute is framework-managed. */
63
+ get attrs(): Record<string, unknown> {
64
+ const decl = (this.constructor as { attrs?: Record<string, (raw: string) => unknown> }).attrs
65
+ const self = this
66
+ return new Proxy(
67
+ {},
68
+ {
69
+ get(_t, prop: string) {
70
+ const coerce = decl?.[prop]
71
+ const attrName = camelToKebab(prop)
72
+ if (coerce === (Boolean as unknown)) {
73
+ return self.hasAttribute(attrName)
74
+ }
75
+ const raw = self.getAttribute(attrName)
76
+ if (raw === null) return undefined
77
+ return coerce ? coerce(raw) : raw
78
+ },
79
+ },
80
+ )
81
+ }
82
+
83
+ /** Register a disposer to run on disconnect (used by generated bind wiring). */
84
+ protected track(dispose: () => void): void {
85
+ this[DISPOSERS].push(dispose)
86
+ }
87
+
88
+ /** Overridden by the compiler-generated subclass: wires bindings + listeners.
89
+ * Runs after actors start, on connect. */
90
+ protected setup(): void {}
91
+
92
+ connectedCallback(): void {
93
+ // Apply deferred seeds now that attributes are available, then start.
94
+ for (const { actor, seedThunk } of this[ACTORS]) {
95
+ if (seedThunk) actor.seed(seedThunk())
96
+ actor.start()
97
+ }
98
+ this.setup()
99
+ }
100
+
101
+ disconnectedCallback(): void {
102
+ for (const dispose of this[DISPOSERS]) dispose()
103
+ this[DISPOSERS] = []
104
+ for (const { actor } of this[ACTORS]) actor.stop()
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Register a client-island class against its custom-element tag. Brackets
110
+ * construction so `use()` calls (which run as field initializers during
111
+ * construction) are collected onto the instance for lifecycle management.
112
+ *
113
+ * The compiler emits `defineElement(QuantityStepper, 'quantity-stepper')`.
114
+ */
115
+ export function defineElement(UserClass: typeof StatorElement, tag: string): void {
116
+ const decl = (UserClass as unknown as { attrs?: Record<string, (raw: string) => unknown> }).attrs
117
+ const observed = decl ? Object.keys(decl).map(camelToKebab) : []
118
+
119
+ const Wrapped = class extends UserClass {
120
+ constructor() {
121
+ const bucket = pushCollector()
122
+ try {
123
+ super()
124
+ } finally {
125
+ popCollector()
126
+ }
127
+ this[ACTORS] = bucket
128
+ }
129
+
130
+ /** Declared attrs are observed; a change invokes the author's
131
+ * `${key}Changed(next)` method (coerced per the attrs declaration).
132
+ * This is how live server state flows INTO an island: bind an attr on
133
+ * the island's tag to a read(), implement `${key}Changed`. */
134
+ static get observedAttributes(): string[] {
135
+ return observed
136
+ }
137
+
138
+ attributeChangedCallback(name: string, oldRaw: string | null, newRaw: string | null): void {
139
+ if (oldRaw === newRaw || !this.isConnected) return
140
+ const key = kebabToCamel(name)
141
+ const handler = (this as unknown as Record<string, unknown>)[`${key}Changed`]
142
+ if (typeof handler !== 'function') return
143
+ const coerce = decl?.[key]
144
+ const value =
145
+ coerce === (Boolean as unknown)
146
+ ? newRaw !== null
147
+ : newRaw === null
148
+ ? undefined
149
+ : coerce
150
+ ? coerce(newRaw)
151
+ : newRaw
152
+ ;(handler as (v: unknown) => void).call(this, value)
153
+ }
154
+ }
155
+ if (!customElements.get(tag)) customElements.define(tag, Wrapped)
156
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Client authoring API for `.stator` `<script>` islands — `@statorjs/stator/client`.
3
+ * Browser-safe (no server imports). The compiler auto-injects what a generated
4
+ * island needs; these are also the symbols an author references directly.
5
+ */
6
+
7
+ export { bind, effect } from './bind.ts'
8
+ export { dispatch } from './dispatch.ts'
9
+ export { defineElement, StatorElement } from './element.ts'
10
+ export type { MachineConfig } from './machine.ts'
11
+ export { machine } from './machine.ts'
12
+ export type { ClientInstance } from './use.ts'
13
+ export { use } from './use.ts'
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Stator dev inspector — a framework-owned, dev-only observability toolbar.
3
+ *
4
+ * Auto-injected by `createDevApp` (never in production). It subscribes to the
5
+ * public `stator:*` CustomEvent contract the client runtime dispatches on
6
+ * `window` and renders a bottom drawer with one row per outgoing event (↑) and
7
+ * per incoming patch batch (↓), plus a brief flash on each patched element.
8
+ *
9
+ * It depends on nothing but that event contract — the same surface any external
10
+ * devtool would use. Self-contained: it injects its own styles.
11
+ */
12
+
13
+ const STYLES = `
14
+ .stator-inspector { position: fixed; bottom: 0; left: 0; right: 0; z-index: 2147483000; pointer-events: none; }
15
+ .stator-inspector > * { pointer-events: auto; }
16
+ .stator-inspector-drawer[hidden], .stator-inspector-toggle[hidden] { display: none; }
17
+ .stator-inspector-toggle {
18
+ position: fixed; bottom: 1rem; right: 1rem; background: #1a1a1a; color: #d0d0d0;
19
+ border: 1px solid #2a2a2a; padding: 0.45rem 0.85rem; border-radius: 999px;
20
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.82rem;
21
+ cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.18); display: inline-flex;
22
+ align-items: center; gap: 0.4rem;
23
+ }
24
+ .stator-inspector-toggle:hover { background: #232323; color: #f0f0f0; }
25
+ .stator-inspector-drawer {
26
+ background: #141414; color: #d0d0d0; border-top: 1px solid #2a2a2a; max-height: 240px;
27
+ display: flex; flex-direction: column; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
28
+ font-size: 0.78rem; box-shadow: 0 -2px 12px rgba(0,0,0,0.25);
29
+ }
30
+ .stator-inspector-header {
31
+ display: flex; align-items: center; gap: 1rem; padding: 0.5rem 0.9rem;
32
+ background: #1a1a1a; border-bottom: 1px solid #262626; flex-shrink: 0;
33
+ }
34
+ .stator-inspector-title { display: inline-flex; align-items: center; gap: 0.5rem; color: #f5f5f5; font-weight: 600; letter-spacing: 0.02em; }
35
+ .stator-inspector-dot { width: 8px; height: 8px; border-radius: 50%; background: #6a9955; box-shadow: 0 0 0 2px rgba(106,153,85,0.18); }
36
+ .stator-inspector-legend { display: inline-flex; gap: 0.75rem; color: #888; }
37
+ .stator-inspector-key--up { color: #dcdcaa; }
38
+ .stator-inspector-key--down { color: #9cdcfe; }
39
+ .stator-inspector-header button {
40
+ background: transparent; border: 1px solid #333; color: #aaa; padding: 0.15rem 0.55rem;
41
+ font-family: inherit; font-size: 0.75rem; border-radius: 4px; cursor: pointer;
42
+ }
43
+ .stator-inspector-header button:hover { background: #232323; color: #f0f0f0; }
44
+ .stator-inspector-close { margin-left: auto; font-size: 1rem !important; line-height: 1; padding: 0.05rem 0.5rem !important; }
45
+ .stator-inspector-body { flex: 1; overflow-y: auto; padding: 0; }
46
+ .stator-inspector-empty { margin: 0; padding: 1rem 0.9rem; color: #6a6a6a; font-style: italic; }
47
+ .stator-inspector-row { border-bottom: 1px solid #1f1f1f; }
48
+ .stator-inspector-row:last-child { border-bottom: none; }
49
+ .stator-inspector-summary {
50
+ display: grid; grid-template-columns: 90px 16px 130px 1fr auto; align-items: baseline;
51
+ gap: 0.75rem; padding: 0.35rem 0.9rem; cursor: pointer; user-select: none;
52
+ }
53
+ .stator-inspector-summary:hover { background: #1c1c1c; }
54
+ .stator-inspector-time { color: #6a6a6a; font-variant-numeric: tabular-nums; }
55
+ .stator-inspector-arrow { text-align: center; font-weight: 700; }
56
+ .stator-inspector-row--up .stator-inspector-arrow { color: #dcdcaa; }
57
+ .stator-inspector-row--down .stator-inspector-arrow { color: #9cdcfe; }
58
+ .stator-inspector-machine { color: #c586c0; font-weight: 500; }
59
+ .stator-inspector-row--down .stator-inspector-machine { color: #9cdcfe; }
60
+ .stator-inspector-event-type { color: #4ec9b0; font-weight: 500; }
61
+ .stator-inspector-params { color: #888; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
62
+ .stator-inspector-detail {
63
+ margin: 0; padding: 0.5rem 0.9rem 0.75rem 2.5rem; background: #0e0e0e; color: #cfcfcf;
64
+ font-size: 0.74rem; white-space: pre-wrap; word-break: break-word; border-top: 1px dashed #2a2a2a;
65
+ }
66
+ .stator-flash { outline-style: solid; outline-offset: 3px; animation: stator-flash 1200ms ease-out forwards; border-radius: 2px; }
67
+ @keyframes stator-flash {
68
+ 0% { outline-width: 3px; outline-color: var(--flash-color, dodgerblue); background-color: var(--flash-bg, rgba(30,144,255,0.16)); }
69
+ 30% { outline-width: 3px; outline-color: var(--flash-color, dodgerblue); background-color: var(--flash-bg, rgba(30,144,255,0.16)); }
70
+ 100% { outline-width: 0; outline-color: transparent; background-color: transparent; }
71
+ }
72
+ .stator-flash--text { --flash-color: #3b82f6; --flash-bg: rgba(59,130,246,0.12); }
73
+ .stator-flash--attr { --flash-color: #a855f7; --flash-bg: rgba(168,85,247,0.12); }
74
+ .stator-flash--html { --flash-color: #14b8a6; --flash-bg: rgba(20,184,166,0.10); }
75
+ @media (max-height: 600px) { .stator-inspector-drawer { max-height: 50vh; } }
76
+ `
77
+
78
+ const STORAGE_KEY = 'stator:inspector:open'
79
+ const MAX_ENTRIES = 40
80
+ const FLASH_MS = 1200
81
+
82
+ const w = window as unknown as { __statorInspectorMounted?: boolean }
83
+
84
+ function escapeHtml(s: unknown): string {
85
+ if (s == null) return ''
86
+ return String(s)
87
+ .replace(/&/g, '&amp;')
88
+ .replace(/</g, '&lt;')
89
+ .replace(/>/g, '&gt;')
90
+ .replace(/"/g, '&quot;')
91
+ }
92
+
93
+ function fmtTime(t: number): string {
94
+ const d = new Date(t)
95
+ const p = (n: number, w = 2) => String(n).padStart(w, '0')
96
+ return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`
97
+ }
98
+
99
+ function summarizePatches(patches: Array<{ op: string }>): string {
100
+ const counts: Record<string, number> = {}
101
+ for (const p of patches) counts[p.op] = (counts[p.op] || 0) + 1
102
+ return Object.keys(counts)
103
+ .sort()
104
+ .map((op) => `${op}·${counts[op]}`)
105
+ .join(' ')
106
+ }
107
+
108
+ function formatEventParams(event: Record<string, unknown>): string {
109
+ const { type: _type, ...rest } = event
110
+ const keys = Object.keys(rest)
111
+ if (keys.length === 0) return ''
112
+ return keys.map((k) => `${k}=${JSON.stringify(rest[k])}`).join(' ')
113
+ }
114
+
115
+ function mount(): void {
116
+ const style = document.createElement('style')
117
+ style.textContent = STYLES
118
+ document.head.appendChild(style)
119
+
120
+ const root = document.createElement('div')
121
+ root.className = 'stator-inspector'
122
+ root.innerHTML = `
123
+ <button class="stator-inspector-toggle" type="button" aria-label="Show stator inspector">
124
+ <span aria-hidden="true">{ }</span> Inspect
125
+ </button>
126
+ <section class="stator-inspector-drawer" aria-label="Stator inspector" hidden>
127
+ <header class="stator-inspector-header">
128
+ <span class="stator-inspector-title"><span class="stator-inspector-dot" aria-hidden="true"></span> Stator inspector</span>
129
+ <span class="stator-inspector-legend">
130
+ <span class="stator-inspector-key stator-inspector-key--up">↑ event</span>
131
+ <span class="stator-inspector-key stator-inspector-key--down">↓ patches</span>
132
+ </span>
133
+ <button class="stator-inspector-clear" type="button" title="Clear log">clear</button>
134
+ <button class="stator-inspector-close" type="button" aria-label="Close inspector">×</button>
135
+ </header>
136
+ <div class="stator-inspector-body">
137
+ <p class="stator-inspector-empty">Interact with the page to see events and patches.</p>
138
+ </div>
139
+ </section>`
140
+ document.body.appendChild(root)
141
+
142
+ const q = (sel: string) => root.querySelector(sel) as HTMLElement
143
+ const drawer = q('.stator-inspector-drawer')
144
+ const body = q('.stator-inspector-body')
145
+ const toggle = q('.stator-inspector-toggle')
146
+
147
+ const setOpen = (open: boolean) => {
148
+ try {
149
+ localStorage.setItem(STORAGE_KEY, open ? 'true' : 'false')
150
+ } catch {}
151
+ ;(drawer as HTMLElement).hidden = !open
152
+ ;(toggle as HTMLElement).hidden = open
153
+ }
154
+ let initiallyOpen = true
155
+ try {
156
+ initiallyOpen = localStorage.getItem(STORAGE_KEY) !== 'false'
157
+ } catch {}
158
+ setOpen(initiallyOpen)
159
+
160
+ toggle.addEventListener('click', () => setOpen(true))
161
+ q('.stator-inspector-close').addEventListener('click', () => setOpen(false))
162
+ q('.stator-inspector-clear').addEventListener('click', () => {
163
+ body.innerHTML = '<p class="stator-inspector-empty">Log cleared.</p>'
164
+ })
165
+
166
+ const addEntry = (kind: 'up' | 'down', html: string, detail: unknown) => {
167
+ const empty = body.querySelector('.stator-inspector-empty')
168
+ if (empty) empty.remove()
169
+ const row = document.createElement('div')
170
+ row.className = `stator-inspector-row stator-inspector-row--${kind}`
171
+ row.innerHTML = html
172
+ const expand = document.createElement('pre')
173
+ expand.className = 'stator-inspector-detail'
174
+ expand.hidden = true
175
+ expand.textContent = JSON.stringify(detail, null, 2)
176
+ row.appendChild(expand)
177
+ ;(row.querySelector('.stator-inspector-summary') as HTMLElement).addEventListener(
178
+ 'click',
179
+ () => {
180
+ expand.hidden = !expand.hidden
181
+ },
182
+ )
183
+ body.insertBefore(row, body.firstChild)
184
+ while (body.children.length > MAX_ENTRIES) body.removeChild(body.lastChild as Node)
185
+ }
186
+
187
+ window.addEventListener('stator:event-sent', (e: Event) => {
188
+ const { machine, event, timestamp } = (e as CustomEvent).detail
189
+ addEntry(
190
+ 'up',
191
+ `<div class="stator-inspector-summary">
192
+ <span class="stator-inspector-time">${fmtTime(timestamp)}</span>
193
+ <span class="stator-inspector-arrow">↑</span>
194
+ <span class="stator-inspector-machine">${escapeHtml(machine)}</span>
195
+ <span class="stator-inspector-event-type">${escapeHtml(event.type)}</span>
196
+ <span class="stator-inspector-params">${escapeHtml(formatEventParams(event))}</span>
197
+ </div>`,
198
+ (e as CustomEvent).detail,
199
+ )
200
+ })
201
+
202
+ window.addEventListener('stator:patches-received', (e: Event) => {
203
+ const { patches, source, durationMs, timestamp } = (e as CustomEvent).detail
204
+ const timing = durationMs != null ? `${durationMs}ms` : ''
205
+ const sourceLabel = source === 'sse' ? '(sse push)' : '(post)'
206
+ addEntry(
207
+ 'down',
208
+ `<div class="stator-inspector-summary">
209
+ <span class="stator-inspector-time">${fmtTime(timestamp)}</span>
210
+ <span class="stator-inspector-arrow">↓</span>
211
+ <span class="stator-inspector-machine">${patches.length} patch${patches.length === 1 ? '' : 'es'}</span>
212
+ <span class="stator-inspector-event-type">${escapeHtml(summarizePatches(patches))}</span>
213
+ <span class="stator-inspector-params">${sourceLabel} ${timing}</span>
214
+ </div>`,
215
+ (e as CustomEvent).detail,
216
+ )
217
+ })
218
+
219
+ window.addEventListener('stator:patch-applied', (e: Event) => {
220
+ const { patch, element } = (e as CustomEvent).detail
221
+ if (!element) return
222
+ const opClass = `stator-flash--${patch.op}`
223
+ ;(element as HTMLElement).classList.add('stator-flash', opClass)
224
+ window.setTimeout(() => {
225
+ ;(element as HTMLElement).classList.remove('stator-flash', opClass)
226
+ }, FLASH_MS)
227
+ })
228
+ }
229
+
230
+ if (!w.__statorInspectorMounted) {
231
+ w.__statorInspectorMounted = true
232
+ if (document.readyState === 'loading') {
233
+ document.addEventListener('DOMContentLoaded', mount)
234
+ } else {
235
+ mount()
236
+ }
237
+ }
@@ -0,0 +1,39 @@
1
+ import { defineMachine, type EventObject, type MachineDef } from '../engine/index.ts'
2
+
3
+ /**
4
+ * Terse machine form for component-local client state. Desugars to a single-state
5
+ * `defineMachine` — context is every top-level key except the reserved `on` /
6
+ * `select` / `name`, transitions go under one implicit `active` state, and
7
+ * `select` becomes selectors.
8
+ *
9
+ * machine({ count: 1, on: { INC: s => s.count++ }, select: { atMax: s => s.count >= 99 } })
10
+ *
11
+ * Client machines run only via `createActor` (never the Store), so the name is
12
+ * just a label and need not be unique.
13
+ */
14
+ export interface MachineConfig {
15
+ /** Optional label (defaults to "ClientMachine"). */
16
+ name?: string
17
+ /** Transition map for the single implicit state. A bare function is an action;
18
+ * an object is a full `{ to?, when?, do?, emit? }` transition. */
19
+ // biome-ignore lint/suspicious/noExplicitAny: the terse form is dynamically shaped — context/event types aren't statically threaded (it desugars through defineMachine)
20
+ on?: Record<string, any>
21
+ /** Derived values, exposed as selectors on the instance. */
22
+ // biome-ignore lint/suspicious/noExplicitAny: same — ctx is the inferred context of the desugared machine
23
+ select?: Record<string, (ctx: any) => unknown>
24
+ /** Everything else is initial context. */
25
+ [key: string]: unknown
26
+ }
27
+
28
+ export function machine(config: MachineConfig): MachineDef {
29
+ const { name, on = {}, select = {}, ...context } = config
30
+ return defineMachine({
31
+ name: name ?? 'ClientMachine',
32
+ lifecycle: 'session',
33
+ events: {} as EventObject,
34
+ context: context as object,
35
+ initial: 'active',
36
+ states: { active: { on } },
37
+ selectors: select,
38
+ }) as MachineDef
39
+ }