@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
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Time-travel frame cursor (#29 Phase A — passive frame-link).
3
+ *
4
+ * `null` = live (panels tail as usual). A number parks every panel at
5
+ * that engine frame: stats read the ring sample at frame ≤ cursor, the
6
+ * protocol log scrolls to the nearest row and stops tailing, buffers
7
+ * freeze (historical playback is the Phase C flight recorder).
8
+ *
9
+ * Cursors are remembered per provider — switching producers restores
10
+ * that producer's parked position (or live).
11
+ */
12
+
13
+ type Listener = () => void
14
+
15
+ const cursorByProvider = new Map<string, number>()
16
+ let activeProviderId: string | null = null
17
+ let liveCursor: number | null = null
18
+ const listeners = new Set<Listener>()
19
+
20
+ function fire(): void {
21
+ for (const cb of listeners) {
22
+ try {
23
+ cb()
24
+ } catch {
25
+ /* listener errors shouldn't break the cursor */
26
+ }
27
+ }
28
+ }
29
+
30
+ export function addFrameCursorListener(cb: Listener): () => void {
31
+ listeners.add(cb)
32
+ return () => {
33
+ listeners.delete(cb)
34
+ }
35
+ }
36
+
37
+ /** The parked frame, or null when live. */
38
+ export function getFrameCursor(): number | null {
39
+ return liveCursor
40
+ }
41
+
42
+ /**
43
+ * Park at a frame (clamped by callers to their known range). Passing
44
+ * `null` deletes the active provider's parked cursor entirely, not
45
+ * just sets it to "live" — the next `setCursorProvider` switch away
46
+ * and back finds no stored entry and comes up live rather than
47
+ * re-parking at the old frame.
48
+ */
49
+ export function setFrameCursor(frame: number | null): void {
50
+ if (liveCursor === frame) return
51
+ liveCursor = frame
52
+ if (activeProviderId !== null) {
53
+ if (frame === null) cursorByProvider.delete(activeProviderId)
54
+ else cursorByProvider.set(activeProviderId, frame)
55
+ }
56
+ fire()
57
+ }
58
+
59
+ /**
60
+ * Return to live. Deletes the active provider's parked cursor (see
61
+ * `setFrameCursor`) — intentional: a manual go-live is a decision to
62
+ * stop watching a specific frame, so switching providers and back
63
+ * should not silently re-park.
64
+ */
65
+ export function goLive(): void {
66
+ setFrameCursor(null)
67
+ }
68
+
69
+ /**
70
+ * Provider switch: remember the outgoing provider's cursor, restore
71
+ * the incoming one's (per-provider cursor semantics).
72
+ */
73
+ export function setCursorProvider(providerId: string | null): void {
74
+ if (activeProviderId === providerId) return
75
+ activeProviderId = providerId
76
+ const restored = providerId !== null ? (cursorByProvider.get(providerId) ?? null) : null
77
+ if (liveCursor !== restored) {
78
+ liveCursor = restored
79
+ fire()
80
+ }
81
+ }
@@ -10,6 +10,7 @@ import { useEffect, useState } from 'preact/hooks'
10
10
  import type { DevtoolsState } from '../devtools-client.js'
11
11
  import { getClient } from './client.js'
12
12
  import { getProtocolStore } from './protocol-store.js'
13
+ import { addFrameCursorListener } from './frame-cursor.js'
13
14
 
14
15
  type Subscriber = () => void
15
16
 
@@ -25,14 +26,20 @@ function scheduleFrame(): void {
25
26
  requestAnimationFrame(() => {
26
27
  _frameScheduled = false
27
28
  for (const cb of _frameListeners) {
28
- try { cb() } catch { /* listener errors shouldn't break the bus */ }
29
+ try {
30
+ cb()
31
+ } catch {
32
+ /* listener errors shouldn't break the bus */
33
+ }
29
34
  }
30
35
  })
31
36
  }
32
37
 
33
38
  function subscribeFrame(cb: Subscriber): () => void {
34
39
  _frameListeners.add(cb)
35
- return () => { _frameListeners.delete(cb) }
40
+ return () => {
41
+ _frameListeners.delete(cb)
42
+ }
36
43
  }
37
44
 
38
45
  // Wire the underlying data sources to the shared rAF once. Any event on
@@ -43,6 +50,7 @@ function ensureSourcesWired(): void {
43
50
  _sourcesWired = true
44
51
  getClient().addListener(scheduleFrame)
45
52
  getProtocolStore().addListener(scheduleFrame)
53
+ addFrameCursorListener(scheduleFrame)
46
54
  }
47
55
 
48
56
  /**
@@ -1140,6 +1140,67 @@
1140
1140
  .registry-panel { flex: initial; height: 300px; }
1141
1141
  .protocol-panel { flex: initial; height: 420px; }
1142
1142
  }
1143
+
1144
+ /* ── Time-travel scrubber (#29 Phase A) ─────────────────────────── */
1145
+ .frame-scrubber {
1146
+ display: flex;
1147
+ align-items: center;
1148
+ gap: 8px;
1149
+ padding: 4px 10px;
1150
+ border-bottom: 1px solid var(--border, #22262e);
1151
+ font-size: 11px;
1152
+ color: var(--fg-muted, #8b93a3);
1153
+ }
1154
+ .frame-scrubber.parked {
1155
+ background: color-mix(in srgb, #d94c87 8%, transparent);
1156
+ }
1157
+ .frame-scrubber .scrub-slider {
1158
+ flex: 1;
1159
+ min-width: 80px;
1160
+ accent-color: #d94c87;
1161
+ }
1162
+ .frame-scrubber .scrub-step,
1163
+ .frame-scrubber .scrub-live {
1164
+ background: none;
1165
+ border: 1px solid var(--border, #22262e);
1166
+ border-radius: 3px;
1167
+ color: inherit;
1168
+ cursor: pointer;
1169
+ font: inherit;
1170
+ padding: 1px 6px;
1171
+ }
1172
+ .frame-scrubber .scrub-live.active {
1173
+ color: #47cca9;
1174
+ border-color: #47cca9;
1175
+ }
1176
+ .frame-scrubber.parked .scrub-live {
1177
+ color: #d94c87;
1178
+ border-color: #d94c87;
1179
+ }
1180
+ .frame-scrubber .scrub-frame {
1181
+ font-variant-numeric: tabular-nums;
1182
+ min-width: 9ch;
1183
+ text-align: right;
1184
+ }
1185
+ .stat-value.stat-parked {
1186
+ color: #d94c87;
1187
+ }
1188
+ .buffers-parked-note,
1189
+ .registry-parked-note {
1190
+ position: absolute;
1191
+ inset-inline: 0;
1192
+ bottom: 0;
1193
+ padding: 4px 8px;
1194
+ font-size: 11px;
1195
+ color: #d94c87;
1196
+ background: color-mix(in srgb, #0d1016 82%, transparent);
1197
+ pointer-events: none;
1198
+ z-index: 2;
1199
+ }
1200
+ .registry-parked-note {
1201
+ position: static;
1202
+ pointer-events: auto;
1203
+ }
1143
1204
  </style>
1144
1205
  </head>
1145
1206
  <body>
@@ -95,9 +95,7 @@ export function BatchesPanel() {
95
95
  run.batches.push({ batchIdx: b.batchIdx, spriteCount: b.spriteCount, label: b.label })
96
96
  }
97
97
  const arr = Array.from(m.values())
98
- arr.sort((a, b) =>
99
- a.kind.localeCompare(b.kind) || a.layer - b.layer || a.materialId - b.materialId,
100
- )
98
+ arr.sort((a, b) => a.kind.localeCompare(b.kind) || a.layer - b.layer || a.materialId - b.materialId)
101
99
  return arr
102
100
  }, [batches.frame])
103
101
 
@@ -131,7 +129,8 @@ export function BatchesPanel() {
131
129
  <header class="panel-header batches-header">
132
130
  <span>Batches</span>
133
131
  <span class="batches-header-meta">
134
- frame {batches.frame} · {passTotals.calls} draws · {runs.length} runs · {batches.batches.length} batches · {totalSprites} sprites
132
+ frame {batches.frame} · {passTotals.calls} draws · {runs.length} runs · {batches.batches.length} batches ·{' '}
133
+ {totalSprites} sprites
135
134
  </span>
136
135
  </header>
137
136
 
@@ -196,12 +195,7 @@ export function BatchesPanel() {
196
195
  <li class="batches-empty">no passes captured</li>
197
196
  ) : (
198
197
  passRoots.map((root) => (
199
- <PassRow
200
- key={root.index}
201
- node={root}
202
- collapsed={collapsedPasses}
203
- onToggle={toggleCollapsed}
204
- />
198
+ <PassRow key={root.index} node={root} collapsed={collapsedPasses} onToggle={toggleCollapsed} />
205
199
  ))
206
200
  )}
207
201
  </ul>
@@ -263,14 +257,7 @@ function PassRow({
263
257
  <span class="batches-col-num">{pass.cpuMs.toFixed(2)}</span>
264
258
  </li>
265
259
  {hasChildren && !isCollapsed
266
- ? children.map((c) => (
267
- <PassRow
268
- key={c.index}
269
- node={c}
270
- collapsed={collapsed}
271
- onToggle={onToggle}
272
- />
273
- ))
260
+ ? children.map((c) => <PassRow key={c.index} node={c} collapsed={collapsed} onToggle={onToggle} />)
274
261
  : null}
275
262
  </>
276
263
  )
@@ -18,6 +18,7 @@
18
18
  import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
19
19
  import type { BufferChunkPayload } from '../../devtools-client.js'
20
20
  import { getClient } from '../client.js'
21
+ import { getFrameCursor } from '../frame-cursor.js'
21
22
  import { useDevtoolsState } from '../hooks.js'
22
23
 
23
24
  const VP9_CODEC = 'vp09.00.10.08'
@@ -63,18 +64,19 @@ export function BuffersPanel() {
63
64
  }, [state.buffers, state.buffers.size])
64
65
 
65
66
  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
67
+ const visible =
68
+ needle.length > 0
69
+ ? entries.filter(
70
+ (e) => e.name.toLowerCase().includes(needle) || (e.label?.toLowerCase().includes(needle) ?? false)
71
+ )
72
+ : entries
69
73
 
70
74
  // Only clear the selection if the currently-selected name has
71
75
  // vanished from the registry. Never auto-pick — selection must be
72
76
  // explicit so we don't silently subscribe a producer in stream mode
73
77
  // the moment a buffer shows up. Unselected = nothing streaming, no
74
78
  // GPU readback or VP9 encode on the producer side.
75
- const effectiveSelected = selectedName !== null && visible.some((e) => e.name === selectedName)
76
- ? selectedName
77
- : null
79
+ const effectiveSelected = selectedName !== null && visible.some((e) => e.name === selectedName) ? selectedName : null
78
80
 
79
81
  // Subscribe/unsubscribe to stream mode based on selection. The dashboard
80
82
  // is a fresh DevtoolsClient consumer so this subscription is additive
@@ -86,7 +88,9 @@ export function BuffersPanel() {
86
88
  return
87
89
  }
88
90
  client.setBuffers({ [effectiveSelected]: { mode: 'stream' } })
89
- return () => { client.setBuffers({}) }
91
+ return () => {
92
+ client.setBuffers({})
93
+ }
90
94
  }, [client, effectiveSelected])
91
95
 
92
96
  // WebCodecs decoder lifecycle — one instance per active selection.
@@ -122,7 +126,11 @@ export function BuffersPanel() {
122
126
  const stopDecoder = (): void => {
123
127
  const d = decoderRef.current
124
128
  if (d !== null && d.state !== 'closed') {
125
- try { d.close() } catch { /* may already be errored */ }
129
+ try {
130
+ d.close()
131
+ } catch {
132
+ /* may already be errored */
133
+ }
126
134
  }
127
135
  decoderRef.current = null
128
136
  decoderDimsRef.current = { w: 0, h: 0 }
@@ -139,6 +147,12 @@ export function BuffersPanel() {
139
147
  waitingForKeyFrameRef.current = true
140
148
  const decoder = new globalThis.VideoDecoder({
141
149
  output: (frame) => {
150
+ // Parked cursor freezes the canvas (decode continues so the
151
+ // delta chain stays valid; we just don't paint).
152
+ if (getFrameCursor() !== null) {
153
+ frame.close()
154
+ return
155
+ }
142
156
  if (canvas.width !== frame.codedWidth || canvas.height !== frame.codedHeight) {
143
157
  canvas.width = frame.codedWidth
144
158
  canvas.height = frame.codedHeight
@@ -201,6 +215,9 @@ export function BuffersPanel() {
201
215
  // Paint those when the stream path isn't decoding.
202
216
  useEffect(() => {
203
217
  if (CODEC_AVAILABLE) return
218
+ // Parked cursor freezes the canvas — the last-drawn pixels stay put
219
+ // until the user returns to live (historical playback is Phase C).
220
+ if (getFrameCursor() !== null) return
204
221
  if (effectiveSelected === null) return
205
222
  const snap = state.buffers.get(effectiveSelected)
206
223
  if (snap === undefined || snap.pixels === null) return
@@ -304,7 +321,10 @@ export function BuffersPanel() {
304
321
  const rect = canvas.getBoundingClientRect()
305
322
  const u = (e.clientX - rect.left) / rect.width
306
323
  const v = (e.clientY - rect.top) / rect.height
307
- if (u < 0 || u > 1 || v < 0 || v > 1) { setProbe(null); return }
324
+ if (u < 0 || u > 1 || v < 0 || v > 1) {
325
+ setProbe(null)
326
+ return
327
+ }
308
328
  const px = Math.min(canvas.width - 1, Math.max(0, Math.floor(u * canvas.width)))
309
329
  const py = Math.min(canvas.height - 1, Math.max(0, Math.floor(v * canvas.height)))
310
330
  try {
@@ -323,7 +343,7 @@ export function BuffersPanel() {
323
343
  setPan({ x: 0, y: 0 })
324
344
  }
325
345
 
326
- const selectedSnap = effectiveSelected !== null ? state.buffers.get(effectiveSelected) ?? null : null
346
+ const selectedSnap = effectiveSelected !== null ? (state.buffers.get(effectiveSelected) ?? null) : null
327
347
 
328
348
  return (
329
349
  <section class="panel buffers-panel">
@@ -347,17 +367,13 @@ export function BuffersPanel() {
347
367
  <li key={e.name}>
348
368
  <button
349
369
  type="button"
350
- class={
351
- 'buffers-row' +
352
- (e.name === effectiveSelected ? ' buffers-row-selected' : '')
353
- }
370
+ class={'buffers-row' + (e.name === effectiveSelected ? ' buffers-row-selected' : '')}
354
371
  onClick={() => setSelectedName((prev) => (prev === e.name ? null : e.name))}
355
372
  title={e.name === effectiveSelected ? 'Click again to stop streaming' : 'Stream this buffer'}
356
373
  >
357
374
  <span class="buffers-name">{e.name}</span>
358
375
  <span class="buffers-meta">
359
- {(e.srcWidth > 0 ? e.srcWidth : e.width)
360
- {(e.srcHeight > 0 ? e.srcHeight : e.height)} · {e.pixelType}
376
+ {e.srcWidth > 0 ? e.srcWidth : e.width}×{e.srcHeight > 0 ? e.srcHeight : e.height} · {e.pixelType}
361
377
  </span>
362
378
  </button>
363
379
  </li>
@@ -383,6 +399,11 @@ export function BuffersPanel() {
383
399
  onMouseMove={onCanvasMove}
384
400
  onMouseLeave={onCanvasLeave}
385
401
  />
402
+ {getFrameCursor() !== null ? (
403
+ <div class="buffers-parked-note">
404
+ parked at frame {getFrameCursor()} — no playback yet (flight recorder lands in Phase C); canvas frozen
405
+ </div>
406
+ ) : null}
386
407
  <div class="buffers-info">
387
408
  <span>{selectedSnap.name}</span>
388
409
  <span>
@@ -392,22 +413,25 @@ export function BuffersPanel() {
392
413
  <span>{selectedSnap.pixelType}</span>
393
414
  <span>{selectedSnap.display}</span>
394
415
  {!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>
416
+ <span class="buffers-zoom">
417
+ {zoom === 1 ? '1.0×' : `${zoom < 10 ? zoom.toFixed(1) : Math.round(zoom)}×`}
418
+ </span>
396
419
  </div>
397
420
  {probe !== null && (
398
421
  <div class="buffers-probe">
399
- <span>({probe.x}, {probe.y})</span>
422
+ <span>
423
+ ({probe.x}, {probe.y})
424
+ </span>
400
425
  <span class="probe-chip" style={{ background: `rgb(${probe.r}, ${probe.g}, ${probe.b})` }} />
401
- <span>{probe.r} {probe.g} {probe.b} {probe.a}</span>
426
+ <span>
427
+ {probe.r} {probe.g} {probe.b} {probe.a}
428
+ </span>
402
429
  </div>
403
430
  )}
404
431
  {(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>
432
+ <button type="button" class="buffers-reset" onClick={resetView} title="Reset zoom & pan">
433
+
434
+ </button>
411
435
  )}
412
436
  <button
413
437
  type="button"
@@ -415,7 +439,9 @@ export function BuffersPanel() {
415
439
  onClick={() => setSelectedName(null)}
416
440
  title="Stop streaming"
417
441
  aria-label="Stop streaming"
418
- >×</button>
442
+ >
443
+ ×
444
+ </button>
419
445
  </>
420
446
  )}
421
447
  </div>
@@ -39,13 +39,12 @@ export function EnvPopover() {
39
39
  }
40
40
  }, [open])
41
41
 
42
- const canvas = s.canvasWidth !== undefined && s.canvasHeight !== undefined
43
- ? `${s.canvasWidth}×${s.canvasHeight}${s.canvasPixelRatio !== undefined ? ` @${s.canvasPixelRatio}x` : ''}`
44
- : ''
42
+ const canvas =
43
+ s.canvasWidth !== undefined && s.canvasHeight !== undefined
44
+ ? `${s.canvasWidth}×${s.canvasHeight}${s.canvasPixelRatio !== undefined ? ` @${s.canvasPixelRatio}x` : ''}`
45
+ : '—'
45
46
 
46
- const summary = s.backendName !== undefined
47
- ? s.backendName
48
- : 'Env'
47
+ const summary = s.backendName !== undefined ? s.backendName : 'Env'
49
48
 
50
49
  return (
51
50
  <div class="env-popover" ref={rootRef}>