@three-flatland/devtools 0.1.0-alpha.3 → 1.0.0-alpha.6

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 (172) hide show
  1. package/README.md +1 -1
  2. package/dist/buffers-modal.d.ts +16 -52
  3. package/dist/buffers-modal.d.ts.map +1 -0
  4. package/dist/buffers-modal.js +459 -464
  5. package/dist/buffers-modal.js.map +1 -1
  6. package/dist/buffers-view.d.ts +22 -34
  7. package/dist/buffers-view.d.ts.map +1 -0
  8. package/dist/buffers-view.js +252 -240
  9. package/dist/buffers-view.js.map +1 -1
  10. package/dist/create-pane.d.ts +38 -37
  11. package/dist/create-pane.d.ts.map +1 -0
  12. package/dist/create-pane.js +197 -166
  13. package/dist/create-pane.js.map +1 -1
  14. package/dist/dashboard/app.tsx +2 -0
  15. package/dist/dashboard/export.ts +1 -3
  16. package/dist/dashboard/frame-cursor.ts +81 -0
  17. package/dist/dashboard/hooks.ts +10 -2
  18. package/dist/dashboard/index.html +61 -0
  19. package/dist/dashboard/panels/batches.tsx +5 -18
  20. package/dist/dashboard/panels/buffers.tsx +52 -26
  21. package/dist/dashboard/panels/env.tsx +5 -6
  22. package/dist/dashboard/panels/protocol-log.tsx +146 -58
  23. package/dist/dashboard/panels/registry.tsx +55 -69
  24. package/dist/dashboard/panels/scrubber.tsx +100 -0
  25. package/dist/dashboard/panels/stats.tsx +60 -32
  26. package/dist/dashboard/protocol-store.ts +25 -8
  27. package/dist/dashboard/vendor/hooks.module.d.ts +146 -0
  28. package/dist/dashboard/vendor/jsx-runtime.d.ts +55 -0
  29. package/dist/dashboard/vendor/jsx.d.ts +3123 -0
  30. package/dist/dashboard/vendor/preact.module.d.ts +397 -0
  31. package/dist/devtools-client.d.ts +312 -300
  32. package/dist/devtools-client.d.ts.map +1 -0
  33. package/dist/devtools-client.js +866 -838
  34. package/dist/devtools-client.js.map +1 -1
  35. package/dist/index.d.ts +6 -8
  36. package/dist/index.js +5 -12
  37. package/dist/perf-trace.d.ts +6 -4
  38. package/dist/perf-trace.d.ts.map +1 -0
  39. package/dist/perf-trace.js +80 -66
  40. package/dist/perf-trace.js.map +1 -1
  41. package/dist/plugins.d.ts +6 -6
  42. package/dist/plugins.d.ts.map +1 -0
  43. package/dist/plugins.js +5 -5
  44. package/dist/plugins.js.map +1 -1
  45. package/dist/provider-switcher.d.ts +8 -15
  46. package/dist/provider-switcher.d.ts.map +1 -0
  47. package/dist/provider-switcher.js +68 -68
  48. package/dist/provider-switcher.js.map +1 -1
  49. package/dist/react/devtools-provider.d.ts +11 -10
  50. package/dist/react/devtools-provider.d.ts.map +1 -0
  51. package/dist/react/devtools-provider.js +60 -32
  52. package/dist/react/devtools-provider.js.map +1 -1
  53. package/dist/react/use-fps-graph.d.ts +7 -7
  54. package/dist/react/use-fps-graph.d.ts.map +1 -0
  55. package/dist/react/use-fps-graph.js +27 -21
  56. package/dist/react/use-fps-graph.js.map +1 -1
  57. package/dist/react/use-pane-button.d.ts +4 -4
  58. package/dist/react/use-pane-button.d.ts.map +1 -0
  59. package/dist/react/use-pane-button.js +35 -30
  60. package/dist/react/use-pane-button.js.map +1 -1
  61. package/dist/react/use-pane-folder.d.ts +5 -4
  62. package/dist/react/use-pane-folder.d.ts.map +1 -0
  63. package/dist/react/use-pane-folder.js +33 -20
  64. package/dist/react/use-pane-folder.js.map +1 -1
  65. package/dist/react/use-pane-input.d.ts +31 -30
  66. package/dist/react/use-pane-input.d.ts.map +1 -0
  67. package/dist/react/use-pane-input.js +46 -42
  68. package/dist/react/use-pane-input.js.map +1 -1
  69. package/dist/react/use-pane-radio-grid.d.ts +20 -20
  70. package/dist/react/use-pane-radio-grid.d.ts.map +1 -0
  71. package/dist/react/use-pane-radio-grid.js +72 -50
  72. package/dist/react/use-pane-radio-grid.js.map +1 -1
  73. package/dist/react/use-pane.d.ts +4 -4
  74. package/dist/react/use-pane.d.ts.map +1 -0
  75. package/dist/react/use-pane.js +52 -17
  76. package/dist/react/use-pane.js.map +1 -1
  77. package/dist/react.d.ts +10 -12
  78. package/dist/react.js +2 -12
  79. package/dist/registry-view.d.ts +8 -21
  80. package/dist/registry-view.d.ts.map +1 -0
  81. package/dist/registry-view.js +357 -338
  82. package/dist/registry-view.js.map +1 -1
  83. package/dist/relay.d.ts +16 -0
  84. package/dist/relay.d.ts.map +1 -0
  85. package/dist/relay.js +189 -0
  86. package/dist/relay.js.map +1 -0
  87. package/dist/remote.d.ts +10 -0
  88. package/dist/remote.d.ts.map +1 -0
  89. package/dist/remote.js +28 -0
  90. package/dist/remote.js.map +1 -0
  91. package/dist/stats-graph.d.ts +23 -31
  92. package/dist/stats-graph.d.ts.map +1 -0
  93. package/dist/stats-graph.js +323 -264
  94. package/dist/stats-graph.js.map +1 -1
  95. package/dist/stats-row.d.ts +8 -21
  96. package/dist/stats-row.d.ts.map +1 -0
  97. package/dist/stats-row.js +99 -101
  98. package/dist/stats-row.js.map +1 -1
  99. package/dist/theme.d.ts +3 -1
  100. package/dist/theme.d.ts.map +1 -0
  101. package/dist/theme.js +43 -54
  102. package/dist/theme.js.map +1 -1
  103. package/dist/vite-plugin.d.ts +7 -6
  104. package/dist/vite-plugin.d.ts.map +1 -0
  105. package/dist/vite-plugin.js +87 -78
  106. package/dist/vite-plugin.js.map +1 -1
  107. package/package.json +33 -29
  108. package/dist/buffers-modal.cjs +0 -491
  109. package/dist/buffers-modal.cjs.map +0 -1
  110. package/dist/buffers-modal.d.cts +0 -62
  111. package/dist/buffers-view.cjs +0 -269
  112. package/dist/buffers-view.cjs.map +0 -1
  113. package/dist/buffers-view.d.cts +0 -39
  114. package/dist/create-pane.cjs +0 -203
  115. package/dist/create-pane.cjs.map +0 -1
  116. package/dist/create-pane.d.cts +0 -66
  117. package/dist/devtools-client.cjs +0 -870
  118. package/dist/devtools-client.cjs.map +0 -1
  119. package/dist/devtools-client.d.cts +0 -362
  120. package/dist/index.cjs +0 -42
  121. package/dist/index.cjs.map +0 -1
  122. package/dist/index.d.cts +0 -8
  123. package/dist/index.js.map +0 -1
  124. package/dist/perf-trace.cjs +0 -103
  125. package/dist/perf-trace.cjs.map +0 -1
  126. package/dist/perf-trace.d.cts +0 -27
  127. package/dist/plugins.cjs +0 -44
  128. package/dist/plugins.cjs.map +0 -1
  129. package/dist/plugins.d.cts +0 -7
  130. package/dist/provider-switcher.cjs +0 -95
  131. package/dist/provider-switcher.cjs.map +0 -1
  132. package/dist/provider-switcher.d.cts +0 -18
  133. package/dist/react/devtools-provider.cjs +0 -59
  134. package/dist/react/devtools-provider.cjs.map +0 -1
  135. package/dist/react/devtools-provider.d.cts +0 -39
  136. package/dist/react/use-fps-graph.cjs +0 -49
  137. package/dist/react/use-fps-graph.cjs.map +0 -1
  138. package/dist/react/use-fps-graph.d.cts +0 -15
  139. package/dist/react/use-pane-button.cjs +0 -58
  140. package/dist/react/use-pane-button.cjs.map +0 -1
  141. package/dist/react/use-pane-button.d.cts +0 -10
  142. package/dist/react/use-pane-folder.cjs +0 -48
  143. package/dist/react/use-pane-folder.cjs.map +0 -1
  144. package/dist/react/use-pane-folder.d.cts +0 -15
  145. package/dist/react/use-pane-input.cjs +0 -70
  146. package/dist/react/use-pane-input.cjs.map +0 -1
  147. package/dist/react/use-pane-input.d.cts +0 -44
  148. package/dist/react/use-pane-radio-grid.cjs +0 -78
  149. package/dist/react/use-pane-radio-grid.cjs.map +0 -1
  150. package/dist/react/use-pane-radio-grid.d.cts +0 -35
  151. package/dist/react/use-pane.cjs +0 -45
  152. package/dist/react/use-pane.cjs.map +0 -1
  153. package/dist/react/use-pane.d.cts +0 -40
  154. package/dist/react.cjs +0 -50
  155. package/dist/react.cjs.map +0 -1
  156. package/dist/react.d.cts +0 -12
  157. package/dist/react.js.map +0 -1
  158. package/dist/registry-view.cjs +0 -371
  159. package/dist/registry-view.cjs.map +0 -1
  160. package/dist/registry-view.d.cts +0 -24
  161. package/dist/stats-graph.cjs +0 -297
  162. package/dist/stats-graph.cjs.map +0 -1
  163. package/dist/stats-graph.d.cts +0 -40
  164. package/dist/stats-row.cjs +0 -153
  165. package/dist/stats-row.cjs.map +0 -1
  166. package/dist/stats-row.d.cts +0 -24
  167. package/dist/theme.cjs +0 -260
  168. package/dist/theme.cjs.map +0 -1
  169. package/dist/theme.d.cts +0 -10
  170. package/dist/vite-plugin.cjs +0 -113
  171. package/dist/vite-plugin.cjs.map +0 -1
  172. package/dist/vite-plugin.d.cts +0 -9
