@volter/sdk 0.0.0-stage → 0.5.203

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 +1166 -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 +317 -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 +403 -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,1918 @@
1
+ /**
2
+ * Browser loader for editor contribution modules. Callable discovery/execution
3
+ * stays Node-side; this module imports the contribution modules the server
4
+ * FOUND BY SCANNING (`server/project-tools.ts`) and reads each one's own
5
+ * declaration — `point`, `title`/`presentations`, and the `tool` it drives.
6
+ *
7
+ * Nothing about a contribution is written down anywhere but the module.
8
+ */
9
+
10
+ import type { IconDefinition } from '@fortawesome/fontawesome-svg-core';
11
+ import { faScrewdriverWrench } from '@fortawesome/free-solid-svg-icons';
12
+ import { EditorClient } from '@volter/sdk';
13
+ import type {
14
+ ToolAnalyticsContributionProps,
15
+ ToolAssetInspectorContributionMatch,
16
+ ToolAssetInspectorContributionProps,
17
+ ToolContributionNode,
18
+ ToolContributionProps,
19
+ ToolGenerationResultContributionMatch,
20
+ ToolGenerationResultContributionProps,
21
+ ToolInspectorContributionMatch,
22
+ ToolInspectorContributionProps,
23
+ ToolUtilityContributionProps,
24
+ } from '@volter/sdk/contributions';
25
+ import { registerProjectModuleLoader } from '@volter/sdk/contributions';
26
+ import {
27
+ chromeContributionKind,
28
+ isCommandContribution,
29
+ isConfigurationKindContribution,
30
+ isEditorLanePath,
31
+ isFinderContribution,
32
+ isServiceContribution,
33
+ lookContributionKind,
34
+ type LookContributionKind,
35
+ TOOL_CONTRIBUTION_SUFFIXES,
36
+ } from '@volter/sdk/session/tool-contribution-convention';
37
+ import type { AuthoringAdapter, EditorNode } from '@volter/project/adapter';
38
+ import type { ToolContributionPoint } from '@volter/sdk/project-tool-catalog';
39
+ import { type ComponentType, createElement } from 'react';
40
+ import { getCurrentProject } from '@volter/sdk/kit/active-project';
41
+ import {
42
+ isContributableMenu,
43
+ registerContributedActions,
44
+ registerContributedHeaderItem,
45
+ registerContributedMenu,
46
+ } from '@volter/sdk/kit/chrome-registry';
47
+ import { contributedCommandRows, registerContributedCommands } from '@volter/sdk/kit/command-registry';
48
+ import { InspectorToolSection } from './components/InspectorToolSection';
49
+ import { ToolHost } from './components/ToolHost';
50
+ import { editorServerJson } from '@volter/sdk/kit/editor-server-response';
51
+ import { CONTRIBUTED_SECTION_ORDER } from '@volter/sdk/kit/inspection-model';
52
+ import {
53
+ type InspectorSectionIcon,
54
+ type InspectorSectionMatchContext,
55
+ registerInspectorSections,
56
+ } from '@volter/sdk/kit/inspector-section-registry';
57
+ import { registerContributedKeymap, reportUnavailableKeymap } from '@volter/sdk/kit/keymap-presets';
58
+ import { projectMounts } from '@volter/sdk/kit/project-shape';
59
+ import type { ProjectToolCatalog, ProjectToolCatalogEntry } from '@volter/sdk/kit/project-tools';
60
+ import { publishPlayUtilitiesReady } from '@volter/sdk/kit/workspace-play-utilities';
61
+ import { registerContributedWorkspace } from './workspace-presets';
62
+ import { registerWorkspaceStatus } from '@volter/sdk/kit/workspace-status-registry';
63
+ import { registerContributedStyle } from './workspace-style';
64
+ import { registerViewPreset, type ViewPreset } from '@volter/sdk/kit/viewport-presentation';
65
+ import { type EnvironmentImageSet, registerEnvironmentImages } from '@volter/sdk/kit/environment-images';
66
+ import { registerWorkspaceUtility } from '@volter/sdk/kit/workspace-utility-registry';
67
+
68
+ /**
69
+ * A SUBJECT-DEPENDENT GLYPH NAME (see {@link LoadedToolContributionBase.icon}).
70
+ * `adapter` is the active authoring adapter, deliberately `unknown` here for
71
+ * the same reason it is at the contribution point: a contribution reads its
72
+ * own adapter's native API and the host fabricates no projection of it.
73
+ */
74
+ export type ToolContributionIconResolver = (
75
+ node: ToolContributionNode | null,
76
+ adapter: unknown,
77
+ ) => string;
78
+
79
+ interface LoadedToolContributionBase {
80
+ /**
81
+ * Path-derived contribution id (`humanoid-builder.document`), or the
82
+ * presentation id for a many-mount module. It used to be prefixed with the
83
+ * owning tool's name because contributions were nested under one; they are
84
+ * not, so the id is simply its own.
85
+ */
86
+ id: string;
87
+ title: string;
88
+ file: string;
89
+ version: number;
90
+ /**
91
+ * `export const icon = 'properties-data'` — the NAME of the glyph that
92
+ * stands for this contribution wherever the host shows one. A name, so the
93
+ * active icon set paints it (and may tint it by category); the host's
94
+ * generic tool glyph when omitted. Read by `selection.inspector` today.
95
+ *
96
+ * IT MAY ALSO BE A FUNCTION OF THE SUBJECT, and that is Blender's own shape
97
+ * rather than a generalization: the Properties editor's Object Data tab
98
+ * draws a DIFFERENT mark per object type — `buttons_context_compute`
99
+ * (`space_buttons/buttons_context.cc:795-810`) sets `sbuts->dataicon` from
100
+ * `RNA_struct_ui_icon(ptr->type)`, so a mesh's tab is `MESH_DATA`, an
101
+ * armature's `ARMATURE_DATA` and a light's `OUTLINER_DATA_LIGHT`. A static
102
+ * export cannot say that. The resolver is handed the same `(node, adapter)`
103
+ * a `selection.inspector` matcher gets and answers a glyph NAME; the host
104
+ * reads either shape (`inspection/compose.ts`).
105
+ */
106
+ icon?: string | ToolContributionIconResolver;
107
+ /**
108
+ * THE PACKAGE (or project folder) THIS MODULE SHIPPED IN, derived from
109
+ * `file`. It is what makes "a document's package owns its Properties rail"
110
+ * answerable: the composer keeps the sections whose owner is the active
111
+ * document's own package and stands every other one down
112
+ * (`inspection/compose.ts`, `ComposeInspectionInput.rail`).
113
+ */
114
+ owner: string;
115
+ /**
116
+ * `export const railGroup = 'scene'` — which GROUP of the Properties rail
117
+ * this section belongs to. Blender's own rail is grouped, not a flat strip:
118
+ * `ED_buttons_tabs_list` (`space_buttons/space_buttons.cc:201-255`) calls
119
+ * `add_spacer()` between the tool tab, the scene group (Render, Output, View
120
+ * Layer, Scene, World), Collection, the object group (Object … Material) and
121
+ * Texture, and each spacer appends a `BCONTEXT_SEPARATOR` the rail draws as
122
+ * a gap. The name is free and compared for equality only; consecutive
123
+ * sections sharing one form a group, and the presentation rules between
124
+ * groups (`components/InspectionProjection.tsx`). Read by
125
+ * `selection.inspector` alone, like `order` and `icon`.
126
+ */
127
+ railGroup?: string;
128
+ /**
129
+ * `export const railDefault = true` — this is the tab the Properties rail
130
+ * OPENS ON when nothing has been chosen for this subject yet. Blender's
131
+ * Properties editor does not open on its first tab: the context is stored
132
+ * per screen in the startup file, and at factory settings
133
+ * `bpy.data.screens['Layout']` reads `OBJECT` while the rail's first tab is
134
+ * Render (measured against Blender 5.2 LTS, walk 5 parity row 3). Without a
135
+ * declaration the presentation falls back to the first tab, which is what
136
+ * every non-Blender rail wants. Read by `selection.inspector` alone, like
137
+ * `order`, `icon` and `railGroup`. A `(node, adapter)` resolver chooses a
138
+ * subject-dependent fallback, such as Scene when no active object exists.
139
+ */
140
+ railDefault?: boolean | ((node: ToolContributionNode | null, adapter: unknown) => boolean);
141
+ /**
142
+ * `export const order = 2` — where this contribution sits among its
143
+ * siblings, low first, INSIDE the contributed band
144
+ * ({@link contributedSectionOrder}). Read where sibling sequence is a real
145
+ * question, which today is `selection.inspector` alone.
146
+ *
147
+ * OPTIONAL, and a module that declares none sits BEHIND every module that
148
+ * did, keeping its discovery position among the other silent ones:
149
+ * declaring is how a module claims a place, and staying silent is how it
150
+ * takes whatever is left. Range 0…{@link MAX_DECLARED_ORDER}, so no
151
+ * declaration can climb out of the band and over a built-in block.
152
+ *
153
+ * It exists because the discovery scan is alphabetical, which is an
154
+ * accident of filenames rather than a statement about the surface: the
155
+ * dev-tools capability's four facet sections are Stats, Cheats, Tuning,
156
+ * Autopilot in the ruling that created them, and would otherwise mount
157
+ * Autopilot → Cheats → Stats → Tuning. A module states its own position
158
+ * because it is the only thing that knows it.
159
+ */
160
+ order?: number;
161
+ }
162
+
163
+ /**
164
+ * The points that DRIVE a callable, and therefore must name one.
165
+ *
166
+ * `tool` lives here rather than on the base because the PRESENTING points do
167
+ * not: {@link UtilityToolContribution}, {@link InspectorToolContribution},
168
+ * and {@link GlobalToolContribution} (a document may be a pure view — a
169
+ * game's Data Book over its own src/data modules). Keeping it off the base
170
+ * is what makes the relaxation apply to exactly those, instead of quietly
171
+ * making every consumer of every other point handle `undefined`.
172
+ */
173
+ interface CallableToolContributionBase extends LoadedToolContributionBase {
174
+ /**
175
+ * The catalog entry the module NAMED (`export const tool = 'project.x.y'`),
176
+ * resolved here. The editor still needs the whole entry — generation role and
177
+ * provider group the generative documents, summary/description search them —
178
+ * so resolution happens once, at load, rather than at every consumer.
179
+ */
180
+ tool: ProjectToolCatalogEntry;
181
+ }
182
+
183
+ export interface GlobalToolContribution extends LoadedToolContributionBase {
184
+ point: 'workspace.document';
185
+ /** Present when the document DRIVES a callable; absent for a pure view. */
186
+ tool?: ProjectToolCatalogEntry;
187
+ Component: ComponentType<ToolContributionProps>;
188
+ /**
189
+ * `export const standing = true` — this document is a PLACE in the project,
190
+ * not an errand: the editor opens it with the project and it is not
191
+ * closeable, exactly like the `3D` / `UI` / `Dev` boards. The default is an
192
+ * action-opened document you reach from the command palette.
193
+ *
194
+ * A capability that owns a whole authoring surface (the `data-tables` tab is
195
+ * the shipped case) declares this; a one-off runner does not.
196
+ */
197
+ standing: boolean;
198
+ /** `export const documentKind = 'model'` — this document is THE EDITOR for
199
+ * every adapter-table document of that kind (`ToolDocumentEntry`): a
200
+ * Content entry of the kind opens as its own document mounting this
201
+ * contribution, never as its source. */
202
+ documentKind?: string;
203
+ /** `export const Toolbar` — the document's header, rendered in the host's
204
+ * own strip (`WorkspaceDocumentSurface`). See `ToolDocumentToolbar`. */
205
+ Toolbar?: ComponentType<ToolContributionProps>;
206
+ /** `export const Shelf` — the document's tool rail (`DocumentShelfRail`). */
207
+ Shelf?: ComponentType<ToolContributionProps>;
208
+ /**
209
+ * `export const inspectorRail = 'owned'` — A DOCUMENT'S PACKAGE OWNS ITS
210
+ * PROPERTIES RAIL (owner ruling, WORK.md §Blender in the tab is Blender,
211
+ * "Inspection parity", I2 decision 1).
212
+ *
213
+ * On a `model` document the rail is Blender's alone. Blender's Properties
214
+ * editor draws the tabs `ED_buttons_tabs_list` returns AND NOTHING ELSE, so
215
+ * a rail that also carries the host's Preview, Transform, Object, Geometry
216
+ * and Materials blocks beside them is not Blender's rail — it is Blender's
217
+ * rail plus another editor's. Declaring this stands the host's own sections
218
+ * down: the composer keeps only the sections contributed by THIS
219
+ * contribution's own package ({@link LoadedToolContributionBase.owner}),
220
+ * plus whatever built-ins {@link inspectorBuiltins} names by id.
221
+ *
222
+ * It is a DOCUMENT's declaration rather than a section's because the
223
+ * question is "what does this editor show", which no single section knows.
224
+ * A document that declares nothing is unchanged: every section composes.
225
+ */
226
+ inspectorRail?: 'owned';
227
+ /**
228
+ * `export const inspectorBuiltins = ['preview']` — the host built-ins this
229
+ * document keeps beside its package's own sections, BY ID
230
+ * (`inspection/model.ts`'s `PREVIEW_SECTION_ID`, `TRANSFORM_SECTION_ID`,
231
+ * `PROPERTIES_SECTION_ID`, `STORIES_SECTION_ID`, a `group:<id>`). Read only
232
+ * with `inspectorRail = 'owned'`; empty or absent means none.
233
+ */
234
+ inspectorBuiltins?: readonly string[];
235
+ }
236
+
237
+ /**
238
+ * A project contribution that mounts as a TAB IN THE BOTTOM DRAWER
239
+ * (`workspace-utility-registry.ts`), in that registry's `project` cluster.
240
+ *
241
+ * The split from {@link GlobalToolContribution} is the drawer/center split the
242
+ * registries already draw: a document is a substantial editor you work IN, a
243
+ * utility is a transient output/record surface you glance at while working
244
+ * somewhere else. So there is no `standing` here — a utility's tab is its own
245
+ * presence, and the drawer already owns whether it is revealed.
246
+ */
247
+ export interface UtilityToolContribution extends LoadedToolContributionBase {
248
+ point: 'workspace.utility';
249
+ Component: ComponentType<ToolUtilityContributionProps>;
250
+ /**
251
+ * `export const available = () => …` — session-scoped availability, the
252
+ * registry's own optional gate (`workspace-utility-registry.ts`): a tab
253
+ * over a live adapter is on offer only while that adapter is registered.
254
+ * Absent means always available.
255
+ */
256
+ available?: () => boolean;
257
+ /**
258
+ * OPTIONAL here, as it is on a {@link InspectorToolContribution}, and
259
+ * required at the three points that RUN (`workspace.document`,
260
+ * `asset.inspector`, `generation.result`): a record-reading panel drives no
261
+ * single callable, so demanding one bought only a false association.
262
+ * Declaring a real one is still allowed and still resolved.
263
+ */
264
+ tool?: ProjectToolCatalogEntry;
265
+ }
266
+
267
+ export interface AnalyticsToolContribution extends LoadedToolContributionBase {
268
+ point: 'workspace.analytics';
269
+ Component: ComponentType<ToolAnalyticsContributionProps>;
270
+ tool?: ProjectToolCatalogEntry;
271
+ }
272
+
273
+ /**
274
+ * A STATUS-BAR item (`workspace-status-registry.ts`): compact, passive,
275
+ * registered under `tool:<id>` like a drawer tab. `export const align` picks
276
+ * the bar's side (default left); `order` is the slot order within it. Never
277
+ * mounted through `ToolHost` — it is a bare component the status bar renders.
278
+ */
279
+ export interface StatusToolContribution extends LoadedToolContributionBase {
280
+ point: 'workspace.status';
281
+ Component: ComponentType;
282
+ align: 'left' | 'right';
283
+ }
284
+
285
+ /** The two points the editor mounts as a WHOLE React surface, and therefore
286
+ * the two `ToolHost` renders (`components/ToolHost.tsx`). */
287
+ export type SurfaceToolContribution =
288
+ | GlobalToolContribution
289
+ | UtilityToolContribution
290
+ | AnalyticsToolContribution;
291
+
292
+ type DrawerToolContribution = UtilityToolContribution | AnalyticsToolContribution;
293
+
294
+ export interface InspectorToolContribution extends LoadedToolContributionBase {
295
+ point: 'selection.inspector';
296
+ Component: ComponentType<ToolInspectorContributionProps>;
297
+ match: ToolInspectorContributionMatch;
298
+ /**
299
+ * OPTIONAL, like a utility's: a section may present state the editor already
300
+ * has — live readings, verbs a game registered as debug commands — and drive
301
+ * no single registered callable. Demanding one there bought only a false
302
+ * association. A section that DOES commit through a callable still declares
303
+ * it and still gets the resolved entry.
304
+ */
305
+ tool?: ProjectToolCatalogEntry;
306
+ }
307
+
308
+ export interface AssetInspectorToolContribution extends LoadedToolContributionBase {
309
+ point: 'asset.inspector';
310
+ Component: ComponentType<ToolAssetInspectorContributionProps>;
311
+ match: ToolAssetInspectorContributionMatch;
312
+ /** OPTIONAL, like a selection inspector's: a section presents what an ASSET
313
+ * is, and one that declares no callable is still a section. */
314
+ tool?: ProjectToolCatalogEntry;
315
+ }
316
+
317
+ export interface GenerationResultToolContribution extends CallableToolContributionBase {
318
+ point: 'generation.result';
319
+ Component: ComponentType<ToolGenerationResultContributionProps>;
320
+ match: ToolGenerationResultContributionMatch;
321
+ }
322
+
323
+ export type LoadedToolContribution =
324
+ | GlobalToolContribution
325
+ | UtilityToolContribution
326
+ | AnalyticsToolContribution
327
+ | InspectorToolContribution
328
+ | AssetInspectorToolContribution
329
+ | GenerationResultToolContribution
330
+ | StatusToolContribution;
331
+
332
+ const TEMPLATE_EXAMPLE = 'editor/contributions/my-tool.document.tsx';
333
+
334
+ /**
335
+ * Every point a contribution module may declare — the whole vocabulary, and
336
+ * the teaching error's own list, so the two cannot disagree.
337
+ *
338
+ * It must stay equal to the SDK's `ToolContributionPoint` union and to the
339
+ * server scan's `TOOL_CONTRIBUTION_SUFFIXES` (`server/server-utils.ts`): a
340
+ * point the scan finds and this loader rejects is a module that loads,
341
+ * teaching-errors, and mounts nowhere.
342
+ */
343
+ const CONTRIBUTION_POINTS: readonly ToolContributionPoint[] = [
344
+ 'workspace.document',
345
+ 'workspace.utility',
346
+ 'workspace.analytics',
347
+ 'workspace.status',
348
+ 'selection.inspector',
349
+ 'asset.inspector',
350
+ 'generation.result',
351
+ ];
352
+
353
+ /**
354
+ * One PRESENTATION of a contribution module.
355
+ *
356
+ * A module is usually mounted once and needs none of this. But one module may
357
+ * legitimately appear many times under different identities — fal's document is
358
+ * seven workspace documents ('generate-image', 'generate-audio', …) and switches
359
+ * on `contributionId` to pick its endpoint. That fan-out belongs to the module,
360
+ * next to the map it must already agree with, not to a manifest that would
361
+ * restate it.
362
+ */
363
+ export interface ToolPresentation {
364
+ id: string;
365
+ title: string;
366
+ }
367
+
368
+ /** A module's declared presentations, or `undefined` when it mounts once. */
369
+ export function readToolPresentations(mod: unknown): ToolPresentation[] | undefined {
370
+ const declared = (mod as Record<string, unknown> | null)?.['presentations'];
371
+ if (!Array.isArray(declared) || declared.length === 0) return undefined;
372
+ const presentations: ToolPresentation[] = [];
373
+ for (const entry of declared) {
374
+ const record = entry as Record<string, unknown>;
375
+ if (typeof record?.['id'] !== 'string' || typeof record['title'] !== 'string') return undefined;
376
+ presentations.push({ id: record['id'], title: record['title'] });
377
+ }
378
+ return presentations;
379
+ }
380
+
381
+ /** `editor/contributions/humanoid-builder.document.tsx` -> `humanoid-builder.document`. */
382
+ function contributionIdFromPath(entryPath: string): string {
383
+ const base = entryPath.split(/[\\/]/).pop() ?? entryPath;
384
+ return base.replace(/\.[jt]sx?$/, '');
385
+ }
386
+
387
+ /**
388
+ * One contribution module that did not load, kept as STATE rather than only as
389
+ * a console line.
390
+ *
391
+ * The console line existed already and is captured (`editor-console.ts`'s
392
+ * session-lifetime wrapper puts it in the editor's `status` command's `sessionErrors`). What
393
+ * did not exist was a RECORD any consumer could read, so every downstream
394
+ * refusal was blind: `editor.present` answered "Tool document is not
395
+ * registered: data-tables.document" while the real answer — its capability's
396
+ * npm dependency was never installed — sat in a console nobody was asked to
397
+ * read (measured live, 2026-08-14, data-tables + missing `react-data-grid`).
398
+ */
399
+ export interface ToolContributionLoadFailure {
400
+ /** The project-relative module the catalog scan found. */
401
+ entryPath: string;
402
+ /** The id that module WOULD have claimed, derived from its path. A module
403
+ * declaring `presentations` claims other ids too, so a lookup that misses
404
+ * here still reports the whole failure list rather than nothing. */
405
+ id: string;
406
+ /** The underlying import error, stringified. */
407
+ error: string;
408
+ }
409
+
410
+ /**
411
+ * The highest `order` a module may declare. The inspector's built-in blocks
412
+ * are spaced 1000 apart (`inspection/model.ts`), so the contributed band has
413
+ * exactly that much room: keeping every declaration inside it is what stops
414
+ * `export const order = 9000` from quietly outranking Transform.
415
+ */
416
+ const MAX_DECLARED_ORDER = 998;
417
+
418
+ /** Where a module that declared no `order` lands: the back of the band. */
419
+ const UNDECLARED_ORDER = 999;
420
+
421
+ /** The rejected value, as the teaching line names it. `JSON.stringify` turns
422
+ * both NaN and Infinity into `"null"`, which is the one thing a message about
423
+ * a bad number must not say. */
424
+ function describeOrder(value: unknown): string {
425
+ return typeof value === 'number' ? String(value) : JSON.stringify(value);
426
+ }
427
+
428
+ function isDeclarableOrder(value: unknown): value is number {
429
+ return (
430
+ typeof value === 'number' &&
431
+ Number.isInteger(value) &&
432
+ value >= 0 &&
433
+ value <= MAX_DECLARED_ORDER
434
+ );
435
+ }
436
+
437
+ /**
438
+ * A contributed section's absolute order — the contributed band's base plus
439
+ * what the module declared, or the back of the band when it declared nothing.
440
+ *
441
+ * Pure, and the ONE place the "declared first, silent last" contract is
442
+ * spelled, so the docblock on `order` above and the composer's sort cannot
443
+ * describe two different arrangements.
444
+ */
445
+ export function contributedSectionOrder(declared: number | undefined): number {
446
+ return CONTRIBUTED_SECTION_ORDER + (declared ?? UNDECLARED_ORDER);
447
+ }
448
+
449
+ /**
450
+ * Shape one failed contribution import into the record and the teaching line —
451
+ * the pure half of the loudness fix, so the wording is testable without a DOM.
452
+ */
453
+ export function describeContributionLoadFailure(
454
+ entryPath: string,
455
+ error: unknown,
456
+ ): { failure: ToolContributionLoadFailure; message: string } {
457
+ const detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
458
+ return {
459
+ failure: { entryPath, id: contributionIdFromPath(entryPath), error: detail },
460
+ message:
461
+ `[tool contributions] failed to load ${entryPath}; fix the module, or rename it out of ` +
462
+ `the *.document/*.utility/*.inspector/*.result convention if it is not a contribution.\n${detail}`,
463
+ };
464
+ }
465
+
466
+ /**
467
+ * Name the failed loads in a refusal that would otherwise name nothing.
468
+ * `id` is the contribution the caller asked for; when a failure claims it, that
469
+ * one failure is the answer, and when none does the whole list is — a module
470
+ * that fans out over `presentations` fails before its ids are readable.
471
+ * Returns `''` when nothing failed, so the caller's own message stands alone.
472
+ */
473
+ export function contributionFailureHint(id: string): string {
474
+ const failures = getToolContributionLoadFailures();
475
+ if (failures.length === 0) return '';
476
+ const owner = failures.find((failure) => failure.id === id);
477
+ if (owner) return ` ${owner.entryPath} failed to load: ${owner.error}`;
478
+ return ` ${failures.length} contribution module(s) failed to load: ${failures
479
+ .map((failure) => `${failure.entryPath} (${failure.error})`)
480
+ .join('; ')}`;
481
+ }
482
+
483
+ /**
484
+ * Read a contribution module's OWN declaration.
485
+ *
486
+ * Nothing is reconciled here, because there is no second source to reconcile
487
+ * against: the module exports `point`, `title` (or `presentations`), and the
488
+ * NAME of the tool it drives, and the scan only says the file exists. The tool
489
+ * name is resolved against `tools` — a name no registered callable answers is a
490
+ * teaching error, the same class as a missing default export. The `match`
491
+ * requirement still varies by point, because a `match` export alone is
492
+ * ambiguous between the inspector and result points.
493
+ */
494
+ /**
495
+ * Resolve a contribution's declared `tool`, enforcing the point's contract.
496
+ *
497
+ * Three points PRESENT rather than RUN, so none has an honest name to put
498
+ * here: a `workspace.utility` reads a record (a log, a run timeline), a
499
+ * `selection.inspector` renders a facet of the inspected subject, and a
500
+ * `workspace.document` may be a pure view (a game's Data Book rendering its
501
+ * own src/data modules — the zero-magic collaboration surface). Requiring a
502
+ * callable at any of these bought nothing but a false association: a surface
503
+ * declaring a tool it never runs. The generation points drive exactly one
504
+ * callable, so the teaching error stands there.
505
+ */
506
+ function resolveDeclaredTool(
507
+ record: Record<string, unknown>,
508
+ point: ToolContributionPoint,
509
+ tools: readonly ProjectToolCatalogEntry[],
510
+ file: string,
511
+ ): { tool: ProjectToolCatalogEntry | undefined } | { error: string; note?: boolean } {
512
+ const declaredTool = record['tool'];
513
+ const toolIsOptional =
514
+ point === 'workspace.utility' ||
515
+ point === 'workspace.analytics' ||
516
+ point === 'workspace.status' ||
517
+ point === 'selection.inspector' ||
518
+ point === 'asset.inspector' ||
519
+ point === 'workspace.document';
520
+ if (typeof declaredTool !== 'string' || declaredTool.trim() === '') {
521
+ if (!toolIsOptional || declaredTool !== undefined) {
522
+ return {
523
+ error: toolIsOptional
524
+ ? `[tool contributions] ${file} exports a \`tool\` that is not a non-empty string. A ` +
525
+ `'${point}' may omit it entirely; it may not declare a broken one. Skipped.`
526
+ : `[tool contributions] ${file} must \`export const tool\` — the name of the registered ` +
527
+ `tool it drives, e.g. \`export const tool = 'project.tuning';\` ` +
528
+ `(see ${TEMPLATE_EXAMPLE}). Skipped.`,
529
+ };
530
+ }
531
+ }
532
+ const tool =
533
+ typeof declaredTool === 'string'
534
+ ? tools.find((entry) => entry.name === declaredTool)
535
+ : undefined;
536
+ if (typeof declaredTool === 'string' && !tool) {
537
+ return {
538
+ error:
539
+ `[tool contributions] ${file} drives ${JSON.stringify(declaredTool)}, which no ` +
540
+ 'registered tool declares. Register it in package.json#volter.tools, or fix the name. ' +
541
+ 'Skipped.',
542
+ };
543
+ }
544
+ return { tool };
545
+ }
546
+
547
+ export function extractProjectToolContribution(
548
+ mod: unknown,
549
+ file: string,
550
+ version: number,
551
+ tools: readonly ProjectToolCatalogEntry[],
552
+ presentation?: ToolPresentation,
553
+ ): { contribution: LoadedToolContribution } | { error: string } {
554
+ const record = (mod ?? {}) as Record<string, unknown>;
555
+ const Component = record['default'];
556
+ if (typeof Component !== 'function') {
557
+ return {
558
+ error:
559
+ `[tool contributions] ${file} has no default-exported React component ` +
560
+ `(see ${TEMPLATE_EXAMPLE}). Skipped.`,
561
+ };
562
+ }
563
+ // POINT FIRST, because it decides whether a `tool` is required at all.
564
+ const point = record['point'];
565
+ if (typeof point !== 'string' || !CONTRIBUTION_POINTS.includes(point as ToolContributionPoint)) {
566
+ return {
567
+ error:
568
+ `[tool contributions] ${file} must \`export const point\` — one of ` +
569
+ `${CONTRIBUTION_POINTS.map((value) => `'${value}'`).join(', ')} ` +
570
+ `(see ${TEMPLATE_EXAMPLE}). Skipped.`,
571
+ };
572
+ }
573
+ const resolvedTool = resolveDeclaredTool(record, point as ToolContributionPoint, tools, file);
574
+ if ('error' in resolvedTool) return resolvedTool;
575
+ const tool = resolvedTool.tool;
576
+ const declaredTitle = presentation?.title ?? record['title'];
577
+ if (typeof declaredTitle !== 'string' || declaredTitle.trim() === '') {
578
+ return {
579
+ error:
580
+ `[tool contributions] ${file} must \`export const title\` (non-empty), or list its ` +
581
+ 'presentations in `export const presentations`. Skipped.',
582
+ };
583
+ }
584
+ const declaredOrder = record['order'];
585
+ // `typeof NaN === 'number'` and so does `-1`, and both are silent: `NaN`
586
+ // poisons the composer's `a.order - b.order` comparator, and a negative one
587
+ // lifts the contribution over built-in blocks it was never meant to
588
+ // outrank. A loader that teaches on every other malformed export teaches
589
+ // here too, rather than clamping something the module plainly meant.
590
+ if (declaredOrder !== undefined && !isDeclarableOrder(declaredOrder)) {
591
+ return {
592
+ error:
593
+ `[tool contributions] ${file} exports an \`order\` of ${describeOrder(declaredOrder)}. ` +
594
+ `It is an optional whole number from 0 to ${MAX_DECLARED_ORDER} — low first, e.g. ` +
595
+ '`export const order = 2;` — and a module that omits it sits behind every module that ' +
596
+ 'declared one. Skipped.',
597
+ };
598
+ }
599
+ const declaredIcon = record['icon'];
600
+ if (
601
+ declaredIcon !== undefined &&
602
+ typeof declaredIcon !== 'function' &&
603
+ (typeof declaredIcon !== 'string' || declaredIcon.trim() === '')
604
+ ) {
605
+ return {
606
+ error:
607
+ `[tool contributions] ${file} exports an \`icon\` that is not a glyph name. It is an ` +
608
+ 'optional non-empty string naming a glyph in the active icon set — e.g. ' +
609
+ "`export const icon = 'properties-data';` — or a FUNCTION of the inspected subject " +
610
+ "answering one, e.g. `export const icon = (node, adapter) => 'properties-data-mesh';`. " +
611
+ 'Skipped.',
612
+ };
613
+ }
614
+ /** A SECTION'S RAIL GROUP (`selection.inspector`): the Properties
615
+ * presentation draws a separator between groups, the way
616
+ * `ED_buttons_tabs_list` (`space_buttons/space_buttons.cc:201-255`) inserts
617
+ * `BCONTEXT_SEPARATOR` between its tab groups. A free name, compared for
618
+ * equality only; sections that declare none form one trailing group. */
619
+ const declaredRailGroup = record['railGroup'];
620
+ if (
621
+ declaredRailGroup !== undefined &&
622
+ (typeof declaredRailGroup !== 'string' || declaredRailGroup.trim() === '')
623
+ ) {
624
+ return {
625
+ error:
626
+ `[tool contributions] ${file} exports a \`railGroup\` that is not a non-empty string. ` +
627
+ 'It names the rail group this section belongs to — e.g. `export const railGroup = ' +
628
+ "'scene';` — and the presentation rules between groups. Skipped.",
629
+ };
630
+ }
631
+ /** A SECTION'S RAIL DEFAULT (`selection.inspector`): the tab the Properties
632
+ * presentation opens on before a person has chosen one — Blender's stored
633
+ * `SpaceProperties.context`, which at factory settings is OBJECT and not
634
+ * the rail's first tab. */
635
+ const declaredRailDefault = record['railDefault'];
636
+ if (declaredRailDefault !== undefined && typeof declaredRailDefault !== 'boolean' && typeof declaredRailDefault !== 'function') {
637
+ return {
638
+ error:
639
+ `[tool contributions] ${file} exports a \`railDefault\` that is not a boolean or subject resolver. ` +
640
+ 'It says this is the tab the Properties rail opens on — `export const railDefault = ' +
641
+ 'true;` or a function of (node, adapter). Skipped.',
642
+ };
643
+ }
644
+ const base = {
645
+ id: presentation?.id ?? contributionIdFromPath(file),
646
+ title: declaredTitle.trim(),
647
+ file,
648
+ // The package (or project folder) this module shipped in — see
649
+ // `LoadedToolContributionBase.owner`.
650
+ owner: packageNameOf(file),
651
+ version,
652
+ ...(declaredOrder === undefined ? {} : { order: declaredOrder }),
653
+ ...(declaredIcon === undefined
654
+ ? {}
655
+ : {
656
+ icon:
657
+ typeof declaredIcon === 'function'
658
+ ? (declaredIcon as ToolContributionIconResolver)
659
+ : declaredIcon.trim(),
660
+ }),
661
+ ...(typeof declaredRailGroup === 'string' ? { railGroup: declaredRailGroup.trim() } : {}),
662
+ ...(declaredRailDefault === true || typeof declaredRailDefault === 'function'
663
+ ? { railDefault: declaredRailDefault as NonNullable<LoadedToolContributionBase['railDefault']> }
664
+ : {}),
665
+ };
666
+ // The two points with no required callable, returned before `tool` is
667
+ // narrowed below.
668
+ if (point === 'workspace.utility') {
669
+ const available = record['available'];
670
+ if (available !== undefined && typeof available !== 'function') {
671
+ return {
672
+ error:
673
+ `[tool contributions] ${file} exports an \`available\` that is not a function. It is ` +
674
+ 'optional: `export const available = () => …` gates the tab on session state. Skipped.',
675
+ };
676
+ }
677
+ return {
678
+ contribution: {
679
+ ...base,
680
+ point,
681
+ ...(tool ? { tool } : {}),
682
+ ...(available ? { available: available as () => boolean } : {}),
683
+ Component: Component as ComponentType<ToolUtilityContributionProps>,
684
+ },
685
+ };
686
+ }
687
+ if (point === 'workspace.status') {
688
+ const align = record['align'] ?? 'left';
689
+ if (align !== 'left' && align !== 'right') {
690
+ return {
691
+ error:
692
+ `[tool contributions] ${file} exports an \`align\` of ${JSON.stringify(align)}; a status ` +
693
+ "item's align is 'left' (default) or 'right'. Skipped.",
694
+ };
695
+ }
696
+ return {
697
+ contribution: {
698
+ ...base,
699
+ point,
700
+ align,
701
+ Component: Component as ComponentType,
702
+ },
703
+ };
704
+ }
705
+ if (point === 'workspace.analytics') {
706
+ return {
707
+ contribution: {
708
+ ...base,
709
+ point,
710
+ ...(tool ? { tool } : {}),
711
+ Component: Component as ComponentType<ToolAnalyticsContributionProps>,
712
+ },
713
+ };
714
+ }
715
+ if (point === 'selection.inspector') {
716
+ const match = record['match'];
717
+ if (typeof match !== 'function') {
718
+ return {
719
+ error:
720
+ `[tool contributions] ${file} declares 'selection.inspector' but does not export ` +
721
+ '`match(node, adapter)`. Skipped.',
722
+ };
723
+ }
724
+ return {
725
+ contribution: {
726
+ ...base,
727
+ point,
728
+ ...(tool ? { tool } : {}),
729
+ Component: Component as ComponentType<ToolInspectorContributionProps>,
730
+ match: match as ToolInspectorContributionMatch,
731
+ },
732
+ };
733
+ }
734
+ if (point === 'workspace.document') {
735
+ // The third presenting point: a pure-view document (a game's Data Book)
736
+ // carries no tool; one that DRIVES a callable still names it above.
737
+ const toolbar = record['Toolbar'];
738
+ if (toolbar !== undefined && typeof toolbar !== 'function') {
739
+ return {
740
+ error:
741
+ `[tool contributions] ${file} exports 'Toolbar' but it is not a component. ` +
742
+ 'A document header is `export const Toolbar = (props) => …`. Skipped.',
743
+ };
744
+ }
745
+ const documentKind = record['documentKind'];
746
+ if (documentKind !== undefined && (typeof documentKind !== 'string' || documentKind === '')) {
747
+ return {
748
+ error:
749
+ `[tool contributions] ${file} exports 'documentKind' but it is not a non-empty string. ` +
750
+ "The kind a document edits is `export const documentKind = 'model'`. Skipped.",
751
+ };
752
+ }
753
+ const shelf = record['Shelf'];
754
+ if (shelf !== undefined && typeof shelf !== 'function') {
755
+ return {
756
+ error:
757
+ `[tool contributions] ${file} exports 'Shelf' but it is not a component. ` +
758
+ 'A document shelf is `export const Shelf = (props) => …`. Skipped.',
759
+ };
760
+ }
761
+ const inspectorRail = record['inspectorRail'];
762
+ if (inspectorRail !== undefined && inspectorRail !== 'owned') {
763
+ return {
764
+ error:
765
+ `[tool contributions] ${file} exports an \`inspectorRail\` of ` +
766
+ `${JSON.stringify(inspectorRail)}. The one value is 'owned' — this document's package ` +
767
+ "owns its Properties rail (`export const inspectorRail = 'owned';`). Skipped.",
768
+ };
769
+ }
770
+ const inspectorBuiltins = record['inspectorBuiltins'];
771
+ if (
772
+ inspectorBuiltins !== undefined &&
773
+ (!Array.isArray(inspectorBuiltins) ||
774
+ inspectorBuiltins.some((id) => typeof id !== 'string' || id.trim() === ''))
775
+ ) {
776
+ return {
777
+ error:
778
+ `[tool contributions] ${file} exports an \`inspectorBuiltins\` that is not a list of ` +
779
+ "section ids — e.g. `export const inspectorBuiltins = ['preview'];`. Skipped.",
780
+ };
781
+ }
782
+ return {
783
+ contribution: {
784
+ ...base,
785
+ point,
786
+ ...(tool ? { tool } : {}),
787
+ Component: Component as ComponentType<ToolContributionProps>,
788
+ standing: record['standing'] === true,
789
+ ...(typeof documentKind === 'string' ? { documentKind } : {}),
790
+ ...(toolbar ? { Toolbar: toolbar as ComponentType<ToolContributionProps> } : {}),
791
+ ...(shelf ? { Shelf: shelf as ComponentType<ToolContributionProps> } : {}),
792
+ ...(inspectorRail === 'owned' ? { inspectorRail } : {}),
793
+ ...(Array.isArray(inspectorBuiltins)
794
+ ? { inspectorBuiltins: inspectorBuiltins as readonly string[] }
795
+ : {}),
796
+ },
797
+ };
798
+ }
799
+ if (point === 'asset.inspector') {
800
+ // Presents an ASSET, the way a selection inspector presents a node: the
801
+ // callable is optional.
802
+ const match = record['match'];
803
+ if (typeof match !== 'function') {
804
+ return {
805
+ error:
806
+ `[tool contributions] ${file} declares 'asset.inspector' but does not export ` +
807
+ '`match(asset)`. Skipped.',
808
+ };
809
+ }
810
+ return {
811
+ contribution: {
812
+ ...base,
813
+ point,
814
+ ...(tool ? { tool } : {}),
815
+ Component: Component as ComponentType<ToolAssetInspectorContributionProps>,
816
+ match: match as ToolAssetInspectorContributionMatch,
817
+ },
818
+ };
819
+ }
820
+ if (!tool) {
821
+ return {
822
+ error:
823
+ `[tool contributions] ${file} must \`export const tool\` — the name of the registered ` +
824
+ `tool it drives, e.g. \`export const tool = 'project.tuning';\` ` +
825
+ `(see ${TEMPLATE_EXAMPLE}). Skipped.`,
826
+ };
827
+ }
828
+ const callable = { ...base, tool };
829
+ if (point === 'generation.result') {
830
+ const match = record['match'];
831
+ if (typeof match !== 'function') {
832
+ return {
833
+ error:
834
+ `[tool contributions] ${file} declares 'generation.result' but does not export ` +
835
+ '`match(job, result)`. Skipped.',
836
+ };
837
+ }
838
+ return {
839
+ contribution: {
840
+ ...callable,
841
+ point,
842
+ Component: Component as ComponentType<ToolGenerationResultContributionProps>,
843
+ match: match as ToolGenerationResultContributionMatch,
844
+ },
845
+ };
846
+ }
847
+ return {
848
+ error: `[tool contributions] ${file} declares unhandled point '${point}'. Skipped.`,
849
+ };
850
+ }
851
+
852
+ /**
853
+ * THE BUNDLED PACKAGES — what the PRODUCT composed into this page, whatever the
854
+ * open project declares (ARCHITECTURE-CORE §The universal editor).
855
+ *
856
+ * The kit names none of them (rule 1). A product's entry is the only writer:
857
+ * `product({ packages: { '@volter/editor-blender': blender, … } })`
858
+ * (`frame/product.ts`) hands over the lists it imported as
859
+ * `volter:contributions/<package>` modules, each row synthesized from that
860
+ * package's own `package.json#volter.contributions`
861
+ * (`vite-plugin-product-contributions.ts`).
862
+ *
863
+ * The SESSION is what decides which of these actually mount: its catalog
864
+ * (`server/project-tools.ts`, `packageContributionModules`) lists the product's
865
+ * composed packages BY SPECIFIER — exactly the key this map is built on, which
866
+ * is how a bundled module is imported from the page instead of fetched a second
867
+ * time through `/@fs/` — and the open project's own declared packages by
868
+ * absolute path. A package on both lists is listed once, as the product's.
869
+ *
870
+ * Filled at the product entry's module scope, before its `mountVolter` can be
871
+ * called, so no refresh pass can see a half-composed page.
872
+ */
873
+ // The product registers once at module evaluation. A loader-only HMR update
874
+ // must retain that composition; otherwise package specifiers fall through to
875
+ // project-relative /@fs/ URLs and every bundled contribution fails to load.
876
+ // Vite's HMR context when a dev server serves this module; the SDK carries no bundler types.
877
+ const hot = (
878
+ import.meta as ImportMeta & {
879
+ hot?: {
880
+ data: Record<string, unknown>;
881
+ dispose(callback: (data: Record<string, unknown>) => void): void;
882
+ on(event: string, callback: (data: { file: string }) => void): void;
883
+ };
884
+ }
885
+ ).hot;
886
+ const bundledPackageLoaders: Map<string, () => Promise<unknown>> =
887
+ (hot?.data['bundledPackageLoaders'] as Map<string, () => Promise<unknown>> | undefined) ?? new Map();
888
+ if (hot) {
889
+ hot.dispose((data) => {
890
+ data['bundledPackageLoaders'] = bundledPackageLoaders;
891
+ });
892
+ }
893
+
894
+ /** One contribution a product bundles. The shape
895
+ * `vite-plugin-product-contributions.ts` emits and this module consumes. */
896
+ export interface BundledPackageContribution {
897
+ readonly entryPath: string;
898
+ readonly load: () => Promise<unknown>;
899
+ }
900
+
901
+ /** The product's composition, handed over by `frame/product.ts`. */
902
+ export function setBundledPackageContributions(
903
+ contributions: readonly BundledPackageContribution[],
904
+ ): void {
905
+ bundledPackageLoaders.clear();
906
+ for (const entry of contributions) bundledPackageLoaders.set(entry.entryPath, entry.load);
907
+ }
908
+
909
+ let globalContributions: GlobalToolContribution[] = [];
910
+ let utilityContributions: DrawerToolContribution[] = [];
911
+ let surfaceContributions: SurfaceToolContribution[] = [];
912
+ let assetInspectorContributions: AssetInspectorToolContribution[] = [];
913
+ let generationResultContributions: GenerationResultToolContribution[] = [];
914
+ let contributionLoadFailures: ToolContributionLoadFailure[] = [];
915
+ const listeners = new Set<() => void>();
916
+ let contributionRefreshEpoch = 0;
917
+
918
+ /** The contribution modules the last discovery pass could not import. Read by
919
+ * the refusals that would otherwise name nothing, and by the Project Tools
920
+ * document, which lists them beside the server's own `loadErrors`. */
921
+ export function getToolContributionLoadFailures(): ToolContributionLoadFailure[] {
922
+ return contributionLoadFailures;
923
+ }
924
+
925
+ export function getGlobalToolContributions(): GlobalToolContribution[] {
926
+ return globalContributions;
927
+ }
928
+
929
+ /**
930
+ * Every contribution the editor mounts as a WHOLE React surface — the center
931
+ * documents and the drawer utilities together, documents first.
932
+ *
933
+ * It exists because a project utility's TAB has to have a door. A drawer
934
+ * registration is `visibleByDefault: false` (a project tab does not force
935
+ * itself into a clean layout), so nothing presents it until a command asks:
936
+ * this is the list the Tools menu enumerates, and `showWorkspaceUtility` is
937
+ * what it calls. Reading `getGlobalToolContributions` there instead — which is
938
+ * documents-only — is a contribution that loads, registers, and can never be
939
+ * opened by the human the drawer exists for.
940
+ *
941
+ * A STABLE array, rebuilt once per load pass when both halves have been
942
+ * written, because it is a `useSyncExternalStore` snapshot (`Object.is`; a
943
+ * fresh array per call loops).
944
+ */
945
+ export function getSurfaceToolContributions(): SurfaceToolContribution[] {
946
+ return surfaceContributions;
947
+ }
948
+
949
+ export function getDocumentToolContributions(): GlobalToolContribution[] {
950
+ return globalContributions.filter((item) => item.point === 'workspace.document');
951
+ }
952
+
953
+ /** The document contribution registered as the editor for table documents of
954
+ * `kind` (`export const documentKind`), or `undefined` when none is. */
955
+ export function documentContributionForKind(kind: string): GlobalToolContribution | undefined {
956
+ return globalContributions.find(
957
+ (item): item is GlobalToolContribution =>
958
+ item.point === 'workspace.document' && item.documentKind === kind,
959
+ );
960
+ }
961
+
962
+ export function getAssetInspectorToolContributions(): AssetInspectorToolContribution[] {
963
+ return assetInspectorContributions;
964
+ }
965
+
966
+ export function getGenerationResultContribution(
967
+ pollTool: string,
968
+ job: import('@volter/sdk/generations').GenerationJob,
969
+ result: unknown,
970
+ ): GenerationResultToolContribution | undefined {
971
+ const matches: GenerationResultToolContribution[] = [];
972
+ for (const item of generationResultContributions) {
973
+ if (item.tool.name !== pollTool) continue;
974
+ try {
975
+ if (item.match(job, result)) matches.push(item);
976
+ } catch (error) {
977
+ teachingError(
978
+ `[tool contributions] ${item.file}'s generation result match() threw.\n${String(error)}`,
979
+ );
980
+ }
981
+ }
982
+ if (matches.length > 1) {
983
+ teachingError(
984
+ `[tool contributions] generation result for ${JSON.stringify(job.operation)} matched ` +
985
+ `${matches.length} renderers (${matches.map((item) => item.id).join(', ')}). ` +
986
+ 'Falling back to native JSON until the matchers are unambiguous.',
987
+ );
988
+ return undefined;
989
+ }
990
+ return matches[0];
991
+ }
992
+
993
+ export function subscribeToolContributions(fn: () => void): () => void {
994
+ listeners.add(fn);
995
+ return () => listeners.delete(fn);
996
+ }
997
+
998
+ let editorClient: EditorClient | null = null;
999
+
1000
+ export function getToolContributionClient(): EditorClient {
1001
+ if (!editorClient) {
1002
+ const origin = globalThis.location?.origin;
1003
+ editorClient = new EditorClient({ ...(origin ? { url: origin } : {}) });
1004
+ }
1005
+ return editorClient;
1006
+ }
1007
+
1008
+ let unregisterInspectorContributions: Array<() => void> = [];
1009
+
1010
+ /** A declared glyph NAME as the icon registry wants it: the wrench drawing
1011
+ * under that name, so a set carrying the name paints its own glyph and one
1012
+ * that does not still draws something sensible. No name is the generic
1013
+ * project-tool wrench. */
1014
+ function glyph(name: string | undefined): IconDefinition {
1015
+ return name
1016
+ ? { ...faScrewdriverWrench, iconName: name as IconDefinition['iconName'] }
1017
+ : faScrewdriverWrench;
1018
+ }
1019
+
1020
+ function registrationForInspectorContribution(item: InspectorToolContribution): () => void {
1021
+ let warned = false;
1022
+ const teachOnce = (what: string, error: unknown): void => {
1023
+ if (warned) return;
1024
+ warned = true;
1025
+ teachingError(
1026
+ `[tool contributions] ${item.file}'s ${what} threw; ` +
1027
+ `treating it as absent until the file is fixed and saved.\n${String(error)}`,
1028
+ );
1029
+ };
1030
+ // A FUNCTION `icon` is resolved PER SUBJECT, so Blender's Object Data tab
1031
+ // can draw the mark its object type earns
1032
+ // (`LoadedToolContributionBase.icon`). It is wrapped the way `match` is:
1033
+ // project code that throws teaches once and falls back to the generic
1034
+ // glyph, rather than taking the whole composition down.
1035
+ const declaredIcon = item.icon;
1036
+ const icon: InspectorSectionIcon =
1037
+ typeof declaredIcon === 'function'
1038
+ ? (node, adapter) => {
1039
+ try {
1040
+ return glyph(declaredIcon(node, adapter));
1041
+ } catch (error) {
1042
+ teachOnce('icon()', error);
1043
+ return glyph(undefined);
1044
+ }
1045
+ }
1046
+ : glyph(declaredIcon);
1047
+ return registerInspectorSections({
1048
+ match: (
1049
+ node: EditorNode | null,
1050
+ adapter: AuthoringAdapter,
1051
+ context: InspectorSectionMatchContext,
1052
+ ) => {
1053
+ try {
1054
+ // The match context travels UNCHANGED to project code: it is how a
1055
+ // contribution scopes itself to one empty-state subject (the play
1056
+ // surface's Game) instead of every surface's.
1057
+ return item.match(node, adapter, context);
1058
+ } catch (error) {
1059
+ teachOnce('match()', error);
1060
+ return false;
1061
+ }
1062
+ },
1063
+ // A project tool's section id is namespaced by its contribution id, so two
1064
+ // tools never collide with each other or with a built-in block's id.
1065
+ id: `tool:${item.id}`,
1066
+ title: item.title,
1067
+ // The contribution's OWN glyph by name when it declared one — the icon
1068
+ // set paints it and may tint it by category — else a project TOOL's
1069
+ // generic glyph. Only the NAME changes: the drawing beneath stays the
1070
+ // wrench, so a set that lacks the name still draws something sensible.
1071
+ icon,
1072
+ // The package this section shipped in — what a document's OWNED rail
1073
+ // filters by (`inspection/compose.ts`, `ComposeInspectionInput.rail`).
1074
+ owner: item.owner,
1075
+ // Which group of the rail it belongs to, and so where the separators fall.
1076
+ ...(item.railGroup === undefined ? {} : { railGroup: item.railGroup }),
1077
+ // The tab the rail opens on when nothing has been chosen for this subject.
1078
+ ...(item.railDefault === undefined ? {} : { railDefault: item.railDefault }),
1079
+ // Contributions sit after every built-in block, and among THEMSELVES in
1080
+ // the order each module declared (`export const order`). Without it the
1081
+ // arrangement is the discovery scan's alphabetical accident — a facet set
1082
+ // whose sequence is part of its meaning cannot be left to filenames.
1083
+ order: contributedSectionOrder(item.order),
1084
+ Section: ({ adapter, nodeId }) =>
1085
+ createElement(InspectorToolSection, {
1086
+ id: item.id,
1087
+ title: item.title,
1088
+ file: item.file,
1089
+ ...(item.tool ? { tool: item.tool } : {}),
1090
+ Component: item.Component,
1091
+ adapter,
1092
+ nodeId,
1093
+ }),
1094
+ });
1095
+ }
1096
+
1097
+ function applyInspectorContributions(items: InspectorToolContribution[]): void {
1098
+ for (const unregister of unregisterInspectorContributions) unregister();
1099
+ unregisterInspectorContributions = items.map(registrationForInspectorContribution);
1100
+ }
1101
+
1102
+ let unregisterUtilityContributions: Array<() => void> = [];
1103
+ let unregisterStatusContributions: Array<() => void> = [];
1104
+
1105
+ /** A look contribution as loaded: which point, and the module's one export. */
1106
+ interface LookModule {
1107
+ readonly entryPath: string;
1108
+ readonly kind: LookContributionKind;
1109
+ readonly module: unknown;
1110
+ }
1111
+
1112
+ /**
1113
+ * Each look kind's registry and the contract its export is checked against, keyed by the
1114
+ * convention's own kinds (`LookContributionKind`), so a kind the convention names cannot be
1115
+ * scanned without being registered.
1116
+ */
1117
+ const LOOK_REGISTRARS: {
1118
+ readonly [K in LookContributionKind]: { readonly contract: string; readonly register: (value: unknown) => () => void };
1119
+ } = {
1120
+ layout: {
1121
+ contract: 'WorkspaceLayoutContribution (@volter/sdk/looks)',
1122
+ register: (value) => registerContributedWorkspace(value as Parameters<typeof registerContributedWorkspace>[0]),
1123
+ },
1124
+ keymap: {
1125
+ contract: 'KeymapContribution (@volter/sdk/looks)',
1126
+ register: (value) => registerContributedKeymap(value as Parameters<typeof registerContributedKeymap>[0]),
1127
+ },
1128
+ style: {
1129
+ contract: 'StyleContribution (@volter/sdk/looks)',
1130
+ register: (value) => registerContributedStyle(value as Parameters<typeof registerContributedStyle>[0]),
1131
+ },
1132
+ view: {
1133
+ contract: 'ViewPreset (@volter/sdk/kit/viewport-presentation)',
1134
+ register: (value) => registerViewPreset(value as ViewPreset),
1135
+ },
1136
+ environment: {
1137
+ contract: 'EnvironmentImageSet (@volter/sdk/kit/environment-images)',
1138
+ register: (value) => registerEnvironmentImages(value as EnvironmentImageSet),
1139
+ },
1140
+ };
1141
+ let unregisterLookContributions: Array<() => void> = [];
1142
+
1143
+ interface CommandModule {
1144
+ readonly entryPath: string;
1145
+ readonly module: unknown;
1146
+ }
1147
+
1148
+ interface ServiceModule {
1149
+ readonly entryPath: string;
1150
+ readonly module: unknown;
1151
+ }
1152
+ /** The running services by entry path, with the module instance each was
1153
+ * started from. */
1154
+ let runningServices = new Map<string, { module: unknown; stop: (() => void) | null }>();
1155
+
1156
+ /** Start the contributed SERVICES (`@volter/sdk/services`) after a
1157
+ * pass. A service whose module is the SAME instance as the one running keeps
1158
+ * running: a service owns state (the Scene document is one), and stopping an
1159
+ * unchanged one on every pass closed that state under the person — the Scene
1160
+ * opened, then a second pass seconds later stopped its service, which
1161
+ * withdrew the document, and nothing reopened it. A changed, added or removed
1162
+ * service is stopped and started. A service that throws on start teaches and
1163
+ * is skipped; one that throws on stop is reported and the rest still stop. */
1164
+ function applyServiceContributions(items: readonly ServiceModule[]): void {
1165
+ const next = new Map<string, ServiceModule>(items.map((item) => [item.entryPath, item]));
1166
+ const kept = new Map<string, { module: unknown; stop: (() => void) | null }>();
1167
+ for (const [entryPath, running] of runningServices) {
1168
+ if (next.get(entryPath)?.module === running.module) {
1169
+ kept.set(entryPath, running);
1170
+ continue;
1171
+ }
1172
+ try {
1173
+ running.stop?.();
1174
+ } catch (error) {
1175
+ teachingError(`[tool contributions] a service could not stop.\n${String(error)}`);
1176
+ }
1177
+ }
1178
+ runningServices = kept;
1179
+ for (const item of items) {
1180
+ if (runningServices.has(item.entryPath)) continue;
1181
+ const record = (item.module ?? {}) as Record<string, unknown>;
1182
+ if (record['point'] !== 'workspace.service') {
1183
+ teachingError(
1184
+ `[tool contributions] ${item.entryPath} must \`export const point = 'workspace.service'\` ` +
1185
+ '(its filename names that point). Skipped.',
1186
+ );
1187
+ continue;
1188
+ }
1189
+ const start = record['start'];
1190
+ if (typeof start !== 'function') {
1191
+ teachingError(
1192
+ `[tool contributions] ${item.entryPath} must \`export function start()\` returning its stop ` +
1193
+ '(@volter/sdk/services). Skipped.',
1194
+ );
1195
+ continue;
1196
+ }
1197
+ try {
1198
+ const stop = (start as () => unknown)();
1199
+ runningServices.set(item.entryPath, {
1200
+ module: item.module,
1201
+ stop: typeof stop === 'function' ? (stop as () => void) : null,
1202
+ });
1203
+ } catch (error) {
1204
+ teachingError(`[tool contributions] ${item.entryPath} could not start.\n${String(error)}`);
1205
+ }
1206
+ }
1207
+ }
1208
+
1209
+ interface ChromeModule {
1210
+ readonly entryPath: string;
1211
+ readonly kind: 'action' | 'menu' | 'header';
1212
+ readonly module: unknown;
1213
+ }
1214
+ let unregisterChromeContributions: Array<() => void> = [];
1215
+
1216
+ /** Republish the contributed CHROME (`@volter/sdk/chrome`): palette
1217
+ * actions and menu items. Data, no component; the same
1218
+ * unregister-then-register discipline as every other point. */
1219
+ function applyChromeContributions(items: readonly ChromeModule[]): void {
1220
+ for (const unregister of unregisterChromeContributions) unregister();
1221
+ unregisterChromeContributions = [];
1222
+ for (const item of items) {
1223
+ const record = (item.module ?? {}) as Record<string, unknown>;
1224
+ const expectedPoint = `workspace.${item.kind}`;
1225
+ if (record['point'] !== expectedPoint) {
1226
+ teachingError(
1227
+ `[tool contributions] ${item.entryPath} must \`export const point = '${expectedPoint}'\` ` +
1228
+ '(its filename names that point). Skipped.',
1229
+ );
1230
+ continue;
1231
+ }
1232
+ try {
1233
+ if (item.kind === 'header') {
1234
+ const Component = record['default'];
1235
+ if (typeof Component !== 'function') {
1236
+ teachingError(
1237
+ `[tool contributions] ${item.entryPath} has no default-exported React component. Skipped.`,
1238
+ );
1239
+ continue;
1240
+ }
1241
+ const order = record['order'];
1242
+ const placement = record['placement'];
1243
+ unregisterChromeContributions.push(
1244
+ registerContributedHeaderItem({
1245
+ id: `tool:${contributionIdFromPath(item.entryPath)}`,
1246
+ order: typeof order === 'number' && Number.isFinite(order) ? order : 0,
1247
+ placement: placement === 'object3d-document' ? 'object3d-document' : 'transport',
1248
+ Component: Component as ComponentType<{ documentId: string }>,
1249
+ }),
1250
+ );
1251
+ continue;
1252
+ }
1253
+ if (item.kind === 'action') {
1254
+ const actions = record['actions'];
1255
+ if (!Array.isArray(actions) && typeof actions !== 'function') {
1256
+ teachingError(
1257
+ `[tool contributions] ${item.entryPath} must \`export const actions\` — an array of ` +
1258
+ '`ContributedAction`, or a function returning one for a live set ' +
1259
+ '(@volter/sdk/chrome). Skipped.',
1260
+ );
1261
+ continue;
1262
+ }
1263
+ const subscribe = record['subscribe'];
1264
+ unregisterChromeContributions.push(
1265
+ registerContributedActions({
1266
+ actions,
1267
+ ...(typeof subscribe === 'function' ? { subscribe } : {}),
1268
+ } as Parameters<typeof registerContributedActions>[0]),
1269
+ );
1270
+ } else {
1271
+ const menu = record['menu'] as { menu?: unknown; items?: unknown } | undefined;
1272
+ if (!menu || !isContributableMenu(menu.menu) || !Array.isArray(menu.items)) {
1273
+ teachingError(
1274
+ `[tool contributions] ${item.entryPath} must \`export const menu\` — a ` +
1275
+ "`MenuContribution` naming one of 'view', 'window', 'debug', 'tools', 'help' and " +
1276
+ 'its items (@volter/sdk/chrome). Skipped.',
1277
+ );
1278
+ continue;
1279
+ }
1280
+ unregisterChromeContributions.push(
1281
+ registerContributedMenu(menu as Parameters<typeof registerContributedMenu>[0]),
1282
+ );
1283
+ }
1284
+ } catch (error) {
1285
+ teachingError(`[tool contributions] ${item.entryPath} could not register.\n${String(error)}`);
1286
+ }
1287
+ }
1288
+ }
1289
+ let unregisterCommandContributions: Array<() => void> = [];
1290
+
1291
+ /**
1292
+ * Republish the contributed COMMANDS (`@volter/sdk/commands`): each
1293
+ * module's `commands` table into the registry, then the registered rows to
1294
+ * the server, which sizes its relay wait per verb from them. The same
1295
+ * unregister-then-register discipline as every other point.
1296
+ */
1297
+ function applyCommandContributions(items: readonly CommandModule[]): void {
1298
+ for (const unregister of unregisterCommandContributions) unregister();
1299
+ unregisterCommandContributions = [];
1300
+ for (const item of items) {
1301
+ const record = (item.module ?? {}) as Record<string, unknown>;
1302
+ if (record['point'] !== 'workspace.command') {
1303
+ teachingError(
1304
+ `[tool contributions] ${item.entryPath} must \`export const point = 'workspace.command'\` ` +
1305
+ '(its filename names that point). Skipped.',
1306
+ );
1307
+ continue;
1308
+ }
1309
+ const commands = record['commands'];
1310
+ if (typeof commands !== 'object' || commands === null) {
1311
+ teachingError(
1312
+ `[tool contributions] ${item.entryPath} must \`export const commands\` — a table of ` +
1313
+ '`CommandSpec` by verb (@volter/sdk/commands). Skipped.',
1314
+ );
1315
+ continue;
1316
+ }
1317
+ try {
1318
+ unregisterCommandContributions.push(
1319
+ registerContributedCommands(
1320
+ item.entryPath,
1321
+ commands as Parameters<typeof registerContributedCommands>[1],
1322
+ ),
1323
+ );
1324
+ } catch (error) {
1325
+ teachingError(`[tool contributions] ${item.entryPath} could not register.\n${String(error)}`);
1326
+ }
1327
+ }
1328
+ void reportContributedCommandRows();
1329
+ }
1330
+
1331
+ /** The server's relay reads the host's table for its per-verb wait; a
1332
+ * contributed verb's row reaches it here, or it would fall to the generic
1333
+ * budget — the exact failure `command-table.ts` records. */
1334
+ async function reportContributedCommandRows(): Promise<void> {
1335
+ try {
1336
+ const response = await fetch('/__editor/contributed-commands', {
1337
+ method: 'POST',
1338
+ headers: { 'content-type': 'application/json' },
1339
+ body: JSON.stringify({ commands: contributedCommandRows() }),
1340
+ });
1341
+ if (!response.ok)
1342
+ teachingError(
1343
+ `[tool contributions] the server refused the contributed command rows (${response.status}).`,
1344
+ );
1345
+ } catch (error) {
1346
+ teachingError(
1347
+ `[tool contributions] could not report contributed command rows: ${String(error)}`,
1348
+ );
1349
+ }
1350
+ }
1351
+
1352
+ /**
1353
+ * Republish the looks — workspaces, keymaps and style bundles a project's
1354
+ * packages contribute (`@volter/sdk/looks`). Data, no component: each
1355
+ * module's one named export is handed to its registry, which is also where
1356
+ * a duplicate id or a bad palette document refuses by name. The same
1357
+ * unregister-then-register discipline as the utilities, so a reload never
1358
+ * doubles a workspace.
1359
+ */
1360
+ function applyLookContributions(items: readonly LookModule[]): void {
1361
+ for (const unregister of unregisterLookContributions) unregister();
1362
+ unregisterLookContributions = [];
1363
+ for (const item of items) {
1364
+ const record = (item.module ?? {}) as Record<string, unknown>;
1365
+ const expectedPoint = `workspace.${item.kind}`;
1366
+ if (record['point'] !== expectedPoint) {
1367
+ teachingError(
1368
+ `[tool contributions] ${item.entryPath} must \`export const point = '${expectedPoint}'\` ` +
1369
+ `(its filename names that point). Skipped.`,
1370
+ );
1371
+ continue;
1372
+ }
1373
+ const value = record[item.kind];
1374
+ if (
1375
+ typeof value !== 'object' ||
1376
+ value === null ||
1377
+ typeof (value as { id?: unknown }).id !== 'string'
1378
+ ) {
1379
+ teachingError(
1380
+ `[tool contributions] ${item.entryPath} must \`export const ${item.kind}\` — an object with a ` +
1381
+ `string \`id\` (see \`${LOOK_REGISTRARS[item.kind].contract}\`). Skipped.`,
1382
+ );
1383
+ continue;
1384
+ }
1385
+ try {
1386
+ unregisterLookContributions.push(LOOK_REGISTRARS[item.kind].register(value));
1387
+ } catch (error) {
1388
+ teachingError(`[tool contributions] ${item.entryPath} could not register.\n${String(error)}`);
1389
+ }
1390
+ }
1391
+ }
1392
+ let statusContributions: StatusToolContribution[] = [];
1393
+
1394
+ /** Republish the status bar's contributed items — the same
1395
+ * unregister-then-register discipline as {@link applyUtilityContributions}. */
1396
+ function applyStatusContributions(items: StatusToolContribution[]): void {
1397
+ for (const unregister of unregisterStatusContributions) unregister();
1398
+ unregisterStatusContributions = [];
1399
+ const claimed: StatusToolContribution[] = [];
1400
+ for (const item of items) {
1401
+ try {
1402
+ unregisterStatusContributions.push(
1403
+ registerWorkspaceStatus({
1404
+ id: `tool:${item.id}`,
1405
+ // The contribution's own TITLE is what a person calls it, and the
1406
+ // status bar's `Hide <item>` menu is where they read it.
1407
+ name: item.title,
1408
+ align: item.align,
1409
+ order: contributedSectionOrder(item.order),
1410
+ Content: item.Component,
1411
+ }),
1412
+ );
1413
+ claimed.push(item);
1414
+ } catch (error) {
1415
+ teachingError(
1416
+ `[tool contributions] ${item.file} could not claim its status-bar slot.\n${String(error)}`,
1417
+ );
1418
+ }
1419
+ }
1420
+ statusContributions = claimed;
1421
+ }
1422
+
1423
+ /** The status items the last load pass claimed (diagnostics; the bar reads
1424
+ * the registry itself). */
1425
+ export function getStatusToolContributions(): readonly StatusToolContribution[] {
1426
+ return statusContributions;
1427
+ }
1428
+ /** A project's finder contribution, imported and waiting for the
1429
+ * document-table host to register it (ARCHITECTURE-CORE §The project model). */
1430
+ export interface ContributedFinderModule {
1431
+ readonly entryPath: string;
1432
+ readonly module: unknown;
1433
+ }
1434
+ let contributedFinders: readonly ContributedFinderModule[] = [];
1435
+
1436
+ /** The finder contributions the last pass imported; `subscribeToolContributions`
1437
+ * fires when the list changes. */
1438
+ export function contributedFinderModules(): readonly ContributedFinderModule[] {
1439
+ return contributedFinders;
1440
+ }
1441
+
1442
+ function registrationForUtilityContribution(item: DrawerToolContribution): () => void {
1443
+ return registerWorkspaceUtility({
1444
+ // Namespaced by contribution id exactly as the inspector sections are, so
1445
+ // a project tab can never collide with one of the editor's own utilities
1446
+ // (ids key drawer tabs and persisted layout — the registry throws).
1447
+ id: `tool:${item.id}`,
1448
+ title: item.title,
1449
+ // The GAME's plane, not the editor's instruments.
1450
+ section: 'project',
1451
+ Content: () => createElement(ToolHost, { contribution: item }),
1452
+ // A project tab is revealed by the Window menu / a command, like every
1453
+ // other on-demand utility; it does not force itself into a clean layout.
1454
+ visibleByDefault: false,
1455
+ closeable: true,
1456
+ // Analytics reads a RUNNING world; a project that mounts nothing has no
1457
+ // run to read (ARCHITECTURE-CORE §Roots), so the tab is not on offer. A
1458
+ // utility gates itself on whatever session state it declared.
1459
+ ...(item.point === 'workspace.analytics'
1460
+ ? { available: projectMounts }
1461
+ : item.available
1462
+ ? { available: item.available }
1463
+ : {}),
1464
+ });
1465
+ }
1466
+
1467
+ /**
1468
+ * Republish the drawer's project cluster — unregister the previous load's
1469
+ * tabs, then register this one's.
1470
+ *
1471
+ * Unregister-then-register (rather than diffing) is the same discipline
1472
+ * {@link applyInspectorContributions} uses, and it is what makes the id
1473
+ * registry's duplicate THROW survivable across an HMR save: the old
1474
+ * registration is always gone before the new one claims the id. A registration
1475
+ * that still throws (an id some other surface owns) teaches and is skipped,
1476
+ * because one bad contribution must not strand the rest of the load with no
1477
+ * tabs at all.
1478
+ */
1479
+ function applyUtilityContributions(items: DrawerToolContribution[]): void {
1480
+ for (const unregister of unregisterUtilityContributions) unregister();
1481
+ unregisterUtilityContributions = [];
1482
+ const claimed: DrawerToolContribution[] = [];
1483
+ for (const item of items) {
1484
+ try {
1485
+ unregisterUtilityContributions.push(registrationForUtilityContribution(item));
1486
+ claimed.push(item);
1487
+ } catch (error) {
1488
+ teachingError(
1489
+ `[tool contributions] ${item.file} could not claim its drawer tab.\n${String(error)}`,
1490
+ );
1491
+ }
1492
+ }
1493
+ // Only the ones that HOLD a tab: the menu entry's whole job is revealing that
1494
+ // tab, so listing a contribution whose registration threw would be a door
1495
+ // onto nothing.
1496
+ utilityContributions = claimed;
1497
+ }
1498
+
1499
+ /** Clear every contribution point this loader owns. Shared by the
1500
+ * no-project and no-host paths so neither leaves a previous load's tabs,
1501
+ * sections and documents standing over a project that cannot supply them. */
1502
+ function clearProjectToolContributions(): void {
1503
+ publishPlayUtilitiesReady(false);
1504
+ assetInspectorContributions = [];
1505
+ generationResultContributions = [];
1506
+ contributionLoadFailures = [];
1507
+ applyGlobalContributions([]);
1508
+ applyInspectorContributions([]);
1509
+ applyUtilityContributions([]);
1510
+ applyStatusContributions([]);
1511
+ applyLookContributions([]);
1512
+ applyCommandContributions([]);
1513
+ applyChromeContributions([]);
1514
+ applyServiceContributions([]);
1515
+ publishToolContributions();
1516
+ }
1517
+
1518
+ /** `@volter/editor-game/contributions/x.ts` → `@volter/editor-game`. */
1519
+ function packageNameOf(specifier: string): string {
1520
+ const parts = specifier.split('/');
1521
+ return specifier.startsWith('@') ? `${parts[0]}/${parts[1]}` : (parts[0] ?? specifier);
1522
+ }
1523
+
1524
+ /**
1525
+ * The newest in-flight pass. A pass that is superseded mid-flight installs
1526
+ * nothing (the epoch check at its end), so an awaiter that only waited for
1527
+ * ITS pass could resume with the registry still empty while the newer pass
1528
+ * is loading — measured live (2026-09-02, load average 60) as a shared-view
1529
+ * URL restore refusing "Tool document is not registered" for a contribution
1530
+ * that was on disk and opened fine seconds later. Every awaiter now returns
1531
+ * only once the newest pass has installed.
1532
+ */
1533
+ /**
1534
+ * The origin that served THIS module — the editor's own dev server (the the editor's
1535
+ * `edit` session), which is also what serves the open project's modules.
1536
+ *
1537
+ * Everywhere but one shape it equals the page's origin. Inside the Code-OSS
1538
+ * DESKTOP frame it does not: the page is `vscode-file://vscode-app` and the
1539
+ * session is loopback http (docs/CODE-OSS.md §Desktop). Taken once, as a plain
1540
+ * string, because `new URL(<expression>, import.meta.url)` is Vite's asset-URL
1541
+ * pattern and is rewritten statically at transform time.
1542
+ */
1543
+ const MODULE_SERVING_ORIGIN = new URL(import.meta.url).origin;
1544
+
1545
+ let latestContributionRefresh: Promise<void> | null = null;
1546
+
1547
+ // A load version identifies the imported module instance, not a catalog scan.
1548
+ // Document hosts key their mounts by it; changing it for an unchanged bundled
1549
+ // module tears down live documents on an unrelated manifest refresh.
1550
+ const contributionModuleVersions = new WeakMap<object, number>();
1551
+ let nextContributionModuleVersion = 0;
1552
+ function contributionModuleVersion(module: unknown): number {
1553
+ if (module === null || (typeof module !== 'object' && typeof module !== 'function')) return 0;
1554
+ const previous = contributionModuleVersions.get(module);
1555
+ if (previous !== undefined) return previous;
1556
+ const version = ++nextContributionModuleVersion;
1557
+ contributionModuleVersions.set(module, version);
1558
+ return version;
1559
+ }
1560
+
1561
+ export async function refreshProjectToolContributions(): Promise<void> {
1562
+ const pass = runContributionRefresh();
1563
+ latestContributionRefresh = pass;
1564
+ await pass;
1565
+ while (latestContributionRefresh !== null && latestContributionRefresh !== pass) {
1566
+ const newer: Promise<void> = latestContributionRefresh;
1567
+ await newer;
1568
+ if (latestContributionRefresh === newer) break;
1569
+ }
1570
+ }
1571
+
1572
+ async function runContributionRefresh(): Promise<void> {
1573
+ publishPlayUtilitiesReady(false);
1574
+ const epoch = ++contributionRefreshEpoch;
1575
+ const project = getCurrentProject();
1576
+ if (!project) {
1577
+ clearProjectToolContributions();
1578
+ return;
1579
+ }
1580
+
1581
+ let catalog: ProjectToolCatalog;
1582
+ try {
1583
+ const response = await fetch('/__editor/project-tools');
1584
+ // Never `res.json()` on an unexamined content type — see
1585
+ // `editor-server-response.ts` for why `response.ok` cannot answer this.
1586
+ catalog = await editorServerJson<ProjectToolCatalog>(response, 'Catalog request failed');
1587
+ } catch (error) {
1588
+ // A load that cannot happen is reported, never swallowed: the panel reads
1589
+ // `getToolContributionLoadFailures()`, so this is what stands between a
1590
+ // real host failure and a blank "No project tools registered."
1591
+ clearProjectToolContributions();
1592
+ const detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
1593
+ contributionLoadFailures = [
1594
+ {
1595
+ entryPath: '/__editor/project-tools',
1596
+ id: 'project-tool-catalog',
1597
+ error: detail,
1598
+ },
1599
+ ];
1600
+ teachingError(`[tool contributions] could not read the project tool catalog.\n${detail}`);
1601
+ publishToolContributions();
1602
+ return;
1603
+ }
1604
+
1605
+ reportContributionConventionSkew(catalog.suffixes);
1606
+
1607
+ const version = Date.now();
1608
+ const nextGlobal: GlobalToolContribution[] = [];
1609
+ const nextUtility: Array<UtilityToolContribution | AnalyticsToolContribution> = [];
1610
+ const nextStatus: StatusToolContribution[] = [];
1611
+ const nextInspector: InspectorToolContribution[] = [];
1612
+ const nextAssetInspector: AssetInspectorToolContribution[] = [];
1613
+ const nextGenerationResult: GenerationResultToolContribution[] = [];
1614
+ const nextFailures: ToolContributionLoadFailure[] = [];
1615
+ // A bundled package's entry is listed by specifier; the page loads it through the
1616
+ // loader its bundle registered, or, when the bundle predates the entry (a checkout's
1617
+ // source moved past its build), from the file the host names. Joined to the project
1618
+ // root it named `<project>/@volter/…`, which exists nowhere.
1619
+ const bundledFiles = new Map(
1620
+ (catalog.contributions ?? []).flatMap((item) => (item.filePath ? [[item.entryPath, item.filePath] as const] : [])),
1621
+ );
1622
+ // A Windows host names files `C:\…`; Vite serves them at `/@fs/C:/…`.
1623
+ const absolute = (entryPath: string) =>
1624
+ entryPath.startsWith('/') || bundledPackageLoaders.has(entryPath)
1625
+ ? entryPath
1626
+ : /^[A-Za-z]:[\\/]/.test(entryPath)
1627
+ ? entryPath.replaceAll('\\', '/')
1628
+ : (bundledFiles.get(entryPath) ?? `${project.rootPath}/${entryPath}`).replaceAll('\\', '/');
1629
+ // The host serves a contribution through its own Vite (`/@fs/`,
1630
+ // cache-busted per refresh); a package this build bundles is already in the
1631
+ // page and loads through its own registered loader.
1632
+ const importContribution = (entryPath: string) =>
1633
+ bundledPackageLoaders.get(entryPath)?.() ??
1634
+ import(/* @vite-ignore */ `/@fs/${absolute(entryPath)}?t=${version}`);
1635
+ // The same door, handed to the contributions themselves for the project
1636
+ // modules THEY need (`@volter/sdk/contributions` `importProjectModule`).
1637
+ registerProjectModuleLoader({
1638
+ import: (path) =>
1639
+ import(/* @vite-ignore */ `/@fs/${absolute(path)}?t=${version}`) as Promise<
1640
+ Record<string, unknown>
1641
+ >,
1642
+ // Resolved against the ORIGIN THAT SERVED THIS MODULE, never the
1643
+ // page's. A project module is served by the session that served this
1644
+ // file, and those two are the same origin in every shape but one:
1645
+ // inside the Code-OSS DESKTOP frame the page is
1646
+ // `vscode-file://vscode-app` (Electron's app root) while the session
1647
+ // stays on loopback http, so a url built from the page sent the Model
1648
+ // document's save-time re-import at the app root, where it 404s
1649
+ // (docs/CODE-OSS.md §Desktop, measured 2026-09-19).
1650
+ //
1651
+ // NOTE THE SHAPE, and do not "simplify" it back:
1652
+ // `new URL(<expression>, import.meta.url)` is Vite's own ASSET-URL
1653
+ // pattern, which its `assetImportMetaUrl` plugin rewrites statically
1654
+ // at transform time — with a computed first argument it resolves to
1655
+ // `undefined` and this door silently hands out
1656
+ // `<origin>/packages/editor/src/undefined` (measured the same day).
1657
+ // `new URL(import.meta.url)` with ONE argument is not that pattern,
1658
+ // so the origin is taken first and used as a plain base string.
1659
+ url: (path) => new URL(`/@fs/${absolute(path)}`, MODULE_SERVING_ORIGIN),
1660
+ // The file's TEXT through Vite's own `?raw` door (the HTTP storage
1661
+ // backend is rooted at `public/`, and a plain fetch of a `.ts` URL
1662
+ // answers with the transformed module). A fresh query each time:
1663
+ // the module may have just been written.
1664
+ source: (path) =>
1665
+ (
1666
+ import(
1667
+ /* @vite-ignore */ `/@fs/${absolute(path)}?raw&volter-source=${Date.now()}`
1668
+ ) as Promise<{ default?: unknown }>
1669
+ ).then((raw) => {
1670
+ if (typeof raw.default !== 'string') {
1671
+ throw new Error(`${path}: the dev server did not answer with the file's text`);
1672
+ }
1673
+ return raw.default;
1674
+ }),
1675
+ });
1676
+ const moduleImports = new Map<string, Promise<unknown>>();
1677
+ for (const { entryPath } of catalog.contributions ?? []) {
1678
+ // Kind contributions are the manifest hosts' (server, validate script);
1679
+ // the page mounts nothing from them.
1680
+ if (isConfigurationKindContribution(entryPath)) continue;
1681
+ const modulePath = absolute(entryPath);
1682
+ moduleImports.set(modulePath, moduleImports.get(modulePath) ?? importContribution(entryPath));
1683
+ }
1684
+ const nextFinderModules: ContributedFinderModule[] = [];
1685
+ const nextLooks: LookModule[] = [];
1686
+ const nextCommands: CommandModule[] = [];
1687
+ const nextChrome: ChromeModule[] = [];
1688
+ const nextServices: ServiceModule[] = [];
1689
+ const claimedIds = new Set<string>();
1690
+ for (const { entryPath } of catalog.contributions ?? []) {
1691
+ if (isConfigurationKindContribution(entryPath)) continue; // a kind is the manifest hosts'
1692
+ if (isServiceContribution(entryPath)) {
1693
+ try {
1694
+ const mod = await moduleImports.get(absolute(entryPath))!;
1695
+ nextServices.push({ entryPath, module: mod });
1696
+ } catch (error) {
1697
+ const { failure, message } = describeContributionLoadFailure(entryPath, error);
1698
+ nextFailures.push(failure);
1699
+ teachingError(message);
1700
+ }
1701
+ continue;
1702
+ }
1703
+ const chrome = chromeContributionKind(entryPath);
1704
+ if (chrome) {
1705
+ try {
1706
+ const mod = await moduleImports.get(absolute(entryPath))!;
1707
+ nextChrome.push({ entryPath, kind: chrome, module: mod });
1708
+ } catch (error) {
1709
+ const { failure, message } = describeContributionLoadFailure(entryPath, error);
1710
+ nextFailures.push(failure);
1711
+ teachingError(message);
1712
+ }
1713
+ continue;
1714
+ }
1715
+ if (isCommandContribution(entryPath)) {
1716
+ try {
1717
+ const mod = await moduleImports.get(absolute(entryPath))!;
1718
+ nextCommands.push({ entryPath, module: mod });
1719
+ } catch (error) {
1720
+ const { failure, message } = describeContributionLoadFailure(entryPath, error);
1721
+ nextFailures.push(failure);
1722
+ teachingError(message);
1723
+ }
1724
+ continue;
1725
+ }
1726
+ const look = lookContributionKind(entryPath);
1727
+ if (look) {
1728
+ // A look is DATA for a registry (`applyLookContributions`), never a
1729
+ // mounted component.
1730
+ try {
1731
+ const mod = await moduleImports.get(absolute(entryPath))!;
1732
+ nextLooks.push({ entryPath, kind: look, module: mod });
1733
+ } catch (error) {
1734
+ const { failure, message } = describeContributionLoadFailure(entryPath, error);
1735
+ nextFailures.push(failure);
1736
+ teachingError(message);
1737
+ }
1738
+ continue;
1739
+ }
1740
+ if (isFinderContribution(entryPath)) {
1741
+ // A finder is the document-table host's (`project-adapter.ts`), which
1742
+ // registers what is collected here after this pass publishes.
1743
+ try {
1744
+ const mod = await moduleImports.get(absolute(entryPath))!;
1745
+ nextFinderModules.push({ entryPath, module: mod });
1746
+ } catch (error) {
1747
+ const { failure, message } = describeContributionLoadFailure(entryPath, error);
1748
+ nextFailures.push(failure);
1749
+ teachingError(message);
1750
+ }
1751
+ continue;
1752
+ }
1753
+ let mod: unknown;
1754
+ try {
1755
+ mod = await moduleImports.get(absolute(entryPath))!;
1756
+ } catch (error) {
1757
+ const { failure, message } = describeContributionLoadFailure(entryPath, error);
1758
+ nextFailures.push(failure);
1759
+ teachingError(message);
1760
+ continue;
1761
+ }
1762
+ // A module declaring `presentations` mounts once PER presentation.
1763
+ for (const presentation of readToolPresentations(mod) ?? [undefined]) {
1764
+ const result = extractProjectToolContribution(
1765
+ mod,
1766
+ entryPath,
1767
+ contributionModuleVersion(mod),
1768
+ catalog.tools,
1769
+ presentation,
1770
+ );
1771
+ if ('error' in result) {
1772
+ if ('note' in result && result.note) teachingNote(result.error);
1773
+ else teachingError(result.error);
1774
+ continue;
1775
+ }
1776
+ // Ids are the editor's mount keys and are no longer tool-qualified, so
1777
+ // two files with the same basename would silently shadow one another.
1778
+ if (claimedIds.has(result.contribution.id)) {
1779
+ // The project's own module registers first, so the loser is the
1780
+ // later one — a package's, when a project still carries the copy a
1781
+ // capability shipped before it became a package.
1782
+ const packaged = entryPath.startsWith('@volter/') || entryPath.includes('/node_modules/');
1783
+ teachingError(
1784
+ `[tool contributions] ${entryPath} claims id ${JSON.stringify(result.contribution.id)}, ` +
1785
+ 'which another contribution already uses. ' +
1786
+ (packaged
1787
+ ? 'This one ships in a package the project declares; if the other is a copy under ' +
1788
+ 'editor/contributions/ from before that capability became a package, delete the copy. '
1789
+ : 'Rename one of them. ') +
1790
+ 'Skipped.',
1791
+ );
1792
+ continue;
1793
+ }
1794
+ claimedIds.add(result.contribution.id);
1795
+ if (result.contribution.point === 'selection.inspector') {
1796
+ nextInspector.push(result.contribution);
1797
+ } else if (
1798
+ result.contribution.point === 'workspace.utility' ||
1799
+ result.contribution.point === 'workspace.analytics'
1800
+ ) {
1801
+ nextUtility.push(result.contribution);
1802
+ } else if (result.contribution.point === 'workspace.status') {
1803
+ nextStatus.push(result.contribution);
1804
+ } else if (result.contribution.point === 'asset.inspector') {
1805
+ nextAssetInspector.push(result.contribution);
1806
+ } else if (result.contribution.point === 'generation.result') {
1807
+ nextGenerationResult.push(result.contribution);
1808
+ } else {
1809
+ nextGlobal.push(result.contribution);
1810
+ }
1811
+ }
1812
+ }
1813
+ if (epoch !== contributionRefreshEpoch || getCurrentProject()?.rootPath !== project.rootPath) {
1814
+ return;
1815
+ }
1816
+ assetInspectorContributions = nextAssetInspector;
1817
+ generationResultContributions = nextGenerationResult;
1818
+ contributionLoadFailures = nextFailures;
1819
+ applyGlobalContributions(nextGlobal);
1820
+ applyInspectorContributions(nextInspector);
1821
+ applyUtilityContributions(nextUtility);
1822
+ applyStatusContributions(nextStatus);
1823
+ applyLookContributions(nextLooks);
1824
+ applyCommandContributions(nextCommands);
1825
+ applyChromeContributions(nextChrome);
1826
+ applyServiceContributions(nextServices);
1827
+ // Only now is "no package contributes it" a fact about this project.
1828
+ reportUnavailableKeymap();
1829
+ publishPlayUtilitiesReady(true);
1830
+ contributedFinders = nextFinderModules;
1831
+ publishToolContributions();
1832
+ }
1833
+
1834
+ function applyGlobalContributions(next: GlobalToolContribution[]): void {
1835
+ globalContributions = next;
1836
+ }
1837
+
1838
+ /**
1839
+ * The ONE publish of a load pass: rebuild the surface snapshot from both halves
1840
+ * and notify every subscriber exactly once.
1841
+ *
1842
+ * The `apply*` functions above only WRITE their half; none of them notifies.
1843
+ * That split is the point — a load pass writes five stores (documents,
1844
+ * utilities, inspector sections, asset inspectors, generation results), and a
1845
+ * notify from inside any one of them publishes a half-updated editor: the new
1846
+ * documents beside the previous load's utilities, in a `useSyncExternalStore`
1847
+ * snapshot subscribers then render and reconcile from. Notifying per half also
1848
+ * ran every subscriber's work twice per refresh (dock reconcile, menu rebuild).
1849
+ */
1850
+ let contributionsPublished = false;
1851
+
1852
+ function publishToolContributions(): void {
1853
+ surfaceContributions = [...globalContributions, ...utilityContributions];
1854
+ contributionsPublished = true;
1855
+ for (const fn of listeners) fn();
1856
+ }
1857
+
1858
+ /** Whether a load pass has published at least once this session: after that, a
1859
+ * subscriber that arrives late has already missed the edge it would wait for. */
1860
+ export function toolContributionsPublished(): boolean {
1861
+ return contributionsPublished;
1862
+ }
1863
+
1864
+ export function __publishGlobalToolContributionsForTest(next: GlobalToolContribution[]): void {
1865
+ applyGlobalContributions(next);
1866
+ publishToolContributions();
1867
+ }
1868
+
1869
+ /**
1870
+ * A page bundle and a host from different revisions disagree about which files
1871
+ * are contributions, and the page then drops or misloads them with no word on
1872
+ * which half is behind. The host lists the suffixes it scans; this compares.
1873
+ */
1874
+ let reportedConventionSkew: string | null = null;
1875
+ function reportContributionConventionSkew(hostSuffixes: readonly string[] | undefined): void {
1876
+ if (!hostSuffixes) return;
1877
+ const page = new Set<string>(TOOL_CONTRIBUTION_SUFFIXES);
1878
+ const host = new Set(hostSuffixes);
1879
+ const hostOnly = [...host].filter((suffix) => !page.has(suffix));
1880
+ const pageOnly = [...page].filter((suffix) => !host.has(suffix));
1881
+ if (hostOnly.length === 0 && pageOnly.length === 0) return;
1882
+ const key = `${hostOnly.join(',')}|${pageOnly.join(',')}`;
1883
+ if (reportedConventionSkew === key) return;
1884
+ reportedConventionSkew = key;
1885
+ teachingError(
1886
+ [
1887
+ '[tool contributions] This page and the session server are different builds.',
1888
+ ...(hostOnly.length > 0
1889
+ ? [`The server lists ${hostOnly.map((s) => `*${s}`).join(', ')} contributions this page does not know, so the PAGE BUNDLE is older: rebuild the product's page bundle, then reload.`]
1890
+ : []),
1891
+ ...(pageOnly.length > 0
1892
+ ? [`This page knows ${pageOnly.map((s) => `*${s}`).join(', ')} contributions the server does not scan, so the SERVER is older: rebuild the kit's server, then restart the session.`]
1893
+ : []),
1894
+ ].join(' '),
1895
+ );
1896
+ }
1897
+
1898
+ function teachingError(message: string): void {
1899
+ // biome-ignore lint/suspicious/noConsole: broken project contributions must be loud without crashing the editor
1900
+ console.error(message);
1901
+ }
1902
+
1903
+ /** A contribution that is fine but INAPPLICABLE HERE — nothing is broken and
1904
+ * nothing on this tier can fix it, so it must not spend the console's error
1905
+ * badge (the "unresolved console is remaining work" rule only holds while
1906
+ * every error is real work). */
1907
+ function teachingNote(message: string): void {
1908
+ // biome-ignore lint/suspicious/noConsole: the tier's own explanation, at warning weight
1909
+ console.warn(message);
1910
+ }
1911
+
1912
+ if (hot) {
1913
+ // `path` is the file's place in the project; `file` is its absolute path.
1914
+ hot.on('volter:script-update', (data: { file: string; path: string }) => {
1915
+ if (!isEditorLanePath(data.path)) return;
1916
+ void refreshProjectToolContributions();
1917
+ });
1918
+ }