@volter/editor-core 0.5.57

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 (719) hide show
  1. package/BUNDLED_NOTICES +1527 -0
  2. package/LICENSE +686 -0
  3. package/LICENSE-APACHE +202 -0
  4. package/NOTICE +18 -0
  5. package/README.md +24 -0
  6. package/dist/build/chunk-JS4D4XZ3.js +175 -0
  7. package/dist/build/chunk-JS4D4XZ3.js.map +7 -0
  8. package/dist/build/vite-plugin-product-contributions.js +100 -0
  9. package/dist/build/vite-plugin-product-contributions.js.map +7 -0
  10. package/dist/build/vite-plugin-shared-react.js +25 -0
  11. package/dist/build/vite-plugin-shared-react.js.map +7 -0
  12. package/dist/build/vite-plugin-shared-three.js +86 -0
  13. package/dist/build/vite-plugin-shared-three.js.map +7 -0
  14. package/dist/server/chunk-KMJC7AGO.js +98 -0
  15. package/dist/server/chunk-KMJC7AGO.js.map +7 -0
  16. package/dist/server/chunk-PSILUMGS.js +46 -0
  17. package/dist/server/chunk-PSILUMGS.js.map +7 -0
  18. package/dist/server/frame-proxy.js +255 -0
  19. package/dist/server/frame-proxy.js.map +7 -0
  20. package/dist/server/open-browser.js +53 -0
  21. package/dist/server/open-browser.js.map +7 -0
  22. package/dist/server/process-shutdown.js +127 -0
  23. package/dist/server/process-shutdown.js.map +7 -0
  24. package/dist/server/session-registry.js +141 -0
  25. package/dist/server/session-registry.js.map +7 -0
  26. package/dist/server/spawn-opener.js +11 -0
  27. package/dist/server/spawn-opener.js.map +7 -0
  28. package/dist/server/worktree-identity.js +11 -0
  29. package/dist/server/worktree-identity.js.map +7 -0
  30. package/dist-server/packaged.mjs +88816 -0
  31. package/package.json +118 -0
  32. package/scripts/build-plugins.mjs +14 -0
  33. package/scripts/build-server.mjs +26 -0
  34. package/scripts/build-session.mjs +9 -0
  35. package/server/account-credentials.ts +60 -0
  36. package/server/account-service.ts +1434 -0
  37. package/server/adapter-region-includes.ts +47 -0
  38. package/server/asset-catalog-v2.ts +175 -0
  39. package/server/asset-history-snapshots.ts +196 -0
  40. package/server/asset-ledger-store.ts +195 -0
  41. package/server/asset-library-routes.ts +1595 -0
  42. package/server/blender-wasm-artifact.ts +430 -0
  43. package/server/boot-timings.ts +106 -0
  44. package/server/canonical-path.ts +50 -0
  45. package/server/checkout-workspace-preflight.ts +349 -0
  46. package/server/cloud-asset-catalog.ts +158 -0
  47. package/server/coding-inference-launch.ts +237 -0
  48. package/server/collaboration-account-client.ts +202 -0
  49. package/server/collaboration-attribution.ts +122 -0
  50. package/server/collaboration-session.ts +934 -0
  51. package/server/comfyui-bridge.ts +248 -0
  52. package/server/console-ledger.ts +499 -0
  53. package/server/creation-site-transform.ts +304 -0
  54. package/server/creation-site-write.ts +648 -0
  55. package/server/data-file-serialize.ts +158 -0
  56. package/server/editor-brand-html.ts +72 -0
  57. package/server/editor-control-socket.ts +366 -0
  58. package/server/editor-server-options.ts +190 -0
  59. package/server/editor-server.ts +1483 -0
  60. package/server/editor-sse.ts +352 -0
  61. package/server/engine-provenance.ts +169 -0
  62. package/server/engine-source-restart.ts +117 -0
  63. package/server/frame-bridge.ts +115 -0
  64. package/server/frame-proxy.ts +490 -0
  65. package/server/frame-workbench.ts +259 -0
  66. package/server/frontend-handoff.ts +229 -0
  67. package/server/game-globals-shadow.ts +113 -0
  68. package/server/gameplay-session-retention.ts +149 -0
  69. package/server/gameplay-sessions.ts +123 -0
  70. package/server/generation-jobs.ts +201 -0
  71. package/server/generation-reconciler.ts +137 -0
  72. package/server/generative-execution-context.ts +59 -0
  73. package/server/git-workflow.ts +612 -0
  74. package/server/harness-chat-caller.ts +36 -0
  75. package/server/harness-chat-service.ts +1681 -0
  76. package/server/idle-shutdown.ts +170 -0
  77. package/server/js-profiling-policy.ts +52 -0
  78. package/server/launcher-settings.ts +84 -0
  79. package/server/local-asset-catalog.ts +410 -0
  80. package/server/managed-account-defaults.ts +22 -0
  81. package/server/mock-control-plane.ts +540 -0
  82. package/server/model-import-conversion.ts +345 -0
  83. package/server/native-credential-store.ts +162 -0
  84. package/server/open-browser.ts +118 -0
  85. package/server/packaged.ts +1192 -0
  86. package/server/play-stall.ts +126 -0
  87. package/server/process-shutdown.ts +218 -0
  88. package/server/product-presets.ts +55 -0
  89. package/server/project-build-artifact.ts +95 -0
  90. package/server/project-components.ts +100 -0
  91. package/server/project-dependency-invalidation.ts +114 -0
  92. package/server/project-file-scan.ts +235 -0
  93. package/server/project-hmr-files.ts +215 -0
  94. package/server/project-install-roots.ts +210 -0
  95. package/server/project-kinds.ts +71 -0
  96. package/server/project-mcp-servers.ts +82 -0
  97. package/server/project-module-freshness.ts +109 -0
  98. package/server/project-module-instance.ts +375 -0
  99. package/server/project-optimize-deps-entries.ts +827 -0
  100. package/server/project-output-writer.ts +679 -0
  101. package/server/project-package-origin.ts +63 -0
  102. package/server/project-root-surface.ts +831 -0
  103. package/server/project-scratch-path.ts +47 -0
  104. package/server/project-script-hmr.ts +619 -0
  105. package/server/project-serving-plugins.ts +236 -0
  106. package/server/project-tools.ts +653 -0
  107. package/server/project-validation.ts +250 -0
  108. package/server/project-verbs.ts +127 -0
  109. package/server/project-view.ts +52 -0
  110. package/server/project-watch.ts +1134 -0
  111. package/server/project-work-coordinator.ts +288 -0
  112. package/server/provider-credentials.ts +331 -0
  113. package/server/recent-projects-store.ts +98 -0
  114. package/server/redact-secrets.ts +71 -0
  115. package/server/repository-presence.ts +267 -0
  116. package/server/routes/account.ts +357 -0
  117. package/server/routes/agents.ts +60 -0
  118. package/server/routes/assets.ts +154 -0
  119. package/server/routes/build.ts +13 -0
  120. package/server/routes/collaboration.ts +595 -0
  121. package/server/routes/configurations.ts +419 -0
  122. package/server/routes/context.ts +264 -0
  123. package/server/routes/control-plane.ts +1811 -0
  124. package/server/routes/logs.ts +272 -0
  125. package/server/routes/project-identity.ts +302 -0
  126. package/server/routes/project-open.ts +429 -0
  127. package/server/routes/project-source.ts +686 -0
  128. package/server/routes/project-state.ts +383 -0
  129. package/server/routes/relay.ts +1029 -0
  130. package/server/routes/route-helpers.ts +29 -0
  131. package/server/routes/served-modules.ts +138 -0
  132. package/server/routes/session-tabs.ts +196 -0
  133. package/server/routes/settings.ts +121 -0
  134. package/server/routes/share-control.ts +486 -0
  135. package/server/routes/themes.ts +116 -0
  136. package/server/routes/tools.ts +230 -0
  137. package/server/routes/worktrees.ts +595 -0
  138. package/server/scoped-game-css.ts +461 -0
  139. package/server/server-utils.ts +1194 -0
  140. package/server/session-product.ts +112 -0
  141. package/server/session-registry.ts +230 -0
  142. package/server/share-claims.ts +138 -0
  143. package/server/share-host.ts +526 -0
  144. package/server/share-session-gateway.ts +1362 -0
  145. package/server/share-tunnel.ts +123 -0
  146. package/server/spawn-opener.ts +74 -0
  147. package/server/support/play/log-format.ts +76 -0
  148. package/server/support/play/session-record.ts +199 -0
  149. package/server/support/project/build-discipline.ts +718 -0
  150. package/server/support/project/inspection-node.ts +119 -0
  151. package/server/support/project/log-naming.ts +54 -0
  152. package/server/support/project/provenance.ts +290 -0
  153. package/server/support/project/run-name.ts +18 -0
  154. package/server/support/project/session-journal.ts +830 -0
  155. package/server/support/project/shared.ts +327 -0
  156. package/server/tab-bootstrap.ts +62 -0
  157. package/server/tab-heartbeat.ts +477 -0
  158. package/server/tab-lifecycle.ts +550 -0
  159. package/server/tab-presence.ts +1508 -0
  160. package/server/team-agent-mirror.ts +95 -0
  161. package/server/twin-auth.ts +138 -0
  162. package/server/vendored-lock-recorder.ts +968 -0
  163. package/server/worktree-identity.ts +121 -0
  164. package/server/worktree-management.ts +398 -0
  165. package/server/worktree-retention.d.mts +13 -0
  166. package/server/worktree-retention.mjs +205 -0
  167. package/src/EditorContext.tsx +169 -0
  168. package/src/account.ts +333 -0
  169. package/src/action-registry.ts +532 -0
  170. package/src/active-product.ts +74 -0
  171. package/src/active-project.ts +155 -0
  172. package/src/adapter-editor-config.ts +25 -0
  173. package/src/adapter-observation.ts +49 -0
  174. package/src/animation/stage-transport.ts +379 -0
  175. package/src/animation/three-clips-subject.ts +172 -0
  176. package/src/api/asset-library-wire.ts +45 -0
  177. package/src/api/assets.ts +365 -0
  178. package/src/api/base.ts +10 -0
  179. package/src/api/build.ts +99 -0
  180. package/src/api/git-wire.ts +56 -0
  181. package/src/api/logs.ts +92 -0
  182. package/src/api/project-identity.ts +74 -0
  183. package/src/api/project-open.ts +355 -0
  184. package/src/api/project-source.ts +162 -0
  185. package/src/api/project-state.ts +107 -0
  186. package/src/api/relay.ts +268 -0
  187. package/src/api/settings.ts +36 -0
  188. package/src/api/themes.ts +45 -0
  189. package/src/api/worktrees.ts +257 -0
  190. package/src/asset-compare-core.ts +171 -0
  191. package/src/asset-compare.ts +294 -0
  192. package/src/asset-editor-context.tsx +100 -0
  193. package/src/asset-events.ts +97 -0
  194. package/src/asset-inspector-actions.ts +87 -0
  195. package/src/asset-preview-framing.ts +357 -0
  196. package/src/asset-preview.ts +2802 -0
  197. package/src/asset-selection-viewer-registry.ts +113 -0
  198. package/src/asset-selection.ts +133 -0
  199. package/src/asset-workflow/asset-capabilities.ts +344 -0
  200. package/src/asset-workflow/asset-import-jobs.ts +106 -0
  201. package/src/asset-workflow/asset-ledger-backend.ts +126 -0
  202. package/src/asset-workflow/asset-ledger.ts +156 -0
  203. package/src/asset-workflow/asset-materialization-report.ts +140 -0
  204. package/src/asset-workflow/asset-pack-manifest.ts +320 -0
  205. package/src/asset-workflow/asset-types.ts +142 -0
  206. package/src/asset-workflow/asset-workflow-quality.ts +61 -0
  207. package/src/asset-workflow/audio-preview-player.ts +193 -0
  208. package/src/asset-workflow/audio-waveform.ts +22 -0
  209. package/src/asset-workflow/cloud-asset-client.ts +263 -0
  210. package/src/asset-workflow/folder-preview.ts +345 -0
  211. package/src/asset-workflow/hosted-asset-materialization.ts +236 -0
  212. package/src/asset-workflow/image-view-scale.ts +32 -0
  213. package/src/asset-workflow/import-contract.ts +124 -0
  214. package/src/asset-workflow/ledger-write-lock.ts +244 -0
  215. package/src/asset-workflow/model-inspection.ts +818 -0
  216. package/src/asset-workflow/pixi-spritesheet.ts +197 -0
  217. package/src/asset-workflow/preview-resource-lifetime.ts +44 -0
  218. package/src/asset-workflow/project-asset-commands.ts +23 -0
  219. package/src/asset-workflow/project-asset-health.ts +139 -0
  220. package/src/asset-workflow/project-asset-operations.ts +154 -0
  221. package/src/asset-workflow/project-asset-roots.ts +68 -0
  222. package/src/asset-workflow/project-content.ts +288 -0
  223. package/src/asset-workflow/project-source-index.ts +550 -0
  224. package/src/asset-workflow/thumbnail-system.ts +256 -0
  225. package/src/authoring/active-adapter.ts +199 -0
  226. package/src/authoring/active-systems.ts +422 -0
  227. package/src/authoring/adapter-key.ts +18 -0
  228. package/src/authoring/authoring-asset-url.ts +27 -0
  229. package/src/authoring/bootstrap-state.ts +49 -0
  230. package/src/authoring/boundary-authoring-adapter.ts +184 -0
  231. package/src/authoring/breakpoint-state.ts +43 -0
  232. package/src/authoring/canvas-scene-guides.ts +76 -0
  233. package/src/authoring/component-instance-root.ts +171 -0
  234. package/src/authoring/composite-authoring-adapter.ts +2110 -0
  235. package/src/authoring/consumer-actions.ts +520 -0
  236. package/src/authoring/css-numeric-style.ts +97 -0
  237. package/src/authoring/design-time-layers.ts +849 -0
  238. package/src/authoring/design-time-mount-registry.ts +235 -0
  239. package/src/authoring/design-time-settle.ts +343 -0
  240. package/src/authoring/edit-mode-authoring.ts +619 -0
  241. package/src/authoring/eyedropper-session.ts +60 -0
  242. package/src/authoring/instance-source-menu-register.ts +13 -0
  243. package/src/authoring/instance-source-menu.ts +135 -0
  244. package/src/authoring/layered-pick.ts +183 -0
  245. package/src/authoring/live-gesture-lock.ts +50 -0
  246. package/src/authoring/live-object-transform.ts +62 -0
  247. package/src/authoring/mount-failure-report.ts +154 -0
  248. package/src/authoring/mounted-root-subjects.ts +144 -0
  249. package/src/authoring/no-authoring-adapter.ts +55 -0
  250. package/src/authoring/null-inspection-subjects.tsx +75 -0
  251. package/src/authoring/object3d-document-persistence.ts +106 -0
  252. package/src/authoring/object3d-document-session-registry.ts +120 -0
  253. package/src/authoring/object3d-document-session.ts +1326 -0
  254. package/src/authoring/object3d-gesture-controller.ts +113 -0
  255. package/src/authoring/panel-authoring.ts +121 -0
  256. package/src/authoring/prefab-instance-inspector-section.tsx +237 -0
  257. package/src/authoring/prefab-instance-section-model.ts +55 -0
  258. package/src/authoring/project-authoring-session.ts +105 -0
  259. package/src/authoring/provenance.ts +99 -0
  260. package/src/authoring/quarks-particle-systems.ts +19 -0
  261. package/src/authoring/react-canvas-navigation.ts +255 -0
  262. package/src/authoring/react-design-canvas-style.ts +20 -0
  263. package/src/authoring/react-story-board.ts +937 -0
  264. package/src/authoring/selection-scope.ts +195 -0
  265. package/src/authoring/shell-document-ops.ts +169 -0
  266. package/src/authoring/shell-object3d-document-write-policy.ts +108 -0
  267. package/src/authoring/shell-viewport-policy.ts +48 -0
  268. package/src/authoring/source-object3d-authoring-adapter.ts +526 -0
  269. package/src/authoring/stories-scope.ts +35 -0
  270. package/src/authoring/story-board-chrome-fit.ts +107 -0
  271. package/src/authoring/story-board-presentation.ts +111 -0
  272. package/src/authoring/three-projection-core.ts +226 -0
  273. package/src/authoring/viewport-pick-context.ts +40 -0
  274. package/src/authoring/viewport-raycast.ts +240 -0
  275. package/src/authoring/viewport-tool-context.ts +73 -0
  276. package/src/authoring/world-canvas-viewport-state.ts +35 -0
  277. package/src/authoring/world-hidden-viewport.ts +152 -0
  278. package/src/authoring/world-pan-state.ts +198 -0
  279. package/src/authoring/world-session-state.ts +101 -0
  280. package/src/authoring/write-pipe.ts +173 -0
  281. package/src/availability-tick.ts +66 -0
  282. package/src/bitmap-label.ts +120 -0
  283. package/src/blender-tab-metrics.ts +161 -0
  284. package/src/board-open-actions.ts +20 -0
  285. package/src/boot-routing.ts +391 -0
  286. package/src/build-identity.ts +15 -0
  287. package/src/bytes-codec.ts +62 -0
  288. package/src/camera-authoring.ts +175 -0
  289. package/src/cancellation-reason.ts +58 -0
  290. package/src/canvas-preview-frames.ts +482 -0
  291. package/src/capture-camera-pose.ts +77 -0
  292. package/src/chrome-registry.ts +159 -0
  293. package/src/chrome-slot-registry.ts +91 -0
  294. package/src/collaboration-client.ts +264 -0
  295. package/src/collaboration-presence.ts +41 -0
  296. package/src/command-dispatch.ts +19 -0
  297. package/src/command-listener.ts +2743 -0
  298. package/src/command-registry.ts +70 -0
  299. package/src/component-board-registry.ts +205 -0
  300. package/src/component-states-registry.ts +200 -0
  301. package/src/components/AgentPresentationNotice.tsx +75 -0
  302. package/src/components/AlignToolbar.tsx +204 -0
  303. package/src/components/AppRoot.tsx +394 -0
  304. package/src/components/ApplicationMenus.tsx +415 -0
  305. package/src/components/AssetBrowser.tsx +2843 -0
  306. package/src/components/AssetEditorShell.tsx +216 -0
  307. package/src/components/AssetInspectorToolSection.tsx +124 -0
  308. package/src/components/BoardRulers.tsx +354 -0
  309. package/src/components/CameraInfo.tsx +81 -0
  310. package/src/components/CanvasSceneViewport.tsx +556 -0
  311. package/src/components/CapabilityCoverageSection.tsx +185 -0
  312. package/src/components/CenterDocuments.tsx +22 -0
  313. package/src/components/ChromeSlot.tsx +20 -0
  314. package/src/components/CodeView.tsx +470 -0
  315. package/src/components/CollaborationHeaderControl.css +74 -0
  316. package/src/components/CompactInspectorCard.tsx +190 -0
  317. package/src/components/CompactInspectorShell.tsx +39 -0
  318. package/src/components/ConsolePanel.css +55 -0
  319. package/src/components/ConsolePanel.tsx +270 -0
  320. package/src/components/DefaultEditorLayout.tsx +106 -0
  321. package/src/components/DocumentHeaderStrip.tsx +96 -0
  322. package/src/components/DocumentShelfRail.tsx +45 -0
  323. package/src/components/DocumentThumbnail.tsx +345 -0
  324. package/src/components/EditorLeaseGuard.tsx +372 -0
  325. package/src/components/ErrorBanner.tsx +123 -0
  326. package/src/components/FolderPreviewTile.tsx +159 -0
  327. package/src/components/GameHierarchy.tsx +3842 -0
  328. package/src/components/GameplaySessionTimeline.tsx +295 -0
  329. package/src/components/InspectionProjection.tsx +968 -0
  330. package/src/components/Inspector.tsx +233 -0
  331. package/src/components/InspectorCanvasPreview.tsx +35 -0
  332. package/src/components/InspectorFieldsSection.tsx +290 -0
  333. package/src/components/InspectorObjectPreview.tsx +57 -0
  334. package/src/components/InspectorStoriesSection.tsx +92 -0
  335. package/src/components/InspectorToolSection.tsx +96 -0
  336. package/src/components/InspectorTransformSection.tsx +245 -0
  337. package/src/components/LightExplorerPanel.tsx +433 -0
  338. package/src/components/MediaProperties.tsx +145 -0
  339. package/src/components/NonThreeAuthoringBootstrap.tsx +19 -0
  340. package/src/components/Object3DDocumentToolbar.css +100 -0
  341. package/src/components/Object3DDocumentToolbar.tsx +454 -0
  342. package/src/components/Object3DDocumentViewport.tsx +58 -0
  343. package/src/components/ProjectHeader.css +617 -0
  344. package/src/components/ProjectHeader.tsx +328 -0
  345. package/src/components/ProjectLayout.tsx +249 -0
  346. package/src/components/ReactCanvasControls.tsx +284 -0
  347. package/src/components/RootSelectionOverlay.tsx +3162 -0
  348. package/src/components/RootTextEditor.tsx +79 -0
  349. package/src/components/SaveStatus.tsx +70 -0
  350. package/src/components/StageHost.tsx +1954 -0
  351. package/src/components/StageOverlays.tsx +21 -0
  352. package/src/components/StartupErrorScreen.tsx +76 -0
  353. package/src/components/StartupLoadingScreen.tsx +65 -0
  354. package/src/components/StatsOverlay.tsx +78 -0
  355. package/src/components/SurfaceStateOverlay.tsx +24 -0
  356. package/src/components/ToolContributionSurfaces.tsx +65 -0
  357. package/src/components/ToolHost.tsx +370 -0
  358. package/src/components/ToolObject3DPreview.tsx +39 -0
  359. package/src/components/Toolbar.tsx +595 -0
  360. package/src/components/TransientHint.tsx +44 -0
  361. package/src/components/TransportStrip.tsx +174 -0
  362. package/src/components/VersionControlSection.tsx +470 -0
  363. package/src/components/VgaiLogo.css +83 -0
  364. package/src/components/VgaiLogo.tsx +35 -0
  365. package/src/components/ViewportControlsHint.tsx +60 -0
  366. package/src/components/ViewportFurniture.tsx +401 -0
  367. package/src/components/ViewportOverlay.tsx +145 -0
  368. package/src/components/ViewportOverlaysMenu.tsx +153 -0
  369. package/src/components/ViewportShadingMenu.tsx +300 -0
  370. package/src/components/ViewportViewMenu.tsx +101 -0
  371. package/src/components/WorkspaceDocumentSurface.tsx +204 -0
  372. package/src/components/WorkspaceUtilitySurface.tsx +22 -0
  373. package/src/components/WorktreeSwitcher.css +239 -0
  374. package/src/components/WorktreeSwitcher.tsx +890 -0
  375. package/src/components/account-documents.tsx +1162 -0
  376. package/src/components/asset-documents.tsx +812 -0
  377. package/src/components/asset-editor-persistence.ts +216 -0
  378. package/src/components/asset-selection-section.tsx +535 -0
  379. package/src/components/asset-thumbnails.tsx +312 -0
  380. package/src/components/asset-viewers/AudioViewer.tsx +201 -0
  381. package/src/components/asset-viewers/EntityModelDocument.tsx +122 -0
  382. package/src/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
  383. package/src/components/asset-viewers/GenericJsonViewer.tsx +102 -0
  384. package/src/components/asset-viewers/ImageViewer.tsx +300 -0
  385. package/src/components/asset-viewers/JsonAssetDocument.tsx +93 -0
  386. package/src/components/asset-viewers/LiveModuleDocument.tsx +417 -0
  387. package/src/components/asset-viewers/LutAssetDocument.tsx +444 -0
  388. package/src/components/asset-viewers/ModelAssetDocument.tsx +105 -0
  389. package/src/components/asset-viewers/Object3DPreview.tsx +359 -0
  390. package/src/components/asset-viewers/OnlineAssetDetail.tsx +421 -0
  391. package/src/components/asset-viewers/PasteboardModuleDocument.tsx +67 -0
  392. package/src/components/asset-viewers/QuarksAssetDocument.tsx +526 -0
  393. package/src/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
  394. package/src/components/asset-viewers/SourceAssetViewer.tsx +281 -0
  395. package/src/components/asset-viewers/SpritesheetSpriteView.tsx +102 -0
  396. package/src/components/asset-viewers/VideoViewer.tsx +101 -0
  397. package/src/components/asset-viewers/shader-source.ts +144 -0
  398. package/src/components/asset-workflow.css +731 -0
  399. package/src/components/board-guides.ts +150 -0
  400. package/src/components/compact-inspector.css +539 -0
  401. package/src/components/core-utilities.tsx +90 -0
  402. package/src/components/editor-notifications.css +40 -0
  403. package/src/components/engine-workspace.css +274 -0
  404. package/src/components/inspector-preview-section.tsx +238 -0
  405. package/src/components/inspector-property-grouping.ts +64 -0
  406. package/src/components/inspector-revert-label.ts +20 -0
  407. package/src/components/inspector-selection.ts +42 -0
  408. package/src/components/inspector-stories-gating.ts +171 -0
  409. package/src/components/inspector-transform-subject.ts +11 -0
  410. package/src/components/inspector-transform.ts +75 -0
  411. package/src/components/kind-documents.tsx +500 -0
  412. package/src/components/palette-action-publisher.tsx +140 -0
  413. package/src/components/primitives/DraftColorInput.tsx +74 -0
  414. package/src/components/product-shell.css +1829 -0
  415. package/src/components/project-tool-documents.tsx +410 -0
  416. package/src/components/scene-documents.tsx +223 -0
  417. package/src/components/stage-keyboard.tsx +37 -0
  418. package/src/components/stage-overlay-set.tsx +107 -0
  419. package/src/components/stage-presence-markers.ts +478 -0
  420. package/src/components/standard-viewport-dressing.ts +417 -0
  421. package/src/components/status-contributions.tsx +403 -0
  422. package/src/components/tool-documents.tsx +309 -0
  423. package/src/components/tool-schema-form.tsx +262 -0
  424. package/src/components/use-after-paint.ts +41 -0
  425. package/src/components/use-project-image-assets.ts +86 -0
  426. package/src/components/viewport-header-controls.css +90 -0
  427. package/src/components/viewport-surface-status.tsx +55 -0
  428. package/src/components/workspace-history.ts +32 -0
  429. package/src/components/workspace-static-panel-registry.tsx +152 -0
  430. package/src/components/workspace-surfaces.css +621 -0
  431. package/src/components/world-documents.tsx +586 -0
  432. package/src/components/world-overlay-gestures.ts +1694 -0
  433. package/src/composite-screenshot.ts +1641 -0
  434. package/src/console-sync.ts +131 -0
  435. package/src/constraint-helper.ts +338 -0
  436. package/src/content-entry-source-registry.ts +184 -0
  437. package/src/coverage/authoring-seam-evidence.ts +300 -0
  438. package/src/coverage/canvas-reveal.ts +192 -0
  439. package/src/coverage/design-time-surfaces.ts +101 -0
  440. package/src/coverage/live-seam-evidence.ts +11 -0
  441. package/src/coverage/ontology-invariants.ts +466 -0
  442. package/src/coverage/session-vitals.ts +501 -0
  443. package/src/coverage/system-seam-evidence.ts +72 -0
  444. package/src/crash-null-boundary.ts +36 -0
  445. package/src/creation-site-edit.ts +1479 -0
  446. package/src/creation-site-registry.ts +160 -0
  447. package/src/delegate-harness-registry.ts +143 -0
  448. package/src/document-context-registry.ts +177 -0
  449. package/src/document-open-registry.ts +200 -0
  450. package/src/document-preview-source.ts +20 -0
  451. package/src/document-renderer-session.ts +138 -0
  452. package/src/editor-api.ts +46 -0
  453. package/src/editor-chrome-capture.ts +139 -0
  454. package/src/editor-commands.ts +164 -0
  455. package/src/editor-console.ts +563 -0
  456. package/src/editor-current-view.ts +82 -0
  457. package/src/editor-document-probe.ts +881 -0
  458. package/src/editor-git-client.ts +115 -0
  459. package/src/editor-host-door.ts +462 -0
  460. package/src/editor-hotkeys.ts +842 -0
  461. package/src/editor-lease-view.ts +39 -0
  462. package/src/editor-lease.ts +415 -0
  463. package/src/editor-mode.ts +19 -0
  464. package/src/editor-notifications.ts +140 -0
  465. package/src/editor-presence.ts +563 -0
  466. package/src/editor-presentation-activity.ts +50 -0
  467. package/src/editor-presentation-notice.ts +42 -0
  468. package/src/editor-runtime.tsx +145 -0
  469. package/src/editor-server-response.ts +86 -0
  470. package/src/editor-session-attribution.ts +75 -0
  471. package/src/editor-session-mode.ts +54 -0
  472. package/src/editor-shell-store.ts +1304 -0
  473. package/src/editor-state-facets.ts +74 -0
  474. package/src/editor-styles.css +33 -0
  475. package/src/editor-view-presentation.ts +810 -0
  476. package/src/editor-viewport.ts +5302 -0
  477. package/src/entity-lod.ts +31 -0
  478. package/src/entity-object.ts +91 -0
  479. package/src/files/file-provider.ts +62 -0
  480. package/src/files/project-files.ts +264 -0
  481. package/src/finders/index.ts +136 -0
  482. package/src/finders/scenes-from-entrypoint-selection.ts +387 -0
  483. package/src/frame/bridge.tsx +1408 -0
  484. package/src/frame/product.ts +77 -0
  485. package/src/gameplay-dom-recording.ts +319 -0
  486. package/src/gameplay-export-state.ts +14 -0
  487. package/src/gameplay-replay.ts +417 -0
  488. package/src/gameplay-session-time.ts +9 -0
  489. package/src/gameplay-sessions.ts +204 -0
  490. package/src/harness-chat-types.ts +253 -0
  491. package/src/hierarchy-component-marks.ts +298 -0
  492. package/src/hierarchy-drop.ts +91 -0
  493. package/src/hierarchy-expansion-state.ts +80 -0
  494. package/src/hierarchy-header-slot.ts +52 -0
  495. package/src/hierarchy-internals.ts +197 -0
  496. package/src/hierarchy-kind-icon.ts +217 -0
  497. package/src/hierarchy-mark-reader.ts +73 -0
  498. package/src/hierarchy-menu-registry.ts +67 -0
  499. package/src/hierarchy-node-rows.ts +307 -0
  500. package/src/hierarchy-panel-view.ts +280 -0
  501. package/src/hierarchy-projection.ts +76 -0
  502. package/src/hierarchy-row-cache.ts +243 -0
  503. package/src/hierarchy-row-model.ts +308 -0
  504. package/src/hierarchy-rows.ts +11 -0
  505. package/src/hierarchy-walk.ts +86 -0
  506. package/src/history/editor-session.ts +25 -0
  507. package/src/history/history-commands.ts +147 -0
  508. package/src/history/history-delegate.ts +163 -0
  509. package/src/history/history-limit-notices.ts +43 -0
  510. package/src/history/history-service.ts +1173 -0
  511. package/src/history/persistence-coordinator.ts +35 -0
  512. package/src/history/project-file-history.ts +386 -0
  513. package/src/history/project-root-history-backends.ts +139 -0
  514. package/src/history/resource-registry.ts +209 -0
  515. package/src/history/snapshot-store.ts +103 -0
  516. package/src/history/source-history-backend.ts +546 -0
  517. package/src/history/types.ts +124 -0
  518. package/src/hmr-registration-group.ts +67 -0
  519. package/src/hmr-stable-react-context.ts +23 -0
  520. package/src/hotkeys.ts +188 -0
  521. package/src/inference-diagnostics.ts +69 -0
  522. package/src/initial-project.ts +80 -0
  523. package/src/inspection/active-subject.ts +578 -0
  524. package/src/inspection/active-surface.ts +142 -0
  525. package/src/inspection/compose.ts +1064 -0
  526. package/src/inspection/display.ts +170 -0
  527. package/src/inspection/document-subject.ts +109 -0
  528. package/src/inspection/game-subject.ts +85 -0
  529. package/src/inspection/model.ts +542 -0
  530. package/src/inspection/null-subject.ts +115 -0
  531. package/src/inspection/serialize.ts +357 -0
  532. package/src/inspection/use-active-inspection.ts +180 -0
  533. package/src/inspector-presentation.ts +201 -0
  534. package/src/inspector-section-registry.ts +221 -0
  535. package/src/instance-source-actions.ts +163 -0
  536. package/src/instanced-presentation.ts +164 -0
  537. package/src/js-heap.ts +71 -0
  538. package/src/key-actions.ts +91 -0
  539. package/src/keymap-presets.ts +401 -0
  540. package/src/layout-policy.ts +31 -0
  541. package/src/learn-links.ts +73 -0
  542. package/src/light-explorer-model.ts +134 -0
  543. package/src/live-canvas-frame.ts +55 -0
  544. package/src/live-document.ts +279 -0
  545. package/src/live-module-source.ts +230 -0
  546. package/src/live-session-registry.ts +220 -0
  547. package/src/live-transition.ts +633 -0
  548. package/src/manifest-project.ts +107 -0
  549. package/src/model-thumbnail.ts +523 -0
  550. package/src/native-selection-style.ts +202 -0
  551. package/src/object3d-document-write-policy.ts +137 -0
  552. package/src/packaged-runtime.ts +108 -0
  553. package/src/palettes/maya.palette.json +57 -0
  554. package/src/palettes/substance.palette.json +57 -0
  555. package/src/pasteboard-module.ts +81 -0
  556. package/src/performance-profiler.ts +367 -0
  557. package/src/performance-sources.ts +69 -0
  558. package/src/play-boot-phase.ts +145 -0
  559. package/src/presentation-surface.ts +248 -0
  560. package/src/project-adapter.ts +1143 -0
  561. package/src/project-asset-refresh.ts +26 -0
  562. package/src/project-declaration-refresh.ts +31 -0
  563. package/src/project-local-state.ts +118 -0
  564. package/src/project-manager.ts +243 -0
  565. package/src/project-module-changes.ts +163 -0
  566. package/src/project-module-split.ts +266 -0
  567. package/src/project-provenance.ts +115 -0
  568. package/src/project-ready.ts +42 -0
  569. package/src/project-session-reset.ts +67 -0
  570. package/src/project-shape.ts +68 -0
  571. package/src/project-tool-discovery.ts +66 -0
  572. package/src/project-tools.ts +107 -0
  573. package/src/project-work-types.ts +149 -0
  574. package/src/projection/three.ts +898 -0
  575. package/src/projection/types.ts +44 -0
  576. package/src/readiness.ts +113 -0
  577. package/src/reflection-probe-helper.ts +142 -0
  578. package/src/reported-play-state.ts +90 -0
  579. package/src/resolve-relative-specifier.ts +33 -0
  580. package/src/scene-document-plan.ts +315 -0
  581. package/src/scene-framing.ts +315 -0
  582. package/src/scene-live-open.ts +210 -0
  583. package/src/scene-view-fog.ts +89 -0
  584. package/src/scoped-game-css.ts +152 -0
  585. package/src/session-orphan-record.ts +193 -0
  586. package/src/session-tombstone.ts +126 -0
  587. package/src/settings/settings-provider.ts +82 -0
  588. package/src/settings-store.ts +345 -0
  589. package/src/shared-view-restore.ts +42 -0
  590. package/src/shell-store-door.ts +45 -0
  591. package/src/source-conflict.ts +122 -0
  592. package/src/spatial-handle-visuals.ts +332 -0
  593. package/src/stage-context.ts +341 -0
  594. package/src/stage-store-registry.ts +63 -0
  595. package/src/stale-chunk-recovery.ts +34 -0
  596. package/src/state-report-deferral.ts +73 -0
  597. package/src/storage/host-files-storage.ts +97 -0
  598. package/src/storage/http-storage.ts +174 -0
  599. package/src/storage/index.ts +75 -0
  600. package/src/storage/mem-storage.ts +149 -0
  601. package/src/storage/path-lock.ts +44 -0
  602. package/src/storage/paths.ts +26 -0
  603. package/src/storage/types.ts +116 -0
  604. package/src/stories/StoryComponentThumbnail.tsx +184 -0
  605. package/src/stories/StoryPreviewMount.tsx +306 -0
  606. package/src/stories/component-content-source.tsx +106 -0
  607. package/src/stories/component-states-source.ts +78 -0
  608. package/src/stories/compose-project-stories.ts +255 -0
  609. package/src/stories/pixi-story-model.ts +30 -0
  610. package/src/stories/prefabs-finder.ts +54 -0
  611. package/src/stories/prefabs-from-stories.ts +182 -0
  612. package/src/stories/project-story-discovery.ts +78 -0
  613. package/src/stories/project-story-regions.ts +24 -0
  614. package/src/stories/story-actions.ts +54 -0
  615. package/src/stories/story-capture-command.ts +124 -0
  616. package/src/stories/story-capture.ts +584 -0
  617. package/src/stories/story-declared-medium.ts +126 -0
  618. package/src/stories/story-discovery.ts +176 -0
  619. package/src/stories/story-document-openers.ts +36 -0
  620. package/src/stories/story-dom-runtime.ts +78 -0
  621. package/src/stories/story-grouping.ts +111 -0
  622. package/src/stories/story-lane.ts +88 -0
  623. package/src/stories/story-mount-turn.ts +27 -0
  624. package/src/stories/story-opener.ts +260 -0
  625. package/src/stories/story-pixi-preview.ts +408 -0
  626. package/src/stories/story-presentation.ts +215 -0
  627. package/src/stories/story-registry.ts +517 -0
  628. package/src/stories/story-three-preview.ts +807 -0
  629. package/src/stories/three-story-model.ts +96 -0
  630. package/src/story-three-preview-runtime.ts +56 -0
  631. package/src/surface-keyboard.ts +112 -0
  632. package/src/surface-state.ts +124 -0
  633. package/src/tab-bootstrap.js +628 -0
  634. package/src/tab-census.ts +198 -0
  635. package/src/tab-lifecycle-client.ts +196 -0
  636. package/src/theme-library.ts +844 -0
  637. package/src/theme-preference.ts +378 -0
  638. package/src/theme.css +3437 -0
  639. package/src/three-viewport/bone-selection-highlight.ts +119 -0
  640. package/src/three-viewport/camera-fit.ts +41 -0
  641. package/src/three-viewport/interactive-renderer.ts +132 -0
  642. package/src/three-viewport/selection-brackets.ts +310 -0
  643. package/src/three-viewport/selection-outline.ts +183 -0
  644. package/src/three-viewport/skeleton-helper.ts +61 -0
  645. package/src/three-viewport/source-color.ts +167 -0
  646. package/src/three-viewport/studio-environment.ts +96 -0
  647. package/src/three-viewport-presentation.ts +23 -0
  648. package/src/tool-contribution-play.ts +74 -0
  649. package/src/tool-loader.ts +1798 -0
  650. package/src/transform-mode-request.ts +57 -0
  651. package/src/transient-hint.ts +78 -0
  652. package/src/trigger-volume-helper.ts +116 -0
  653. package/src/ui-source/adapter-region-includes.ts +241 -0
  654. package/src/ui-source/ensure-import.ts +132 -0
  655. package/src/ui-source/file-region-resolver.ts +302 -0
  656. package/src/ui-source/inspect.ts +775 -0
  657. package/src/ui-source/oid-transform.ts +1845 -0
  658. package/src/ui-source/plan-csf-story.ts +196 -0
  659. package/src/ui-source/plan-extract-component.ts +421 -0
  660. package/src/ui-source/plan-fork-component.ts +621 -0
  661. package/src/ui-source/plan-named-style.ts +95 -0
  662. package/src/ui-source/plan-source-edit.ts +353 -0
  663. package/src/ui-source/r3f-contract-resolver.ts +327 -0
  664. package/src/ui-source/r3f-diagnostic-index.ts +165 -0
  665. package/src/ui-source/r3f-environment-binding.ts +133 -0
  666. package/src/ui-source/r3f-joint-binding.ts +251 -0
  667. package/src/ui-source/r3f-lod-binding.ts +91 -0
  668. package/src/ui-source/r3f-particle-binding.ts +235 -0
  669. package/src/ui-source/r3f-physics-binding.ts +483 -0
  670. package/src/ui-source/r3f-project-contracts.ts +444 -0
  671. package/src/ui-source/relative-import-specifier.ts +34 -0
  672. package/src/ui-source/reparent-guard.ts +544 -0
  673. package/src/ui-source/source-edit-request.ts +97 -0
  674. package/src/ui-source/source-write-backend.ts +618 -0
  675. package/src/ui-source/syntactic-prop-specs.ts +296 -0
  676. package/src/ui-source/tier-source-write-backend.ts +279 -0
  677. package/src/ui-source/ts-ast.ts +171 -0
  678. package/src/ui-source/utility-class-support.ts +169 -0
  679. package/src/ui-source/write-component-default.ts +133 -0
  680. package/src/ui-source/writer.ts +1989 -0
  681. package/src/viewport-activation-timings.ts +840 -0
  682. package/src/viewport-authoring-policy.ts +186 -0
  683. package/src/viewport-controls-hint.ts +57 -0
  684. package/src/viewport-door.ts +237 -0
  685. package/src/viewport-shading-boundary.ts +12 -0
  686. package/src/vite-error-surface.ts +141 -0
  687. package/src/wait-until.ts +37 -0
  688. package/src/workspace-areas.ts +156 -0
  689. package/src/workspace-aux-commands.ts +11 -0
  690. package/src/workspace-available-documents.ts +127 -0
  691. package/src/workspace-core-utilities.ts +31 -0
  692. package/src/workspace-document-ids.ts +59 -0
  693. package/src/workspace-document-registry.ts +612 -0
  694. package/src/workspace-document-restore.ts +145 -0
  695. package/src/workspace-host-commands.ts +141 -0
  696. package/src/workspace-persistence-gate.ts +40 -0
  697. package/src/workspace-play-utilities.ts +44 -0
  698. package/src/workspace-presets.ts +425 -0
  699. package/src/workspace-regions.ts +294 -0
  700. package/src/workspace-state-persistence.ts +536 -0
  701. package/src/workspace-static-panels.ts +73 -0
  702. package/src/workspace-status-registry.ts +121 -0
  703. package/src/workspace-style.ts +239 -0
  704. package/src/workspace-utility-commands.ts +74 -0
  705. package/src/workspace-utility-registry.ts +263 -0
  706. package/src/workspace-viewport-rect.ts +97 -0
  707. package/src/world-adoption.ts +115 -0
  708. package/src/world-document-routing.ts +104 -0
  709. package/vite-plugin-creation-site-write.ts +184 -0
  710. package/vite-plugin-creation-site.ts +80 -0
  711. package/vite-plugin-game-static.ts +303 -0
  712. package/vite-plugin-module-doorways.ts +336 -0
  713. package/vite-plugin-product-contributions.ts +197 -0
  714. package/vite-plugin-project-game-static.ts +125 -0
  715. package/vite-plugin-project-jsx-js.ts +65 -0
  716. package/vite-plugin-project-root-absolute-assets.ts +359 -0
  717. package/vite-plugin-shared-react.ts +420 -0
  718. package/vite-plugin-shared-three.ts +203 -0
  719. package/vite-plugin-ui-oid.ts +2106 -0
