@vgai/editor 0.2.0 → 0.4.0-canary.20260715.0

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 (430) hide show
  1. package/README.md +24 -0
  2. package/dist/assets/axe-C7YBtOuM.js +30 -0
  3. package/dist/assets/browserAll-DDtGGE48.js +1 -0
  4. package/dist/assets/create-runtime-DbTlBlKG.js +1 -0
  5. package/dist/assets/{game-BI111o5f.js → game-BE0r7DMI.js} +3 -3
  6. package/dist/assets/game-BdTHR2_X.js +1 -0
  7. package/dist/assets/{game-pt_1lQLv.js → game-C3iQGSqo.js} +1 -1
  8. package/dist/assets/game-CcHfYxjP.js +1 -0
  9. package/dist/assets/game-Cqzj_lpD.js +1 -0
  10. package/dist/assets/game-DSZdeCHu.js +1 -0
  11. package/dist/assets/game-Dch9sAPc.js +1 -0
  12. package/dist/assets/{game-xsOho_wg.js → game-SAFn90pA.js} +3 -3
  13. package/dist/assets/game-loop-DMIgSMV-.js +1 -0
  14. package/dist/assets/{game-source-CvidN7AB.js → game-source-Dk83w26t.js} +1 -1
  15. package/dist/assets/{index-Bp5cecD6.js → index-4idIsHHJ.js} +2 -2
  16. package/dist/assets/{index-DL7RcQ0n.js → index-C-LhOTBN.js} +1 -1
  17. package/dist/assets/{index-Cs8xwv3e.js → index-CDkxceqE.js} +1 -1
  18. package/dist/assets/index-CQEfi217.js +15412 -0
  19. package/dist/assets/index-Dvi-V0NE.css +1 -0
  20. package/dist/assets/ingest-siblings-s0P5I1Ub.js +1 -0
  21. package/dist/assets/mount-manifest-DXMOlF79.js +1 -0
  22. package/dist/assets/react-18-C9eHn8x1.js +1 -0
  23. package/dist/assets/webworkerAll-Fn0e-ouF.js +1 -0
  24. package/dist/assets/world-documents-GM0a9Kv7.js +1 -0
  25. package/dist/index.html +15 -13
  26. package/dist/ingest/racing-game/vgai.game.json +5 -4
  27. package/dist/ingest/simcity/vgai.game.json +5 -4
  28. package/dist/ingest/tanks/vgai.game.json +5 -4
  29. package/dist/project-scripts.bundle.js +3917 -0
  30. package/dist/scenes/untitled.vscn.json +2 -0
  31. package/dist-server/packaged.mjs +7697 -3435
  32. package/package.json +31 -4
  33. package/scripts/probe-reporter.ts +349 -0
  34. package/scripts/run-probe.ts +2260 -0
  35. package/server/asset-catalog-audit.ts +268 -0
  36. package/server/asset-catalog-v2.ts +146 -0
  37. package/server/asset-library-routes.ts +276 -58
  38. package/server/canonical-path.ts +1 -1
  39. package/server/dev.ts +148 -44
  40. package/server/editor-server.ts +888 -61
  41. package/server/editor-sse.ts +115 -4
  42. package/server/harness-chat-service.ts +796 -0
  43. package/server/local-asset-catalog.ts +157 -22
  44. package/server/packaged.ts +51 -21
  45. package/server/process-shutdown.ts +136 -0
  46. package/server/prod.ts +9 -6
  47. package/server/project-hmr-files.ts +41 -0
  48. package/server/project-operations.ts +343 -0
  49. package/server/project-optimize-deps-entries.ts +4 -4
  50. package/server/project-output-writer.ts +156 -0
  51. package/server/project-validation.ts +140 -0
  52. package/server/project-view.ts +8 -50
  53. package/server/server-utils.ts +75 -10
  54. package/server/session-registry.ts +12 -0
  55. package/src/EditorContext.tsx +107 -3
  56. package/src/action-registry.ts +115 -17
  57. package/src/adapter-resolver.ts +213 -478
  58. package/src/asset-events.ts +64 -1
  59. package/src/asset-preview.ts +906 -0
  60. package/src/asset-selection.ts +121 -0
  61. package/src/asset-workflow/asset-capabilities.ts +259 -0
  62. package/src/asset-workflow/asset-editor-errors.ts +46 -0
  63. package/src/asset-workflow/asset-import-pipeline.ts +176 -0
  64. package/src/asset-workflow/asset-library-query.ts +107 -0
  65. package/src/asset-workflow/asset-types.ts +192 -0
  66. package/src/asset-workflow/asset-workflow-quality.ts +65 -0
  67. package/src/asset-workflow/import-contract.ts +113 -0
  68. package/src/asset-workflow/model-conversion.ts +126 -0
  69. package/src/asset-workflow/model-inspection.ts +185 -0
  70. package/src/asset-workflow/project-asset-commands.ts +23 -0
  71. package/src/asset-workflow/project-asset-health.ts +153 -0
  72. package/src/asset-workflow/project-asset-operations.ts +119 -0
  73. package/src/asset-workflow/thumbnail-system.ts +172 -0
  74. package/src/authoring/active-systems.ts +15 -0
  75. package/src/authoring/boundary-authoring-adapter.ts +16 -5
  76. package/src/authoring/composite-authoring-adapter.ts +305 -124
  77. package/src/authoring/design-time-layers.ts +300 -117
  78. package/src/authoring/edit-mode-authoring.ts +278 -293
  79. package/src/authoring/embed-only-adapter.ts +4 -2
  80. package/src/authoring/first-party-hierarchy-menu.ts +7 -2
  81. package/src/authoring/first-party-inspector/AddSectionMenu.tsx +5 -12
  82. package/src/authoring/first-party-inspector/EnvironmentSection.tsx +53 -126
  83. package/src/authoring/first-party-inspector/HeaderSection.tsx +54 -65
  84. package/src/authoring/first-party-inspector/is-first-party-selection.ts +30 -11
  85. package/src/authoring/first-party-inspector/register.tsx +13 -2
  86. package/src/authoring/ingest-authoring-adapter.ts +142 -45
  87. package/src/authoring/mount-failure-report.ts +5 -5
  88. package/src/authoring/provenance.ts +41 -0
  89. package/src/authoring/react-dom-authoring-adapter.ts +92 -92
  90. package/src/authoring/react-inspector-section.tsx +489 -51
  91. package/src/authoring/react-world-authoring-adapter.ts +541 -306
  92. package/src/authoring/story-overlay.ts +1 -1
  93. package/src/authoring/story-types.ts +11 -0
  94. package/src/authoring/vgai-scene-authoring-adapter.ts +54 -25
  95. package/src/authoring/viewport-tool-context.ts +78 -0
  96. package/src/authoring/world-canvas-viewport-state.ts +35 -0
  97. package/src/authoring/world-hidden-viewport.ts +186 -0
  98. package/src/authoring/world-pan-state.ts +33 -17
  99. package/src/authoring/world-session-state.ts +1 -1
  100. package/src/authoring/world2d-authoring-adapter.ts +109 -52
  101. package/src/availability-tick.ts +56 -0
  102. package/src/browser-play-entry.ts +5 -5
  103. package/src/browser-project-scripts.ts +1 -0
  104. package/src/build-identity.ts +15 -0
  105. package/src/command-listener.ts +893 -19
  106. package/src/components/AlignToolbar.tsx +74 -63
  107. package/src/components/AppRoot.tsx +193 -26
  108. package/src/components/ApplicationMenus.tsx +272 -0
  109. package/src/components/AssetBrowser.tsx +1450 -293
  110. package/src/components/AssetEditorShell.tsx +141 -0
  111. package/src/components/AssetImportDetails.tsx +218 -0
  112. package/src/components/AssetLibraryPanel.tsx +14 -0
  113. package/src/components/CameraInfo.tsx +11 -20
  114. package/src/components/CenterDocuments.tsx +585 -0
  115. package/src/components/CommandPalette.tsx +75 -30
  116. package/src/components/ConsolePanel.css +55 -0
  117. package/src/components/ConsolePanel.tsx +57 -112
  118. package/src/components/DebugConsolePanel.tsx +461 -0
  119. package/src/components/DebuggerPanel.tsx +165 -0
  120. package/src/components/DefaultEditorLayout.tsx +167 -231
  121. package/src/components/GameHierarchy.tsx +873 -290
  122. package/src/components/GamePerformanceOverlay.tsx +46 -0
  123. package/src/components/HarnessChatPanel.tsx +2352 -0
  124. package/src/components/HistoryPanel.tsx +226 -0
  125. package/src/components/Inspector.tsx +153 -99
  126. package/src/components/InspectorToolSection.tsx +6 -4
  127. package/src/components/MountFailureBanner.tsx +31 -48
  128. package/src/components/NetworkSection.tsx +25 -14
  129. package/src/components/OnlineAssetBrowser.tsx +794 -343
  130. package/src/components/OverlayReportBanner.tsx +38 -64
  131. package/src/components/PerformancePanel.tsx +206 -0
  132. package/src/components/PlayBar.tsx +93 -263
  133. package/src/components/PrefabSaveDialog.tsx +55 -127
  134. package/src/components/ProjectHeader.css +60 -0
  135. package/src/components/ProjectHeader.tsx +170 -105
  136. package/src/components/ProjectScreen.tsx +246 -184
  137. package/src/components/ReactCanvasControls.tsx +185 -0
  138. package/src/components/ResolutionPicker.tsx +85 -0
  139. package/src/components/RootActivateDialog.tsx +39 -68
  140. package/src/components/SaveDialog.tsx +55 -127
  141. package/src/components/SaveStatus.tsx +11 -29
  142. package/src/components/StartupLoadingScreen.tsx +96 -0
  143. package/src/components/StateWatchPanel.tsx +302 -0
  144. package/src/components/StatsOverlay.tsx +12 -5
  145. package/src/components/StatusBar.tsx +64 -0
  146. package/src/components/ThemeManagerDialog.css +89 -0
  147. package/src/components/ThemeManagerDialog.tsx +371 -0
  148. package/src/components/ToolHost.tsx +26 -15
  149. package/src/components/Toolbar.tsx +117 -214
  150. package/src/components/VgaiVoxelLogo.tsx +89 -0
  151. package/src/components/ViewportOverlay.tsx +97 -172
  152. package/src/components/ViewportPanel.tsx +130 -31
  153. package/src/components/WorkspaceDock.tsx +891 -0
  154. package/src/components/WorldSelectionOverlay.tsx +51 -63
  155. package/src/components/WorldTextEditor.tsx +9 -5
  156. package/src/components/asset-documents.tsx +390 -0
  157. package/src/components/asset-editor-persistence.ts +44 -0
  158. package/src/components/asset-selection-section.tsx +338 -0
  159. package/src/components/asset-viewers/AudioViewer.tsx +73 -21
  160. package/src/components/asset-viewers/GenericJsonViewer.tsx +26 -11
  161. package/src/components/asset-viewers/ImageViewer.tsx +43 -11
  162. package/src/components/asset-viewers/InputMapViewer.tsx +512 -134
  163. package/src/components/asset-viewers/MaterialAssetEditor.tsx +540 -0
  164. package/src/components/asset-viewers/ModelViewer.tsx +805 -121
  165. package/src/components/asset-viewers/OnlineAssetDetail.tsx +177 -60
  166. package/src/components/asset-viewers/SourceAssetViewer.tsx +121 -0
  167. package/src/components/asset-workflow.css +506 -0
  168. package/src/components/build-documents.tsx +696 -0
  169. package/src/components/core-utilities.tsx +75 -0
  170. package/src/components/data-documents.tsx +364 -0
  171. package/src/components/{DataPanel.tsx → data-editor.tsx} +138 -288
  172. package/src/components/debugger-panel-state.ts +57 -0
  173. package/src/components/inspector-widgets/AlignmentGrid.tsx +8 -14
  174. package/src/components/inspector-widgets/BorderEditor.tsx +41 -33
  175. package/src/components/inspector-widgets/ColorPicker.tsx +39 -39
  176. package/src/components/inspector-widgets/FontPicker.tsx +13 -21
  177. package/src/components/inspector-widgets/GradientEditor.tsx +11 -19
  178. package/src/components/inspector-widgets/ScrubbableInput.tsx +6 -3
  179. package/src/components/inspector-widgets/ShadowEditor.tsx +20 -33
  180. package/src/components/inspector-widgets/shared.tsx +90 -49
  181. package/src/components/inspectors/AnimationSection.tsx +39 -40
  182. package/src/components/inspectors/AudioSection.tsx +28 -39
  183. package/src/components/inspectors/BoneAttachmentSection.tsx +19 -24
  184. package/src/components/inspectors/CameraSection.tsx +14 -23
  185. package/src/components/inspectors/ComponentsSection.tsx +39 -43
  186. package/src/components/inspectors/JointsSection.tsx +34 -43
  187. package/src/components/inspectors/LightSection.tsx +90 -35
  188. package/src/components/inspectors/MaterialSection.tsx +137 -174
  189. package/src/components/inspectors/MeshSection.tsx +9 -17
  190. package/src/components/inspectors/NavigationSection.tsx +8 -15
  191. package/src/components/inspectors/ParticleSection.tsx +91 -76
  192. package/src/components/inspectors/PhysicsSection.tsx +42 -46
  193. package/src/components/inspectors/PostProcessingSection.tsx +28 -36
  194. package/src/components/inspectors/RenderingSection.tsx +12 -20
  195. package/src/components/inspectors/ShadowSection.tsx +7 -8
  196. package/src/components/inspectors/SplineSection.tsx +31 -42
  197. package/src/components/inspectors/TransformSection.tsx +14 -22
  198. package/src/components/patterns/Dialog.tsx +95 -0
  199. package/src/components/patterns/Fields.tsx +44 -0
  200. package/src/components/patterns/List.tsx +25 -0
  201. package/src/components/patterns/StateSurface.tsx +40 -0
  202. package/src/components/patterns/Surfaces.tsx +115 -0
  203. package/src/components/patterns/Tabs.tsx +80 -0
  204. package/src/components/patterns/Toolbar.tsx +51 -0
  205. package/src/components/patterns/Tree.tsx +72 -0
  206. package/src/components/primitives/Button.tsx +46 -0
  207. package/src/components/primitives/ColorInput.tsx +11 -47
  208. package/src/components/primitives/EditorIcon.tsx +99 -0
  209. package/src/components/primitives/FormControls.tsx +71 -0
  210. package/src/components/primitives/JsonInput.tsx +15 -2
  211. package/src/components/primitives/Layout.tsx +100 -0
  212. package/src/components/primitives/Menu.tsx +105 -0
  213. package/src/components/primitives/NumberInput.tsx +8 -35
  214. package/src/components/primitives/Panel.tsx +49 -44
  215. package/src/components/primitives/SectionHeader.tsx +10 -28
  216. package/src/components/primitives/Text.tsx +46 -0
  217. package/src/components/primitives/Tooltip.tsx +20 -12
  218. package/src/components/primitives/Vec3Input.tsx +3 -0
  219. package/src/components/primitives/banner-tones.ts +33 -0
  220. package/src/components/primitives/editor-icons.ts +102 -0
  221. package/src/components/primitives/panel-header-styles.ts +30 -0
  222. package/src/components/project-operation-documents.tsx +226 -0
  223. package/src/components/status-contributions.tsx +175 -0
  224. package/src/components/story-documents-register.ts +15 -0
  225. package/src/components/story-documents.tsx +1185 -0
  226. package/src/components/tool-documents.tsx +110 -0
  227. package/src/components/workspace-dock.css +293 -0
  228. package/src/components/workspace-history.ts +33 -0
  229. package/src/components/world-documents.tsx +145 -0
  230. package/src/components/xstate-documents.tsx +209 -0
  231. package/src/composite-screenshot.ts +248 -0
  232. package/src/design-system/index.ts +70 -0
  233. package/src/design-system-stories/Foundation.stories.tsx +183 -0
  234. package/src/design-system-stories/Patterns.stories.tsx +251 -0
  235. package/src/design-system-stories/Workspace.stories.tsx +538 -0
  236. package/src/doctor/report.ts +57 -13
  237. package/src/editor-api.ts +295 -31
  238. package/src/editor-compatibility.ts +200 -0
  239. package/src/editor-console.ts +35 -17
  240. package/src/editor-hotkeys.ts +85 -33
  241. package/src/editor-store.ts +538 -233
  242. package/src/editor-viewport.ts +160 -34
  243. package/src/entity-factory.ts +14 -3
  244. package/src/gizmo-registry.ts +21 -5
  245. package/src/harness-chat-editor-context.ts +37 -0
  246. package/src/harness-chat-indicator.ts +68 -0
  247. package/src/harness-chat-layout.ts +53 -0
  248. package/src/harness-chat-lifecycle.ts +170 -0
  249. package/src/harness-chat-reconnect.ts +85 -0
  250. package/src/harness-chat-types.ts +409 -0
  251. package/src/hierarchy-drop.ts +50 -0
  252. package/src/hierarchy-expansion-state.ts +100 -0
  253. package/src/hierarchy-menu-registry.ts +1 -1
  254. package/src/hierarchy-projection.ts +76 -0
  255. package/src/history/editor-session.ts +21 -0
  256. package/src/history/history-commands.ts +147 -0
  257. package/src/history/history-service.ts +904 -0
  258. package/src/history/json-history-resource.ts +165 -0
  259. package/src/history/persistence-coordinator.ts +35 -0
  260. package/src/history/project-file-history.ts +266 -0
  261. package/src/history/project-root-history-backends.ts +53 -0
  262. package/src/history/resource-registry.ts +209 -0
  263. package/src/history/snapshot-store.ts +108 -0
  264. package/src/history/source-history-backend.ts +374 -0
  265. package/src/history/types.ts +100 -0
  266. package/src/hmr-registration-group.ts +67 -0
  267. package/src/hmr-stable-react-context.ts +23 -0
  268. package/src/hosted-example.ts +12 -2
  269. package/src/hotkeys.ts +89 -18
  270. package/src/ingest/discovery-react.ts +9 -10
  271. package/src/ingest/discovery.ts +4 -4
  272. package/src/ingest/discovery2d.ts +5 -5
  273. package/src/ingest/games/bloom-composer/vgai.game.json +3 -3
  274. package/src/ingest/games/embed-bundle/vgai.game.json +3 -3
  275. package/src/ingest/games/games-fps/vgai.game.json +3 -3
  276. package/src/ingest/games/iframe-reachable/vgai.game.json +3 -3
  277. package/src/ingest/games/interactive-cubes/vgai.game.json +3 -3
  278. package/src/ingest/games/mismatched-three/vgai.game.json +3 -3
  279. package/src/ingest/games/own-three-bundled/vgai.game.json +3 -3
  280. package/src/ingest/games/pointerlock-maze/vgai.game.json +7 -4
  281. package/src/ingest/games-2d/bubbo-bubbo/vgai.game.json +3 -3
  282. package/src/ingest/games-2d/bunnymark/vgai.game.json +3 -3
  283. package/src/ingest/games-2d/flappy/vgai.game.json +3 -3
  284. package/src/ingest/games-2d/flappy-pixi/vgai.game.json +3 -3
  285. package/src/ingest/games-2d/puzzling-potions/vgai.game.json +3 -3
  286. package/src/ingest/games-2d/tilemap-camera/vgai.game.json +3 -3
  287. package/src/ingest/games-react/react-rpg/vgai.game.json +3 -3
  288. package/src/ingest/types.ts +1 -1
  289. package/src/ingest-mode.ts +47 -52
  290. package/src/ingest-siblings.ts +23 -33
  291. package/src/initial-scene.ts +1 -2
  292. package/src/main.tsx +16 -8
  293. package/src/manifest-project.ts +10 -12
  294. package/src/model-thumbnail.ts +261 -26
  295. package/src/module-mode.ts +44 -50
  296. package/src/play-log-events.ts +26 -0
  297. package/src/play-mode.ts +495 -401
  298. package/src/play-scene-adoption.ts +23 -0
  299. package/src/playground/main.ts +68 -24
  300. package/src/playwright-shim.ts +473 -0
  301. package/src/project-manager.ts +90 -10
  302. package/src/project-operations.ts +112 -0
  303. package/src/project-story-discovery.ts +25 -0
  304. package/src/project-tool-discovery.ts +43 -0
  305. package/src/scene-serializer.ts +0 -6
  306. package/src/scene-sync.ts +7 -2
  307. package/src/script-loader.ts +3 -3
  308. package/src/skeleton-helper.ts +57 -0
  309. package/src/storage/seed.ts +68 -8
  310. package/src/stories/StoryPreviewMount.tsx +265 -0
  311. package/src/stories/action-stories.fixture.tsx +44 -0
  312. package/src/stories/button-stories.fixture.tsx +50 -0
  313. package/src/stories/compose-project-stories.ts +182 -0
  314. package/src/stories/loader-stories.fixture.tsx +53 -0
  315. package/src/stories/story-discovery.ts +130 -0
  316. package/src/stories/story-registry.ts +146 -0
  317. package/src/theme-css.ts +1049 -0
  318. package/src/theme-library.ts +238 -0
  319. package/src/theme-preference.ts +130 -0
  320. package/src/theme.ts +527 -0
  321. package/src/tool-loader.ts +80 -24
  322. package/src/ui-source/inspect.ts +27 -4
  323. package/src/ui-source/source-write-backend.ts +83 -22
  324. package/src/widgets/index.ts +9 -18
  325. package/src/workspace-aux-commands.ts +11 -0
  326. package/src/workspace-dock-controller.ts +64 -0
  327. package/src/workspace-document-ids.ts +5 -0
  328. package/src/workspace-document-registry.ts +367 -0
  329. package/src/workspace-state-persistence.ts +455 -0
  330. package/src/workspace-status-registry.ts +110 -0
  331. package/src/workspace-utility-commands.ts +49 -0
  332. package/src/workspace-utility-registry.ts +161 -0
  333. package/src/world-document-routing.ts +16 -0
  334. package/src/xstate-inspect/XStateMachineInspector.tsx +544 -0
  335. package/src/xstate-inspect/character-animation-machine.fixture.ts +74 -0
  336. package/src/xstate-inspect/use-live-actor-state.ts +39 -0
  337. package/src/xstate-inspect/xstate-graph.ts +265 -0
  338. package/src/xstate-inspect/xstate-layout.ts +76 -0
  339. package/src/z-index.ts +26 -0
  340. package/template/.claude/skills/editor/SKILL.md +129 -90
  341. package/template/AGENTS.md +1110 -0
  342. package/template/CLAUDE.md +1 -512
  343. package/template/IDIOMS.md +50 -0
  344. package/template/check-idioms.ts +706 -0
  345. package/template/manifest-entry-modules-plugin.ts +53 -0
  346. package/template/package.json +13 -4
  347. package/template/public/scenes/default.scn2d.json +6 -0
  348. package/template/public/scenes/default.vscn.json +7 -1
  349. package/template/public/scenes/untitled.vscn.json +10 -0
  350. package/template/scripts/playtest.ts +40 -0
  351. package/template/server/loopback.ts +75 -0
  352. package/template/server/rooms/arena-room.ts +69 -1
  353. package/template/server/rooms/game-room.ts +68 -1
  354. package/template/src/data/README.md +9 -4
  355. package/template/src/data/data-ref.ts +35 -0
  356. package/template/src/data/tuning.schema.ts +1 -1
  357. package/template/src/main.ts +41 -62
  358. package/template/src/manifest-entry-modules.d.ts +3 -0
  359. package/template/src/operations/README.md +88 -0
  360. package/template/src/runtime/pixi-adapter.ts +24 -0
  361. package/template/src/scripts/components/data-spinner.ts +43 -2
  362. package/template/src/scripts/components/spin-lap-math.ts +27 -0
  363. package/template/src/tools/README.md +5 -3
  364. package/template/src/tools/example.tool.tsx +13 -7
  365. package/template/src/ui/Button.stories.tsx +66 -0
  366. package/template/src/ui/RebindPanel.tsx +2 -6
  367. package/template/src/ui/game.tsx +17 -0
  368. package/template/tests/acceptance/example.spec.ts +64 -0
  369. package/template/tests/logic/example.test.ts +31 -0
  370. package/template/tests/logic/room.test.ts +100 -0
  371. package/template/tests/tsconfig.json +30 -0
  372. package/template/validate-manifest.ts +5 -5
  373. package/template/validate-scenes.ts +50 -18
  374. package/template/vgai.game.json +20 -13
  375. package/template/vite.config.ts +77 -16
  376. package/vite-plugin-ui-oid.ts +261 -17
  377. package/dist/assets/CCDIKSolver-Dz-ysxZF.js +0 -1
  378. package/dist/assets/SelectionBox-BXsv_6aH.js +0 -1
  379. package/dist/assets/browser-project-scripts-DOru4L1x.js +0 -2
  380. package/dist/assets/browserAll-CEkWYlFk.js +0 -1
  381. package/dist/assets/create-runtime-DYIWr3va.js +0 -1
  382. package/dist/assets/data-asset-Bb9sIM7i.js +0 -5
  383. package/dist/assets/game-8SDZPPgH.js +0 -1
  384. package/dist/assets/game-CevorxyV.js +0 -1
  385. package/dist/assets/game-Di7XlywD.js +0 -1
  386. package/dist/assets/game-DjnCaw8R.js +0 -1
  387. package/dist/assets/game-Q9A0aXSV.js +0 -1
  388. package/dist/assets/index-CPVV6pjw.js +0 -1
  389. package/dist/assets/index-CpOnYyeD.js +0 -213
  390. package/dist/assets/index-Dr8H_6Rl.js +0 -1
  391. package/dist/assets/index-_bCaipUk.js +0 -13650
  392. package/dist/assets/ingest-siblings-Dz8eEqrV.js +0 -1
  393. package/dist/assets/mount-manifest-Bhk0ohsl.js +0 -1
  394. package/dist/assets/scene-query-BivAHhJu.js +0 -1
  395. package/dist/assets/webworkerAll-BLrH0F6F.js +0 -1
  396. package/dist/assets/xstate-animation-binding-1NGLmw6y.js +0 -1
  397. package/dist/assets/xstate.esm-BgUD33nl.js +0 -3
  398. package/src/authoring/scene-ui-layer.ts +0 -485
  399. package/src/authoring/ui-authoring-adapter.ts +0 -325
  400. package/src/authoring/ui-hierarchy-menu.ts +0 -40
  401. package/src/authoring/ui-inspector-sections.tsx +0 -156
  402. package/src/authoring/undo-timeline.ts +0 -145
  403. package/src/components/BottomPanel.tsx +0 -326
  404. package/src/components/BuildPanel.tsx +0 -455
  405. package/src/components/FolderTree.tsx +0 -179
  406. package/src/components/RightPanel.tsx +0 -166
  407. package/src/components/WorldContextMenu.tsx +0 -257
  408. package/src/components/asset-viewers/AssetViewer.tsx +0 -38
  409. package/src/ingest/games-react/react-rpg/src/ui/game-state.tsx +0 -129
  410. package/src/scene-ui-mode/active-scene-ui.ts +0 -58
  411. package/src/scene-ui-mode/scene-ui-palette-panel.tsx +0 -152
  412. package/src/scene-ui-mode/scene-ui-timeline-tab.tsx +0 -29
  413. package/src/scene-ui-mode/ui-prefab-dialog-host.tsx +0 -74
  414. package/src/scene-ui-mode/ui-prefab-hierarchy-menu.ts +0 -49
  415. package/src/scene-ui-mode/ui-prefab-io.ts +0 -76
  416. package/src/ui-editor/inspector.tsx +0 -563
  417. package/src/ui-editor/node-ops.ts +0 -266
  418. package/src/ui-editor/overlay.tsx +0 -380
  419. package/src/ui-editor/palette.tsx +0 -114
  420. package/src/ui-editor/react-store.ts +0 -78
  421. package/src/ui-editor/timeline-panel.tsx +0 -443
  422. package/src/ui-editor/ui-edit-store.ts +0 -552
  423. package/src/ui-editor/ui-prefab.ts +0 -95
  424. package/template/project.json +0 -9
  425. package/template/src/scripts/main.ts +0 -94
  426. package/template/src/ui/DefaultHud.tsx +0 -40
  427. package/template/src/ui/game-state.tsx +0 -37
  428. package/template/src/ui/mount-default-hud.tsx +0 -19
  429. package/template/src/ui/mount-ui.ts +0 -15
  430. package/vite-plugin-react-world-provider.ts +0 -81
