@statorjs/stator 2.5.1 → 2.7.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.
@@ -3,9 +3,13 @@ import { createDevApp } from '../../server/dev.ts'
3
3
  import { loadConfig, resolvePort } from '../config.ts'
4
4
  import type { CliContext } from '../run.ts'
5
5
 
6
- /** Run the dev server (Vite-backed today: live reload, compile-error overlay,
7
- * inspector). Conventions for machines/routes/static; `stator.config.ts` for
8
- * the store/session/port an app previously hand-wrote a `server.ts` for. */
6
+ /** Run the dev server. Live reload, inspector, conventions for machines/routes/
7
+ * static; `stator.config.ts` for the store/session/port an app previously
8
+ * hand-wrote a `server.ts` for.
9
+ *
10
+ * The app runs natively from its source tree, exactly as `stator start` runs a
11
+ * build. `STATOR_VITE_DEV=1` keeps the previous Vite-embedded dev server for
12
+ * one minor as an escape hatch. */
9
13
  export async function run(ctx: CliContext): Promise<void> {
10
14
  const config = await loadConfig(ctx.root)
11
15
  const app = await createDevApp({
@@ -16,7 +16,7 @@ export async function run(ctx: CliContext): Promise<void> {
16
16
  }
17
17
 
18
18
  const config = await loadConfig(ctx.root)
19
- const { headExtras, buildId } = await loadProductionHead(dist)
19
+ const { headExtras, buildId, machines } = await loadProductionHead(dist)
20
20
  const app = await createApp({
21
21
  machinesDir: resolve(dist, 'machines'),
22
22
  routesDir: resolve(dist, 'routes'),
@@ -31,6 +31,7 @@ export async function run(ctx: CliContext): Promise<void> {
31
31
  cors: config.cors,
32
32
  headExtras,
33
33
  buildId,
34
+ machineHashes: machines,
34
35
  middlewareFile: resolve(dist, 'middleware.ts'),
35
36
  bootFile: resolve(dist, 'boot.ts'),
36
37
  })
package/src/cli/run.ts CHANGED
@@ -65,7 +65,7 @@ const ctx: CliContext = {
65
65
  const { loadDotenv } = await import('../server/env.ts')
66
66
  loadDotenv(ctx.root)
67
67
 
68
- // Dynamic import per command so `check` never loads the dev server's Vite, etc.
68
+ // Dynamic import per command so `check` never loads the dev server's watcher, etc.
69
69
  const { run } = (await import(`./commands/${command}.ts`)) as {
70
70
  run: (ctx: CliContext) => Promise<void>
71
71
  }
package/src/cli/stator.js CHANGED
@@ -23,5 +23,14 @@ if (typeof register !== 'function') {
23
23
 
24
24
  register('./loader.js', import.meta.url)
25
25
 
26
+ // Apply sourcemaps to stack traces. Every transform in the owned pipeline (the
27
+ // TS loader above, the dev loader's `.stator` compile) already EMITS an inline
28
+ // map, but V8 ignores maps when capturing stacks unless the process opts in —
29
+ // this is the runtime equivalent of `--enable-source-maps`, which a bin script
30
+ // can't put on its own invocation. Covers dev and `stator start` alike.
31
+ // (`.stator` traces resolve to the compiled server module — right file,
32
+ // generated lines — until the compiler emits its own map to compose.)
33
+ process.setSourceMapsEnabled?.(true)
34
+
26
35
  // The real CLI is TypeScript, transformed on import by the loader just registered.
27
36
  await import('./run.ts')
@@ -189,6 +189,204 @@
189
189
  border-top: 1px dashed #2a2a2a;
190
190
  }
191
191
 
192
+ .stator-inspector-tabs {
193
+ display: inline-flex;
194
+ gap: 0.25rem;
195
+ }
196
+ .stator-inspector-header button.stator-inspector-tab {
197
+ border-color: transparent;
198
+ }
199
+ .stator-inspector-header button.stator-inspector-tab--active {
200
+ border-color: #333;
201
+ background: #232323;
202
+ color: #f0f0f0;
203
+ }
204
+ /* Resize grip: the drawer's draggable top edge. */
205
+ .stator-inspector-grip {
206
+ flex-shrink: 0;
207
+ height: 5px;
208
+ cursor: ns-resize;
209
+ touch-action: none;
210
+ background: transparent;
211
+ }
212
+ .stator-inspector-grip:hover,
213
+ .stator-inspector-grip:active {
214
+ background: #333;
215
+ }
216
+ /* Machines tab: master-detail — nav of machines (live state inline), detail pane. */
217
+ .stator-inspector-machines {
218
+ flex: 1;
219
+ min-height: 0;
220
+ display: flex;
221
+ align-items: stretch;
222
+ }
223
+ .stator-inspector-machines[hidden],
224
+ .stator-inspector-body[hidden],
225
+ .stator-inspector-legend[hidden],
226
+ .stator-inspector-header button[hidden] {
227
+ display: none;
228
+ }
229
+ .stator-inspector-mnav {
230
+ flex: 0 0 230px;
231
+ min-width: 0;
232
+ overflow-y: auto;
233
+ border-right: 1px solid #1f1f1f;
234
+ padding-bottom: 0.4rem;
235
+ }
236
+ .stator-inspector-mnav-head {
237
+ padding: 0.45rem 0.9rem 0.2rem;
238
+ color: #6a6a6a;
239
+ text-transform: uppercase;
240
+ letter-spacing: 0.06em;
241
+ font-size: 0.68rem;
242
+ }
243
+ .stator-inspector-mnav-row {
244
+ display: flex;
245
+ justify-content: space-between;
246
+ align-items: baseline;
247
+ gap: 0.5rem;
248
+ width: 100%;
249
+ text-align: left;
250
+ background: transparent;
251
+ border: none;
252
+ color: #d0d0d0;
253
+ padding: 0.3rem 0.9rem;
254
+ font: inherit;
255
+ cursor: pointer;
256
+ }
257
+ .stator-inspector-mnav-row:hover {
258
+ background: #1c1c1c;
259
+ }
260
+ .stator-inspector-mnav-row--active {
261
+ background: #232323;
262
+ color: #f5f5f5;
263
+ }
264
+ .stator-inspector-mnav-name {
265
+ color: #c586c0;
266
+ font-weight: 500;
267
+ overflow: hidden;
268
+ text-overflow: ellipsis;
269
+ white-space: nowrap;
270
+ }
271
+ .stator-inspector-mnav-state {
272
+ color: #4ec9b0;
273
+ white-space: nowrap;
274
+ }
275
+ .stator-inspector-mdetail {
276
+ flex: 1;
277
+ min-width: 0;
278
+ overflow-y: auto;
279
+ padding: 0.5rem 0.9rem 0.75rem;
280
+ }
281
+ .stator-inspector-detail-head {
282
+ display: flex;
283
+ align-items: baseline;
284
+ gap: 0.75rem;
285
+ padding-bottom: 0.4rem;
286
+ }
287
+ .stator-inspector-badge {
288
+ color: #6a6a6a;
289
+ border: 1px solid #2a2a2a;
290
+ border-radius: 4px;
291
+ padding: 0 0.35rem;
292
+ font-size: 0.7rem;
293
+ }
294
+ .stator-inspector-state {
295
+ color: #4ec9b0;
296
+ font-weight: 500;
297
+ }
298
+ .stator-inspector-dim {
299
+ color: #6a6a6a;
300
+ font-weight: 400;
301
+ font-style: italic;
302
+ }
303
+ .stator-inspector-stale {
304
+ color: #d16969;
305
+ border: 1px solid rgba(209, 105, 105, 0.4);
306
+ border-radius: 4px;
307
+ padding: 0 0.35rem;
308
+ font-size: 0.7rem;
309
+ margin-left: 0.3rem;
310
+ }
311
+ .stator-inspector-accepts {
312
+ color: #888;
313
+ display: flex;
314
+ flex-wrap: wrap;
315
+ gap: 0.3rem;
316
+ align-items: baseline;
317
+ padding-bottom: 0.45rem;
318
+ }
319
+ .stator-inspector-chip {
320
+ color: #dcdcaa;
321
+ border: 1px solid #2f2f2f;
322
+ border-radius: 4px;
323
+ padding: 0 0.3rem;
324
+ font-size: 0.72rem;
325
+ white-space: nowrap;
326
+ }
327
+ .stator-inspector-chip--server {
328
+ color: #6a6a6a;
329
+ border-style: dashed;
330
+ }
331
+ /* Cross-link chips: machine selectors (buttons) and route links (anchors). */
332
+ button.stator-inspector-chip {
333
+ font: inherit;
334
+ font-size: 0.72rem;
335
+ background: transparent;
336
+ cursor: pointer;
337
+ }
338
+ a.stator-inspector-chip {
339
+ text-decoration: none;
340
+ }
341
+ .stator-inspector-chip--link {
342
+ color: #c586c0;
343
+ }
344
+ .stator-inspector-chip--link:hover {
345
+ background: #1c1c1c;
346
+ text-decoration: underline;
347
+ }
348
+ .stator-inspector-chip-guard {
349
+ color: #9cdcfe;
350
+ margin-left: 0.15rem;
351
+ }
352
+ .stator-inspector-context {
353
+ margin: 0;
354
+ padding: 0.4rem 0.6rem;
355
+ background: #0e0e0e;
356
+ color: #cfcfcf;
357
+ font-size: 0.74rem;
358
+ white-space: pre-wrap;
359
+ word-break: break-word;
360
+ border: 1px solid #1f1f1f;
361
+ border-radius: 4px;
362
+ }
363
+ .stator-inspector-context--initial {
364
+ color: #7a7a7a;
365
+ }
366
+ .stator-inspector-route {
367
+ display: grid;
368
+ grid-template-columns: minmax(120px, max-content) 1fr;
369
+ gap: 0.75rem;
370
+ padding: 0.25rem 0;
371
+ border-bottom: 1px solid #1a1a1a;
372
+ }
373
+ .stator-inspector-route:last-child {
374
+ border-bottom: none;
375
+ }
376
+ .stator-inspector-route-path {
377
+ color: #c586c0;
378
+ }
379
+ a.stator-inspector-route-path {
380
+ text-decoration: none;
381
+ }
382
+ a.stator-inspector-route-path:hover {
383
+ text-decoration: underline;
384
+ }
385
+ .stator-inspector-route-method {
386
+ color: #888;
387
+ font-size: 0.72rem;
388
+ }
389
+
192
390
  @media (max-height: 600px) {
193
391
  .stator-inspector-drawer {
194
392
  max-height: 50vh;
@@ -1,13 +1,25 @@
1
1
  /**
2
2
  * Stator dev inspector — a framework-owned, dev-only observability toolbar.
3
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.
4
+ * Auto-injected by `createDevApp` (never in production). Two tabs in a
5
+ * height-adjustable drawer (drag the top edge; the size persists):
8
6
  *
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.
7
+ * - **Wire** subscribes to the public `stator:*` CustomEvent contract the
8
+ * client runtime dispatches on `window` and renders one row per outgoing
9
+ * event (↑) and per incoming patch batch (↓), plus a brief flash on each
10
+ * patched element.
11
+ * - **Machines** fetches `/@stator/inspect` — the dev server's cookie-scoped
12
+ * state endpoint — into a master-detail view: a nav listing every machine
13
+ * with its live state (your session's machines first, then app-lifecycle
14
+ * machines, which are process-global and labeled as such — their context is
15
+ * whatever the app aggregates there), and a detail pane with the selected
16
+ * machine's context and accepted events. The endpoint exists only on the
17
+ * dev server, so on a production page that opted into the toolbar the tab
18
+ * degrades to a notice.
19
+ *
20
+ * The wire tab depends on nothing but the public event contract — the same
21
+ * surface any external devtool would use. Self-contained: it injects its own
22
+ * styles.
11
23
  *
12
24
  * The widget is a `<stator-inspector>` custom element with a shadow root
13
25
  * (the Astro-dev-toolbar / vite-error-overlay pattern): the inspected app's
@@ -21,11 +33,60 @@ import inspectorCss from './inspector.css'
21
33
  import flashCss from './inspector-flash.css'
22
34
 
23
35
  const STORAGE_KEY = 'stator:inspector:open'
36
+ const TAB_KEY = 'stator:inspector:tab'
37
+ const HEIGHT_KEY = 'stator:inspector:height'
38
+ const SELECTED_KEY = 'stator:inspector:machine'
39
+ const ROUTES_SELECTION = '@routes' // storage sentinel; machine names are identifiers
24
40
  const MAX_ENTRIES = 40
25
41
  const FLASH_MS = 1200
42
+ const REFRESH_DEBOUNCE_MS = 250
43
+ const MIN_DRAWER_PX = 140
26
44
 
27
45
  const w = window as unknown as { __statorInspectorMounted?: boolean }
28
46
 
47
+ /** Structural mirror of the server's InspectPayload — the toolbar depends on
48
+ * the wire shape, not the server module. */
49
+ interface TransitionDesc {
50
+ to?: string
51
+ guarded: boolean
52
+ action: boolean
53
+ emits: string[]
54
+ effect: boolean
55
+ }
56
+ interface StateDesc {
57
+ on: Record<string, TransitionDesc[]>
58
+ entry: boolean
59
+ after: Array<{ delay: number | 'dynamic'; send: string }>
60
+ }
61
+ interface MachineDesc {
62
+ name: string
63
+ lifecycle: 'app' | 'session'
64
+ persist: boolean
65
+ initial: string
66
+ states: Record<string, StateDesc>
67
+ on: Record<string, TransitionDesc[]>
68
+ events: string[]
69
+ serverOnly: string[]
70
+ selectors: string[]
71
+ reads: string[]
72
+ context: unknown
73
+ hash?: string
74
+ }
75
+ interface SnapshotDesc {
76
+ value: string[]
77
+ context: unknown
78
+ code?: string
79
+ }
80
+ interface InspectPayloadDesc {
81
+ machines: MachineDesc[]
82
+ session: Record<string, SnapshotDesc | null>
83
+ app: Record<string, SnapshotDesc>
84
+ routes: Array<{
85
+ urlPath: string
86
+ methods: Record<string, { kind: string; reads: string[]; live?: boolean }>
87
+ }>
88
+ }
89
+
29
90
  function escapeHtml(s: unknown): string {
30
91
  if (s == null) return ''
31
92
  return String(s)
@@ -57,6 +118,116 @@ function formatEventParams(event: Record<string, unknown>): string {
57
118
  return keys.map((k) => `${k}=${JSON.stringify(rest[k])}`).join(' ')
58
119
  }
59
120
 
121
+ const snapshotOf = (p: InspectPayloadDesc, m: MachineDesc): SnapshotDesc | null | undefined =>
122
+ m.lifecycle === 'session' ? p.session[m.name] : p.app[m.name]
123
+
124
+ const leafOf = (m: MachineDesc, snap: SnapshotDesc | null | undefined): string =>
125
+ snap ? (snap.value[snap.value.length - 1] ?? m.initial) : m.initial
126
+
127
+ /** Stale = a PERSISTED snapshot stamped by different machine code (absent
128
+ * stamp included — a pre-policy snapshot also resets on hydration). Session
129
+ * machines only: an app machine's snapshot is the live actor's, which never
130
+ * carries the persist-time `code` stamp and can't be stale — it is, by
131
+ * construction, running the current code. */
132
+ const isStale = (m: MachineDesc, snap: SnapshotDesc | null | undefined): boolean =>
133
+ m.lifecycle === 'session' && Boolean(snap && m.hash && snap.code !== m.hash)
134
+
135
+ /** Events the machine accepts in `leaf`: the state's own handlers plus the
136
+ * machine-level fallbacks the state doesn't shadow — actor.send's lookup. */
137
+ function acceptedEvents(
138
+ m: MachineDesc,
139
+ leaf: string,
140
+ ): Array<{ type: string; serverOnly: boolean; guarded: boolean }> {
141
+ const stateOn = m.states[leaf]?.on ?? {}
142
+ const merged: Record<string, TransitionDesc[]> = { ...m.on, ...stateOn }
143
+ return Object.keys(merged)
144
+ .sort()
145
+ .map((type) => ({
146
+ type,
147
+ serverOnly: m.serverOnly.includes(type),
148
+ guarded: (merged[type] ?? []).every((t) => t.guarded),
149
+ }))
150
+ }
151
+
152
+ const STALE_TITLE =
153
+ 'the persisted snapshot was written by different machine code — this session resets on its next request'
154
+
155
+ function navRow(m: MachineDesc, p: InspectPayloadDesc, active: boolean): string {
156
+ const snap = snapshotOf(p, m)
157
+ const leaf = leafOf(m, snap)
158
+ return `<button type="button" class="stator-inspector-mnav-row${active ? ' stator-inspector-mnav-row--active' : ''}" data-name="${escapeHtml(m.name)}">
159
+ <span class="stator-inspector-mnav-name">${escapeHtml(m.name)}</span>
160
+ <span class="stator-inspector-mnav-state">${escapeHtml(leaf)}${isStale(m, snap) ? `<span class="stator-inspector-stale" title="${STALE_TITLE}">!</span>` : ''}</span>
161
+ </button>`
162
+ }
163
+
164
+ /** A cross-link chip selecting a machine's detail — wired up after render. */
165
+ const machineChip = (name: string): string =>
166
+ `<button type="button" class="stator-inspector-chip stator-inspector-chip--link" data-machine="${escapeHtml(name)}">${escapeHtml(name)}</button>`
167
+
168
+ const isNavigable = (r: InspectPayloadDesc['routes'][number]): boolean =>
169
+ r.methods.GET !== undefined && !/[:*]/.test(r.urlPath)
170
+
171
+ function machineDetail(
172
+ m: MachineDesc,
173
+ snap: SnapshotDesc | null | undefined,
174
+ payload: InspectPayloadDesc,
175
+ ): string {
176
+ const leaf = leafOf(m, snap)
177
+ const context = snap ? snap.context : m.context
178
+ const chips = acceptedEvents(m, leaf)
179
+ .map(
180
+ (e) =>
181
+ `<span class="stator-inspector-chip${e.serverOnly ? ' stator-inspector-chip--server' : ''}" title="${e.serverOnly ? 'server-only — clients may not dispatch this' : 'dispatchable'}${e.guarded ? '; guarded' : ''}">${escapeHtml(e.type)}${e.guarded ? '<span class="stator-inspector-chip-guard">?</span>' : ''}</span>`,
182
+ )
183
+ .join('')
184
+ // The graph glue: which machines this one reads, and which routes read it.
185
+ // Navigable routes render as real links (go look at the page showing this
186
+ // machine); parameterized ones stay text.
187
+ const reads = m.reads.map(machineChip).join('')
188
+ const readBy = payload.routes
189
+ .filter((r) => Object.values(r.methods).some((meth) => meth.reads.includes(m.name)))
190
+ .map((r) =>
191
+ isNavigable(r)
192
+ ? `<a class="stator-inspector-chip stator-inspector-chip--link" href="${escapeHtml(r.urlPath)}">${escapeHtml(r.urlPath)}</a>`
193
+ : `<span class="stator-inspector-chip">${escapeHtml(r.urlPath)}</span>`,
194
+ )
195
+ .join('')
196
+ return `<div class="stator-inspector-detail-head">
197
+ <span class="stator-inspector-machine">${escapeHtml(m.name)}</span>
198
+ <span class="stator-inspector-badge">${m.lifecycle}${m.persist ? ' · persist' : ''}</span>
199
+ <span class="stator-inspector-state">${escapeHtml(leaf)}${snap ? '' : ' <span class="stator-inspector-dim">(initial — not touched this session)</span>'}</span>
200
+ ${isStale(m, snap) ? `<span class="stator-inspector-stale" title="${STALE_TITLE}">stale</span>` : ''}
201
+ </div>
202
+ <div class="stator-inspector-accepts">accepts ${chips || '<span class="stator-inspector-dim">nothing in this state</span>'}</div>
203
+ ${reads ? `<div class="stator-inspector-accepts">reads ${reads}</div>` : ''}
204
+ <div class="stator-inspector-accepts">read by ${readBy || '<span class="stator-inspector-dim">no routes</span>'}</div>
205
+ <pre class="stator-inspector-context${snap ? '' : ' stator-inspector-context--initial'}">${escapeHtml(JSON.stringify(context, null, 2))}</pre>`
206
+ }
207
+
208
+ function routesDetail(payload: InspectPayloadDesc): string {
209
+ const rows = payload.routes
210
+ .map((r) => {
211
+ const methods = Object.entries(r.methods)
212
+ .map(([method, m]) => {
213
+ const reads = m.reads.length
214
+ ? m.reads.map(machineChip).join('')
215
+ : '<span class="stator-inspector-dim">no reads</span>'
216
+ return `<span class="stator-inspector-route-method">${escapeHtml(method)}·${escapeHtml(m.kind)}${m.live ? '·live' : ''}</span> ${reads}`
217
+ })
218
+ .join('<br>')
219
+ // A static GET route is navigable — render it as a real link (cmd-click
220
+ // works; the toolbar's persisted state survives the navigation). A
221
+ // parameterized path has no concrete URL to offer.
222
+ const path = isNavigable(r)
223
+ ? `<a class="stator-inspector-route-path" href="${escapeHtml(r.urlPath)}">${escapeHtml(r.urlPath)}</a>`
224
+ : `<span class="stator-inspector-route-path">${escapeHtml(r.urlPath)}</span>`
225
+ return `<div class="stator-inspector-route">${path}<span>${methods}</span></div>`
226
+ })
227
+ .join('')
228
+ return `<div class="stator-inspector-detail-head"><span class="stator-inspector-machine">Routes</span><span class="stator-inspector-badge">url → reads</span></div>${rows || '<p class="stator-inspector-empty">No routes discovered.</p>'}`
229
+ }
230
+
60
231
  function mount(): void {
61
232
  // Flash styles decorate APP elements, so they can't live in the widget's
62
233
  // shadow — they're document-level, in `@layer stator-inspector` (see
@@ -80,25 +251,168 @@ function mount(): void {
80
251
  <span aria-hidden="true">{ }</span> Inspect
81
252
  </button>
82
253
  <section class="stator-inspector-drawer" aria-label="Stator inspector" hidden>
254
+ <div class="stator-inspector-grip" title="Drag to resize"></div>
83
255
  <header class="stator-inspector-header">
84
256
  <span class="stator-inspector-title"><span class="stator-inspector-dot" aria-hidden="true"></span> Stator inspector</span>
257
+ <nav class="stator-inspector-tabs" aria-label="Inspector tabs">
258
+ <button class="stator-inspector-tab" type="button" data-tab="wire">Wire</button>
259
+ <button class="stator-inspector-tab" type="button" data-tab="machines">Machines</button>
260
+ </nav>
85
261
  <span class="stator-inspector-legend">
86
262
  <span class="stator-inspector-key stator-inspector-key--up">↑ event</span>
87
263
  <span class="stator-inspector-key stator-inspector-key--down">↓ patches</span>
88
264
  </span>
89
265
  <button class="stator-inspector-clear" type="button" title="Clear log">clear</button>
266
+ <button class="stator-inspector-refresh" type="button" title="Refresh machine state" hidden>refresh</button>
90
267
  <button class="stator-inspector-close" type="button" aria-label="Close inspector">×</button>
91
268
  </header>
92
269
  <div class="stator-inspector-body">
93
270
  <p class="stator-inspector-empty">Interact with the page to see events and patches.</p>
94
271
  </div>
272
+ <div class="stator-inspector-machines" hidden>
273
+ <p class="stator-inspector-empty">Loading machine state…</p>
274
+ </div>
95
275
  </section>`
96
276
  document.body.appendChild(root)
97
277
 
98
278
  const q = (sel: string) => shadow.querySelector(sel) as HTMLElement
99
279
  const drawer = q('.stator-inspector-drawer')
100
280
  const body = q('.stator-inspector-body')
281
+ const machinesEl = q('.stator-inspector-machines')
101
282
  const toggle = q('.stator-inspector-toggle')
283
+ const legend = q('.stator-inspector-legend')
284
+ const clearBtn = q('.stator-inspector-clear')
285
+ const refreshBtn = q('.stator-inspector-refresh')
286
+ const grip = q('.stator-inspector-grip')
287
+
288
+ // ── Drawer resize: drag the top edge; height persists ────────────────────
289
+ const clampHeight = (h: number): number =>
290
+ Math.min(Math.max(h, MIN_DRAWER_PX), Math.round(window.innerHeight * 0.85))
291
+ const applyHeight = (h: number): void => {
292
+ drawer.style.height = `${clampHeight(h)}px`
293
+ drawer.style.maxHeight = 'none'
294
+ }
295
+ try {
296
+ const stored = Number.parseInt(localStorage.getItem(HEIGHT_KEY) ?? '', 10)
297
+ if (Number.isFinite(stored)) applyHeight(stored)
298
+ } catch {}
299
+ grip.addEventListener('pointerdown', (e: PointerEvent) => {
300
+ e.preventDefault()
301
+ grip.setPointerCapture(e.pointerId)
302
+ const move = (ev: PointerEvent) => applyHeight(window.innerHeight - ev.clientY)
303
+ const up = () => {
304
+ grip.removeEventListener('pointermove', move)
305
+ grip.removeEventListener('pointerup', up)
306
+ try {
307
+ localStorage.setItem(HEIGHT_KEY, String(Number.parseInt(drawer.style.height, 10)))
308
+ } catch {}
309
+ }
310
+ grip.addEventListener('pointermove', move)
311
+ grip.addEventListener('pointerup', up)
312
+ })
313
+
314
+ // ── Machines tab: fetch + master-detail render ───────────────────────────
315
+ let refreshTimer: number | undefined
316
+ let lastPayload: InspectPayloadDesc | undefined
317
+ let selected: string = ((): string => {
318
+ try {
319
+ return localStorage.getItem(SELECTED_KEY) ?? ''
320
+ } catch {
321
+ return ''
322
+ }
323
+ })()
324
+
325
+ const renderMachines = (payload: InspectPayloadDesc): void => {
326
+ lastPayload = payload
327
+ // Your session's machines first — they're what you came to see.
328
+ const session = payload.machines.filter((m) => m.lifecycle === 'session')
329
+ const appMachines = payload.machines.filter((m) => m.lifecycle === 'app')
330
+ // A stored selection may name a machine that no longer exists.
331
+ const names = new Set(payload.machines.map((m) => m.name))
332
+ if (selected !== ROUTES_SELECTION && !names.has(selected)) {
333
+ selected = session[0]?.name ?? appMachines[0]?.name ?? ROUTES_SELECTION
334
+ }
335
+ const section = (label: string, machines: MachineDesc[]): string =>
336
+ machines.length
337
+ ? `<div class="stator-inspector-mnav-head">${label}</div>${machines.map((m) => navRow(m, payload, selected === m.name)).join('')}`
338
+ : ''
339
+ const nav = `<nav class="stator-inspector-mnav">
340
+ ${section('your session', session)}
341
+ ${section('app · process-global', appMachines)}
342
+ <div class="stator-inspector-mnav-head">pages</div>
343
+ <button type="button" class="stator-inspector-mnav-row${selected === ROUTES_SELECTION ? ' stator-inspector-mnav-row--active' : ''}" data-routes>
344
+ <span class="stator-inspector-mnav-name">Routes</span>
345
+ <span class="stator-inspector-mnav-state">${payload.routes.length}</span>
346
+ </button>
347
+ </nav>`
348
+ const selectedMachine = payload.machines.find((m) => m.name === selected)
349
+ const detail =
350
+ selected === ROUTES_SELECTION || !selectedMachine
351
+ ? routesDetail(payload)
352
+ : machineDetail(selectedMachine, snapshotOf(payload, selectedMachine), payload)
353
+ // Re-render in place, keeping each pane's scroll position (live refreshes
354
+ // arrive on every patch batch — a jumping pane would be unusable).
355
+ const prevNav = machinesEl.querySelector('.stator-inspector-mnav')
356
+ const prevDetail = machinesEl.querySelector('.stator-inspector-mdetail')
357
+ const navScroll = prevNav?.scrollTop ?? 0
358
+ const detailScroll = prevDetail?.scrollTop ?? 0
359
+ machinesEl.innerHTML = `${nav}<div class="stator-inspector-mdetail">${detail}</div>`
360
+ const navEl = machinesEl.querySelector('.stator-inspector-mnav')
361
+ const detailEl = machinesEl.querySelector('.stator-inspector-mdetail')
362
+ if (navEl) navEl.scrollTop = navScroll
363
+ if (detailEl) detailEl.scrollTop = detailScroll
364
+ const select = (name: string): void => {
365
+ selected = name
366
+ try {
367
+ localStorage.setItem(SELECTED_KEY, selected)
368
+ } catch {}
369
+ if (lastPayload) renderMachines(lastPayload)
370
+ }
371
+ for (const row of Array.from(
372
+ machinesEl.querySelectorAll<HTMLButtonElement>('.stator-inspector-mnav-row'),
373
+ )) {
374
+ row.addEventListener('click', () =>
375
+ select('routes' in row.dataset ? ROUTES_SELECTION : (row.dataset.name ?? '')),
376
+ )
377
+ }
378
+ // Cross-link chips (route reads, a machine's own reads) select that machine.
379
+ for (const chip of Array.from(
380
+ machinesEl.querySelectorAll<HTMLButtonElement>('button[data-machine]'),
381
+ )) {
382
+ chip.addEventListener('click', () => select(chip.dataset.machine ?? ''))
383
+ }
384
+ }
385
+ const refreshMachines = (): void => {
386
+ fetch('/@stator/inspect', { headers: { Accept: 'application/json' } })
387
+ .then((res) => (res.ok ? res.json() : Promise.reject(new Error(String(res.status)))))
388
+ .then((payload: InspectPayloadDesc) => renderMachines(payload))
389
+ .catch(() => {
390
+ machinesEl.innerHTML =
391
+ '<p class="stator-inspector-empty">State inspection is served by the dev server only — this page has the wire toolbar without it.</p>'
392
+ })
393
+ }
394
+ const scheduleRefresh = (): void => {
395
+ if (drawer.hidden || machinesEl.hidden) return
396
+ window.clearTimeout(refreshTimer)
397
+ refreshTimer = window.setTimeout(refreshMachines, REFRESH_DEBOUNCE_MS)
398
+ }
399
+
400
+ // ── Tabs ─────────────────────────────────────────────────────────────────
401
+ const tabs = Array.from(shadow.querySelectorAll<HTMLButtonElement>('.stator-inspector-tab'))
402
+ const setTab = (tab: string) => {
403
+ try {
404
+ localStorage.setItem(TAB_KEY, tab)
405
+ } catch {}
406
+ const machines = tab === 'machines'
407
+ body.hidden = machines
408
+ machinesEl.hidden = !machines
409
+ legend.hidden = machines
410
+ clearBtn.hidden = machines
411
+ refreshBtn.hidden = !machines
412
+ for (const t of tabs) t.classList.toggle('stator-inspector-tab--active', t.dataset.tab === tab)
413
+ if (machines) refreshMachines()
414
+ }
415
+ for (const t of tabs) t.addEventListener('click', () => setTab(t.dataset.tab ?? 'wire'))
102
416
 
103
417
  const setOpen = (open: boolean) => {
104
418
  try {
@@ -106,16 +420,23 @@ function mount(): void {
106
420
  } catch {}
107
421
  ;(drawer as HTMLElement).hidden = !open
108
422
  ;(toggle as HTMLElement).hidden = open
423
+ if (open && !machinesEl.hidden) refreshMachines()
109
424
  }
110
425
  let initiallyOpen = true
111
426
  try {
112
427
  initiallyOpen = localStorage.getItem(STORAGE_KEY) !== 'false'
113
428
  } catch {}
429
+ let initialTab = 'wire'
430
+ try {
431
+ initialTab = localStorage.getItem(TAB_KEY) === 'machines' ? 'machines' : 'wire'
432
+ } catch {}
433
+ setTab(initialTab)
114
434
  setOpen(initiallyOpen)
115
435
 
116
436
  toggle.addEventListener('click', () => setOpen(true))
117
437
  q('.stator-inspector-close').addEventListener('click', () => setOpen(false))
118
- q('.stator-inspector-clear').addEventListener('click', () => {
438
+ refreshBtn.addEventListener('click', refreshMachines)
439
+ clearBtn.addEventListener('click', () => {
119
440
  body.innerHTML = '<p class="stator-inspector-empty">Log cleared.</p>'
120
441
  })
121
442
 
@@ -185,6 +506,8 @@ function mount(): void {
185
506
  </div>`,
186
507
  (e as CustomEvent).detail,
187
508
  )
509
+ // Patches mean state moved — keep the Machines tab current while visible.
510
+ scheduleRefresh()
188
511
  })
189
512
 
190
513
  window.addEventListener('stator:patch-applied', (e: Event) => {