@three-flatland/devtools 0.1.0-alpha.3

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 (148) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +82 -0
  3. package/dist/buffers-modal.cjs +491 -0
  4. package/dist/buffers-modal.cjs.map +1 -0
  5. package/dist/buffers-modal.d.cts +62 -0
  6. package/dist/buffers-modal.d.ts +62 -0
  7. package/dist/buffers-modal.js +467 -0
  8. package/dist/buffers-modal.js.map +1 -0
  9. package/dist/buffers-view.cjs +269 -0
  10. package/dist/buffers-view.cjs.map +1 -0
  11. package/dist/buffers-view.d.cts +39 -0
  12. package/dist/buffers-view.d.ts +39 -0
  13. package/dist/buffers-view.js +245 -0
  14. package/dist/buffers-view.js.map +1 -0
  15. package/dist/create-pane.cjs +203 -0
  16. package/dist/create-pane.cjs.map +1 -0
  17. package/dist/create-pane.d.cts +66 -0
  18. package/dist/create-pane.d.ts +66 -0
  19. package/dist/create-pane.js +178 -0
  20. package/dist/create-pane.js.map +1 -0
  21. package/dist/dashboard/app.tsx +42 -0
  22. package/dist/dashboard/client.ts +21 -0
  23. package/dist/dashboard/export.ts +84 -0
  24. package/dist/dashboard/hooks.ts +75 -0
  25. package/dist/dashboard/index.html +1149 -0
  26. package/dist/dashboard/index.tsx +18 -0
  27. package/dist/dashboard/panels/batches.tsx +282 -0
  28. package/dist/dashboard/panels/buffers.tsx +425 -0
  29. package/dist/dashboard/panels/env.tsx +85 -0
  30. package/dist/dashboard/panels/header-stats.tsx +30 -0
  31. package/dist/dashboard/panels/producer-select.tsx +81 -0
  32. package/dist/dashboard/panels/protocol-log.tsx +743 -0
  33. package/dist/dashboard/panels/registry.tsx +642 -0
  34. package/dist/dashboard/panels/stats.tsx +431 -0
  35. package/dist/dashboard/protocol-store.ts +312 -0
  36. package/dist/dashboard/tsconfig.json +19 -0
  37. package/dist/dashboard/vendor/hooks.module.js +1 -0
  38. package/dist/dashboard/vendor/jsx-runtime.js +22 -0
  39. package/dist/dashboard/vendor/preact.module.js +12 -0
  40. package/dist/devtools-client.cjs +870 -0
  41. package/dist/devtools-client.cjs.map +1 -0
  42. package/dist/devtools-client.d.cts +362 -0
  43. package/dist/devtools-client.d.ts +362 -0
  44. package/dist/devtools-client.js +852 -0
  45. package/dist/devtools-client.js.map +1 -0
  46. package/dist/index.cjs +42 -0
  47. package/dist/index.cjs.map +1 -0
  48. package/dist/index.d.cts +8 -0
  49. package/dist/index.d.ts +8 -0
  50. package/dist/index.js +13 -0
  51. package/dist/index.js.map +1 -0
  52. package/dist/perf-trace.cjs +103 -0
  53. package/dist/perf-trace.cjs.map +1 -0
  54. package/dist/perf-trace.d.cts +27 -0
  55. package/dist/perf-trace.d.ts +27 -0
  56. package/dist/perf-trace.js +79 -0
  57. package/dist/perf-trace.js.map +1 -0
  58. package/dist/plugins.cjs +44 -0
  59. package/dist/plugins.cjs.map +1 -0
  60. package/dist/plugins.d.cts +7 -0
  61. package/dist/plugins.d.ts +7 -0
  62. package/dist/plugins.js +9 -0
  63. package/dist/plugins.js.map +1 -0
  64. package/dist/provider-switcher.cjs +95 -0
  65. package/dist/provider-switcher.cjs.map +1 -0
  66. package/dist/provider-switcher.d.cts +18 -0
  67. package/dist/provider-switcher.d.ts +18 -0
  68. package/dist/provider-switcher.js +71 -0
  69. package/dist/provider-switcher.js.map +1 -0
  70. package/dist/react/devtools-provider.cjs +59 -0
  71. package/dist/react/devtools-provider.cjs.map +1 -0
  72. package/dist/react/devtools-provider.d.cts +39 -0
  73. package/dist/react/devtools-provider.d.ts +39 -0
  74. package/dist/react/devtools-provider.js +38 -0
  75. package/dist/react/devtools-provider.js.map +1 -0
  76. package/dist/react/use-fps-graph.cjs +49 -0
  77. package/dist/react/use-fps-graph.cjs.map +1 -0
  78. package/dist/react/use-fps-graph.d.cts +15 -0
  79. package/dist/react/use-fps-graph.d.ts +15 -0
  80. package/dist/react/use-fps-graph.js +25 -0
  81. package/dist/react/use-fps-graph.js.map +1 -0
  82. package/dist/react/use-pane-button.cjs +58 -0
  83. package/dist/react/use-pane-button.cjs.map +1 -0
  84. package/dist/react/use-pane-button.d.cts +10 -0
  85. package/dist/react/use-pane-button.d.ts +10 -0
  86. package/dist/react/use-pane-button.js +34 -0
  87. package/dist/react/use-pane-button.js.map +1 -0
  88. package/dist/react/use-pane-folder.cjs +48 -0
  89. package/dist/react/use-pane-folder.cjs.map +1 -0
  90. package/dist/react/use-pane-folder.d.cts +15 -0
  91. package/dist/react/use-pane-folder.d.ts +15 -0
  92. package/dist/react/use-pane-folder.js +24 -0
  93. package/dist/react/use-pane-folder.js.map +1 -0
  94. package/dist/react/use-pane-input.cjs +70 -0
  95. package/dist/react/use-pane-input.cjs.map +1 -0
  96. package/dist/react/use-pane-input.d.cts +44 -0
  97. package/dist/react/use-pane-input.d.ts +44 -0
  98. package/dist/react/use-pane-input.js +46 -0
  99. package/dist/react/use-pane-input.js.map +1 -0
  100. package/dist/react/use-pane-radio-grid.cjs +78 -0
  101. package/dist/react/use-pane-radio-grid.cjs.map +1 -0
  102. package/dist/react/use-pane-radio-grid.d.cts +35 -0
  103. package/dist/react/use-pane-radio-grid.d.ts +35 -0
  104. package/dist/react/use-pane-radio-grid.js +54 -0
  105. package/dist/react/use-pane-radio-grid.js.map +1 -0
  106. package/dist/react/use-pane.cjs +45 -0
  107. package/dist/react/use-pane.cjs.map +1 -0
  108. package/dist/react/use-pane.d.cts +40 -0
  109. package/dist/react/use-pane.d.ts +40 -0
  110. package/dist/react/use-pane.js +21 -0
  111. package/dist/react/use-pane.js.map +1 -0
  112. package/dist/react.cjs +50 -0
  113. package/dist/react.cjs.map +1 -0
  114. package/dist/react.d.cts +12 -0
  115. package/dist/react.d.ts +12 -0
  116. package/dist/react.js +19 -0
  117. package/dist/react.js.map +1 -0
  118. package/dist/registry-view.cjs +371 -0
  119. package/dist/registry-view.cjs.map +1 -0
  120. package/dist/registry-view.d.cts +24 -0
  121. package/dist/registry-view.d.ts +24 -0
  122. package/dist/registry-view.js +347 -0
  123. package/dist/registry-view.js.map +1 -0
  124. package/dist/stats-graph.cjs +297 -0
  125. package/dist/stats-graph.cjs.map +1 -0
  126. package/dist/stats-graph.d.cts +40 -0
  127. package/dist/stats-graph.d.ts +40 -0
  128. package/dist/stats-graph.js +273 -0
  129. package/dist/stats-graph.js.map +1 -0
  130. package/dist/stats-row.cjs +153 -0
  131. package/dist/stats-row.cjs.map +1 -0
  132. package/dist/stats-row.d.cts +24 -0
  133. package/dist/stats-row.d.ts +24 -0
  134. package/dist/stats-row.js +129 -0
  135. package/dist/stats-row.js.map +1 -0
  136. package/dist/theme.cjs +260 -0
  137. package/dist/theme.cjs.map +1 -0
  138. package/dist/theme.d.cts +10 -0
  139. package/dist/theme.d.ts +10 -0
  140. package/dist/theme.js +235 -0
  141. package/dist/theme.js.map +1 -0
  142. package/dist/vite-plugin.cjs +113 -0
  143. package/dist/vite-plugin.cjs.map +1 -0
  144. package/dist/vite-plugin.d.cts +9 -0
  145. package/dist/vite-plugin.d.ts +9 -0
  146. package/dist/vite-plugin.js +88 -0
  147. package/dist/vite-plugin.js.map +1 -0
  148. package/package.json +124 -0
