@volter/sdk 0.0.0-stage → 0.5.204

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