@@ -28,6 +28,7 @@ import type { DebugMessage } from 'three-flatland/debug-protocol'
28
28
  import { getClient } from '../client.js'
29
29
  import { exportSession } from '../export.js'
30
30
  import { useDevtoolsState } from '../hooks.js'
31
+ import { getFrameCursor, setFrameCursor } from '../frame-cursor.js'
31
32
  import { getProtocolStore, type LogEntry } from '../protocol-store.js'
32
33
 
33
34
  const ROW_HEIGHT = 22
@@ -64,8 +65,12 @@ export function ProtocolLog() {
64
65
  const [hoveredId, setHoveredId] = useState<number | null>(null)
65
66
 
66
67
  const [tail, setTail] = useState(true)
68
+ // Time-travel cursor (#29 Phase A): parked wins over tail — the log
69
+ // stops following new entries while the user inspects a past frame.
70
+ const frameCursor = getFrameCursor()
71
+ const effectiveTail = tail && frameCursor === null
67
72
  const tailRef = useRef(true)
68
- tailRef.current = tail
73
+ tailRef.current = effectiveTail
69
74
 
70
75
  const scrollerRef = useRef<HTMLDivElement | null>(null)
71
76
  const filterInputRef = useRef<HTMLInputElement | null>(null)
@@ -75,6 +80,62 @@ export function ProtocolLog() {
75
80
  const pendingBumpRef = useRef(0)
76
81
  const [viewport, setViewport] = useState({ scrollTop: 0, height: 0 })
77
82
 
83
+ // Parked cursor → scroll to the row whose frame is closest ≤ the
84
+ // cursor (nearest-frame semantics: data batches at ~4 Hz, engine
85
+ // frames at 60 Hz — most frames have no exact row). Only rows in the
86
+ // tail cache are scanned; deeper history scrolls in as it hydrates.
87
+ const parkedCursorRef = useRef<{ providerId: string | null; frame: number | null }>({
88
+ providerId: null,
89
+ frame: null,
90
+ })
91
+ useEffect(() => {
92
+ // Key the parked cursor by provider AND frame: switching producers while
93
+ // both are parked on the same frame number must still re-scroll.
94
+ const sameCursor =
95
+ parkedCursorRef.current.providerId === activeProviderId && parkedCursorRef.current.frame === frameCursor
96
+ if (frameCursor === null || sameCursor) {
97
+ parkedCursorRef.current = { providerId: activeProviderId, frame: frameCursor }
98
+ return
99
+ }
100
+ parkedCursorRef.current = { providerId: activeProviderId, frame: frameCursor }
101
+ if (activeProviderId === null) return
102
+ const { total, ids } = store.statsFor(activeProviderId)
103
+ let matched = false
104
+ for (let i = 0; i < total; i++) {
105
+ const id = ids[total - 1 - i]
106
+ if (id === undefined) continue
107
+ const entry = store.peek(activeProviderId, id)
108
+ if (entry?.frame !== undefined && entry.frame <= frameCursor) {
109
+ const el = scrollerRef.current
110
+ if (el !== null) el.scrollTop = i * ROW_HEIGHT
111
+ matched = true
112
+ break
113
+ }
114
+ }
115
+ if (!matched) {
116
+ // Older than the tail cache — locate the row through IDB, then
117
+ // scroll if the cursor hasn't moved on while we were reading.
118
+ const target = frameCursor
119
+ const provider = activeProviderId
120
+ void store
121
+ .queryFiltered(provider, (entry) => entry.frame !== undefined && entry.frame <= target)
122
+ .then((matchedIds) => {
123
+ if (parkedCursorRef.current.providerId !== provider || parkedCursorRef.current.frame !== target) return
124
+ const lastId = matchedIds[matchedIds.length - 1]
125
+ if (lastId === undefined) return
126
+ const { total: t, ids: allIds } = store.statsFor(provider)
127
+ for (let i = 0; i < t; i++) {
128
+ if (allIds[t - 1 - i] === lastId) {
129
+ const el = scrollerRef.current
130
+ if (el !== null) el.scrollTop = i * ROW_HEIGHT
131
+ break
132
+ }
133
+ }
134
+ })
135
+ }
136
+ // eslint-disable-next-line react-hooks/exhaustive-deps
137
+ }, [frameCursor, activeProviderId])
138
+
78
139
  // Wire the raw-message tap → store. The store owns IDB persistence,
79
140
  // counters, cache + eviction; this listener translates raw messages
80
141
  // to log rows AND tags each with whichever provider was selected at
@@ -195,10 +256,7 @@ export function ProtocolLog() {
195
256
  minBytes,
196
257
  excluded: excludedTypes,
197
258
  }
198
- const hasActiveFilter = needle.length > 0
199
- || dirFilter !== null
200
- || minBytes > 0
201
- || excludedTypes.size > 0
259
+ const hasActiveFilter = needle.length > 0 || dirFilter !== null || minBytes > 0 || excludedTypes.size > 0
202
260
 
203
261
  // Filter pipeline is authoritative — when a filter is active, we
204
262
  // query IDB directly via the store's `queryFiltered` cursor. Query
@@ -223,23 +281,19 @@ export function ProtocolLog() {
223
281
  const signal = { aborted: false }
224
282
  const predicate = filterPredicateRef.current!
225
283
  setFilterLoading(true)
226
- const handle = (globalThis.setTimeout as unknown as (cb: () => void, ms: number) => number)(
227
- () => {
228
- void store.queryFiltered(activeProviderId, predicate, signal).then((result) => {
229
- if (signal.aborted) return
230
- setFilteredIds(result)
231
- setFilterLoading(false)
232
- })
233
- },
234
- 150,
235
- )
284
+ const handle = (globalThis.setTimeout as unknown as (cb: () => void, ms: number) => number)(() => {
285
+ void store.queryFiltered(activeProviderId, predicate, signal).then((result) => {
286
+ if (signal.aborted) return
287
+ setFilteredIds(result)
288
+ setFilterLoading(false)
289
+ })
290
+ }, 150)
236
291
  return () => {
237
292
  signal.aborted = true
238
293
  clearTimeout(handle)
239
294
  }
240
295
  // Filter primitives as deps; `filterPredicateRef.current` is read
241
296
  // inside but kept stable against renders via the ref.
242
-
243
297
  }, [store, activeProviderId, hasActiveFilter, needle, dirFilter, minBytes, excludedTypes])
244
298
 
245
299
  // Live-append: when a new message matches the active filter, extend
@@ -275,7 +329,10 @@ export function ProtocolLog() {
275
329
  let visualIdx = 0
276
330
  if (selectedId !== null) {
277
331
  for (let i = 0; i < total; i++) {
278
- if (ids[total - 1 - i] === selectedId) { visualIdx = i; break }
332
+ if (ids[total - 1 - i] === selectedId) {
333
+ visualIdx = i
334
+ break
335
+ }
279
336
  }
280
337
  }
281
338
  const nextIdx = Math.max(0, Math.min(total - 1, visualIdx + delta))
@@ -294,7 +351,10 @@ export function ProtocolLog() {
294
351
  }
295
352
  const onKey = (e: KeyboardEvent) => {
296
353
  if (e.key === 'Escape') {
297
- if (selectedId !== null) { setSelectedId(null); e.preventDefault() }
354
+ if (selectedId !== null) {
355
+ setSelectedId(null)
356
+ e.preventDefault()
357
+ }
298
358
  return
299
359
  }
300
360
  if (isTypingTarget(e.target)) return
@@ -325,8 +385,8 @@ export function ProtocolLog() {
325
385
 
326
386
  // Window-scope prefetch (unfiltered case). When filter is active the
327
387
  // bulk prefetch above covers it.
328
- const startId = end > 0 && total > 0 ? ids[Math.max(0, total - end)] ?? 0 : 0
329
- const endId = total > 0 ? ids[Math.max(0, total - 1 - start)] ?? 0 : 0
388
+ const startId = end > 0 && total > 0 ? (ids[Math.max(0, total - end)] ?? 0) : 0
389
+ const endId = total > 0 ? (ids[Math.max(0, total - 1 - start)] ?? 0) : 0
330
390
  useEffect(() => {
331
391
  if (hasActiveFilter) return
332
392
  if (activeProviderId === null || startId === 0 || startId > endId) return
@@ -334,7 +394,10 @@ export function ProtocolLog() {
334
394
  for (let i = start; i < end; i++) {
335
395
  const id = ids[total - 1 - i]
336
396
  if (id === undefined) continue
337
- if (store.peek(activeProviderId, id) === null) { needsFetch = true; break }
397
+ if (store.peek(activeProviderId, id) === null) {
398
+ needsFetch = true
399
+ break
400
+ }
338
401
  }
339
402
  if (needsFetch) void store.prefetchRange(activeProviderId, startId, endId)
340
403
  }, [store, activeProviderId, hasActiveFilter, startId, endId, start, end, total, ids])
@@ -351,16 +414,24 @@ export function ProtocolLog() {
351
414
  // Selection follows the active provider. Swapping providers clears
352
415
  // any cross-provider selection.
353
416
  useEffect(() => {
354
- if (selectedId === null || activeProviderId === null) { setSelectedEntry(null); return }
417
+ if (selectedId === null || activeProviderId === null) {
418
+ setSelectedEntry(null)
419
+ return
420
+ }
355
421
  const cached = store.peek(activeProviderId, selectedId)
356
- if (cached !== null) { setSelectedEntry(cached); return }
422
+ if (cached !== null) {
423
+ setSelectedEntry(cached)
424
+ return
425
+ }
357
426
  let cancelled = false
358
427
  void store.prefetchRange(activeProviderId, selectedId, selectedId).then(() => {
359
428
  if (cancelled) return
360
429
  const fetched = store.peek(activeProviderId, selectedId)
361
430
  if (fetched !== null) setSelectedEntry(fetched)
362
431
  })
363
- return () => { cancelled = true }
432
+ return () => {
433
+ cancelled = true
434
+ }
364
435
  }, [store, activeProviderId, selectedId])
365
436
  const selected = selectedEntry
366
437
 
@@ -418,11 +489,7 @@ export function ProtocolLog() {
418
489
  <div class="protocol-filter-menu-wrap" ref={filterMenuRef}>
419
490
  <button
420
491
  type="button"
421
- class={
422
- (minBytes > 0 || excludedTypes.size > 0
423
- ? 'protocol-btn protocol-btn-on'
424
- : 'protocol-btn')
425
- }
492
+ class={minBytes > 0 || excludedTypes.size > 0 ? 'protocol-btn protocol-btn-on' : 'protocol-btn'}
426
493
  onClick={() => setFilterMenuOpen((o) => !o)}
427
494
  title="Type + size filters"
428
495
  >
@@ -510,7 +577,17 @@ export function ProtocolLog() {
510
577
  type="button"
511
578
  class={cls}
512
579
  style={{ transform: `translateY(${offsetTop}px)`, height: `${ROW_HEIGHT}px` }}
513
- onClick={isPlaceholder ? undefined : () => setSelectedId((x) => (x === id ? null : id))}
580
+ onClick={
581
+ isPlaceholder
582
+ ? undefined
583
+ : () => {
584
+ setSelectedId((x) => (x === id ? null : id))
585
+ // Entry point per the time-travel design:
586
+ // clicking a row parks the cursor at its frame.
587
+ const frame = e!.frame
588
+ if (frame !== undefined) setFrameCursor(frame)
589
+ }
590
+ }
514
591
  onMouseEnter={isPlaceholder ? undefined : () => setHoveredId(id)}
515
592
  onMouseLeave={isPlaceholder ? undefined : () => setHoveredId((h) => (h === id ? null : h))}
516
593
  title={isPlaceholder ? undefined : new Date(e!.at).toISOString()}
@@ -520,7 +597,7 @@ export function ProtocolLog() {
520
597
  <span class="protocol-time">{isLoading ? 'loading…' : formatTime(e!.at)}</span>
521
598
  <span class="protocol-frame">{!isPlaceholder && e!.frame !== undefined ? `#${e!.frame}` : ''}</span>
522
599
  <span class="protocol-type">{isPlaceholder ? '' : e!.type}</span>
523
- <span class="protocol-tag">{isPlaceholder ? '' : e!.tag ?? ''}</span>
600
+ <span class="protocol-tag">{isPlaceholder ? '' : (e!.tag ?? '')}</span>
524
601
  <span class="protocol-size">{isPlaceholder ? '' : formatBytes(e!.bytes)}</span>
525
602
  </button>
526
603
  )
@@ -536,12 +613,9 @@ export function ProtocolLog() {
536
613
  {selected.type}
537
614
  {selected.tag !== undefined && <span class="protocol-tag"> · {selected.tag}</span>}
538
615
  </span>
539
- <button
540
- type="button"
541
- class="protocol-btn"
542
- onClick={() => setSelectedId(null)}
543
- aria-label="Close detail"
544
- >×</button>
616
+ <button type="button" class="protocol-btn" onClick={() => setSelectedId(null)} aria-label="Close detail">
617
+ ×
618
+ </button>
545
619
  </div>
546
620
  <div class="protocol-detail-meta">
547
621
  <span>{formatTime(selected.at)}</span>
@@ -612,7 +686,9 @@ function FilterMenu({
612
686
  type="button"
613
687
  class="protocol-btn protocol-filter-menu-clear"
614
688
  onClick={() => setExcludedTypes(new Set())}
615
- >reset</button>
689
+ >
690
+ reset
691
+ </button>
616
692
  )}
617
693
  </div>
618
694
  <ul class="protocol-filter-types">
@@ -622,11 +698,7 @@ function FilterMenu({
622
698
  types.map((t) => (
623
699
  <li key={t}>
624
700
  <label class="protocol-filter-type">
625
- <input
626
- type="checkbox"
627
- checked={!excludedTypes.has(t)}
628
- onChange={() => toggle(t)}
629
- />
701
+ <input type="checkbox" checked={!excludedTypes.has(t)} onChange={() => toggle(t)} />
630
702
  <span>{t}</span>
631
703
  </label>
632
704
  </li>
@@ -671,14 +743,29 @@ function estimateBytes(msg: DebugMessage): number {
671
743
  const seen = new WeakSet<object>()
672
744
  const walk = (v: unknown): void => {
673
745
  if (v === null || v === undefined) return
674
- if (typeof v === 'string') { bytes += v.length; return }
675
- if (typeof v === 'number' || typeof v === 'boolean') { bytes += 8; return }
676
- if (v instanceof ArrayBuffer) { bytes += v.byteLength; return }
677
- if (ArrayBuffer.isView(v)) { bytes += v.byteLength; return }
746
+ if (typeof v === 'string') {
747
+ bytes += v.length
748
+ return
749
+ }
750
+ if (typeof v === 'number' || typeof v === 'boolean') {
751
+ bytes += 8
752
+ return
753
+ }
754
+ if (v instanceof ArrayBuffer) {
755
+ bytes += v.byteLength
756
+ return
757
+ }
758
+ if (ArrayBuffer.isView(v)) {
759
+ bytes += v.byteLength
760
+ return
761
+ }
678
762
  if (typeof v === 'object') {
679
763
  if (seen.has(v)) return
680
764
  seen.add(v)
681
- if (Array.isArray(v)) { for (const x of v) walk(x); return }
765
+ if (Array.isArray(v)) {
766
+ for (const x of v) walk(x)
767
+ return
768
+ }
682
769
  for (const k in v as Record<string, unknown>) {
683
770
  bytes += k.length
684
771
  walk((v as Record<string, unknown>)[k])
@@ -703,11 +790,15 @@ function formatTime(at: number): string {
703
790
 
704
791
  function stringify(msg: DebugMessage): string {
705
792
  try {
706
- return JSON.stringify(msg, (_k, v) => {
707
- if (v instanceof ArrayBuffer) return `[ArrayBuffer ${v.byteLength}B]`
708
- if (ArrayBuffer.isView(v)) return `[${v.constructor.name} ${v.byteLength}B]`
709
- return v as unknown
710
- }, 2)
793
+ return JSON.stringify(
794
+ msg,
795
+ (_k, v) => {
796
+ if (v instanceof ArrayBuffer) return `[ArrayBuffer ${v.byteLength}B]`
797
+ if (ArrayBuffer.isView(v)) return `[${v.constructor.name} ${v.byteLength}B]`
798
+ return v as unknown
799
+ },
800
+ 2
801
+ )
711
802
  } catch {
712
803
  return '[unserialisable]'
713
804
  }
@@ -722,10 +813,7 @@ function stringify(msg: DebugMessage): string {
722
813
  * arbitrary markup from message payloads.
723
814
  */
724
815
  function highlightJson(json: string): string {
725
- const escaped = json
726
- .replace(/&/g, '&amp;')
727
- .replace(/</g, '&lt;')
728
- .replace(/>/g, '&gt;')
816
+ const escaped = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
729
817
  return escaped.replace(
730
818
  /("(?:\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,
731
819
  (match) => {
@@ -738,6 +826,6 @@ function highlightJson(json: string): string {
738
826
  cls = 'json-null'
739
827
  }
740
828
  return `<span class="${cls}">${match}</span>`
741
- },
829
+ }
742
830
  )
743
831
  }
@@ -17,6 +17,7 @@
17
17
  import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
18
18
  import type { RegistryEntrySnapshot } from '../../devtools-client.js'
19
19
  import { getClient } from '../client.js'
20
+ import { getFrameCursor } from '../frame-cursor.js'
20
21
  import { useDevtoolsState } from '../hooks.js'
21
22
 
22
23
  const ROW_STRIDE = 8
@@ -43,7 +44,9 @@ export function RegistryPanel() {
43
44
  // Opt into the unfiltered drain on mount; reset on unmount.
44
45
  useEffect(() => {
45
46
  client.setRegistry(null)
46
- return () => { client.setRegistry([]) }
47
+ return () => {
48
+ client.setRegistry([])
49
+ }
47
50
  }, [client])
48
51
 
49
52
  const entries = useMemo(() => {
@@ -53,15 +56,17 @@ export function RegistryPanel() {
53
56
  }, [state.registry, state.registry.size])
54
57
 
55
58
  const needle = filter.trim().toLowerCase()
56
- const visible = needle.length > 0
57
- ? entries.filter((e) => e.name.toLowerCase().includes(needle) || (e.label?.toLowerCase().includes(needle) ?? false))
58
- : entries
59
+ const visible =
60
+ needle.length > 0
61
+ ? entries.filter(
62
+ (e) => e.name.toLowerCase().includes(needle) || (e.label?.toLowerCase().includes(needle) ?? false)
63
+ )
64
+ : entries
59
65
 
60
- const effectiveSelected = selected !== null && visible.some((e) => e.name === selected)
61
- ? selected
62
- : (visible[0]?.name ?? null)
66
+ const effectiveSelected =
67
+ selected !== null && visible.some((e) => e.name === selected) ? selected : (visible[0]?.name ?? null)
63
68
 
64
- const selectedEntry = effectiveSelected !== null ? state.registry.get(effectiveSelected) ?? null : null
69
+ const selectedEntry = effectiveSelected !== null ? (state.registry.get(effectiveSelected) ?? null) : null
65
70
 
66
71
  return (
67
72
  <section class="panel registry-panel">
@@ -76,6 +81,11 @@ export function RegistryPanel() {
76
81
  />
77
82
  <span class="registry-count">{entries.length}</span>
78
83
  </header>
84
+ {getFrameCursor() !== null ? (
85
+ <div class="registry-parked-note">
86
+ parked — showing the latest registry state (per-frame history needs the Phase C checkpoint snapshots)
87
+ </div>
88
+ ) : null}
79
89
  <div class="registry-layout">
80
90
  <ul class="registry-list">
81
91
  {visible.length === 0 ? (
@@ -85,10 +95,7 @@ export function RegistryPanel() {
85
95
  <li key={e.name}>
86
96
  <button
87
97
  type="button"
88
- class={
89
- 'registry-row' +
90
- (e.name === effectiveSelected ? ' registry-row-selected' : '')
91
- }
98
+ class={'registry-row' + (e.name === effectiveSelected ? ' registry-row-selected' : '')}
92
99
  onClick={() => setSelected(e.name)}
93
100
  >
94
101
  <span class="registry-kind">{e.kind}</span>
@@ -139,13 +146,7 @@ function RegistrySparkline({
139
146
  if (ctx === null) return
140
147
  drawVisualizer(ctx, w, h, entry)
141
148
  }, [entry.version, entry.name, width, height])
142
- return (
143
- <canvas
144
- class="registry-sparkline"
145
- ref={canvasRef}
146
- style={{ width: `${width}px`, height: `${height}px` }}
147
- />
148
- )
149
+ return <canvas class="registry-sparkline" ref={canvasRef} style={{ width: `${width}px`, height: `${height}px` }} />
149
150
  }
150
151
 
151
152
  function RegistryDetail({ entry }: { entry: RegistryEntrySnapshot }): preact.JSX.Element {
@@ -171,11 +172,7 @@ function RegistryDetail({ entry }: { entry: RegistryEntrySnapshot }): preact.JSX
171
172
  const scrollGridToAxisIdx = (idx: number): void => {
172
173
  const el = gridScrollerRef.current
173
174
  if (el === null) return
174
- const perAxisIdx =
175
- entry.kind === 'float2' ? 2 :
176
- entry.kind === 'float3' ? 3 :
177
- entry.kind === 'float4' ? 4 :
178
- 1
175
+ const perAxisIdx = entry.kind === 'float2' ? 2 : entry.kind === 'float3' ? 3 : entry.kind === 'float4' ? 4 : 1
179
176
  const scalarIdx = entry.kind === 'bits' ? idx : idx * perAxisIdx
180
177
  const rowIdx = Math.floor(scalarIdx / ROW_STRIDE)
181
178
  const top = rowIdx * SAMPLE_ROW_HEIGHT
@@ -270,9 +267,7 @@ function RegistryDetail({ entry }: { entry: RegistryEntrySnapshot }): preact.JSX
270
267
  <span>v{version}</span>
271
268
  <span>{sample.constructor.name}</span>
272
269
  {stats.summary !== '' && <span>{stats.summary}</span>}
273
- {cursorIdx !== null && (
274
- <span class="registry-cursor-label">cursor {cursorIdx}</span>
275
- )}
270
+ {cursorIdx !== null && <span class="registry-cursor-label">cursor {cursorIdx}</span>}
276
271
  </div>
277
272
  <div class="registry-viz-wrap">
278
273
  <div
@@ -289,19 +284,19 @@ function RegistryDetail({ entry }: { entry: RegistryEntrySnapshot }): preact.JSX
289
284
  </div>
290
285
  </div>
291
286
  <div class="registry-detail-body">
292
- {sample.length === 0
293
- ? <div class="panel-empty">No sample yet.</div>
294
- : (
295
- <SampleGrid
296
- entry={entry}
297
- stride={ROW_STRIDE}
298
- float={isFloat}
299
- pinnedIdx={pinnedIdx}
300
- scrollerRef={gridScrollerRef}
301
- onPinIdx={(idx) => setPinnedIdx(idx)}
302
- onScrollIdx={(idx) => setPinnedIdx(idx)}
303
- />
304
- )}
287
+ {sample.length === 0 ? (
288
+ <div class="panel-empty">No sample yet.</div>
289
+ ) : (
290
+ <SampleGrid
291
+ entry={entry}
292
+ stride={ROW_STRIDE}
293
+ float={isFloat}
294
+ pinnedIdx={pinnedIdx}
295
+ scrollerRef={gridScrollerRef}
296
+ onPinIdx={(idx) => setPinnedIdx(idx)}
297
+ onScrollIdx={(idx) => setPinnedIdx(idx)}
298
+ />
299
+ )}
305
300
  </div>
306
301
  </>
307
302
  )
@@ -328,11 +323,7 @@ function SampleGrid({
328
323
  onScrollIdx: (i: number) => void
329
324
  }): preact.JSX.Element {
330
325
  const { kind, sample } = entry
331
- const scalarsPerAxisIdx =
332
- kind === 'float2' ? 2 :
333
- kind === 'float3' ? 3 :
334
- kind === 'float4' ? 4 :
335
- 1
326
+ const scalarsPerAxisIdx = kind === 'float2' ? 2 : kind === 'float3' ? 3 : kind === 'float4' ? 4 : 1
336
327
  const cursorIdx = pinnedIdx
337
328
 
338
329
  const [viewport, setViewport] = useState({ scrollTop: 0, height: 0 })
@@ -343,7 +334,7 @@ function SampleGrid({
343
334
  setViewport((v) => ({ ...v, height: el.clientHeight }))
344
335
  const obs = new ResizeObserver(() => {
345
336
  const h = scrollerRef.current?.clientHeight ?? 0
346
- setViewport((v) => v.height === h ? v : { ...v, height: h })
337
+ setViewport((v) => (v.height === h ? v : { ...v, height: h }))
347
338
  })
348
339
  obs.observe(el)
349
340
  return () => obs.disconnect()
@@ -359,9 +350,7 @@ function SampleGrid({
359
350
  if (height === 0) return
360
351
  const centerRow = Math.floor((scrollTop + height / 2) / SAMPLE_ROW_HEIGHT)
361
352
  const centerScalarIdx = centerRow * stride
362
- const axisIdx = kind === 'bits'
363
- ? centerScalarIdx * 32
364
- : Math.floor(centerScalarIdx / scalarsPerAxisIdx)
353
+ const axisIdx = kind === 'bits' ? centerScalarIdx * 32 : Math.floor(centerScalarIdx / scalarsPerAxisIdx)
365
354
  if (axisIdx >= 0) onScrollIdx(axisIdx)
366
355
  }
367
356
 
@@ -377,14 +366,12 @@ function SampleGrid({
377
366
  for (let j = 0; j < stride && i + j < sample.length; j++) {
378
367
  const scalarIdx = i + j
379
368
  const v = sample[scalarIdx]!
380
- const axisIdxForCell = kind === 'bits'
381
- ? scalarIdx * 32
382
- : Math.floor(scalarIdx / scalarsPerAxisIdx)
383
- const inCursor = cursorIdx !== null && (
384
- kind === 'bits'
369
+ const axisIdxForCell = kind === 'bits' ? scalarIdx * 32 : Math.floor(scalarIdx / scalarsPerAxisIdx)
370
+ const inCursor =
371
+ cursorIdx !== null &&
372
+ (kind === 'bits'
385
373
  ? cursorIdx >= axisIdxForCell && cursorIdx < axisIdxForCell + 32
386
- : axisIdxForCell === cursorIdx
387
- )
374
+ : axisIdxForCell === cursorIdx)
388
375
  cells.push(
389
376
  <span
390
377
  class={`sample-cell${inCursor ? ' sample-cell-cursor' : ''}`}
@@ -392,7 +379,7 @@ function SampleGrid({
392
379
  onClick={() => onPinIdx(axisIdxForCell)}
393
380
  >
394
381
  {float ? fmtFloatFixed(v) : v.toString()}
395
- </span>,
382
+ </span>
396
383
  )
397
384
  }
398
385
  rows.push(
@@ -406,13 +393,13 @@ function SampleGrid({
406
393
  >
407
394
  <span class="sample-index">{i}</span>
408
395
  {cells}
409
- </div>,
396
+ </div>
410
397
  )
411
398
  }
412
399
 
413
400
  const onScroll = (e: Event): void => {
414
401
  const el = e.currentTarget as HTMLDivElement
415
- setViewport((v) => v.scrollTop === el.scrollTop ? v : { ...v, scrollTop: el.scrollTop })
402
+ setViewport((v) => (v.scrollTop === el.scrollTop ? v : { ...v, scrollTop: el.scrollTop }))
416
403
  reportScrollIdx(el.scrollTop, el.clientHeight)
417
404
  }
418
405
 
@@ -458,7 +445,10 @@ function computeStats(entry: RegistryEntrySnapshot): { summary: string } {
458
445
  let ones = 0
459
446
  for (let i = 0; i < data.length; i++) {
460
447
  let w = data[i]! >>> 0
461
- while (w !== 0) { ones += w & 1; w >>>= 1 }
448
+ while (w !== 0) {
449
+ ones += w & 1
450
+ w >>>= 1
451
+ }
462
452
  }
463
453
  return { summary: `${ones} / ${count * 32} set` }
464
454
  }
@@ -485,9 +475,7 @@ function computeStats(entry: RegistryEntrySnapshot): { summary: string } {
485
475
  sum += v
486
476
  }
487
477
  const mean = sum / n
488
- const f = (v: number) => kind === 'uint' || kind === 'int'
489
- ? Math.round(v).toString()
490
- : v.toFixed(2)
478
+ const f = (v: number) => (kind === 'uint' || kind === 'int' ? Math.round(v).toString() : v.toFixed(2))
491
479
  return { summary: `μ=${f(mean)} ${f(min)}–${f(max)}` }
492
480
  }
493
481
 
@@ -502,7 +490,7 @@ function drawVisualizer(
502
490
  w: number,
503
491
  h: number,
504
492
  entry: RegistryEntrySnapshot,
505
- cursorIdx: number | null = null,
493
+ cursorIdx: number | null = null
506
494
  ): void {
507
495
  ctx.clearRect(0, 0, w, h)
508
496
  const { kind, sample } = entry
@@ -551,7 +539,7 @@ function drawBars(
551
539
  h: number,
552
540
  sample: Float32Array | Int32Array | Uint32Array,
553
541
  isSigned: boolean,
554
- color: string,
542
+ color: string
555
543
  ): void {
556
544
  let min = Infinity
557
545
  let max = -Infinity
@@ -564,9 +552,7 @@ function drawBars(
564
552
  if (!Number.isFinite(min) || !Number.isFinite(max)) return
565
553
 
566
554
  const zeroY = isSigned ? h / 2 : h
567
- const scale = isSigned
568
- ? (h / 2) / Math.max(Math.abs(min), Math.abs(max) || 1)
569
- : h / (max || 1)
555
+ const scale = isSigned ? h / 2 / Math.max(Math.abs(min), Math.abs(max) || 1) : h / (max || 1)
570
556
 
571
557
  ctx.fillStyle = color
572
558
 
@@ -623,7 +609,7 @@ function drawBits(
623
609
  h: number,
624
610
  sample: Uint32Array,
625
611
  count: number,
626
- color: string,
612
+ color: string
627
613
  ): void {
628
614
  const totalBits = count * 32
629
615
  if (totalBits === 0) return