@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,2110 @@
1
+ /**
2
+ * CompositeAuthoringAdapter — merges N per-world {@link AuthoringAdapter}s into ONE authoring
3
+ * tree so the editor's hierarchy lists EVERY mounted world's entities and selection/inspection
4
+ * routes to the right backend. This is the editor-multi-root-selection seam, generalized to N-ary
5
+ * (T6.1 slice 3): one top-level GROUP NODE per world
6
+ * (`world:<id> (<kind>)`), the child adapter's own roots nested beneath it. The project/game
7
+ * identity already lives in the editor chrome, so the authoring projection starts at those real
8
+ * world roots — it does not add a redundant synthetic Game row. Routing is by GROUP-NODE
9
+ * OWNERSHIP: the child adapter whose tree contains a given node id.
10
+ *
11
+ * The editor still talks to a single {@link AuthoringAdapter}; selection +
12
+ * inspection work in every world; the 3D transform gizmo stays threejs-only (a
13
+ * viewport concern, outside this adapter); play/pause stays game-level (T7.6).
14
+ */
15
+
16
+ import type {
17
+ AssetDropProvider,
18
+ AssetSubjectProvider,
19
+ AuthoringAdapter,
20
+ AuthoringCapabilities,
21
+ ComponentInstancesProvider,
22
+ EditorNode,
23
+ HierarchyProvider,
24
+ InspectorProvider,
25
+ NodeCreationSite,
26
+ PersistenceProvider,
27
+ PropertyDescriptor,
28
+ RelatedSubjectsProvider,
29
+ SelectionProvider,
30
+ SpatialHandlesProvider,
31
+ StoriesProvider,
32
+ StoryRef,
33
+ StructureProvider,
34
+ Transform,
35
+ TransformProvider,
36
+ TruthProvider,
37
+ WriteAck,
38
+ } from '@volter/editor-project/adapter';
39
+ import type * as THREE from 'three';
40
+ import { recordAuthoringConsumerUse } from '../coverage/authoring-seam-evidence';
41
+ import { NO_OBJECT_REASON } from '../creation-site-registry';
42
+ import { editorConsole } from '../editor-console';
43
+ import type { HierarchyProjection, HierarchyProjectionGroup } from '../hierarchy-projection';
44
+ import { forEachHierarchyNode } from '../hierarchy-walk';
45
+ import {
46
+ applyAuthoringInstanceToComponent,
47
+ applyAuthoringTransform,
48
+ beginAuthoringTransformEdit,
49
+ commitAuthoringTransformSource,
50
+ copyAuthoringNodes,
51
+ createAuthoringNode,
52
+ cutAuthoringNodes,
53
+ dropAuthoringAsset,
54
+ duplicateAuthoringNode,
55
+ endAuthoringTransformEdit,
56
+ groupAuthoringNodes,
57
+ pasteAuthoringNodes,
58
+ removeAuthoringNode,
59
+ removeAuthoringTransform,
60
+ removeManyAuthoringNodes,
61
+ reorderAuthoringNode,
62
+ reparentAuthoringNode,
63
+ revertAuthoringInstance,
64
+ saveAuthoringDocument,
65
+ setAuthoringSelection,
66
+ ungroupAuthoringNode,
67
+ unwrapAuthoringNode,
68
+ wrapAuthoringNode,
69
+ } from './consumer-actions';
70
+ import { WORLD_SCOPE_NODE_ID } from './stories-scope';
71
+ import { LIVE_ONLY_ACK, NO_PERSISTABLE_CHILD_DESTINATION } from './write-pipe';
72
+
73
+ /** One child world's authoring adapter, ordered as given to the constructor. */
74
+ export interface CompositeChild {
75
+ /** Manifest world id (T3.1) — becomes the group node id `world:<worldId>`. */
76
+ readonly worldId: string;
77
+ /** World render substrate kind — shown in the group node's label. */
78
+ readonly kind: string;
79
+ /** Runtime manifest roots are the default. A nested authoring surface is
80
+ * adapter-owned content of its parent world, not another runtime world. */
81
+ readonly role?: 'world' | 'surface';
82
+ /** Optional user-facing label for a nested authoring surface. */
83
+ readonly label?: string;
84
+ readonly adapter: AuthoringAdapter;
85
+ /**
86
+ * D12 (B4) — the world's INSTALL-TIME manifest zOrder (`world.zOrder ?? 0`),
87
+ * independent of this child's position in the constructor's array. Absent
88
+ * (every pre-B4 call site) ⇒ `childAdapters()` falls back to the array
89
+ * index, today's behavior unchanged. See `childAdapters()`'s own doc
90
+ * comment for why this stopped being "array order doubles as z-order" and
91
+ * what "install-time" means here.
92
+ */
93
+ readonly zOrder?: number;
94
+ /** Manifest pausing policy, retained for read-only play-mode inspection. */
95
+ readonly pausable?: boolean;
96
+ /** Adapter-owned content declaration, retained for play-mode inspection. */
97
+ readonly content?: string;
98
+ /**
99
+ * Nest this child's group node UNDER another child's group node instead
100
+ * of directly in the hierarchy root list. Used by the scene-UI child
101
+ * (`edit-mode-authoring.ts`'s UI child, `kind: 'scene-ui'`) to appear
102
+ * "under its owning world" in the ONE hierarchy, per the C3 spec text.
103
+ * Absent ⇒ today's flat top-level shape, unchanged.
104
+ */
105
+ readonly parentRootId?: string;
106
+ }
107
+
108
+ function childAssetEntries(adapter: AuthoringAdapter) {
109
+ const provider = adapter.assetSubject;
110
+ if (!provider) return [];
111
+ const declared = provider.entries?.();
112
+ if (declared) return declared;
113
+ const entries: Array<{
114
+ id: string;
115
+ subject: NonNullable<ReturnType<AssetSubjectProvider['get']>>;
116
+ }> = [];
117
+ forEachHierarchyNode(adapter.hierarchy, (node) => {
118
+ const subject = provider.get(node.id);
119
+ if (subject) entries.push({ id: node.id, subject });
120
+ });
121
+ return entries;
122
+ }
123
+
124
+ const GROUP_PREFIX = 'world:';
125
+ const KIND_PREFIX = 'kind:';
126
+ const PROJECTION_PREFIX = 'projection:';
127
+
128
+ /**
129
+ * Display label per root KIND — what the host hands the root, never which
130
+ * library the root chose.
131
+ *
132
+ * This used to read `pixijs: 'PixiJS'` / `react: 'React'`, which the kind
133
+ * vocabulary makes a lie: a `canvas` root may be Pixi, Phaser, Babylon, raw
134
+ * WebGL/WebGPU or a 2D context, and a `dom` root may be React, Vue, Svelte or
135
+ * plain HTML. The host cannot know, and labelling it "PixiJS" would tell a
136
+ * Phaser author something false about their own game.
137
+ *
138
+ * `three` is the exception on purpose — that kind is DEFINED by the shared
139
+ * `three` instance the host provides, so naming it is honest.
140
+ *
141
+ * An adapter that wants to say "Pixi" here can: it knows what it is, and the
142
+ * label belongs to it rather than to this table.
143
+ */
144
+ const KIND_LABELS: Readonly<Record<string, string>> = {
145
+ three: 'Three.js',
146
+ canvas: 'Canvas',
147
+ dom: 'DOM',
148
+ };
149
+
150
+ function groupNodeId(worldId: string): string {
151
+ return `${GROUP_PREFIX}${worldId}`;
152
+ }
153
+
154
+ /**
155
+ * The refusal sentence for an id no child owns — named here (not inlined at
156
+ * each call site) so the console message, the thrown `transforms.get` error and
157
+ * the `editor.select` door all say the SAME thing: which resolver rejected it,
158
+ * which id, and which worlds actually exist.
159
+ */
160
+ export function unownedIdRefusal(
161
+ operation: string,
162
+ id: string,
163
+ children: ReadonlyArray<{ readonly worldId: string }>,
164
+ ): string {
165
+ return (
166
+ `[CompositeAuthoringAdapter] ${operation}: no root owns entity id "${id}". ` +
167
+ `Roots in this composition: ${children.map((c) => c.worldId).join(', ')}. ` +
168
+ `Refusing rather than answering from the first root — an id nothing owns has no subject.`
169
+ );
170
+ }
171
+
172
+ /**
173
+ * The refusal sentence for an id a child DOES own but has no transform truth
174
+ * for — the other half of {@link unownedIdRefusal}. Ownership answers "which
175
+ * root", not "does that root pose this node": a child adapter may expose no
176
+ * `TransformProvider` at all (the interface makes it optional), and answering
177
+ * an identity pose there is a fabricated subject, not a degrade. `dimensions`
178
+ * already returns `null` for this case so the Transform section never renders;
179
+ * this is what the non-nullable `get` says when a caller reaches it anyway.
180
+ */
181
+ export function untransformedIdRefusal(operation: string, id: string, worldId: string): string {
182
+ return (
183
+ `[CompositeAuthoringAdapter] ${operation}: root "${worldId}" owns entity id "${id}" but ` +
184
+ 'exposes no transform provider for it. Refusing rather than answering an identity pose — ' +
185
+ 'a node with no transform truth has no pose to report.'
186
+ );
187
+ }
188
+
189
+ /** True for a synthetic group-node id this Composite itself manufactures (never
190
+ * produced by a child adapter — the `world:` prefix is reserved for this use). */
191
+ function isGroupNodeId(id: string): boolean {
192
+ return id.startsWith(GROUP_PREFIX);
193
+ }
194
+
195
+ function kindNodeId(kind: string): string {
196
+ return `${KIND_PREFIX}${kind}`;
197
+ }
198
+
199
+ function projectionNodeId(id: string): string {
200
+ return `${PROJECTION_PREFIX}${id}`;
201
+ }
202
+
203
+ function isOrganizationNodeId(id: string): boolean {
204
+ return id.startsWith(KIND_PREFIX) || id.startsWith(PROJECTION_PREFIX);
205
+ }
206
+
207
+ function humanizeId(id: string): string {
208
+ const words = id.replace(/[-_]+/g, ' ').trim();
209
+ return words ? words.replace(/\b\w/g, (letter) => letter.toUpperCase()) : id;
210
+ }
211
+
212
+ /**
213
+ * Manifest-backed properties on each authored root, implemented by
214
+ * `edit-mode-authoring.ts`'s `ManifestAuthoring` over the RAW `vgai.project.json`
215
+ * (read-modify-write, never round-tripped through Zod). Defined here (not
216
+ * imported from `edit-mode-authoring.ts`) to avoid a circular import — that
217
+ * module already imports `CompositeAuthoringAdapter`.
218
+ */
219
+ export interface RootManifestProvider {
220
+ /** Adapter-owned content declaration shown on the world boundary (for
221
+ * example `Entry · src/world.tsx` or `Entry · src/ui/App.tsx`). */
222
+ getRootContentSource?(worldId: string): string | undefined;
223
+ getRootZOrder(worldId: string): number;
224
+ setRootZOrder(worldId: string, value: number): void;
225
+ getRootPausable(worldId: string): boolean;
226
+ setRootPausable(worldId: string, value: boolean): void;
227
+ }
228
+
229
+ /** OR every child's capabilities together — a feature is offered if ANY
230
+ * child supports it (the editor gates per-node via the routed adapter
231
+ * anyway). Factored out of the constructor (B1) so {@link
232
+ * CompositeAuthoringAdapter.replaceChild} can recompute it after an
233
+ * in-place child swap without duplicating the reduce. */
234
+ function computeCapabilities(children: ReadonlyArray<CompositeChild>): AuthoringCapabilities {
235
+ return children.reduce<AuthoringCapabilities>(
236
+ (acc, c) => ({
237
+ transform: acc.transform || c.adapter.capabilities.transform,
238
+ inspectorFields: acc.inspectorFields || c.adapter.capabilities.inspectorFields,
239
+ persist: acc.persist || c.adapter.capabilities.persist,
240
+ }),
241
+ {
242
+ transform: false,
243
+ inspectorFields: false,
244
+ persist: false,
245
+ },
246
+ );
247
+ }
248
+
249
+ export class CompositeAuthoringAdapter implements AuthoringAdapter {
250
+ /** NOT `readonly` (interface-level `readonly` is a consumer contract, not a
251
+ * ban on internal reassignment) — B1's {@link replaceChild} recomputes
252
+ * this after an in-place child swap (e.g. a world's design-time layer
253
+ * mounting successfully upgrades its Boundary adapter to a live one). */
254
+ capabilities: AuthoringCapabilities;
255
+ /**
256
+ * NOT `ReadonlyArray` (B1): {@link replaceChild} mutates ONE element of
257
+ * this array in place, preserving both the array's own identity-adjacent
258
+ * invariants (length, index/z-order) and — critically — THIS composite's
259
+ * OWN object identity, which `world-root-stage.ts`'s teardown-identity
260
+ * capture, `exitEditModeAuthoring`'s installed-instance check, and the
261
+ * `markEditModeOverride` brand all key off. A REBUILT composite (a new
262
+ * `CompositeAuthoringAdapter` instance) would silently break all three —
263
+ * see this class's own doc comment and `edit-mode-authoring.ts`'s. Copied
264
+ * (`[...children]`) out of the constructor's `ReadonlyArray` parameter so
265
+ * mutating it here never aliases a caller's own array.
266
+ */
267
+ private readonly children: CompositeChild[];
268
+ /** Optional writable manifest surface for world composition properties.
269
+ * Runtime still has one semantic Game root; the authoring hierarchy does not
270
+ * mirror that project-level wrapper because the editor chrome already owns it. */
271
+ private readonly manifest: RootManifestProvider | null;
272
+ /** Optional editor-only organization; never consulted by runtime mounting. */
273
+ private readonly projection: HierarchyProjection | null;
274
+ private readonly resolvedProjectionGroups: Array<HierarchyProjectionGroup & { roots: string[] }>;
275
+ /** Which child owns the session's last successful structural copy/cut. The
276
+ * payload itself stays inside that adapter; the composite remembers only the
277
+ * routing fact needed after selection moves. */
278
+ private structureClipboardWorldId: string | null = null;
279
+ /** Cross-root authored edges rebuilt once at the start of each hierarchy read. */
280
+ private crossSurfaceParents = new Map<string, string>();
281
+ private crossSurfaceChildren = new Map<string, string[]>();
282
+ private crossSurfaceOrders = new Map<string, number>();
283
+ private crossSurfaceRootParents = new Map<string, string>();
284
+ private crossSurfaceRootChildren = new Map<string, string[]>();
285
+ private crossSurfaceGroupLabels = new Map<string, string>();
286
+ /** A DOM semantic host may render OID-stamped implementation elements
287
+ * between source-owned semantic rows. These maps make those wrappers
288
+ * transparent only inside an explicitly identified cross-surface subtree. */
289
+ private crossSurfaceDomParents = new Map<string, string>();
290
+ private crossSurfaceDomChildren = new Map<string, string[]>();
291
+ private crossSurfaceInputSignatures: ReadonlyArray<string | null> | null = null;
292
+ /** Stable panel views for a native root that belongs to one explicit
293
+ * cross-surface semantic document. The proxy changes only `hierarchy`; every
294
+ * provider and capability remains this composite's live routed seam. */
295
+ private readonly projectedDocumentAdapters = new Map<string, AuthoringAdapter>();
296
+
297
+ /** Stable routing object. A composite can gain/lose a capable child through
298
+ * `replaceChild`, so its provider remains present and honestly returns no
299
+ * layers while none of the current children implements the seam. */
300
+ readonly spatialHandles: SpatialHandlesProvider = {
301
+ layers: (id) => this.routeOwned(id, 'spatialHandles.layers')?.spatialHandles?.layers(id) ?? [],
302
+ preview: (id, handleId, worldPosition) => {
303
+ this.routeOwned(id, 'spatialHandles.preview')?.spatialHandles?.preview(
304
+ id,
305
+ handleId,
306
+ worldPosition,
307
+ );
308
+ },
309
+ commit: (id, handleId, worldPosition) =>
310
+ this.routeOwned(id, 'spatialHandles.commit')?.spatialHandles?.commit(
311
+ id,
312
+ handleId,
313
+ worldPosition,
314
+ ),
315
+ };
316
+
317
+ /**
318
+ * N-ary (T6.1 slice 3): an ordered `{ worldId, kind, adapter }[]` — one group
319
+ * node per entry, in array order.
320
+ *
321
+ * `manifest` is an optional writable provider. Omitting it leaves root
322
+ * composition properties read-only.
323
+ */
324
+ constructor(
325
+ children: ReadonlyArray<CompositeChild>,
326
+ manifest?: RootManifestProvider,
327
+ projection?: HierarchyProjection,
328
+ ) {
329
+ this.children = [...children];
330
+ this.manifest = manifest ?? null;
331
+ this.projection = projection ?? null;
332
+ // Zero children is a real state (ARCHITECTURE-CORE §Roots: a project may
333
+ // declare no roots); every answer below degrades to its honest empty one.
334
+ this.resolvedProjectionGroups = this.resolveProjectionGroups();
335
+ this.capabilities = computeCapabilities(this.children);
336
+ this.refreshTruth();
337
+ this.refreshRelated();
338
+ }
339
+
340
+ /**
341
+ * B1 — swap `worldId`'s child adapter IN PLACE (the design-time layer
342
+ * mount's "adapter upgrade": a world's read-only `BoundaryAuthoringAdapter`
343
+ * is replaced by a live one — react: `ReactRootAuthoringAdapter` — once its
344
+ * layer mounts successfully; a layer that fails/throws rebuilds the
345
+ * Boundary with the caught reason, also through this method). Mutates the
346
+ * existing children array (preserving array order — z-order/`childAdapters()`
347
+ * index is unaffected) and recomputes `capabilities`, rather than
348
+ * constructing a new `CompositeAuthoringAdapter` — a rebuild would break
349
+ * `world-root-stage.ts`'s captured teardown identity, `exitEditModeAuthoring`'s
350
+ * installed-instance check, and the `markEditModeOverride` brand, all of
351
+ * which key off THIS instance's identity (see the class doc comment).
352
+ *
353
+ * No-op (loud warn, #18 discipline) when `worldId` doesn't name an existing
354
+ * child — never silently routes to the wrong world or grows the array.
355
+ * Callers are responsible for their own side effects around the swap
356
+ * (`store.notifyIngestEdit()` so panels re-render against the new child) —
357
+ * this method only owns the swap + capability recompute.
358
+ */
359
+ replaceChild(worldId: string, adapter: AuthoringAdapter): void {
360
+ const index = this.children.findIndex((c) => c.worldId === worldId);
361
+ if (index === -1) {
362
+ editorConsole.warn(
363
+ `[CompositeAuthoringAdapter] replaceChild: no child with worldId "${worldId}" — ignoring.`,
364
+ 'authoring',
365
+ );
366
+ return;
367
+ }
368
+ // D12 (B4) — carry the outgoing child's `zOrder` forward too (not just
369
+ // `kind`): a swap must not silently reset a world's install-time zOrder
370
+ // back to "no override" (array-index fallback). Built conditionally (not
371
+ // `{ ..., zOrder }`) because `zOrder` is an OPTIONAL property under this
372
+ // repo's `exactOptionalPropertyTypes` — an explicit `zOrder: undefined`
373
+ // is not the same as an absent property to the type checker.
374
+ const previous = this.children[index]!;
375
+ this.children[index] = { ...previous, worldId, adapter };
376
+ this.crossSurfaceInputSignatures = null;
377
+ this.capabilities = computeCapabilities(this.children);
378
+ // The swap can bring a truth resolver in (a Boundary upgrading to a
379
+ // live adapter) or take the last one away — same recompute, same reason.
380
+ this.refreshTruth();
381
+ this.refreshRelated();
382
+ // An id the outgoing child did not own may be owned by the incoming one.
383
+ this.refusedIds.clear();
384
+ // Re-point every live subscription at the NEW child set, then tell the
385
+ // subscribers — the swap is itself the "this world finished mounting"
386
+ // structure change (see the change fan-out section below).
387
+ this.syncChildFanOut();
388
+ this.fanOutStructure();
389
+ }
390
+
391
+ /** The group node id for a given child (exposed for callers building
392
+ * post-construction selection/expansion state — e.g. tests, e2e hooks). */
393
+ groupNodeId(worldId: string): string {
394
+ return groupNodeId(worldId);
395
+ }
396
+
397
+ /** True for a read-only shell node manufactured only for hierarchy layout. */
398
+ isOrganizationNode(nodeId: string): boolean {
399
+ return isOrganizationNodeId(nodeId);
400
+ }
401
+
402
+ /** The child that owns `id` — by ACTUAL ownership (its hierarchy resolves the
403
+ * id), not by any prefix convention. `null` for a group-node id or an id no
404
+ * child recognizes. O(children) per call; fine at editor-UI scale, and avoids
405
+ * a cache that could go stale as a live tree adds/removes nodes. */
406
+ private findOwnerChild(id: string): CompositeChild | null {
407
+ if (isGroupNodeId(id) || isOrganizationNodeId(id)) return null;
408
+ for (const child of this.children) {
409
+ if (child.adapter.hierarchy.node(id)) return child;
410
+ }
411
+ return null;
412
+ }
413
+
414
+ /** Ids already refused, so a bogus selection that survives across renders logs
415
+ * its refusal ONCE instead of once per row per notify. Cleared whenever the
416
+ * child set changes ({@link replaceChild}) — the same id can become real. */
417
+ private readonly refusedIds = new Set<string>();
418
+
419
+ /**
420
+ * Every real id this composite has ever OBSERVED or ROUTED successfully — the memory that
421
+ * separates "a row from a previous mount epoch" from "a subject that never
422
+ * existed", so only the second one is an error.
423
+ *
424
+ * The refusal below exists for a subject that does not exist
425
+ * (`editor.select('Player')`). An id that a PREVIOUS mount epoch really did
426
+ * own is a different fact: the row existed, the caller read it from this
427
+ * composite's own hierarchy, and then Play (or a design-time layer upgrade)
428
+ * swapped the child underneath it. That is the ordinary shape of a remount,
429
+ * and ARCHITECTURE-CORE says so directly — "receipts from an old adapter
430
+ * object/mount epoch never grade the replacement".
431
+ *
432
+ * It is unavoidable for an ingest root specifically, and that is what made it
433
+ * visible: an ingested world has no source oids, so `projection/three.ts`
434
+ * mints `live:<worldId>:<n>` from a session counter against the Object3D
435
+ * IDENTITIES it walked. A remount builds new Object3Ds, so every id is new by
436
+ * construction — nothing can carry them across, the way `r3f:<worldId>:<oid>`
437
+ * carries across for a source-stamped world.
438
+ *
439
+ * MEASURED on the vendored `racing-game` ingest: six
440
+ * `[CompositeAuthoringAdapter] inspector.get: no root owns entity id
441
+ * "live:racing-game:N"` errors in three of four `vgai doctor` runs, every one
442
+ * of them within two seconds of ▶ — `GameHierarchy` re-rendering rows it read
443
+ * before the swap against the children that came after it, one render before
444
+ * its own row rebuild lands. (Flaky precisely because it is a race.)
445
+ *
446
+ * Recorded when this hierarchy resolves a row and when a provider routes it,
447
+ * rather than snapshotted from the outgoing child at {@link replaceChild},
448
+ * and that is not a shortcut: both swap paths dispose
449
+ * the old mount BEFORE they replace it (`design-time-layers.ts`'s
450
+ * `suspendForPlay` calls `disposeEverything()` first; `r3f-design-session.ts`
451
+ * calls `disposeMounted()` first), so walking the outgoing adapter's
452
+ * hierarchy at swap time would read a disposed stage. Hierarchy-time capture
453
+ * is essential: a consumer can resolve/render a row and lose the race to the
454
+ * swap before its FIRST provider request (`hierarchy.object3D` exposed this
455
+ * exact ordering in Doctor). Provider-time capture remains for callers that
456
+ * route a real id without first resolving its row. Both cost one `Set.add`.
457
+ *
458
+ * Never cleared: an id this composite has SEEN is a real id forever, and the
459
+ * set is bounded by the rows one session actually mounted.
460
+ */
461
+ private readonly supersededIds = new Set<string>();
462
+
463
+ /**
464
+ * ONE resolver for "which child owns this id?", and the ONLY door into a
465
+ * child adapter for a caller holding a raw id.
466
+ *
467
+ * There used to be a floor here: an id no child owned fell through to
468
+ * `children[0]`, "so callers that pass a bogus id get inert/default responses
469
+ * rather than a throw". They did not. The first child answered AS IF it owned
470
+ * the id, and the composite's own `transforms.get` `??`-default then handed
471
+ * back `{position:[0,0,0], rotation:[0,0,0,1], scale:[1,1,1]}` — so
472
+ * `editor.select('Player')`, a string no world has ever heard of, produced a
473
+ * fully-populated inspector reading all zeros. That is the anti-shim rule's
474
+ * exact failure: the adapter fabricated first-party data for a subject that
475
+ * does not exist. An id nothing owns is now REFUSED, loudly and by name, and
476
+ * every caller below degrades to its honest empty answer.
477
+ */
478
+ private routeOwned(id: string, operation: string): AuthoringAdapter | null {
479
+ const owner = this.findOwnerChild(id);
480
+ if (owner) {
481
+ // This id is real, now. Remembering that is what lets a later miss on it
482
+ // be read as a superseded row rather than a bogus subject — see
483
+ // {@link supersededIds}.
484
+ this.supersededIds.add(id);
485
+ return owner.adapter;
486
+ }
487
+ // A row THIS COMPOSITE MANUFACTURED is not a bogus id. A group node has no
488
+ // live object by construction — that is what makes it a group node — so
489
+ // "no child owns it" is the expected answer here, and the caller's own
490
+ // empty degrade is the right one. The refusal above exists for a subject
491
+ // that does not exist (`editor.select('Player')`); shouting it for the
492
+ // composite's own root row says something false about the editor to the
493
+ // person who merely clicked that row. Every provider with a real answer
494
+ // for these rows already intercepts them ahead of this call
495
+ // (`rootProperties`, `inspector.get`/`set`/`editability`); the rest —
496
+ // `spatialHandles`, `transforms`, `instances`, `assetDrop`, `stories` —
497
+ // have nothing to hand back, which is exactly `null`.
498
+ //
499
+ // MEASURED as two console errors in one session on a packaged build over
500
+ // a canvas ingest root with a DOM UI beside it — `inspector.editability`
501
+ // and then, once that one answered for itself, `spatialHandles.layers` —
502
+ // on the same id, `world:probe-canvas:canvas`. Fixing the providers one at
503
+ // a time was fixing instances of this.
504
+ //
505
+ // A synthetic id naming a row this composition does NOT have still
506
+ // refuses: a stale selection outliving a root removal, or a projection
507
+ // group deleted from the manifest, is a subject nothing owns — exactly
508
+ // what the refusal is for. So the quiet answer is gated on the row
509
+ // EXISTING, not on the id's prefix: `world:` against the current children,
510
+ // `kind:`/`projection:` against `hierarchy.node`, which is this class's own
511
+ // answer to "is this a row of mine?" (a `kind:` node exists only where a
512
+ // kind has >1 unprojected root; a `projection:` node only for a group the
513
+ // manifest still declares). Asking the hierarchy rather than restating its
514
+ // rules is what keeps the two from drifting; both branches are O(children)
515
+ // and walk no child scene.
516
+ if (this.groupChild(id) || (isOrganizationNodeId(id) && this.hierarchy.node(id))) return null;
517
+ // An id a replaced child really did own is a superseded row, not a subject
518
+ // that never existed — quiet, and the caller's own empty degrade is right.
519
+ if (this.supersededIds.has(id)) return null;
520
+ if (!this.refusedIds.has(id)) {
521
+ this.refusedIds.add(id);
522
+ editorConsole.error(unownedIdRefusal(operation, id, this.children), 'authoring');
523
+ }
524
+ return null;
525
+ }
526
+
527
+ /** PUBLIC ownership query (T0 — A3's inspector dispatch and B4's pick routing
528
+ * both need it): the `worldId` of the child that owns `id`, or `null` for a
529
+ * group-node id or an id no child recognizes. */
530
+ ownerOf(id: string): string | null {
531
+ return this.findOwnerChild(id)?.worldId ?? null;
532
+ }
533
+
534
+ /**
535
+ * PUBLIC read-only view of the wrapped children (T0), ordered as constructed.
536
+ *
537
+ * `zOrder` is EACH CHILD'S OWN `CompositeChild.zOrder` (the world's
538
+ * install-time manifest zOrder) when supplied, falling back to the array
539
+ * index otherwise. Array order is NOT z-order (D12/B4 correction of this
540
+ * doc comment's former claim): children are built in MANIFEST DECLARATION
541
+ * order (`edit-mode-authoring.ts`), not stacking order — paint/pick order is
542
+ * `stackOrder` over each child's manifest zOrder (mirroring
543
+ * `create-runtime.ts`'s real stacking pass and `design-time-layers.ts`'s
544
+ * layer z-index assignment), which is exactly why this field exists.
545
+ * "Install-time" — the zOrder captured when this composite/child was built,
546
+ * not a live subscription: a world's zOrder edited afterward (the generic
547
+ * inspector's `zOrder` property, `A4`/D8) does NOT retroactively reorder an
548
+ * already-mounted design-time layer stack or an already-computed pick walk
549
+ * order this call returns; both settle to the new order the next time the
550
+ * composite/layers are rebuilt. Callers that need the LIVE manifest value
551
+ * (e.g. `GameHierarchy.tsx`'s zOrder badge) read it from the manifest
552
+ * surface directly instead of trusting this field alone — see
553
+ * `compositeGroupBadge`'s own comment.
554
+ */
555
+ childAdapters(): ReadonlyArray<{
556
+ worldId: string;
557
+ adapter: AuthoringAdapter;
558
+ kind: string;
559
+ zOrder: number;
560
+ role: 'world' | 'surface';
561
+ label?: string;
562
+ parentRootId?: string;
563
+ }> {
564
+ return this.children.map((c, index) => ({
565
+ worldId: c.worldId,
566
+ adapter: c.adapter,
567
+ kind: c.kind,
568
+ zOrder: c.zOrder ?? index,
569
+ role: c.role ?? 'world',
570
+ ...(c.label !== undefined ? { label: c.label } : {}),
571
+ ...(c.parentRootId !== undefined ? { parentRootId: c.parentRootId } : {}),
572
+ }));
573
+ }
574
+
575
+ /**
576
+ * The semantic document containing `worldId`, when the manifest explicitly
577
+ * groups that native root with at least one other surface.
578
+ *
579
+ * A Scene panel ordinarily scopes to its native Three child. That loses an
580
+ * authored DOM/canvas child whose semantic parent is in Three, even though
581
+ * this composite already owns the exact cross-surface edge. This view keeps
582
+ * all provider routing on the composite and narrows only the hierarchy roots
583
+ * to the declared group, so unrelated scenes remain outside the document.
584
+ */
585
+ projectedDocumentAdapter(worldId: string): AuthoringAdapter | null {
586
+ const group = this.resolvedProjectionGroups.find(
587
+ (candidate) => candidate.roots.length > 1 && candidate.roots.includes(worldId),
588
+ );
589
+ if (!group) return null;
590
+ const cached = this.projectedDocumentAdapters.get(group.id);
591
+ if (cached) return cached;
592
+
593
+ const hierarchy: HierarchyProvider = {
594
+ ...this.hierarchy,
595
+ roots: () => {
596
+ this.ensureCrossSurfaceEdges();
597
+ return [this.projectionGroupNode(group)];
598
+ },
599
+ };
600
+ const projected = new Proxy(this, {
601
+ get: (target, property) =>
602
+ property === 'hierarchy' ? hierarchy : Reflect.get(target, property, target),
603
+ });
604
+ this.projectedDocumentAdapters.set(group.id, projected);
605
+ return projected;
606
+ }
607
+
608
+ /**
609
+ * C3 — a child declared with `parentRootId` nests its group node UNDER
610
+ * that parent's group node instead of at the top level: the parent's
611
+ * `parentId` resolution below picks up the nested group id automatically
612
+ * (`this.children.find(...).parentRootId`), and this method also appends
613
+ * every child NESTED under `worldId` (in declaration order) to the END of
614
+ * `childIds`, after `worldId`'s own adapter roots — so e.g. a `scene-ui`
615
+ * child appears as a trailing "ui (scene-ui)" row under its owning world.
616
+ */
617
+ private groupNode(worldId: string, kind: string, childRoots: EditorNode[]): EditorNode {
618
+ const self = this.children.find((c) => c.worldId === worldId);
619
+ const parentId = self?.parentRootId
620
+ ? groupNodeId(self.parentRootId)
621
+ : this.parentForTopLevelRoot(worldId, kind);
622
+ const nestedGroupIds = this.children
623
+ .filter((c) => c.parentRootId === worldId)
624
+ .map((c) => groupNodeId(c.worldId));
625
+ return {
626
+ id: groupNodeId(worldId),
627
+ label:
628
+ self?.role === 'surface'
629
+ ? (self.label ?? `${worldId} (${kind})`)
630
+ : this.labelForRoot(worldId, kind),
631
+ role: self?.role === 'surface' ? 'document' : 'root',
632
+ kind,
633
+ parentId,
634
+ childIds: [...childRoots.map((r) => r.id), ...nestedGroupIds],
635
+ };
636
+ }
637
+
638
+ /** C3 — every child NOT nested under another (`parentRootId` unset): the
639
+ * top-level group set. A nested child is reached only via its
640
+ * parent's `childIds` (`groupNode` above), never listed here too — a node
641
+ * in two parents' `childIds` would render twice / cycle the row walk. */
642
+ private topLevelChildren(): CompositeChild[] {
643
+ return this.children.filter((c) => !c.parentRootId);
644
+ }
645
+
646
+ private runtimeRootChildren(): CompositeChild[] {
647
+ return this.topLevelChildren().filter((c) => (c.role ?? 'world') === 'world');
648
+ }
649
+
650
+ private resolveProjectionGroups(): Array<HierarchyProjectionGroup & { roots: string[] }> {
651
+ const known = new Set(this.runtimeRootChildren().map((child) => child.worldId));
652
+ const placed = new Set<string>();
653
+ return (this.projection?.groups ?? []).flatMap((group) => {
654
+ const roots = group.roots.filter((rootId) => {
655
+ if (!known.has(rootId)) {
656
+ editorConsole.warn(
657
+ `[CompositeAuthoringAdapter] hierarchy group "${group.label}" references unknown root "${rootId}" — ignoring.`,
658
+ 'authoring',
659
+ );
660
+ return false;
661
+ }
662
+ if (placed.has(rootId)) {
663
+ editorConsole.warn(
664
+ `[CompositeAuthoringAdapter] hierarchy root "${rootId}" is referenced by more than one group; the tree projection uses its first placement.`,
665
+ 'authoring',
666
+ );
667
+ return false;
668
+ }
669
+ placed.add(rootId);
670
+ return true;
671
+ });
672
+ return roots.length > 0 ? [{ ...group, roots }] : [];
673
+ });
674
+ }
675
+
676
+ private projectionGroups(): Array<HierarchyProjectionGroup & { roots: string[] }> {
677
+ return this.resolvedProjectionGroups;
678
+ }
679
+
680
+ private projectionGroupFor(worldId: string): HierarchyProjectionGroup | undefined {
681
+ return this.projectionGroups().find((group) => group.roots.includes(worldId));
682
+ }
683
+
684
+ private unprojectedRoots(): CompositeChild[] {
685
+ const projected = new Set(this.projectionGroups().flatMap((group) => group.roots));
686
+ return this.runtimeRootChildren().filter((child) => !projected.has(child.worldId));
687
+ }
688
+
689
+ private rootsOfKind(kind: string): CompositeChild[] {
690
+ return this.unprojectedRoots().filter((child) => child.kind === kind);
691
+ }
692
+
693
+ private parentForTopLevelRoot(worldId: string, kind: string): string | null {
694
+ const group = this.projectionGroupFor(worldId);
695
+ if (group) return projectionNodeId(group.id);
696
+ return this.rootsOfKind(kind).length > 1 ? kindNodeId(kind) : null;
697
+ }
698
+
699
+ private labelForRoot(worldId: string, kind: string): string {
700
+ const configured = this.projection?.rootLabels?.[worldId]?.trim();
701
+ if (configured) return configured;
702
+ return this.rootsOfKind(kind).length === 1 && !this.projectionGroupFor(worldId)
703
+ ? (KIND_LABELS[kind] ?? humanizeId(kind))
704
+ : humanizeId(worldId);
705
+ }
706
+
707
+ private kindNode(kind: string): EditorNode {
708
+ return {
709
+ id: kindNodeId(kind),
710
+ label: KIND_LABELS[kind] ?? humanizeId(kind),
711
+ role: 'folder',
712
+ kind: 'group',
713
+ parentId: null,
714
+ childIds: this.rootsOfKind(kind).map((child) => groupNodeId(child.worldId)),
715
+ };
716
+ }
717
+
718
+ private projectionGroupNode(group: HierarchyProjectionGroup & { roots: string[] }): EditorNode {
719
+ return {
720
+ id: projectionNodeId(group.id),
721
+ label: this.crossSurfaceGroupLabels.get(group.id) ?? group.label,
722
+ role: 'folder',
723
+ kind: 'group',
724
+ parentId: null,
725
+ childIds: this.crossSurfaceRootChildren.get(group.id) ?? group.roots.map(groupNodeId),
726
+ };
727
+ }
728
+
729
+ private orderJoinedSiblings(childIds: readonly string[]): string[] {
730
+ return childIds
731
+ .map((id, index) => ({ id, index, order: this.crossSurfaceOrders.get(id) }))
732
+ .sort((left, right) => {
733
+ if (left.order === undefined && right.order === undefined) return left.index - right.index;
734
+ if (left.order === undefined) return 1;
735
+ if (right.order === undefined) return -1;
736
+ return left.order - right.order || left.index - right.index;
737
+ })
738
+ .map(({ id }) => id);
739
+ }
740
+
741
+ /**
742
+ * A projected group may span several native adapters while its source owns
743
+ * one sibling list. Lift only rows that explicitly carry a semantic identity
744
+ * and ordinal, and only after every contributing adapter has mounted one;
745
+ * until then the ordinary adapter group rows remain intact.
746
+ */
747
+ private refreshCrossSurfaceRoots(
748
+ nodes: ReadonlyArray<{ child: CompositeChild; node: EditorNode }>,
749
+ semanticIds: ReadonlyMap<string, { child: CompositeChild; nodeId: string } | null>,
750
+ ): void {
751
+ this.crossSurfaceRootParents.clear();
752
+ this.crossSurfaceRootChildren.clear();
753
+ this.crossSurfaceGroupLabels.clear();
754
+ const nodesByChild = new Map<CompositeChild, Map<string, EditorNode>>();
755
+ for (const { child, node } of nodes) {
756
+ const owned = nodesByChild.get(child) ?? new Map<string, EditorNode>();
757
+ owned.set(node.id, node);
758
+ nodesByChild.set(child, owned);
759
+ }
760
+
761
+ for (const group of this.projectionGroups()) {
762
+ const participatingWorlds = new Set(group.roots);
763
+ const liveLabels = new Set(
764
+ nodes.flatMap(({ child, node }) => {
765
+ const label = node.crossSurfaceGroupLabel?.trim();
766
+ return participatingWorlds.has(child.worldId) && label ? [label] : [];
767
+ }),
768
+ );
769
+ if (liveLabels.size === 1) this.crossSurfaceGroupLabels.set(group.id, [...liveLabels][0]!);
770
+ if (group.roots.length < 2) continue;
771
+ const candidates = nodes.filter(({ child, node }) => {
772
+ if (
773
+ !participatingWorlds.has(child.worldId) ||
774
+ node.crossSurfaceId === undefined ||
775
+ node.crossSurfaceOrder === undefined ||
776
+ node.crossSurfaceParentId !== undefined ||
777
+ semanticIds.get(node.crossSurfaceId)?.nodeId !== node.id
778
+ ) {
779
+ return false;
780
+ }
781
+ let parentId = node.parentId;
782
+ const owned = nodesByChild.get(child);
783
+ while (parentId !== null) {
784
+ const parent = owned?.get(parentId);
785
+ if (parent === undefined) break;
786
+ if (parent.crossSurfaceId !== undefined) return false;
787
+ parentId = parent.parentId;
788
+ }
789
+ return true;
790
+ });
791
+ // A surface may contribute only descendants whose authored parents live on another
792
+ // surface. Requiring a top-level candidate from every adapter leaves those already-joined
793
+ // rows hidden behind synthetic native-root folders. Participation is therefore proven by
794
+ // any unique semantic row; only the union-level roots themselves become group children.
795
+ const mountedWorlds = new Set(
796
+ nodes.flatMap(({ child, node }) =>
797
+ participatingWorlds.has(child.worldId) &&
798
+ node.crossSurfaceId !== undefined &&
799
+ semanticIds.get(node.crossSurfaceId)?.nodeId === node.id
800
+ ? [child.worldId]
801
+ : [],
802
+ ),
803
+ );
804
+ if (group.roots.some((worldId) => !mountedWorlds.has(worldId))) continue;
805
+ if (candidates.length === 0) continue;
806
+ const childIds = this.orderJoinedSiblings(candidates.map(({ node }) => node.id));
807
+ this.crossSurfaceRootChildren.set(group.id, childIds);
808
+ const parentId = projectionNodeId(group.id);
809
+ for (const childId of childIds) this.crossSurfaceRootParents.set(childId, parentId);
810
+ }
811
+ }
812
+
813
+ /**
814
+ * Project the source-owned DOM semantic tree rather than the implementation
815
+ * DOM a semantic host component happens to render.
816
+ *
817
+ * Ordinary React/HTML authoring is unchanged: folding starts only below a
818
+ * DOM row carrying an explicit `crossSurfaceId`. Within that subtree, the
819
+ * nearest descendant rows carrying their own identities become its authored
820
+ * children and intervening OID rows are transparent. This is the DOM half of
821
+ * the same cross-surface contract the edge join already consumes; it does
822
+ * not infer a framework or library from tag names.
823
+ */
824
+ private refreshCrossSurfaceDomTrees(
825
+ nodes: ReadonlyArray<{ child: CompositeChild; node: EditorNode }>,
826
+ ): void {
827
+ this.crossSurfaceDomParents.clear();
828
+ this.crossSurfaceDomChildren.clear();
829
+
830
+ for (const child of this.children) {
831
+ if (child.kind !== 'dom') continue;
832
+ const owned = new Map(
833
+ nodes
834
+ .filter((candidate) => candidate.child === child)
835
+ .map(({ node }) => [node.id, node] as const),
836
+ );
837
+ const roots = [...owned.values()].filter((node) => node.parentId === null);
838
+ const visited = new Set<string>();
839
+ const visit = (node: EditorNode, semanticParentId: string | null): void => {
840
+ if (visited.has(node.id)) return;
841
+ visited.add(node.id);
842
+ let nextSemanticParentId = semanticParentId;
843
+ if (node.crossSurfaceId !== undefined) {
844
+ this.crossSurfaceDomChildren.set(node.id, []);
845
+ if (semanticParentId !== null) {
846
+ this.crossSurfaceDomParents.set(node.id, semanticParentId);
847
+ this.crossSurfaceDomChildren.get(semanticParentId)?.push(node.id);
848
+ }
849
+ nextSemanticParentId = node.id;
850
+ }
851
+ for (const childId of node.childIds) {
852
+ const descendant = owned.get(childId);
853
+ if (descendant) visit(descendant, nextSemanticParentId);
854
+ }
855
+ };
856
+ for (const root of roots) visit(root, null);
857
+ }
858
+ }
859
+
860
+ /**
861
+ * Join only explicit project-owned semantic edges. Native parentage stays
862
+ * adapter-owned; this adds the one relationship a substrate cannot express:
863
+ * a DOM/canvas row authored beneath a Three row (or any other pair of roots).
864
+ */
865
+ private refreshCrossSurfaceEdges(): void {
866
+ const nodes: Array<{ child: CompositeChild; node: EditorNode }> = [];
867
+ const ids = new Map<string, { child: CompositeChild; nodeId: string } | null>();
868
+ for (const child of this.children) {
869
+ forEachHierarchyNode(child.adapter.hierarchy, (node) => {
870
+ nodes.push({ child, node });
871
+ if (node.crossSurfaceId === undefined) return;
872
+ const previous = ids.get(node.crossSurfaceId);
873
+ ids.set(node.crossSurfaceId, previous === undefined ? { child, nodeId: node.id } : null);
874
+ });
875
+ }
876
+ this.crossSurfaceParents.clear();
877
+ this.crossSurfaceChildren.clear();
878
+ this.crossSurfaceOrders.clear();
879
+ for (const { node } of nodes) {
880
+ if (node.crossSurfaceOrder !== undefined) {
881
+ this.crossSurfaceOrders.set(node.id, node.crossSurfaceOrder);
882
+ }
883
+ }
884
+ this.refreshCrossSurfaceDomTrees(nodes);
885
+ for (const { child, node } of nodes) {
886
+ if (node.crossSurfaceParentId === undefined) continue;
887
+ const parent = ids.get(node.crossSurfaceParentId);
888
+ if (parent === undefined || parent === null || parent.child === child) continue;
889
+ this.crossSurfaceParents.set(node.id, parent.nodeId);
890
+ const children = this.crossSurfaceChildren.get(parent.nodeId) ?? [];
891
+ children.push(node.id);
892
+ this.crossSurfaceChildren.set(parent.nodeId, children);
893
+ }
894
+ this.refreshCrossSurfaceRoots(nodes, ids);
895
+ }
896
+
897
+ private clearCrossSurfaceEdges(): void {
898
+ this.crossSurfaceParents.clear();
899
+ this.crossSurfaceChildren.clear();
900
+ this.crossSurfaceOrders.clear();
901
+ this.crossSurfaceRootParents.clear();
902
+ this.crossSurfaceRootChildren.clear();
903
+ this.crossSurfaceGroupLabels.clear();
904
+ this.crossSurfaceDomParents.clear();
905
+ this.crossSurfaceDomChildren.clear();
906
+ }
907
+
908
+ /** Refresh semantic joins only when a child reports that the fields which
909
+ * define those joins changed. Ordinary native hierarchy churn still flows
910
+ * through each child's own provider; it simply cannot affect these maps. */
911
+ private ensureCrossSurfaceEdges(): void {
912
+ const signatures: Array<string | null> = [];
913
+ for (const child of this.children) {
914
+ const signature = child.adapter.hierarchy.crossSurfaceStructureSignature;
915
+ if (!signature) {
916
+ this.crossSurfaceInputSignatures = null;
917
+ this.refreshCrossSurfaceEdges();
918
+ return;
919
+ }
920
+ signatures.push(signature.call(child.adapter.hierarchy));
921
+ }
922
+ if (
923
+ this.crossSurfaceInputSignatures !== null &&
924
+ signatures.length === this.crossSurfaceInputSignatures.length &&
925
+ signatures.every((value, index) => value === this.crossSurfaceInputSignatures?.[index])
926
+ ) {
927
+ return;
928
+ }
929
+ this.crossSurfaceInputSignatures = signatures;
930
+ if (signatures.every((signature) => signature === null)) {
931
+ this.clearCrossSurfaceEdges();
932
+ return;
933
+ }
934
+ this.refreshCrossSurfaceEdges();
935
+ }
936
+
937
+ private projectCrossSurfaceNode(node: EditorNode, defaultParentId: string | null): EditorNode {
938
+ const parentId =
939
+ this.crossSurfaceParents.get(node.id) ??
940
+ this.crossSurfaceDomParents.get(node.id) ??
941
+ this.crossSurfaceRootParents.get(node.id) ??
942
+ defaultParentId;
943
+ const nativeChildIds = this.crossSurfaceDomChildren.get(node.id) ?? node.childIds;
944
+ const childIds = this.orderJoinedSiblings(
945
+ nativeChildIds
946
+ .filter(
947
+ (childId) =>
948
+ !this.crossSurfaceParents.has(childId) && !this.crossSurfaceRootParents.has(childId),
949
+ )
950
+ .concat(this.crossSurfaceChildren.get(node.id) ?? []),
951
+ );
952
+ return parentId === node.parentId &&
953
+ childIds.length === node.childIds.length &&
954
+ childIds.every((childId, index) => childId === node.childIds[index])
955
+ ? node
956
+ : { ...node, parentId, childIds };
957
+ }
958
+
959
+ private defaultProjectionNodeIds(): string[] {
960
+ const ids: string[] = [];
961
+ const seenKinds = new Set<string>();
962
+ for (const child of this.unprojectedRoots()) {
963
+ if (seenKinds.has(child.kind)) continue;
964
+ seenKinds.add(child.kind);
965
+ const peers = this.rootsOfKind(child.kind);
966
+ ids.push(peers.length > 1 ? kindNodeId(child.kind) : groupNodeId(child.worldId));
967
+ }
968
+ return ids;
969
+ }
970
+
971
+ readonly hierarchy: HierarchyProvider = {
972
+ roots: (): EditorNode[] => {
973
+ this.ensureCrossSurfaceEdges();
974
+ return [
975
+ ...this.projectionGroups().map((group) => this.projectionGroupNode(group)),
976
+ ...this.defaultProjectionNodeIds()
977
+ .map((id) => this.hierarchy.node(id))
978
+ .filter((node): node is EditorNode => node !== null),
979
+ ];
980
+ },
981
+ node: (id) => {
982
+ if (id.startsWith(KIND_PREFIX)) {
983
+ const kind = id.slice(KIND_PREFIX.length);
984
+ return this.rootsOfKind(kind).length > 1 ? this.kindNode(kind) : null;
985
+ }
986
+ if (id.startsWith(PROJECTION_PREFIX)) {
987
+ const groupId = id.slice(PROJECTION_PREFIX.length);
988
+ const group = this.projectionGroups().find((candidate) => candidate.id === groupId);
989
+ return group ? this.projectionGroupNode(group) : null;
990
+ }
991
+ if (isGroupNodeId(id)) {
992
+ const worldId = id.slice(GROUP_PREFIX.length);
993
+ const child = this.children.find((c) => c.worldId === worldId);
994
+ if (!child) return null;
995
+ return this.groupNode(child.worldId, child.kind, child.adapter.hierarchy.roots());
996
+ }
997
+ const owner = this.findOwnerChild(id);
998
+ if (!owner) return null;
999
+ const node = owner.adapter.hierarchy.node(id);
1000
+ if (!node) return null;
1001
+ // Resolving the row is already proof that this mount epoch genuinely
1002
+ // owned the id. Remember it before any provider lookup: Play/remount can
1003
+ // replace the child between this read and that lookup, and the stale row
1004
+ // must then degrade quietly rather than be mislabeled as fabricated.
1005
+ this.supersededIds.add(id);
1006
+ // A child's OWN root has `parentId: null` in its adapter's tree — rewrite
1007
+ // it to point at this world's group node so the merged tree is one
1008
+ // connected forest instead of the child roots looking parentless again.
1009
+ return this.projectCrossSurfaceNode(
1010
+ node,
1011
+ node.parentId === null ? groupNodeId(owner.worldId) : node.parentId,
1012
+ );
1013
+ },
1014
+ // The composite spans mixed substrates, so it always answers these — but
1015
+ // only three-backed children implement them; the rest are absent (P-5).
1016
+ object3D: (id) =>
1017
+ isGroupNodeId(id) || isOrganizationNodeId(id)
1018
+ ? null
1019
+ : (this.routeOwned(id, 'hierarchy.object3D')?.hierarchy.object3D?.(id) ?? null),
1020
+ idForObject3D: (o: THREE.Object3D) => {
1021
+ for (const child of this.children) {
1022
+ const id = child.adapter.hierarchy.idForObject3D?.(o);
1023
+ if (id) return id;
1024
+ }
1025
+ return null;
1026
+ },
1027
+ };
1028
+
1029
+ readonly selection: SelectionProvider = {
1030
+ // Union of every child's own selection.
1031
+ get: () => {
1032
+ const seen = new Set<string>();
1033
+ for (const child of this.children) {
1034
+ for (const id of child.adapter.selection?.get() ?? []) seen.add(id);
1035
+ }
1036
+ return [...seen];
1037
+ },
1038
+ set: (ids, options) => {
1039
+ // A group-node id is not a real entity in any
1040
+ // child's tree — route it specially rather than forwarding a bogus id
1041
+ // nobody owns. Route each REAL id to its owning child.
1042
+ //
1043
+ // Every existing adapter (first-party/ingest/canvas) delegates
1044
+ // selection to the SAME editor-global `EditorShellStore` — `selectMultiple`
1045
+ // REPLACES the store's whole selected-id set, it does not merge. So
1046
+ // calling `.set([])` on every non-owning child (the naive "clear the
1047
+ // rest" approach) would call `selectMultiple` a second time on the
1048
+ // SAME shared store and immediately clear out whatever the owning
1049
+ // child's `.set()` call just wrote — a single click could never
1050
+ // actually select anything. Only forward to children that own at
1051
+ // least one of `ids`.
1052
+ //
1053
+ // A4 (D8): when NONE do (an empty selection, OR a selection that is
1054
+ // PURELY a synthetic id), forward to exactly ONE child (any child
1055
+ // sharing the backing store reaches every other child too) — but
1056
+ // forward the SYNTHETIC ID ITSELF (not an empty clear) when one was
1057
+ // given. This is why `RightPanel.tsx`'s `[...store.selectedEntityIds][0]`
1058
+ // (and `Inspector.tsx`'s identical read) can show a world group's own
1059
+ // properties: every stock adapter shares the SAME `EditorShellStore`, so
1060
+ // writing `'world:<id>'` into any one
1061
+ // child's selection lands in that one shared `selectedEntityIds` set,
1062
+ // which both those raw-store reads AND this composite's OWN `get()`
1063
+ // (via the owning-adapter's `selection.get()`) then see identically —
1064
+ // no separate synthetic-id bookkeeping needed on this class at all.
1065
+ const byChild = new Map<AuthoringAdapter, string[]>();
1066
+ let synthetic: string | null = null;
1067
+ for (const id of ids) {
1068
+ if (isGroupNodeId(id) || isOrganizationNodeId(id)) {
1069
+ synthetic = id; // last one wins — only used when NOTHING else claims byChild
1070
+ continue;
1071
+ }
1072
+ const owner = this.findOwnerChild(id);
1073
+ if (!owner) continue;
1074
+ const arr = byChild.get(owner.adapter);
1075
+ if (arr) arr.push(id);
1076
+ else byChild.set(owner.adapter, [id]);
1077
+ }
1078
+ const setChildSelection = (adapter: AuthoringAdapter, childIds: string[]): void => {
1079
+ setAuthoringSelection(adapter, childIds, options);
1080
+ };
1081
+ if (byChild.size === 0) {
1082
+ const first = this.children[0];
1083
+ if (first) setChildSelection(first.adapter, synthetic ? [synthetic] : []);
1084
+ return;
1085
+ }
1086
+ for (const [adapter, childIds] of byChild) setChildSelection(adapter, childIds);
1087
+ },
1088
+ };
1089
+
1090
+ readonly transforms: TransformProvider = {
1091
+ // `null` for an unowned id is what keeps the fabricated all-zero Transform
1092
+ // section off the inspector: `inspection/compose.ts` renders that section
1093
+ // only when `dimensions` answers, so the refusal below is the gate every
1094
+ // shell path already respects. `get` can only THROW (its contract returns a
1095
+ // non-nullable `Transform`), which is correct for a caller that reaches it
1096
+ // anyway — for an id nothing owns, and equally for an id whose OWNING root
1097
+ // exposes no transform provider. A named error beats a pose in both cases;
1098
+ // `the world root's stage`'s camera-authoring gestures catch and report it.
1099
+ dimensions: (id) => {
1100
+ if (isGroupNodeId(id) || isOrganizationNodeId(id)) return null;
1101
+ const transforms = this.routeOwned(id, 'transforms.dimensions')?.transforms;
1102
+ if (!transforms) return null;
1103
+ const dimensions = transforms.dimensions?.(id);
1104
+ return dimensions === undefined ? '3d' : dimensions;
1105
+ },
1106
+ get: (id): Transform => {
1107
+ const owner = this.routeOwned(id, 'transforms.get');
1108
+ if (!owner) throw new Error(unownedIdRefusal('transforms.get', id, this.children));
1109
+ const transforms = owner.transforms;
1110
+ if (!transforms) {
1111
+ throw new Error(
1112
+ untransformedIdRefusal('transforms.get', id, this.ownerOf(id) ?? 'unknown'),
1113
+ );
1114
+ }
1115
+ return transforms.get(id);
1116
+ },
1117
+ editability: (id, channel) => {
1118
+ const owner = this.routeOwned(id, 'transforms.editability');
1119
+ if (!owner) return { writable: false, reason: `No root owns entity id "${id}".` };
1120
+ return owner.transforms?.editability?.(id, channel) ?? { writable: true };
1121
+ },
1122
+ beginEdit: (id) => {
1123
+ const owner = this.routeOwned(id, 'transforms.beginEdit');
1124
+ if (owner) beginAuthoringTransformEdit(owner, id);
1125
+ },
1126
+ apply: (id, transform) => {
1127
+ const owner = this.routeOwned(id, 'transforms.apply');
1128
+ if (owner) applyAuthoringTransform(owner, id, transform);
1129
+ },
1130
+ endEdit: (id) => {
1131
+ const owner = this.routeOwned(id, 'transforms.endEdit');
1132
+ return owner ? endAuthoringTransformEdit(owner, id) : undefined;
1133
+ },
1134
+ // The OWNING child's ack, passed straight through — same reason `endEdit`
1135
+ // above must never synthesize one. Whether the channel HAS an override to
1136
+ // drop is `editability`'s answer (`removable`), routed per id the same way,
1137
+ // so a composite over a child with no removal door still refuses by name.
1138
+ remove: (id, channel) => {
1139
+ const owner = this.routeOwned(id, 'transforms.remove');
1140
+ return owner ? removeAuthoringTransform(owner, id, channel) : undefined;
1141
+ },
1142
+ sourceCommit: {
1143
+ availability: (id) => {
1144
+ const owner = this.routeOwned(id, 'transforms.sourceCommit');
1145
+ return (
1146
+ owner?.transforms?.sourceCommit?.availability(id) ?? {
1147
+ available: false,
1148
+ reason: 'The owning root exposes no explicit live-transform source commit.',
1149
+ }
1150
+ );
1151
+ },
1152
+ commit: async (id) => {
1153
+ const owner = this.routeOwned(id, 'transforms.sourceCommit');
1154
+ const outcome = owner ? commitAuthoringTransformSource(owner, id) : undefined;
1155
+ const ack = await outcome;
1156
+ return ack ?? { destination: 'no owning source-commit provider', persisted: false };
1157
+ },
1158
+ },
1159
+ };
1160
+
1161
+ /**
1162
+ * H5 — route a row's authorability warnings to the child that owns it.
1163
+ *
1164
+ * NOT part of `AuthoringAdapter`: like H3's source accessors this is an
1165
+ * OPTIONAL capability a child either has or does not, probed structurally by
1166
+ * `hierarchy-row-model.ts`'s `RowDiagnosticsSource`. Forwarding it here (as
1167
+ * opposed to letting the hierarchy walk `childAdapters()` itself, the way the
1168
+ * H3 context menu does) is what keeps the per-row cost identical to the
1169
+ * neighbouring `transforms.editability` probe — one `route(id)` per row, and
1170
+ * `RowWarningCache` pays even that at most once per row per version.
1171
+ */
1172
+ diagnosticsFor(id: string): readonly { code: string; message: string }[] | undefined {
1173
+ if (isGroupNodeId(id) || isOrganizationNodeId(id)) return undefined;
1174
+ const owner = (this.routeOwned(id, 'diagnosticsFor') ?? {}) as {
1175
+ diagnosticsFor?: (nodeId: string) => unknown;
1176
+ };
1177
+ return typeof owner.diagnosticsFor === 'function'
1178
+ ? (owner.diagnosticsFor(id) as readonly { code: string; message: string }[] | undefined)
1179
+ : undefined;
1180
+ }
1181
+
1182
+ /**
1183
+ * H6 — route "what does this row's projection hide?" to the child that owns
1184
+ * the row, exactly as {@link diagnosticsFor} routes its warnings and for the
1185
+ * same reason: it is an OPTIONAL capability probed structurally
1186
+ * (`hierarchy-internals.ts`'s `InternalsSource`), not part of
1187
+ * `AuthoringAdapter`, so a child without it simply reveals nothing.
1188
+ *
1189
+ * The synthetic group/organization ids are answered here rather than routed:
1190
+ * they are shell rows with no live object at all, so "reveal its internals"
1191
+ * has no referent — and `route()`'s unknown-id floor would otherwise ask the
1192
+ * FIRST child about an id it has never heard of.
1193
+ */
1194
+ internalChildren(id: string): EditorNode[] | undefined {
1195
+ if (isGroupNodeId(id) || isOrganizationNodeId(id)) return undefined;
1196
+ // Reveal state is session-only and deliberately survives a child remount. During that remount
1197
+ // an id which belonged to the old live graph may temporarily (or permanently) have no owner;
1198
+ // hierarchy-internals.ts's contract says that stale reveal is silently inert. This is an
1199
+ // optional read-only projection probe, not an authoring operation, so it must not go through
1200
+ // routeOwned's loud anti-shim refusal.
1201
+ const owner = (this.findOwnerChild(id)?.adapter ?? {}) as {
1202
+ internalChildren?: (nodeId: string) => EditorNode[] | undefined;
1203
+ };
1204
+ return typeof owner.internalChildren === 'function' ? owner.internalChildren(id) : undefined;
1205
+ }
1206
+
1207
+ /** H6 — the companion cheap probe; same routing rules as
1208
+ * {@link internalChildren}. */
1209
+ hasInternals(id: string): boolean {
1210
+ if (isGroupNodeId(id) || isOrganizationNodeId(id)) return false;
1211
+ // Same stale-reveal rule as internalChildren above.
1212
+ const owner = (this.findOwnerChild(id)?.adapter ?? {}) as {
1213
+ hasInternals?: (nodeId: string) => boolean;
1214
+ };
1215
+ return typeof owner.hasInternals === 'function' ? owner.hasInternals(id) : false;
1216
+ }
1217
+
1218
+ /**
1219
+ * A4 (D8) — each adapter root's stable `id`, `zOrder`/`pausable`/`kind` are
1220
+ * a MANIFEST concept, not something any child
1221
+ * adapter's own inspector knows about — intercepted here, BEFORE `route()`,
1222
+ * so a real per-world adapter is never asked about an id it doesn't own.
1223
+ * Without a writable provider, these synthetic nodes still expose read-only
1224
+ * composition facts instead of being routed into a child that cannot own them.
1225
+ */
1226
+ private rootProperties(id: string): PropertyDescriptor[] | null {
1227
+ if (isOrganizationNodeId(id)) {
1228
+ return [{ path: 'organization', label: 'Authoring Group', type: 'string', readonly: true }];
1229
+ }
1230
+ const child = this.groupChild(id);
1231
+ if (!child) return null;
1232
+ const surfaceRow = (child.role ?? 'world') === 'surface';
1233
+ return [
1234
+ { path: 'id', label: 'Root ID', type: 'string', readonly: true },
1235
+ { path: 'kind', label: 'Kind', type: 'string', readonly: true },
1236
+ ...(this.manifest?.getRootContentSource || child.content
1237
+ ? ([{ path: 'content', label: 'Content', type: 'string', readonly: true }] as const)
1238
+ : []),
1239
+ // A `surface` row is a seam this composite manufactured INSIDE a root
1240
+ // (an ingested game's DOM UI beside its canvas), not a manifest root of
1241
+ // its own. `zOrder`/`pausable` are manifest-root facts keyed by root id,
1242
+ // and there is no manifest root under this worldId to read or write — so
1243
+ // the row answers what it honestly knows (`id`, `kind`, `content`) and
1244
+ // does not manufacture the two it does not.
1245
+ ...(surfaceRow
1246
+ ? []
1247
+ : ([
1248
+ {
1249
+ path: 'zOrder',
1250
+ label: 'Z Order',
1251
+ type: 'number',
1252
+ ...(this.manifest ? {} : { readonly: true }),
1253
+ },
1254
+ {
1255
+ path: 'pausable',
1256
+ label: 'Pausable',
1257
+ type: 'boolean',
1258
+ ...(this.manifest ? {} : { readonly: true }),
1259
+ },
1260
+ ] as PropertyDescriptor[])),
1261
+ ];
1262
+ }
1263
+
1264
+ /**
1265
+ * The child a GROUP-node id names, whatever its `role`.
1266
+ *
1267
+ * The role gates used to live here, so a `role: 'surface'` child's group row
1268
+ * answered nothing: `rootProperties` returned `null`, the inspector then
1269
+ * routed the `world:`-prefixed id into {@link findOwnerChild}, which refuses
1270
+ * every group id by construction (a group node has NO live object — that is
1271
+ * what makes it a group node), and the row logged an unowned-id refusal
1272
+ * instead of its own facts. Measured on an ingested game's `…:dom-ui` seam
1273
+ * row while `…:canvas` beside it answered normally.
1274
+ *
1275
+ * Widening the gate is the right half to move, not `findOwnerChild`: entity
1276
+ * ownership genuinely is "some child's hierarchy resolves this id", and a
1277
+ * group id resolves in nobody's. `role` distinguishes how a row is PROJECTED
1278
+ * ({@link groupNode}'s `document` vs `root`, {@link runtimeRootChildren}'s
1279
+ * top-level set) — it was never meant to decide whether a row exists to be
1280
+ * inspected. `hierarchy.node()` already treats both roles alike.
1281
+ */
1282
+ private groupChild(id: string): CompositeChild | null {
1283
+ if (!isGroupNodeId(id)) return null;
1284
+ return this.children.find((c) => groupNodeId(c.worldId) === id) ?? null;
1285
+ }
1286
+
1287
+ readonly inspector: InspectorProvider = {
1288
+ properties: (id): PropertyDescriptor[] =>
1289
+ this.rootProperties(id) ??
1290
+ this.routeOwned(id, 'inspector.properties')?.inspector?.properties(id) ??
1291
+ [],
1292
+ get: (id, path) => {
1293
+ if (isOrganizationNodeId(id)) {
1294
+ return path === 'organization' ? 'Editor-only hierarchy projection' : undefined;
1295
+ }
1296
+ const child = this.groupChild(id);
1297
+ if (child) {
1298
+ const worldId = child.worldId;
1299
+ if (path === 'id') return worldId;
1300
+ if (path === 'kind') return child.kind;
1301
+ if (path === 'content') {
1302
+ return this.manifest?.getRootContentSource?.(worldId) ?? child.content;
1303
+ }
1304
+ // Surface rows declare neither (see `rootProperties`) — answering
1305
+ // `undefined` keeps read and describe agreeing.
1306
+ if ((child.role ?? 'world') === 'surface') return undefined;
1307
+ if (path === 'zOrder') {
1308
+ return this.manifest?.getRootZOrder(worldId) ?? child.zOrder ?? 0;
1309
+ }
1310
+ if (path === 'pausable') {
1311
+ return this.manifest?.getRootPausable(worldId) ?? child.pausable ?? true;
1312
+ }
1313
+ return undefined;
1314
+ }
1315
+ return this.routeOwned(id, 'inspector.get')?.inspector?.get(id, path);
1316
+ },
1317
+ // A synthetic row answers for ITSELF here, exactly as `properties`/`get`/
1318
+ // `set` above already do. Routing a `world:`-prefixed id into
1319
+ // `routeOwned` refuses every group id by construction (a group node has NO
1320
+ // live object — that is what makes it a group node), so the inspector
1321
+ // logged an unowned-id refusal for a row it had just described. MEASURED
1322
+ // on a packaged build against a canvas ingest root with a DOM UI beside
1323
+ // it: `inspector.editability: no root owns entity id
1324
+ // "world:probe-canvas:canvas"` on every selection of that root's own row.
1325
+ //
1326
+ // The descriptor is the ONE source of truth for whether a synthetic row's
1327
+ // field is writable (`rootProperties` decides `readonly` per path — the
1328
+ // manifest provider gates `zOrder`/`pausable`, `id`/`kind`/`content` are
1329
+ // always read-only, and a `surface` row declares neither of the first
1330
+ // two), so this reads the answer back off it instead of restating the
1331
+ // rules and drifting from them. An unknown path on a synthetic row is not
1332
+ // writable: nothing would receive the write.
1333
+ editability: (id, path) => {
1334
+ const synthetic = this.rootProperties(id);
1335
+ if (synthetic) {
1336
+ const descriptor = synthetic.find((property) => property.path === path);
1337
+ if (!descriptor) {
1338
+ return {
1339
+ writable: false,
1340
+ reason: `this root row has no "${path}" field`,
1341
+ };
1342
+ }
1343
+ return descriptor.readonly
1344
+ ? {
1345
+ writable: false,
1346
+ reason: 'a composition fact, read from the manifest — not an authored value',
1347
+ }
1348
+ : { writable: true };
1349
+ }
1350
+ return (
1351
+ this.routeOwned(id, 'inspector.editability')?.inspector?.editability?.(id, path) ?? {
1352
+ writable: true,
1353
+ }
1354
+ );
1355
+ },
1356
+ set: (id, path, value) => {
1357
+ const child = this.groupChild(id);
1358
+ if (child) {
1359
+ if (this.manifest && (child.role ?? 'world') === 'world') {
1360
+ if (path === 'zOrder') this.manifest.setRootZOrder(child.worldId, Number(value));
1361
+ else if (path === 'pausable')
1362
+ this.manifest.setRootPausable(child.worldId, Boolean(value));
1363
+ }
1364
+ return;
1365
+ }
1366
+ // The OWNING child's ack, passed straight through. The composite has no
1367
+ // ack of its own to give and must never synthesize one: joining every
1368
+ // child's persistence is how a three-root edit came to report the DOM
1369
+ // root's destination with `persisted: true`.
1370
+ return this.routeOwned(id, 'inspector.set')?.inspector?.set(id, path, value);
1371
+ },
1372
+ remove: (id, path) => {
1373
+ // Root-group synthetic props have no removable override; everything else
1374
+ // routes to the owning child (which optional-omits `remove` when its
1375
+ // dialect has no way to express absence).
1376
+ if (isGroupNodeId(id) || isOrganizationNodeId(id)) return;
1377
+ // The OWNING child's ack, passed straight through — same reason `set`
1378
+ // above must never synthesize one.
1379
+ return this.routeOwned(id, 'inspector.remove')?.inspector?.remove?.(id, path);
1380
+ },
1381
+ };
1382
+
1383
+ /** Source-derived component instances, routed by the same ownership answer
1384
+ * as the generic Inspector. Synthetic root/group rows have no instance. */
1385
+ readonly instances: ComponentInstancesProvider = {
1386
+ describe: (id) => {
1387
+ if (isGroupNodeId(id) || isOrganizationNodeId(id)) return null;
1388
+ return this.routeOwned(id, 'instances.describe')?.instances?.describe(id) ?? null;
1389
+ },
1390
+ revert: async (id, paths) => {
1391
+ if (isGroupNodeId(id) || isOrganizationNodeId(id)) return;
1392
+ const adapter = this.routeOwned(id, 'instances.revert');
1393
+ if (!adapter?.instances) return;
1394
+ return revertAuthoringInstance(adapter, id, paths);
1395
+ },
1396
+ applyToComponent: async (id, path) => {
1397
+ if (isGroupNodeId(id) || isOrganizationNodeId(id)) {
1398
+ return { changed: false, message: 'A composition row is not a component instance.' };
1399
+ }
1400
+ const adapter = this.routeOwned(id, 'instances.applyToComponent');
1401
+ const provider = adapter?.instances;
1402
+ if (!provider) {
1403
+ return { changed: false, message: 'This subject has no writable component source.' };
1404
+ }
1405
+ return applyAuthoringInstanceToComponent(adapter, id, path);
1406
+ },
1407
+ };
1408
+
1409
+ /**
1410
+ * Resolve a `create`/`creatableKinds` target: a group-node id
1411
+ * (`world:<w>`) targets that world's OWN root (forwarded parentId
1412
+ * `undefined`); a real id targets its owning child (forwarded as-is);
1413
+ * `null`/`undefined` (no parent — the toolbar's root-level "Add") targets
1414
+ * the FIRST child that actually has a structure provider — in edit mode
1415
+ * that is the one focused, live world adapter; every other
1416
+ * child is a read-only `BoundaryAuthoringAdapter` with no `structure` at
1417
+ * all. `null` when nothing can serve the request, so a create is never
1418
+ * silently routed to the wrong world.
1419
+ */
1420
+ private resolveStructureTarget(
1421
+ parentId: string | null | undefined,
1422
+ ): { child: CompositeChild; forwardParentId: string | undefined } | null {
1423
+ if (parentId != null && isOrganizationNodeId(parentId)) return null;
1424
+ if (parentId != null && isGroupNodeId(parentId)) {
1425
+ const worldId = parentId.slice(GROUP_PREFIX.length);
1426
+ const child = this.children.find((c) => c.worldId === worldId);
1427
+ return child ? { child, forwardParentId: undefined } : null;
1428
+ }
1429
+ if (parentId != null) {
1430
+ const child = this.findOwnerChild(parentId);
1431
+ if (!child) return null;
1432
+ const parent = child.adapter.hierarchy.node(parentId);
1433
+ return {
1434
+ child,
1435
+ // Documents and design states organize an adapter's native roots;
1436
+ // they are not native parent ids. The structure contract's root
1437
+ // spelling is `undefined`/`null`, so forward that explicitly.
1438
+ forwardParentId:
1439
+ parent?.role === 'document' || parent?.role === 'story' ? undefined : parentId,
1440
+ };
1441
+ }
1442
+ const child = this.children.find((c) => c.adapter.structure);
1443
+ return child ? { child, forwardParentId: undefined } : null;
1444
+ }
1445
+
1446
+ /**
1447
+ * A2 (map §5) — structural pass-through, routed by ownership. Never a
1448
+ * silent no-op when a request can't be served: `create`/`remove`/
1449
+ * `duplicate` log loudly via `editorConsole.warn` and return an honest
1450
+ * empty/unchanged result (#18) rather than pretending to route to the
1451
+ * wrong world. `reorder` is the one exception — it mirrors the base
1452
+ * contract's OWN "absent means no reorder UI" convention, so a missing
1453
+ * `reorder` on the owning child is a silent no-op, not a warning.
1454
+ */
1455
+ readonly structure: StructureProvider = {
1456
+ // The owning child's WHOLE answer is forwarded — id and ack together. A
1457
+ // route that found no owner attempted no write, so its ack is `undefined`
1458
+ // (the honest `void` of `StructuralWriteOutcome`), never a fabricated one.
1459
+ create: (kind, parentId) => {
1460
+ const target = this.resolveStructureTarget(parentId ?? null);
1461
+ if (!target?.child.adapter.structure) {
1462
+ editorConsole.warn(
1463
+ `[CompositeAuthoringAdapter] create: no owning child with a structure ` +
1464
+ `provider for parent "${parentId ?? '(root)'}"`,
1465
+ 'authoring',
1466
+ );
1467
+ return { id: '', ack: undefined };
1468
+ }
1469
+ return createAuthoringNode(target.child.adapter, kind, target.forwardParentId);
1470
+ },
1471
+ remove: (id) => {
1472
+ if (isGroupNodeId(id) || isOrganizationNodeId(id)) return;
1473
+ const owner = this.findOwnerChild(id);
1474
+ if (!owner?.adapter.structure) {
1475
+ editorConsole.warn(
1476
+ `[CompositeAuthoringAdapter] remove: no owner/structure for id "${id}"`,
1477
+ 'authoring',
1478
+ );
1479
+ return;
1480
+ }
1481
+ // Propagate the owning child's return value (a react-world child
1482
+ // returns an awaitable) so `deleteSelection` can still serialize a
1483
+ // multi-delete THROUGH the composite the same way it does for a
1484
+ // directly-active override adapter (see that adapter's own `remove`
1485
+ // doc comment).
1486
+ return removeAuthoringNode(owner.adapter, id);
1487
+ },
1488
+ removeMany: async (ids) => {
1489
+ const groups = new Map<CompositeChild, string[]>();
1490
+ for (const id of ids) {
1491
+ if (isGroupNodeId(id) || isOrganizationNodeId(id)) continue;
1492
+ const owner = this.findOwnerChild(id);
1493
+ if (!owner?.adapter.structure) {
1494
+ editorConsole.warn(
1495
+ `[CompositeAuthoringAdapter] removeMany: no owner/structure for id "${id}"`,
1496
+ 'authoring',
1497
+ );
1498
+ continue;
1499
+ }
1500
+ const ownedIds = groups.get(owner);
1501
+ if (ownedIds) ownedIds.push(id);
1502
+ else groups.set(owner, [id]);
1503
+ }
1504
+
1505
+ // Preserve each child's strongest atomicity guarantee. In the common case
1506
+ // (one world's multi-selection), this is one child batch and therefore one
1507
+ // history transaction. Cross-world selections remain one ordered batch per
1508
+ // owner because no child can soundly mutate another world's document.
1509
+ // One gesture, one answer. A cross-world batch cannot honestly name TWO
1510
+ // destinations, so the composite reports the last owner's ack only when
1511
+ // EVERY owner persisted; the moment one did not, the whole batch degrades
1512
+ // to the live-only floor. Naming a file that carried only part of the
1513
+ // selection is the blanket ack the persistence pipe exists to kill.
1514
+ let ack: WriteAck = LIVE_ONLY_ACK;
1515
+ let allPersisted = true;
1516
+ for (const [owner, ownedIds] of groups) {
1517
+ const structure = owner.adapter.structure!;
1518
+ // biome-ignore lint/complexity/noUselessUndefinedInitialization: not useless — `void | WriteAck` is not definitely assigned by either branch below, and tsc reads the two reads that follow as use-before-assignment without it.
1519
+ let last: void | WriteAck = undefined;
1520
+ if (structure.removeMany) last = await removeManyAuthoringNodes(owner.adapter, ownedIds);
1521
+ else for (const id of ownedIds) last = await removeAuthoringNode(owner.adapter, id);
1522
+ if (last) ack = last;
1523
+ if (!last?.persisted) allPersisted = false;
1524
+ }
1525
+ return allPersisted ? ack : LIVE_ONLY_ACK;
1526
+ },
1527
+ canCopy: (ids) => {
1528
+ const owners = ids.map((id) => this.findOwnerChild(id));
1529
+ const owner = owners[0];
1530
+ return !!(
1531
+ owner?.adapter.structure?.copy &&
1532
+ !owners.some((candidate) => candidate?.worldId !== owner.worldId) &&
1533
+ (owner.adapter.structure.canCopy?.(ids) ?? true)
1534
+ );
1535
+ },
1536
+ copy: async (ids) => {
1537
+ const owners = ids.map((id) => this.findOwnerChild(id));
1538
+ const owner = owners[0];
1539
+ if (!this.structure.canCopy?.(ids) || !owner?.adapter.structure?.copy) {
1540
+ editorConsole.warn(
1541
+ '[CompositeAuthoringAdapter] copy requires source-addressable entities from one world.',
1542
+ 'authoring',
1543
+ );
1544
+ return false;
1545
+ }
1546
+ if (!(await copyAuthoringNodes(owner.adapter, ids))) return false;
1547
+ this.structureClipboardWorldId = owner.worldId;
1548
+ return true;
1549
+ },
1550
+ cut: async (ids) => {
1551
+ const owners = ids.map((id) => this.findOwnerChild(id));
1552
+ const owner = owners[0];
1553
+ if (!this.structure.canCopy?.(ids) || !owner?.adapter.structure?.cut) {
1554
+ editorConsole.warn(
1555
+ '[CompositeAuthoringAdapter] cut requires source-addressable entities from one world.',
1556
+ 'authoring',
1557
+ );
1558
+ return false;
1559
+ }
1560
+ const ack = await cutAuthoringNodes(owner.adapter, ids);
1561
+ if (ack === false) return false;
1562
+ this.structureClipboardWorldId = owner.worldId;
1563
+ return ack;
1564
+ },
1565
+ canPaste: (parentId) => {
1566
+ const clipboardOwner = this.children.find(
1567
+ (child) => child.worldId === this.structureClipboardWorldId,
1568
+ );
1569
+ if (!clipboardOwner?.adapter.structure?.paste) return false;
1570
+ const target =
1571
+ parentId === null
1572
+ ? { child: clipboardOwner, forwardParentId: null }
1573
+ : this.resolveStructureTarget(parentId);
1574
+ return !!(
1575
+ target &&
1576
+ target.child.worldId === clipboardOwner.worldId &&
1577
+ (clipboardOwner.adapter.structure.canPaste?.(target.forwardParentId ?? null) ?? true)
1578
+ );
1579
+ },
1580
+ paste: async (parentId) => {
1581
+ const clipboardOwner = this.children.find(
1582
+ (child) => child.worldId === this.structureClipboardWorldId,
1583
+ );
1584
+ if (!clipboardOwner?.adapter.structure?.paste) {
1585
+ editorConsole.warn(
1586
+ '[CompositeAuthoringAdapter] paste has no copied entity payload to route.',
1587
+ 'authoring',
1588
+ );
1589
+ return false;
1590
+ }
1591
+ const target =
1592
+ parentId === null
1593
+ ? { child: clipboardOwner, forwardParentId: undefined }
1594
+ : this.resolveStructureTarget(parentId);
1595
+ if (!target || target.child.worldId !== clipboardOwner.worldId) {
1596
+ editorConsole.warn(
1597
+ '[CompositeAuthoringAdapter] refusing to paste an entity across world roots.',
1598
+ 'authoring',
1599
+ );
1600
+ return false;
1601
+ }
1602
+ return await pasteAuthoringNodes(clipboardOwner.adapter, target.forwardParentId ?? null);
1603
+ },
1604
+ duplicate: (id) => {
1605
+ if (isGroupNodeId(id) || isOrganizationNodeId(id)) return { id, ack: undefined };
1606
+ const owner = this.findOwnerChild(id);
1607
+ if (!owner?.adapter.structure) {
1608
+ editorConsole.warn(
1609
+ `[CompositeAuthoringAdapter] duplicate: no owner/structure for id "${id}"`,
1610
+ 'authoring',
1611
+ );
1612
+ return { id, ack: undefined };
1613
+ }
1614
+ return duplicateAuthoringNode(owner.adapter, id);
1615
+ },
1616
+ reparent: (id, newParentId) => {
1617
+ const owner = this.findOwnerChild(id);
1618
+ if (!owner?.adapter.structure) {
1619
+ editorConsole.warn(
1620
+ `[CompositeAuthoringAdapter] reparent: no owner/structure for id "${id}"`,
1621
+ 'authoring',
1622
+ );
1623
+ return;
1624
+ }
1625
+ if (newParentId === null || newParentId === groupNodeId(owner.worldId)) {
1626
+ return reparentAuthoringNode(owner.adapter, id, null); // -> a root of its own world
1627
+ }
1628
+ const newOwner = this.findOwnerChild(newParentId);
1629
+ if (!newOwner || newOwner.worldId !== owner.worldId) {
1630
+ editorConsole.warn(
1631
+ `[CompositeAuthoringAdapter] reparent: refusing to move "${id}" across roots ` +
1632
+ `(target parent "${newParentId}" is not in world "${owner.worldId}")`,
1633
+ 'authoring',
1634
+ );
1635
+ return;
1636
+ }
1637
+ const newParent = newOwner.adapter.hierarchy.node(newParentId);
1638
+ if (newParent?.role === 'document' || newParent?.role === 'story') {
1639
+ return reparentAuthoringNode(owner.adapter, id, null);
1640
+ }
1641
+ return reparentAuthoringNode(owner.adapter, id, newParentId);
1642
+ },
1643
+ reorder: (id, beforeSiblingId) => {
1644
+ if (isGroupNodeId(id) || isOrganizationNodeId(id)) return;
1645
+ const owner = this.findOwnerChild(id);
1646
+ if (!owner) return;
1647
+ // Cross-world guard: a non-null sibling anchor MUST belong to the same
1648
+ // child as `id`. Otherwise (e.g. a drag onto another world's row whose
1649
+ // reparent was already refused) the child maps the foreign sibling id to
1650
+ // "append to end" and silently moves the entity within its own list on an
1651
+ // operation the composite just rejected. A null anchor (move to end) is
1652
+ // always valid.
1653
+ if (beforeSiblingId !== null && this.findOwnerChild(beforeSiblingId) !== owner) return;
1654
+ return reorderAuthoringNode(owner.adapter, id, beforeSiblingId);
1655
+ },
1656
+ creatableKinds: (parentId) => {
1657
+ const target = this.resolveStructureTarget(parentId);
1658
+ return (
1659
+ target?.child.adapter.structure?.creatableKinds?.(target.forwardParentId ?? null) ?? []
1660
+ );
1661
+ },
1662
+ // D3.a (spec 27 §6) — forward wrap/unwrap to the owning child, same
1663
+ // owner-lookup + loud-warn-on-miss shape as `duplicate`/`remove` above
1664
+ // (unlike `reorder`, which mirrors the base contract's OWN "absent means
1665
+ // no UI" silent-no-op convention — wrap/unwrap follow the LOUD group
1666
+ // instead since they're triggered from an explicit, always-visible menu
1667
+ // item, same as duplicate/delete).
1668
+ wrap: (id, wrapperTag) => {
1669
+ if (isGroupNodeId(id) || isOrganizationNodeId(id)) return;
1670
+ const owner = this.findOwnerChild(id);
1671
+ if (!owner?.adapter.structure?.wrap) {
1672
+ editorConsole.warn(
1673
+ `[CompositeAuthoringAdapter] wrap: no owner/structure.wrap for id "${id}"`,
1674
+ 'authoring',
1675
+ );
1676
+ return;
1677
+ }
1678
+ return wrapAuthoringNode(owner.adapter, id, wrapperTag);
1679
+ },
1680
+ unwrap: (id) => {
1681
+ if (isGroupNodeId(id) || isOrganizationNodeId(id)) return;
1682
+ const owner = this.findOwnerChild(id);
1683
+ if (!owner?.adapter.structure?.unwrap) {
1684
+ editorConsole.warn(
1685
+ `[CompositeAuthoringAdapter] unwrap: no owner/structure.unwrap for id "${id}"`,
1686
+ 'authoring',
1687
+ );
1688
+ return;
1689
+ }
1690
+ return unwrapAuthoringNode(owner.adapter, id);
1691
+ },
1692
+ // A cross-world selection is REFUSED whole rather than grouped in whichever
1693
+ // world came first — the same degrade `removeMany` makes for a batch whose
1694
+ // owners disagree. Nothing was written, so there is nothing to ack.
1695
+ group: (ids) => {
1696
+ if (ids.length === 0) return { id: null, ack: undefined };
1697
+ const owners = ids.map((id) => this.findOwnerChild(id));
1698
+ const owner = owners[0];
1699
+ if (
1700
+ !owner?.adapter.structure?.group ||
1701
+ owners.some((candidate) => candidate?.adapter !== owner.adapter)
1702
+ ) {
1703
+ return { id: null, ack: undefined };
1704
+ }
1705
+ return groupAuthoringNodes(owner.adapter, ids);
1706
+ },
1707
+ ungroup: (id) => {
1708
+ const owner = this.findOwnerChild(id);
1709
+ return owner ? ungroupAuthoringNode(owner.adapter, id) : { ids: [], ack: undefined };
1710
+ },
1711
+ canUngroup: (id) => {
1712
+ const owner = this.findOwnerChild(id);
1713
+ return owner?.adapter.structure?.canUngroup?.(id) ?? false;
1714
+ },
1715
+ };
1716
+
1717
+ /**
1718
+ * Asset-drop pass-through. A group-node id (`world:<w>`) must NOT fall
1719
+ * through `route()`'s "unknown id -> first child" floor: that would target
1720
+ * whatever child happened to be FIRST in the array regardless of which
1721
+ * world's row was actually under the cursor. Translate
1722
+ * the group-node id to that world's child directly, forwarding `''` (the
1723
+ * root-drop sentinel) as the node id, since the synthetic `world:<w>`
1724
+ * string means nothing to the child's own hierarchy.
1725
+ */
1726
+ private resolveAssetDropTarget(
1727
+ nodeId: string,
1728
+ ): { adapter: AuthoringAdapter; forwardId: string } | null {
1729
+ // A VIEWPORT drop names no node (`''`): it means "into the spatial world
1730
+ // under the pointer". Route it to the one three-surface child that accepts
1731
+ // drops — every design session is a composite (world + UI), and the
1732
+ // unknown-id refusal below made a drag from Content onto the 3D view die
1733
+ // silently on all of them (runhuman pass 45). Two spatial worlds would be
1734
+ // ambiguous, and the refusal stands for that case.
1735
+ if (nodeId === '') {
1736
+ const spatial = this.children.filter(
1737
+ (c) => c.kind === 'three' && c.role !== 'surface' && c.adapter.assetDrop !== undefined,
1738
+ );
1739
+ return spatial.length === 1 && spatial[0]
1740
+ ? { adapter: spatial[0].adapter, forwardId: '' }
1741
+ : null;
1742
+ }
1743
+ if (isGroupNodeId(nodeId)) {
1744
+ const worldId = nodeId.slice(GROUP_PREFIX.length);
1745
+ const child = this.children.find((c) => c.worldId === worldId);
1746
+ return child ? { adapter: child.adapter, forwardId: '' } : null;
1747
+ }
1748
+ const owner = this.findOwnerChild(nodeId);
1749
+ // Unknown real id: REFUSED (`routeOwned`), not floored onto the first child —
1750
+ // dropping an asset onto a row nothing owns must not land it in whatever
1751
+ // world happens to be first.
1752
+ if (!owner) {
1753
+ this.routeOwned(nodeId, 'assetDrop');
1754
+ return null;
1755
+ }
1756
+ const node = owner.adapter.hierarchy.node(nodeId);
1757
+ return {
1758
+ adapter: owner.adapter,
1759
+ forwardId: node?.role === 'document' || node?.role === 'story' ? '' : nodeId,
1760
+ };
1761
+ }
1762
+
1763
+ /**
1764
+ * D4 (B2) — resolves a `stories` call THE SAME WAY `resolveAssetDropTarget`
1765
+ * (above) resolves an asset drop: a group-node id (`world:<w>`) used to
1766
+ * fall through `route()`'s "unknown id -> first child" floor, so
1767
+ * `storiesFor('world:hud')` silently asked whatever child happened to be
1768
+ * FIRST in the array (never the actual owner), meaning the story picker
1769
+ * could NEVER appear on the world's own group row — the one place B2's
1770
+ * shell (`Inspector.tsx`) actually calls it. Translated to `<w>`'s own
1771
+ * child adapter. Unlike the asset-drop translation, the node id is
1772
+ * forwarded UNCHANGED (not blanked to `''`) — B2's `ReactRootAuthoringAdapter.stories`
1773
+ * is world-level and ignores `nodeId` entirely (see that class's doc
1774
+ * comment), but a future per-node catalog (B3) will want the real id, so
1775
+ * there is no sentinel to invent here.
1776
+ */
1777
+ private resolveStoriesTarget(
1778
+ nodeId: string,
1779
+ ): { adapter: AuthoringAdapter; forwardId: string } | null {
1780
+ if (isGroupNodeId(nodeId)) {
1781
+ const worldId = nodeId.slice(GROUP_PREFIX.length);
1782
+ const child = this.children.find((c) => c.worldId === worldId);
1783
+ return child ? { adapter: child.adapter, forwardId: nodeId } : null;
1784
+ }
1785
+ const adapter = this.routeOwned(nodeId, 'stories');
1786
+ return adapter ? { adapter, forwardId: nodeId } : null;
1787
+ }
1788
+
1789
+ /** D4 — storybook stories, routed by node ownership (group-node ids
1790
+ * translated — see `resolveStoriesTarget` above, the same fix
1791
+ * `resolveAssetDropTarget` applied for asset drops). A node whose owning
1792
+ * adapter has no `stories` provider reports/does nothing (empty list, null
1793
+ * active, no-op apply/isolate) rather than throwing. */
1794
+ readonly stories: StoriesProvider = {
1795
+ storiesFor: (nodeId): StoryRef[] => {
1796
+ // THE SCOPE PROBE IS NOT AN UNOWNED ID. `storiesFor(WORLD_SCOPE_NODE_ID)`
1797
+ // is the protocol's world-level question ("list YOUR stories", asked with
1798
+ // no node in hand — `authoring/stories-scope.ts`), and this provider is
1799
+ // node-scoped BY CONSTRUCTION: it routes every call by node ownership, so
1800
+ // its honest answer with no node is the empty list. Routing the probe
1801
+ // through `routeOwned` produced that same empty list plus a loud
1802
+ // unowned-id error on every probe — noise for the one answer the protocol
1803
+ // asks for. Only this verb short-circuits: `active`/`apply`/`isolate` are
1804
+ // reached with the sentinel only after `storiesFor` answered rows, which
1805
+ // this provider never does, so an empty id arriving there IS a caller
1806
+ // error and keeps its error.
1807
+ if (nodeId === WORLD_SCOPE_NODE_ID) return [];
1808
+ const target = this.resolveStoriesTarget(nodeId);
1809
+ return target?.adapter.stories?.storiesFor(target.forwardId) ?? [];
1810
+ },
1811
+ active: (nodeId) => {
1812
+ const target = this.resolveStoriesTarget(nodeId);
1813
+ return target?.adapter.stories?.active(target.forwardId) ?? null;
1814
+ },
1815
+ apply: (nodeId, storyId) => {
1816
+ const target = this.resolveStoriesTarget(nodeId);
1817
+ const provider = target?.adapter.stories;
1818
+ if (!target || !provider) return;
1819
+ recordAuthoringConsumerUse({
1820
+ adapter: target.adapter,
1821
+ seam: 'editor.stories.apply',
1822
+ stage: 'effect',
1823
+ detail: `the composite applied story ${storyId ?? 'default'} through its owning adapter`,
1824
+ run: () => provider.apply(target.forwardId, storyId),
1825
+ });
1826
+ },
1827
+ isolate: (nodeId, storyId) => {
1828
+ // A null nodeId (exit isolation) has no owner to route by — forward the
1829
+ // exit to every child that implements `isolate` so nothing is left stuck
1830
+ // in an isolated state.
1831
+ if (nodeId === null) {
1832
+ for (const child of this.children) child.adapter.stories?.isolate?.(null, storyId);
1833
+ return;
1834
+ }
1835
+ const target = this.resolveStoriesTarget(nodeId);
1836
+ target?.adapter.stories?.isolate?.(target.forwardId, storyId);
1837
+ },
1838
+ };
1839
+
1840
+ /** Atomic authored assets, routed by the same node ownership as Inspector. */
1841
+ readonly assetSubject: AssetSubjectProvider = {
1842
+ get: (id) => this.findOwnerChild(id)?.adapter.assetSubject?.get(id) ?? null,
1843
+ entries: () =>
1844
+ this.children.flatMap((child) =>
1845
+ childAssetEntries(child.adapter).map(({ id, subject }) => ({
1846
+ id: `${child.worldId}:${id}`,
1847
+ subject,
1848
+ })),
1849
+ ),
1850
+ };
1851
+
1852
+ /** Asset drop, routed by node ownership (group-node ids translated — see
1853
+ * `resolveAssetDropTarget` above). */
1854
+ readonly assetDrop: AssetDropProvider = {
1855
+ accepts: (nodeId, assetPath, context) => {
1856
+ const target = this.resolveAssetDropTarget(nodeId);
1857
+ const provider = target?.adapter.assetDrop;
1858
+ if (!target || !provider) return false;
1859
+ return context === undefined
1860
+ ? provider.accepts(target.forwardId, assetPath)
1861
+ : provider.accepts(target.forwardId, assetPath, context);
1862
+ },
1863
+ drop: (nodeId, assetPath, context) => {
1864
+ const target = this.resolveAssetDropTarget(nodeId);
1865
+ const provider = target?.adapter.assetDrop;
1866
+ if (!target || !provider) {
1867
+ // A human gesture never dies mutely: name what the composition holds
1868
+ // so the refusal is a fact, not a mystery (runhuman pass 45).
1869
+ const worlds = this.children
1870
+ .map((c) => `${c.worldId}:${c.kind}${c.adapter.assetDrop ? '' : ' (no drop target)'}`)
1871
+ .join(', ');
1872
+ editorConsole.warn(
1873
+ `Dropped ${assetPath} was not placed: ${
1874
+ nodeId === ''
1875
+ ? 'no single spatial world accepts a viewport drop'
1876
+ : `“${nodeId}” has no owning world`
1877
+ } — worlds here: ${worlds}.`,
1878
+ 'editor',
1879
+ );
1880
+ return;
1881
+ }
1882
+ return dropAuthoringAsset(target.adapter, target.forwardId, assetPath, context);
1883
+ },
1884
+ };
1885
+
1886
+ // NOTE: `pickable` is deliberately NOT implemented here — layered viewport
1887
+ // picking (D12) across children is shell policy (B4), not something this
1888
+ // composite merges. Callers needing per-layer pick should go through
1889
+ // `childAdapters()` and pick each child's own `pickable` themselves.
1890
+ //
1891
+ // NOTE: `provenance` is deliberately NOT implemented here either, for the
1892
+ // complementary reason: provenance is a PER-WORLD declaration, and a
1893
+ // composite spans worlds whose declarations differ (a stamped ingest canvas
1894
+ // beside a live DOM HUD) — one adapter-level value would assert one world's
1895
+ // truth over another's rows, which is the fabrication the anti-shim rule
1896
+ // forbids. The shell resolves it per node through
1897
+ // `authoring/provenance.ts`'s governing-adapter hop, and the hierarchy's
1898
+ // world group rows read each child's own declaration off `childAdapters()`.
1899
+ //
1900
+ // Both absences are DELIVERED capabilities measured at a different door;
1901
+ // `adapter-reach.ts`'s `measureAdapter` states this so the coverage report
1902
+ // does not read them as gaps.
1903
+
1904
+ /**
1905
+ * Truth resolution, routed by SUBJECT OWNERSHIP exactly as
1906
+ * {@link inspector} routes: the child that actually owns the node answers
1907
+ * for it.
1908
+ *
1909
+ * PRESENT ONLY when at least one child actually indexes, because ABSENCE is
1910
+ * itself read as a fact by the shell and a composite must not answer for a
1911
+ * composition whose children index nothing: `shell-document-ops.ts`'s
1912
+ * `activeSelectionCreationSite` treats absence as "nothing to ask" (never a
1913
+ * fabricated unanchored verdict). It reads the ACTIVE adapter, which is this
1914
+ * composite for every promoted session (ingest with a detected DOM UI,
1915
+ * ingest+siblings, and ALL first-party play) — so without this, those
1916
+ * sessions lost creation-site reveal entirely even when the child holding
1917
+ * the running objects had a full index.
1918
+ *
1919
+ * A plain conditionally-assigned field rather than a getter, for the same
1920
+ * reason `BoundaryAuthoringAdapter.pickable` is one: under
1921
+ * `exactOptionalPropertyTypes` the key must be genuinely ABSENT, and a
1922
+ * getter's declared type would have to include `undefined`, which no longer
1923
+ * satisfies `AuthoringAdapter.truth?: TruthProvider`. NOT
1924
+ * `readonly` for the same reason `capabilities` is not — {@link
1925
+ * replaceChild} recomputes it after an in-place child swap (a design-time
1926
+ * layer mount upgrading a Boundary to a live adapter is exactly a swap that
1927
+ * can bring an index in, or take one away).
1928
+ */
1929
+ truth?: TruthProvider;
1930
+
1931
+ /** The routing provider itself never changes — only whether this adapter
1932
+ * offers it at all does. Unlike `route()`, an unowned id is NOT floored
1933
+ * onto the first child: a synthetic group/organization row has no live
1934
+ * object, and an id no child recognizes must not be answered by a world
1935
+ * that never saw it (#18). Both get the same honest `NO_OBJECT_REASON` the
1936
+ * per-world adapters give for an id with nothing behind it. */
1937
+ private readonly truthProvider: TruthProvider = {
1938
+ resolve: (id, property) => {
1939
+ if (isGroupNodeId(id) || isOrganizationNodeId(id)) {
1940
+ return {
1941
+ site: { anchored: false, reason: NO_OBJECT_REASON } as NodeCreationSite,
1942
+ writeAnchorKind: undefined,
1943
+ };
1944
+ }
1945
+ return (
1946
+ this.findOwnerChild(id)?.adapter.truth?.resolve(id, property) ?? {
1947
+ site: { anchored: false, reason: NO_OBJECT_REASON },
1948
+ writeAnchorKind: undefined,
1949
+ }
1950
+ );
1951
+ },
1952
+ };
1953
+
1954
+ private refreshTruth(): void {
1955
+ if (this.children.some((c) => c.adapter.truth)) {
1956
+ this.truth = this.truthProvider;
1957
+ } else {
1958
+ // `delete`, not `= undefined`: an explicit `undefined` is not an absent
1959
+ // property under `exactOptionalPropertyTypes`, and absence is the fact
1960
+ // both shell readers are checking for.
1961
+ delete this.truth;
1962
+ }
1963
+ }
1964
+
1965
+ /**
1966
+ * Related-subject links, routed by SUBJECT OWNERSHIP exactly as
1967
+ * {@link truth} routes — the inspector reads `adapter.related` off the
1968
+ * ACTIVE adapter (`inspection/compose.ts`), which is this composite for
1969
+ * every promoted session, so without this route every subject in a
1970
+ * composite session lost its jump links even when the owning child could
1971
+ * answer. Same conditionally-assigned shape as {@link truth}, recomputed by
1972
+ * {@link replaceChild} (a Boundary upgrading to a live adapter is exactly a
1973
+ * swap that can bring a provider in, or take one away). A synthetic
1974
+ * group/organization row has no defining document and honestly reports no
1975
+ * links.
1976
+ */
1977
+ related?: RelatedSubjectsProvider;
1978
+
1979
+ private readonly relatedProvider: RelatedSubjectsProvider = {
1980
+ links: (id) => {
1981
+ if (isGroupNodeId(id) || isOrganizationNodeId(id)) return [];
1982
+ return this.findOwnerChild(id)?.adapter.related?.links(id) ?? [];
1983
+ },
1984
+ };
1985
+
1986
+ private refreshRelated(): void {
1987
+ if (this.children.some((c) => c.adapter.related)) {
1988
+ this.related = this.relatedProvider;
1989
+ } else {
1990
+ delete this.related;
1991
+ }
1992
+ }
1993
+
1994
+ // ------------------------------------------------------- change fan-out
1995
+ //
1996
+ // A consumer subscribes to THIS composite, not to a child — and the child set
1997
+ // is not fixed. `replaceChild` swaps a world's adapter mid-session (a
1998
+ // suspended R3F world finishing its async mount replaces its read-only
1999
+ // Boundary with the live source adapter; play suspend/Stop swap it back), and
2000
+ // the previous implementation bound each subscriber directly to the children
2001
+ // that existed AT SUBSCRIBE TIME. Those bindings survived the swap pointing at
2002
+ // the OUTGOING adapter, so every consumer that subscribed before a world
2003
+ // finished mounting was permanently deaf to the world that actually mounted —
2004
+ // the hierarchy panel among them, whose only adapter-side signal is
2005
+ // `adapter.subscribe`. Callers papered over it by pushing a store
2006
+ // notification alongside every swap (`store.notifyIngestEdit()`), which is a
2007
+ // side channel around a broken seam, not the seam working.
2008
+ //
2009
+ // Now the composite keeps the LISTENERS and re-derives its child bindings
2010
+ // whenever the child set changes, and the swap itself notifies: replacing a
2011
+ // world's adapter IS a structure change, so a subscriber hears about the
2012
+ // mounted world from the composite rather than from a store call the caller
2013
+ // has to remember.
2014
+ private readonly structureListeners = new Set<() => void>();
2015
+ /** Live child bindings, or `null` when nothing is listening. */
2016
+ private childFanOutUnsubs: Array<() => void> | null = null;
2017
+
2018
+ private readonly fanOutStructure = (): void => {
2019
+ for (const listener of [...this.structureListeners]) listener();
2020
+ };
2021
+
2022
+ /** (Re)bind child subscriptions to match the CURRENT children and the current
2023
+ * listener demand. Idempotent; the only mutator of `childFanOutUnsubs`. */
2024
+ private syncChildFanOut(): void {
2025
+ if (this.childFanOutUnsubs) {
2026
+ for (const unsub of this.childFanOutUnsubs) unsub();
2027
+ this.childFanOutUnsubs = null;
2028
+ }
2029
+ if (this.structureListeners.size === 0) return;
2030
+ const unsubs: Array<() => void> = [];
2031
+ for (const child of this.children) {
2032
+ if (this.structureListeners.size > 0) {
2033
+ const subscribe = child.adapter.subscribe;
2034
+ const unsub = subscribe
2035
+ ? recordAuthoringConsumerUse({
2036
+ adapter: child.adapter,
2037
+ seam: 'editor.subscribe',
2038
+ stage: 'effect',
2039
+ detail: 'the composite subscribed to child authoring changes',
2040
+ run: () => subscribe.call(child.adapter, this.fanOutStructure),
2041
+ })
2042
+ : undefined;
2043
+ if (unsub) unsubs.push(unsub);
2044
+ }
2045
+ }
2046
+ this.childFanOutUnsubs = unsubs;
2047
+ }
2048
+
2049
+ subscribe(listener: () => void): () => void {
2050
+ this.structureListeners.add(listener);
2051
+ this.syncChildFanOut();
2052
+ return () => {
2053
+ this.structureListeners.delete(listener);
2054
+ this.syncChildFanOut();
2055
+ };
2056
+ }
2057
+
2058
+ /**
2059
+ * Persistence (T3.2 slice 3, generalized 2→N unchanged in semantics): `isDirty`
2060
+ * = OR of children that actually have a provider (a child without one, e.g. an
2061
+ * no-authoring adapter, is simply skipped — it has nothing to be dirty about);
2062
+ * `save()` saves the dirty children SEQUENTIALLY, logging loudly per-child on
2063
+ * failure without aborting the rest; `destination` joins child destinations
2064
+ * with `' + '`.
2065
+ *
2066
+ * History is resource-driven, so the composite needs no ordering or inverse
2067
+ * logic of its own; its children journal into the open project's service.
2068
+ */
2069
+ get persistence(): PersistenceProvider {
2070
+ // Always a REAL (never `undefined`) provider — see composite-authoring-adapter's
2071
+ // 2-child precedent / `ephemeral-persistence.ts` for the same pattern.
2072
+ const persistable = this.children.filter(
2073
+ (
2074
+ c,
2075
+ ): c is CompositeChild & {
2076
+ adapter: AuthoringAdapter & { persistence: PersistenceProvider };
2077
+ } => c.adapter.capabilities.persist && !!c.adapter.persistence,
2078
+ );
2079
+ return {
2080
+ isDirty: () => persistable.some((c) => c.adapter.persistence.isDirty()),
2081
+ lastError: () => {
2082
+ const errors = new Set<string>();
2083
+ for (const child of persistable) {
2084
+ const error = child.adapter.persistence.lastError?.();
2085
+ if (error) errors.add(error);
2086
+ }
2087
+ return errors.size > 0 ? [...errors].join(' · ') : null;
2088
+ },
2089
+ save: async () => {
2090
+ for (const c of persistable) {
2091
+ const p = c.adapter.persistence;
2092
+ if (!p.isDirty()) continue;
2093
+ try {
2094
+ await saveAuthoringDocument(c.adapter, `the composite saved dirty child ${c.worldId}`);
2095
+ } catch (err) {
2096
+ console.error(
2097
+ `[CompositeAuthoringAdapter] save failed for world "${c.worldId}" ` +
2098
+ `(destination: ${p.destination}) — continuing with remaining roots:`,
2099
+ err,
2100
+ );
2101
+ }
2102
+ }
2103
+ },
2104
+ destination:
2105
+ persistable.length > 0
2106
+ ? persistable.map((c) => c.adapter.persistence.destination).join(' + ')
2107
+ : NO_PERSISTABLE_CHILD_DESTINATION,
2108
+ };
2109
+ }
2110
+ }