@@ -1,3 +1,12 @@
1
+ import {
2
+ Button,
3
+ chromeSize,
4
+ IconButton,
5
+ space,
6
+ TextInput,
7
+ themeVars,
8
+ zIndex,
9
+ } from '../design-system';
1
10
  /**
2
11
  * GameHierarchy — the ONE hierarchy panel (A2,
3
12
  * docs/unified-world-editor/26-unified-editor-spec.md §2 A2). Replaces the
@@ -15,12 +24,12 @@
15
24
  * unpack/open/save-as) live behind `hierarchy-menu-registry.ts`'s escape
16
25
  * hatch, registered elsewhere (`authoring/first-party-hierarchy-menu.ts`).
17
26
  *
18
- * Shape handling (the manager's reconciliation constraint): the ACTIVE
19
- * adapter is either a bare per-scene adapter (single-world edit, an ingested
20
- * game, …) — `adapter.hierarchy.roots()` returns real entity roots directly —
21
- * or `CompositeAuthoringAdapter` (2+ worlds) — roots are synthetic
22
- * `world:<id>` GROUP nodes (`flags.runtimeOnly`), each world's own roots
23
- * nested beneath. Both shapes are walked by the SAME generic row engine
27
+ * Shape handling: project authoring is a `CompositeAuthoringAdapter` rooted
28
+ * at Game for every N >= 1 composition. Each runtime world is a synthetic
29
+ * `world:<id>` group; adapter-owned surfaces such as scene UI may be nested
30
+ * beneath their owning world without pretending to be runtime roots. The
31
+ * same generic row engine also handles bare adapters used by bounded ingest
32
+ * and compatibility sessions. Both shapes are walked by the SAME generic row engine
24
33
  * (`flattenIngestRows` — the format-neutral `EditorNode`-typed row
25
34
  * flattener, not the legacy first-party-typed sibling module); a group node
26
35
  * only ever gets a small set of cosmetic extras (kind/zOrder badge, a
@@ -35,23 +44,51 @@
35
44
  * existing ingest-family e2e specs keep passing unchanged.
36
45
  */
