@volter/editor-game 0.5.65

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 (302) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +23 -0
  3. package/contributions/asset-budget-asset.menu.ts +26 -0
  4. package/contributions/asset-budget.action.ts +18 -0
  5. package/contributions/asset-budget.document.tsx +21 -0
  6. package/contributions/asset-budget.menu.ts +22 -0
  7. package/contributions/audio-unlock.service.ts +17 -0
  8. package/contributions/audio.utility.tsx +15 -0
  9. package/contributions/autoplay.service.ts +48 -0
  10. package/contributions/bridge.command.ts +172 -0
  11. package/contributions/build-profiles.document.tsx +21 -0
  12. package/contributions/build-progress.status.tsx +45 -0
  13. package/contributions/build.action.ts +26 -0
  14. package/contributions/build.command.ts +27 -0
  15. package/contributions/build.header.tsx +37 -0
  16. package/contributions/build.menu.ts +31 -0
  17. package/contributions/build.service.ts +31 -0
  18. package/contributions/connection.status.tsx +43 -0
  19. package/contributions/coverage.service.ts +118 -0
  20. package/contributions/edit-mode-audio.service.ts +23 -0
  21. package/contributions/edit-mode-networking.service.ts +30 -0
  22. package/contributions/game-document.service.ts +25 -0
  23. package/contributions/game-eval.command.ts +210 -0
  24. package/contributions/game.layout.ts +19 -0
  25. package/contributions/gameplay.command.ts +255 -0
  26. package/contributions/generation.service.ts +96 -0
  27. package/contributions/generations.status.tsx +53 -0
  28. package/contributions/ingest.service.ts +80 -0
  29. package/contributions/instances.command.ts +75 -0
  30. package/contributions/navmesh.menu.ts +39 -0
  31. package/contributions/navmesh.service.ts +19 -0
  32. package/contributions/network.utility.tsx +17 -0
  33. package/contributions/play.command.ts +355 -0
  34. package/contributions/profiler.action.ts +16 -0
  35. package/contributions/profiler.menu.ts +22 -0
  36. package/contributions/profiler.utility.tsx +17 -0
  37. package/contributions/react/component-board.service.ts +21 -0
  38. package/contributions/react/design-time-mount.service.ts +60 -0
  39. package/contributions/react/pasteboard.action.ts +41 -0
  40. package/contributions/react/react-inspector.service.ts +85 -0
  41. package/contributions/react/story-documents.service.ts +44 -0
  42. package/contributions/scene-document.service.ts +32 -0
  43. package/contributions/state-watch.action.ts +17 -0
  44. package/contributions/state-watch.menu.ts +23 -0
  45. package/contributions/state-watch.utility.tsx +20 -0
  46. package/contributions/team-playtest.service.ts +124 -0
  47. package/contributions/three/camera-runtime.inspector.tsx +34 -0
  48. package/contributions/three/component-board.service.ts +24 -0
  49. package/contributions/three/component-verbs.command.ts +110 -0
  50. package/contributions/three/component-verbs.service.ts +92 -0
  51. package/contributions/three/constraints.inspector.tsx +34 -0
  52. package/contributions/three/model-asset-sections.service.ts +26 -0
  53. package/contributions/three/reflection-probe-capture.inspector.tsx +32 -0
  54. package/contributions/three/story-documents.service.ts +31 -0
  55. package/contributions/three/three-authoring.service.ts +66 -0
  56. package/contributions/transport.header.tsx +19 -0
  57. package/contributions/xstate-behavior.action.ts +42 -0
  58. package/contributions/xstate-behavior.document.tsx +73 -0
  59. package/contributions/xstate-behavior.inspector.tsx +28 -0
  60. package/contributions/xstate-behavior.menu.ts +23 -0
  61. package/package.json +144 -0
  62. package/src/asset-budget/AssetBudgetPanel.tsx +1172 -0
  63. package/src/asset-budget/asset-budget-model.ts +799 -0
  64. package/src/asset-budget/basis-encoder.ts +165 -0
  65. package/src/asset-budget/gltf-io.ts +154 -0
  66. package/src/asset-budget/gltf-optimize.ts +384 -0
  67. package/src/asset-budget/image-dims.ts +78 -0
  68. package/src/asset-budget/optimize-apply.ts +221 -0
  69. package/src/audio/AudioDebuggerPanel.tsx +457 -0
  70. package/src/audio/audio-debugger-model.ts +87 -0
  71. package/src/bridge/call.ts +60 -0
  72. package/src/bridge/dispatch.ts +459 -0
  73. package/src/bridge/live-frames.ts +25 -0
  74. package/src/bridge/screenshot.ts +316 -0
  75. package/src/build/BuildProfilesPanel.tsx +476 -0
  76. package/src/build/build-session.ts +247 -0
  77. package/src/build/format-bytes.ts +14 -0
  78. package/src/command-results.ts +36 -0
  79. package/src/coverage/live-authoring-surface.ts +30 -0
  80. package/src/coverage/live-project-verbs.ts +162 -0
  81. package/src/coverage/native-system-coverage.ts +143 -0
  82. package/src/coverage/root-coverage.ts +79 -0
  83. package/src/coverage/session-coverage.ts +193 -0
  84. package/src/design-system-stories/ApplicationChrome.stories.tsx +100 -0
  85. package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +406 -0
  86. package/src/edit-mode/edit-mode-audio.ts +56 -0
  87. package/src/edit-mode/edit-mode-networking.ts +112 -0
  88. package/src/game-document/DevicePresetPicker.tsx +84 -0
  89. package/src/game-document/GameCaptureFrameButton.tsx +45 -0
  90. package/src/game-document/GameDocument.tsx +215 -0
  91. package/src/game-document/GamePanel.tsx +662 -0
  92. package/src/game-document/InstanceInspectorPicker.tsx +183 -0
  93. package/src/game-document/crowd-debug.ts +183 -0
  94. package/src/game-document/device-preview.ts +336 -0
  95. package/src/game-document/game-view-store.ts +107 -0
  96. package/src/game-document/physics-debug.ts +187 -0
  97. package/src/generation/GenerationActivity.tsx +421 -0
  98. package/src/generation/GenerationGallery.css +231 -0
  99. package/src/generation/generation-documents.tsx +338 -0
  100. package/src/generation/generation-jobs.ts +128 -0
  101. package/src/generation/generation-presentation.ts +257 -0
  102. package/src/host/adapter-reach.ts +445 -0
  103. package/src/host/adapter-runtime-bindings.ts +303 -0
  104. package/src/host/after-paint.ts +112 -0
  105. package/src/host/api/configurations.ts +66 -0
  106. package/src/host/authoring/babylon-authoring-adapter.ts +703 -0
  107. package/src/host/authoring/canvas-runtime-recognition.ts +50 -0
  108. package/src/host/authoring/contract-hierarchy-authoring.ts +144 -0
  109. package/src/host/authoring/contract-scenes-stories.ts +204 -0
  110. package/src/host/authoring/creation-site-related.ts +55 -0
  111. package/src/host/authoring/ephemeral-persistence.ts +29 -0
  112. package/src/host/authoring/gesture-persist.ts +84 -0
  113. package/src/host/authoring/ingest-data-writer.ts +232 -0
  114. package/src/host/authoring/ingest-source-persistence.ts +826 -0
  115. package/src/host/authoring/mount-isolated-pixi-screen.ts +250 -0
  116. package/src/host/authoring/mounted-authoring.ts +41 -0
  117. package/src/host/authoring/owned-pixi-ticker-listeners.ts +96 -0
  118. package/src/host/authoring/phaser-live-authoring-adapter.ts +265 -0
  119. package/src/host/authoring/pixi-authoring-adapter.ts +1554 -0
  120. package/src/host/authoring/pixi-creation-site-write-target.ts +60 -0
  121. package/src/host/authoring/pixi-isolation-assets.ts +25 -0
  122. package/src/host/authoring/pixi-live-write-target.ts +979 -0
  123. package/src/host/authoring/pixi-source-identity.ts +141 -0
  124. package/src/host/authoring/pixi-still-presentation.ts +71 -0
  125. package/src/host/authoring/pixi-structure-history.ts +237 -0
  126. package/src/host/authoring/pixi-transform-channels.ts +205 -0
  127. package/src/host/authoring/selection-remount-handoff.ts +23 -0
  128. package/src/host/authoring/source-persistence-backend.ts +373 -0
  129. package/src/host/authoring/source-refresh-revisions.ts +81 -0
  130. package/src/host/authoring/struct-write-pipe.ts +143 -0
  131. package/src/host/auto-frame-window.ts +89 -0
  132. package/src/host/binding-resolver.ts +393 -0
  133. package/src/host/browser-transpile.ts +631 -0
  134. package/src/host/canvas-entry-runtime.ts +95 -0
  135. package/src/host/components/CameraAuthoringOverlay.tsx +216 -0
  136. package/src/host/components/HeaderTelemetry.tsx +341 -0
  137. package/src/host/components/PixiIsolationSceneContent.tsx +280 -0
  138. package/src/host/components/ResolutionPicker.tsx +89 -0
  139. package/src/host/components/ThreeIsolationSceneContent.tsx +180 -0
  140. package/src/host/components/frame-debugger-model.ts +579 -0
  141. package/src/host/components/header-telemetry-model.ts +74 -0
  142. package/src/host/components/scene-document.tsx +447 -0
  143. package/src/host/components/utility-view-state.ts +87 -0
  144. package/src/host/components/world-root-stage-binding.tsx +133 -0
  145. package/src/host/components/world-root-stage.ts +1046 -0
  146. package/src/host/coverage/authoring-read-probe.ts +583 -0
  147. package/src/host/coverage/capability-coverage.ts +1587 -0
  148. package/src/host/coverage/coverage-accounting.ts +261 -0
  149. package/src/host/coverage/game-contract-seam-evidence.ts +82 -0
  150. package/src/host/coverage/project-verb-coverage.ts +148 -0
  151. package/src/host/coverage/system-adapter-coverage.ts +373 -0
  152. package/src/host/design-system-stories/StoryLayout.tsx +104 -0
  153. package/src/host/design-system-stories/fixtures/authoring.ts +247 -0
  154. package/src/host/design-system-stories/fixtures/editor-runtime.tsx +115 -0
  155. package/src/host/document-preview-three.ts +110 -0
  156. package/src/host/entry-adjudication.ts +89 -0
  157. package/src/host/game-location-guard.ts +150 -0
  158. package/src/host/game-module-access.ts +196 -0
  159. package/src/host/game-realm-page.ts +358 -0
  160. package/src/host/game-realm-reclaim.ts +55 -0
  161. package/src/host/game-realm-storage.ts +104 -0
  162. package/src/host/gameplay-export.ts +288 -0
  163. package/src/host/gameplay-recording.ts +717 -0
  164. package/src/host/gated-globals.ts +1511 -0
  165. package/src/host/history/json-history-resource.ts +254 -0
  166. package/src/host/ingest/registry.ts +261 -0
  167. package/src/host/instance-extract-actions.ts +120 -0
  168. package/src/host/instance-fork-actions.ts +120 -0
  169. package/src/host/play-control-hook.ts +26 -0
  170. package/src/host/playwright-shim.ts +479 -0
  171. package/src/host/projection/dom.ts +295 -0
  172. package/src/host/projection/pixi.ts +288 -0
  173. package/src/host/r3f-entry-runtime.ts +77 -0
  174. package/src/host/react-mount-runtime.ts +162 -0
  175. package/src/host/realm-services.ts +148 -0
  176. package/src/host/recording-preview.ts +106 -0
  177. package/src/host/roots/module-root.ts +203 -0
  178. package/src/host/roots/react-root.ts +181 -0
  179. package/src/host/same-realm-loop-gate.ts +544 -0
  180. package/src/host/scene-view-drawability.ts +69 -0
  181. package/src/host/sdk/tools.ts +31 -0
  182. package/src/host/served-bundle-runtime-modules.ts +331 -0
  183. package/src/host/server-log-bridge.ts +60 -0
  184. package/src/host/staged-projects.ts +24 -0
  185. package/src/host/stories/mounted-story-viewport-source.ts +64 -0
  186. package/src/host/stories/story-arg-descriptors.ts +50 -0
  187. package/src/host/stories/story-media-presence.ts +152 -0
  188. package/src/host/surface-content.ts +87 -0
  189. package/src/host/take-named-export.ts +23 -0
  190. package/src/host/three-ingest-runtime.ts +76 -0
  191. package/src/host/types-fastnoise-lite.d.ts +7 -0
  192. package/src/host/types-mikktspace.d.ts +20 -0
  193. package/src/host/types-troika-three-text.d.ts +7 -0
  194. package/src/host/use-active-performance-source.ts +53 -0
  195. package/src/host/viewport-pose-memory.ts +48 -0
  196. package/src/host/viewport-root-presentation.ts +40 -0
  197. package/src/ingest/active-ingest.ts +251 -0
  198. package/src/ingest/active-scene-navigation.ts +43 -0
  199. package/src/ingest/authoring/ingest-dom-surface-authoring.ts +191 -0
  200. package/src/ingest/authoring/ingest-root-adapter.ts +897 -0
  201. package/src/ingest/capture-wait-report.ts +122 -0
  202. package/src/ingest/deferred-ingest-play.ts +216 -0
  203. package/src/ingest/deferred-ingest-session.ts +23 -0
  204. package/src/ingest/discovery-public-ingest.ts +242 -0
  205. package/src/ingest/dom-stub-mark.ts +7 -0
  206. package/src/ingest/entry-load.ts +56 -0
  207. package/src/ingest/game-contract-realm.ts +34 -0
  208. package/src/ingest/game-pointer-lock.ts +89 -0
  209. package/src/ingest/held-scene-repaint.ts +73 -0
  210. package/src/ingest/host-surface-box.ts +174 -0
  211. package/src/ingest/ingest-boot-viewport.ts +54 -0
  212. package/src/ingest/ingest-canvas-scene-document.tsx +177 -0
  213. package/src/ingest/ingest-canvas-scene.ts +50 -0
  214. package/src/ingest/ingest-evidence-hook.ts +131 -0
  215. package/src/ingest/ingest-frame-snapshot.ts +183 -0
  216. package/src/ingest/ingest-play-commands.ts +134 -0
  217. package/src/ingest/ingest-play-control.ts +299 -0
  218. package/src/ingest/ingest-render-debug.ts +320 -0
  219. package/src/ingest/ingest-siblings.ts +487 -0
  220. package/src/ingest/ingest-status.ts +62 -0
  221. package/src/ingest/live-ingest-facet.ts +46 -0
  222. package/src/ingest/module-mode.ts +229 -0
  223. package/src/ingest/mount-canvas-ingest-root.ts +899 -0
  224. package/src/ingest/mount-coverage.ts +296 -0
  225. package/src/ingest/mount-dom-ingest-root.ts +282 -0
  226. package/src/ingest/mount-ingest-root.ts +744 -0
  227. package/src/ingest/mount-three-ingest-root.ts +366 -0
  228. package/src/ingest/resolve-canvas.ts +47 -0
  229. package/src/ingest/resolve-three.ts +123 -0
  230. package/src/ingest/served-bundle.ts +109 -0
  231. package/src/ingest/served-html-boot.ts +292 -0
  232. package/src/ingest/surface-canvas.ts +84 -0
  233. package/src/ingest/surface-dom.ts +84 -0
  234. package/src/ingest/surface-three.ts +128 -0
  235. package/src/ingest/types.ts +76 -0
  236. package/src/ingest/unmount-ingest-root.ts +224 -0
  237. package/src/navmesh/navmesh-actions.ts +27 -0
  238. package/src/navmesh/navmesh-handler.ts +237 -0
  239. package/src/navmesh/navmesh-workflow-store.ts +78 -0
  240. package/src/network/NetworkInspectorPanel.tsx +644 -0
  241. package/src/network/network-inspector-model.ts +225 -0
  242. package/src/play/play-boot-stall.ts +118 -0
  243. package/src/play/play-log-events.ts +26 -0
  244. package/src/play/play-mode.ts +2553 -0
  245. package/src/play/play-recording.ts +335 -0
  246. package/src/play/react-play-live-authoring.ts +165 -0
  247. package/src/play-bar/PlayBar.tsx +488 -0
  248. package/src/play-bar/PlayerCountPicker.tsx +100 -0
  249. package/src/profiler/FrameDebuggerPanel.tsx +458 -0
  250. package/src/profiler/PerformancePanel.tsx +1068 -0
  251. package/src/profiler/ProfilerPanel.tsx +53 -0
  252. package/src/profiler/frame-debugger-store.ts +97 -0
  253. package/src/profiler/main-thread-busy.ts +90 -0
  254. package/src/react/design-time-react-mount.ts +782 -0
  255. package/src/react/dom-authoring-adapter.ts +764 -0
  256. package/src/react/pasteboard-materialize.ts +154 -0
  257. package/src/react/react-inspector-section.tsx +2496 -0
  258. package/src/react/react-world-authoring-adapter.ts +3795 -0
  259. package/src/react/story-documents/story-args-section.ts +19 -0
  260. package/src/react/story-documents/story-documents.tsx +1380 -0
  261. package/src/react/story-paint-bounds.ts +94 -0
  262. package/src/react/ui-board-document.tsx +101 -0
  263. package/src/react/ui-board-title.ts +9 -0
  264. package/src/react/ui-component-board.ts +72 -0
  265. package/src/services/audio-pose-guard.ts +81 -0
  266. package/src/services/game-audio-unlock.ts +48 -0
  267. package/src/state-watch/StateWatchPanel.tsx +535 -0
  268. package/src/three/authoring/camera-runtime-inspector-section.tsx +135 -0
  269. package/src/three/authoring/constraint-inspector-section.tsx +189 -0
  270. package/src/three/authoring/design-time-renderer.ts +189 -0
  271. package/src/three/authoring/model-asset-inspector-section.css +41 -0
  272. package/src/three/authoring/model-asset-inspector-section.tsx +869 -0
  273. package/src/three/authoring/oid-source-persistence.ts +557 -0
  274. package/src/three/authoring/r3f-design-session.ts +1174 -0
  275. package/src/three/authoring/r3f-source-authoring-adapter.ts +5949 -0
  276. package/src/three/authoring/reflection-probe-inspector-section.tsx +76 -0
  277. package/src/three/authoring/spatial-audio-handles.ts +215 -0
  278. package/src/three/authoring/spatial-collider-handles.ts +229 -0
  279. package/src/three/authoring/spatial-joint-handles.ts +114 -0
  280. package/src/three/authoring/spatial-light-handles.ts +178 -0
  281. package/src/three/authoring/spatial-lod-handles.ts +93 -0
  282. package/src/three/authoring/spatial-particle-handles.ts +368 -0
  283. package/src/three/authoring/three-authoring-adapter.ts +1953 -0
  284. package/src/three/authoring/three-scene-identity.ts +19 -0
  285. package/src/three/authoring/three-spatial-handles.ts +161 -0
  286. package/src/three/authoring/typed-three-inspector.ts +528 -0
  287. package/src/three/component-verbs/extract-menu.ts +71 -0
  288. package/src/three/component-verbs/fork-menu.ts +78 -0
  289. package/src/three/component-verbs/internals-menu.ts +91 -0
  290. package/src/three/story-documents/three-story-documents.tsx +607 -0
  291. package/src/three/three-board/ThreeBoardDocument.tsx +888 -0
  292. package/src/three/three-board/board-framing.ts +412 -0
  293. package/src/three/three-board/board-layout.ts +401 -0
  294. package/src/three/three-board/board-scene.ts +901 -0
  295. package/src/three/three-board/three-component-board.ts +62 -0
  296. package/src/xstate/XStateBehaviorSection.tsx +130 -0
  297. package/src/xstate/XStateMachineInspector.tsx +566 -0
  298. package/src/xstate/character-animation-machine.fixture.ts +74 -0
  299. package/src/xstate/live-behaviors.ts +106 -0
  300. package/src/xstate/use-live-actor-state.ts +44 -0
  301. package/src/xstate/xstate-graph.ts +235 -0
  302. package/src/xstate/xstate-layout.ts +76 -0