@@ -0,0 +1,425 @@
1
+ /** @jsxImportSource preact */
2
+ /**
3
+ * Buffers panel — live preview of registered debug textures.
4
+ *
5
+ * Same transport as the Tweakpane modal: when an entry is selected the
6
+ * panel subscribes in `stream` mode so the provider ships VP9-encoded
7
+ * frames through the worker. Decoded frames draw into a canvas which
8
+ * aspect-fits to the available main area and rescales on ResizeObserver.
9
+ *
10
+ * Thumbnail mode (downsampled `buffer:raw` payloads) is used as a fast
11
+ * fallback when WebCodecs isn't available — the worker converts to RGBA8
12
+ * and the panel paints via putImageData. Both paths share `fitCanvas`
13
+ * so CSS sizing stays consistent across the mode switch.
14
+ *
15
+ * Multiple dashboard consumers (or the tweakpane modal) subscribing to
16
+ * different entries is fine — the producer unions selections server-side.
17
+ */
18
+ import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
19
+ import type { BufferChunkPayload } from '../../devtools-client.js'
20
+ import { getClient } from '../client.js'
21
+ import { useDevtoolsState } from '../hooks.js'
22
+
23
+ const VP9_CODEC = 'vp09.00.10.08'
24
+ const CODEC_AVAILABLE = typeof globalThis.VideoDecoder !== 'undefined'
25
+
26
+ const MIN_ZOOM = 0.5
27
+ const MAX_ZOOM = 64
28
+
29
+ interface PixelProbe {
30
+ x: number
31
+ y: number
32
+ r: number
33
+ g: number
34
+ b: number
35
+ a: number
36
+ }
37
+
38
+ export function BuffersPanel() {
39
+ const state = useDevtoolsState()
40
+ const client = getClient()
41
+ const [selectedName, setSelectedName] = useState<string | null>(null)
42
+ const [filter, setFilter] = useState('')
43
+
44
+ const canvasRef = useRef<HTMLCanvasElement | null>(null)
45
+ const mainRef = useRef<HTMLDivElement | null>(null)
46
+
47
+ const [zoom, setZoom] = useState(1)
48
+ const [pan, setPan] = useState({ x: 0, y: 0 })
49
+ const [probe, setProbe] = useState<PixelProbe | null>(null)
50
+ const dragStateRef = useRef<{ active: boolean; startX: number; startY: number; panX: number; panY: number }>({
51
+ active: false,
52
+ startX: 0,
53
+ startY: 0,
54
+ panX: 0,
55
+ panY: 0,
56
+ })
57
+
58
+ // Stable sorted buffer list.
59
+ const entries = useMemo(() => {
60
+ const arr = Array.from(state.buffers.values())
61
+ arr.sort((a, b) => a.name.localeCompare(b.name))
62
+ return arr
63
+ }, [state.buffers, state.buffers.size])
64
+
65
+ const needle = filter.trim().toLowerCase()
66
+ const visible = needle.length > 0
67
+ ? entries.filter((e) => e.name.toLowerCase().includes(needle) || (e.label?.toLowerCase().includes(needle) ?? false))
68
+ : entries
69
+
70
+ // Only clear the selection if the currently-selected name has
71
+ // vanished from the registry. Never auto-pick — selection must be
72
+ // explicit so we don't silently subscribe a producer in stream mode
73
+ // the moment a buffer shows up. Unselected = nothing streaming, no
74
+ // GPU readback or VP9 encode on the producer side.
75
+ const effectiveSelected = selectedName !== null && visible.some((e) => e.name === selectedName)
76
+ ? selectedName
77
+ : null
78
+
79
+ // Subscribe/unsubscribe to stream mode based on selection. The dashboard
80
+ // is a fresh DevtoolsClient consumer so this subscription is additive
81
+ // to whatever the tweakpane pane happens to want — the provider unions
82
+ // selections across consumers.
83
+ useEffect(() => {
84
+ if (effectiveSelected === null) {
85
+ client.setBuffers({})
86
+ return
87
+ }
88
+ client.setBuffers({ [effectiveSelected]: { mode: 'stream' } })
89
+ return () => { client.setBuffers({}) }
90
+ }, [client, effectiveSelected])
91
+
92
+ // WebCodecs decoder lifecycle — one instance per active selection.
93
+ // We keep decoder state in refs (not component state) because it's
94
+ // imperative by nature and should not trigger re-renders.
95
+ const decoderRef = useRef<VideoDecoder | null>(null)
96
+ const decoderDimsRef = useRef({ w: 0, h: 0 })
97
+ const waitingForKeyFrameRef = useRef(true)
98
+ const canvasSrcRef = useRef({ w: 0, h: 0 })
99
+
100
+ const fitCanvas = (srcW: number, srcH: number): void => {
101
+ const canvas = canvasRef.current
102
+ const main = mainRef.current
103
+ if (canvas === null || main === null || srcW <= 0 || srcH <= 0) return
104
+ const rect = main.getBoundingClientRect()
105
+ const maxW = Math.max(1, rect.width - 16)
106
+ const maxH = Math.max(1, rect.height - 16)
107
+ const srcAspect = srcW / srcH
108
+ const boxAspect = maxW / maxH
109
+ let cssW: number
110
+ let cssH: number
111
+ if (srcAspect > boxAspect) {
112
+ cssW = maxW
113
+ cssH = Math.max(1, Math.round(maxW / srcAspect))
114
+ } else {
115
+ cssH = maxH
116
+ cssW = Math.max(1, Math.round(maxH * srcAspect))
117
+ }
118
+ canvas.style.width = `${cssW}px`
119
+ canvas.style.height = `${cssH}px`
120
+ }
121
+
122
+ const stopDecoder = (): void => {
123
+ const d = decoderRef.current
124
+ if (d !== null && d.state !== 'closed') {
125
+ try { d.close() } catch { /* may already be errored */ }
126
+ }
127
+ decoderRef.current = null
128
+ decoderDimsRef.current = { w: 0, h: 0 }
129
+ waitingForKeyFrameRef.current = true
130
+ }
131
+
132
+ const startDecoder = (w: number, h: number): void => {
133
+ stopDecoder()
134
+ if (!CODEC_AVAILABLE) return
135
+ const canvas = canvasRef.current
136
+ if (canvas === null) return
137
+ const ctx = canvas.getContext('2d')
138
+ if (ctx === null) return
139
+ waitingForKeyFrameRef.current = true
140
+ const decoder = new globalThis.VideoDecoder({
141
+ output: (frame) => {
142
+ if (canvas.width !== frame.codedWidth || canvas.height !== frame.codedHeight) {
143
+ canvas.width = frame.codedWidth
144
+ canvas.height = frame.codedHeight
145
+ }
146
+ canvasSrcRef.current = { w: frame.codedWidth, h: frame.codedHeight }
147
+ fitCanvas(frame.codedWidth, frame.codedHeight)
148
+ ctx.drawImage(frame as unknown as CanvasImageSource, 0, 0)
149
+ frame.close()
150
+ },
151
+ error: () => {
152
+ stopDecoder()
153
+ },
154
+ })
155
+ decoder.configure({ codec: VP9_CODEC, codedWidth: w, codedHeight: h })
156
+ decoderRef.current = decoder
157
+ decoderDimsRef.current = { w, h }
158
+ }
159
+
160
+ // Wire chunk listener. Filters for the active selection; reconfigures
161
+ // the decoder when the source dimensions change (common on resize of
162
+ // the producer's render target).
163
+ useEffect(() => {
164
+ if (effectiveSelected === null) {
165
+ stopDecoder()
166
+ return
167
+ }
168
+ const activeName = effectiveSelected
169
+ const unsub = client.addChunkListener((chunk: BufferChunkPayload) => {
170
+ if (chunk.name !== activeName) return
171
+ if (
172
+ decoderRef.current === null ||
173
+ chunk.width !== decoderDimsRef.current.w ||
174
+ chunk.height !== decoderDimsRef.current.h
175
+ ) {
176
+ startDecoder(chunk.width, chunk.height)
177
+ }
178
+ const d = decoderRef.current
179
+ if (d === null) return
180
+ if (waitingForKeyFrameRef.current && !chunk.keyFrame) return
181
+ if (chunk.keyFrame) waitingForKeyFrameRef.current = false
182
+ try {
183
+ const enc = new EncodedVideoChunk({
184
+ type: chunk.keyFrame ? 'key' : 'delta',
185
+ timestamp: chunk.capturedAt * 1000,
186
+ data: chunk.data,
187
+ })
188
+ d.decode(enc)
189
+ } catch {
190
+ stopDecoder()
191
+ }
192
+ })
193
+ return () => {
194
+ unsub()
195
+ stopDecoder()
196
+ }
197
+ }, [client, effectiveSelected])
198
+
199
+ // Thumbnail fallback: when WebCodecs isn't available the worker sends
200
+ // `buffer:raw` payloads which land on `state.buffers[name].pixels`.
201
+ // Paint those when the stream path isn't decoding.
202
+ useEffect(() => {
203
+ if (CODEC_AVAILABLE) return
204
+ if (effectiveSelected === null) return
205
+ const snap = state.buffers.get(effectiveSelected)
206
+ if (snap === undefined || snap.pixels === null) return
207
+ const canvas = canvasRef.current
208
+ if (canvas === null) return
209
+ const ctx = canvas.getContext('2d')
210
+ if (ctx === null) return
211
+ if (canvas.width !== snap.width || canvas.height !== snap.height) {
212
+ canvas.width = snap.width
213
+ canvas.height = snap.height
214
+ }
215
+ canvasSrcRef.current = {
216
+ w: snap.srcWidth > 0 ? snap.srcWidth : snap.width,
217
+ h: snap.srcHeight > 0 ? snap.srcHeight : snap.height,
218
+ }
219
+ fitCanvas(canvasSrcRef.current.w, canvasSrcRef.current.h)
220
+ if (snap.pixels instanceof Uint8Array) {
221
+ const img = ctx.createImageData(snap.width, snap.height)
222
+ img.data.set(snap.pixels.subarray(0, img.data.length))
223
+ ctx.putImageData(img, 0, 0)
224
+ }
225
+ }, [state.buffers, effectiveSelected])
226
+
227
+ // Re-fit on layout changes (panel resize, sidebar collapse, etc.).
228
+ useEffect(() => {
229
+ const main = mainRef.current
230
+ if (main === null) return
231
+ const obs = new ResizeObserver(() => {
232
+ const { w, h } = canvasSrcRef.current
233
+ if (w > 0 && h > 0) fitCanvas(w, h)
234
+ })
235
+ obs.observe(main)
236
+ return () => obs.disconnect()
237
+ }, [])
238
+
239
+ // Reset view when selection changes so each buffer opens 1:1 centered.
240
+ useEffect(() => {
241
+ setZoom(1)
242
+ setPan({ x: 0, y: 0 })
243
+ setProbe(null)
244
+ }, [effectiveSelected])
245
+
246
+ const onWheel = (e: WheelEvent): void => {
247
+ e.preventDefault()
248
+ const main = mainRef.current
249
+ if (main === null) return
250
+ const factor = e.deltaY < 0 ? 1.2 : 1 / 1.2
251
+ const nextZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom * factor))
252
+ if (nextZoom === zoom) return
253
+ // Anchor zoom to the cursor: compute the canvas-space point under
254
+ // the cursor, then adjust pan so that same point remains under it.
255
+ const rect = main.getBoundingClientRect()
256
+ const cx = e.clientX - rect.left - rect.width / 2
257
+ const cy = e.clientY - rect.top - rect.height / 2
258
+ const scale = nextZoom / zoom
259
+ setPan({
260
+ x: cx - (cx - pan.x) * scale,
261
+ y: cy - (cy - pan.y) * scale,
262
+ })
263
+ setZoom(nextZoom)
264
+ }
265
+
266
+ const onMouseDown = (e: MouseEvent): void => {
267
+ if (e.button !== 0) return
268
+ dragStateRef.current = {
269
+ active: true,
270
+ startX: e.clientX,
271
+ startY: e.clientY,
272
+ panX: pan.x,
273
+ panY: pan.y,
274
+ }
275
+ }
276
+ useEffect(() => {
277
+ const onMove = (e: MouseEvent): void => {
278
+ if (!dragStateRef.current.active) return
279
+ setPan({
280
+ x: dragStateRef.current.panX + (e.clientX - dragStateRef.current.startX),
281
+ y: dragStateRef.current.panY + (e.clientY - dragStateRef.current.startY),
282
+ })
283
+ }
284
+ const onUp = (): void => {
285
+ dragStateRef.current.active = false
286
+ }
287
+ window.addEventListener('mousemove', onMove)
288
+ window.addEventListener('mouseup', onUp)
289
+ return () => {
290
+ window.removeEventListener('mousemove', onMove)
291
+ window.removeEventListener('mouseup', onUp)
292
+ }
293
+ }, [])
294
+
295
+ // Pixel probe: translate the cursor's screen-space position into the
296
+ // canvas's backing-buffer coordinates, then `getImageData(1,1)` at
297
+ // that point. Drawn into a tooltip overlay. Runs on every mousemove
298
+ // over the canvas — keep the read cheap (1×1 pixel).
299
+ const onCanvasMove = (e: MouseEvent): void => {
300
+ const canvas = canvasRef.current
301
+ if (canvas === null) return
302
+ const ctx = canvas.getContext('2d', { willReadFrequently: true })
303
+ if (ctx === null) return
304
+ const rect = canvas.getBoundingClientRect()
305
+ const u = (e.clientX - rect.left) / rect.width
306
+ const v = (e.clientY - rect.top) / rect.height
307
+ if (u < 0 || u > 1 || v < 0 || v > 1) { setProbe(null); return }
308
+ const px = Math.min(canvas.width - 1, Math.max(0, Math.floor(u * canvas.width)))
309
+ const py = Math.min(canvas.height - 1, Math.max(0, Math.floor(v * canvas.height)))
310
+ try {
311
+ const data = ctx.getImageData(px, py, 1, 1).data
312
+ setProbe({ x: px, y: py, r: data[0]!, g: data[1]!, b: data[2]!, a: data[3]! })
313
+ } catch {
314
+ // Some frame formats (e.g. YUV) produce canvases that refuse
315
+ // readback; silently drop the probe.
316
+ setProbe(null)
317
+ }
318
+ }
319
+ const onCanvasLeave = (): void => setProbe(null)
320
+
321
+ const resetView = (): void => {
322
+ setZoom(1)
323
+ setPan({ x: 0, y: 0 })
324
+ }
325
+
326
+ const selectedSnap = effectiveSelected !== null ? state.buffers.get(effectiveSelected) ?? null : null
327
+
328
+ return (
329
+ <section class="panel buffers-panel">
330
+ <header class="panel-header buffers-header">
331
+ <span>Buffers</span>
332
+ <input
333
+ type="text"
334
+ class="protocol-filter"
335
+ placeholder="filter…"
336
+ value={filter}
337
+ onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
338
+ />
339
+ <span class="registry-count">{entries.length}</span>
340
+ </header>
341
+ <div class="buffers-layout">
342
+ <ul class="buffers-list">
343
+ {visible.length === 0 ? (
344
+ <li class="panel-empty">No buffers{needle.length > 0 ? ' match' : ' yet'}.</li>
345
+ ) : (
346
+ visible.map((e) => (
347
+ <li key={e.name}>
348
+ <button
349
+ type="button"
350
+ class={
351
+ 'buffers-row' +
352
+ (e.name === effectiveSelected ? ' buffers-row-selected' : '')
353
+ }
354
+ onClick={() => setSelectedName((prev) => (prev === e.name ? null : e.name))}
355
+ title={e.name === effectiveSelected ? 'Click again to stop streaming' : 'Stream this buffer'}
356
+ >
357
+ <span class="buffers-name">{e.name}</span>
358
+ <span class="buffers-meta">
359
+ {(e.srcWidth > 0 ? e.srcWidth : e.width)}×
360
+ {(e.srcHeight > 0 ? e.srcHeight : e.height)} · {e.pixelType}
361
+ </span>
362
+ </button>
363
+ </li>
364
+ ))
365
+ )}
366
+ </ul>
367
+ <div
368
+ class="buffers-stage"
369
+ ref={mainRef}
370
+ onWheel={selectedSnap !== null ? onWheel : undefined}
371
+ onMouseDown={selectedSnap !== null ? onMouseDown : undefined}
372
+ >
373
+ {selectedSnap === null ? (
374
+ <div class="panel-empty">
375
+ {entries.length === 0 ? 'No buffers registered.' : 'Pick a buffer to stream.'}
376
+ </div>
377
+ ) : (
378
+ <>
379
+ <canvas
380
+ ref={canvasRef}
381
+ class="buffers-canvas"
382
+ style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` }}
383
+ onMouseMove={onCanvasMove}
384
+ onMouseLeave={onCanvasLeave}
385
+ />
386
+ <div class="buffers-info">
387
+ <span>{selectedSnap.name}</span>
388
+ <span>
389
+ {selectedSnap.srcWidth > 0 ? selectedSnap.srcWidth : selectedSnap.width}×
390
+ {selectedSnap.srcHeight > 0 ? selectedSnap.srcHeight : selectedSnap.height}
391
+ </span>
392
+ <span>{selectedSnap.pixelType}</span>
393
+ <span>{selectedSnap.display}</span>
394
+ {!CODEC_AVAILABLE && <span class="buffers-warn">no WebCodecs — thumbnail fallback</span>}
395
+ <span class="buffers-zoom">{zoom === 1 ? '1.0×' : `${zoom < 10 ? zoom.toFixed(1) : Math.round(zoom)}×`}</span>
396
+ </div>
397
+ {probe !== null && (
398
+ <div class="buffers-probe">
399
+ <span>({probe.x}, {probe.y})</span>
400
+ <span class="probe-chip" style={{ background: `rgb(${probe.r}, ${probe.g}, ${probe.b})` }} />
401
+ <span>{probe.r} {probe.g} {probe.b} {probe.a}</span>
402
+ </div>
403
+ )}
404
+ {(zoom !== 1 || pan.x !== 0 || pan.y !== 0) && (
405
+ <button
406
+ type="button"
407
+ class="buffers-reset"
408
+ onClick={resetView}
409
+ title="Reset zoom & pan"
410
+ >⟲</button>
411
+ )}
412
+ <button
413
+ type="button"
414
+ class="buffers-close"
415
+ onClick={() => setSelectedName(null)}
416
+ title="Stop streaming"
417
+ aria-label="Stop streaming"
418
+ >×</button>
419
+ </>
420
+ )}
421
+ </div>
422
+ </div>
423
+ </section>
424
+ )
425
+ }
@@ -0,0 +1,85 @@
1
+ /** @jsxImportSource preact */
2
+ /**
3
+ * Environment popover — compact info chip in the header that opens a
4
+ * floating list of runtime details on click. Lives in the header so it
5
+ * doesn't eat vertical space in the main content area; this info is
6
+ * reference-only, not something you watch tick by tick.
7
+ */
8
+ import { useEffect, useRef, useState } from 'preact/hooks'
9
+ import { useDevtoolsState } from '../hooks.js'
10
+
11
+ function fmtMB(v: number | undefined): string {
12
+ return v === undefined ? '—' : `${Math.round(v)} MB`
13
+ }
14
+
15
+ function fmtBool(v: boolean | undefined | null): string {
16
+ if (v === undefined) return '—'
17
+ if (v === null) return 'disjoint'
18
+ return v ? 'yes' : 'no'
19
+ }
20
+
21
+ export function EnvPopover() {
22
+ const s = useDevtoolsState()
23
+ const [open, setOpen] = useState(false)
24
+ const rootRef = useRef<HTMLDivElement | null>(null)
25
+
26
+ useEffect(() => {
27
+ if (!open) return
28
+ const onDocClick = (e: MouseEvent) => {
29
+ if (rootRef.current !== null && !rootRef.current.contains(e.target as Node)) setOpen(false)
30
+ }
31
+ const onKey = (e: KeyboardEvent) => {
32
+ if (e.key === 'Escape') setOpen(false)
33
+ }
34
+ document.addEventListener('mousedown', onDocClick)
35
+ document.addEventListener('keydown', onKey)
36
+ return () => {
37
+ document.removeEventListener('mousedown', onDocClick)
38
+ document.removeEventListener('keydown', onKey)
39
+ }
40
+ }, [open])
41
+
42
+ const canvas = s.canvasWidth !== undefined && s.canvasHeight !== undefined
43
+ ? `${s.canvasWidth}×${s.canvasHeight}${s.canvasPixelRatio !== undefined ? ` @${s.canvasPixelRatio}x` : ''}`
44
+ : '—'
45
+
46
+ const summary = s.backendName !== undefined
47
+ ? s.backendName
48
+ : 'Env'
49
+
50
+ return (
51
+ <div class="env-popover" ref={rootRef}>
52
+ <button
53
+ type="button"
54
+ class={`env-trigger${open ? ' env-trigger-open' : ''}`}
55
+ onClick={() => setOpen((o) => !o)}
56
+ title="Runtime environment"
57
+ >
58
+ <span>Env</span>
59
+ <span class="env-trigger-summary">{summary}</span>
60
+ <span class="env-caret">{open ? '▲' : '▼'}</span>
61
+ </button>
62
+ {open && (
63
+ <div class="env-menu">
64
+ <Row label="three-flatland" value={s.threeFlatlandVersion ?? '—'} />
65
+ <Row label="three revision" value={s.threeRevision ?? '—'} />
66
+ <Row label="backend" value={s.backendName ?? '—'} />
67
+ <Row label="canvas" value={canvas} />
68
+ <Row label="gpu mode" value={fmtBool(s.gpuModeEnabled)} />
69
+ <Row label="timestamps" value={fmtBool(s.backendTrackTimestamp)} />
70
+ <Row label="disjoint" value={fmtBool(s.backendDisjoint)} />
71
+ <Row label="heap" value={`${fmtMB(s.heapUsedMB)} / ${fmtMB(s.heapLimitMB)}`} />
72
+ </div>
73
+ )}
74
+ </div>
75
+ )
76
+ }
77
+
78
+ function Row({ label, value }: { label: string; value: string }) {
79
+ return (
80
+ <div class="env-row">
81
+ <span class="env-label">{label}</span>
82
+ <span class="env-value">{value}</span>
83
+ </div>
84
+ )
85
+ }
@@ -0,0 +1,30 @@
1
+ /** @jsxImportSource preact */
2
+ /**
3
+ * Header byte counters — running totals of inbound/outbound bytes seen
4
+ * since session start. Updates ride the shared dashboard rAF tick so
5
+ * high message rates don't thrash the header on their own.
6
+ */
7
+ import { getProtocolStore } from '../protocol-store.js'
8
+ import { useFrameTick } from '../hooks.js'
9
+
10
+ function fmtBytes(n: number): string {
11
+ if (n < 1024) return `${n} B`
12
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
13
+ if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`
14
+ return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`
15
+ }
16
+
17
+ export function HeaderStats() {
18
+ useFrameTick()
19
+ const store = getProtocolStore()
20
+ return (
21
+ <div class="header-stats">
22
+ <span title="Bytes received from providers">
23
+ <span class="header-arrow-in">↓</span> {fmtBytes(store.bytesIn)}
24
+ </span>
25
+ <span title="Bytes sent to providers">
26
+ <span class="header-arrow-out">↑</span> {fmtBytes(store.bytesOut)}
27
+ </span>
28
+ </div>
29
+ )
30
+ }
@@ -0,0 +1,81 @@
1
+ /** @jsxImportSource preact */
2
+ /**
3
+ * Producer dropdown — custom-styled selector, since native `<select>`
4
+ * open state is still OS-controlled in all shipping browsers (Chrome's
5
+ * `appearance: base-select` is behind a flag).
6
+ *
7
+ * Closes on outside click and on Escape. The currently-selected provider
8
+ * comes from `DevtoolsState.selectedProviderId`.
9
+ */
10
+ import { useEffect, useRef, useState } from 'preact/hooks'
11
+ import { getClient } from '../client.js'
12
+ import { useDevtoolsState } from '../hooks.js'
13
+
14
+ export function ProducerSelect() {
15
+ const state = useDevtoolsState()
16
+ const client = getClient()
17
+ const [open, setOpen] = useState(false)
18
+ const rootRef = useRef<HTMLDivElement | null>(null)
19
+
20
+ useEffect(() => {
21
+ if (!open) return
22
+ const onDocClick = (e: MouseEvent) => {
23
+ if (rootRef.current !== null && !rootRef.current.contains(e.target as Node)) setOpen(false)
24
+ }
25
+ const onKey = (e: KeyboardEvent) => {
26
+ if (e.key === 'Escape') setOpen(false)
27
+ }
28
+ document.addEventListener('mousedown', onDocClick)
29
+ document.addEventListener('keydown', onKey)
30
+ return () => {
31
+ document.removeEventListener('mousedown', onDocClick)
32
+ document.removeEventListener('keydown', onKey)
33
+ }
34
+ }, [open])
35
+
36
+ const selected = state.providers.find((p) => p.id === state.selectedProviderId) ?? null
37
+ const label = selected !== null ? selected.name : 'No producer'
38
+
39
+ return (
40
+ <div class="producer-select" ref={rootRef}>
41
+ <button
42
+ type="button"
43
+ class={`producer-button${open ? ' producer-button-open' : ''}`}
44
+ onClick={() => setOpen((o) => !o)}
45
+ disabled={state.providers.length === 0}
46
+ >
47
+ <span class={state.serverAlive && selected !== null ? 'dot dot-live' : 'dot'} />
48
+ <span class="producer-label">{label}</span>
49
+ {selected !== null && <span class="producer-kind">{selected.kind}</span>}
50
+ <span class="producer-caret">{open ? '▲' : '▼'}</span>
51
+ </button>
52
+ {open && (
53
+ <ul class="producer-menu">
54
+ {state.providers.length === 0 ? (
55
+ <li class="panel-empty">No producers announced.</li>
56
+ ) : (
57
+ state.providers.map((p) => {
58
+ const active = p.id === state.selectedProviderId
59
+ return (
60
+ <li key={p.id}>
61
+ <button
62
+ type="button"
63
+ class={`producer-option${active ? ' producer-option-active' : ''}`}
64
+ onClick={() => {
65
+ client.selectProvider(p.id)
66
+ setOpen(false)
67
+ }}
68
+ >
69
+ <span class="producer-check">{active ? '✓' : ''}</span>
70
+ <span class="producer-label">{p.name}</span>
71
+ <span class="producer-kind">{p.kind}</span>
72
+ </button>
73
+ </li>
74
+ )
75
+ })
76
+ )}
77
+ </ul>
78
+ )}
79
+ </div>
80
+ )
81
+ }