37
46
 
38
- import type { AuthoringAdapter } from '@engine/adapter';
47
+ import type {
48
+ AuthoringAdapter,
49
+ AuthoringProvenance,
50
+ EditorNode,
51
+ EditorNodeRole,
52
+ } from '@engine/adapter';
53
+ import type { IconDefinition } from '@fortawesome/fontawesome-svg-core';
39
54
  import {
55
+ faBookOpen,
56
+ faCamera,
40
57
  faCaretDown,
41
58
  faCaretRight,
59
+ faCircle,
60
+ faCircleDot,
61
+ faCirclePlay,
62
+ faCode,
42
63
  faCrosshairs,
64
+ faCube,
43
65
  faEye,
44
66
  faEyeSlash,
67
+ faFileCode,
68
+ faFolder,
45
69
  faHandPointer,
70
+ faImage,
71
+ faLayerGroup,
72
+ faLightbulb,
73
+ faLink,
74
+ faList,
46
75
  faLock,
47
76
  faLockOpen,
77
+ faMinus,
78
+ faObjectGroup,
48
79
  faPlus,
80
+ faPuzzlePiece,
81
+ faRotateLeft,
82
+ faSquare,
83
+ faTriangleExclamation,
49
84
  } from '@fortawesome/free-solid-svg-icons';
50
- import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
51
85
  import { useCallback, useEffect, useReducer, useRef, useState, useSyncExternalStore } from 'react';
52
86
  import { getActiveAuthoring, hasAuthoringOverride } from '../authoring/active-adapter';
53
87
  import { CompositeAuthoringAdapter } from '../authoring/composite-authoring-adapter';
88
+ import { requestWorldFocus, worldFocusScenePath } from '../authoring/edit-mode-authoring';
89
+ import { provenanceForNode, unavailableReason } from '../authoring/provenance';
54
90
  import { requestRootActivation } from '../authoring/root-activation';