@@ -0,0 +1,3842 @@
1
+ import {
2
+ Button,
3
+ chromeSize,
4
+ IconButton,
5
+ Menu,
6
+ MenuItem,
7
+ space,
8
+ TextInput,
9
+ ThemeRootPortal,
10
+ themeVars,
11
+ zIndex,
12
+ } from '@volter/editor-sdk/widgets';
13
+ import { authoringAdapterKey } from '../authoring/adapter-key';
14
+
15
+ /**
16
+ * GameHierarchy — the ONE hierarchy panel (A2). Replaces the former pair of
17
+ * per-format hierarchy panels (first-party single-document, ingested-game) — a
18
+ * single contract-only shell driven ENTIRELY through the active {@link
19
+ * AuthoringAdapter}: `hierarchy`/`selection`/`inspector`/ `structure`/`assetDrop`.
20
+ * Every row affordance is gated on the PRESENCE of a provider or a reserved
21
+ * inspector path, never on which concrete adapter is active.
22
+ *
23
+ * Rule zero (spec §0): this file imports ONLY from `@volter/editor-project/adapter` types
24
+ * and editor-shell modules — no private first-party entity type, no direct
25
+ * 3D-library value import, no first-party document-format string literal.
26
+ * First-party-only affordances not yet contract-expressible live behind
27
+ * `hierarchy-menu-registry.ts`'s escape hatch, registered elsewhere by the
28
+ * owning adapter module (formerly prefab unpack/open/save-as; currently
29
+ * unregistered).
30
+ *
31
+ * Shape handling: project authoring is a `CompositeAuthoringAdapter` for every
32
+ * N >= 1 composition. The Game document projects that whole composite; an
33
+ * authored root document projects only its owning child. In the composite,
34
+ * each runtime world is a synthetic `world:<id>` group; adapter-owned surfaces
35
+ * such as scene UI may be nested beneath their owning world without pretending
36
+ * to be runtime roots. The same generic row engine also handles bare adapters
37
+ * used by root documents, bounded ingest and compatibility sessions. Both
38
+ * shapes are walked by the SAME generic row engine
39
+ * (`flattenHierarchyRows` — the format-neutral `EditorNode`-typed row
40
+ * flattener, not the legacy first-party-typed sibling module); a group node
41
+ * only ever gets a small set of cosmetic extras (kind/zOrder badge, a
42
+ * session-local eye) layered on TOP of the same row component.
43
+ *
44
+ * Testids: every row carries BOTH the
45
+ * `data-entity-name`/`data-testid="scene-name"` family (what
46
+ * `EditorPage.getEntityNames` reads) and the full `ingest-*` family
47
+ * (`ingest-row`, `ingest-row-caret`, `ingest-row-visibility`,
48
+ * `ingest-search`, `ingest-hierarchy`, `data-ingest-name`,
49
+ * `ingest-context-menu`), so a probe can address any row by either
50
+ * vocabulary regardless of which adapter produced it.
51
+ */
52
+
53
+ import type { IconDefinition } from '@fortawesome/fontawesome-svg-core';
54
+ import {
55
+ faCaretDown,
56
+ faCaretRight,
57
+ faChevronDown,
58
+ faCircleDot,
59
+ faCirclePlay,
60
+ faCrosshairs,
61
+ faEye,
62
+ faEyeSlash,
63
+ faFileCode,
64
+ faFolder,
65
+ faHandPointer,
66
+ faLayerGroup,
67
+ faLock,
68
+ faLockOpen,
69
+ faMagnifyingGlass,
70
+ faObjectGroup,
71
+ faPlus,
72
+ faPuzzlePiece,
73
+ faTriangleExclamation,
74
+ } from '@fortawesome/free-solid-svg-icons';
75
+ import type { CollaborationSnapshot } from '@volter/editor-sdk/session/collaboration-types';
76
+ import {
77
+ clampRectToViewport,
78
+ DisclosureIcon,
79
+ DropIndicator,
80
+ EditorIcon,
81
+ EditorToolbar,
82
+ lineHeightVar,
83
+ Panel,
84
+ spaceVar,
85
+ } from '@volter/editor-sdk/widgets';
86
+ import type {
87
+ AuthoringAdapter,
88
+ AuthoringProvenance,
89
+ EditorNode,
90
+ EditorNodeRole,
91
+ HierarchyProvider,
92
+ } from '@volter/editor-project/adapter';
93
+ import {
94
+ memo,
95
+ useCallback,
96
+ useDeferredValue,
97
+ useEffect,
98
+ useLayoutEffect,
99
+ useMemo,
100
+ useReducer,
101
+ useRef,
102
+ useState,
103
+ useSyncExternalStore,
104
+ } from 'react';
105
+ import { createPortal } from 'react-dom';
106
+ import {
107
+ activeAuthoringVersion,
108
+ hasAuthoringOverride,
109
+ subscribeActiveAuthoring,
110
+ } from '../authoring/active-adapter';
111
+ import { CompositeAuthoringAdapter } from '../authoring/composite-authoring-adapter';
112
+ import {
113
+ copyAuthoringNodes,
114
+ createAuthoringNode,
115
+ cutAuthoringNodes,
116
+ dropAuthoringAsset,
117
+ duplicateAuthoringNode,
118
+ groupAuthoringNodes,
119
+ pasteAuthoringNodes,
120
+ reorderAuthoringNode,
121
+ reparentAuthoringNode,
122
+ setAuthoringSelection,
123
+ ungroupAuthoringNode,
124
+ unwrapAuthoringNode,
125
+ wrapAuthoringNode,
126
+ } from '../authoring/consumer-actions';
127
+ import { enterInstanceRow } from '../authoring/instance-source-menu';
128
+ import { resolvePanelAuthoring } from '../authoring/panel-authoring';
129
+ import {
130
+ authoringDestination,
131
+ provenanceForNode,
132
+ unavailableReason,
133
+ } from '../authoring/provenance';
134
+ import {
135
+ selectionScopeStack,
136
+ selectionScopeVersion,
137
+ setSelectionScope,
138
+ subscribeSelectionScope,
139
+ } from '../authoring/selection-scope';
140
+ import { resolveThreeViewportRootId } from '../authoring/world-hidden-viewport';
141
+ import {
142
+ isRootHidden,
143
+ isRootInteractive,
144
+ isRootPickLocked,
145
+ toggleRootHidden,
146
+ toggleRootInteractive,
147
+ toggleRootPickLock,
148
+ } from '../authoring/world-session-state';
149
+ import { collaborationSnapshot, connectCollaboration } from '../collaboration-client';
150
+ import { openRegisteredDocument } from '../document-open-registry';
151
+ import { deleteSelection, duplicateSelection } from '../editor-hotkeys';
152
+ import { EDITOR_PARTICIPANT_ID } from '../editor-presence';
153
+ import { useEditorStore } from '../editor-runtime';
154
+ import type { EditorShellStore } from '../editor-shell-store';
155
+ import { entityObject3D } from '../entity-object';
156
+ import {
157
+ applyComponentRootExpansionDefaults,
158
+ componentMarkView,
159
+ type MarkedTreeSource,
160
+ } from '../hierarchy-component-marks';
161
+ import { hierarchyDropRefusal, isSameRootDrop } from '../hierarchy-drop';
162
+ import {
163
+ clearHierarchyExpansionPreferences,
164
+ hierarchyExpansionPreference,
165
+ setHierarchyExpansionPreferences,
166
+ } from '../hierarchy-expansion-state';
167
+ import { hierarchyHeaderSlot, subscribeHierarchyHeaderSlot } from '../hierarchy-header-slot';
168
+ import { applyRevealExpansionDefaults, internalsProjection } from '../hierarchy-internals';
169
+ import {
170
+ hierarchyKindIcon,
171
+ isDatablockKind,
172
+ OUTLINER_EXCLUDE_OFF,
173
+ OUTLINER_EXCLUDE_ON,
174
+ OUTLINER_RENDER_OFF,
175
+ OUTLINER_RENDER_ON,
176
+ } from '../hierarchy-kind-icon';
177
+ import { markReaderFor } from '../hierarchy-mark-reader';
178
+ import { getHierarchyMenuItems } from '../hierarchy-menu-registry';
179
+ import {
180
+ flattenHierarchyRows,
181
+ type HierarchyNodeRow,
182
+ hierarchyRowKey,
183
+ hierarchyRowWindow,
184
+ patchUniqueHierarchyRows,
185
+ } from '../hierarchy-node-rows';
186
+ import {
187
+ clearHierarchyPanelSnapshot,
188
+ type HierarchyPanelSnapshot,
189
+ publishHierarchyPanelSnapshot,
190
+ } from '../hierarchy-panel-view';
191
+ import {
192
+ ancestorPathKeys,
193
+ HierarchyRowCache,
194
+ indexRowPaths,
195
+ type RowPathIndex,
196
+ revealSignature,
197
+ selectedAncestorNodeIds,
198
+ selectedRevealCounts,
199
+ } from '../hierarchy-row-cache';
200
+ import {
201
+ type RowDiagnosticsSource,
202
+ RowWarningCache,
203
+ rowAffordances,
204
+ rowIdentity,
205
+ TransformLockCache,
206
+ UNLOCKED,
207
+ } from '../hierarchy-row-model';
208
+ import { CHILD_CAP } from '../hierarchy-rows';
209
+ import { setActiveScope } from '../hotkeys';
210
+ import { instancedRowDetail } from '../instanced-presentation';
211
+ import { getCurrentProject } from '../project-manager';
212
+ import { focusedStageStore } from '../stage-context';
213
+ import { editorPaintedRegions, subscribeEditorTheme } from '../theme-preference';
214
+ import { showTransientHint } from '../transient-hint';
215
+ import {
216
+ subscribeWorkspaceDocuments,
217
+ workspaceDocumentRegistryVersion,
218
+ } from '../workspace-document-registry';
219
+ import {
220
+ activeChromeRegions,
221
+ type ChromeRegions,
222
+ chromeRegionsKey,
223
+ subscribeChromeRegions,
224
+ } from '../workspace-regions';
225
+ import { activateRootDocument } from '../world-document-routing';
226
+
227
+ const ASSET_MIME = 'application/x-editor-asset';
228
+ const REORDER_MIME = 'application/x-hierarchy-reorder';
229
+ /**
230
+ * The row's height as the ACTIVE material declares it.
231
+ *
232
+ * `chromeSize.treeRow` is the editor's OWN default (24). A material may
233
+ * retune it — Blender's does, to the 20px its Outliner rows measure — and a
234
+ * constant read at module load never learns that: the row was painted at the
235
+ * inline 24 while `--vgai-tree-row-height` said 20, so the density
236
+ * contribution reached nothing. The spacer math, hit-testing and reveal
237
+ * scrolling must all use the number the row is actually painted at.
238
+ *
239
+ * Measured from a live row FIRST (the paint is the truth), then the declared
240
+ * var, then the default — the shape the workspace host's `bottomTabStripHeight`
241
+ * uses for the dock's own tab strip.
242
+ *
243
+ * THE ROW HEIGHT IS NOT WHY THE MODEL OUTLINER LOOKS SHORT — do not come here
244
+ * to fix that. Measured 2026-09-21 through the chrome door on a live model
245
+ * session (`editor.document.query('.vgai-tree-row', { scope: 'outliner',
246
+ * styles: [...] })`): computed `height: 20px`, rendered rect 20, row pitch
247
+ * exactly 20, `--vgai-tree-row-height: "20px"` — and Blender's own Outliner in
248
+ * `blender-reference/modeling-object-none.png` steps 40 device px at native 2x,
249
+ * i.e. the same 20. The two agree exactly, and `blender.style.ts`'s
250
+ * `treeRow: 20` is what puts them there.
251
+ *
252
+ * What is short is the PANEL. On a first boot in a 953-CSS-tall window the
253
+ * Outliner view's pane is 155 px of an 860 px sidebar column — 18.0 %, which is
254
+ * the `weight: 18` that `packages/model-editor/workbench/src/
255
+ * product.contribution.ts` declares from Blender's own area measurement
256
+ * (189/1028). Of those 155, 22 go to the workbench's pane title bar (Blender's
257
+ * area has none) and ~22 to this panel's header, leaving 109 px = 5.45 rows
258
+ * where Blender at the same column height shows ~6.6. The missing row is that
259
+ * title bar, and the one line that could buy it back is the product's weight —
260
+ * which is compiled into the Code-OSS release, so it cannot be changed and
261
+ * verified from this repo alone.
262
+ */
263
+ function treeRowHeight(container: HTMLElement | null): number {
264
+ const row = container?.querySelector<HTMLElement>('.vgai-tree-row');
265
+ const measured = row?.getBoundingClientRect().height ?? 0;
266
+ if (measured > 0) return Math.round(measured);
267
+ const declared = container
268
+ ? Number.parseFloat(getComputedStyle(container).getPropertyValue('--vgai-tree-row-height'))
269
+ : Number.NaN;
270
+ return Number.isFinite(declared) && declared > 0 ? declared : chromeSize.treeRow;
271
+ }
272
+ /**
273
+ * ONE LEVEL OF TREE INDENT, as the ACTIVE material declares it
274
+ * (`chromeSize.treeIndent`, default 14 — the number this constant always
275
+ * held). A material may retune it, and Blender's does: its Outliner steps by
276
+ * its ROW HEIGHT, a square grid, measured at 20 on `outliner.png`. Spelled as
277
+ * the CSS variable rather than read once at module load, for the reason
278
+ * `treeRowHeight` above records — a constant read at load never learns what a
279
+ * density contribution declares.
280
+ */
281
+ const INDENT = 'var(--vgai-tree-indent)';
282
+ /** `left`/`padding-left` for a row at `depth`, in the material's own step. */
283
+ function indentPx(depth: number): string {
284
+ return `calc(${INDENT} * ${depth})`;
285
+ }
286
+ /**
287
+ * How far the INDENT GUIDE is held back from the first and last row of the
288
+ * block it spans. Measured on `modeling-object-none.png` (Blender 5.2, native
289
+ * 2x, object mode): the rule runs y 143..242 over child rows 133..172,
290
+ * 173..212 and 213..252 — 10 device px in at the top, 10 short at the bottom,
291
+ * and unbroken in between. See `.vgai-tree-indent-guide` in `theme.css` for
292
+ * the ink, the column and where the frame stops answering.
293
+ */
294
+ const GUIDE_BLOCK_INSET = 5;
295
+ /** One ancestor column's rule, as this row draws its slice of it. */
296
+ interface IndentGuide {
297
+ /** The ancestor's depth — the caret column the rule sits on. */
298
+ readonly column: number;
299
+ /** This row is the first of that ancestor's block, so the rule starts here. */
300
+ readonly starts: boolean;
301
+ /** This row is the last of that ancestor's block, so the rule ends here. */
302
+ readonly ends: boolean;
303
+ }
304
+ /**
305
+ * The guide slices a row at `depth` draws, given its neighbours' depths in the
306
+ * flattened list (`-1` where there is no neighbour). A visible row's every
307
+ * ancestor is open by construction, so it crosses one rule per level above it;
308
+ * the ends are local facts — the row starts a block when the row above it IS
309
+ * that ancestor, and ends every block the row below it has left.
310
+ */
311
+ function indentGuidesFor(depth: number, prevDepth: number, nextDepth: number): IndentGuide[] {
312
+ const guides: IndentGuide[] = [];
313
+ for (let column = 0; column < depth; column++) {
314
+ guides.push({
315
+ column,
316
+ starts: prevDepth === column,
317
+ ends: nextDepth <= column,
318
+ });
319
+ }
320
+ return guides;
321
+ }
322
+ const EMPTY_REMOTE_SELECTION_COLORS: readonly string[] = [];
323
+ /**
324
+ * How many of a collapsed row's children the datablock summary looks at. An
325
+ * object carries a handful of datablocks and a scene group carries thousands
326
+ * of children; this is what keeps the second case from costing thousands of
327
+ * adapter reads per painted row. No reference frame shows more than one
328
+ * datablock on a row, so what a row with several draws is OUR reading — they
329
+ * sit side by side at the same cell pitch, in child order, and a row that
330
+ * somehow carries more than this many shows the first few.
331
+ */
332
+ const DATABLOCK_SUMMARY_SCAN = 6;
333
+
334
+ // Scroll-window sizing:
335
+ // `treeRowHeight()` above is THIS panel's row height. Every rendered row
336
+ // (Row/MoreRow) is painted by `.vgai-tree-row`'s own `height:
337
+ // var(--vgai-tree-row-height)`, so the spacer-div trick only keeps the
338
+ // scrollbar honest as long as that resolved number is what the math uses —
339
+ // which is why it is measured rather than restated. OVERSCAN keeps a small buffer
340
+ // of already-rendered rows on each side of the visible band so a fast scroll/keyboard-nav
341
+ // doesn't flash empty space before the next paint catches up. DEFAULT_VIEWPORT_PX is used
342
+ // whenever the container hasn't reported a real (nonzero) `clientHeight` yet — jsdom never
343
+ // performs real layout, so unit tests never measure anything unless they explicitly mock it;
344
+ // this generous fallback also keeps every pre-existing small-tree render byte-identical
345
+ // without any test needing to.
346
+ const OVERSCAN = 8;
347
+ const DEFAULT_VIEWPORT_PX = 2400;
348
+
349
+ /**
350
+ * The active document is already named by the viewport tab/panel. Adapters
351
+ * still need their document node as the stable source/persistence boundary,
352
+ * but repeating that boundary as the first hierarchy row makes the authored
353
+ * tree read `Three.js > src/world.tsx > Stage` instead of `Three.js > Stage`.
354
+ *
355
+ * Keep the node addressable through `node()` (selection, inspection and
356
+ * structure routing continue to use its real id/parentage) while promoting
357
+ * its children in the display walk. A bare adapter's sole document root is
358
+ * likewise promoted because the panel header already names it. In a
359
+ * composite, `secondaryLabel` is the adapter-owned source path that
360
+ * distinguishes this redundant boundary from meaningful document-like
361
+ * surface rows such as a scene UI canvas.
362
+ *
363
+ * WHY THE PROMOTION IS MEMOIZED, and what the memo is allowed to hold.
364
+ * Deciding a node's promoted child list READS EVERY CHILD of that node — one
365
+ * `hierarchy.node()` per child, and on the canvas lane each of those is a
366
+ * projection lookup plus a native-node read. The row walk asks this provider
367
+ * for every row it emits AND for every child it descends into, so an
368
+ * unmemoized provider makes one walk cost `O(rows × fan-out)` rather than
369
+ * `O(rows)`. Measured on the scale harness's canvas N=20000 world: the row
370
+ * walk that a click re-runs emitted 1124 rows and its top self frames were
371
+ * `promoteSourceDocumentChildren`/`projectNode` and the `toNode` reads under
372
+ * them.
373
+ *
374
+ * The memo holds the PROMOTION ONLY — the child-id list, keyed by node id —
375
+ * never the node record. Node DATA (label, role, parentage) is re-read from
376
+ * the underlying provider on every call, so a live label or visibility change
377
+ * is never served stale from here. What is frozen is whether each child is a
378
+ * source-document boundary, which is a STRUCTURAL fact: it can only change
379
+ * through a structural edit, and a structural edit advances
380
+ * `contentVersion`, which rebuilds this provider along with the structural
381
+ * snapshot it was built for (see the row pipeline's two memoized halves).
382
+ */
383
+ function authoredContentHierarchy(hierarchy: HierarchyProvider): HierarchyProvider {
384
+ /** node id → promoted child ids, or `null` for "unchanged, use the node's own". */
385
+ const promoted = new Map<string, string[] | null>();
386
+
387
+ const computePromotion = (node: EditorNode): string[] | null => {
388
+ const childIds = node.childIds.flatMap((childId) => {
389
+ const child = hierarchy.node(childId);
390
+ return child?.role === 'document' && child.secondaryLabel ? child.childIds : [childId];
391
+ });
392
+ return childIds.length === node.childIds.length &&
393
+ childIds.every((childId, index) => childId === node.childIds[index])
394
+ ? null
395
+ : childIds;
396
+ };
397
+
398
+ const projectNode = (node: EditorNode): EditorNode => {
399
+ let childIds = promoted.get(node.id);
400
+ if (childIds === undefined) {
401
+ childIds = computePromotion(node);
402
+ promoted.set(node.id, childIds);
403
+ }
404
+ return childIds === null ? node : { ...node, childIds };
405
+ };
406
+
407
+ return {
408
+ ...hierarchy,
409
+ roots: () => {
410
+ const roots = hierarchy.roots();
411
+ if (roots.length === 1 && roots[0]?.role === 'document') {
412
+ return roots[0].childIds
413
+ .map((childId) => hierarchy.node(childId))
414
+ .filter((node): node is EditorNode => node !== null)
415
+ .map(projectNode);
416
+ }
417
+ return roots.map(projectNode);
418
+ },
419
+ node: (id) => {
420
+ const node = hierarchy.node(id);
421
+ return node ? projectNode(node) : null;
422
+ },
423
+ };
424
+ }
425
+ // Only scroll-window ABOVE this row count. Small/medium trees (every real
426
+ // multi-world browse tree — world-group rows + CHILD_CAP-bounded children)
427
+ // render in full, immune to a transient/small `clientHeight` measurement
428
+ // windowing out later world-group rows (which broke 19-multi-world/22-tri-world
429
+ // when virtualization first landed). Windowing only kicks in for pathologically
430
+ // large lists (e.g. an ingested game's uncapped SEARCH result — react-rpg's
431
+ // dungeon is ~1900 rows), which is exactly the case this targets.
432
+ const VIRTUALIZE_MIN_ROWS = 300;
433
+
434
+ /** Shared empty reveal map — a fresh `Map` per render would defeat nothing,
435
+ * but there is no reason to allocate one for the uncapped path. */
436
+ const EMPTY_REVEAL_COUNTS: ReadonlyMap<string, number> = new Map();
437
+
438
+ const ROLE_ICON: Partial<Record<EditorNodeRole, IconDefinition>> = {
439
+ folder: faFolder,
440
+ document: faFileCode,
441
+ story: faCirclePlay,
442
+ component: faPuzzlePiece,
443
+ instance: faObjectGroup,
444
+ boundary: faTriangleExclamation,
445
+ };
446
+
447
+ function iconForNode(node: EditorNode): IconDefinition {
448
+ if (node.role === 'root') return hierarchyKindIcon(node.kind, faLayerGroup);
449
+ if (node.role === 'entity' || node.role === 'element') return hierarchyKindIcon(node.kind);
450
+ return (node.role ? ROLE_ICON[node.role] : undefined) ?? hierarchyKindIcon(node.kind);
451
+ }
452
+
453
+ const STRUCTURAL_ROLES = new Set<EditorNodeRole>([
454
+ 'folder',
455
+ 'root',
456
+ 'document',
457
+ 'story',
458
+ 'boundary',
459
+ ]);
460
+
461
+ type DropZone = 'before' | 'child' | 'after';
462
+ interface DropTarget {
463
+ nodeId: string;
464
+ zone: DropZone;
465
+ }
466
+
467
+ /** Walk UP from `targetId` via `parentId` — true if any ancestor (or itself)
468
+ * is in `dragIds` (prevents dropping a subtree into its own descendant). */
469
+ function isDescendantOfAny(
470
+ targetId: string,
471
+ dragIds: string[],
472
+ adapter: AuthoringAdapter,
473
+ ): boolean {
474
+ let current: string | null = targetId;
475
+ let guard = 0;
476
+ while (current && guard++ < 1000) {
477
+ if (dragIds.includes(current)) return true;
478
+ current = adapter.hierarchy.node(current)?.parentId ?? null;
479
+ }
480
+ return false;
481
+ }
482
+
483
+ /** The ids of `parentId`'s children, in order — `null` parentId means "the
484
+ * adapter's own roots" (NOT necessarily the document root of a composite —
485
+ * callers pass the group-node id for "this world's roots"). */
486
+ function siblingIdsOf(adapter: AuthoringAdapter, parentId: string | null): string[] {
487
+ if (parentId === null) return adapter.hierarchy.roots().map((n) => n.id);
488
+ return adapter.hierarchy.node(parentId)?.childIds ?? [];
489
+ }
490
+
491
+ /**
492
+ * `CompositeAuthoringAdapter`'s zOrder badge for a `world:<id>` group row —
493
+ * `null` when the active adapter isn't a composite (bare shape) or the id
494
+ * isn't one of its synthetic group nodes.
495
+ *
496
+ * IMPORTANT: "is a group node" is answered by ASKING THE COMPOSITE, never by
497
+ * any per-node marker. Only the composite manufactures the synthetic
498
+ * `world:<id>` group rows this panel treats specially (session-local eye per
499
+ * D9, kind/zOrder badge, non-draggable); an ingested game's own rows are
500
+ * ordinary rows whose eye/lock/rename route through the contract like any
501
+ * other. Keying this off a per-node flag is the hazard: adapters set node
502
+ * flags to mean "not an authored first-party entity", so every ingest/react
503
+ * row reads as a world wrapper and the ingest eye toggle regresses to a
504
+ * session-local shell hide instead of the adapter's
505
+ * `inspector.set(id,'visible',…)`.
506
+ */
507
+ function compositeGroupBadge(
508
+ adapter: AuthoringAdapter,
509
+ nodeId: string,
510
+ ): {
511
+ zOrder: number;
512
+ worldId: string;
513
+ role: 'world' | 'surface';
514
+ kind: string;
515
+ provenance: AuthoringProvenance | null;
516
+ } | null {
517
+ if (!(adapter instanceof CompositeAuthoringAdapter)) return null;
518
+ for (const child of adapter.childAdapters()) {
519
+ if (adapter.groupNodeId(child.worldId) === nodeId) {
520
+ // A4 (D8) — prefer the LIVE manifest zOrder (editable on the root row,
521
+ // `composite.inspector.get(groupId, 'zOrder')`) over the array-index
522
+ // fallback `child.zOrder`, so editing zOrder through the generic
523
+ // inspector is reflected on this badge immediately (the write path
524
+ // calls `store.notifyIngestEdit()`, which re-renders this panel).
525
+ // Falls back to the array index when there's no manifest surface
526
+ // (play-mode composites, or any adapter that doesn't report a
527
+ // `zOrder` inspector property for its own group-node id) — unchanged
528
+ // from before A4.
529
+ const manifestZOrder = adapter.inspector?.get(nodeId, 'zOrder');
530
+ return {
531
+ zOrder: typeof manifestZOrder === 'number' ? manifestZOrder : child.zOrder,
532
+ // B1 — the RAW manifest world id (`CompositeChild.worldId`), so
533
+ // callers can key session-local world state (`world-session-state.ts`
534
+ // — eye/interactive/pick-lock) consistently with `design-time-layers.ts`,
535
+ // which reads the SAME id off `composite.childAdapters()` directly.
536
+ worldId: child.worldId,
537
+ role: child.role,
538
+ kind: child.kind,
539
+ // Spec 29 §5 — the world's own seam-level provenance declaration
540
+ // (null when the child adapter declared none; the row shows no chip).
541
+ provenance: child.adapter.provenance ?? null,
542
+ };
543
+ }
544
+ }
545
+ return null;
546
+ }
547
+
548
+ /** True only for a composite's synthetic `world:<id>` group row (see
549
+ * {@link compositeGroupBadge}) — never for an ordinary (even runtime-only)
550
+ * ingest/react/canvas node. */
551
+ function isCompositeGroupNode(adapter: AuthoringAdapter, nodeId: string): boolean {
552
+ return compositeGroupBadge(adapter, nodeId) !== null;
553
+ }
554
+
555
+ /** Editor-only kind/semantic projection rows have no native entity owner. */
556
+ function isCompositeOrganizationNode(adapter: AuthoringAdapter, nodeId: string): boolean {
557
+ return adapter instanceof CompositeAuthoringAdapter && adapter.isOrganizationNode(nodeId);
558
+ }
559
+
560
+ /**
561
+ * The composite child adapter that OWNS `nodeId` — the adapter whose
562
+ * PROVIDERS answer for it — or the active adapter itself when bare. Affordance
563
+ * GATES must ask the owner (the composite's own `structure`/`inspector` are
564
+ * never-undefined routers, so probing them says "yes" for a row whose owning
565
+ * child would refuse — the false-claim pattern spec 29 §3 forbids); ACTIONS
566
+ * still go through the ACTIVE adapter, which routes by ownership.
567
+ */
568
+ function ownerAdapterOf(adapter: AuthoringAdapter, nodeId: string): AuthoringAdapter {
569
+ if (!(adapter instanceof CompositeAuthoringAdapter)) return adapter;
570
+ const worldId = adapter.ownerOf(nodeId);
571
+ return adapter.childAdapters().find((c) => c.worldId === worldId)?.adapter ?? adapter;
572
+ }
573
+
574
+ /**
575
+ * Whether the adapter that OWNS this row can reparent at all.
576
+ *
577
+ * Asked of the owner, not the composite: the composite's `structure` is a
578
+ * never-undefined router, so probing it says "yes" for a row whose owning child
579
+ * has no structure provider — the false-claim pattern spec 29 §3 forbids, and
580
+ * exactly the shape of the dead drag this cycle fixes (a three world
581
+ * deliberately ships NO `structure`: "a running world's structure belongs to
582
+ * its source, not to a document this adapter could write").
583
+ */
584
+ function hasStructureWritePathFor(adapter: AuthoringAdapter, nodeId: string | undefined): boolean {
585
+ if (nodeId === undefined) return adapter.structure !== undefined;
586
+ return ownerAdapterOf(adapter, nodeId).structure !== undefined;
587
+ }
588
+
589
+ function isStructuralHierarchyNode(
590
+ adapter: AuthoringAdapter,
591
+ nodeId: string,
592
+ role: EditorNodeRole | undefined,
593
+ ): boolean {
594
+ return (
595
+ isCompositeGroupNode(adapter, nodeId) ||
596
+ isCompositeOrganizationNode(adapter, nodeId) ||
597
+ (role !== undefined && STRUCTURAL_ROLES.has(role))
598
+ );
599
+ }
600
+
601
+ /**
602
+ * First-open policy: organizational rows stay open, as does the first real
603
+ * authored node below them. Single-child wrapper chains continue open until
604
+ * the first meaningful branch; branches beneath that point start folded.
605
+ * This is shape-based — never label- or kind-based — so the same rule covers
606
+ * Three.js, React, PixiJS, and arbitrary projections.
607
+ *
608
+ * THE ONE KIND-BASED CLAUSE, and why it is not an exception to that sentence.
609
+ * A row whose children are ALL DATABLOCKS starts CLOSED. A datablock is not a
610
+ * branch of the tree — it is the thing the row already IS, hung off it the way
611
+ * Blender's Outliner hangs a mesh off its object — so opening it by default
612
+ * shows one row twice. Measured on `outliner.png`: at rest every object row in
613
+ * the frame is collapsed behind a `>` and carries its datablock as an inline
614
+ * glyph after the label (`Row`'s summary below is the other half of this). The
615
+ * shape rule alone cannot see it: our object row is at authored depth 1 with a
616
+ * single child, so it matched TWO of the clauses above and opened.
617
+ *
618
+ * Nothing else in the estate can reach this clause: `isDatablockKind` names
619
+ * exactly the kinds `hierarchy-kind-icon.ts` gives the DATA glyph, and no
620
+ * first-party three/React/Pixi/ingest adapter sends one — only `@volter/editor-blender`'s
621
+ * hierarchy decorator does.
622
+ */
623
+ function defaultExpansionByNode(
624
+ rows: readonly HierarchyNodeRow[],
625
+ adapter: AuthoringAdapter,
626
+ ): Map<string, boolean> {
627
+ const result = new Map<string, boolean>();
628
+ const authoredDepthAtTreeDepth: number[] = [];
629
+ const nodeAtTreeDepth: HierarchyNodeRow['node'][] = [];
630
+ const kindById = new Map<string, string>();
631
+ for (const row of rows) if (!row.more) kindById.set(row.node.id, row.node.kind);
632
+ for (const row of rows) {
633
+ if (row.more) continue;
634
+ const parentAuthoredDepth =
635
+ row.depth === 0 ? 0 : (authoredDepthAtTreeDepth[row.depth - 1] ?? 0);
636
+ const parentNode = row.depth === 0 ? null : nodeAtTreeDepth[row.depth - 1];
637
+ const structural = isStructuralHierarchyNode(adapter, row.node.id, row.node.role);
638
+ const authoredDepth = parentAuthoredDepth + (structural ? 0 : 1);
639
+ authoredDepthAtTreeDepth[row.depth] = authoredDepth;
640
+ authoredDepthAtTreeDepth.length = row.depth + 1;
641
+ nodeAtTreeDepth[row.depth] = row.node;
642
+ nodeAtTreeDepth.length = row.depth + 1;
643
+ const onlyDatablockChildren =
644
+ row.node.childIds.length > 0 &&
645
+ row.node.childIds.every((id) => isDatablockKind(kindById.get(id)));
646
+ result.set(
647
+ row.node.id,
648
+ // AN ADAPTER THAT KNOWS ITS OWN DEFAULT WINS. `EditorNode.defaultExpanded`
649
+ // is stated only where the tree carries a real per-element flag rather
650
+ // than a shape a heuristic could read — Blender's Outliner is the case
651
+ // it was added for (see the field's own docblock). Everything else falls
652
+ // through to the shape rule below exactly as before.
653
+ row.node.defaultExpanded ??
654
+ (!onlyDatablockChildren &&
655
+ (structural || authoredDepth <= 1 || parentNode?.childIds.length === 1)),
656
+ );
657
+ }
658
+ return result;
659
+ }
660
+
661
+ /**
662
+ * H6 — THE ONE PLACE search exclusion is enforced. A revealed internal is in
663
+ * `rows` like any other row (that is the point of injecting internals into the
664
+ * hierarchy provider rather than forking the flattener), so it would otherwise
665
+ * be searchable: one `mixamorig` query across four revealed enemies would bury
666
+ * every real row under 240 bones. `rowAffordances(...).searchable` is the
667
+ * predicate, the same one that refuses the row's writes.
668
+ *
669
+ * Ancestors still get revealed for the matches that DO count, and no non-internal
670
+ * row can hide underneath an internal one: a nested instance is always surfaced
671
+ * as a projected child of its owner FIRST (projected children are listed before
672
+ * appended internals, and the flattener keeps the first occurrence), so a
673
+ * revealed subtree is made only of internal rows.
674
+ */
675
+ function searchPathKeys(
676
+ rows: readonly HierarchyNodeRow[],
677
+ term: string,
678
+ isInternal: (id: string) => boolean,
679
+ ): Set<string> {
680
+ const visible = new Set<string>();
681
+ for (const row of rows) {
682
+ if (
683
+ row.more ||
684
+ !rowAffordances({ structural: false, internal: isInternal(row.node.id) }).searchable
685
+ )
686
+ continue;
687
+ if (!row.node.label.toLowerCase().includes(term)) continue;
688
+ visible.add(row.pathKey);
689
+ for (const ancestor of ancestorPathKeys(row.pathKey)) visible.add(ancestor);
690
+ }
691
+ return visible;
692
+ }
693
+
694
+ /** Fold the selection-driven reveal counts (see `selectedRevealCounts`) into the
695
+ * author's own persisted "show more" counts, taking the larger of each. */
696
+ function mergeRevealCounts(
697
+ persisted: ReadonlyMap<string, number>,
698
+ temporary: ReadonlyMap<string, number>,
699
+ ): Map<string, number> {
700
+ const merged = new Map(persisted);
701
+ for (const [pathKey, count] of temporary) {
702
+ merged.set(pathKey, Math.max(merged.get(pathKey) ?? 0, count));
703
+ }
704
+ return merged;
705
+ }
706
+
707
+ /**
708
+ * The Outliner's search — a PERSISTENT field, on a header row it has to SHARE.
709
+ *
710
+ * WHAT THIS CONTROL IS: a live incremental filter over the tree. The term is
711
+ * this panel's own state (`GameHierarchy`'s `search`) and filters on every
712
+ * keystroke through `searchPathKeys`; nothing about that changed when the
713
+ * field stopped hiding. ESCAPE clears the term; a second Escape on an empty
714
+ * field blurs, handing focus back to the tree. There is no "close" any more,
715
+ * which is the point: a collapsed field cannot be photographed, so it cannot
716
+ * be proved through any door the product has (no sanctioned door clicks
717
+ * editor chrome — `command-listener.ts`'s `collapse-hierarchy-all` note),
718
+ * and a control that cannot be seen is a control that cannot be judged
719
+ * against the frame.
720
+ *
721
+ * MEASURED, native 2x `outliner.png`, CSS = half. Blender's header, x from the
722
+ * area's left edge: editor-type well 8.5..40.5 (32x20), display-mode well
723
+ * 45.5..77.5 (32x20), SEARCH FIELD 93.5..208.5 (115x20), then 43 px of flexible
724
+ * gap, filter chevron well 251.5..271.5 (20x20), New Collection button
725
+ * 276.5..296.5 (20x20), 8 px right pad. Every widget is 20 px tall, top at 2.5.
726
+ * The ORDER is the finding: the field sits with the LEFT cluster, right after
727
+ * the two selectors, and only the chevron and the button are pinned right.
728
+ *
729
+ * THE FIELD'S PAINT, re-verified against the frame this unit: 115x20 border
730
+ * box; fill 28/28/28 = `#1c1c1c` = the palette's `widget.field` = our
731
+ * `--vgai-bg-inset`; one-pixel 60/60/60 = `#3c3c3c` = `boundary.default` =
732
+ * `--vgai-border-1`; corner arc ~3 CSS px; magnifier 14x14 of 229/229/229 ink,
733
+ * its left edge 5.0 CSS px inside the border box; placeholder "Search" (no
734
+ * ellipsis — read off the frame) at 94/94/94 = `#5e5e5e`, its left edge 26.5
735
+ * CSS px inside the border box. `.vgai-input` already paints all of that from
736
+ * the palette, so this control adds GEOMETRY only: the leading glyph and the
737
+ * inset that clears it.
738
+ *
739
+ * ITS WIDTH IS STRIP-AWARE: Blender's 115 where the dock hides the tab strip,
740
+ * 64 where it does not. A field that fits its header is not an inconsistency —
741
+ * Blender's own is 115 because Blender's header spends 32 px on an editor-type
742
+ * WELL where ours spends 203 on a tab STRIP, so the two numbers are the same
743
+ * answer to two different headers. The 64 below is the constant's home and the
744
+ * fallback; the raise is one declaration in `workspace-dock.css`, under the
745
+ * `[data-vgai-tabstrip="hidden"]` attribute the dock already publishes, and it
746
+ * arrives here through {@link SEARCH_FIELD_BASIS_VAR}.
747
+ *
748
+ * WHY 64 IS THE BEHIND-A-STRIP NUMBER, and the arithmetic. MEASURED
749
+ * live on the 308 px Model-workspace Outliner under this look: 5 px of area
750
+ * groove at x 1420..1424, then a tab strip that needs 203 px (three labels of
751
+ * 41/54/36 px of ink inside `--vgai-space-5` padding), then the strip's drag
752
+ * void, then the actions. The four verbs this replaced occupied exactly 90 px
753
+ * of that row (x 1638..1728: four 20 px buttons, three 2 px gaps, 4 px of
754
+ * right padding) with 10 px of void beside them — so 90 is the footprint that
755
+ * is KNOWN to leave the strip whole, and the field takes what is left of it
756
+ * after the chevron's 20, its 2 px gap and the row's 4 px right padding: 64.
757
+ *
758
+ * 74 WAS TRIED AND MEASURED WRONG. It fits arithmetically (the row would be
759
+ * 100 px against 303 of header) but it leaves the tab container at exactly its
760
+ * own content width, and the strip declares overflow at that boundary: it
761
+ * shifted 8 px right, "Library" collapsed to "l" and its own `⌄ 1`
762
+ * overflow dropdown appeared. The 10 px of void is the headroom that keeps
763
+ * that from happening — and it is also the group's drag handle, so spending
764
+ * it costs twice.
765
+ *
766
+ * Our editor-type selector spends 203 px where Blender's spends 32, which is
767
+ * the whole of the remaining distance. The one lever that would buy more is
768
+ * `.dv-tab`'s `--vgai-space-5` padding: at Blender's own ~5.5 px a side it
769
+ * would return ~24 px and put the field at 88. Declined — the rule would
770
+ * either repaint every dock tab in the editor, or (scoped to the Outliner's
771
+ * region) leave two tab strips in one window at two different paddings, for a
772
+ * field still 27 px short of Blender's.
773
+ *
774
+ * AND IT DOES NOT FLEX UP. Tried first and MEASURED as wrong: `flex: 1 1 auto`
775
+ * capped at 115 inside the actions container (`display:flex` with
776
+ * `min-width: auto`) did not shrink to fit — the row overflowed the group's
777
+ * right edge, the chevron went off screen entirely and the tab strip
778
+ * collapsed to "L" behind its own overflow dropdown. So
779
+ * the field carries its OWN basis and only shrinks, down to
780
+ * {@link SEARCH_FIELD_MIN}, for a genuinely narrower column. That is still
781
+ * true — the strip-hidden case raises the BASIS, it does not make the field
782
+ * flexible.
783
+ */
784
+ function HierarchySearch({ value, onChange }: { value: string; onChange: (next: string) => void }) {
785
+ const inputRef = useRef<HTMLInputElement>(null);
786
+ return (
787
+ <div
788
+ style={{
789
+ position: 'relative',
790
+ display: 'flex',
791
+ alignItems: 'center',
792
+ // Its own basis, shrinkable but never growing: the actions container
793
+ // is content-sized, so a growing field overflows the group instead of
794
+ // taking width from the tab strip (measured). The basis is a
795
+ // variable so the HOST — the only thing that knows whether a tab strip
796
+ // is beside this row — can raise it to Blender's 115; the constant
797
+ // below is its home and the value every other header gets.
798
+ flex: `0 1 var(${SEARCH_FIELD_BASIS_VAR}, ${SEARCH_FIELD_WIDTH}px)`,
799
+ minWidth: SEARCH_FIELD_MIN,
800
+ }}
801
+ >
802
+ {/* The leading magnifier, 5 px inside the border box per the frame. It is
803
+ decoration over the field, not a button: clicking it must land in the
804
+ input, so it takes no pointer events. */}
805
+ <EditorIcon
806
+ icon={faMagnifyingGlass}
807
+ aria-hidden="true"
808
+ style={{
809
+ position: 'absolute',
810
+ left: SEARCH_GLYPH_INSET,
811
+ pointerEvents: 'none',
812
+ color: 'var(--vgai-text-1)',
813
+ // MEASURED, not chosen: at the chrome's `sm` rung (14 under Blender)
814
+ // this glyph inked 12x12 against the frame's 14x14, because these
815
+ // faces ink at ~0.85 of their box. `lg` is the rung that lands on 14.
816
+ fontSize: 'var(--vgai-icon-lg)',
817
+ }}
818
+ />
819
+ {/* L-6 — `.vgai-input` supplies a real `:focus-visible` border/outline
820
+ (this box previously did `outline:'none'` with no replacement). */}
821
+ <TextInput
822
+ ref={inputRef}
823
+ type="text"
824
+ data-testid="ingest-search"
825
+ placeholder="Search"
826
+ className="vgai-input"
827
+ value={value}
828
+ onChange={(e) => onChange(e.target.value)}
829
+ onKeyDown={(e) => {
830
+ if (e.key !== 'Escape') return;
831
+ // A persistent field has nothing to close, so Escape means CLEAR —
832
+ // and on an already-empty field it means "give me the tree back",
833
+ // which is a blur. Stopped here either way so the editor's own
834
+ // Escape (deselect) does not also fire from inside the field.
835
+ e.stopPropagation();
836
+ if (value !== '') onChange('');
837
+ else inputRef.current?.blur();
838
+ }}
839
+ style={{
840
+ flex: 1,
841
+ minWidth: 0,
842
+ paddingLeft: `var(${SEARCH_TEXT_INSET_VAR}, ${SEARCH_TEXT_INSET}px)`,
843
+ paddingRight: SEARCH_FIELD_END_GUTTER,
844
+ }}
845
+ />
846
+ </div>
847
+ );
848
+ }
849
+
850
+ /** The 90 px footprint the four view buttons used to occupy, less the
851
+ * chevron's 20, its 2 px gap and the row's 4 px right padding. Blender's own
852
+ * field is 115 (`outliner.png`, x 93.5..208.5 at matched scale) in a header
853
+ * whose editor-type selector spends 32 px where our tab strip spends 203.
854
+ * This is the value behind a STRIP; `workspace-dock.css` raises it to
855
+ * Blender's 115 through {@link SEARCH_FIELD_BASIS_VAR} where there is none. */
856
+ const SEARCH_FIELD_WIDTH = 64;
857
+
858
+ /**
859
+ * The dock's handle on the two numbers above and below — the field's basis and
860
+ * its text inset — because whether a tab strip sits beside this row is a fact
861
+ * only the dock has (`workspace-dock.css`, `[data-vgai-tabstrip="hidden"]`,
862
+ * written by the workspace host's `syncCenterTabHeaderVisibility`). Declared
863
+ * here, where the constants live, so the fallback and the raise are read
864
+ * together; nothing outside `workspace-dock.css` sets either.
865
+ */
866
+ const SEARCH_FIELD_BASIS_VAR = '--vgai-hierarchy-search-basis';
867
+
868
+ /** Below this a field stops reading as a field — there is no room for the
869
+ * glyph plus a syllable of the term. Our 308 px Outliner lands just above it. */
870
+ const SEARCH_FIELD_MIN = 56;
871
+
872
+ /** The glyph BOX's left offset, tuned so its INK lands where Blender's does —
873
+ * 5.0 CSS px inside the border box (`outliner.png`, bright ink at x 98.5 of a
874
+ * field whose border box starts at 93.5). The box is wider than the ink, so
875
+ * this is 2, not 5. */
876
+ const SEARCH_GLYPH_INSET = 2;
877
+
878
+ /** Where the text starts, same origin — the value for a field behind a TAB
879
+ * STRIP, where 64 px is all there is. Blender's own is 26.5, and at 64 px
880
+ * that leaves 35 px for a word that sets 37 at the 11 px UI face, so "Search"
881
+ * rendered as "Searc". What is preserved there is the glyph and the text
882
+ * INSET ORDER; what is spent is the gap between them (3.5 px here against
883
+ * Blender's 7.5). Where the strip is hidden the field IS Blender's 115 and
884
+ * nothing is short, so `workspace-dock.css` returns the inset to the one that
885
+ * lands our first INK on Blender's 26.5, through {@link SEARCH_TEXT_INSET_VAR}
886
+ * — that number lives THERE, in the declaration that also raises the width,
887
+ * because the two are one decision and a copy here would drift. */
888
+ const SEARCH_TEXT_INSET = 21;
889
+
890
+ /** The dock's handle on the inset — same ownership story as
891
+ * {@link SEARCH_FIELD_BASIS_VAR}, and set in the same declaration. */
892
+ const SEARCH_TEXT_INSET_VAR = '--vgai-hierarchy-search-text-inset';
893
+
894
+ /** The field's own right gutter. `.vgai-input`'s shared 6 px is a third of the
895
+ * word's room in a field this narrow. */
896
+ const SEARCH_FIELD_END_GUTTER = 2;
897
+
898
+ /**
899
+ * The three view verbs, folded into ONE chevron — Blender's own arrangement.
900
+ *
901
+ * Blender's Outliner header spends this slot on a filter chevron and keeps
902
+ * Collapse/Reset/Expand behind its View menu. Ours now does the same: nothing
903
+ * is removed, the three verbs are one click away, and the 66 px four buttons
904
+ * were spending goes to the search field that had no room before.
905
+ *
906
+ * THE WELL IS THE HEADER'S OWN, measured on the native 2x `outliner.png`: the
907
+ * chevron sits in a 20x20 box at x 251.5..271.5 whose INTERIOR is 39/39/39 —
908
+ * the header band's own fill, `widget.menu` — inside a one-pixel 60/60/60
909
+ * `boundary.default`, with a ~3 px corner. That is a different widget class
910
+ * from the button beside it (New Collection draws the lighter 83/83/83
911
+ * `widget.regular`), and it needs no rule of its own: `theme.css` already
912
+ * paints a `secondary` button that carries `aria-haspopup="menu"` as a menu
913
+ * well, because a dropdown TRIGGER is what the class is for. The mark itself
914
+ * is small — 6.5 x 4.0 CSS px of 216-ink — which is why the glyph takes the
915
+ * icon scale's SMALLEST rung (`--vgai-icon-xs`, 12 under Blender) rather than
916
+ * the 14 px rung the rest of the chrome's glyphs use.
917
+ */
918
+ function HierarchyViewMenu({
919
+ onCollapseAll,
920
+ onResetExpansion,
921
+ onExpandAll,
922
+ }: {
923
+ onCollapseAll: () => void;
924
+ onResetExpansion: () => void;
925
+ onExpandAll: () => void;
926
+ }) {
927
+ const [open, setOpen] = useState(false);
928
+ const triggerRef = useRef<HTMLDivElement>(null);
929
+ return (
930
+ <div ref={triggerRef} style={{ position: 'relative', flexShrink: 0 }}>
931
+ <IconButton
932
+ title="View"
933
+ aria-label="View"
934
+ aria-haspopup="menu"
935
+ aria-expanded={open}
936
+ data-testid="hierarchy-view-menu"
937
+ onClick={() => setOpen((was) => !was)}
938
+ variant="secondary"
939
+ size="compact"
940
+ >
941
+ <EditorIcon
942
+ icon={faChevronDown}
943
+ aria-hidden="true"
944
+ // Blender's own filter chevron is a SMALL mark — 6.5 x 4.0 CSS px of
945
+ // ink in its 20 px well, measured on `outliner.png` — so this takes
946
+ // the icon scale's smallest rung (12 under Blender) rather than the
947
+ // 14 the rest of the chrome's glyphs use; at the type scale's `xs`
948
+ // it inked 5 x 3.
949
+ style={{ fontSize: 'var(--vgai-icon-xs)' }}
950
+ />
951
+ </IconButton>
952
+ {open && (
953
+ <Menu
954
+ autoFocusFirst
955
+ dismissBoundaryRef={triggerRef}
956
+ onDismiss={() => setOpen(false)}
957
+ style={{
958
+ position: 'absolute',
959
+ top: '100%',
960
+ right: 0,
961
+ marginTop: space[2],
962
+ minWidth: 170,
963
+ }}
964
+ >
965
+ <MenuItem
966
+ data-testid="hierarchy-collapse-all"
967
+ onSelect={() => {
968
+ onCollapseAll();
969
+ setOpen(false);
970
+ }}
971
+ >
972
+ Collapse All
973
+ </MenuItem>
974
+ <MenuItem
975
+ data-testid="hierarchy-reset-expansion"
976
+ onSelect={() => {
977
+ onResetExpansion();
978
+ setOpen(false);
979
+ }}
980
+ >
981
+ Reset Openness
982
+ </MenuItem>
983
+ <MenuItem
984
+ data-testid="hierarchy-expand-all"
985
+ onSelect={() => {
986
+ onExpandAll();
987
+ setOpen(false);
988
+ }}
989
+ >
990
+ Expand All
991
+ </MenuItem>
992
+ </Menu>
993
+ )}
994
+ </div>
995
+ );
996
+ }
997
+
998
+ /**
999
+ * The Outliner's controls — the search field, the View menu, Create.
1000
+ *
1001
+ * They render into the dock group's header-actions slot when one is published
1002
+ * (`hierarchy-header-slot.ts`), which is what makes the tab strip the panel's
1003
+ * ONE header row. With no slot — the design-system stories, any bounded host
1004
+ * that renders this panel without a dock — they fall back to the panel's own
1005
+ * toolbar row, which is why this is a portal and not a second copy of the
1006
+ * controls living in the dock.
1007
+ *
1008
+ * THE INVENTORY IS BLENDER'S, as of 2026-09-18: a persistent search field, one
1009
+ * chevron well opening the View popover that holds Collapse All / Reset
1010
+ * Openness / Expand All, and Create. Four icon buttons in a row read louder
1011
+ * than Blender's one and spent 86 px of a 308 px header on verbs Blender keeps
1012
+ * in a menu; the fold returns 66 of those to the field, which is the only
1013
+ * thing that lets the field be on screen at all.
1014
+ *
1015
+ * WHAT DID NOT CHANGE, and must not be "fixed": Create keeps the lighter
1016
+ * 83/83/83 `widget.regular` fill inside its one-pixel 60/60/60 outline,
1017
+ * because that is exactly what Blender's own header BUTTON draws (New
1018
+ * Collection, `outliner.png` x 553..592, y 5..44: border 60, fill 83). The
1019
+ * chevron beside it is a different widget class and draws the darker
1020
+ * `widget.menu` well — see `HierarchyViewMenu`.
1021
+ *
1022
+ * WHERE THIS ROW LIVES, and who owns its INSETS. It is portalled into the dock
1023
+ * group's header, so the row's leading/trailing padding depends on something
1024
+ * only the dock knows — whether the tab strip is beside it. Under a look whose
1025
+ * `regions.tabs` is `hidden` (Blender) a lone panel loses the strip and this
1026
+ * row takes the whole band, in Blender's own two groups: the search field left
1027
+ * at 8 px, the chevron and Create pushed to the trailing edge. Behind a strip
1028
+ * it is content-sized with a 4 px trailing gutter. Both live in
1029
+ * `workspace-dock.css` beside `.vgai-dock-header-actions`; nothing here sets a
1030
+ * padding.
1031
+ *
1032
+ * (Until 2026-09-18 that same rule hid the whole header, band and controls
1033
+ * together, so the Blender Game workspace's Outliner had NO header at all.
1034
+ * Fixed in `syncCenterTabHeaderVisibility`, which now takes the strip and
1035
+ * leaves the band whenever the lone panel owns controls in it.)
1036
+ *
1037
+ * WHAT THIS COSTS IN PROOF: the popover's own opening is not drivable — no
1038
+ * sanctioned door clicks editor chrome — so Collapse All and Expand All are
1039
+ * verified through their existing commands (`collapse-hierarchy-all`,
1040
+ * `expand-hierarchy-all`, which call the panel's own actions and never the
1041
+ * buttons), and Reset Openness is verified by reading the same handler both
1042
+ * paths share. Reset had no command before this move and still has none; the
1043
+ * fold made it one click further away, not less proved.
1044
+ */
1045
+ function HierarchyControls({
1046
+ slot,
1047
+ search,
1048
+ setSearch,
1049
+ adapter,
1050
+ createTargetId,
1051
+ onCollapseAll,
1052
+ onResetExpansion,
1053
+ onExpandAll,
1054
+ }: {
1055
+ slot: HTMLElement | null;
1056
+ search: string;
1057
+ setSearch: (next: string) => void;
1058
+ adapter: AuthoringAdapter;
1059
+ createTargetId: string | null;
1060
+ onCollapseAll: () => void;
1061
+ onResetExpansion: () => void;
1062
+ onExpandAll: () => void;
1063
+ }) {
1064
+ const controls = (
1065
+ <>
1066
+ <HierarchySearch value={search} onChange={setSearch} />
1067
+ <HierarchyViewMenu
1068
+ onCollapseAll={onCollapseAll}
1069
+ onResetExpansion={onResetExpansion}
1070
+ onExpandAll={onExpandAll}
1071
+ />
1072
+ <CreateMenu adapter={adapter} parentId={createTargetId} />
1073
+ </>
1074
+ );
1075
+ if (slot) {
1076
+ return createPortal(
1077
+ <div
1078
+ data-testid="hierarchy-header-controls"
1079
+ style={{
1080
+ display: 'flex',
1081
+ alignItems: 'center',
1082
+ gap: spaceVar[1],
1083
+ height: '100%',
1084
+ minWidth: 0,
1085
+ }}
1086
+ >
1087
+ {controls}
1088
+ </div>,
1089
+ slot,
1090
+ );
1091
+ }
1092
+ return (
1093
+ <EditorToolbar label="Hierarchy controls" compact style={{ gap: spaceVar[2] }}>
1094
+ {controls}
1095
+ </EditorToolbar>
1096
+ );
1097
+ }
1098
+
1099
+ // --- Context menu ---
1100
+
1101
+ interface ContextMenuState {
1102
+ x: number;
1103
+ y: number;
1104
+ nodeId: string;
1105
+ }
1106
+
1107
+ function ContextMenu({
1108
+ state,
1109
+ adapter,
1110
+ store,
1111
+ isGroupNode,
1112
+ isInternal,
1113
+ onRename,
1114
+ onClose,
1115
+ }: {
1116
+ state: ContextMenuState;
1117
+ adapter: AuthoringAdapter;
1118
+ store: EditorShellStore;
1119
+ isGroupNode: boolean;
1120
+ /** H6 — a revealed internal row: every WRITE item is withheld (not disabled),
1121
+ * and the adapter's own refusal sentence is shown in their place. */
1122
+ isInternal: boolean;
1123
+ onRename: (id: string) => void;
1124
+ onClose: () => void;
1125
+ }) {
1126
+ const menuRef = useRef<HTMLDivElement>(null);
1127
+ // KEEP THE WHOLE MENU ON SCREEN. It is `position: fixed` at the pointer, so
1128
+ // a right-click on a row near the bottom of the panel ran the menu off the
1129
+ // viewport and took its LAST items with it — Delete is last, and a tester
1130
+ // reported exactly that ("the delete gets hidden over here on the last
1131
+ // line", runhuman pass 105). The shared clamp already solves this; this
1132
+ // menu just did not use it.
1133
+ const [menuPosition, setMenuPosition] = useState<{ left: number; top: number }>({
1134
+ left: state.x,
1135
+ top: state.y,
1136
+ });
1137
+ useLayoutEffect(() => {
1138
+ const el = menuRef.current;
1139
+ if (!el) return;
1140
+ const rect = el.getBoundingClientRect();
1141
+ setMenuPosition(
1142
+ clampRectToViewport({
1143
+ top: state.y,
1144
+ left: state.x,
1145
+ width: rect.width,
1146
+ height: rect.height,
1147
+ }),
1148
+ );
1149
+ }, [state.x, state.y]);
1150
+ const [createOpen, setCreateOpen] = useState(false);
1151
+ // Same at-most-once-per-open guard as CreateMenu: `onClose()` on select is
1152
+ // async, so a double-dispatched click/Enter on a create item could otherwise
1153
+ // double-invoke `create` before the menu unmounts. Reset on each open.
1154
+ const createGuardRef = useRef(false);
1155
+
1156
+ useEffect(() => {
1157
+ if (createOpen) createGuardRef.current = false;
1158
+ }, [createOpen]);
1159
+
1160
+ useEffect(() => {
1161
+ const handler = (e: MouseEvent) => {
1162
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose();
1163
+ };
1164
+ const keyHandler = (e: KeyboardEvent) => {
1165
+ if (e.key === 'Escape') onClose();
1166
+ };
1167
+ document.addEventListener('mousedown', handler);
1168
+ document.addEventListener('keydown', keyHandler);
1169
+ return () => {
1170
+ document.removeEventListener('mousedown', handler);
1171
+ document.removeEventListener('keydown', keyHandler);
1172
+ };
1173
+ }, [onClose]);
1174
+
1175
+ const node = adapter.hierarchy.node(state.nodeId);
1176
+ if (!node) return null;
1177
+
1178
+ const creatableKinds = adapter.structure?.creatableKinds?.(state.nodeId) ?? [];
1179
+ // Gates ask the OWNING child (see ownerAdapterOf) — the composite's own
1180
+ // structure/inspector are never-undefined routers, so probing THEM would
1181
+ // advertise operations the owner refuses (live-caught on a Boundary row:
1182
+ // Duplicate/Hide/Delete rendered enabled and no-op'd). `visible` is a
1183
+ // VALUE probe like `name`/`locked`: every live adapter answers it, a
1184
+ // Boundary answers undefined.
1185
+ const owner = ownerAdapterOf(adapter, state.nodeId);
1186
+ const ownerStructure = owner.structure;
1187
+ const hasStructure = !!ownerStructure;
1188
+ const nameKnown = adapter.inspector?.get(state.nodeId, 'name') !== undefined;
1189
+ const lockedKnown = adapter.inspector?.get(state.nodeId, 'locked') !== undefined;
1190
+ const locked = adapter.inspector?.get(state.nodeId, 'locked') === true;
1191
+ const visibleKnown = adapter.inspector?.get(state.nodeId, 'visible') !== undefined;
1192
+ const visible = adapter.inspector?.get(state.nodeId, 'visible') !== false;
1193
+ const lockedEditability = adapter.inspector?.editability?.(state.nodeId, 'locked');
1194
+ const visibleEditability = adapter.inspector?.editability?.(state.nodeId, 'visible');
1195
+ const lockedWritable = lockedKnown && (lockedEditability?.writable ?? true);
1196
+ const visibleWritable = visibleKnown && (visibleEditability?.writable ?? true);
1197
+ // The FOCUSED stage's selection (ARCHITECTURE-CORE §One stage unit 4): a
1198
+ // context menu opened over a model document's row must copy that stage's
1199
+ // selection, not the world's.
1200
+ const selectedIds = [...focusedStageStore(store).selectedEntityIds];
1201
+ const clipboardIds = selectedIds.includes(state.nodeId) ? selectedIds : [state.nodeId];
1202
+
1203
+ // Spec 29 §5 disabled-with-reason: when the SUBJECT of an operation is
1204
+ // present (the row exists) but the adapter can't perform it, the item
1205
+ // renders DISABLED with the world's own provenance explanation as its
1206
+ // tooltip — the complement of spec 28's rule (chrome whose subject is
1207
+ // absent is hidden, not disabled).
1208
+ const reason = unavailableReason(provenanceForNode(adapter, state.nodeId));
1209
+ const copyDisabledReason = ownerStructure?.copy
1210
+ ? 'Copy needs source-addressable entities from one world.'
1211
+ : reason;
1212
+ const pasteDisabledReason = ownerStructure?.paste
1213
+ ? 'Copy or cut a source-addressable entity in this world first.'
1214
+ : reason;
1215
+ const items: {
1216
+ label: string;
1217
+ testId?: string;
1218
+ action: () => void;
1219
+ color?: string;
1220
+ disabledReason?: string;
1221
+ }[] = [];
1222
+
1223
+ const affordances = rowAffordances({ structural: isGroupNode, internal: isInternal });
1224
+ // H6 — the only verb a revealed internal keeps is one that writes nothing:
1225
+ // walking UP to the thing that actually is authored. Everything else in the
1226
+ // block below is a write.
1227
+ if (isInternal && node.parentId !== null) {
1228
+ items.push({
1229
+ label: 'Select Parent',
1230
+ testId: 'hierarchy-context-menu-select-parent',
1231
+ action: () => {
1232
+ setAuthoringSelection(adapter, [node.parentId!]);
1233
+ onClose();
1234
+ },
1235
+ });
1236
+ }
1237
+
1238
+ if (!isGroupNode && affordances.writable) {
1239
+ items.push({
1240
+ label: 'Rename',
1241
+ testId: 'hierarchy-context-menu-rename',
1242
+ ...(nameKnown ? {} : { disabledReason: reason }),
1243
+ action: () => {
1244
+ onRename(state.nodeId);
1245
+ onClose();
1246
+ },
1247
+ });
1248
+ items.push({
1249
+ label: 'Cut',
1250
+ testId: 'hierarchy-context-menu-cut',
1251
+ ...(ownerStructure?.cut && (adapter.structure?.canCopy?.(clipboardIds) ?? true)
1252
+ ? {}
1253
+ : { disabledReason: copyDisabledReason }),
1254
+ action: () => {
1255
+ void cutAuthoringNodes(adapter, clipboardIds);
1256
+ onClose();
1257
+ },
1258
+ });
1259
+ items.push({
1260
+ label: 'Copy',
1261
+ testId: 'hierarchy-context-menu-copy',
1262
+ ...(ownerStructure?.copy && (adapter.structure?.canCopy?.(clipboardIds) ?? true)
1263
+ ? {}
1264
+ : { disabledReason: copyDisabledReason }),
1265
+ action: () => {
1266
+ void copyAuthoringNodes(adapter, clipboardIds);
1267
+ onClose();
1268
+ },
1269
+ });
1270
+ items.push({
1271
+ label: 'Paste',
1272
+ testId: 'hierarchy-context-menu-paste',
1273
+ ...(ownerStructure?.paste && (adapter.structure?.canPaste?.(node.parentId) ?? true)
1274
+ ? {}
1275
+ : { disabledReason: pasteDisabledReason }),
1276
+ action: () => {
1277
+ void pasteAuthoringNodes(adapter, node.parentId);
1278
+ onClose();
1279
+ },
1280
+ });
1281
+ items.push({
1282
+ label: 'Duplicate',
1283
+ testId: 'hierarchy-context-menu-duplicate',
1284
+ ...(hasStructure ? {} : { disabledReason: reason }),
1285
+ action: () => {
1286
+ // ON THE WHOLE SELECTION when the clicked row is part of one — the
1287
+ // menu acts on what is selected, exactly as the hotkey does. Acting on
1288
+ // the clicked row alone silently dropped the other members (runhuman
1289
+ // pass 68: "select all this, click Delete — just one gets deleted").
1290
+ if (selectedIds.length > 1 && selectedIds.includes(state.nodeId)) {
1291
+ void duplicateSelection(store);
1292
+ } else {
1293
+ void duplicateAuthoringNode(adapter, state.nodeId).ack;
1294
+ }
1295
+ onClose();
1296
+ },
1297
+ });
1298
+ if (ownerStructure?.group && selectedIds.length > 1 && selectedIds.includes(state.nodeId)) {
1299
+ items.push({
1300
+ label: 'Group Selection',
1301
+ testId: 'hierarchy-context-menu-group',
1302
+ action: () => {
1303
+ void groupAuthoringNodes(adapter, selectedIds).ack;
1304
+ onClose();
1305
+ },
1306
+ });
1307
+ }
1308
+ if (ownerStructure?.ungroup && ownerStructure.canUngroup?.(state.nodeId)) {
1309
+ items.push({
1310
+ label: 'Ungroup',
1311
+ testId: 'hierarchy-context-menu-ungroup',
1312
+ action: () => {
1313
+ void ungroupAuthoringNode(adapter, state.nodeId).ack;
1314
+ onClose();
1315
+ },
1316
+ });
1317
+ }
1318
+ if (ownerStructure?.wrap) {
1319
+ items.push({
1320
+ label: 'Wrap',
1321
+ testId: 'hierarchy-context-menu-wrap',
1322
+ action: () => {
1323
+ void wrapAuthoringNode(adapter, state.nodeId);
1324
+ onClose();
1325
+ },
1326
+ });
1327
+ }
1328
+ if (ownerStructure?.unwrap) {
1329
+ items.push({
1330
+ label: 'Unwrap',
1331
+ testId: 'hierarchy-context-menu-unwrap',
1332
+ action: () => {
1333
+ void unwrapAuthoringNode(adapter, state.nodeId);
1334
+ onClose();
1335
+ },
1336
+ });
1337
+ }
1338
+ if (node.parentId !== null) {
1339
+ items.push({
1340
+ label: 'Select Parent',
1341
+ testId: 'hierarchy-context-menu-select-parent',
1342
+ action: () => {
1343
+ setAuthoringSelection(adapter, [node.parentId!]);
1344
+ onClose();
1345
+ },
1346
+ });
1347
+ }
1348
+ items.push({
1349
+ label: visibleKnown && !visible ? 'Show' : 'Hide',
1350
+ ...(visibleWritable ? {} : { disabledReason: visibleEditability?.reason ?? reason }),
1351
+ action: () => {
1352
+ adapter.inspector!.set(state.nodeId, 'visible', !visible);
1353
+ store.notifyIngestEdit();
1354
+ onClose();
1355
+ },
1356
+ });
1357
+ items.push({
1358
+ label: locked ? 'Unlock' : 'Lock',
1359
+ ...(lockedWritable ? {} : { disabledReason: lockedEditability?.reason ?? reason }),
1360
+ action: () => {
1361
+ adapter.inspector!.set(state.nodeId, 'locked', !locked);
1362
+ store.notifyIngestEdit();
1363
+ onClose();
1364
+ },
1365
+ });
1366
+ // First-party escape-hatch extras (formerly prefab unpack/open/save-as,
1367
+ // currently unregistered) — permanent adapter-module contribution seam
1368
+ // (see hierarchy-menu-registry.ts).
1369
+ for (const extra of getHierarchyMenuItems({ nodeId: state.nodeId, store })) {
1370
+ items.push({
1371
+ label: extra.label,
1372
+ ...(extra.color !== undefined ? { color: extra.color } : {}),
1373
+ action: () => {
1374
+ extra.action();
1375
+ onClose();
1376
+ },
1377
+ });
1378
+ }
1379
+ items.push({
1380
+ label: 'Delete',
1381
+ testId: 'hierarchy-context-menu-delete',
1382
+ color: themeVars.semantic.danger,
1383
+ ...(hasStructure ? {} : { disabledReason: reason }),
1384
+ action: () => {
1385
+ // Same selection rule as Duplicate above — and the selection path also
1386
+ // carries the ordering/serialization a multi-delete needs (see
1387
+ // `deleteSelection`'s source-corruption note).
1388
+ if (selectedIds.length > 1 && selectedIds.includes(state.nodeId)) {
1389
+ void deleteSelection(store);
1390
+ } else {
1391
+ adapter.structure!.remove(state.nodeId);
1392
+ }
1393
+ onClose();
1394
+ },
1395
+ });
1396
+ }
1397
+
1398
+ return (
1399
+ // Portaled to the theme root (W6): `position: fixed` inside a
1400
+ // backdrop-filtered floating card would resolve against the card and be
1401
+ // clipped by its overflow — see `ThemeRootPortal`'s rationale.
1402
+ <ThemeRootPortal>
1403
+ <Menu
1404
+ ref={menuRef}
1405
+ data-testid="ingest-context-menu"
1406
+ style={{
1407
+ // U6a (P6 glass-native chrome): chrome paint (fill/border/radius/
1408
+ // shadow/frost/ink) comes from the shared `.vgai-menu` overlay
1409
+ // material — the same Regular-glass vocabulary every other menu
1410
+ // consumes — so only geometry stays inline.
1411
+ position: 'fixed',
1412
+ left: menuPosition.left,
1413
+ top: menuPosition.top,
1414
+ minWidth: 150,
1415
+ maxWidth: 260,
1416
+ fontSize: 'var(--vgai-font-base)',
1417
+ }}
1418
+ >
1419
+ {/* A4 (D8): also suppressed for a root-group MEMBER row, same as an
1420
+ existing composite group row — `isGroupNode` covers both (see the
1421
+ caller's computation below). The Create submenu on a member id would silently
1422
+ land at the scene root instead of doing anything meaningful. */}
1423
+ {!isGroupNode && affordances.writable && creatableKinds.length > 0 && (
1424
+ <div>
1425
+ <MenuItem
1426
+ data-testid="hierarchy-context-menu-create"
1427
+ onSelect={() => setCreateOpen((v) => !v)}
1428
+ style={{
1429
+ justifyContent: 'space-between',
1430
+ gap: space[4],
1431
+ }}
1432
+ >
1433
+ <span>Create</span>
1434
+ <DisclosureIcon direction="right" tone="dim" />
1435
+ </MenuItem>
1436
+ {createOpen &&
1437
+ creatableKinds.map((k) => (
1438
+ <MenuItem
1439
+ key={k.kind}
1440
+ data-testid={`hierarchy-context-menu-create-${k.kind}`}
1441
+ onSelect={() => {
1442
+ if (createGuardRef.current) return;
1443
+ createGuardRef.current = true;
1444
+ void createAuthoringNode(adapter, k.kind, state.nodeId).ack;
1445
+ onClose();
1446
+ }}
1447
+ style={{
1448
+ paddingLeft: spaceVar[12],
1449
+ }}
1450
+ >
1451
+ {k.label}
1452
+ </MenuItem>
1453
+ ))}
1454
+ </div>
1455
+ )}
1456
+ {items.map((item) =>
1457
+ item.disabledReason !== undefined ? (
1458
+ // Same shape as before the Menu-primitive rebuild (U6a): an
1459
+ // aria-disabled row (NOT a :disabled button — the reason tooltip
1460
+ // must keep showing, spec 29 §5) whose click is a no-op.
1461
+ <MenuItem
1462
+ key={item.label}
1463
+ data-testid={item.testId}
1464
+ aria-disabled="true"
1465
+ title={item.disabledReason}
1466
+ style={{
1467
+ // L-21 — disabled rows get a dedicated muted color (not just
1468
+ // opacity-faded active text) plus a not-allowed cursor.
1469
+ cursor: 'not-allowed',
1470
+ color: themeVars.content.dim,
1471
+ opacity: 0.7,
1472
+ }}
1473
+ >
1474
+ {item.label}
1475
+ </MenuItem>
1476
+ ) : (
1477
+ <MenuItem
1478
+ key={item.label}
1479
+ data-testid={item.testId}
1480
+ onSelect={item.action}
1481
+ style={item.color !== undefined ? { color: item.color } : undefined}
1482
+ >
1483
+ {item.label}
1484
+ </MenuItem>
1485
+ ),
1486
+ )}
1487
+ {/* H6 — refusal is VISIBLE and NAMED (doctrine rule 2). The sentence is
1488
+ the ADAPTER's own `editability` reason, the identical string the row's
1489
+ lock glyph, the inspector field and the gizmo-denial hint show; this
1490
+ menu never writes one of its own. */}
1491
+ {isInternal && (
1492
+ <div
1493
+ data-testid="hierarchy-context-menu-internal-note"
1494
+ style={{
1495
+ padding: `${space[4]}px ${space[6]}px`,
1496
+ color: themeVars.content.dim,
1497
+ maxWidth: 240,
1498
+ lineHeight: lineHeightVar.normal,
1499
+ }}
1500
+ >
1501
+ {adapter.transforms?.editability?.(state.nodeId, 'position')?.reason ??
1502
+ 'This rendered part has no authored source identity.'}
1503
+ </div>
1504
+ )}
1505
+ {/* Bounded-authoring honest degrade (landmine f): the ACTIVE adapter has
1506
+ no `structure` provider at all (an ingested/foreign game owns its
1507
+ own scene graph) — Rename/Show-Hide/Lock above still work (they're
1508
+ gated independently, on `inspector`), but Create/Duplicate/Delete/
1509
+ Reparent are not, so say so plainly instead of hiding the menu. */}
1510
+ {!hasStructure && !isInternal && (
1511
+ <div
1512
+ style={{
1513
+ padding: `${space[4]}px ${space[6]}px`,
1514
+ color: themeVars.semantic.warning,
1515
+ maxWidth: 240,
1516
+ lineHeight: lineHeightVar.normal,
1517
+ }}
1518
+ >
1519
+ <strong>Create / Delete / Reparent — not available.</strong> This game owns its own
1520
+ scene graph, so structural authoring isn't reapplicable. You can edit transform /
1521
+ material / visibility, or capture a subtree into the first-party editable format.
1522
+ </div>
1523
+ )}
1524
+ </Menu>
1525
+ </ThemeRootPortal>
1526
+ );
1527
+ }
1528
+
1529
+ // --- Add / Create menu (shared by the toolbar "+" and reused create list) ---
1530
+
1531
+ function CreateMenu({ adapter, parentId }: { adapter: AuthoringAdapter; parentId: string | null }) {
1532
+ const [open, setOpen] = useState(false);
1533
+ const ref = useRef<HTMLDivElement>(null);
1534
+ // At-most-once-per-open guard. `setOpen(false)` on select is async — the menu
1535
+ // item stays mounted until the next render, so under CI input jank a single
1536
+ // menu-open can receive two `click` dispatches on the same item before it
1537
+ // unmounts, double-invoking `create` (the "expected 3, saw 8" overshoot in
1538
+ // 29-game-hierarchy). React state can't gate this because both clicks land in
1539
+ // the same commit; a ref reset on each open bounds it to one create per open.
1540
+ const creatingRef = useRef(false);
1541
+ const kinds = adapter.structure?.creatableKinds?.(parentId) ?? [];
1542
+ const targetLabel = parentId ? adapter.hierarchy.node(parentId)?.label : null;
1543
+
1544
+ useEffect(() => {
1545
+ if (!open) return;
1546
+ creatingRef.current = false; // fresh open → allow exactly one create
1547
+ const handler = (e: MouseEvent) => {
1548
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
1549
+ };
1550
+ const keyHandler = (e: KeyboardEvent) => {
1551
+ if (e.key === 'Escape') setOpen(false);
1552
+ };
1553
+ document.addEventListener('mousedown', handler);
1554
+ document.addEventListener('keydown', keyHandler);
1555
+ return () => {
1556
+ document.removeEventListener('mousedown', handler);
1557
+ document.removeEventListener('keydown', keyHandler);
1558
+ };
1559
+ }, [open]);
1560
+
1561
+ if (kinds.length === 0) return null;
1562
+
1563
+ return (
1564
+ <div ref={ref} style={{ position: 'relative', flexShrink: 0 }}>
1565
+ {/* L-11 — same fixed 20×20 boxed treatment as its `ViewButton` siblings
1566
+ in the toolbar row (collapse/expand/reset), instead of an unboxed
1567
+ inline span with mismatched padding. */}
1568
+ <Button
1569
+ title={targetLabel ? `Add to ${targetLabel}` : 'Add'}
1570
+ aria-label={targetLabel ? `Add to ${targetLabel}` : 'Add'}
1571
+ aria-expanded={open}
1572
+ data-testid="hierarchy-add-menu"
1573
+ onClick={() => setOpen(!open)}
1574
+ variant={open ? 'primary' : 'secondary'}
1575
+ size="compact"
1576
+ >
1577
+ <EditorIcon icon={faPlus} style={{ fontSize: 'var(--vgai-font-sm)' }} />{' '}
1578
+ <EditorIcon icon={faCaretDown} style={{ fontSize: 'var(--vgai-font-xs)' }} />
1579
+ </Button>
1580
+ {open && (
1581
+ <Menu
1582
+ style={{
1583
+ // U6a: same Menu-primitive rebuild as the ingest context menu
1584
+ // above — chrome paint arrives through `.vgai-menu`'s shared
1585
+ // overlay material; geometry stays inline.
1586
+ position: 'absolute',
1587
+ top: '100%',
1588
+ right: 0,
1589
+ marginTop: space[2],
1590
+ minWidth: 130,
1591
+ maxHeight: 'min(480px, 70vh)',
1592
+ overflowY: 'auto',
1593
+ }}
1594
+ >
1595
+ {kinds.map((k) => (
1596
+ <MenuItem
1597
+ key={k.kind}
1598
+ data-testid={`hierarchy-add-${k.kind}`}
1599
+ onSelect={() => {
1600
+ if (creatingRef.current) return; // ignore a double-dispatched click
1601
+ creatingRef.current = true;
1602
+ void createAuthoringNode(adapter, k.kind, parentId ?? undefined).ack;
1603
+ setOpen(false);
1604
+ }}
1605
+ >
1606
+ {k.label}
1607
+ </MenuItem>
1608
+ ))}
1609
+ </Menu>
1610
+ )}
1611
+ </div>
1612
+ );
1613
+ }
1614
+
1615
+ // --- Row ---
1616
+
1617
+ function MoreRow({
1618
+ row,
1619
+ onReveal,
1620
+ }: {
1621
+ row: HierarchyNodeRow;
1622
+ onReveal: (parentPathKey: string) => void;
1623
+ }) {
1624
+ const hidden = row.more!.hidden;
1625
+ return (
1626
+ <Button
1627
+ type="button"
1628
+ variant="ghost"
1629
+ className="vgai-hierarchy-more"
1630
+ data-testid="ingest-row-more"
1631
+ onClick={() => onReveal(row.more!.parentPathKey)}
1632
+ style={{
1633
+ paddingLeft: indentPx(row.depth + 1),
1634
+ }}
1635
+ >
1636
+ … {hidden} more
1637
+ </Button>
1638
+ );
1639
+ }
1640
+
1641
+ /**
1642
+ * D4 (spec27 §6 D4, layer-tree "search filters + HIGHLIGHTS") — split
1643
+ * `label` into `<mark>`-wrapped/plain segments around every case-insensitive
1644
+ * occurrence of `term`, or the plain label unchanged when `term` is empty
1645
+ * (the pre-D4 render, byte-identical). Pure text math — no adapter/DOM
1646
+ * access — factored out purely so `Row` itself stays simple.
1647
+ */
1648
+ function highlightSegments(label: string, term: string): React.ReactNode {
1649
+ if (!term) return label;
1650
+ const lower = label.toLowerCase();
1651
+ const needle = term.toLowerCase();
1652
+ const parts: React.ReactNode[] = [];
1653
+ let cursor = 0;
1654
+ let idx = lower.indexOf(needle, cursor);
1655
+ if (idx < 0) return label;
1656
+ let key = 0;
1657
+ while (idx >= 0) {
1658
+ if (idx > cursor) parts.push(label.slice(cursor, idx));
1659
+ parts.push(
1660
+ <mark
1661
+ key={key++}
1662
+ data-testid="ingest-row-search-highlight"
1663
+ style={{
1664
+ background: themeVars.semantic.warning,
1665
+ color: themeVars.surface.shell,
1666
+ borderRadius: themeVars.shape.small,
1667
+ }}
1668
+ >
1669
+ {label.slice(idx, idx + needle.length)}
1670
+ </mark>,
1671
+ );
1672
+ cursor = idx + needle.length;
1673
+ idx = lower.indexOf(needle, cursor);
1674
+ }
1675
+ if (cursor < label.length) parts.push(label.slice(cursor));
1676
+ return parts;
1677
+ }
1678
+
1679
+ interface RowProps {
1680
+ row: HierarchyNodeRow;
1681
+ adapter: AuthoringAdapter;
1682
+ store: EditorShellStore;
1683
+ isSelected: boolean;
1684
+ /** Is this the selection's ACTIVE row — Blender's active object, and here
1685
+ * `EditorShellStore.selectedEntityId`: the last id the selection took, which
1686
+ * is already the gizmo's binding and the inspector's subject. Never true
1687
+ * without {@link isSelected}. Within a single selection it is simply the one
1688
+ * row; within a multi-selection it is the only thing on screen that says
1689
+ * which row those two panels are answering about. */
1690
+ isSelectionActive: boolean;
1691
+ isEditing: boolean;
1692
+ isCollapsed: boolean;
1693
+ dropZone: DropZone | null;
1694
+ isDragged: boolean;
1695
+ /** The row's ABSOLUTE index in the flattened tree — the alternating stripe's
1696
+ * phase. Not its DOM position: this list is virtualized behind a spacer
1697
+ * div, so `nth-child` counts the wrong thing (see the call site). */
1698
+ rowIndex: number;
1699
+ /** The depths of the rows immediately above and below this one in the
1700
+ * FLATTENED list, `-1` where there is none — the only two facts the indent
1701
+ * guides need beyond the row's own depth ({@link indentGuidesFor}). Passed
1702
+ * rather than derived here because a virtualized row cannot see its
1703
+ * neighbours; the caller holds the whole list. */
1704
+ prevDepth: number;
1705
+ nextDepth: number;
1706
+ /** H6 — this row exists only because its owner's internals were revealed:
1707
+ * a rendered part with no authored source identity. Read-only, dim, and
1708
+ * invisible to search; see `rowAffordances`. */
1709
+ isInternal: boolean;
1710
+ /** The manifest's sole live Three viewport world, recomputed by the panel
1711
+ * per notify (`resolveThreeViewportRootId`); its group row renders a
1712
+ * live-content marker. */
1713
+ threeRootId: string | null;
1714
+ /** D4 — the trimmed, lowercased-by-the-caller search term (empty string
1715
+ * when the search box is empty), for {@link highlightSegments}. */
1716
+ searchTerm: string;
1717
+ /** H2 — the panel-owned memo of "are all three transform channels refused".
1718
+ * `editability` is NOT free per render (the R3F adapter re-parses JSX
1719
+ * attributes), so no row is allowed to call it directly. */
1720
+ lockCache: TransformLockCache;
1721
+ /** H5 — the panel-owned memo of "does this row carry authorability
1722
+ * warnings". Same key as {@link lockCache}; see `RowWarningCache`. */
1723
+ warningCache: RowWarningCache;
1724
+ /** H2 + H5 — the caches' shared invalidation key; see `TransformLockCache`. */
1725
+ editabilityVersion: number;
1726
+ /** The ACTIVE ADAPTER's own change epoch — see the `adapter.subscribe` effect.
1727
+ * A row reads eye/lock/name/warning state straight off the adapter, so a
1728
+ * change the adapter reports with no change to this row's NODE fields is
1729
+ * invisible without it. */
1730
+ adapterVersion: number;
1731
+ onToggleOpen: (row: HierarchyNodeRow, recursive: boolean) => void;
1732
+ onStartEditing: (id: string) => void;
1733
+ onContextMenu: (e: React.MouseEvent, id: string) => void;
1734
+ onDragStart: (e: React.DragEvent, id: string) => void;
1735
+ onClick: (e: React.MouseEvent, id: string) => void;
1736
+ onEnterScope: (id: string) => void;
1737
+ remoteSelectionColors: readonly string[];
1738
+ /** The `hierarchyTypeSuffix` chrome region, resolved ONCE by the panel
1739
+ * (`workspace-regions.ts`): does a component-instance row print `·Type`
1740
+ * after its name. A look that says `hidden` — Blender's, whose rows carry
1741
+ * the name alone — drops the text only; see the label span for where the
1742
+ * two facts it carried go instead. */
1743
+ typeSuffixShown: boolean;
1744
+ /** The `hierarchyInstanceRule` chrome region, resolved ONCE by the panel
1745
+ * (`workspace-regions.ts`): does a component-instance row draw the dotted
1746
+ * rule under its name. A look that says `hidden` — Blender's, whose Outliner
1747
+ * marks an instance nowhere — drops the rule on every row, selected or not. */
1748
+ instanceRuleShown: boolean;
1749
+ /** The `hierarchyRestrictions` chrome region, resolved ONCE by the panel
1750
+ * (`workspace-regions.ts`): which toggle columns the row's right edge
1751
+ * carries. `viewport+render` drops the selection lock and reserves the
1752
+ * outer column so the eye sits in Blender's own eye slot. */
1753
+ restrictionColumns: NonNullable<ChromeRegions['hierarchyRestrictions']>;
1754
+ /**
1755
+ * THE EXCLUDE CHECKBOX COLUMN, present when ANY row of this tree answers the
1756
+ * reserved `exclude` path — never a chrome knob (owner ruling, 2026-09-19:
1757
+ * restriction columns follow what the adapter ANSWERS; the render column
1758
+ * already worked this way). Blender draws it for `LayerCollection` rows and
1759
+ * it is the LEFTMOST of the restriction columns — `restrict_offsets` is built
1760
+ * right-to-left and `enable` is taken last (`outliner_draw.cc:1218-1245`),
1761
+ * with `SO_RESTRICT_ENABLE` in the default set (`space_outliner.cc:399`). A
1762
+ * row that answers nothing gets the reserved blank cell, so the eye and the
1763
+ * camera stay in one column down the whole tree.
1764
+ */
1765
+ excludeColumnShown: boolean;
1766
+ }
1767
+
1768
+ function sameStrings(left: readonly string[], right: readonly string[]): boolean {
1769
+ return left.length === right.length && left.every((value, index) => value === right[index]);
1770
+ }
1771
+
1772
+ function sameIngestRow(left: HierarchyNodeRow, right: HierarchyNodeRow): boolean {
1773
+ const leftNode = left.node;
1774
+ const rightNode = right.node;
1775
+ return (
1776
+ left.depth === right.depth &&
1777
+ left.pathKey === right.pathKey &&
1778
+ left.hasChildren === right.hasChildren &&
1779
+ left.hiddenByCollapse === right.hiddenByCollapse &&
1780
+ left.more?.parentPathKey === right.more?.parentPathKey &&
1781
+ left.more?.hidden === right.more?.hidden &&
1782
+ leftNode.id === rightNode.id &&
1783
+ leftNode.label === rightNode.label &&
1784
+ leftNode.role === rightNode.role &&
1785
+ leftNode.secondaryLabel === rightNode.secondaryLabel &&
1786
+ leftNode.kind === rightNode.kind &&
1787
+ leftNode.typeLabel === rightNode.typeLabel &&
1788
+ leftNode.parentId === rightNode.parentId &&
1789
+ sameStrings(leftNode.childIds, rightNode.childIds) &&
1790
+ leftNode.crossSurfaceId === rightNode.crossSurfaceId &&
1791
+ leftNode.crossSurfaceParentId === rightNode.crossSurfaceParentId &&
1792
+ leftNode.crossSurfaceOrder === rightNode.crossSurfaceOrder &&
1793
+ leftNode.crossSurfaceGroupLabel === rightNode.crossSurfaceGroupLabel
1794
+ );
1795
+ }
1796
+
1797
+ function sameRowProps(left: RowProps, right: RowProps): boolean {
1798
+ return (
1799
+ sameIngestRow(left.row, right.row) &&
1800
+ left.adapter === right.adapter &&
1801
+ left.store === right.store &&
1802
+ left.isSelected === right.isSelected &&
1803
+ left.isSelectionActive === right.isSelectionActive &&
1804
+ left.isEditing === right.isEditing &&
1805
+ left.isCollapsed === right.isCollapsed &&
1806
+ left.dropZone === right.dropZone &&
1807
+ left.isDragged === right.isDragged &&
1808
+ left.rowIndex === right.rowIndex &&
1809
+ left.prevDepth === right.prevDepth &&
1810
+ left.nextDepth === right.nextDepth &&
1811
+ left.isInternal === right.isInternal &&
1812
+ left.searchTerm === right.searchTerm &&
1813
+ left.lockCache === right.lockCache &&
1814
+ left.warningCache === right.warningCache &&
1815
+ left.editabilityVersion === right.editabilityVersion &&
1816
+ left.adapterVersion === right.adapterVersion &&
1817
+ left.threeRootId === right.threeRootId &&
1818
+ left.onToggleOpen === right.onToggleOpen &&
1819
+ left.onStartEditing === right.onStartEditing &&
1820
+ left.onContextMenu === right.onContextMenu &&
1821
+ left.onDragStart === right.onDragStart &&
1822
+ left.onClick === right.onClick &&
1823
+ left.onEnterScope === right.onEnterScope &&
1824
+ left.typeSuffixShown === right.typeSuffixShown &&
1825
+ left.instanceRuleShown === right.instanceRuleShown &&
1826
+ left.restrictionColumns === right.restrictionColumns &&
1827
+ left.excludeColumnShown === right.excludeColumnShown &&
1828
+ sameStrings(left.remoteSelectionColors, right.remoteSelectionColors)
1829
+ );
1830
+ }
1831
+
1832
+ const Row = memo(function Row({
1833
+ row,
1834
+ adapter,
1835
+ store,
1836
+ isSelected,
1837
+ isSelectionActive,
1838
+ isEditing,
1839
+ isCollapsed,
1840
+ dropZone,
1841
+ isDragged,
1842
+ rowIndex,
1843
+ prevDepth,
1844
+ nextDepth,
1845
+ isInternal,
1846
+ searchTerm,
1847
+ lockCache,
1848
+ warningCache,
1849
+ editabilityVersion,
1850
+ adapterVersion: _adapterVersion,
1851
+ onToggleOpen,
1852
+ onStartEditing,
1853
+ threeRootId,
1854
+ onContextMenu,
1855
+ onDragStart,
1856
+ onClick,
1857
+ onEnterScope,
1858
+ remoteSelectionColors,
1859
+ typeSuffixShown,
1860
+ instanceRuleShown,
1861
+ restrictionColumns,
1862
+ excludeColumnShown,
1863
+ }: RowProps) {
1864
+ const { node, depth, hasChildren } = row;
1865
+ // A composite `world:<id>` group row (session-local eye per D9, zOrder badge,
1866
+ // non-draggable) — NOT merely a runtime-only node (see `isCompositeGroupNode`).
1867
+ const badge = compositeGroupBadge(adapter, node.id);
1868
+ const isGroupNode = badge !== null;
1869
+ const isOrganizationRow = isCompositeOrganizationNode(adapter, node.id);
1870
+ const isStructural =
1871
+ isGroupNode ||
1872
+ isOrganizationRow ||
1873
+ (node.role !== undefined && STRUCTURAL_ROLES.has(node.role));
1874
+ // H6 — the ONE gate every affordance below consults. A revealed internal is
1875
+ // non-writable BY CONSTRUCTION (no authored source identity to write to), so
1876
+ // it refuses drag, rename and every write control; selection stays open, and
1877
+ // the H2 lock glyph below carries the adapter's own reason for the refusal.
1878
+ const affordances = rowAffordances({ structural: isStructural, internal: isInternal });
1879
+ // Reserved-path reads, resolved ONCE per row and only for non-structural
1880
+ // rows: an adapter that doesn't support a reserved path returns
1881
+ // `undefined` (its affordance simply doesn't render). Cheap for every
1882
+ // stock adapter — the ingest/react adapters early-return `undefined` for a
1883
+ // non-style path before touching their tree; the first-party adapter
1884
+ // reads a plain descriptor.
1885
+ const lockedValue = isStructural ? undefined : adapter.inspector?.get(node.id, 'locked');
1886
+ const nameValue = isStructural ? undefined : adapter.inspector?.get(node.id, 'name');
1887
+ // VALUE probe like `locked`/`name` above (not provider presence — the
1888
+ // composite's inspector is a never-undefined router, which put a no-op eye
1889
+ // on Boundary rows): every live adapter answers `visible`; a Boundary
1890
+ // answers undefined and gets no eye.
1891
+ const visibleKnown = !isStructural && adapter.inspector?.get(node.id, 'visible') !== undefined;
1892
+ const lockedEditability = isStructural
1893
+ ? undefined
1894
+ : adapter.inspector?.editability?.(node.id, 'locked');
1895
+ const visibleEditability = isStructural
1896
+ ? undefined
1897
+ : adapter.inspector?.editability?.(node.id, 'visible');
1898
+ // THE RENDER COLUMN's own reserved path, probed the same way `visible` is.
1899
+ // Almost every adapter answers `undefined` — three.js has ONE
1900
+ // `Object3D.visible` governing viewport and render alike — and the column
1901
+ // then stays the reserved blank cell it has been. An adapter whose truth
1902
+ // really does separate the two fills it in, which is what the region's own
1903
+ // note (`workspace-regions.ts`, `hierarchyRestrictions`) said would happen:
1904
+ // Blender's Outliner draws `hide_viewport` and `hide_render` as two columns
1905
+ // (`outliner_draw.cc:1291-1384`), and `@volter/editor-blender`'s tree answers both.
1906
+ const renderValue = isStructural ? undefined : adapter.inspector?.get(node.id, 'renderVisible');
1907
+ const renderKnown = renderValue !== undefined;
1908
+ const renderVisible = renderValue !== false;
1909
+ const renderEditability = isStructural
1910
+ ? undefined
1911
+ : adapter.inspector?.editability?.(node.id, 'renderVisible');
1912
+ const renderWritable = renderKnown && (renderEditability?.writable ?? true);
1913
+ // THE EXCLUDE COLUMN's own reserved path, probed exactly as `renderVisible`
1914
+ // is. `true` means EXCLUDED, the sense Blender's own property carries
1915
+ // (`LayerCollection.exclude`), so the checkbox is CHECKED when the value is
1916
+ // false — which is also how Blender draws it: the button is an icon toggle
1917
+ // over a `hide_`-sense property and `outliner_draw.cc:561` inverts it.
1918
+ const excludeValue = isStructural ? undefined : adapter.inspector?.get(node.id, 'exclude');
1919
+ const excludeKnown = excludeValue !== undefined;
1920
+ const excluded = excludeValue === true;
1921
+ const excludeEditability = isStructural
1922
+ ? undefined
1923
+ : adapter.inspector?.editability?.(node.id, 'exclude');
1924
+ const excludeWritable = excludeKnown && (excludeEditability?.writable ?? true);
1925
+ // B1 (D9) — a group row's eye/interactive/pick-lock read the LIFTED,
1926
+ // module-level session store (`world-session-state.ts`), keyed by the
1927
+ // RAW manifest world id `badge.worldId` — not the group-node id itself.
1928
+ const worldHidden = badge ? isRootHidden(badge.worldId) : false;
1929
+ const worldInteractive = badge ? isRootInteractive(badge.worldId) : false;
1930
+ const worldPickLocked = badge ? isRootPickLocked(badge.worldId) : false;
1931
+ const visible = isGroupNode ? !worldHidden : adapter.inspector?.get(node.id, 'visible') !== false;
1932
+ const lockedKnown = lockedValue !== undefined;
1933
+ const locked = lockedValue === true;
1934
+ const lockedWritable = lockedKnown && (lockedEditability?.writable ?? true);
1935
+ const visibleWritable = visibleKnown && (visibleEditability?.writable ?? true);
1936
+ const nameKnown = nameValue !== undefined;
1937
+ // H1 — a component-instance row prints its type as a dim suffix
1938
+ // (`EnemyBravo ·Enemy`) and tints its own label. Detached `#n` pockets carry
1939
+ // no `typeLabel` by construction (#635), so they stay plain.
1940
+ const identity = rowIdentity(node);
1941
+ // Gated like its H2/H5 siblings below: a structural row (a world group, a
1942
+ // member root, a document/folder/story) is not an instance of anything by
1943
+ // construction, so asking is a question with no possible answer.
1944
+ const prefabOverrideCount = isStructural
1945
+ ? 0
1946
+ : (adapter.instances?.describe(node.id)?.overrides.length ?? 0);
1947
+ // H2 — one memoized verdict per row; structural rows (world groups, member
1948
+ // roots, documents/folders/stories) own no transform and are never probed.
1949
+ const transformLock = isStructural
1950
+ ? UNLOCKED
1951
+ : lockCache.summaryFor(adapter, editabilityVersion, node.id);
1952
+ // H5 — authorability warnings badge the row they are about. Structural rows
1953
+ // (world groups, member roots, documents/folders/stories) address no source
1954
+ // element, so they are never probed.
1955
+ // The cast is the STRUCTURAL probe, and the same one `instance-source-menu.ts`
1956
+ // uses for H3: `diagnosticsFor` is an optional adapter capability that
1957
+ // deliberately isn't in the `AuthoringAdapter` contract, so an adapter without
1958
+ // it simply produces no badges (the cache reads `undefined` and stops).
1959
+ const warning = isStructural
1960
+ ? null
1961
+ : warningCache.badgeFor(adapter as RowDiagnosticsSource, editabilityVersion, node.id);
1962
+ // An `InstancedMesh` is ONE object drawing N units, so this row — and every
1963
+ // count derived from the same walk — reports 1 where the reader sees N, with
1964
+ // no error anywhere. The detail is the only place the shortfall is
1965
+ // expressible. Cheap by construction (a type check and a number read, see
1966
+ // `instancedRowDetail`), so it costs a structural row nothing to ask.
1967
+ const instancedDetail = isStructural
1968
+ ? null
1969
+ : instancedRowDetail(entityObject3D(adapter, store.objectMap, node.id));
1970
+ // THE COLLAPSED ROW'S DATABLOCK SUMMARY — Blender's rest state, the first
1971
+ // difference the eye catches in the column. Measured on `outliner.png`:
1972
+ // every object row in the frame sits behind a `>` with its datablock drawn
1973
+ // as an inline glyph one cell past the label's end. Expanding is what
1974
+ // produces the child row; the two never show at once.
1975
+ //
1976
+ // BOUNDED BY CONSTRUCTION. Only a collapsed row with children asks, and it
1977
+ // reads at most the first few child ids — a collapsed group with twenty
1978
+ // thousand scene children must not turn one row into twenty thousand
1979
+ // adapter reads, and it cannot: none of the first ids is a datablock, so
1980
+ // the summary is empty and nothing more is looked up.
1981
+ const datablockSummary =
1982
+ isCollapsed && hasChildren
1983
+ ? node.childIds
1984
+ .slice(0, DATABLOCK_SUMMARY_SCAN)
1985
+ .map((id) => adapter.hierarchy.node(id))
1986
+ .filter((child): child is EditorNode => child !== null && isDatablockKind(child.kind))
1987
+ : [];
1988
+
1989
+ return (
1990
+ <div
1991
+ className="vgai-tree-row"
1992
+ // R5 (P6-U7): gate on the STABLE `isStructural` (which includes the
1993
+ // `role: 'root'`/`document`/`folder`/`story`/`boundary` STRUCTURAL_ROLES),
1994
+ // not just member/organization rows. A world-root navigator row
1995
+ // (role 'root') must never pollute `getEntityNames()` — and unlike the
1996
+ // async-settling `isOrganizationRow`, the role check doesn't flicker, so
1997
+ // search results stay entity-only deterministically.
1998
+ data-entity-name={isStructural ? undefined : node.label}
1999
+ data-testid="ingest-row"
2000
+ data-ingest-name={node.label}
2001
+ data-node-role={node.role}
2002
+ // THE ROW'S OWN DEPTH, as a fact and not as a padding. It is drawn as
2003
+ // `padding-left: calc(var(--vgai-tree-indent) * depth)`, so until this
2004
+ // attribute existed a reader (the product's chrome door,
2005
+ // `editor-document-probe.ts` scope `'outliner'`) could only recover the
2006
+ // tree's SHAPE by dividing one measured pixel number by another — which
2007
+ // is a reading of the paint, not of the tree. NOT `aria-level`: that
2008
+ // attribute is only valid under a `treeitem`/`row`/`listitem` role, and
2009
+ // this row declares none (it is a `div` the panel hit-tests itself), so
2010
+ // writing it would be invalid ARIA for the sake of a reader that has
2011
+ // `data-depth`.
2012
+ data-depth={depth}
2013
+ data-selected={isSelected ? 'true' : undefined}
2014
+ // The selection's ACTIVE row (see `isSelectionActive`). Its own attribute
2015
+ // rather than a second value on `data-selected`, because that value is
2016
+ // queried by name elsewhere in this file and by the e2e estate.
2017
+ data-selection-active={isSelectionActive ? 'true' : undefined}
2018
+ data-remotely-selected={remoteSelectionColors.length > 0 ? 'true' : undefined}
2019
+ data-dragging={isDragged ? 'true' : undefined}
2020
+ // The alternating stripe — see `rowIndex`'s own comment.
2021
+ data-row-alt={rowIndex % 2 === 1 ? 'true' : undefined}
2022
+ // H6 — de-emphasized through the SAME muted token the panel already uses
2023
+ // for a hidden/inactive row (`.vgai-tree-row[data-muted]` →
2024
+ // `--vgai-content-dim`), rather than a second dimming vocabulary. The
2025
+ // separate `data-internal` marker is what identifies the row's KIND.
2026
+ data-internal={isInternal ? 'true' : undefined}
2027
+ // AN EXCLUDED SUBTREE IS FADED, which is Blender's own answer:
2028
+ // `element_should_draw_faded` returns true for a `TSE_LAYER_COLLECTION`
2029
+ // whose `LAYER_COLLECTION_EXCLUDE` is set (`outliner_draw.cc:3331-3334`)
2030
+ // and the row's icon and text are then drawn at `alpha_fac` 0.5
2031
+ // (`:3376`, `:3525`). It rides the SAME muted token every other
2032
+ // de-emphasized row uses rather than a second dimming vocabulary — see
2033
+ // H6 above — and the whole SUBTREE follows because Blender's own
2034
+ // recursive write sets the flag on every descendant collection
2035
+ // (`view_layer__layer_collection_set_flag_recursive_fn`, `:1648-1651`),
2036
+ // so each descendant row answers `exclude` for itself.
2037
+ data-muted={isInternal || excluded || (!isStructural && !visible) ? 'true' : undefined}
2038
+ draggable={affordances.draggable}
2039
+ onDragStart={(e) => onDragStart(e, node.id)}
2040
+ onClick={(e) => {
2041
+ if (badge?.role === 'world') {
2042
+ // Runtime composition belongs to Game. In edit mode a manifest
2043
+ // world row is a document navigator: one click reveals that
2044
+ // world's isolated authoring surface, matching scene/file trees in
2045
+ // established editors. Nothing is swapped underneath: an
2046
+ // entry-based three root has no separate document to load.
2047
+ activateRootDocument(badge.worldId);
2048
+ }
2049
+ if (node.role === 'story') {
2050
+ // React edit mode is a Figma-style board, not a single selected
2051
+ // story pretending to be the runtime root. A story click activates
2052
+ // its owning world document and focuses that already-mounted frame.
2053
+ // Always apply—even when already active—so a second click recenters
2054
+ // a panned board. Double-click remains the explicit route to the
2055
+ // standalone Storybook document and its addon utilities.
2056
+ const storyRootId =
2057
+ adapter instanceof CompositeAuthoringAdapter ? adapter.ownerOf(node.id) : null;
2058
+ if (storyRootId) activateRootDocument(storyRootId);
2059
+ adapter.stories?.apply(node.id, node.id);
2060
+ }
2061
+ onClick(e, node.id);
2062
+ }}
2063
+ onDoubleClick={() => {
2064
+ if (node.role === 'story') {
2065
+ // A story row's node id IS the composed story id, which is the
2066
+ // whole address (`document-open-registry.ts`): resolving it, and
2067
+ // choosing the medium's document, is the story kind's own job.
2068
+ openRegisteredDocument('story', store, { storyId: node.id });
2069
+ } else if (
2070
+ node.role === 'component' ||
2071
+ node.role === 'instance' ||
2072
+ node.role === 'boundary'
2073
+ ) {
2074
+ onEnterScope(node.id);
2075
+ } else if (affordances.renamable && nameKnown) {
2076
+ onStartEditing(node.id);
2077
+ }
2078
+ }}
2079
+ onContextMenu={(e) => {
2080
+ e.preventDefault();
2081
+ onContextMenu(e, node.id);
2082
+ }}
2083
+ style={{
2084
+ // No `height` here: `.vgai-tree-row` already paints
2085
+ // `var(--vgai-tree-row-height)`, and an inline copy silently outranked
2086
+ // the active material's density.
2087
+ position: 'relative',
2088
+ paddingLeft: indentPx(depth),
2089
+ paddingRight: spaceVar[2],
2090
+ gap: spaceVar[2],
2091
+ cursor: 'pointer',
2092
+ outline: dropZone === 'child' ? `1px dashed ${themeVars.accent.default}` : 'none',
2093
+ whiteSpace: 'nowrap',
2094
+ overflow: 'hidden',
2095
+ // The tree is body text at the material's own face size (Blender's
2096
+ // Outliner is 11px); `font-lg` made every row a size larger than the
2097
+ // material asked for.
2098
+ fontSize: 'var(--vgai-font-base)',
2099
+ }}
2100
+ >
2101
+ {dropZone === 'before' && (
2102
+ <DropIndicator position="before" style={{ left: indentPx(depth) }} />
2103
+ )}
2104
+ {dropZone === 'after' && <DropIndicator position="after" style={{ left: indentPx(depth) }} />}
2105
+
2106
+ {/* One rule per ancestor column, on the caret centre of each. Always
2107
+ * rendered: `--vgai-tree-indent-guide` is `transparent` for every
2108
+ * palette that names no `color.boundary.indent`, so a skin without the
2109
+ * member paints nothing and the spans cost it no pixels. */}
2110
+ {indentGuidesFor(depth, prevDepth, nextDepth).map((guide) => (
2111
+ <span
2112
+ key={guide.column}
2113
+ className="vgai-tree-indent-guide"
2114
+ aria-hidden="true"
2115
+ style={{
2116
+ left: `calc(${INDENT} * ${guide.column + 0.5} - 0.5px)`,
2117
+ top: guide.starts ? GUIDE_BLOCK_INSET : 0,
2118
+ bottom: guide.ends ? GUIDE_BLOCK_INSET : 0,
2119
+ }}
2120
+ />
2121
+ ))}
2122
+
2123
+ {hasChildren ? (
2124
+ <IconButton
2125
+ type="button"
2126
+ variant="ghost"
2127
+ size="compact"
2128
+ aria-label={isCollapsed ? `Expand ${node.label}` : `Collapse ${node.label}`}
2129
+ // The state, not only the next gesture's name: a reader asking "is
2130
+ // this branch open" had to parse the English of the label above.
2131
+ aria-expanded={!isCollapsed}
2132
+ data-testid="ingest-row-caret"
2133
+ onClick={(e) => {
2134
+ e.stopPropagation();
2135
+ onToggleOpen(row, e.altKey);
2136
+ }}
2137
+ title="Expand/collapse (Option/Alt-click applies to the branch)"
2138
+ className="vgai-tree-caret"
2139
+ >
2140
+ <EditorIcon icon={isCollapsed ? faCaretRight : faCaretDown} aria-hidden="true" />
2141
+ </IconButton>
2142
+ ) : (
2143
+ <span
2144
+ data-testid="ingest-row-caret"
2145
+ aria-hidden="true"
2146
+ // The same cell the caret button occupies, so a leaf's type glyph
2147
+ // lands in the SAME column as an expandable sibling's. One indent
2148
+ // cell, as Blender's is — see `.vgai-tree-caret` in `theme.css`.
2149
+ style={{ width: INDENT, flexShrink: 0 }}
2150
+ />
2151
+ )}
2152
+
2153
+ {remoteSelectionColors.length > 0 && (
2154
+ <span
2155
+ className="vgai-tree-remote-selection"
2156
+ role="img"
2157
+ title={`Selected by ${remoteSelectionColors.length} collaborator${remoteSelectionColors.length === 1 ? '' : 's'}`}
2158
+ aria-label="Selected by a collaborator"
2159
+ >
2160
+ {remoteSelectionColors.slice(0, 3).map((color) => (
2161
+ <span key={color} style={{ background: color }} aria-hidden="true" />
2162
+ ))}
2163
+ </span>
2164
+ )}
2165
+
2166
+ {/* The live-content marker appears only on the manifest's sole Three
2167
+ * root. Other media open their own authored documents when selected. */}
2168
+ {badge?.role === 'world' && badge.worldId === threeRootId && (
2169
+ <span
2170
+ data-testid="world-focus-radio"
2171
+ data-world-focused="true"
2172
+ title="Live Three world — shown in the viewport"
2173
+ style={{ fontSize: 'var(--vgai-font-xs)', opacity: 0.8, flexShrink: 0 }}
2174
+ >
2175
+ <EditorIcon icon={faCircleDot} aria-hidden="true" />
2176
+ </span>
2177
+ )}
2178
+
2179
+ {/* THE TYPE GLYPH, and why its opacity is a measurement. This is where
2180
+ a glyph's own category ink lands (`EditorIcon` paints a toned glyph
2181
+ in `var(--vgai-category-…)`, and every category token is INK — see
2182
+ `theme.ts`'s category docblock), and the opacity is the SITE's half
2183
+ of Blender's model: the Outliner composites its glyphs at 0.80 over
2184
+ whatever the row paints. At 0.65 the Blender palette's `object`
2185
+ #e09557 composites to #a27149 over the panel, a washed tan against
2186
+ the #bb7f4d Blender actually draws; at 0.80 it composites to exactly
2187
+ #bb7f4d on the row's #272727 and exactly #c48856 on the plated
2188
+ #525252 — every channel of both, which is what proves the alpha.
2189
+ (It read 0.79 until 2026-09-18, from the two-background form
2190
+ (196−187)/(82−39) whose numerator carried one rounding step; 0.80
2191
+ is the direct fit against the ink photographed at full in
2192
+ `properties-object.png`.) It is a glyph's weight against its row,
2193
+ not a de-emphasis: do not round it away. */}
2194
+ <span
2195
+ // The class is what `theme.css`'s ACTIVE-ROW PLATE hangs its rule on;
2196
+ // the plate is drawn as this span's own `::before` so the glyph's box
2197
+ // — and with it every row measurement already fitted to the frame —
2198
+ // does not move.
2199
+ className="vgai-tree-type-glyph"
2200
+ title={node.role ? `${node.role}: ${node.kind}` : node.kind}
2201
+ style={{
2202
+ // ICONS HAVE THEIR OWN SIZE AXIS (`theme.ts`'s `iconSize`), and this
2203
+ // call site was still on the TYPE axis, so a skin that tightened its
2204
+ // text shrank the Outliner's type glyphs with it. The icon rungs
2205
+ // default to the type scale's values, so nothing moves for a skin
2206
+ // that declares no `density.icon`.
2207
+ //
2208
+ // Blender's own is larger still: on the native 2x `outliner.png` the
2209
+ // Camera row's glyph inks 30x30 device px — 15 CSS — where our set
2210
+ // draws ink at ~0.85 of its box (`blender-icons.source.mjs`'s `S`),
2211
+ // so 15 of ink is an 18px box and `icon.sm` (14) leaves ~3 px on the
2212
+ // table. That is a rung this site cannot spend without moving every
2213
+ // other skin's tree; it is a measurement, recorded, not a paint-over.
2214
+ width: 'var(--vgai-icon-md)',
2215
+ fontSize: 'var(--vgai-icon-sm)',
2216
+ // THE 0.80 MOVED TO THE GLYPH (`theme.css`'s
2217
+ // `.vgai-tree-type-glyph > *`), for the reason its datablock sibling
2218
+ // already carries: the active row's plate is this span's own
2219
+ // `::before`, so a group opacity here multiplied the plate's
2220
+ // measured alphas by 0.8 as well — measured 0.203 against the
2221
+ // declared 0.26 before the move. The glyph's own weight is
2222
+ // unchanged, and so is every row that draws no plate. */
2223
+ flexShrink: 0,
2224
+ textAlign: 'center',
2225
+ }}
2226
+ >
2227
+ <EditorIcon icon={iconForNode(node)} aria-hidden="true" />
2228
+ </span>
2229
+
2230
+ {isEditing ? (
2231
+ <TextInput
2232
+ autoFocus
2233
+ defaultValue={node.label}
2234
+ onBlur={(e) => {
2235
+ adapter.inspector!.set(node.id, 'name', e.currentTarget.value);
2236
+ store.notifyIngestEdit();
2237
+ onStartEditing('');
2238
+ }}
2239
+ onKeyDown={(e) => {
2240
+ if (e.key === 'Enter') {
2241
+ adapter.inspector!.set(node.id, 'name', e.currentTarget.value);
2242
+ store.notifyIngestEdit();
2243
+ onStartEditing('');
2244
+ }
2245
+ if (e.key === 'Escape') onStartEditing('');
2246
+ }}
2247
+ onClick={(e) => e.stopPropagation()}
2248
+ data-variant="tree-edit"
2249
+ style={{
2250
+ flex: 1,
2251
+ minWidth: 0,
2252
+ }}
2253
+ />
2254
+ ) : (
2255
+ <span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>
2256
+ {/* H1 — instance identity AT THE ROW, carried by an UNDERLINE rather
2257
+ than by recolored text (owner, 2026-07-31). Unity tints the label
2258
+ blue; a rule under the name says the same thing without spending
2259
+ the row's one text color, and it survives what color could not:
2260
+ it reads identically in every palette, needs no contrast budget
2261
+ against the row background, and does not compete with the
2262
+ warning badge and lock glyph that sit on this same row. It also
2263
+ says the right thing — an instance REFERENCES a definition
2264
+ elsewhere, which is what an underline has always meant, and what
2265
+ this row's Go to Callsite / Open Component Source actions do.
2266
+ `semantic.instance` survives as the rule's own color, so the hue
2267
+ is a hint rather than the whole signal.
2268
+
2269
+ The suffix rides INSIDE this ellipsizing span, so a long name
2270
+ still truncates with the row instead of pushing the eye/lock
2271
+ glyphs off it, and is deliberately NOT underlined: the underline
2272
+ marks the instance's NAME, and drawing it under `·Enemy` too
2273
+ would read as one hyperlink over both. Search is unaffected:
2274
+ `searchPathKeys`/`highlightSegments` both read `node.label` only,
2275
+ so the suffix can neither create nor highlight a match.
2276
+
2277
+ A LOOK MAY DROP THE SUFFIX (`typeSuffixShown`, the
2278
+ `hierarchyTypeSuffix` chrome region). Blender's does: measured on
2279
+ `outliner.png`, not one row in that frame carries type text after
2280
+ its name, because the type is the GLYPH there.
2281
+
2282
+ A LOOK MAY ALSO DROP THE RULE (`instanceRuleShown`, the
2283
+ `hierarchyInstanceRule` region), and Blender's does — measured
2284
+ across all three Outliner frames, it marks an instance nowhere in
2285
+ that panel, in any selection state. That is a separate decision
2286
+ from the suffix and it took its own region key: the earlier note
2287
+ here reasoned that a row with no suffix is the row that most needs
2288
+ the rule, which is true of THIS editor's row and says nothing
2289
+ about a look whose reference draws neither.
2290
+
2291
+ THE SELECTED NAME takes the palette's active ink where it declares
2292
+ one (`content.active`; Blender's `#ffae28`, an 82-pixel plateau on
2293
+ the Cube in `outliner.png` and again in
2294
+ `modeling-object-selected.png`). Under a palette that names none
2295
+ the token is empty, the declaration is invalid at computed-value
2296
+ time, and the name inherits the row's ink exactly as before.
2297
+
2298
+ A SELECTED-BUT-NOT-ACTIVE name takes `content.selected` instead —
2299
+ Blender's `#e86900` (232,105,0, a solid plateau on Camera and
2300
+ Light in `modeling-object-selected.png`), against `#ffae28` on the
2301
+ active Cube. The emitter falls `content.selected` back to
2302
+ `content.active`, so a palette naming only the one inks every
2303
+ selected row as before.
2304
+
2305
+ The instance rule yields on a selected row — see
2306
+ `--vgai-tree-active-name-underline` in `theme.ts` for why and for
2307
+ what a palette without an active ink gets; a look may also drop
2308
+ the rule outright, above. */}
2309
+ {identity.typeSuffix ? (
2310
+ <span
2311
+ data-testid="hierarchy-instance-label"
2312
+ // WHEN THE SUFFIX IS HIDDEN the name carries both facts the
2313
+ // suffix used to: the sentence moves onto this `title`, and the
2314
+ // override `*` rides the name rather than leaving with the span
2315
+ // it used to sit in. Nothing the row could say is lost — only
2316
+ // the type TEXT, which is what the region asked to drop.
2317
+ {...(typeSuffixShown
2318
+ ? {}
2319
+ : { title: `Component instance of <${identity.typeSuffix}>` })}
2320
+ style={{
2321
+ // The LOOK decides first (`instanceRuleShown`); only where the
2322
+ // rule is drawn at all does the palette's active-ink rule get
2323
+ // to make the selected row yield.
2324
+ textDecorationLine: !instanceRuleShown
2325
+ ? 'none'
2326
+ : isSelected
2327
+ ? 'var(--vgai-tree-active-name-underline)'
2328
+ : 'underline',
2329
+ textDecorationStyle: 'dotted',
2330
+ textDecorationColor: themeVars.semantic.instance,
2331
+ textUnderlineOffset: '3px',
2332
+ ...(isSelected
2333
+ ? {
2334
+ color: isSelectionActive
2335
+ ? themeVars.content.active
2336
+ : themeVars.content.selected,
2337
+ }
2338
+ : {}),
2339
+ }}
2340
+ >
2341
+ {highlightSegments(node.label, searchTerm)}
2342
+ {!typeSuffixShown && prefabOverrideCount > 0 ? '*' : ''}
2343
+ </span>
2344
+ ) : (
2345
+ <span
2346
+ data-testid="hierarchy-plain-label"
2347
+ style={
2348
+ isSelected
2349
+ ? {
2350
+ color: isSelectionActive
2351
+ ? themeVars.content.active
2352
+ : themeVars.content.selected,
2353
+ }
2354
+ : undefined
2355
+ }
2356
+ >
2357
+ {highlightSegments(node.label, searchTerm)}
2358
+ </span>
2359
+ )}
2360
+ {/* THE DATABLOCK SUMMARY, and why it rides INSIDE the label span.
2361
+ The span is `flex: 1`, so anything placed after it lands against
2362
+ the row's right edge where the eye and lock columns are —
2363
+ Blender draws the glyph one cell past the NAME, not at the
2364
+ margin. The suffixes above solve the same problem the same way.
2365
+
2366
+ Measured on `outliner.png` (native 2x, halve for points): the
2367
+ plate is 40x36 device px — one 20-point cell wide, two points
2368
+ shorter than the 20-point row — with a ~10 device-px corner (the
2369
+ left edge is full at y=184 on a box that starts at y=175). Our
2370
+ radius is the material's own `shape.small`; Blender's widgets
2371
+ round at 4 where this plate rounds at 5, and we carry no token
2372
+ for the second number. The cell starts 40-41 device px past the
2373
+ label's last ink on all three of the frame's rows, which is one
2374
+ cell, so the gap is the indent step. */}
2375
+ {datablockSummary.map((child) => (
2376
+ <span
2377
+ key={child.id}
2378
+ className="vgai-tree-datablock"
2379
+ // THE PLATE FOLLOWS THE ACTIVE ROW, NOT THE SELECTION, and the
2380
+ // frames are unambiguous: in `modeling-object-selected.png` all
2381
+ // three rows are selected and NOT ONE carries a mesh-data plate,
2382
+ // while in `outliner.png` (edit mode) only the ACTIVE row's does.
2383
+ // Under `isSelected` a multi-selection plated every selected
2384
+ // row's datablock — a mark Blender never draws.
2385
+ data-active={isSelectionActive ? 'true' : undefined}
2386
+ title={`${child.kind}: ${child.label}`}
2387
+ >
2388
+ <EditorIcon icon={hierarchyKindIcon(child.kind)} aria-hidden="true" />
2389
+ </span>
2390
+ ))}
2391
+ {identity.typeSuffix && typeSuffixShown && (
2392
+ <span
2393
+ data-testid="hierarchy-instance-type"
2394
+ data-type-label={identity.typeSuffix}
2395
+ title={`Component instance of <${identity.typeSuffix}>`}
2396
+ style={{
2397
+ marginLeft: spaceVar[2],
2398
+ fontSize: 'var(--vgai-font-sm)',
2399
+ color: themeVars.content.dim,
2400
+ }}
2401
+ >
2402
+ ·{identity.typeSuffix}
2403
+ {prefabOverrideCount > 0 ? '*' : ''}
2404
+ </span>
2405
+ )}
2406
+ {node.secondaryLabel && (
2407
+ <span
2408
+ style={{ marginLeft: spaceVar[3], fontSize: 'var(--vgai-font-sm)', opacity: 0.5 }}
2409
+ >
2410
+ {node.secondaryLabel}
2411
+ </span>
2412
+ )}
2413
+ {instancedDetail && (
2414
+ <span
2415
+ data-testid="hierarchy-instanced-units"
2416
+ style={{ marginLeft: spaceVar[3], fontSize: 'var(--vgai-font-sm)', opacity: 0.5 }}
2417
+ >
2418
+ {instancedDetail}
2419
+ </span>
2420
+ )}
2421
+ {badge?.role === 'world' && badge.zOrder !== 0 && (
2422
+ <span
2423
+ title={`Z order: ${badge.zOrder}`}
2424
+ style={{ marginLeft: spaceVar[3], fontSize: 'var(--vgai-font-sm)', opacity: 0.6 }}
2425
+ >
2426
+ Z {badge.zOrder}
2427
+ </span>
2428
+ )}
2429
+ {badge?.provenance && (
2430
+ <span
2431
+ data-testid="world-provenance"
2432
+ data-provenance={badge.provenance.source}
2433
+ title={badge.provenance.detail}
2434
+ style={{
2435
+ marginLeft: spaceVar[3],
2436
+ fontSize: 'var(--vgai-font-xs)',
2437
+ padding: `0 ${space[2]}px`,
2438
+ borderRadius: themeVars.shape.small,
2439
+ border: `1px solid ${themeVars.boundary.strong}`,
2440
+ color: themeVars.content.muted,
2441
+ verticalAlign: 'middle',
2442
+ }}
2443
+ >
2444
+ {badge.provenance.label}
2445
+ </span>
2446
+ )}
2447
+ </span>
2448
+ )}
2449
+
2450
+ {/* H5 — "warnings live on the row" (Godot's yellow `!`). Placed BEFORE
2451
+ the H2 lock glyph deliberately: the two are read together far more
2452
+ often than either is read alone (an unmovable enemy is usually
2453
+ unmovable BECAUSE of an R3F002), and this order puts the cause
2454
+ immediately left of the effect, with both sitting between the label
2455
+ and the interactive action strip (lock/eye buttons) rather than
2456
+ inside it. Like the lock it is NOT a button — a warning is a
2457
+ statement about the source, and the fix is a source edit.
2458
+
2459
+ The tooltip is the analyzer's own sentence(s), verbatim and
2460
+ newline-joined by `rowWarningBadge`; nothing here rewords, ranks or
2461
+ truncates them. */}
2462
+ {warning && (
2463
+ <span
2464
+ data-testid="hierarchy-row-warning"
2465
+ data-warning-codes={warning.codes.join(',')}
2466
+ data-warning-count={warning.count}
2467
+ role="img"
2468
+ title={warning.tooltip}
2469
+ aria-label={`${node.label}: ${warning.tooltip}`}
2470
+ style={{
2471
+ fontSize: 'var(--vgai-font-sm)',
2472
+ color: themeVars.semantic.warning,
2473
+ flexShrink: 0,
2474
+ }}
2475
+ >
2476
+ <EditorIcon icon={faTriangleExclamation} aria-hidden="true" />
2477
+ </span>
2478
+ )}
2479
+
2480
+ {/* H2 — movability at a glance. NOT a button: this is a read-only
2481
+ statement about the source, not a toggle (the toggle is the
2482
+ `ingest-row-lock` control below, which writes the adapter's `locked`
2483
+ property). The tooltip is the adapter's own reason string, verbatim
2484
+ — the same sentence the inspector field and the gizmo-denial hint
2485
+ show. Never paraphrase it here. */}
2486
+ {transformLock.locked && transformLock.reason && (
2487
+ <span
2488
+ data-testid="hierarchy-transform-lock"
2489
+ role="img"
2490
+ title={transformLock.reason}
2491
+ aria-label={`${node.label}: ${transformLock.reason}`}
2492
+ style={{
2493
+ fontSize: 'var(--vgai-font-sm)',
2494
+ color: themeVars.content.dim,
2495
+ flexShrink: 0,
2496
+ }}
2497
+ >
2498
+ <EditorIcon icon={faLock} aria-hidden="true" />
2499
+ </span>
2500
+ )}
2501
+
2502
+ {/* THE SELECTION LOCK is a restriction COLUMN, and a look decides which
2503
+ columns a row carries (`restrictionColumns`, the
2504
+ `hierarchyRestrictions` chrome region — Blender calls the same
2505
+ decision its Outliner filter). Under `viewport+render` this column is
2506
+ not in the set: measured on `outliner.png`, no row in the frame draws
2507
+ a lock, Blender's Disable Selection column being off by default. The
2508
+ `locked` property itself is untouched — the inspector's own field
2509
+ still writes it, so nothing becomes unreachable. */}
2510
+ {restrictionColumns === 'select+viewport' && lockedKnown && affordances.writable && (
2511
+ <IconButton
2512
+ type="button"
2513
+ variant="ghost"
2514
+ size="compact"
2515
+ data-testid="ingest-row-lock"
2516
+ disabled={!lockedWritable}
2517
+ title={
2518
+ lockedWritable ? (locked ? 'Unlock' : 'Lock') : lockedEditability?.reason || 'Locked'
2519
+ }
2520
+ aria-label={locked ? `Unlock ${node.label}` : `Lock ${node.label}`}
2521
+ aria-pressed={locked}
2522
+ onClick={(e) => {
2523
+ e.stopPropagation();
2524
+ adapter.inspector!.set(node.id, 'locked', !locked);
2525
+ store.notifyIngestEdit();
2526
+ }}
2527
+ >
2528
+ <EditorIcon icon={locked ? faLock : faLockOpen} aria-hidden="true" />
2529
+ </IconButton>
2530
+ )}
2531
+
2532
+ {/* THE EXCLUDE COLUMN — Blender's leftmost restriction column, drawn for
2533
+ `LayerCollection` rows (`outliner_draw.cc:1634-1653`) and present in
2534
+ its default `show_restrict_flags` (`space_outliner.cc:399`).
2535
+
2536
+ IT FOLLOWS WHAT THE ADAPTER ANSWERS and nothing else (owner ruling,
2537
+ 2026-09-19; the render column already worked this way). The column
2538
+ exists on every row of a tree in which ANY row answers `exclude`
2539
+ (`excludeColumnShown`, resolved once by the panel), and a row that
2540
+ answers nothing gets the reserved blank cell — so the eye and the
2541
+ camera stay in one column down the whole tree, which is the same
2542
+ reason the render cell is reserved.
2543
+
2544
+ The value's sense is Blender's: `true` is EXCLUDED, so the box is
2545
+ CHECKED when the collection is IN the view layer. Blender draws it the
2546
+ same way round — an icon toggle over a `hide_`-sense property, which
2547
+ `outliner_draw.cc:561` inverts. */}
2548
+ {excludeColumnShown &&
2549
+ (excludeKnown && affordances.writable ? (
2550
+ <IconButton
2551
+ type="button"
2552
+ variant="ghost"
2553
+ size="compact"
2554
+ data-testid="ingest-row-exclude"
2555
+ disabled={!excludeWritable}
2556
+ title={
2557
+ excludeWritable
2558
+ ? excluded
2559
+ ? 'Include in view layer'
2560
+ : 'Exclude from view layer'
2561
+ : excludeEditability?.reason || 'Exclusion is read-only'
2562
+ }
2563
+ aria-label={
2564
+ excluded
2565
+ ? `Include ${node.label} in the view layer`
2566
+ : `Exclude ${node.label} from the view layer`
2567
+ }
2568
+ aria-checked={!excluded}
2569
+ role="checkbox"
2570
+ onClick={(e) => {
2571
+ e.stopPropagation();
2572
+ adapter.inspector!.set(node.id, 'exclude', !excluded);
2573
+ store.notifyIngestEdit();
2574
+ }}
2575
+ >
2576
+ <EditorIcon
2577
+ icon={excluded ? OUTLINER_EXCLUDE_OFF : OUTLINER_EXCLUDE_ON}
2578
+ aria-hidden="true"
2579
+ />
2580
+ </IconButton>
2581
+ ) : (
2582
+ <span aria-hidden="true" style={{ width: INDENT, flexShrink: 0 }} />
2583
+ ))}
2584
+
2585
+ {/* H6 — the eye is SUPPRESSED on a revealed internal. This gate is
2586
+ contract-level: an adapter may implement `internalChildren` over
2587
+ source-backed or foreign content, and H6's promise is that revealing
2588
+ internals adds NO write surface at all. A row that refuses rename,
2589
+ drag, duplicate and delete but offers one working toggle reads as an
2590
+ accident, not a rule. */}
2591
+ {(visibleKnown || isGroupNode) && affordances.writable && (
2592
+ <IconButton
2593
+ type="button"
2594
+ variant="ghost"
2595
+ size="compact"
2596
+ data-testid="ingest-row-visibility"
2597
+ disabled={!isGroupNode && !visibleWritable}
2598
+ title={
2599
+ isGroupNode || visibleWritable
2600
+ ? visible
2601
+ ? 'Hide'
2602
+ : 'Show'
2603
+ : visibleEditability?.reason || 'Visibility is read-only'
2604
+ }
2605
+ aria-label={visible ? `Hide ${node.label}` : `Show ${node.label}`}
2606
+ aria-pressed={!visible}
2607
+ onClick={(e) => {
2608
+ e.stopPropagation();
2609
+ if (badge) {
2610
+ toggleRootHidden(badge.worldId);
2611
+ store.notifyIngestEdit();
2612
+ } else {
2613
+ adapter.inspector!.set(node.id, 'visible', !visible);
2614
+ store.notifyIngestEdit();
2615
+ }
2616
+ }}
2617
+ >
2618
+ <EditorIcon icon={visible ? faEye : faEyeSlash} aria-hidden="true" />
2619
+ </IconButton>
2620
+ )}
2621
+
2622
+ {/* THE RENDER COLUMN. Under `viewport+render` the row's column set is
2623
+ Blender's, whose outer slot is Disable in Renders
2624
+ (`outliner_draw.cc:1363-1384`, `Object.hide_render`).
2625
+
2626
+ BLANK UNLESS THE ADAPTER SEPARATES THE TWO, which almost none do:
2627
+ three.js has ONE `Object3D.visible` governing viewport and render
2628
+ alike, so a toggle over it would write nothing and fail "a control is
2629
+ accepted through its own click". It is then the POSITION that is
2630
+ load-bearing — Blender's columns are slots, its eye 32 CSS px in from
2631
+ the area's right edge and its camera 12, so an eye drawn last lands
2632
+ in the camera's slot and aligns with neither. One reserved cell (the
2633
+ tree's own indent step, which is Blender's 20 px column pitch) puts
2634
+ the eye where Blender's eye is.
2635
+
2636
+ An adapter that DOES separate them answers the reserved
2637
+ `renderVisible` path and gets the real toggle: `@volter/editor-blender`'s
2638
+ Outliner, whose rows carry `hide_viewport` and `hide_render` as two
2639
+ different facts. Not rendered at all under the editor's own column
2640
+ pair, so no other look gains a gutter. */}
2641
+ {restrictionColumns === 'viewport+render' &&
2642
+ (renderKnown && affordances.writable ? (
2643
+ <IconButton
2644
+ type="button"
2645
+ variant="ghost"
2646
+ size="compact"
2647
+ data-testid="ingest-row-render-visibility"
2648
+ disabled={!renderWritable}
2649
+ title={
2650
+ renderWritable
2651
+ ? renderVisible
2652
+ ? 'Disable in renders'
2653
+ : 'Enable in renders'
2654
+ : renderEditability?.reason || 'Render visibility is read-only'
2655
+ }
2656
+ aria-label={
2657
+ renderVisible ? `Disable ${node.label} in renders` : `Enable ${node.label} in renders`
2658
+ }
2659
+ aria-pressed={!renderVisible}
2660
+ onClick={(e) => {
2661
+ e.stopPropagation();
2662
+ adapter.inspector!.set(node.id, 'renderVisible', !renderVisible);
2663
+ store.notifyIngestEdit();
2664
+ }}
2665
+ >
2666
+ <EditorIcon
2667
+ icon={renderVisible ? OUTLINER_RENDER_ON : OUTLINER_RENDER_OFF}
2668
+ aria-hidden="true"
2669
+ />
2670
+ </IconButton>
2671
+ ) : (
2672
+ <span aria-hidden="true" style={{ width: INDENT, flexShrink: 0 }} />
2673
+ ))}
2674
+
2675
+ {/* B1 (D9) — session-local, per-world design-time toggles: `interactive`
2676
+ * forwards real pointer events to the world's layer (design-time
2677
+ * hover/press testing, `design-time-layers.ts`); `pickLock` is stored
2678
+ * for B4's layered viewport picking to consume. Group rows only —
2679
+ * neither concept applies to an ordinary entity row. */}
2680
+ {badge && (
2681
+ <IconButton
2682
+ type="button"
2683
+ variant="ghost"
2684
+ size="compact"
2685
+ data-testid="world-interactive-toggle"
2686
+ title={
2687
+ worldInteractive
2688
+ ? 'Interactive (layer forwards pointer events)'
2689
+ : 'Not interactive (layer is pointer-events:none)'
2690
+ }
2691
+ aria-label={`Forward pointer events to ${node.label}`}
2692
+ aria-pressed={worldInteractive}
2693
+ onClick={(e) => {
2694
+ e.stopPropagation();
2695
+ toggleRootInteractive(badge.worldId);
2696
+ store.notifyIngestEdit();
2697
+ }}
2698
+ >
2699
+ <EditorIcon icon={faHandPointer} aria-hidden="true" />
2700
+ </IconButton>
2701
+ )}
2702
+ {badge && (
2703
+ <IconButton
2704
+ type="button"
2705
+ variant="ghost"
2706
+ size="compact"
2707
+ data-testid="world-picklock-toggle"
2708
+ title={
2709
+ worldPickLocked ? 'Pick-locked (skipped by topmost-layer picking)' : 'Not pick-locked'
2710
+ }
2711
+ aria-label={`Exclude ${node.label} from viewport picking`}
2712
+ aria-pressed={worldPickLocked}
2713
+ onClick={(e) => {
2714
+ e.stopPropagation();
2715
+ toggleRootPickLock(badge.worldId);
2716
+ store.notifyIngestEdit();
2717
+ }}
2718
+ >
2719
+ <EditorIcon icon={faCrosshairs} aria-hidden="true" />
2720
+ </IconButton>
2721
+ )}
2722
+ </div>
2723
+ );
2724
+ }, sameRowProps);
2725
+
2726
+ // --- Main panel ---
2727
+
2728
+ export interface GameHierarchySurfaceProps {
2729
+ readonly store: EditorShellStore;
2730
+ readonly adapter: AuthoringAdapter;
2731
+ }
2732
+
2733
+ /** The half of the row pipeline derived from the TREE alone — memoized on
2734
+ * `[adapter, store.contentVersion, adapterVersion]`. See the block that
2735
+ * builds it. */
2736
+ interface StructuralRowView {
2737
+ readonly adapter: AuthoringAdapter;
2738
+ readonly contentVersion: number;
2739
+ readonly objectMapEpoch: number;
2740
+ readonly marked: ReturnType<typeof componentMarkView>;
2741
+ readonly internals: ReturnType<typeof internalsProjection>;
2742
+ readonly hierarchyView: ReturnType<typeof authoredContentHierarchy>;
2743
+ readonly structuralRows: HierarchyNodeRow[];
2744
+ readonly defaultExpansion: Map<string, boolean>;
2745
+ readonly index: RowPathIndex;
2746
+ }
2747
+
2748
+ /** The FOLD projected onto a {@link StructuralRowView} — the collapsed set
2749
+ * every row is drawn with, plus the scope row it is relative to. Memoized on
2750
+ * the structure, the fold generation and the scope; NOT on the selection. */
2751
+ interface FoldRowView {
2752
+ readonly scopeRow: HierarchyNodeRow | null;
2753
+ readonly effectiveCollapsedPaths: ReadonlySet<string>;
2754
+ }
2755
+
2756
+ /** The capped/searched/scoped view built on top of a {@link StructuralRowView}
2757
+ * and a {@link FoldRowView}. */
2758
+ interface BrowseRowView {
2759
+ readonly rows: HierarchyNodeRow[];
2760
+ }
2761
+
2762
+ /** Production hierarchy surface with its runtime dependencies made explicit.
2763
+ * The editor shell supplies the active store/adapter below; bounded hosts such
2764
+ * as Storybook can supply the same public authoring contract without booting a
2765
+ * project session or maintaining a second hierarchy implementation. */
2766
+ export function GameHierarchySurface({ store, adapter }: GameHierarchySurfaceProps) {
2767
+ useSyncExternalStore(store.subscribe, store.getSnapshot);
2768
+ const deferredContentVersion = useDeferredValue(store.contentVersion);
2769
+ useSyncExternalStore(subscribeSelectionScope, selectionScopeVersion);
2770
+ // THE ROW MARKS THE ACTIVE LOOK ASKS FOR, read ONCE per panel render and
2771
+ // handed down as ordinary props — never per row. Same shape as
2772
+ // `HeaderTelemetry.tsx`'s read of the same store; the key is a stable string
2773
+ // so `useSyncExternalStore` can compare snapshots by identity.
2774
+ useSyncExternalStore(subscribeChromeRegions, chromeRegionsKey, chromeRegionsKey);
2775
+ const typeSuffixShown = activeChromeRegions().hierarchyTypeSuffix !== 'hidden';
2776
+ const instanceRuleShown = activeChromeRegions().hierarchyInstanceRule !== 'hidden';
2777
+ const restrictionColumns =
2778
+ activeChromeRegions().hierarchyRestrictions ?? ('select+viewport' as const);
2779
+ const [adapterVersion, forceAdapterUpdate] = useReducer((value: number) => value + 1, 0);
2780
+ const observedStoreVersion = useRef(store.getSnapshot());
2781
+ observedStoreVersion.current = store.getSnapshot();
2782
+ const [collaboration, setCollaboration] = useState<CollaborationSnapshot | null>(
2783
+ collaborationSnapshot(),
2784
+ );
2785
+ // AN ADAPTER'S NOTIFICATION ALWAYS BUMPS `adapterVersion`, and that version is
2786
+ // a ROW PROP (see {@link sameRowProps}). It used to bump only when the store
2787
+ // had not moved, which was enough while every adapter mutated its truth
2788
+ // SYNCHRONOUSLY inside the click that changed it: the store's own re-render
2789
+ // then already carried the new value. An adapter whose truth is somewhere
2790
+ // else answers LATER — `@volter/editor-blender`'s Outliner writes a restriction column
2791
+ // into the engine and re-reads the tree a round trip afterwards — and by then
2792
+ // the store's version has long since moved, so the notification was dropped
2793
+ // and the row kept drawing the state it had before the click (measured
2794
+ // 2026-09-19: the eye wrote `hide_viewport` in Blender, the tree re-read, the
2795
+ // store published, and the row's eye never moved). One render per
2796
+ // notification is what a notification means.
2797
+ useEffect(
2798
+ () =>
2799
+ adapter.subscribe?.(() => {
2800
+ observedStoreVersion.current = store.getSnapshot();
2801
+ forceAdapterUpdate();
2802
+ }),
2803
+ [adapter, store],
2804
+ );
2805
+ useEffect(() => connectCollaboration(setCollaboration), []);
2806
+ const remoteSelectionColors = useMemo(() => {
2807
+ const byNode = new Map<string, string[]>();
2808
+ for (const participant of collaboration?.participants ?? []) {
2809
+ if (participant.participantId === EDITOR_PARTICIPANT_ID) continue;
2810
+ for (const selectedId of participant.presence.selection) {
2811
+ const colors = byNode.get(selectedId) ?? [];
2812
+ if (!colors.includes(participant.color)) colors.push(participant.color);
2813
+ byNode.set(selectedId, colors);
2814
+ }
2815
+ }
2816
+ return byNode;
2817
+ }, [collaboration]);
2818
+ // H2 — one memo of the per-row transform-lock verdict for the WHOLE panel,
2819
+ // so `editability` (which the R3F adapter answers by re-parsing JSX
2820
+ // attributes out of cached source text) is called at most once per row per
2821
+ // change, not three times per row per render. The invalidation key is the
2822
+ // sum of the two structural counters: the shell store's deferred content
2823
+ // version and this component's own adapter-subscription counter. Both are
2824
+ // monotone, so the sum strictly
2825
+ // increases whenever either does — a stale entry cannot outlive a source
2826
+ // edit. The cache is a `useRef` (never module state, so it can't leak
2827
+ // between editor sessions) and additionally clears itself whenever the
2828
+ // adapter identity changes; see `TransformLockCache`.
2829
+ const lockCacheRef = useRef(new TransformLockCache());
2830
+ // H5 — the same memo shape and the SAME key for the row's authorability
2831
+ // warnings. Sharing the key is not a shortcut: diagnostics ride the OID index,
2832
+ // and the R3F adapter's `refreshSourceState()` ends by calling
2833
+ // `store.notifyIngestEdit()`, so a newly-fetched index bumps the content version
2834
+ // and clears this cache on the same tick it clears the lock cache.
2835
+ const warningCacheRef = useRef(new RowWarningCache());
2836
+ // Selection/play-state notifications still re-render synchronously through
2837
+ // useSyncExternalStore above, but cannot change transform editability. Keep
2838
+ // the expensive per-row lock/body queries on the store's explicit facet
2839
+ // epoch. An exact object-map delta admits/removes rows but cannot change the
2840
+ // reflected properties of survivors; new ids miss both caches naturally.
2841
+ const editabilityVersion = store.hierarchyRowFacetVersion;
2842
+ // A4 — recomputed per notify (never cached across notifies): drives the
2843
+ // world-group rows' ●/○ focus radio.
2844
+ const threeViewportRootId = resolveThreeViewportRootId(store);
2845
+
2846
+ const [search, setSearch] = useState('');
2847
+ // The dock group's header-actions element, when the shell published one —
2848
+ // the Outliner's controls ride the tab strip rather than a row of their own
2849
+ // (`hierarchy-header-slot.ts`).
2850
+ const headerSlot = useSyncExternalStore(
2851
+ subscribeHierarchyHeaderSlot,
2852
+ hierarchyHeaderSlot,
2853
+ () => null,
2854
+ );
2855
+ const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
2856
+ const [editingId, setEditingId] = useState<string | null>(null);
2857
+ const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
2858
+ const [tailDrop, setTailDrop] = useState(false);
2859
+ const [draggedIds, setDraggedIds] = useState<string[] | null>(null);
2860
+ /** The sentence explaining why the CURRENT hover would do nothing, or `null`.
2861
+ * A ref, not state: it must not re-render the panel on every dragover frame,
2862
+ * and it is only ever read once, at drop. */
2863
+ const dropRefusalRef = useRef<string | null>(null);
2864
+ // D9 — world-group visibility (eye) is SESSION-LOCAL shell state, never
2865
+ // routed through `inspector`/serialized. B1 LIFTED this out of a local
2866
+ // `useState` (which the design-time layer stack could never see) into the
2867
+ // module-level `world-session-state.ts` singleton, so `design-time-layers.ts`
2868
+ // can actually hide/show a world's DOM layer, not just dim its hierarchy
2869
+ // row — see that module's header comment.
2870
+ // Scroll-window state — the live `scrollTop`, updated by
2871
+ // `onScroll` below; `clientHeight` is read fresh from `containerRef` every
2872
+ // render rather than cached in state (see the constants' doc comment).
2873
+ const [scrollTop, setScrollTop] = useState(0);
2874
+
2875
+ const containerRef = useRef<HTMLDivElement>(null);
2876
+ // THE OUTLINER, ONLY WHEN A LOOK PAINTS ONE. `data-vgai-region` is the
2877
+ // host's capability attribute for "this is that editor AREA", and it is
2878
+ // published here too because a panel's body can live in a render container
2879
+ // of the host's own, where the region's copy is not an ancestor of anything
2880
+ // inside the panel.
2881
+ //
2882
+ // The condition is the PALETTE's claim, never a look's name: a palette that
2883
+ // names `color.region.outliner` paints editor areas as areas, and the
2884
+ // frame-justified chrome that only reads as chrome inside one (the empty-row
2885
+ // zebra below, `theme.css`) is that look's. A host with none declared —
2886
+ // Classic's reference frame — gets no attribute and no zebra.
2887
+ const outlinerRegion = useSyncExternalStore(
2888
+ subscribeEditorTheme,
2889
+ editorPaintedRegions,
2890
+ editorPaintedRegions,
2891
+ ).includes('outliner')
2892
+ ? 'outliner'
2893
+ : undefined;
2894
+ const revealedRef = useRef(new Map<string, number>());
2895
+ const sessionExpansionRef = useRef(new Map<string, boolean>());
2896
+ // The row pipeline's two memo slots — see the block that reads them below.
2897
+ // A `useRef`, never module state, so nothing survives an editor session.
2898
+ const rowCacheRef = useRef(
2899
+ new HierarchyRowCache<StructuralRowView, BrowseRowView, FoldRowView>(),
2900
+ );
2901
+ /**
2902
+ * Bumped by every REAL write to the two fold stores this panel owns — the
2903
+ * session map above, the per-project persisted preferences, and the
2904
+ * "show more children" counts. It is the browse memo's invalidation key,
2905
+ * because neither store is a value the memo could compare: both are mutable
2906
+ * maps this component writes in place, and one of them (`localStorage`-backed
2907
+ * expansion preferences) is not even held here.
2908
+ */
2909
+ const expansionGenerationRef = useRef(0);
2910
+ const sessionExpansionProjectRef = useRef<string | null>(null);
2911
+ const lastSelectionExpansionRef = useRef<{
2912
+ adapter: AuthoringAdapter;
2913
+ selection: string;
2914
+ } | null>(null);
2915
+ const lastClickedRef = useRef<string | null>(null);
2916
+ const lastAutoRevealRef = useRef<{ adapter: AuthoringAdapter; selection: string } | null>(null);
2917
+ const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
2918
+
2919
+ const term = search.trim().toLowerCase();
2920
+ const projectIdentity = getCurrentProject()?.rootPath ?? '__unscoped__';
2921
+ const selected = adapter.selection?.get() ?? [];
2922
+ // The toolbar's generic Add menu targets the selected hierarchy node. The
2923
+ // composite adapter then routes the request to that node's owning world
2924
+ // adapter, whose `creatableKinds` is the sole source of menu contents.
2925
+ // Multi-selection has no unambiguous parent, so it intentionally falls
2926
+ // back to the active adapter's root-level insertion behavior.
2927
+ const createTargetId = selected.length === 1 ? selected[0]! : null;
2928
+ const selectionSignature = [...selected].sort().join('\0');
2929
+ const selectedIds = useMemo(
2930
+ () => new Set(selectionSignature === '' ? [] : selectionSignature.split('\0')),
2931
+ [selectionSignature],
2932
+ );
2933
+ // THE SELECTION'S ANCHOR, read from the UNSORTED array — the signature above
2934
+ // sorts, which is right for a memo key ("is it the same SET?") and destroys
2935
+ // the one thing this needs. `selection.get()` reports the adapter's own order
2936
+ // and every stock adapter hands back its insertion-ordered set, so the last
2937
+ // id is the one the selection took last. That id is already what the gizmo
2938
+ // binds to (`EditorShellStore.selectedEntityId`) and what the inspector
2939
+ // inspects (`resolveInspectionSubjectId`); the Outliner's active row is those
2940
+ // two panels saying which row they are answering about, not a new fact.
2941
+ const selectionAnchorId = selected.at(-1) ?? null;
2942
+ const scopeStack = selectionScopeStack(adapter);
2943
+ const scopeId = scopeStack.at(-1) ?? null;
2944
+
2945
+ // THE SELECTED ROW IS ON SCREEN. Reveal opened the ancestors, but the list
2946
+ // never scrolled: a dropped tower was selected at row 24 while the list
2947
+ // clipped at row ~15, so the author still had to hunt for what they had
2948
+ // just dropped (build-47 gate, 2026-09-02). Scroll once per selection
2949
+ // change — and retry across structure updates until the row exists, since
2950
+ // a drop selects its element before the hierarchy has absorbed the remount
2951
+ // — never on later edits, so an author who scrolled away is not dragged
2952
+ // back.
2953
+ const lastScrolledSelectionRef = useRef('');
2954
+ useEffect(() => {
2955
+ if (selectionSignature === '' || lastScrolledSelectionRef.current === selectionSignature) {
2956
+ return;
2957
+ }
2958
+ const container = containerRef.current;
2959
+ const row = container?.querySelector<HTMLElement>('[data-selected="true"]');
2960
+ if (!container || !row) return;
2961
+ const box = container.getBoundingClientRect();
2962
+ const at = row.getBoundingClientRect();
2963
+ if (at.top < box.top || at.bottom > box.bottom) row.scrollIntoView({ block: 'nearest' });
2964
+ lastScrolledSelectionRef.current = selectionSignature;
2965
+ });
2966
+
2967
+ if (sessionExpansionProjectRef.current !== projectIdentity) {
2968
+ sessionExpansionRef.current.clear();
2969
+ sessionExpansionProjectRef.current = projectIdentity;
2970
+ lastSelectionExpansionRef.current = null;
2971
+ }
2972
+
2973
+ // H6 — the hierarchy the panel walks is the adapter's own, plus the internals
2974
+ // of whatever rows the author revealed. Everything downstream (cap, collapse,
2975
+ // search, selection reveal, virtualization) is unchanged and unaware: reveal
2976
+ // composes by feeding the SAME flattener a richer provider, never by forking
2977
+ // the row pipeline. Identity-equal to `adapter.hierarchy` when nothing is
2978
+ // revealed, which is the overwhelmingly common case.
2979
+ //
2980
+ // HIER-COMPONENT-TREE composes IN FRONT of that: the game's own
2981
+ // `vgaiComponentRoot`/`vgaiBuiltInternal` marks turn an instance into one
2982
+ // closed row and fold its runtime-built parts (bones, particle renderers)
2983
+ // into exactly the internals reveal already knows how to show. Feeding the
2984
+ // marked view to `internalsProjection` instead of the bare adapter is the
2985
+ // WHOLE integration — read-only, search exclusion, drag refusal and the
2986
+ // Reveal Internals menu item are the same code for both sources of internals.
2987
+ // ---------------------------------------------------------------------
2988
+ // THE ROW PIPELINE, IN TWO MEMOIZED HALVES.
2989
+ //
2990
+ // Everything from here to `rows` used to run on every render — and this
2991
+ // panel re-renders on every store notify, selection included. On a
2992
+ // 20 000-node world that is two uncapped flattens, a per-node first-open
2993
+ // policy and a per-row ancestor climb, paid for a click that moved a
2994
+ // highlight: 100-430ms of main-thread block AFTER a `select` whose reply
2995
+ // had already gone out in 3ms (scale-speed baseline, 22 of 43 offenders).
2996
+ //
2997
+ // The split is by what each half actually READS. `structure` reads the tree
2998
+ // and nothing else, so it keys on the adapter plus `store.contentVersion`
2999
+ // and the adapter's own structural notification counter —
3000
+ // the counter that stands still for a selection-only notify and moves for
3001
+ // every other mutation, the adapter's own `notifyIngestEdit` on a live
3002
+ // structural change included. `browse` reads the fold/scope/search/reveal
3003
+ // state on top of it. Selection reaches `browse` only through the reveal it
3004
+ // opens, which is why the two helpers below are now indexed rather than
3005
+ // scanned: a click that reveals nothing new leaves both halves untouched.
3006
+ // ---------------------------------------------------------------------
3007
+ const rowCache = rowCacheRef.current;
3008
+ const structuralKey = [adapter, deferredContentVersion, adapterVersion] as const;
3009
+ const structure = rowCache.structural(structuralKey, (previous) => {
3010
+ const marked = componentMarkView(
3011
+ adapter as unknown as MarkedTreeSource,
3012
+ markReaderFor(adapter.hierarchy),
3013
+ );
3014
+ const internals = internalsProjection(marked);
3015
+ const hierarchyView = authoredContentHierarchy(internals.hierarchy);
3016
+ // Always take one uncapped, uncollapsed structural snapshot first. It is
3017
+ // the source for stable defaults, search ancestry, selection reveal, and
3018
+ // branch operations. The final browse tree below is still capped and
3019
+ // virtualized, so this does not inflate rendered DOM for large ingests.
3020
+ const objectMapEpoch = store.gizmoEpoch;
3021
+ const contentDelta = previous ? deferredContentVersion - previous.contentVersion : 0;
3022
+ const objectMapDelta = previous ? objectMapEpoch - previous.objectMapEpoch : 0;
3023
+ const incrementalChanges =
3024
+ previous?.adapter === adapter &&
3025
+ deferredContentVersion === store.contentVersion &&
3026
+ contentDelta > 0 &&
3027
+ contentDelta === objectMapDelta
3028
+ ? store.ingestObjectMapChangesSince(previous.objectMapEpoch)
3029
+ : null;
3030
+ const patchedRows =
3031
+ previous && incrementalChanges
3032
+ ? patchUniqueHierarchyRows(
3033
+ hierarchyView,
3034
+ previous.structuralRows,
3035
+ incrementalChanges.changedParentIds,
3036
+ )
3037
+ : null;
3038
+ const structuralRows = patchedRows ?? flattenHierarchyRows(hierarchyView, new Set());
3039
+ // H6 first-open policy: the revealed row opens, its internals start folded —
3040
+ // the reveal root's DIRECT children are what "reveal" means; a 60-bone rig
3041
+ // dumped whole is not. See `applyRevealExpansionDefaults`.
3042
+ // Order is the policy: shape rule first, then "an instance opens closed",
3043
+ // then reveal (which must win — revealing an instance's internals that left
3044
+ // the instance itself folded would look like the action did nothing). All
3045
+ // three are DEFAULTS; the author's session/persisted fold still wins over
3046
+ // every one of them.
3047
+ const defaultExpansion = applyRevealExpansionDefaults(
3048
+ applyComponentRootExpansionDefaults(
3049
+ defaultExpansionByNode(structuralRows, adapter),
3050
+ structuralRows,
3051
+ (id) => marked.isComponentRoot(id),
3052
+ ),
3053
+ internals,
3054
+ );
3055
+ return {
3056
+ adapter,
3057
+ contentVersion: deferredContentVersion,
3058
+ objectMapEpoch,
3059
+ marked,
3060
+ internals,
3061
+ hierarchyView,
3062
+ structuralRows,
3063
+ defaultExpansion,
3064
+ index: indexRowPaths(structuralRows),
3065
+ };
3066
+ });
3067
+ const { marked, internals, hierarchyView, structuralRows, defaultExpansion } = structure;
3068
+
3069
+ // The fold state a row is ACTUALLY rendered with: the session's own choice
3070
+ // first, then this project's persisted preference, then the structural
3071
+ // default, then open. Every consumer below asks this question by node id —
3072
+ // including the selection-reveal guard, which is why it is not a
3073
+ // row-shaped helper.
3074
+ const expandedById = (id: string): boolean =>
3075
+ sessionExpansionRef.current.get(id) ??
3076
+ hierarchyExpansionPreference(projectIdentity, id) ??
3077
+ defaultExpansion.get(id) ??
3078
+ true;
3079
+
3080
+ // Selection reveal behaves like an ordinary tree expansion for the rest of
3081
+ // this editor session. It is applied only when selection changes, so a user
3082
+ // can explicitly collapse the revealed branch without the current
3083
+ // selection immediately forcing it open again.
3084
+ const lastExpansion = lastSelectionExpansionRef.current;
3085
+ if (lastExpansion?.adapter !== adapter || lastExpansion.selection !== selectionSignature) {
3086
+ for (const id of selectedAncestorNodeIds(structure.index, selectedIds)) {
3087
+ // Only a REAL change counts. Re-asserting a fold that is already open is
3088
+ // what selecting an already-visible node does, and letting that bump the
3089
+ // generation would make the browse memo miss on every single click.
3090
+ //
3091
+ // "Already open" is the EFFECTIVE state, not just the session map's own
3092
+ // entry. Reading only the session map made every first selection of a
3093
+ // branch bump the generation — a node open by default or by the stored
3094
+ // preference has no session entry at all — so the memo below missed on
3095
+ // essentially every click and re-derived the whole browse view for a
3096
+ // fold that never moved.
3097
+ if (expandedById(id)) continue;
3098
+ sessionExpansionRef.current.set(id, true);
3099
+ expansionGenerationRef.current += 1;
3100
+ }
3101
+ // A selection the index cannot place yet has no ancestors to open — a
3102
+ // drop selects the element it made BEFORE the hierarchy's structure has
3103
+ // absorbed the remount, so the reveal above found nothing and, with the
3104
+ // signature recorded here, never tried again: the new tower sat selected
3105
+ // under a collapsed scene root (measured on the build-44 gate,
3106
+ // 2026-09-02). Record the reveal as done only once every selected id is
3107
+ // in the index; until then the next structure retries it.
3108
+ const everySelectedPlaced = [...selectedIds].every((id) =>
3109
+ structure.index.pathKeysByNodeId.has(id),
3110
+ );
3111
+ if (everySelectedPlaced) {
3112
+ lastSelectionExpansionRef.current = { adapter, selection: selectionSignature };
3113
+ }
3114
+ }
3115
+
3116
+ const isExpanded = (row: HierarchyNodeRow): boolean => expandedById(row.node.id);
3117
+
3118
+ // Cap browse-view children (D-C1) whenever a non-first-party/composite
3119
+ // session is active (an ingested/multi-world tree may be huge), OR while
3120
+ // playing (a live scene graph can also balloon) — matches the exact
3121
+ // capping policy the former SceneHierarchy (play-only) / IngestHierarchy
3122
+ // (always, unless searching) components each had on their own.
3123
+ const capEnabled = term.length === 0 && (hasAuthoringOverride() || store.playState !== 'stopped');
3124
+ const temporaryRevealed = mergeRevealCounts(
3125
+ revealedRef.current,
3126
+ capEnabled ? selectedRevealCounts(structure.index, selectedIds) : EMPTY_REVEAL_COUNTS,
3127
+ );
3128
+ // THE FOLD, PROJECTED ONTO THE TREE — its own slot, because deriving it
3129
+ // scans every structural row and a selection is not one of its inputs. It
3130
+ // used to sit inside the browse compute below, whose key carries the reveal
3131
+ // a selection opens, so a click that revealed a branch re-scanned all
3132
+ // 20 000 rows to rebuild a set that had not changed.
3133
+ const fold = rowCacheRef.current.collapsed(
3134
+ [structure, expansionGenerationRef.current, projectIdentity, scopeId],
3135
+ () => {
3136
+ const row = scopeId
3137
+ ? (structuralRows.find((r) => !r.more && r.node.id === scopeId) ?? null)
3138
+ : null;
3139
+ const paths = new Set(
3140
+ structuralRows
3141
+ .filter((r) => !r.more && r.hasChildren && !isExpanded(r))
3142
+ .map((r) => r.pathKey),
3143
+ );
3144
+ if (row) {
3145
+ paths.delete(row.pathKey);
3146
+ for (const path of ancestorPathKeys(row.pathKey)) paths.delete(path);
3147
+ }
3148
+ return { scopeRow: row, effectiveCollapsedPaths: paths };
3149
+ },
3150
+ );
3151
+ const { scopeRow, effectiveCollapsedPaths } = fold;
3152
+ const browse = rowCacheRef.current.browse(
3153
+ [
3154
+ structure,
3155
+ fold,
3156
+ term,
3157
+ capEnabled,
3158
+ // ONLY when capping is on. `flattenHierarchyRows` ignores `revealed`
3159
+ // entirely otherwise, and the selected row's child INDEX is in this
3160
+ // signature — so carrying it uncapped would move the key on every single
3161
+ // click for a value the walk never reads, which is the cache paying its
3162
+ // cost and buying nothing.
3163
+ capEnabled ? revealSignature(temporaryRevealed) : '',
3164
+ ],
3165
+ () => {
3166
+ const browseRows = flattenHierarchyRows(
3167
+ hierarchyView,
3168
+ effectiveCollapsedPaths,
3169
+ temporaryRevealed,
3170
+ capEnabled,
3171
+ );
3172
+ const matchingPaths = term
3173
+ ? searchPathKeys(structuralRows, term, internals.isInternal)
3174
+ : null;
3175
+ const sourceRows = matchingPaths
3176
+ ? structuralRows.filter((row) => matchingPaths.has(row.pathKey))
3177
+ : browseRows;
3178
+ // Session-local world-hide (D9): drop every row belonging to a hidden world
3179
+ // group (the group row itself stays, dimmed by its own `visible` flag above).
3180
+ const scopedRows = scopeRow
3181
+ ? sourceRows
3182
+ .filter((row) => row.pathKey.startsWith(`${scopeRow.pathKey}.`))
3183
+ .map((row) => ({ ...row, depth: Math.max(0, row.depth - scopeRow.depth - 1) }))
3184
+ : sourceRows;
3185
+ const worldFiltered = scopedRows.filter((row) => {
3186
+ // the group row itself always shows
3187
+ if (isCompositeGroupNode(adapter, row.node.id)) return true;
3188
+ let current: string | null = row.node.parentId;
3189
+ while (current) {
3190
+ const badge = compositeGroupBadge(adapter, current);
3191
+ if (badge && isRootHidden(badge.worldId)) return false;
3192
+ current = hierarchyView.node(current)?.parentId ?? null;
3193
+ }
3194
+ return true;
3195
+ });
3196
+ return { rows: term ? worldFiltered : worldFiltered.filter((r) => !r.hiddenByCollapse) };
3197
+ },
3198
+ );
3199
+ const { rows } = browse;
3200
+ // THE EXCLUDE COLUMN IS PRESENT WHEN ANY ROW ANSWERS IT (owner ruling,
3201
+ // 2026-09-19: a restriction column follows what the adapter ANSWERS, never a
3202
+ // chrome knob). Resolved ONCE per panel render and handed down as an ordinary
3203
+ // prop, the way the chrome-region marks above are: asking per row would make
3204
+ // the column appear and disappear down the tree, which is the opposite of
3205
+ // what a column is. Almost every adapter answers `undefined` for every row
3206
+ // and the whole pass is one `undefined` per visible row; `@volter/editor-blender`'s
3207
+ // Outliner answers it for its layer-collection rows.
3208
+ const excludeColumnShown = useMemo(
3209
+ () => rows.some((row) => adapter.inspector?.get(row.node.id, 'exclude') !== undefined),
3210
+ [rows, adapter, adapterVersion, deferredContentVersion],
3211
+ );
3212
+
3213
+ // A filtered-down result set shouldn't leave the viewport
3214
+ // parked at a stale deep scroll offset from a previous (larger) list.
3215
+ useEffect(() => {
3216
+ setScrollTop(0);
3217
+ if (containerRef.current) containerRef.current.scrollTop = 0;
3218
+ }, [term]);
3219
+
3220
+ // Render only the windowed slice of `rows` — everything
3221
+ // above (cap/collapse/search/world-hide filtering) is unchanged; only the
3222
+ // final `.map()` further down is restricted. The drag/reorder + tailDrop
3223
+ // math below index/position against the FULL `rows` array (and read
3224
+ // `containerRef.current.scrollTop` directly), so windowing this slice
3225
+ // alone doesn't disturb them.
3226
+ const viewportPx = containerRef.current?.clientHeight || DEFAULT_VIEWPORT_PX;
3227
+ const rowHeight = treeRowHeight(containerRef.current);
3228
+ // Below the threshold, render every row (no window) — see VIRTUALIZE_MIN_ROWS.
3229
+ const { start, end } =
3230
+ rows.length > VIRTUALIZE_MIN_ROWS
3231
+ ? hierarchyRowWindow(rows.length, scrollTop, viewportPx, rowHeight, OVERSCAN)
3232
+ : { start: 0, end: rows.length };
3233
+ const windowRows = rows.slice(start, end);
3234
+ const topSpacerPx = start * rowHeight;
3235
+ const bottomSpacerPx = (rows.length - end) * rowHeight;
3236
+
3237
+ const setAllExpanded = useCallback(
3238
+ (expanded: boolean) => {
3239
+ const updates = structuralRows
3240
+ .filter((row) => row.hasChildren)
3241
+ .map((row) => [row.node.id, expanded] as const);
3242
+ for (const [id, value] of updates) sessionExpansionRef.current.set(id, value);
3243
+ setHierarchyExpansionPreferences(projectIdentity, updates);
3244
+ expansionGenerationRef.current += 1;
3245
+ forceUpdate();
3246
+ },
3247
+ [projectIdentity, structuralRows],
3248
+ );
3249
+
3250
+ // `editor.hierarchy()`'s door (`hierarchy-panel-view.ts`). This hands over the
3251
+ // rows and predicates computed ABOVE, by reference — the door serializes the
3252
+ // panel's own output rather than re-walking the adapter, because a second row
3253
+ // pipeline is free to disagree with this one and a door that can disagree with
3254
+ // the panel cannot diagnose it (see that module's header). Published from an
3255
+ // effect, so what the door reports has been committed to the DOM.
3256
+ const panelSnapshot: HierarchyPanelSnapshot = {
3257
+ rows,
3258
+ window: { start, end },
3259
+ searchTerm: term ? term : null,
3260
+ scopeId,
3261
+ isExpanded,
3262
+ isInternal: (id) => internals.isInternal(id),
3263
+ isComponentRoot: (id) => marked.isComponentRoot(id),
3264
+ foldedInternalCount: (id) => marked.internalChildren?.(id)?.length ?? 0,
3265
+ expandAll: () => setAllExpanded(true),
3266
+ collapseAll: () => setAllExpanded(false),
3267
+ };
3268
+ useEffect(() => {
3269
+ publishHierarchyPanelSnapshot(panelSnapshot);
3270
+ return () => clearHierarchyPanelSnapshot(panelSnapshot);
3271
+ }, [panelSnapshot]);
3272
+
3273
+ // Row event functions must stay stable across a live structural delta or
3274
+ // React.memo has to revisit every visible survivor merely to receive a new
3275
+ // closure. The callbacks read the latest committed panel inputs through the
3276
+ // same render-ref pattern used by the editor's other long-lived handlers.
3277
+ const rowInteractionRef = useRef({
3278
+ adapter,
3279
+ rows,
3280
+ selectedIds,
3281
+ structuralRows,
3282
+ isExpanded,
3283
+ projectIdentity,
3284
+ });
3285
+ rowInteractionRef.current = {
3286
+ adapter,
3287
+ rows,
3288
+ selectedIds,
3289
+ structuralRows,
3290
+ isExpanded,
3291
+ projectIdentity,
3292
+ };
3293
+
3294
+ const toggleOpen = useCallback((row: HierarchyNodeRow, recursive: boolean) => {
3295
+ const current = rowInteractionRef.current;
3296
+ const expanded = !current.isExpanded(row);
3297
+ const updates: Array<readonly [string, boolean]> = [[row.node.id, expanded]];
3298
+ if (recursive) {
3299
+ const prefix = `${row.pathKey}.`;
3300
+ for (const descendant of current.structuralRows) {
3301
+ if (descendant.hasChildren && descendant.pathKey.startsWith(prefix)) {
3302
+ updates.push([descendant.node.id, expanded]);
3303
+ }
3304
+ }
3305
+ }
3306
+ for (const [id, value] of updates) sessionExpansionRef.current.set(id, value);
3307
+ setHierarchyExpansionPreferences(current.projectIdentity, updates);
3308
+ expansionGenerationRef.current += 1;
3309
+ forceUpdate();
3310
+ }, []);
3311
+
3312
+ const resetExpansion = useCallback(() => {
3313
+ sessionExpansionRef.current.clear();
3314
+ for (const id of selectedAncestorNodeIds(structure.index, selectedIds)) {
3315
+ sessionExpansionRef.current.set(id, true);
3316
+ }
3317
+ clearHierarchyExpansionPreferences(projectIdentity);
3318
+ expansionGenerationRef.current += 1;
3319
+ forceUpdate();
3320
+ }, [projectIdentity, selectionSignature, structure]);
3321
+
3322
+ const revealMore = useCallback((parentPathKey: string) => {
3323
+ const m = revealedRef.current;
3324
+ m.set(parentPathKey, (m.get(parentPathKey) ?? CHILD_CAP) + CHILD_CAP);
3325
+ expansionGenerationRef.current += 1;
3326
+ forceUpdate();
3327
+ }, []);
3328
+
3329
+ // H3 — double-click ENTERS the instance: its scope when it has one, its
3330
+ // callsite when it does not (see `enterInstanceRow` for why Isolate, the
3331
+ // design record's stated target, is not reachable from a scene instance row).
3332
+ const handleEnterScope = useCallback(
3333
+ (id: string) => {
3334
+ enterInstanceRow(adapter, id);
3335
+ },
3336
+ [adapter, store],
3337
+ );
3338
+
3339
+ // Viewport picks and external editor-control selection reveal the selected
3340
+ // row, including through a previously collapsed path and a virtualized
3341
+ // list. Do this only when the selection or adapter changes; ordinary store
3342
+ // notifications must not fight a user's manual scroll position.
3343
+ useEffect(() => {
3344
+ if (!selectionSignature) {
3345
+ lastAutoRevealRef.current = null;
3346
+ return;
3347
+ }
3348
+ const last = lastAutoRevealRef.current;
3349
+ if (last?.adapter === adapter && last.selection === selectionSignature) return;
3350
+ lastAutoRevealRef.current = { adapter, selection: selectionSignature };
3351
+ const index = rows.findIndex((row) => selectedIds.has(row.node.id));
3352
+ const container = containerRef.current;
3353
+ if (index < 0 || !container) return;
3354
+ const height = treeRowHeight(container);
3355
+ const top = index * height;
3356
+ const bottom = top + height;
3357
+ if (top < container.scrollTop) container.scrollTop = top;
3358
+ else if (bottom > container.scrollTop + container.clientHeight) {
3359
+ container.scrollTop = Math.max(0, bottom - container.clientHeight);
3360
+ }
3361
+ setScrollTop(container.scrollTop);
3362
+ }, [adapter, rows, selectedIds, selectionSignature]);
3363
+
3364
+ const handleRowClick = useCallback((e: React.MouseEvent, nodeId: string) => {
3365
+ const current = rowInteractionRef.current;
3366
+ if (!current.adapter.selection) return;
3367
+ if (e.metaKey || e.ctrlKey) {
3368
+ const ids = new Set(current.selectedIds);
3369
+ if (ids.has(nodeId)) ids.delete(nodeId);
3370
+ else ids.add(nodeId);
3371
+ setAuthoringSelection(current.adapter, [...ids]);
3372
+ } else if (e.shiftKey && lastClickedRef.current) {
3373
+ const lastIdx = current.rows.findIndex((r) => r.node.id === lastClickedRef.current);
3374
+ const curIdx = current.rows.findIndex((r) => r.node.id === nodeId);
3375
+ if (lastIdx >= 0 && curIdx >= 0) {
3376
+ const start = Math.min(lastIdx, curIdx);
3377
+ const end = Math.max(lastIdx, curIdx);
3378
+ const rangeIds = current.rows
3379
+ .slice(start, end + 1)
3380
+ .filter((r) => !r.more)
3381
+ .map((r) => r.node.id);
3382
+ setAuthoringSelection(current.adapter, rangeIds);
3383
+ }
3384
+ } else {
3385
+ setAuthoringSelection(current.adapter, [nodeId]);
3386
+ }
3387
+ lastClickedRef.current = nodeId;
3388
+ }, []);
3389
+
3390
+ const handleRowDragStart = useCallback((e: React.DragEvent, nodeId: string) => {
3391
+ const selectedIds = rowInteractionRef.current.selectedIds;
3392
+ const ids = selectedIds.has(nodeId) ? [...selectedIds] : [nodeId];
3393
+ setDraggedIds(ids);
3394
+ e.dataTransfer.effectAllowed = 'move';
3395
+ e.dataTransfer.setData(REORDER_MIME, JSON.stringify(ids));
3396
+ e.dataTransfer.setData('text/plain', nodeId);
3397
+ }, []);
3398
+ const handleRowContextMenu = useCallback((e: React.MouseEvent, id: string) => {
3399
+ setContextMenu({ x: e.clientX, y: e.clientY, nodeId: id });
3400
+ }, []);
3401
+
3402
+ const handleDragOver = useCallback(
3403
+ (e: React.DragEvent) => {
3404
+ const isInternal = e.dataTransfer.types.includes(REORDER_MIME);
3405
+ const isAsset = e.dataTransfer.types.includes(ASSET_MIME);
3406
+ if (!isInternal && !isAsset) return;
3407
+ e.preventDefault();
3408
+ e.dataTransfer.dropEffect = isInternal ? 'move' : 'copy';
3409
+
3410
+ // A refusal computed here is REPORTED at drop time (see `handleDrop`), not
3411
+ // on every dragover frame: a hint that re-fires 60×/s is noise, and the
3412
+ // question the author is asking ("why did nothing happen?") is asked by
3413
+ // letting go. Cleared first so a drag that moves off a refusing row keeps
3414
+ // no stale sentence.
3415
+ dropRefusalRef.current = null;
3416
+
3417
+ const rect = containerRef.current!.getBoundingClientRect();
3418
+ const scrollTop = containerRef.current!.scrollTop;
3419
+ const y = e.clientY - rect.top + scrollTop;
3420
+ const rowHeight = treeRowHeight(containerRef.current);
3421
+ const rowIndex = Math.floor(y / rowHeight);
3422
+
3423
+ if (rowIndex >= rows.length) {
3424
+ setDropTarget(null);
3425
+ setTailDrop(true);
3426
+ if (isInternal && draggedIds) {
3427
+ dropRefusalRef.current = hierarchyDropRefusal({
3428
+ hasStructureWritePath: hasStructureWritePathFor(adapter, draggedIds[0]),
3429
+ targetIsInternal: false,
3430
+ });
3431
+ }
3432
+ return;
3433
+ }
3434
+ setTailDrop(false);
3435
+
3436
+ // ABOVE the first row is not a row. `y` is pointer-minus-container-top
3437
+ // plus scroll, so it goes NEGATIVE whenever a drag drifts over the
3438
+ // panel's own chrome — dragging a prefab up out of the asset browser
3439
+ // does it — and `Math.floor` of a negative is a negative index that the
3440
+ // tail guard above cannot catch. `rows[-1]` is undefined, and the read
3441
+ // below threw "Cannot read properties of undefined (reading 'more')"
3442
+ // straight out of a dragover handler (runhuman pass 105). Not a row
3443
+ // means no drop target, the same answer a `more` row gets.
3444
+ const row = rowIndex >= 0 ? rows[rowIndex] : undefined;
3445
+ if (!row || row.more) {
3446
+ setDropTarget(null);
3447
+ return;
3448
+ }
3449
+ // H6 — a revealed internal is not a drop TARGET either. It cannot be
3450
+ // dragged (`rowAffordances.draggable`), and reparenting/asset-dropping
3451
+ // INTO it would ask the adapter to write source for an object that has
3452
+ // none. HIER-COMPONENT-TREE adds the other half: the refusal now carries
3453
+ // the sentence that says so, because a drag that silently does nothing is
3454
+ // indistinguishable from a broken editor.
3455
+ const refusal = hierarchyDropRefusal({
3456
+ // A REORDER drag needs the owning adapter's `structure` provider; an
3457
+ // ASSET drop goes through `assetDrop` instead, so a missing structure
3458
+ // provider is not what stands in ITS way and must not be named as if it
3459
+ // were. Both are refused by an internal target, for the same reason.
3460
+ hasStructureWritePath: isInternal ? hasStructureWritePathFor(adapter, row.node.id) : true,
3461
+ targetIsInternal: internals.isInternal(row.node.id),
3462
+ });
3463
+ if (refusal !== null) {
3464
+ dropRefusalRef.current = refusal;
3465
+ setDropTarget(null);
3466
+ return;
3467
+ }
3468
+ const rowY = y - rowIndex * rowHeight;
3469
+ let zone: DropZone;
3470
+ if (rowY < rowHeight * 0.25) zone = 'before';
3471
+ else if (rowY > rowHeight * 0.75) zone = 'after';
3472
+ else zone = 'child';
3473
+
3474
+ if (isInternal && draggedIds) {
3475
+ const targetId = row.node.id;
3476
+ if (draggedIds.includes(targetId)) return;
3477
+ if (isDescendantOfAny(targetId, draggedIds, adapter)) return;
3478
+ if (!isSameRootDrop(adapter, draggedIds, targetId)) {
3479
+ setDropTarget(null);
3480
+ return;
3481
+ }
3482
+ }
3483
+ if (isCompositeGroupNode(adapter, row.node.id) && zone !== 'child') {
3484
+ // A composite world-group row has no siblings of its own kind to
3485
+ // reorder against — only "drop into this world" makes sense.
3486
+ zone = 'child';
3487
+ }
3488
+ if (row.node.role === 'document' || row.node.role === 'story') zone = 'child';
3489
+ if (row.node.role === 'folder' || row.node.role === 'boundary') {
3490
+ setDropTarget(null);
3491
+ return;
3492
+ }
3493
+
3494
+ setDropTarget({ nodeId: row.node.id, zone });
3495
+ },
3496
+ [rows, draggedIds, adapter, internals],
3497
+ );
3498
+
3499
+ const handleDragEnd = useCallback(() => {
3500
+ setDropTarget(null);
3501
+ setTailDrop(false);
3502
+ setDraggedIds(null);
3503
+ dropRefusalRef.current = null;
3504
+ }, []);
3505
+
3506
+ const handleDrop = useCallback(
3507
+ (e: React.DragEvent) => {
3508
+ e.preventDefault();
3509
+ e.stopPropagation();
3510
+ const currentDropTarget = dropTarget;
3511
+ const isTail = tailDrop;
3512
+ const refusal = dropRefusalRef.current;
3513
+ setDropTarget(null);
3514
+ setTailDrop(false);
3515
+ setDraggedIds(null);
3516
+ dropRefusalRef.current = null;
3517
+
3518
+ // NO DEAD DRAGS. Where the hover already decided this drop cannot land,
3519
+ // say so through the editor's own inline notice rather than returning in
3520
+ // silence (which is what made reparenting look broken on a translated
3521
+ // port). Never a blocking dialog — repo rule.
3522
+ if (refusal !== null) {
3523
+ showTransientHint(refusal);
3524
+ return;
3525
+ }
3526
+
3527
+ const structure = adapter.structure;
3528
+
3529
+ // Internal reorder/reparent.
3530
+ const reorderData = e.dataTransfer.getData(REORDER_MIME);
3531
+ if (reorderData && structure) {
3532
+ const ids = JSON.parse(reorderData) as string[];
3533
+ if (isTail) {
3534
+ for (const id of ids) reparentAuthoringNode(adapter, id, null);
3535
+ return;
3536
+ }
3537
+ if (!currentDropTarget) return;
3538
+ const targetRow = rows.find((r) => r.node.id === currentDropTarget.nodeId);
3539
+ if (!targetRow) return;
3540
+ const targetId = targetRow.node.id;
3541
+ if (currentDropTarget.zone === 'child') {
3542
+ for (const id of ids) reparentAuthoringNode(adapter, id, targetId);
3543
+ return;
3544
+ }
3545
+ const targetParentId = targetRow.node.parentId;
3546
+ const siblings = siblingIdsOf(adapter, targetParentId).filter((id) => !ids.includes(id));
3547
+ const idx = siblings.indexOf(targetId);
3548
+ const beforeSiblingId =
3549
+ currentDropTarget.zone === 'before'
3550
+ ? targetId
3551
+ : idx >= 0
3552
+ ? (siblings[idx + 1] ?? null)
3553
+ : null;
3554
+ for (const id of ids) {
3555
+ const currentParentId = adapter.hierarchy.node(id)?.parentId ?? null;
3556
+ if (currentParentId !== targetParentId)
3557
+ reparentAuthoringNode(adapter, id, targetParentId ?? null);
3558
+ reorderAuthoringNode(adapter, id, beforeSiblingId);
3559
+ }
3560
+ return;
3561
+ }
3562
+
3563
+ // External asset drop.
3564
+ const assetData = e.dataTransfer.getData(ASSET_MIME);
3565
+ if (assetData && adapter.assetDrop) {
3566
+ try {
3567
+ const data = JSON.parse(assetData) as {
3568
+ path: string;
3569
+ kind: string;
3570
+ name?: string;
3571
+ component?: import('@volter/editor-project/adapter').AssetDropContext['item'];
3572
+ };
3573
+ if (data.kind === 'scene') return; // scene-file drop: A4/deferred, not this shell's job
3574
+ const context = {
3575
+ item: data.component ?? {
3576
+ kind: 'file' as const,
3577
+ name: data.name ?? data.path.split('/').pop() ?? data.path,
3578
+ },
3579
+ };
3580
+ if (isTail) {
3581
+ if (adapter.assetDrop.accepts('', data.path, context))
3582
+ void dropAuthoringAsset(adapter, '', data.path, context);
3583
+ return;
3584
+ }
3585
+ if (!currentDropTarget) return;
3586
+ const targetRow = rows.find((r) => r.node.id === currentDropTarget.nodeId);
3587
+ if (!targetRow) return;
3588
+ const targetId = targetRow.node.id;
3589
+ if (adapter.assetDrop.accepts(targetId, data.path, context))
3590
+ void dropAuthoringAsset(adapter, targetId, data.path, context);
3591
+ } catch {
3592
+ /* ignore malformed drag payload */
3593
+ }
3594
+ }
3595
+ },
3596
+ [dropTarget, tailDrop, rows, adapter],
3597
+ );
3598
+
3599
+ const panelName = authoringDestination(adapter) ?? 'Untitled';
3600
+
3601
+ return (
3602
+ <Panel
3603
+ // THE OUTLINER HEADER IS ONE ROW, and that row is the dock group's tab
3604
+ // strip (Content · Hierarchy · Library — structurally Blender's
3605
+ // editor-type selector). The breadcrumb this panel used to head itself
3606
+ // with — the destination path and the seam badge — is a fact about the
3607
+ // datablock, not about the tree, and now rides the Inspector's identity
3608
+ // row (`inspection/model.ts`'s `InspectionDocumentLine`), which is
3609
+ // exactly where Blender's Properties editor carries it.
3610
+ name={null}
3611
+ hideHeader
3612
+ onPointerDown={() => setActiveScope('hierarchy')}
3613
+ onDragOver={(e) => {
3614
+ if (e.dataTransfer.types.includes(ASSET_MIME)) {
3615
+ e.preventDefault();
3616
+ e.dataTransfer.dropEffect = 'copy';
3617
+ }
3618
+ }}
3619
+ // The host owns the view's resizable/collapsible persisted width; this
3620
+ // panel fills whatever its current one gives it.
3621
+ style={{
3622
+ flex: 1,
3623
+ minWidth: 0,
3624
+ minHeight: 0,
3625
+ borderRight: `1px solid ${themeVars.boundary.default}`,
3626
+ }}
3627
+ >
3628
+ {/* The panel no longer PRINTS its destination (it is the Inspector's
3629
+ datablock line now), but the name stays readable as data: the dock
3630
+ tab says "Hierarchy", and which document that tree is of is the one
3631
+ fact a control command still has to be able to ask for. */}
3632
+ <span data-testid="scene-name" data-scene={panelName} style={{ display: 'none' }}>
3633
+ {panelName}
3634
+ </span>
3635
+ <HierarchyControls
3636
+ slot={headerSlot}
3637
+ search={search}
3638
+ setSearch={setSearch}
3639
+ adapter={adapter}
3640
+ createTargetId={createTargetId}
3641
+ onCollapseAll={() => setAllExpanded(false)}
3642
+ onResetExpansion={resetExpansion}
3643
+ onExpandAll={() => setAllExpanded(true)}
3644
+ />
3645
+
3646
+ {scopeId && (
3647
+ <nav
3648
+ data-testid="selection-scope-breadcrumb"
3649
+ aria-label="Selection scope"
3650
+ style={{
3651
+ display: 'flex',
3652
+ alignItems: 'center',
3653
+ gap: 3,
3654
+ minHeight: 26,
3655
+ padding: `0 ${space[2]}px`,
3656
+ borderBottom: `1px solid ${themeVars.boundary.default}`,
3657
+ overflow: 'hidden',
3658
+ flexShrink: 0,
3659
+ }}
3660
+ >
3661
+ <Button
3662
+ variant="ghost"
3663
+ size="compact"
3664
+ data-testid="selection-scope-root"
3665
+ onClick={() => setSelectionScope(adapter, null)}
3666
+ >
3667
+ Game
3668
+ </Button>
3669
+ {scopeStack.map((id, index) => {
3670
+ const node = adapter.hierarchy.node(id);
3671
+ if (!node) return null;
3672
+ const current = index === scopeStack.length - 1;
3673
+ return (
3674
+ <span key={id} style={{ display: 'flex', alignItems: 'center', minWidth: 0 }}>
3675
+ <span aria-hidden="true" style={{ color: themeVars.content.muted }}>
3676
+ /
3677
+ </span>
3678
+ <Button
3679
+ variant="ghost"
3680
+ size="compact"
3681
+ data-testid="selection-scope-crumb"
3682
+ data-scope-current={current ? 'true' : undefined}
3683
+ aria-current={current ? 'location' : undefined}
3684
+ onClick={() => setSelectionScope(adapter, id)}
3685
+ style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}
3686
+ >
3687
+ {node.label}
3688
+ </Button>
3689
+ </span>
3690
+ );
3691
+ })}
3692
+ </nav>
3693
+ )}
3694
+
3695
+ <div
3696
+ ref={containerRef}
3697
+ data-testid="ingest-hierarchy"
3698
+ // §2.31 P2 amendment: the tree is a text-dense zone — local frost
3699
+ // layer keeps rows legible over open glass (inert elsewhere).
3700
+ // `vgai-tree-body` carries the ZEBRA — Blender stripes the whole body
3701
+ // including the empty rows past the last item, and the rule lives on
3702
+ // the scrolling element so its phase is the content's (see theme.css).
3703
+ // It paints only under `data-vgai-region="outliner"` (see
3704
+ // `outlinerRegion` above), so a look that paints no editor areas gets
3705
+ // an inert element rather than another look's stripe.
3706
+ className="vgai-content-frost vgai-tree-body"
3707
+ data-vgai-region={outlinerRegion}
3708
+ style={{
3709
+ flex: 1,
3710
+ minHeight: 0,
3711
+ overflow: 'auto',
3712
+ position: 'relative',
3713
+ pointerEvents: 'auto',
3714
+ }}
3715
+ onDragOver={handleDragOver}
3716
+ onDrop={handleDrop}
3717
+ onDragEnd={handleDragEnd}
3718
+ onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
3719
+ onDragLeave={(e) => {
3720
+ if (!containerRef.current?.contains(e.relatedTarget as Node)) {
3721
+ setDropTarget(null);
3722
+ setTailDrop(false);
3723
+ }
3724
+ }}
3725
+ >
3726
+ <div data-testid="game-hierarchy-spacer-top" style={{ height: topSpacerPx }} />
3727
+ {windowRows.map((row, windowIndex) =>
3728
+ row.more ? (
3729
+ <MoreRow key={hierarchyRowKey(row)} row={row} onReveal={revealMore} />
3730
+ ) : (
3731
+ <Row
3732
+ key={hierarchyRowKey(row)}
3733
+ row={row}
3734
+ // THE STRIPE'S PHASE IS THE ROW'S ABSOLUTE INDEX, never its DOM
3735
+ // position. `.vgai-tree-row:nth-child(even)` counted siblings,
3736
+ // and this list's first sibling is the virtualization SPACER —
3737
+ // so row 0 always painted the alternate fill (Blender's row 0 is
3738
+ // the base one, `outliner.png`: Scene Collection #272727, then
3739
+ // Collection #2a2a2a), and the whole stripe flipped whenever the
3740
+ // scroll window started on an odd row.
3741
+ rowIndex={start + windowIndex}
3742
+ // The indent guides' only non-local inputs. Read off the FULL
3743
+ // list, not the window, so the rule crosses the window's own
3744
+ // edges exactly as it crosses any other row boundary.
3745
+ prevDepth={rows[start + windowIndex - 1]?.depth ?? -1}
3746
+ nextDepth={rows[start + windowIndex + 1]?.depth ?? -1}
3747
+ adapter={adapter}
3748
+ store={store}
3749
+ isSelected={selectedIds.has(row.node.id)}
3750
+ isSelectionActive={selectionAnchorId === row.node.id}
3751
+ isEditing={editingId === row.node.id}
3752
+ isCollapsed={term ? false : effectiveCollapsedPaths.has(row.pathKey)}
3753
+ dropZone={dropTarget && dropTarget.nodeId === row.node.id ? dropTarget.zone : null}
3754
+ isDragged={draggedIds?.includes(row.node.id) ?? false}
3755
+ isInternal={internals.isInternal(row.node.id)}
3756
+ threeRootId={threeViewportRootId}
3757
+ searchTerm={term}
3758
+ lockCache={lockCacheRef.current}
3759
+ warningCache={warningCacheRef.current}
3760
+ editabilityVersion={editabilityVersion}
3761
+ adapterVersion={adapterVersion}
3762
+ onToggleOpen={toggleOpen}
3763
+ onStartEditing={setEditingId}
3764
+ onContextMenu={handleRowContextMenu}
3765
+ onDragStart={handleRowDragStart}
3766
+ onClick={handleRowClick}
3767
+ onEnterScope={handleEnterScope}
3768
+ typeSuffixShown={typeSuffixShown}
3769
+ instanceRuleShown={instanceRuleShown}
3770
+ restrictionColumns={restrictionColumns}
3771
+ excludeColumnShown={excludeColumnShown}
3772
+ remoteSelectionColors={
3773
+ remoteSelectionColors.get(row.node.id) ?? EMPTY_REMOTE_SELECTION_COLORS
3774
+ }
3775
+ />
3776
+ ),
3777
+ )}
3778
+ <div data-testid="game-hierarchy-spacer-bottom" style={{ height: bottomSpacerPx }} />
3779
+ <div style={{ minHeight: 40 }} />
3780
+ {tailDrop && (
3781
+ <div
3782
+ style={{
3783
+ position: 'absolute',
3784
+ left: 0,
3785
+ right: 0,
3786
+ top: rows.length * rowHeight,
3787
+ height: 2,
3788
+ background: themeVars.accent.default,
3789
+ pointerEvents: 'none',
3790
+ zIndex: zIndex.overlayLow,
3791
+ }}
3792
+ />
3793
+ )}
3794
+ </div>
3795
+
3796
+ {contextMenu &&
3797
+ (() => {
3798
+ const menuNode = hierarchyView.node(contextMenu.nodeId);
3799
+ // A composite group row owns no name/visible/locked path and no
3800
+ // meaningful Create/Duplicate/Delete target.
3801
+ const isGroupNode = menuNode
3802
+ ? isCompositeGroupNode(adapter, menuNode.id) ||
3803
+ isCompositeOrganizationNode(adapter, menuNode.id)
3804
+ : false;
3805
+ return (
3806
+ <ContextMenu
3807
+ state={contextMenu}
3808
+ adapter={adapter}
3809
+ store={store}
3810
+ isGroupNode={isGroupNode}
3811
+ isInternal={internals.isInternal(contextMenu.nodeId)}
3812
+ onRename={setEditingId}
3813
+ onClose={() => setContextMenu(null)}
3814
+ />
3815
+ );
3816
+ })()}
3817
+ </Panel>
3818
+ );
3819
+ }
3820
+
3821
+ /** Every authorable center document uses the same adapter-backed hierarchy. */
3822
+ export function GameHierarchy() {
3823
+ const store = useEditorStore();
3824
+ const [, refreshBinding] = useReducer((value: number) => value + 1, 0);
3825
+ useSyncExternalStore(subscribeWorkspaceDocuments, workspaceDocumentRegistryVersion);
3826
+ // A1: the override slot is module state — subscribe to it, or this panel
3827
+ // keeps rendering the adapter it resolved BEFORE an ingest session installed
3828
+ // its own (`authoring/active-adapter.ts`'s notification comment).
3829
+ useSyncExternalStore(subscribeActiveAuthoring, activeAuthoringVersion);
3830
+ // Shares ONE resolver with the Inspector — the two panels disagreeing about
3831
+ // the active adapter is the bug (`authoring/panel-authoring.ts`).
3832
+ const { adapter } = resolvePanelAuthoring(store);
3833
+ // A composite can replace its focused Boundary child with a live R3F child
3834
+ // in place while retaining both the composite and active-override identity.
3835
+ // The surface's subscription can invalidate rows, but it cannot replace its
3836
+ // own adapter prop. Re-resolve the document binding at this outer boundary
3837
+ // whenever the resolved adapter reports structural change.
3838
+ useEffect(() => adapter.subscribe?.(refreshBinding), [adapter]);
3839
+ return (
3840
+ <GameHierarchySurface key={authoringAdapterKey(adapter)} store={store} adapter={adapter} />
3841
+ );
3842
+ }