@@ -0,0 +1,1068 @@
1
+ /**
2
+ * The full Performance analysis UI — since W3 of the workspace-shell program
3
+ * this renders as the Profiler view inside the bottom Debugger. The live
4
+ * active rendered document remains visible above it. The source is selected
5
+ * by workspace document identity, not by assuming Play mode.
6
+ */
7
+
8
+ import {
9
+ aggregateLongAnimationFrames,
10
+ deriveRenderDebugCapabilities,
11
+ type FrameBudget,
12
+ flamegraphRows,
13
+ formatBytes,
14
+ frameHistoryBars,
15
+ type LongAnimationFrameEntryLike,
16
+ offLoopAccount,
17
+ type TimeSpentRow,
18
+ timeSpentRows,
19
+ } from '../host/components/frame-debugger-model';
20
+ import { isJsHeapReading, readJsHeap } from '@volter/editor-core/js-heap';
21
+ import { useActivePerformanceSource } from '../host/use-active-performance-source';
22
+ import { editorHost, useHostAvailabilitySelector } from '@volter/editor-sdk/host';
23
+ import {
24
+ Button,
25
+ EditorBadge,
26
+ EditorBanner,
27
+ EditorTab,
28
+ EditorTabList,
29
+ fontSizeVar,
30
+ spaceVar,
31
+ themeVars,
32
+ } from '@volter/editor-sdk/widgets';
33
+ import { buildChromeTrace } from '@volter/game-runtime/dev/chrome-trace';
34
+ import type {
35
+ PerformanceFrame,
36
+ PerformanceProfiler,
37
+ PerformanceSnapshot,
38
+ } from '@volter/game-runtime/dev/performance-profiler';
39
+ import type { RenderMemorySnapshot } from '@volter/game-runtime/dev/render-memory';
40
+ import { useEffect, useRef, useState } from 'react';
41
+ import { createMainThreadBusySampler, type MainThreadBusySampler } from './main-thread-busy';
42
+
43
+ const EMPTY: PerformanceSnapshot = {
44
+ enabled: false,
45
+ recording: false,
46
+ fps: 0,
47
+ cpuMs: 0,
48
+ p95Ms: 0,
49
+ p99Ms: 0,
50
+ frames: [],
51
+ phases: [],
52
+ systems: [],
53
+ components: [],
54
+ render: {
55
+ gpuMs: null,
56
+ drawCalls: 0,
57
+ triangles: 0,
58
+ geometries: 0,
59
+ textures: 0,
60
+ renderPasses: null,
61
+ },
62
+ };
63
+
64
+ /** Human-readable telemetry cadence; capture itself remains full-frame rate. */
65
+ const DISPLAY_INTERVAL_MS = 500;
66
+
67
+ function downloadCapture(profiler: PerformanceProfiler): void {
68
+ const url = URL.createObjectURL(new Blob([profiler.exportJSON()], { type: 'application/json' }));
69
+ const anchor = document.createElement('a');
70
+ anchor.href = url;
71
+ anchor.download = `vgai-performance-${new Date().toISOString().replaceAll(':', '-')}.json`;
72
+ anchor.click();
73
+ URL.revokeObjectURL(url);
74
+ }
75
+
76
+ /** Export the recorded frames as a Chrome Trace Event Format file
77
+ * (chrome://tracing / Perfetto) — the same download idiom as `downloadCapture`,
78
+ * over the M1 pure exporter. `main` is the only thread we measure on. */
79
+ function downloadTrace(profiler: PerformanceProfiler, processName: string): void {
80
+ const trace = buildChromeTrace(profiler.getSnapshot().frames, {
81
+ processName,
82
+ threadName: 'main',
83
+ });
84
+ const url = URL.createObjectURL(new Blob([JSON.stringify(trace)], { type: 'application/json' }));
85
+ const anchor = document.createElement('a');
86
+ anchor.href = url;
87
+ anchor.download = `vgai-trace-${new Date().toISOString().replaceAll(':', '-')}.json`;
88
+ anchor.click();
89
+ URL.revokeObjectURL(url);
90
+ }
91
+
92
+ type ProfilerView = 'overview' | 'flamegraph' | 'memory';
93
+
94
+ const FLAME_WIDTH = 720;
95
+ const FLAME_ROW_H = 18;
96
+ const STRIP_H = 40;
97
+
98
+ const FLAME_DEPTH_COLORS = [
99
+ themeVars.accent.default,
100
+ themeVars.semantic.success,
101
+ themeVars.semantic.warning,
102
+ themeVars.content.muted,
103
+ ];
104
+
105
+ /** Flamegraph view: a frame-history strip (click to select a frame) over one
106
+ * frame's measured CPU spans as absolutely-positioned rows. GPU is never a
107
+ * row — only measured CPU spans exist; the header prints GPU n/a exactly like
108
+ * the overview. */
109
+ function FlamegraphView({ snapshot }: { snapshot: PerformanceSnapshot }) {
110
+ const frames = snapshot.frames;
111
+ const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
112
+ const effectiveIndex =
113
+ selectedIndex !== null && selectedIndex < frames.length ? selectedIndex : frames.length - 1;
114
+ const frame: PerformanceFrame | undefined = frames[effectiveIndex];
115
+ const bars = frameHistoryBars(frames, FLAME_WIDTH, STRIP_H);
116
+ const rows = frame ? flamegraphRows(frame, FLAME_WIDTH) : [];
117
+ const maxDepth = rows.reduce((d, r) => Math.max(d, r.depth), 0);
118
+
119
+ if (frames.length === 0) {
120
+ return (
121
+ <div
122
+ data-testid="flamegraph-empty"
123
+ style={{ padding: spaceVar[6], color: themeVars.content.muted }}
124
+ >
125
+ No frames recorded yet. Frames accumulate while the profiler is enabled; use{' '}
126
+ <strong>Record capture</strong> to also collect per-component spans.
127
+ </div>
128
+ );
129
+ }
130
+
131
+ return (
132
+ <div data-testid="flamegraph-view" style={{ padding: spaceVar[4] }}>
133
+ <div style={{ marginBottom: spaceVar[4], color: themeVars.content.muted }}>
134
+ Frame <span style={{ color: themeVars.content.primary }}>{frame ? frame.id : '—'}</span> ·{' '}
135
+ {frame ? `${frame.cpuMs.toFixed(2)} ms CPU` : ''} · GPU{' '}
136
+ <span style={{ color: themeVars.content.primary }}>
137
+ {frame && frame.render.gpuMs !== null ? `${frame.render.gpuMs.toFixed(2)} ms` : 'n/a'}
138
+ </span>
139
+ </div>
140
+ {/* Frame-history strip */}
141
+ <div
142
+ data-testid="flame-frame-strip"
143
+ style={{
144
+ position: 'relative',
145
+ width: FLAME_WIDTH,
146
+ height: STRIP_H,
147
+ // §2.31: hairline-bounded strip — the bars carry the data, the
148
+ // panel surface shows through.
149
+ boxShadow: `inset 0 0 0 1px ${themeVars.boundary.default}`,
150
+ marginBottom: spaceVar[5],
151
+ }}
152
+ >
153
+ {bars.map((bar) => (
154
+ // A data-viz bar (not a form control) — a clickable div, the same
155
+ // idiom the Network inspector's tree rows use, so it needs neither a
156
+ // raw native button (product-chrome ban) nor Button paint overrides.
157
+ <div
158
+ key={bar.frameId}
159
+ role="button"
160
+ tabIndex={0}
161
+ data-testid="flame-frame-bar"
162
+ data-frame-index={bar.index}
163
+ data-selected={bar.index === effectiveIndex || undefined}
164
+ title={`frame ${bar.frameId} · ${bar.cpuMs.toFixed(2)} ms`}
165
+ onClick={() => setSelectedIndex(bar.index)}
166
+ onKeyDown={(e) => {
167
+ if (e.key === 'Enter' || e.key === ' ') setSelectedIndex(bar.index);
168
+ }}
169
+ style={{
170
+ position: 'absolute',
171
+ left: bar.x,
172
+ bottom: 0,
173
+ width: Math.max(1, bar.w - 1),
174
+ height: Math.max(1, bar.h),
175
+ cursor: 'pointer',
176
+ background:
177
+ bar.index === effectiveIndex
178
+ ? themeVars.semantic.warning
179
+ : themeVars.accent.default,
180
+ }}
181
+ />
182
+ ))}
183
+ </div>
184
+ {/* Selected frame's flame rows */}
185
+ <div
186
+ data-testid="flamegraph-rows"
187
+ style={{
188
+ position: 'relative',
189
+ width: FLAME_WIDTH,
190
+ height: (maxDepth + 1) * FLAME_ROW_H,
191
+ }}
192
+ >
193
+ {rows.map((row, i) => (
194
+ <div
195
+ key={`${row.depth}-${i}-${row.label}`}
196
+ data-testid="flame-rect"
197
+ data-depth={row.depth}
198
+ data-name={row.label}
199
+ title={`${row.label} · ${row.ms.toFixed(3)} ms`}
200
+ style={{
201
+ position: 'absolute',
202
+ left: row.x,
203
+ top: row.depth * FLAME_ROW_H,
204
+ width: Math.max(1, row.w),
205
+ height: FLAME_ROW_H - 1,
206
+ overflow: 'hidden',
207
+ whiteSpace: 'nowrap',
208
+ fontSize: fontSizeVar.sm,
209
+ lineHeight: `${FLAME_ROW_H - 1}px`,
210
+ paddingLeft: 3,
211
+ color: themeVars.content.onAccent,
212
+ background: FLAME_DEPTH_COLORS[Math.min(row.depth, FLAME_DEPTH_COLORS.length - 1)],
213
+ borderRight: `1px solid ${themeVars.surface.inset}`,
214
+ }}
215
+ >
216
+ {row.label}
217
+ </div>
218
+ ))}
219
+ </div>
220
+ <div
221
+ style={{ marginTop: spaceVar[3], fontSize: fontSizeVar.sm, color: themeVars.content.dim }}
222
+ >
223
+ Rows: frame · phase · system{snapshot.recording ? ' · component (recording)' : ''}. Only
224
+ measured CPU spans are drawn — GPU time is a whole-frame aggregate, never a fabricated lane.
225
+ </div>
226
+ </div>
227
+ );
228
+ }
229
+
230
+ function ByteTable({
231
+ title,
232
+ entries,
233
+ estimated,
234
+ testId,
235
+ }: {
236
+ title: string;
237
+ entries: readonly { name: string; bytes: number }[];
238
+ estimated: boolean;
239
+ testId: string;
240
+ }) {
241
+ return (
242
+ <div
243
+ data-testid={testId}
244
+ style={{
245
+ // §2.31: hairline-only cards — no interior fill over the surface.
246
+ marginTop: spaceVar[5],
247
+ border: `1px solid ${themeVars.boundary.default}`,
248
+ }}
249
+ >
250
+ <div
251
+ style={{
252
+ padding: `${spaceVar[3]} ${spaceVar[5]}`,
253
+ borderBottom: `1px solid ${themeVars.boundary.default}`,
254
+ color: themeVars.content.muted,
255
+ }}
256
+ >
257
+ {title}
258
+ {estimated && (
259
+ <span
260
+ style={{
261
+ marginLeft: spaceVar[3],
262
+ fontSize: fontSizeVar.xs,
263
+ color: themeVars.content.dim,
264
+ }}
265
+ >
266
+ ESTIMATED
267
+ </span>
268
+ )}
269
+ </div>
270
+ {entries.length === 0 ? (
271
+ <div
272
+ style={{
273
+ padding: `${spaceVar[2]} ${spaceVar[5]}`,
274
+ color: themeVars.content.dim,
275
+ fontSize: fontSizeVar.base,
276
+ }}
277
+ >
278
+ none
279
+ </div>
280
+ ) : (
281
+ entries.map((entry) => (
282
+ <div
283
+ key={entry.name}
284
+ style={{
285
+ display: 'flex',
286
+ justifyContent: 'space-between',
287
+ gap: spaceVar[8],
288
+ padding: '3px 10px',
289
+ }}
290
+ >
291
+ <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
292
+ {entry.name}
293
+ </span>
294
+ <span style={{ fontVariantNumeric: 'tabular-nums' }}>{formatBytes(entry.bytes)}</span>
295
+ </div>
296
+ ))
297
+ )}
298
+ </div>
299
+ );
300
+ }
301
+
302
+ /** Memory view: the render-memory snapshot (counts + ESTIMATED VRAM + top-N +
303
+ * the unestimated list) polled at 1 Hz from the render-debug adapter, plus the
304
+ * browser JS-heap tile. Every unmeasurable fact is shown as absent-with-reason,
305
+ * never a fabricated number. */
306
+ function MemoryView() {
307
+ const adapter = useHostAvailabilitySelector(
308
+ () => editorHost().systems.inspected().renderDebug ?? null,
309
+ );
310
+ const caps = deriveRenderDebugCapabilities(adapter);
311
+ const [snap, setSnap] = useState<RenderMemorySnapshot | null>(null);
312
+
313
+ useEffect(() => {
314
+ if (!adapter?.memorySnapshot) {
315
+ setSnap(null);
316
+ return;
317
+ }
318
+ const poll = () => setSnap(adapter.memorySnapshot?.() ?? null);
319
+ poll();
320
+ const id = setInterval(poll, 1000);
321
+ return () => clearInterval(id);
322
+ }, [adapter]);
323
+
324
+ const heap = readJsHeap();
325
+
326
+ return (
327
+ <div style={{ padding: spaceVar[4] }}>
328
+ {/* JS heap tile — browser-only, honest reason when absent. */}
329
+ <div
330
+ data-testid="memory-heap"
331
+ style={{
332
+ border: `1px solid ${themeVars.boundary.default}`,
333
+ padding: spaceVar[5],
334
+ marginBottom: spaceVar[5],
335
+ }}
336
+ >
337
+ <div
338
+ style={{
339
+ color: themeVars.content.dim,
340
+ textTransform: 'uppercase',
341
+ fontSize: fontSizeVar.sm,
342
+ }}
343
+ >
344
+ JS heap (performance.memory)
345
+ </div>
346
+ {isJsHeapReading(heap) ? (
347
+ <div style={{ fontSize: fontSizeVar.xl, color: themeVars.content.primary, marginTop: 3 }}>
348
+ {formatBytes(heap.usedBytes)}{' '}
349
+ <span style={{ fontSize: fontSizeVar.base, color: themeVars.content.muted }}>
350
+ used / {formatBytes(heap.totalBytes)} total
351
+ </span>
352
+ </div>
353
+ ) : (
354
+ <div
355
+ data-testid="memory-heap-unavailable"
356
+ style={{
357
+ fontSize: fontSizeVar.base,
358
+ color: themeVars.content.muted,
359
+ marginTop: 3,
360
+ fontStyle: 'italic',
361
+ }}
362
+ >
363
+ {heap.unavailable}
364
+ </div>
365
+ )}
366
+ </div>
367
+
368
+ {!caps?.memory ? (
369
+ <div
370
+ data-testid="memory-absent"
371
+ style={{
372
+ padding: spaceVar[4],
373
+ color: themeVars.content.muted,
374
+ fontSize: fontSizeVar.base,
375
+ fontStyle: 'italic',
376
+ }}
377
+ >
378
+ Render-memory introspection is not provided by this world's adapter — a headless mount has
379
+ no <code>renderer.info</code>, and a canvas (PixiJS) world's renderer publishes no managed
380
+ geometry collection to measure at all. Enter Play with a first-party three world to
381
+ inspect geometry/texture footprints.
382
+ </div>
383
+ ) : snap === null ? (
384
+ <div
385
+ style={{
386
+ padding: spaceVar[4],
387
+ color: themeVars.content.muted,
388
+ fontSize: fontSizeVar.base,
389
+ }}
390
+ >
391
+ Waiting for the first render-memory sample…
392
+ </div>
393
+ ) : (
394
+ <div data-testid="memory-section">
395
+ <div
396
+ style={{
397
+ display: 'grid',
398
+ gridTemplateColumns: 'repeat(3, minmax(80px, 1fr))',
399
+ gap: spaceVar[4],
400
+ }}
401
+ >
402
+ {[
403
+ ['Geometries', String(snap.counts.geometries)],
404
+ ['Textures', String(snap.counts.textures)],
405
+ [
406
+ 'Programs',
407
+ snap.counts.programs === null
408
+ ? 'n/a until first render'
409
+ : String(snap.counts.programs),
410
+ ],
411
+ ].map(([label, value]) => (
412
+ <div
413
+ key={label}
414
+ style={{
415
+ border: `1px solid ${themeVars.boundary.default}`,
416
+ padding: spaceVar[5],
417
+ }}
418
+ >
419
+ <div
420
+ style={{
421
+ color: themeVars.content.dim,
422
+ textTransform: 'uppercase',
423
+ fontSize: fontSizeVar.sm,
424
+ }}
425
+ >
426
+ {label}
427
+ </div>
428
+ <div
429
+ style={{
430
+ fontSize: fontSizeVar['2xl'],
431
+ color: themeVars.content.primary,
432
+ marginTop: 3,
433
+ }}
434
+ >
435
+ {value}
436
+ </div>
437
+ </div>
438
+ ))}
439
+ </div>
440
+ <div
441
+ data-testid="memory-vram-totals"
442
+ style={{
443
+ display: 'flex',
444
+ gap: 18,
445
+ marginTop: spaceVar[5],
446
+ fontSize: fontSizeVar.base,
447
+ color: themeVars.content.muted,
448
+ }}
449
+ >
450
+ <span>
451
+ geometry buffers{' '}
452
+ <span style={{ color: themeVars.content.primary }}>
453
+ {formatBytes(snap.estimatedGeometryBytes)}
454
+ </span>{' '}
455
+ <span style={{ fontSize: fontSizeVar.xs, color: themeVars.content.dim }}>EXACT</span>
456
+ </span>
457
+ <span>
458
+ texture VRAM{' '}
459
+ <span style={{ color: themeVars.content.primary }}>
460
+ {formatBytes(snap.estimatedTextureBytes)}
461
+ </span>{' '}
462
+ <span style={{ fontSize: fontSizeVar.xs, color: themeVars.content.dim }}>
463
+ ESTIMATED
464
+ </span>
465
+ </span>
466
+ </div>
467
+ <ByteTable
468
+ title="Top geometries"
469
+ entries={snap.topGeometries}
470
+ estimated={false}
471
+ testId="memory-top-geometries"
472
+ />
473
+ <ByteTable
474
+ title="Top textures"
475
+ entries={snap.topTextures}
476
+ estimated
477
+ testId="memory-top-textures"
478
+ />
479
+ {snap.unestimated.length > 0 && (
480
+ <div
481
+ data-testid="memory-unestimated"
482
+ style={{
483
+ marginTop: spaceVar[5],
484
+ border: `1px solid ${themeVars.boundary.default}`,
485
+ }}
486
+ >
487
+ <div
488
+ style={{
489
+ padding: `${spaceVar[3]} ${spaceVar[5]}`,
490
+ borderBottom: `1px solid ${themeVars.boundary.default}`,
491
+ color: themeVars.content.muted,
492
+ }}
493
+ >
494
+ Unmeasured textures ({snap.unestimated.length}) — excluded from the total, not
495
+ zeroed
496
+ </div>
497
+ {snap.unestimated.map((entry) => (
498
+ <div
499
+ key={entry.name}
500
+ style={{
501
+ display: 'flex',
502
+ justifyContent: 'space-between',
503
+ gap: spaceVar[8],
504
+ padding: '3px 10px',
505
+ fontSize: fontSizeVar.base,
506
+ }}
507
+ >
508
+ <span
509
+ style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
510
+ >
511
+ {entry.name}
512
+ </span>
513
+ <span style={{ color: themeVars.content.dim }}>{entry.reason}</span>
514
+ </div>
515
+ ))}
516
+ </div>
517
+ )}
518
+ </div>
519
+ )}
520
+ </div>
521
+ );
522
+ }
523
+
524
+ /**
525
+ * Rolling window of the browser's long-animation-frame entries (frames with
526
+ * >50ms of main-thread work, WITH script attribution) while a profiler
527
+ * source is mounted. The entries land in a ref — the section reads them on
528
+ * the panel's existing 500ms snapshot cadence, so it updates with the same
529
+ * display throttle and freezes with "Freeze view".
530
+ */
531
+ function useLongAnimationFrames(profiler: PerformanceProfiler | null): {
532
+ entries: readonly LongAnimationFrameEntryLike[];
533
+ supported: boolean;
534
+ } {
535
+ const entriesRef = useRef<LongAnimationFrameEntryLike[]>([]);
536
+ const supported =
537
+ typeof PerformanceObserver !== 'undefined' &&
538
+ (PerformanceObserver.supportedEntryTypes ?? []).includes('long-animation-frame');
539
+ useEffect(() => {
540
+ if (!profiler || !supported) return;
541
+ entriesRef.current = [];
542
+ const observer = new PerformanceObserver((list) => {
543
+ const entries = entriesRef.current;
544
+ for (const entry of list.getEntries()) {
545
+ entries.push(entry as unknown as LongAnimationFrameEntryLike);
546
+ }
547
+ if (entries.length > 40) entries.splice(0, entries.length - 40);
548
+ });
549
+ observer.observe({ type: 'long-animation-frame' } as PerformanceObserverInit);
550
+ return () => observer.disconnect();
551
+ }, [profiler, supported]);
552
+ return { entries: entriesRef.current, supported };
553
+ }
554
+
555
+ /**
556
+ * The time-spent tree for the Overview: runs the main-thread busy sampler
557
+ * while a profiler source is mounted and folds it with the profiler's frame
558
+ * window and the LoAF entries. Recomputed on the snapshot's 500ms display
559
+ * cadence; the frame window, the busy-sample window, and the tree's LoAF
560
+ * attribution are the SAME last second, so every level of the tree sums.
561
+ */
562
+ function useTimeSpent(
563
+ profiler: PerformanceProfiler | null,
564
+ snapshot: PerformanceSnapshot,
565
+ loafEntries: readonly LongAnimationFrameEntryLike[],
566
+ ): { rows: readonly TimeSpentRow[]; budget: FrameBudget } | null {
567
+ const samplerRef = useRef<MainThreadBusySampler | null>(null);
568
+ if (samplerRef.current === null) samplerRef.current = createMainThreadBusySampler();
569
+ useEffect(() => {
570
+ const sampler = samplerRef.current;
571
+ if (!profiler || !sampler) return;
572
+ sampler.start();
573
+ return () => sampler.stop();
574
+ }, [profiler]);
575
+ const windowStart = performance.now() - 1000;
576
+ return timeSpentRows(
577
+ snapshot.frames,
578
+ windowStart,
579
+ samplerRef.current.busySince(windowStart),
580
+ loafEntries,
581
+ );
582
+ }
583
+
584
+ /** One tree row: indented label + note, share bar scaled to the frame
585
+ * interval, tabular ms. Parallel (overlapping) measures render dimmed and
586
+ * italic — they annotate the tree, they do not sum into it. */
587
+ function TimeSpentRowView({ row, rootMs }: { row: TimeSpentRow; rootMs: number }) {
588
+ const share = row.ms !== null && rootMs > 0 ? Math.min(1, row.ms / rootMs) : 0;
589
+ const root = row.depth === 0;
590
+ return (
591
+ <div
592
+ style={{
593
+ display: 'grid',
594
+ gridTemplateColumns: '260px 1fr 70px',
595
+ gap: spaceVar[5],
596
+ padding: `${spaceVar[2]} ${spaceVar[5]}`,
597
+ paddingLeft: 10 + row.depth * 16,
598
+ }}
599
+ >
600
+ <span
601
+ style={{
602
+ color: root || row.depth === 1 ? themeVars.content.primary : themeVars.content.muted,
603
+ fontStyle: row.parallel ? 'italic' : undefined,
604
+ overflow: 'hidden',
605
+ textOverflow: 'ellipsis',
606
+ whiteSpace: 'nowrap',
607
+ }}
608
+ title={row.note ? `${row.label} — ${row.note}` : row.label}
609
+ >
610
+ {row.label}
611
+ {row.note && (
612
+ <span
613
+ style={{
614
+ marginLeft: spaceVar[3],
615
+ fontSize: fontSizeVar.sm,
616
+ color: themeVars.content.dim,
617
+ }}
618
+ >
619
+ {row.note}
620
+ </span>
621
+ )}
622
+ </span>
623
+ <div
624
+ style={{
625
+ background: themeVars.surface.raised,
626
+ height: 6,
627
+ marginTop: 5,
628
+ opacity: row.parallel ? 0.45 : 1,
629
+ }}
630
+ >
631
+ <div
632
+ style={{
633
+ width: `${share * 100}%`,
634
+ height: '100%',
635
+ background:
636
+ !root && row.ms !== null && row.ms > 8
637
+ ? themeVars.semantic.warning
638
+ : themeVars.accent.default,
639
+ }}
640
+ />
641
+ </div>
642
+ <span
643
+ style={{
644
+ textAlign: 'right',
645
+ fontVariantNumeric: 'tabular-nums',
646
+ color: root ? themeVars.content.primary : undefined,
647
+ }}
648
+ >
649
+ {row.ms === null ? '—' : `${row.ms.toFixed(1)} ms`}
650
+ </span>
651
+ </div>
652
+ );
653
+ }
654
+
655
+ /**
656
+ * The structural "where the frame goes" tree: one hierarchy, one denominator
657
+ * (mean ms/frame over the last second), every level summing to its parent —
658
+ * frame interval → { scene loop → phases → systems, other main thread →
659
+ * named scripts + unattributed, display pipeline → GPU draw ∥ + compositor/
660
+ * vsync }. Born from the 2026-07-31 glass-chain incident, where "14fps with
661
+ * 1.1ms of render" had no answer in the panel: ~20ms/frame of compositor
662
+ * cost that no phase could ever show. Below the tree, the browser's
663
+ * long-animation-frame report keeps a longer (10s) memory of named
664
+ * main-thread offenders.
665
+ */
666
+ function OffLoopSection({
667
+ snapshot,
668
+ timeSpent,
669
+ entries,
670
+ loafSupported,
671
+ }: {
672
+ snapshot: PerformanceSnapshot;
673
+ timeSpent: { rows: readonly TimeSpentRow[]; budget: FrameBudget } | null;
674
+ entries: readonly LongAnimationFrameEntryLike[];
675
+ loafSupported: boolean;
676
+ }) {
677
+ const budget = timeSpent?.budget ?? null;
678
+ // The prose verdict reads from the SAME window as the tree when one exists
679
+ // (mean-based) — a p95 verdict beside mean rows contradicted itself
680
+ // whenever hitches skewed the tail.
681
+ const account = budget
682
+ ? offLoopAccount(budget.frameMs, budget.loopMs)
683
+ : offLoopAccount(snapshot.p95Ms, snapshot.cpuMs);
684
+ // The named-offender list keeps a longer memory than the tree's 1s window:
685
+ // attribution needs persistence (a 1s window forgets a hitch immediately),
686
+ // but it is time-windowed, not count-windowed — "last 40 entries" silently
687
+ // reached back to boot hitches and printed totals with no denominator.
688
+ const loafWindowMs = 10_000;
689
+ const loaf = aggregateLongAnimationFrames(
690
+ entries.filter((entry) => (entry.startTime ?? 0) >= performance.now() - loafWindowMs),
691
+ );
692
+ if (timeSpent === null && !account.dominated && loaf.longFrameCount === 0) return null;
693
+ return (
694
+ <div
695
+ data-testid="time-spent-section"
696
+ style={{ marginTop: spaceVar[6], border: `1px solid ${themeVars.boundary.default}` }}
697
+ >
698
+ <div
699
+ style={{
700
+ padding: '7px 10px',
701
+ borderBottom: `1px solid ${themeVars.boundary.default}`,
702
+ color: themeVars.content.muted,
703
+ }}
704
+ >
705
+ Where the frame goes
706
+ </div>
707
+ {timeSpent !== null && (
708
+ <div data-testid="time-spent-tree" style={{ paddingBottom: spaceVar[1] }}>
709
+ {timeSpent.rows.map((row) => (
710
+ <TimeSpentRowView
711
+ key={`${row.depth}:${row.label}`}
712
+ row={row}
713
+ rootMs={timeSpent.budget.frameMs}
714
+ />
715
+ ))}
716
+ </div>
717
+ )}
718
+ {account.dominated && (
719
+ <div
720
+ style={{
721
+ padding: `${spaceVar[3]} ${spaceVar[5]}`,
722
+ color: themeVars.content.muted,
723
+ borderTop: `1px solid ${themeVars.boundary.default}`,
724
+ }}
725
+ >
726
+ The measured loop explains less than half of the frame time — the bottleneck is{' '}
727
+ {loaf.scripts.length > 0
728
+ ? 'partly the main-thread work below, and the rest'
729
+ : 'not on the main thread: look at the display pipeline'}{' '}
730
+ (GPU/compositor: post-effects, glass chrome, pixel ratio) or browser throttling.
731
+ </div>
732
+ )}
733
+ {loaf.scripts.length > 0 && (
734
+ <div
735
+ style={{
736
+ padding: '5px 10px 2px',
737
+ borderTop: `1px solid ${themeVars.boundary.default}`,
738
+ fontSize: fontSizeVar.sm,
739
+ color: themeVars.content.dim,
740
+ }}
741
+ >
742
+ Named main-thread offenders — longer memory than the tree (last 10s of long frames)
743
+ </div>
744
+ )}
745
+ {loaf.scripts.map((row) => (
746
+ <div
747
+ key={row.key}
748
+ style={{
749
+ display: 'flex',
750
+ justifyContent: 'space-between',
751
+ gap: spaceVar[8],
752
+ padding: `${spaceVar[2]} ${spaceVar[5]}`,
753
+ }}
754
+ >
755
+ <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
756
+ {row.key}
757
+ </span>
758
+ <span style={{ fontVariantNumeric: 'tabular-nums' }}>
759
+ {row.totalMs} ms ×{row.count}
760
+ </span>
761
+ </div>
762
+ ))}
763
+ {loaf.longFrameCount > 0 && (
764
+ <div
765
+ style={{
766
+ padding: `${spaceVar[2]} ${spaceVar[5]}`,
767
+ fontSize: fontSizeVar.sm,
768
+ color: themeVars.content.dim,
769
+ }}
770
+ >
771
+ {loaf.longFrameCount} long main-thread frame{loaf.longFrameCount === 1 ? '' : 's'} (
772
+ {loaf.totalLongMs} ms total, {loaf.unattributedMs} ms unattributed style/layout/GC) —
773
+ browser long-animation-frame report, last 10s.
774
+ </div>
775
+ )}
776
+ {!loafSupported && (
777
+ <div
778
+ style={{
779
+ padding: `${spaceVar[2]} ${spaceVar[5]}`,
780
+ fontSize: fontSizeVar.sm,
781
+ color: themeVars.content.dim,
782
+ }}
783
+ >
784
+ Main-thread attribution unavailable — this browser does not support long-animation-frame
785
+ timing.
786
+ </div>
787
+ )}
788
+ </div>
789
+ );
790
+ }
791
+
792
+ export function PerformancePanel() {
793
+ const source = useActivePerformanceSource();
794
+ const profiler = source?.profiler ?? null;
795
+ const [snapshot, setSnapshot] = useState<PerformanceSnapshot>(EMPTY);
796
+ const [displayFrozen, setDisplayFrozen] = useState(false);
797
+ const [warningDismissed, setWarningDismissed] = useState(false);
798
+ const [view, setView] = useState<ProfilerView>('overview');
799
+ const displayFrozenRef = useRef(false);
800
+ const loaf = useLongAnimationFrames(profiler);
801
+ const timeSpent = useTimeSpent(profiler, snapshot, loaf.entries);
802
+
803
+ const toggleDisplayFreeze = () => {
804
+ const next = !displayFrozenRef.current;
805
+ displayFrozenRef.current = next;
806
+ setDisplayFrozen(next);
807
+ if (!next && profiler) setSnapshot(profiler.getSnapshot());
808
+ };
809
+
810
+ const toggleProfiler = () => {
811
+ if (!profiler) return;
812
+ const enabled = !profiler.enabled;
813
+ if (enabled) setWarningDismissed(false);
814
+ profiler.enabled = enabled;
815
+ setSnapshot(profiler.getSnapshot());
816
+ };
817
+
818
+ useEffect(() => {
819
+ if (!profiler) {
820
+ setSnapshot(EMPTY);
821
+ return;
822
+ }
823
+ displayFrozenRef.current = false;
824
+ setDisplayFrozen(false);
825
+ setWarningDismissed(false);
826
+ // Opening the Profiler is the explicit opt-in. It measures continuously
827
+ // while this surface is mounted; Record capture adds detailed marks.
828
+ const enabledBeforePanel = profiler.enabled;
829
+ profiler.enabled = true;
830
+ setSnapshot(profiler.getSnapshot());
831
+ let timer: ReturnType<typeof setTimeout> | null = null;
832
+ let lastPublishedAt = performance.now();
833
+ const publish = () => {
834
+ timer = null;
835
+ if (displayFrozenRef.current) return;
836
+ lastPublishedAt = performance.now();
837
+ setSnapshot(profiler.getSnapshot());
838
+ };
839
+ const unsubscribe = profiler.subscribe(() => {
840
+ if (displayFrozenRef.current || timer !== null) return;
841
+ const remaining = Math.max(0, DISPLAY_INTERVAL_MS - (performance.now() - lastPublishedAt));
842
+ timer = setTimeout(publish, remaining);
843
+ });
844
+ return () => {
845
+ unsubscribe();
846
+ if (timer !== null) clearTimeout(timer);
847
+ // Automatic live measurement belongs to this visible instrument. An
848
+ // explicit capture keeps running; a source already enabled elsewhere
849
+ // (for example the Game stats overlay) retains its prior state.
850
+ if (!enabledBeforePanel && !profiler.recording) profiler.enabled = false;
851
+ };
852
+ }, [profiler]);
853
+
854
+ if (!profiler) {
855
+ // B-8: the same lightweight paragraph empty-state treatment the other
856
+ // surfaces (Data/Stories/Dev) give their "nothing here yet".
857
+ return (
858
+ <div
859
+ style={{
860
+ padding: spaceVar[6],
861
+ fontSize: fontSizeVar.md,
862
+ color: themeVars.content.muted,
863
+ maxWidth: 480,
864
+ }}
865
+ >
866
+ <p style={{ margin: 0 }}>
867
+ {editorHost().documents.active()
868
+ ? 'The active document has no live render loop to profile.'
869
+ : 'Open a rendered document to profile it.'}
870
+ </p>
871
+ </div>
872
+ );
873
+ }
874
+
875
+ return (
876
+ <div
877
+ className="vgai-content-frost"
878
+ style={{
879
+ height: '100%',
880
+ overflow: 'auto',
881
+ padding: spaceVar[6],
882
+ color: themeVars.content.primary,
883
+ fontSize: fontSizeVar.md,
884
+ }}
885
+ >
886
+ <div style={{ marginBottom: spaceVar[5], color: themeVars.content.muted }}>
887
+ Profiling <span style={{ color: themeVars.content.primary }}>{source?.label}</span>
888
+ {snapshot.enabled && warningDismissed && (
889
+ <EditorBadge style={{ marginLeft: spaceVar[4] }}>Profiling active</EditorBadge>
890
+ )}
891
+ </div>
892
+ {snapshot.enabled && !warningDismissed && (
893
+ <EditorBanner
894
+ tone="warning"
895
+ style={{ marginBottom: spaceVar[5] }}
896
+ actions={
897
+ <Button size="compact" variant="ghost" onClick={() => setWarningDismissed(true)}>
898
+ Dismiss
899
+ </Button>
900
+ }
901
+ >
902
+ Live profiling is active and adds measurement overhead. Displayed timings include that
903
+ cost
904
+ {snapshot.recording ? '; capture recording adds detailed timing marks' : ''}.
905
+ </EditorBanner>
906
+ )}
907
+ {/* B-5: these four were the dock's one off-palette button family
908
+ (#303030/#454545/#ddd) — now the shared dock button language. */}
909
+ <div
910
+ style={{
911
+ display: 'flex',
912
+ gap: spaceVar[4],
913
+ alignItems: 'center',
914
+ marginBottom: spaceVar[6],
915
+ }}
916
+ >
917
+ <Button size="compact" onClick={toggleDisplayFreeze}>
918
+ {displayFrozen ? 'Resume view' : 'Freeze view'}
919
+ </Button>
920
+ <Button size="compact" onClick={toggleProfiler}>
921
+ {snapshot.enabled ? 'Disable' : 'Enable'} profiler
922
+ </Button>
923
+ <Button
924
+ size="compact"
925
+ onClick={() =>
926
+ snapshot.recording ? profiler.stopRecording() : profiler.startRecording()
927
+ }
928
+ >
929
+ {snapshot.recording ? 'Stop capture' : 'Record capture'}
930
+ </Button>
931
+ <Button size="compact" onClick={() => profiler.clear()}>
932
+ Clear
933
+ </Button>
934
+ <Button size="compact" onClick={() => downloadCapture(profiler)}>
935
+ Export JSON
936
+ </Button>
937
+ <Button
938
+ size="compact"
939
+ data-testid="profiler-export-trace"
940
+ onClick={() => downloadTrace(profiler, source?.label ?? 'vgai')}
941
+ >
942
+ Export Chrome trace
943
+ </Button>
944
+ </div>
945
+ <EditorTabList aria-label="Profiler views" style={{ marginBottom: spaceVar[6] }}>
946
+ {(
947
+ [
948
+ ['overview', 'Overview'],
949
+ ['flamegraph', 'Flamegraph'],
950
+ ['memory', 'Memory'],
951
+ ] as const
952
+ ).map(([id, label]) => (
953
+ <EditorTab
954
+ key={id}
955
+ selected={view === id}
956
+ data-testid={`profiler-view-${id}`}
957
+ onClick={() => setView(id)}
958
+ >
959
+ {label}
960
+ </EditorTab>
961
+ ))}
962
+ </EditorTabList>
963
+ {view === 'flamegraph' && <FlamegraphView snapshot={snapshot} />}
964
+ {view === 'memory' && <MemoryView />}
965
+ {view === 'overview' && (
966
+ <>
967
+ <div
968
+ style={{
969
+ display: 'grid',
970
+ // F9 (U6.5): `repeat(7, …)` forced a 608px min track sum, so in a
971
+ // narrower console card the chip row overflowed horizontally past
972
+ // the card's rounded-glass lens. `auto-fit` lets the chips wrap to
973
+ // the next row when the card is narrow — they never spill past the
974
+ // card bounds, and still lay out as a single 7-up row when wide.
975
+ gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))',
976
+ gap: spaceVar[4],
977
+ }}
978
+ >
979
+ {[
980
+ ['FPS', snapshot.fps.toFixed(0)],
981
+ ['CPU', `${snapshot.cpuMs.toFixed(2)} ms`],
982
+ ['p95 frame', `${snapshot.p95Ms.toFixed(2)} ms`],
983
+ ['p99 frame', `${snapshot.p99Ms.toFixed(2)} ms`],
984
+ [
985
+ 'GPU',
986
+ snapshot.render.gpuMs === null ? 'n/a' : `${snapshot.render.gpuMs.toFixed(2)} ms`,
987
+ ],
988
+ ['Draw calls', String(snapshot.render.drawCalls)],
989
+ ['Triangles', snapshot.render.triangles.toLocaleString()],
990
+ ].map(([label, value]) => (
991
+ <div
992
+ key={label}
993
+ style={{
994
+ border: `1px solid ${themeVars.boundary.default}`,
995
+ padding: spaceVar[5],
996
+ }}
997
+ >
998
+ <div
999
+ style={{
1000
+ color: themeVars.content.dim,
1001
+ textTransform: 'uppercase',
1002
+ fontSize: fontSizeVar.sm,
1003
+ }}
1004
+ >
1005
+ {label}
1006
+ </div>
1007
+ <div style={{ fontSize: 18, color: themeVars.content.primary, marginTop: 3 }}>
1008
+ {value}
1009
+ </div>
1010
+ </div>
1011
+ ))}
1012
+ </div>
1013
+ {/* The time-spent tree ABSORBED the former "Current frame by engine
1014
+ phase" table and the standalone Systems table (owner direction,
1015
+ 2026-07-31: one structural hierarchy instead of three flat views
1016
+ with three different denominators) — phases and their systems now
1017
+ appear as windowed means under "Scene loop (CPU)"; the Flamegraph
1018
+ view remains the per-frame drill-down. */}
1019
+ <OffLoopSection
1020
+ snapshot={snapshot}
1021
+ timeSpent={timeSpent}
1022
+ entries={loaf.entries}
1023
+ loafSupported={loaf.supported}
1024
+ />
1025
+ {[['Components (recording)', snapshot.components]].map(([label, timings]) => (
1026
+ <div
1027
+ key={label as string}
1028
+ style={{
1029
+ marginTop: spaceVar[6],
1030
+ border: `1px solid ${themeVars.boundary.default}`,
1031
+ }}
1032
+ >
1033
+ <div
1034
+ style={{
1035
+ padding: '7px 10px',
1036
+ borderBottom: `1px solid ${themeVars.boundary.default}`,
1037
+ color: themeVars.content.muted,
1038
+ }}
1039
+ >
1040
+ {label as string}
1041
+ </div>
1042
+ {(timings as readonly { name: string; ms: number }[]).slice(0, 12).map((timing) => (
1043
+ <div
1044
+ key={timing.name}
1045
+ style={{
1046
+ display: 'flex',
1047
+ justifyContent: 'space-between',
1048
+ gap: spaceVar[8],
1049
+ padding: `${spaceVar[2]} ${spaceVar[5]}`,
1050
+ }}
1051
+ >
1052
+ <span
1053
+ style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
1054
+ >
1055
+ {timing.name}
1056
+ </span>
1057
+ <span style={{ fontVariantNumeric: 'tabular-nums' }}>
1058
+ {timing.ms.toFixed(2)} ms
1059
+ </span>
1060
+ </div>
1061
+ ))}
1062
+ </div>
1063
+ ))}
1064
+ </>
1065
+ )}
1066
+ </div>
1067
+ );
1068
+ }