91
+ import { resolveFocusedViewportWorldId } from '../authoring/world-hidden-viewport';
55
92
  import {
56
93
  isWorldHidden,
57
94
  isWorldInteractive,
@@ -60,8 +97,15 @@ import {
60
97
  toggleWorldInteractive,
61
98
  toggleWorldPickLock,
62
99
  } from '../authoring/world-session-state';
100
+ import { DisclosureIcon, DropIndicator, EditorIcon, EditorToolbar, Panel } from '../design-system';
63
101
  import { useEditorStore } from '../EditorContext';
64
102
  import type { EditorStore } from '../editor-store';
103
+ import { isSameWorldDrop } from '../hierarchy-drop';
104
+ import {
105
+ clearHierarchyExpansionPreferences,
106
+ hierarchyExpansionPreference,
107
+ setHierarchyExpansionPreferences,
108
+ } from '../hierarchy-expansion-state';
65
109
  import { getHierarchyMenuItems } from '../hierarchy-menu-registry';
66
110
  import { CHILD_CAP } from '../hierarchy-rows';
67
111
  import { setActiveScope } from '../hotkeys';
@@ -71,12 +115,14 @@ import {
71
115
  ingestRowKey,
72
116
  ingestRowWindow,
73
117
  } from '../ingest-hierarchy-rows';
118
+ import { getCurrentProject } from '../project-manager';
119
+ import { activateWorldDocument } from '../world-document-routing';
74
120
  import { OverlayReportBanner } from './OverlayReportBanner';
75
- import { Panel } from './primitives/Panel';
121
+ import { openStoryDocumentByStoryId } from './story-documents';
76
122
 
77
123
  const ASSET_MIME = 'application/x-editor-asset';
78
124
  const REORDER_MIME = 'application/x-hierarchy-reorder';
79
- const ROW_HEIGHT = 24;
125
+ const ROW_HEIGHT = chromeSize.treeRow;
80
126
  const INDENT = 14;
81
127
 
82
128
  // Wave-18-ported scroll-window sizing (docs/WAVE18-INGEST-HIERARCHY-VIRTUALIZATION-DESIGN.md),
@@ -102,19 +148,49 @@ const DEFAULT_VIEWPORT_PX = 2400;
102
148
  // dungeon is ~1900 rows), which is exactly the case wave-18 targeted.
103
149
  const VIRTUALIZE_MIN_ROWS = 300;
104
150
 
105
- const KIND_GLYPH: Record<string, string> = {
106
- mesh: '◧',
107
- light: '☀',
108
- camera: '▣',
109
- group: '▾',
110
- object: '•',
111
- // B3 — a react world's catalog component node (`kind:'component'`, the
112
- // registry entry, not the entry-preview's DOM subtree). A neutral glyph
113
- // (NOT react-branded) — this shell only ever knows the format-neutral
114
- // `EditorNode.kind` string, never react internals.
115
- component: '❖',
151
+ const ROLE_ICON: Partial<Record<EditorNodeRole, IconDefinition>> = {
152
+ folder: faFolder,
153
+ document: faFileCode,
154
+ story: faCirclePlay,
155
+ component: faPuzzlePiece,
156
+ boundary: faTriangleExclamation,
157
+ };
158
+
159
+ const KIND_ICON: Record<string, IconDefinition> = {
160
+ threejs: faCube,
161
+ react: faCode,
162
+ pixijs: faLayerGroup,
163
+ scene: faBookOpen,
164
+ mesh: faCube,
165
+ light: faLightbulb,
166
+ camera: faCamera,
167
+ group: faObjectGroup,
168
+ object: faCircle,
169
+ component: faPuzzlePiece,
170
+ img: faImage,
171
+ a: faLink,
172
+ ul: faList,
173
+ ol: faList,
174
+ li: faList,
175
+ button: faSquare,
116
176
  };
117
177
 
178
+ function iconForNode(node: EditorNode): IconDefinition {
179
+ if (node.role === 'root') return KIND_ICON[node.kind] ?? faLayerGroup;
180
+ if (node.role === 'entity' || node.role === 'element') {
181
+ return KIND_ICON[node.kind] ?? faCircle;
182
+ }
183
+ return (node.role ? ROLE_ICON[node.role] : undefined) ?? KIND_ICON[node.kind] ?? faCircle;
184
+ }
185
+
186
+ const STRUCTURAL_ROLES = new Set<EditorNodeRole>([
187
+ 'folder',
188
+ 'root',
189
+ 'document',
190
+ 'story',
191
+ 'boundary',
192
+ ]);
193
+
118
194
  type DropZone = 'before' | 'child' | 'after';
119
195
  interface DropTarget {
120
196
  nodeId: string;
@@ -166,11 +242,17 @@ function siblingIdsOf(adapter: AuthoringAdapter, parentId: string | null): strin
166
242
  function compositeGroupBadge(
167
243
  adapter: AuthoringAdapter,
168
244
  nodeId: string,
169
- ): { zOrder: number; worldId: string } | null {
245
+ ): {
246
+ zOrder: number;
247
+ worldId: string;
248
+ role: 'world' | 'surface';
249
+ kind: string;
250
+ provenance: AuthoringProvenance | null;
251
+ } | null {
170
252
  if (!(adapter instanceof CompositeAuthoringAdapter)) return null;
171
253
  for (const child of adapter.childAdapters()) {
172
254
  if (adapter.groupNodeId(child.worldId) === nodeId) {
173
- // A4 (D8) — prefer the LIVE manifest zOrder (editable on the game root,
255
+ // A4 (D8) — prefer the LIVE manifest zOrder (editable on the root row,
174
256
  // `composite.inspector.get(groupId, 'zOrder')`) over the array-index
175
257
  // fallback `child.zOrder`, so editing zOrder through the generic
176
258
  // inspector is reflected on this badge immediately (the write path
@@ -187,6 +269,11 @@ function compositeGroupBadge(
187
269
  // — eye/interactive/pick-lock) consistently with `design-time-layers.ts`,
188
270
  // which reads the SAME id off `composite.childAdapters()` directly.
189
271
  worldId: child.worldId,
272
+ role: child.role,
273
+ kind: child.kind,
274
+ // Spec 29 §5 — the world's own seam-level provenance declaration
275
+ // (null when the child adapter declared none; the row shows no chip).
276
+ provenance: child.adapter.provenance ?? null,
190
277
  };
191
278
  }
192
279
  }
@@ -200,15 +287,23 @@ function isCompositeGroupNode(adapter: AuthoringAdapter, nodeId: string): boolea
200
287
  return compositeGroupBadge(adapter, nodeId) !== null;
201
288
  }
202
289
 
290
+ /** Editor-only kind/semantic projection rows have no native entity owner. */
291
+ function isCompositeOrganizationNode(adapter: AuthoringAdapter, nodeId: string): boolean {
292
+ return adapter instanceof CompositeAuthoringAdapter && adapter.isOrganizationNode(nodeId);
293
+ }
294
+
203
295
  /**
204
- * A4 (D8) — true only for the composite's synthetic game-root row. Asks the
205
- * adapter for its reserved id (`CompositeAuthoringAdapter.gameRootId`) rather
206
- * than hardcoding the string here — same "ask, don't sniff" discipline as
207
- * `groupNodeId()`/`compositeGroupBadge` above (Rule zero: this file never
208
- * decodes an adapter-minted id itself).
296
+ * The composite child adapter that OWNS `nodeId` — the adapter whose
297
+ * PROVIDERS answer for it — or the active adapter itself when bare. Affordance
298
+ * GATES must ask the owner (the composite's own `structure`/`inspector` are
299
+ * never-undefined routers, so probing them says "yes" for a row whose owning
300
+ * child would refuse — the false-claim pattern spec 29 §3 forbids); ACTIONS
301
+ * still go through the ACTIVE adapter, which routes by ownership.
209
302
  */
210
- function isGameRootNode(adapter: AuthoringAdapter, nodeId: string): boolean {
211
- return adapter instanceof CompositeAuthoringAdapter && adapter.gameRootId === nodeId;
303
+ function ownerAdapterOf(adapter: AuthoringAdapter, nodeId: string): AuthoringAdapter {
304
+ if (!(adapter instanceof CompositeAuthoringAdapter)) return adapter;
305
+ const worldId = adapter.ownerOf(nodeId);
306
+ return adapter.childAdapters().find((c) => c.worldId === worldId)?.adapter ?? adapter;
212
307
  }
213
308
 
214
309
  /**
@@ -230,6 +325,141 @@ function buildRootMembers(
230
325
  return map;
231
326
  }
232
327
 
328
+ function isStructuralHierarchyNode(
329
+ adapter: AuthoringAdapter,
330
+ nodeId: string,
331
+ role: EditorNodeRole | undefined,
332
+ rootMembers: ReadonlyMap<string, { groupId: string; active: boolean }>,
333
+ ): boolean {
334
+ return (
335
+ isCompositeGroupNode(adapter, nodeId) ||
336
+ isCompositeOrganizationNode(adapter, nodeId) ||
337
+ rootMembers.has(nodeId) ||
338
+ (role !== undefined && STRUCTURAL_ROLES.has(role))
339
+ );
340
+ }
341
+
342
+ /**
343
+ * First-open policy: organizational rows stay open, as does the first real
344
+ * authored node below them. Single-child wrapper chains continue open until
345
+ * the first meaningful branch; branches beneath that point start folded.
346
+ * This is shape-based — never label- or kind-based — so the same rule covers
347
+ * Three.js, React, PixiJS, and arbitrary projections.
348
+ */
349
+ function defaultExpansionByNode(
350
+ rows: readonly IngestRow[],
351
+ adapter: AuthoringAdapter,
352
+ rootMembers: ReadonlyMap<string, { groupId: string; active: boolean }>,
353
+ ): Map<string, boolean> {
354
+ const result = new Map<string, boolean>();
355
+ const authoredDepthAtTreeDepth: number[] = [];
356
+ const nodeAtTreeDepth: IngestRow['node'][] = [];
357
+ for (const row of rows) {
358
+ if (row.more) continue;
359
+ const parentAuthoredDepth =
360
+ row.depth === 0 ? 0 : (authoredDepthAtTreeDepth[row.depth - 1] ?? 0);
361
+ const parentNode = row.depth === 0 ? null : nodeAtTreeDepth[row.depth - 1];
362
+ const structural = isStructuralHierarchyNode(adapter, row.node.id, row.node.role, rootMembers);
363
+ const authoredDepth = parentAuthoredDepth + (structural ? 0 : 1);
364
+ authoredDepthAtTreeDepth[row.depth] = authoredDepth;
365
+ authoredDepthAtTreeDepth.length = row.depth + 1;
366
+ nodeAtTreeDepth[row.depth] = row.node;
367
+ nodeAtTreeDepth.length = row.depth + 1;
368
+ result.set(row.node.id, structural || authoredDepth <= 1 || parentNode?.childIds.length === 1);
369
+ }
370
+ return result;
371
+ }
372
+
373
+ function ancestorPathKeys(pathKey: string): string[] {
374
+ const parts = pathKey.split('.');
375
+ const ancestors: string[] = [];
376
+ for (let i = 1; i < parts.length; i++) ancestors.push(parts.slice(0, i).join('.'));
377
+ return ancestors;
378
+ }
379
+
380
+ function searchPathKeys(rows: readonly IngestRow[], term: string): Set<string> {
381
+ const visible = new Set<string>();
382
+ for (const row of rows) {
383
+ if (row.more || !row.node.label.toLowerCase().includes(term)) continue;
384
+ visible.add(row.pathKey);
385
+ for (const ancestor of ancestorPathKeys(row.pathKey)) visible.add(ancestor);
386
+ }
387
+ return visible;
388
+ }
389
+
390
+ /** Make capped browse mode include a selected node at any sibling index. */
391
+ function selectedRevealCounts(
392
+ rows: readonly IngestRow[],
393
+ selectedIds: ReadonlySet<string>,
394
+ ): Map<string, number> {
395
+ const revealed = new Map<string, number>();
396
+ for (const row of rows) {
397
+ if (!selectedIds.has(row.node.id)) continue;
398
+ const parts = row.pathKey.split('.');
399
+ let parent = parts[0]!;
400
+ for (let i = 1; i < parts.length; i++) {
401
+ const childIndex = Number(parts[i]);
402
+ if (Number.isFinite(childIndex)) {
403
+ revealed.set(parent, Math.max(revealed.get(parent) ?? 0, childIndex + 1));
404
+ }
405
+ parent = `${parent}.${parts[i]}`;
406
+ }
407
+ }
408
+ return revealed;
409
+ }
410
+
411
+ function selectedAncestorNodeIds(
412
+ rows: readonly IngestRow[],
413
+ selectedIds: ReadonlySet<string>,
414
+ ): Set<string> {
415
+ const ids = new Set<string>();
416
+ const byPath = new Map(rows.filter((row) => !row.more).map((row) => [row.pathKey, row.node.id]));
417
+ for (const row of rows) {
418
+ if (!selectedIds.has(row.node.id)) continue;
419
+ for (const ancestor of ancestorPathKeys(row.pathKey)) {
420
+ const id = byPath.get(ancestor);
421
+ if (id) ids.add(id);
422
+ }
423
+ }
424
+ return ids;
425
+ }
426
+
427
+ function mergeRevealCounts(
428
+ persisted: ReadonlyMap<string, number>,
429
+ temporary: ReadonlyMap<string, number>,
430
+ ): Map<string, number> {
431
+ const merged = new Map(persisted);
432
+ for (const [pathKey, count] of temporary) {
433
+ merged.set(pathKey, Math.max(merged.get(pathKey) ?? 0, count));
434
+ }
435
+ return merged;
436
+ }
437
+
438
+ function ViewButton({
439
+ label,
440
+ icon,
441
+ testId,
442
+ onClick,
443
+ }: {
444
+ label: string;
445
+ icon: IconDefinition;
446
+ testId: string;
447
+ onClick: () => void;
448
+ }) {
449
+ return (
450
+ <IconButton
451
+ title={label}
452
+ aria-label={label}
453
+ data-testid={testId}
454
+ onClick={onClick}
455
+ variant="secondary"
456
+ size="compact"
457
+ >
458
+ <EditorIcon icon={icon} aria-hidden="true" />
459
+ </IconButton>
460
+ );
461
+ }
462
+
233
463
  // --- Context menu ---
234
464
 
235
465
  interface ContextMenuState {
@@ -283,54 +513,118 @@ function ContextMenu({
283
513
  if (!node) return null;
284
514
 
285
515
  const creatableKinds = adapter.structure?.creatableKinds?.(state.nodeId) ?? [];
286
- const hasStructure = !!adapter.structure;
516
+ // Gates ask the OWNING child (see ownerAdapterOf) — the composite's own
517
+ // structure/inspector are never-undefined routers, so probing THEM would
518
+ // advertise operations the owner refuses (live-caught on a Boundary row:
519
+ // Duplicate/Hide/Delete rendered enabled and no-op'd). `visible` is a
520
+ // VALUE probe like `name`/`locked`: every live adapter answers it, a
521
+ // Boundary answers undefined.
522
+ const owner = ownerAdapterOf(adapter, state.nodeId);
523
+ const ownerStructure = owner.structure;
524
+ const hasStructure = !!ownerStructure;
287
525
  const nameKnown = adapter.inspector?.get(state.nodeId, 'name') !== undefined;
288
526
  const lockedKnown = adapter.inspector?.get(state.nodeId, 'locked') !== undefined;
289
527
  const locked = adapter.inspector?.get(state.nodeId, 'locked') === true;
290
- const visibleKnown = adapter.inspector !== undefined;
528
+ const visibleKnown = adapter.inspector?.get(state.nodeId, 'visible') !== undefined;
291
529
  const visible = adapter.inspector?.get(state.nodeId, 'visible') !== false;
292
530
 
293
- const items: { label: string; action: () => void; color?: string }[] = [];
294
-
295
- if (!isGroupNode) {
296
- if (nameKnown) {
531
+ // Spec 29 §5 disabled-with-reason: when the SUBJECT of an operation is
532
+ // present (the row exists) but the adapter can't perform it, the item
533
+ // renders DISABLED with the world's own provenance explanation as its
534
+ // tooltip — the complement of spec 28's rule (chrome whose subject is
535
+ // absent is hidden, not disabled).
536
+ const reason = unavailableReason(provenanceForNode(adapter, state.nodeId));
537
+ const items: {
538
+ label: string;
539
+ testId?: string;
540
+ action: () => void;
541
+ color?: string;
542
+ disabledReason?: string;
543
+ }[] = [];
544
+
545
+ // A4 (focus-switch) — a group row's ONE menu verb: make an unfocused
546
+ // threejs world (with a loadable scene) the live one. Same dirty-gated
547
+ // path as the row's double-click.
548
+ if (isGroupNode) {
549
+ const groupBadge = compositeGroupBadge(adapter, state.nodeId);
550
+ if (groupBadge && worldFocusScenePath(groupBadge.worldId) !== null) {
297
551
  items.push({
298
- label: 'Rename',
552
+ label: 'Edit This World',
299
553
  action: () => {
300
- onRename(state.nodeId);
554
+ requestWorldFocus(store, groupBadge.worldId);
301
555
  onClose();
302
556
  },
303
557
  });
304
558
  }
305
- if (hasStructure) {
559
+ }
560
+
561
+ if (!isGroupNode) {
562
+ items.push({
563
+ label: 'Rename',
564
+ testId: 'hierarchy-context-menu-rename',
565
+ ...(nameKnown ? {} : { disabledReason: reason }),
566
+ action: () => {
567
+ onRename(state.nodeId);
568
+ onClose();
569
+ },
570
+ });
571
+ items.push({
572
+ label: 'Duplicate',
573
+ testId: 'hierarchy-context-menu-duplicate',
574
+ ...(hasStructure ? {} : { disabledReason: reason }),
575
+ action: () => {
576
+ adapter.structure!.duplicate(state.nodeId);
577
+ onClose();
578
+ },
579
+ });
580
+ if (ownerStructure?.wrap) {
306
581
  items.push({
307
- label: 'Duplicate',
582
+ label: 'Wrap',
583
+ testId: 'hierarchy-context-menu-wrap',
308
584
  action: () => {
309
- adapter.structure!.duplicate(state.nodeId);
585
+ ownerStructure.wrap!(state.nodeId);
310
586
  onClose();
311
587
  },
312
588
  });
313
589
  }
314
- if (visibleKnown) {
590
+ if (ownerStructure?.unwrap) {
315
591
  items.push({
316
- label: visible ? 'Hide' : 'Show',
592
+ label: 'Unwrap',
593
+ testId: 'hierarchy-context-menu-unwrap',
317
594
  action: () => {
318
- adapter.inspector!.set(state.nodeId, 'visible', !visible);
319
- store.notifyIngestEdit();
595
+ ownerStructure.unwrap!(state.nodeId);
320
596
  onClose();
321
597
  },
322
598
  });
323
599
  }
324
- if (lockedKnown) {
600
+ if (node.parentId !== null) {
325
601
  items.push({
326
- label: locked ? 'Unlock' : 'Lock',
602
+ label: 'Select Parent',
603
+ testId: 'hierarchy-context-menu-select-parent',
327
604
  action: () => {
328
- adapter.inspector!.set(state.nodeId, 'locked', !locked);
329
- store.notifyIngestEdit();
605
+ adapter.selection?.set([node.parentId!]);
330
606
  onClose();
331
607
  },
332
608
  });
333
609
  }
610
+ items.push({
611
+ label: visibleKnown && !visible ? 'Show' : 'Hide',
612
+ ...(visibleKnown ? {} : { disabledReason: reason }),
613
+ action: () => {
614
+ adapter.inspector!.set(state.nodeId, 'visible', !visible);
615
+ store.notifyIngestEdit();
616
+ onClose();
617
+ },
618
+ });
619
+ items.push({
620
+ label: locked ? 'Unlock' : 'Lock',
621
+ ...(lockedKnown ? {} : { disabledReason: reason }),
622
+ action: () => {
623
+ adapter.inspector!.set(state.nodeId, 'locked', !locked);
624
+ store.notifyIngestEdit();
625
+ onClose();
626
+ },
627
+ });
334
628
  // First-party escape-hatch extras (prefab unpack/open/save-as, …) —
335
629
  // permanent adapter-module contribution seam (see hierarchy-menu-registry.ts).
336
630
  for (const extra of getHierarchyMenuItems({ nodeId: state.nodeId, store })) {
@@ -343,16 +637,16 @@ function ContextMenu({
343
637
  },
344
638
  });
345
639
  }
346
- if (hasStructure) {
347
- items.push({
348
- label: 'Delete',
349
- color: '#ff6b6b',
350
- action: () => {
351
- adapter.structure!.remove(state.nodeId);
352
- onClose();
353
- },
354
- });
355
- }
640
+ items.push({
641
+ label: 'Delete',
642
+ testId: 'hierarchy-context-menu-delete',
643
+ color: themeVars.semantic.danger,
644
+ ...(hasStructure ? {} : { disabledReason: reason }),
645
+ action: () => {
646
+ adapter.structure!.remove(state.nodeId);
647
+ onClose();
648
+ },
649
+ });
356
650
  }
357
651
 
358
652
  return (
@@ -363,39 +657,52 @@ function ContextMenu({
363
657
  position: 'fixed',
364
658
  left: state.x,
365
659
  top: state.y,
366
- background: '#2c2c2c',
367
- border: '1px solid #444',
368
- borderRadius: 4,
369
- padding: '4px 0',
370
- zIndex: 1000,
660
+ // Menus/popovers contract (editor-style-polish §5): bg-2/border-2/
661
+ // radius-md/shadow-md, item padding aligned to `.vgai-menu-item`.
662
+ background: themeVars.surface.chrome,
663
+ border: `1px solid ${themeVars.boundary.strong}`,
664
+ borderRadius: themeVars.shape.medium,
665
+ padding: `${space[2]}px 0`,
666
+ zIndex: zIndex.sticky,
371
667
  minWidth: 150,
372
668
  maxWidth: 260,
373
- boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
374
- fontSize: 12,
375
- color: '#c5c8ce',
669
+ boxShadow: themeVars.elevation.medium,
670
+ fontSize: 'var(--vgai-font-base)',
671
+ color: themeVars.content.primary,
376
672
  }}
377
673
  >
378
674
  {/* A4 (D8): also suppressed for a root-group MEMBER row, same as an
379
675
  existing composite group row — `isGroupNode` covers both (see the
380
- caller's computation below). Create▸ on a member id would silently
676
+ caller's computation below). The Create submenu on a member id would silently
381
677
  land at the scene root instead of doing anything meaningful. */}
382
678
  {!isGroupNode && creatableKinds.length > 0 && (
383
679
  <div>
384
680
  <div
681
+ data-testid="hierarchy-context-menu-create"
385
682
  role="menuitem"
386
683
  tabIndex={0}
387
684
  onClick={() => setCreateOpen((v) => !v)}
388
685
  onKeyDown={(e) => {
389
686
  if (e.key === 'Enter') setCreateOpen((v) => !v);
390
687
  }}
391
- style={{ padding: '5px 12px', cursor: 'pointer' }}
688
+ style={{
689
+ display: 'flex',
690
+ alignItems: 'center',
691
+ justifyContent: 'space-between',
692
+ gap: space[4],
693
+ padding: `${space[2]}px ${space[4]}px`,
694
+ cursor: 'pointer',
695
+ }}
392
696
  >
393
- Create ▸
697
+ <span>Create</span>
698
+ <DisclosureIcon direction="right" tone="dim" />
394
699
  </div>
395
700
  {createOpen &&
396
701
  creatableKinds.map((k) => (
397
702
  <div
703
+ className="vgai-menu-item"
398
704
  key={k.kind}
705
+ data-testid={`hierarchy-context-menu-create-${k.kind}`}
399
706
  role="menuitem"
400
707
  tabIndex={0}
401
708
  onClick={() => {
@@ -412,12 +719,9 @@ function ContextMenu({
412
719
  onClose();
413
720
  }
414
721
  }}
415
- style={{ padding: '5px 12px 5px 24px', cursor: 'pointer' }}
416
- onMouseEnter={(e) => {
417
- (e.currentTarget as HTMLDivElement).style.background = '#3a3a3a';
418
- }}
419
- onMouseLeave={(e) => {
420
- (e.currentTarget as HTMLDivElement).style.background = 'transparent';
722
+ style={{
723
+ padding: `${space[2]}px ${space[4]}px ${space[2]}px 24px`,
724
+ cursor: 'pointer',
421
725
  }}
422
726
  >
423
727
  {k.label}
@@ -425,33 +729,60 @@ function ContextMenu({
425
729
  ))}
426
730
  </div>
427
731
  )}
428
- {items.map((item) => (
429
- <div
430
- key={item.label}
431
- role="menuitem"
432
- tabIndex={0}
433
- onClick={item.action}
434
- onKeyDown={(e) => {
435
- if (e.key === 'Enter') item.action();
436
- }}
437
- style={{ padding: '5px 12px', cursor: 'pointer', color: item.color ?? '#c5c8ce' }}
438
- onMouseEnter={(e) => {
439
- (e.currentTarget as HTMLDivElement).style.background = '#3a3a3a';
440
- }}
441
- onMouseLeave={(e) => {
442
- (e.currentTarget as HTMLDivElement).style.background = 'transparent';
443
- }}
444
- >
445
- {item.label}
446
- </div>
447
- ))}
732
+ {items.map((item) =>
733
+ item.disabledReason !== undefined ? (
734
+ <div
735
+ className="vgai-menu-item"
736
+ key={item.label}
737
+ data-testid={item.testId}
738
+ role="menuitem"
739
+ aria-disabled="true"
740
+ title={item.disabledReason}
741
+ style={{
742
+ padding: `${space[2]}px ${space[4]}px`,
743
+ // L-21 — disabled rows get a dedicated muted color (not just
744
+ // opacity-faded active text) plus a not-allowed cursor.
745
+ cursor: 'not-allowed',
746
+ color: themeVars.content.dim,
747
+ opacity: 0.7,
748
+ }}
749
+ >
750
+ {item.label}
751
+ </div>
752
+ ) : (
753
+ <div
754
+ key={item.label}
755
+ data-testid={item.testId}
756
+ role="menuitem"
757
+ tabIndex={0}
758
+ onClick={item.action}
759
+ onKeyDown={(e) => {
760
+ if (e.key === 'Enter') item.action();
761
+ }}
762
+ style={{
763
+ padding: `${space[2]}px ${space[4]}px`,
764
+ cursor: 'pointer',
765
+ color: item.color ?? themeVars.content.primary,
766
+ }}
767
+ >
768
+ {item.label}
769
+ </div>
770
+ ),
771
+ )}
448
772
  {/* Bounded-authoring honest degrade (landmine f): the ACTIVE adapter has
449
773
  no `structure` provider at all (an ingested/foreign game owns its
450
774
  own scene graph) — Rename/Show-Hide/Lock above still work (they're
451
775
  gated independently, on `inspector`), but Create/Duplicate/Delete/
452
776
  Reparent are not, so say so plainly instead of hiding the menu. */}
453
777
  {!hasStructure && (
454
- <div style={{ padding: '8px 12px', color: '#c9a227', maxWidth: 240, lineHeight: 1.4 }}>
778
+ <div
779
+ style={{
780
+ padding: `${space[4]}px ${space[6]}px`,
781
+ color: themeVars.semantic.warning,
782
+ maxWidth: 240,
783
+ lineHeight: 1.4,
784
+ }}
785
+ >
455
786
  <strong>Create / Delete / Reparent — not available.</strong> This game owns its own scene
456
787
  graph, so structural authoring isn't reapplicable. You can edit transform / material /
457
788
  visibility, or capture a subtree into the first-party editable format.
@@ -474,6 +805,7 @@ function CreateMenu({ adapter, parentId }: { adapter: AuthoringAdapter; parentId
474
805
  // the same commit; a ref reset on each open bounds it to one create per open.
475
806
  const creatingRef = useRef(false);
476
807
  const kinds = adapter.structure?.creatableKinds?.(parentId) ?? [];
808
+ const targetLabel = parentId ? adapter.hierarchy.node(parentId)?.label : null;
477
809
 
478
810
  useEffect(() => {
479
811
  if (!open) return;
@@ -496,42 +828,42 @@ function CreateMenu({ adapter, parentId }: { adapter: AuthoringAdapter; parentId
496
828
 
497
829
  return (
498
830
  <div ref={ref} style={{ position: 'relative', flexShrink: 0 }}>
499
- <span
500
- role="button"
501
- title="Add Entity"
831
+ {/* L-11 — same fixed 20×20 boxed treatment as its `ViewButton` siblings
832
+ in the toolbar row (collapse/expand/reset), instead of an unboxed
833
+ inline span with mismatched padding. */}
834
+ <Button
835
+ title={targetLabel ? `Add to ${targetLabel}` : 'Add'}
836
+ aria-label={targetLabel ? `Add to ${targetLabel}` : 'Add'}
837
+ aria-expanded={open}
502
838
  data-testid="hierarchy-add-menu"
503
839
  onClick={() => setOpen(!open)}
504
- style={{
505
- cursor: 'pointer',
506
- fontSize: 13,
507
- padding: '1px 4px',
508
- borderRadius: 3,
509
- background: open ? '#3a3a3a' : 'transparent',
510
- userSelect: 'none',
511
- color: '#c5c8ce',
512
- }}
840
+ variant={open ? 'primary' : 'secondary'}
841
+ size="compact"
513
842
  >
514
- <FontAwesomeIcon icon={faPlus} style={{ fontSize: 10 }} />{' '}
515
- <FontAwesomeIcon icon={faCaretDown} style={{ fontSize: 9 }} />
516
- </span>
843
+ <EditorIcon icon={faPlus} style={{ fontSize: 'var(--vgai-font-sm)' }} />{' '}
844
+ <EditorIcon icon={faCaretDown} style={{ fontSize: 'var(--vgai-font-xs)' }} />
845
+ </Button>
517
846
  {open && (
518
847
  <div
519
848
  style={{
520
849
  position: 'absolute',
521
850
  top: '100%',
522
851
  right: 0,
523
- marginTop: 4,
524
- background: '#2c2c2c',
525
- border: '1px solid #444',
526
- borderRadius: 4,
527
- padding: '4px 0',
528
- zIndex: 1000,
852
+ marginTop: space[2],
853
+ background: themeVars.surface.chrome,
854
+ border: `1px solid ${themeVars.boundary.strong}`,
855
+ borderRadius: themeVars.shape.medium,
856
+ padding: `${space[2]}px 0`,
857
+ zIndex: zIndex.sticky,
529
858
  minWidth: 130,
530
- boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
859
+ maxHeight: 'min(480px, 70vh)',
860
+ overflowY: 'auto',
861
+ boxShadow: themeVars.elevation.medium,
531
862
  }}
532
863
  >
533
864
  {kinds.map((k) => (
534
865
  <div
866
+ className="vgai-menu-item"
535
867
  key={k.kind}
536
868
  data-testid={`hierarchy-add-${k.kind}`}
537
869
  onClick={() => {
@@ -540,12 +872,11 @@ function CreateMenu({ adapter, parentId }: { adapter: AuthoringAdapter; parentId
540
872
  adapter.structure!.create(k.kind, parentId ?? undefined);
541
873
  setOpen(false);
542
874
  }}
543
- style={{ padding: '5px 12px', cursor: 'pointer', fontSize: 12, color: '#c5c8ce' }}
544
- onMouseEnter={(e) => {
545
- (e.currentTarget as HTMLDivElement).style.background = '#3a3a3a';
546
- }}
547
- onMouseLeave={(e) => {
548
- (e.currentTarget as HTMLDivElement).style.background = 'transparent';
875
+ style={{
876
+ padding: `${space[2]}px ${space[4]}px`,
877
+ cursor: 'pointer',
878
+ fontSize: 'var(--vgai-font-base)',
879
+ color: themeVars.content.primary,
549
880
  }}
550
881
  >
551
882
  {k.label}
@@ -562,27 +893,18 @@ function CreateMenu({ adapter, parentId }: { adapter: AuthoringAdapter; parentId
562
893
  function MoreRow({ row, onReveal }: { row: IngestRow; onReveal: (parentPathKey: string) => void }) {
563
894
  const hidden = row.more!.hidden;
564
895
  return (
565
- <button
896
+ <Button
566
897
  type="button"
898
+ variant="ghost"
899
+ className="vgai-hierarchy-more"
567
900
  data-testid="ingest-row-more"
568
901
  onClick={() => onReveal(row.more!.parentPathKey)}
569
902
  style={{
570
- display: 'flex',
571
- alignItems: 'center',
572
- width: '100%',
573
- height: ROW_HEIGHT,
574
903
  paddingLeft: row.depth * INDENT + INDENT,
575
- border: 'none',
576
- background: 'transparent',
577
- color: '#7d828c',
578
- fontSize: 12,
579
- fontStyle: 'italic',
580
- textAlign: 'left',
581
- cursor: 'pointer',
582
904
  }}
583
905
  >
584
906
  … {hidden} more
585
- </button>
907
+ </Button>
586
908
  );
587
909
  }
588
910
 
@@ -608,7 +930,11 @@ function highlightSegments(label: string, term: string): React.ReactNode {
608
930
  <mark
609
931
  key={key++}
610
932
  data-testid="ingest-row-search-highlight"
611
- style={{ background: '#e0a030', color: '#1a1a1a', borderRadius: 2 }}
933
+ style={{
934
+ background: themeVars.semantic.warning,
935
+ color: themeVars.surface.shell,
936
+ borderRadius: themeVars.shape.small,
937
+ }}
612
938
  >
613
939
  {label.slice(idx, idx + needle.length)}
614
940
  </mark>,
@@ -633,6 +959,7 @@ function Row({
633
959
  searchTerm,
634
960
  onToggleOpen,
635
961
  onStartEditing,
962
+ focusedWorldId,
636
963
  onContextMenu,
637
964
  onDragStart,
638
965
  onClick,
@@ -649,28 +976,36 @@ function Row({
649
976
  /** A4 (D8) — set when this row is a root-group member (a scene-file
650
977
  * root); `active` is whether it's the currently-loaded one. */
651
978
  rootMember: { groupId: string; active: boolean } | undefined;
979
+ /** A4 (focus-switch) — the live/focused viewport world, recomputed by the
980
+ * panel per notify (`resolveFocusedViewportWorldId`); group rows render
981
+ * the ●/○ focus radio against it. */
982
+ focusedWorldId: string | null;
652
983
  /** D4 — the trimmed, lowercased-by-the-caller search term (empty string
653
984
  * when the search box is empty), for {@link highlightSegments}. */
654
985
  searchTerm: string;
655
- onToggleOpen: (pathKey: string) => void;
986
+ onToggleOpen: (row: IngestRow, recursive: boolean) => void;
656
987
  onStartEditing: (id: string) => void;
657
988
  onContextMenu: (e: React.MouseEvent, id: string) => void;
658
989
  onDragStart: (e: React.DragEvent, id: string) => void;
659
990
  onClick: (e: React.MouseEvent, id: string) => void;
660
991
  onActivateRoot: (groupId: string, rootId: string) => void;
661
992
  }) {
662
- const { node, depth, hasChildren, pathKey } = row;
993
+ const { node, depth, hasChildren } = row;
663
994
  // A composite `world:<id>` group row (session-local eye per D9, zOrder badge,
664
995
  // non-draggable) — NOT merely a runtime-only node (see `isCompositeGroupNode`).
665
996
  const badge = compositeGroupBadge(adapter, node.id);
666
997
  const isGroupNode = badge !== null;
667
- // A4 (D8) — the synthetic game root, and a root-group member (scene-file
668
- // root) row. Neither owns a real name/visible/locked path, isn't
998
+ // A4 (D8) — a root-group member (scene-file root) row does not own a real
999
+ // name/visible/locked path, isn't
669
1000
  // draggable, and carries no `data-entity-name` (landmine: every project
670
1001
  // ships 2+ scene-file members — they must not pollute `getEntityNames()`).
671
- const isGameRootRow = isGameRootNode(adapter, node.id);
672
1002
  const isMemberRow = rootMember !== undefined;
673
- const isStructural = isGroupNode || isGameRootRow || isMemberRow;
1003
+ const isOrganizationRow = isCompositeOrganizationNode(adapter, node.id);
1004
+ const isStructural =
1005
+ isGroupNode ||
1006
+ isOrganizationRow ||
1007
+ isMemberRow ||
1008
+ (node.role !== undefined && STRUCTURAL_ROLES.has(node.role));
674
1009
  // Reserved-path reads, resolved ONCE per row and only for non-structural
675
1010
  // rows: an adapter that doesn't support a reserved path returns
676
1011
  // `undefined` (its affordance simply doesn't render). Cheap for every
@@ -679,7 +1014,11 @@ function Row({
679
1014
  // reads a plain descriptor.
680
1015
  const lockedValue = isStructural ? undefined : adapter.inspector?.get(node.id, 'locked');
681
1016
  const nameValue = isStructural ? undefined : adapter.inspector?.get(node.id, 'name');
682
- const visibleKnown = !isStructural && adapter.inspector !== undefined;
1017
+ // VALUE probe like `locked`/`name` above (not provider presence — the
1018
+ // composite's inspector is a never-undefined router, which put a no-op eye
1019
+ // on Boundary rows): every live adapter answers `visible`; a Boundary
1020
+ // answers undefined and gets no eye.
1021
+ const visibleKnown = !isStructural && adapter.inspector?.get(node.id, 'visible') !== undefined;
683
1022
  // B1 (D9) — a group row's eye/interactive/pick-lock read the LIFTED,
684
1023
  // module-level session store (`world-session-state.ts`), keyed by the
685
1024
  // RAW manifest world id `badge.worldId` — not the group-node id itself.
@@ -691,20 +1030,57 @@ function Row({
691
1030
  const locked = lockedValue === true;
692
1031
  const nameKnown = nameValue !== undefined;
693
1032
 
694
- const bg =
695
- dropZone === 'child' ? 'rgba(87, 158, 255, 0.25)' : isSelected ? '#2a4a7a' : 'transparent';
696
-
697
1033
  return (
698
1034
  <div
699
- data-entity-name={isMemberRow || isGameRootRow ? undefined : node.label}
1035
+ className="vgai-tree-row"
1036
+ data-entity-name={isMemberRow || isOrganizationRow ? undefined : node.label}
700
1037
  data-testid="ingest-row"
701
1038
  data-ingest-name={node.label}
1039
+ data-node-role={node.role}
1040
+ data-selected={isSelected ? 'true' : undefined}
1041
+ data-dragging={isDragged ? 'true' : undefined}
1042
+ data-muted={
1043
+ (!isStructural && !visible) || (rootMember && !rootMember.active) ? 'true' : undefined
1044
+ }
702
1045
  draggable={!isStructural}
703
1046
  onDragStart={(e) => onDragStart(e, node.id)}
704
- onClick={(e) => onClick(e, node.id)}
1047
+ onClick={(e) => {
1048
+ if (badge?.role === 'world') {
1049
+ // Runtime composition belongs to Game. In edit mode a manifest
1050
+ // world row is a document navigator: one click reveals that
1051
+ // world's isolated authoring surface, matching scene/file trees in
1052
+ // established editors. A Three world additionally switches the
1053
+ // native scene slot when it is not already focused.
1054
+ activateWorldDocument(badge.worldId);
1055
+ if (
1056
+ badge.kind === 'threejs' &&
1057
+ focusedWorldId !== badge.worldId &&
1058
+ worldFocusScenePath(badge.worldId) !== null
1059
+ ) {
1060
+ requestWorldFocus(store, badge.worldId);
1061
+ }
1062
+ }
1063
+ if (node.role === 'story') {
1064
+ // §4.2 (W2): clicking a story row selects it, applies it to its
1065
+ // React root (the existing behavior below), AND opens/activates
1066
+ // its CENTER story document — resolved through the live story
1067
+ // registry; a row whose id isn't a discovered portable story
1068
+ // (e.g. a legacy registry story) opens no document (nothing is
1069
+ // fabricated). During Play the live connector owns the root:
1070
+ // portable-story rows aren't projected then, and the document
1071
+ // path never touches the play session either way.
1072
+ if (node.flags.active !== true) adapter.stories?.apply(node.id, node.id);
1073
+ openStoryDocumentByStoryId(store, node.id);
1074
+ }
1075
+ onClick(e, node.id);
1076
+ }}
705
1077
  onDoubleClick={() => {
706
1078
  if (rootMember && !rootMember.active) {
707
1079
  onActivateRoot(rootMember.groupId, node.id);
1080
+ } else if (badge?.role === 'world' && worldFocusScenePath(badge.worldId) !== null) {
1081
+ // A4 — same gesture as the D8 radio roots, one level up: an
1082
+ // inactive-but-switchable threejs world becomes the focused one.
1083
+ requestWorldFocus(store, badge.worldId);
708
1084
  } else if (nameKnown) {
709
1085
  onStartEditing(node.id);
710
1086
  }
@@ -715,88 +1091,96 @@ function Row({
715
1091
  }}
716
1092
  style={{
717
1093
  position: 'relative',
718
- display: 'flex',
719
- alignItems: 'center',
720
1094
  height: ROW_HEIGHT,
721
1095
  paddingLeft: depth * INDENT,
722
1096
  paddingRight: 4,
723
1097
  gap: 4,
724
1098
  cursor: 'pointer',
725
- background: bg,
726
- outline: dropZone === 'child' ? '1px dashed #579EFF' : 'none',
1099
+ outline: dropZone === 'child' ? `1px dashed ${themeVars.accent.default}` : 'none',
727
1100
  whiteSpace: 'nowrap',
728
1101
  overflow: 'hidden',
729
- opacity:
730
- isDragged || (!isStructural && !visible) || (rootMember && !rootMember.active) ? 0.45 : 1,
731
- fontSize: 13,
732
- color: '#c5c8ce',
733
- userSelect: 'none',
1102
+ fontSize: 'var(--vgai-font-lg)',
734
1103
  }}
735
1104
  >
736
1105
  {dropZone === 'before' && (
737
- <div
738
- style={{
739
- position: 'absolute',
740
- top: 0,
741
- left: depth * INDENT,
742
- right: 0,
743
- height: 2,
744
- background: '#579EFF',
745
- pointerEvents: 'none',
746
- zIndex: 10,
747
- }}
748
- />
1106
+ <DropIndicator position="before" style={{ left: depth * INDENT }} />
749
1107
  )}
750
- {dropZone === 'after' && (
751
- <div
752
- style={{
753
- position: 'absolute',
754
- bottom: 0,
755
- left: depth * INDENT,
756
- right: 0,
757
- height: 2,
758
- background: '#579EFF',
759
- pointerEvents: 'none',
760
- zIndex: 10,
1108
+ {dropZone === 'after' && <DropIndicator position="after" style={{ left: depth * INDENT }} />}
1109
+
1110
+ {hasChildren ? (
1111
+ <IconButton
1112
+ type="button"
1113
+ variant="ghost"
1114
+ size="compact"
1115
+ aria-label={isCollapsed ? `Expand ${node.label}` : `Collapse ${node.label}`}
1116
+ data-testid="ingest-row-caret"
1117
+ onClick={(e) => {
1118
+ e.stopPropagation();
1119
+ onToggleOpen(row, e.altKey);
761
1120
  }}
1121
+ title="Expand/collapse (Option/Alt-click applies to the branch)"
1122
+ className="vgai-tree-caret"
1123
+ >
1124
+ <EditorIcon icon={isCollapsed ? faCaretRight : faCaretDown} aria-hidden="true" />
1125
+ </IconButton>
1126
+ ) : (
1127
+ <span
1128
+ data-testid="ingest-row-caret"
1129
+ aria-hidden="true"
1130
+ style={{ width: 12, flexShrink: 0 }}
762
1131
  />
763
1132
  )}
764
1133
 
765
- <span
766
- data-testid="ingest-row-caret"
767
- onClick={(e) => {
768
- e.stopPropagation();
769
- onToggleOpen(pathKey);
770
- }}
771
- style={{
772
- width: 12,
773
- fontSize: 8,
774
- textAlign: 'center',
775
- flexShrink: 0,
776
- cursor: hasChildren ? 'pointer' : 'default',
777
- color: '#8c8c8c',
778
- }}
779
- >
780
- {hasChildren ? <FontAwesomeIcon icon={isCollapsed ? faCaretRight : faCaretDown} /> : ''}
781
- </span>
782
-
783
1134
  {rootMember && (
784
1135
  <span
785
1136
  data-testid="root-radio"
786
1137
  data-root-active={rootMember.active ? 'true' : 'false'}
787
1138
  title={rootMember.active ? 'Active root' : 'Inactive root — double-click to activate'}
788
- style={{ fontSize: 9, opacity: 0.8, flexShrink: 0 }}
1139
+ style={{ fontSize: 'var(--vgai-font-xs)', opacity: 0.8, flexShrink: 0 }}
789
1140
  >
790
- {rootMember.active ? '●' : '○'}
1141
+ <EditorIcon icon={rootMember.active ? faCircleDot : faCircle} aria-hidden="true" />
791
1142
  </span>
792
1143
  )}
793
1144
 
794
- <span style={{ fontSize: 10, opacity: 0.6, flexShrink: 0 }}>
795
- {KIND_GLYPH[node.kind] ?? '•'}
1145
+ {/* A4 (focus-switch) — the world-level radio, same visual grammar as the
1146
+ * D8 scene-root radio above: ● = the live/focused viewport world,
1147
+ * ○ = an unfocused threejs world with a loadable scene (double-click or
1148
+ * context-menu "Edit This World" to focus it). Worlds that are neither
1149
+ * (DOM/pixi kinds, entry-only threejs) show no dot — nothing to claim. */}
1150
+ {badge?.role === 'world' &&
1151
+ (badge.worldId === focusedWorldId || worldFocusScenePath(badge.worldId) !== null) && (
1152
+ <span
1153
+ data-testid="world-focus-radio"
1154
+ data-world-focused={badge.worldId === focusedWorldId ? 'true' : 'false'}
1155
+ title={
1156
+ badge.worldId === focusedWorldId
1157
+ ? 'Focused world — live in the viewport'
1158
+ : 'Inactive world — double-click to edit it'
1159
+ }
1160
+ style={{ fontSize: 'var(--vgai-font-xs)', opacity: 0.8, flexShrink: 0 }}
1161
+ >
1162
+ <EditorIcon
1163
+ icon={badge.worldId === focusedWorldId ? faCircleDot : faCircle}
1164
+ aria-hidden="true"
1165
+ />
1166
+ </span>
1167
+ )}
1168
+
1169
+ <span
1170
+ title={node.role ? `${node.role}: ${node.kind}` : node.kind}
1171
+ style={{
1172
+ width: 12,
1173
+ fontSize: 'var(--vgai-font-sm)',
1174
+ opacity: 0.65,
1175
+ flexShrink: 0,
1176
+ textAlign: 'center',
1177
+ }}
1178
+ >
1179
+ <EditorIcon icon={iconForNode(node)} aria-hidden="true" />
796
1180
  </span>
797
1181
 
798
1182
  {isEditing ? (
799
- <input
1183
+ <TextInput
800
1184
  autoFocus
801
1185
  defaultValue={node.label}
802
1186
  onBlur={(e) => {
@@ -816,44 +1200,80 @@ function Row({
816
1200
  style={{
817
1201
  flex: 1,
818
1202
  minWidth: 0,
819
- background: '#1a1a1a',
820
- border: '1px solid #579EFF',
821
- borderRadius: 2,
822
- color: '#c5c8ce',
823
- fontSize: 13,
824
- padding: '0 4px',
1203
+ background: themeVars.surface.inset,
1204
+ border: `1px solid ${themeVars.accent.default}`,
1205
+ borderRadius: themeVars.shape.small,
1206
+ color: themeVars.content.primary,
1207
+ fontSize: 'var(--vgai-font-lg)',
1208
+ padding: `0 ${space[2]}px`,
825
1209
  outline: 'none',
826
1210
  }}
827
1211
  />
828
1212
  ) : (
829
1213
  <span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>
830
1214
  {highlightSegments(node.label, searchTerm)}
831
- {badge && (
832
- <span style={{ marginLeft: 6, fontSize: 10, opacity: 0.6 }}>z{badge.zOrder}</span>
1215
+ {node.secondaryLabel && (
1216
+ <span style={{ marginLeft: 6, fontSize: 'var(--vgai-font-sm)', opacity: 0.5 }}>
1217
+ {node.secondaryLabel}
1218
+ </span>
1219
+ )}
1220
+ {badge?.role === 'world' && badge.zOrder !== 0 && (
1221
+ <span
1222
+ title={`Z order: ${badge.zOrder}`}
1223
+ style={{ marginLeft: 6, fontSize: 'var(--vgai-font-sm)', opacity: 0.6 }}
1224
+ >
1225
+ Z {badge.zOrder}
1226
+ </span>
1227
+ )}
1228
+ {badge?.provenance && (
1229
+ <span
1230
+ data-testid="world-provenance"
1231
+ data-provenance={badge.provenance.source}
1232
+ title={badge.provenance.detail}
1233
+ style={{
1234
+ marginLeft: 6,
1235
+ fontSize: 'var(--vgai-font-xs)',
1236
+ padding: `0 ${space[2]}px`,
1237
+ borderRadius: themeVars.shape.small,
1238
+ border: `1px solid ${themeVars.boundary.strong}`,
1239
+ color: themeVars.content.muted,
1240
+ verticalAlign: 'middle',
1241
+ }}
1242
+ >
1243
+ {badge.provenance.label}
1244
+ </span>
833
1245
  )}
834
1246
  </span>
835
1247
  )}
836
1248
 
837
1249
  {lockedKnown && (
838
- <span
1250
+ <IconButton
1251
+ type="button"
1252
+ variant="ghost"
1253
+ size="compact"
839
1254
  data-testid="ingest-row-lock"
840
- title="Toggle Lock"
1255
+ title={locked ? 'Unlock' : 'Lock'}
1256
+ aria-label={locked ? `Unlock ${node.label}` : `Lock ${node.label}`}
1257
+ aria-pressed={locked}
841
1258
  onClick={(e) => {
842
1259
  e.stopPropagation();
843
1260
  adapter.inspector!.set(node.id, 'locked', !locked);
844
1261
  store.notifyIngestEdit();
845
1262
  }}
846
- style={{ fontSize: 9, cursor: 'pointer', flexShrink: 0, opacity: locked ? 0.8 : 0.35 }}
847
1263
  >
848
- <FontAwesomeIcon icon={locked ? faLock : faLockOpen} />
849
- </span>
1264
+ <EditorIcon icon={locked ? faLock : faLockOpen} aria-hidden="true" />
1265
+ </IconButton>
850
1266
  )}
851
1267
 
852
1268
  {(visibleKnown || isGroupNode) && (
853
- <button
1269
+ <IconButton
854
1270
  type="button"
1271
+ variant="ghost"
1272
+ size="compact"
855
1273
  data-testid="ingest-row-visibility"
856
1274
  title={visible ? 'Hide' : 'Show'}
1275
+ aria-label={visible ? `Hide ${node.label}` : `Show ${node.label}`}
1276
+ aria-pressed={!visible}
857
1277
  onClick={(e) => {
858
1278
  e.stopPropagation();
859
1279
  if (badge) {
@@ -864,19 +1284,9 @@ function Row({
864
1284
  store.notifyIngestEdit();
865
1285
  }
866
1286
  }}
867
- style={{
868
- width: 20,
869
- height: 20,
870
- border: 'none',
871
- background: 'transparent',
872
- color: visible ? '#9aa0a6' : '#e0a030',
873
- cursor: 'pointer',
874
- fontSize: 11,
875
- flexShrink: 0,
876
- }}
877
1287
  >
878
- <FontAwesomeIcon icon={visible ? faEye : faEyeSlash} />
879
- </button>
1288
+ <EditorIcon icon={visible ? faEye : faEyeSlash} aria-hidden="true" />
1289
+ </IconButton>
880
1290
  )}
881
1291
 
882
1292
  {/* B1 (D9) — session-local, per-world design-time toggles: `interactive`
@@ -885,48 +1295,46 @@ function Row({
885
1295
  * for B4's layered viewport picking to consume. Group rows only —
886
1296
  * neither concept applies to an ordinary entity row. */}
887
1297
  {badge && (
888
- <span
1298
+ <IconButton
1299
+ type="button"
1300
+ variant="ghost"
1301
+ size="compact"
889
1302
  data-testid="world-interactive-toggle"
890
1303
  title={
891
1304
  worldInteractive
892
1305
  ? 'Interactive (layer forwards pointer events)'
893
1306
  : 'Not interactive (layer is pointer-events:none)'
894
1307
  }
1308
+ aria-label={`Forward pointer events to ${node.label}`}
1309
+ aria-pressed={worldInteractive}
895
1310
  onClick={(e) => {
896
1311
  e.stopPropagation();
897
1312
  toggleWorldInteractive(badge.worldId);
898
1313
  store.notifyIngestEdit();
899
1314
  }}
900
- style={{
901
- fontSize: 9,
902
- cursor: 'pointer',
903
- flexShrink: 0,
904
- opacity: worldInteractive ? 0.9 : 0.35,
905
- }}
906
1315
  >
907
- <FontAwesomeIcon icon={faHandPointer} />
908
- </span>
1316
+ <EditorIcon icon={faHandPointer} aria-hidden="true" />
1317
+ </IconButton>
909
1318
  )}
910
1319
  {badge && (
911
- <span
1320
+ <IconButton
1321
+ type="button"
1322
+ variant="ghost"
1323
+ size="compact"
912
1324
  data-testid="world-picklock-toggle"
913
1325
  title={
914
1326
  worldPickLocked ? 'Pick-locked (skipped by topmost-layer picking)' : 'Not pick-locked'
915
1327
  }
1328
+ aria-label={`Exclude ${node.label} from viewport picking`}
1329
+ aria-pressed={worldPickLocked}
916
1330
  onClick={(e) => {
917
1331
  e.stopPropagation();
918
1332
  toggleWorldPickLock(badge.worldId);
919
1333
  store.notifyIngestEdit();
920
1334
  }}
921
- style={{
922
- fontSize: 9,
923
- cursor: 'pointer',
924
- flexShrink: 0,
925
- opacity: worldPickLocked ? 0.9 : 0.35,
926
- }}
927
1335
  >
928
- <FontAwesomeIcon icon={faCrosshairs} />
929
- </span>
1336
+ <EditorIcon icon={faCrosshairs} aria-hidden="true" />
1337
+ </IconButton>
930
1338
  )}
931
1339
  </div>
932
1340
  );
@@ -938,6 +1346,9 @@ export function GameHierarchy() {
938
1346
  const store = useEditorStore();
939
1347
  useSyncExternalStore(store.subscribe, store.getSnapshot);
940
1348
  const adapter = getActiveAuthoring(store);
1349
+ // A4 — recomputed per notify (never cached across notifies): drives the
1350
+ // world-group rows' ●/○ focus radio.
1351
+ const focusedViewportWorldId = resolveFocusedViewportWorldId(store);
941
1352
 
942
1353
  const [search, setSearch] = useState('');
943
1354
  const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
@@ -957,28 +1368,94 @@ export function GameHierarchy() {
957
1368
  const [scrollTop, setScrollTop] = useState(0);
958
1369
 
959
1370
  const containerRef = useRef<HTMLDivElement>(null);
960
- const collapsedKeysRef = useRef(new Set<string>());
961
1371
  const revealedRef = useRef(new Map<string, number>());
1372
+ const sessionExpansionRef = useRef(new Map<string, boolean>());
1373
+ const sessionExpansionProjectRef = useRef<string | null>(null);
1374
+ const lastSelectionExpansionRef = useRef<{
1375
+ adapter: AuthoringAdapter;
1376
+ selection: string;
1377
+ } | null>(null);
962
1378
  const lastClickedRef = useRef<string | null>(null);
1379
+ const lastAutoRevealRef = useRef<{ adapter: AuthoringAdapter; selection: string } | null>(null);
963
1380
  const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
964
1381
 
965
1382
  const term = search.trim().toLowerCase();
1383
+ const projectIdentity = getCurrentProject()?.rootPath ?? '__unscoped__';
1384
+ // A4 (D8) — every root-group member, rebuilt each render straight from
1385
+ // `rootGroups.groups()` (never from the id string — see `buildRootMembers`'s
1386
+ // doc comment).
1387
+ const rootMembers = buildRootMembers(adapter);
1388
+ const selected = adapter.selection?.get() ?? [];
1389
+ const selectedIds = new Set(selected);
1390
+ // The toolbar's generic Add menu targets the selected hierarchy node. The
1391
+ // composite adapter then routes the request to that node's owning world
1392
+ // adapter, whose `creatableKinds` is the sole source of menu contents.
1393
+ // Multi-selection has no unambiguous parent, so it intentionally falls
1394
+ // back to the active adapter's root-level insertion behavior.
1395
+ const createTargetId = selected.length === 1 ? selected[0]! : null;
1396
+ const selectionSignature = [...selectedIds].sort().join('\0');
1397
+
1398
+ if (sessionExpansionProjectRef.current !== projectIdentity) {
1399
+ sessionExpansionRef.current.clear();
1400
+ sessionExpansionProjectRef.current = projectIdentity;
1401
+ lastSelectionExpansionRef.current = null;
1402
+ }
1403
+
1404
+ // Always take one uncapped, uncollapsed structural snapshot first. It is
1405
+ // the source for stable defaults, search ancestry, selection reveal, and
1406
+ // branch operations. The final browse tree below is still capped and
1407
+ // virtualized, so this does not inflate rendered DOM for large ingests.
1408
+ const structuralRows = flattenIngestRows(adapter.hierarchy, new Set());
1409
+ const defaultExpansion = defaultExpansionByNode(structuralRows, adapter, rootMembers);
1410
+
1411
+ // Selection reveal behaves like an ordinary tree expansion for the rest of
1412
+ // this editor session. It is applied only when selection changes, so a user
1413
+ // can explicitly collapse the revealed branch without the current
1414
+ // selection immediately forcing it open again.
1415
+ const lastExpansion = lastSelectionExpansionRef.current;
1416
+ if (lastExpansion?.adapter !== adapter || lastExpansion.selection !== selectionSignature) {
1417
+ for (const id of selectedAncestorNodeIds(structuralRows, selectedIds)) {
1418
+ sessionExpansionRef.current.set(id, true);
1419
+ }
1420
+ lastSelectionExpansionRef.current = { adapter, selection: selectionSignature };
1421
+ }
1422
+
1423
+ const isExpanded = (row: IngestRow): boolean =>
1424
+ sessionExpansionRef.current.get(row.node.id) ??
1425
+ hierarchyExpansionPreference(projectIdentity, row.node.id) ??
1426
+ defaultExpansion.get(row.node.id) ??
1427
+ true;
1428
+
1429
+ const persistedCollapsedPaths = new Set(
1430
+ structuralRows
1431
+ .filter((row) => !row.more && row.hasChildren && !isExpanded(row))
1432
+ .map((row) => row.pathKey),
1433
+ );
1434
+ const effectiveCollapsedPaths = persistedCollapsedPaths;
1435
+
966
1436
  // Cap browse-view children (D-C1) whenever a non-first-party/composite
967
1437
  // session is active (an ingested/multi-world tree may be huge), OR while
968
1438
  // playing (a live scene graph can also balloon) — matches the exact
969
1439
  // capping policy the former SceneHierarchy (play-only) / IngestHierarchy
970
1440
  // (always, unless searching) components each had on their own.
971
1441
  const capEnabled = term.length === 0 && (hasAuthoringOverride() || store.playState !== 'stopped');
972
-
973
- const allRows = flattenIngestRows(
974
- adapter.hierarchy,
975
- collapsedKeysRef.current,
1442
+ const temporaryRevealed = mergeRevealCounts(
976
1443
  revealedRef.current,
1444
+ selectedRevealCounts(structuralRows, selectedIds),
1445
+ );
1446
+ const browseRows = flattenIngestRows(
1447
+ adapter.hierarchy,
1448
+ effectiveCollapsedPaths,
1449
+ temporaryRevealed,
977
1450
  capEnabled,
978
1451
  );
1452
+ const matchingPaths = term ? searchPathKeys(structuralRows, term) : null;
1453
+ const sourceRows = matchingPaths
1454
+ ? structuralRows.filter((row) => matchingPaths.has(row.pathKey))
1455
+ : browseRows;
979
1456
  // Session-local world-hide (D9): drop every row belonging to a hidden world
980
1457
  // group (the group row itself stays, dimmed by its own `visible` flag above).
981
- const worldFiltered = allRows.filter((row) => {
1458
+ const worldFiltered = sourceRows.filter((row) => {
982
1459
  if (isCompositeGroupNode(adapter, row.node.id)) return true; // the group row itself always shows
983
1460
  let current: string | null = row.node.parentId;
984
1461
  while (current) {
@@ -988,9 +1465,7 @@ export function GameHierarchy() {
988
1465
  }
989
1466
  return true;
990
1467
  });
991
- const rows = term
992
- ? worldFiltered.filter((r) => r.node.label.toLowerCase().includes(term))
993
- : worldFiltered.filter((r) => !r.hiddenByCollapse);
1468
+ const rows = term ? worldFiltered : worldFiltered.filter((r) => !r.hiddenByCollapse);
994
1469
 
995
1470
  // Wave-18-ported: a filtered-down result set shouldn't leave the viewport
996
1471
  // parked at a stale deep scroll offset from a previous (larger) list.
@@ -1015,12 +1490,45 @@ export function GameHierarchy() {
1015
1490
  const topSpacerPx = start * ROW_HEIGHT;
1016
1491
  const bottomSpacerPx = (rows.length - end) * ROW_HEIGHT;
1017
1492
 
1018
- const toggleOpen = useCallback((pathKey: string) => {
1019
- const set = collapsedKeysRef.current;
1020
- if (set.has(pathKey)) set.delete(pathKey);
1021
- else set.add(pathKey);
1493
+ const toggleOpen = useCallback(
1494
+ (row: IngestRow, recursive: boolean) => {
1495
+ const expanded = !isExpanded(row);
1496
+ const updates: Array<readonly [string, boolean]> = [[row.node.id, expanded]];
1497
+ if (recursive) {
1498
+ const prefix = `${row.pathKey}.`;
1499
+ for (const descendant of structuralRows) {
1500
+ if (descendant.hasChildren && descendant.pathKey.startsWith(prefix)) {
1501
+ updates.push([descendant.node.id, expanded]);
1502
+ }
1503
+ }
1504
+ }
1505
+ for (const [id, value] of updates) sessionExpansionRef.current.set(id, value);
1506
+ setHierarchyExpansionPreferences(projectIdentity, updates);
1507
+ forceUpdate();
1508
+ },
1509
+ [isExpanded, projectIdentity, structuralRows],
1510
+ );
1511
+
1512
+ const setAllExpanded = useCallback(
1513
+ (expanded: boolean) => {
1514
+ const updates = structuralRows
1515
+ .filter((row) => row.hasChildren)
1516
+ .map((row) => [row.node.id, expanded] as const);
1517
+ for (const [id, value] of updates) sessionExpansionRef.current.set(id, value);
1518
+ setHierarchyExpansionPreferences(projectIdentity, updates);
1519
+ forceUpdate();
1520
+ },
1521
+ [projectIdentity, structuralRows],
1522
+ );
1523
+
1524
+ const resetExpansion = useCallback(() => {
1525
+ sessionExpansionRef.current.clear();
1526
+ for (const id of selectedAncestorNodeIds(structuralRows, selectedIds)) {
1527
+ sessionExpansionRef.current.set(id, true);
1528
+ }
1529
+ clearHierarchyExpansionPreferences(projectIdentity);
1022
1530
  forceUpdate();
1023
- }, []);
1531
+ }, [projectIdentity, selectionSignature, structuralRows]);
1024
1532
 
1025
1533
  const revealMore = useCallback((parentPathKey: string) => {
1026
1534
  const m = revealedRef.current;
@@ -1028,14 +1536,11 @@ export function GameHierarchy() {
1028
1536
  forceUpdate();
1029
1537
  }, []);
1030
1538
 
1031
- // A4 (D8) — every root-group member, rebuilt each render straight from
1032
- // `rootGroups.groups()` (never from the id string — see `buildRootMembers`'s
1033
- // doc comment). `handleActivateRoot` is the ONE choke point a double-click
1539
+ // `handleActivateRoot` is the ONE choke point a double-click
1034
1540
  // on an inactive member row goes through, shared with the asset-browser's
1035
1541
  // scene double-click (`DefaultEditorLayout.tsx`'s `handleOpenScene`) via
1036
1542
  // `requestRootActivation` so the dirty-gate/confirmation UX can't drift
1037
1543
  // between the two call sites.
1038
- const rootMembers = buildRootMembers(adapter);
1039
1544
  const handleActivateRoot = useCallback(
1040
1545
  (groupId: string, rootId: string) => {
1041
1546
  requestRootActivation(adapter, groupId, rootId);
@@ -1043,7 +1548,29 @@ export function GameHierarchy() {
1043
1548
  [adapter],
1044
1549
  );
1045
1550
 
1046
- const selectedIds = new Set(adapter.selection?.get() ?? []);
1551
+ // Viewport picks and external editor-control selection reveal the selected
1552
+ // row, including through a previously collapsed path and a virtualized
1553
+ // list. Do this only when the selection or adapter changes; ordinary store
1554
+ // notifications must not fight a user's manual scroll position.
1555
+ useEffect(() => {
1556
+ if (!selectionSignature) {
1557
+ lastAutoRevealRef.current = null;
1558
+ return;
1559
+ }
1560
+ const last = lastAutoRevealRef.current;
1561
+ if (last?.adapter === adapter && last.selection === selectionSignature) return;
1562
+ lastAutoRevealRef.current = { adapter, selection: selectionSignature };
1563
+ const index = rows.findIndex((row) => selectedIds.has(row.node.id));
1564
+ const container = containerRef.current;
1565
+ if (index < 0 || !container) return;
1566
+ const top = index * ROW_HEIGHT;
1567
+ const bottom = top + ROW_HEIGHT;
1568
+ if (top < container.scrollTop) container.scrollTop = top;
1569
+ else if (bottom > container.scrollTop + container.clientHeight) {
1570
+ container.scrollTop = Math.max(0, bottom - container.clientHeight);
1571
+ }
1572
+ setScrollTop(container.scrollTop);
1573
+ }, [adapter, rows, selectedIds, selectionSignature]);
1047
1574
 
1048
1575
  const handleRowClick = useCallback(
1049
1576
  (e: React.MouseEvent, nodeId: string) => {
@@ -1119,12 +1646,21 @@ export function GameHierarchy() {
1119
1646
  const targetId = row.node.id;
1120
1647
  if (draggedIds.includes(targetId)) return;
1121
1648
  if (isDescendantOfAny(targetId, draggedIds, adapter)) return;
1649
+ if (!isSameWorldDrop(adapter, draggedIds, targetId)) {
1650
+ setDropTarget(null);
1651
+ return;
1652
+ }
1122
1653
  }
1123
1654
  if (isCompositeGroupNode(adapter, row.node.id) && zone !== 'child') {
1124
1655
  // A composite world-group row has no siblings of its own kind to
1125
1656
  // reorder against — only "drop into this world" makes sense.
1126
1657
  zone = 'child';
1127
1658
  }
1659
+ if (row.node.role === 'document' || row.node.role === 'story') zone = 'child';
1660
+ if (row.node.role === 'folder' || row.node.role === 'boundary') {
1661
+ setDropTarget(null);
1662
+ return;
1663
+ }
1128
1664
 
1129
1665
  setDropTarget({ nodeId: row.node.id, zone });
1130
1666
  },
@@ -1207,14 +1743,36 @@ export function GameHierarchy() {
1207
1743
  );
1208
1744
 
1209
1745
  const panelName = adapter.persistence?.destination ?? 'Untitled';
1746
+ const compositeHierarchy = adapter instanceof CompositeAuthoringAdapter;
1210
1747
 
1211
1748
  return (
1212
1749
  <Panel
1213
1750
  name={
1214
1751
  <span data-testid="scene-name" data-scene={panelName}>
1215
1752
  {panelName}
1753
+ {/* Spec 29 §5 — a BARE adapter's provenance renders here (its whole
1754
+ panel is one world); a composite's renders per world-group row. */}
1755
+ {!(adapter instanceof CompositeAuthoringAdapter) && adapter.provenance && (
1756
+ <span
1757
+ data-testid="panel-provenance"
1758
+ data-provenance={adapter.provenance.source}
1759
+ title={adapter.provenance.detail}
1760
+ style={{
1761
+ marginLeft: 6,
1762
+ fontSize: 'var(--vgai-font-xs)',
1763
+ fontWeight: 400,
1764
+ padding: `0 ${space[2]}px`,
1765
+ borderRadius: themeVars.shape.small,
1766
+ border: `1px solid ${themeVars.boundary.strong}`,
1767
+ color: themeVars.content.muted,
1768
+ }}
1769
+ >
1770
+ {adapter.provenance.label}
1771
+ </span>
1772
+ )}
1216
1773
  </span>
1217
1774
  }
1775
+ hideHeader={compositeHierarchy}
1218
1776
  onPointerDown={() => setActiveScope('hierarchy')}
1219
1777
  onDragOver={(e) => {
1220
1778
  if (e.dataTransfer.types.includes(ASSET_MIME)) {
@@ -1222,39 +1780,63 @@ export function GameHierarchy() {
1222
1780
  e.dataTransfer.dropEffect = 'copy';
1223
1781
  }
1224
1782
  }}
1225
- style={{ width: 240, borderRight: '1px solid #333' }}
1783
+ // Dockview owns the group's resizable/collapsible persisted width; this
1784
+ // panel fills whatever its current group gives it.
1785
+ style={{
1786
+ flex: 1,
1787
+ minWidth: 0,
1788
+ minHeight: 0,
1789
+ borderRight: `1px solid ${themeVars.boundary.default}`,
1790
+ }}
1226
1791
  >
1792
+ {compositeHierarchy && (
1793
+ <span data-testid="scene-name" data-scene={panelName} style={{ display: 'none' }}>
1794
+ {panelName}
1795
+ </span>
1796
+ )}
1227
1797
  <OverlayReportBanner />
1228
- <div
1798
+ <EditorToolbar
1799
+ label="Hierarchy controls"
1800
+ compact
1229
1801
  style={{
1230
- padding: '4px 8px',
1231
- borderBottom: '1px solid #333',
1232
- display: 'flex',
1233
- alignItems: 'center',
1234
1802
  gap: 4,
1235
1803
  flexShrink: 0,
1236
1804
  }}
1237
1805
  >
1238
- <input
1806
+ {/* L-6 — `.vgai-input` supplies a real `:focus-visible` border/outline
1807
+ (this box previously did `outline:'none'` with no replacement). */}
1808
+ <TextInput
1239
1809
  type="text"
1240
1810
  data-testid="ingest-search"
1241
1811
  placeholder="Search..."
1812
+ className="vgai-input"
1242
1813
  value={search}
1243
1814
  onChange={(e) => setSearch(e.target.value)}
1244
1815
  style={{
1245
1816
  flex: 1,
1246
1817
  minWidth: 0,
1247
- background: '#1e1e1e',
1248
- border: '1px solid #333',
1249
- borderRadius: 3,
1250
- color: '#c5c8ce',
1251
- fontSize: 11,
1252
- padding: '3px 6px',
1253
- outline: 'none',
1254
1818
  }}
1255
1819
  />
1256
- <CreateMenu adapter={adapter} parentId={null} />
1257
- </div>
1820
+ <ViewButton
1821
+ label="Collapse all"
1822
+ icon={faMinus}
1823
+ testId="hierarchy-collapse-all"
1824
+ onClick={() => setAllExpanded(false)}
1825
+ />
1826
+ <ViewButton
1827
+ label="Reset to default openness"
1828
+ icon={faRotateLeft}
1829
+ testId="hierarchy-reset-expansion"
1830
+ onClick={resetExpansion}
1831
+ />
1832
+ <ViewButton
1833
+ label="Expand all"
1834
+ icon={faPlus}
1835
+ testId="hierarchy-expand-all"
1836
+ onClick={() => setAllExpanded(true)}
1837
+ />
1838
+ <CreateMenu adapter={adapter} parentId={createTargetId} />
1839
+ </EditorToolbar>
1258
1840
 
1259
1841
  <div
1260
1842
  ref={containerRef}
@@ -1289,10 +1871,11 @@ export function GameHierarchy() {
1289
1871
  store={store}
1290
1872
  isSelected={selectedIds.has(row.node.id)}
1291
1873
  isEditing={editingId === row.node.id}
1292
- isCollapsed={collapsedKeysRef.current.has(row.pathKey)}
1874
+ isCollapsed={term ? false : effectiveCollapsedPaths.has(row.pathKey)}
1293
1875
  dropZone={dropTarget && dropTarget.nodeId === row.node.id ? dropTarget.zone : null}
1294
1876
  isDragged={draggedIds?.includes(row.node.id) ?? false}
1295
1877
  rootMember={rootMembers.get(row.node.id)}
1878
+ focusedWorldId={focusedViewportWorldId}
1296
1879
  searchTerm={term}
1297
1880
  onToggleOpen={toggleOpen}
1298
1881
  onStartEditing={setEditingId}
@@ -1313,9 +1896,9 @@ export function GameHierarchy() {
1313
1896
  right: 0,
1314
1897
  top: rows.length * ROW_HEIGHT,
1315
1898
  height: 2,
1316
- background: '#579EFF',
1899
+ background: themeVars.accent.default,
1317
1900
  pointerEvents: 'none',
1318
- zIndex: 10,
1901
+ zIndex: zIndex.overlayLow,
1319
1902
  }}
1320
1903
  />
1321
1904
  )}
@@ -1324,13 +1907,13 @@ export function GameHierarchy() {
1324
1907
  {contextMenu &&
1325
1908
  (() => {
1326
1909
  const menuNode = adapter.hierarchy.node(contextMenu.nodeId);
1327
- // A4 (D8): a root-group member or the game root gets the same
1910
+ // A4 (D8): a root-group member gets the same
1328
1911
  // "no structural affordances" treatment as an existing composite
1329
1912
  // group row — none of the three own a name/visible/locked path or
1330
1913
  // a meaningful Create/Duplicate/Delete target.
1331
1914
  const isGroupNode = menuNode
1332
1915
  ? isCompositeGroupNode(adapter, menuNode.id) ||
1333
- isGameRootNode(adapter, menuNode.id) ||
1916
+ isCompositeOrganizationNode(adapter, menuNode.id) ||
1334
1917
  rootMembers.has(menuNode.id)
1335
1918
  : false;
1336
1919
  return (