@volter/editor-core 0.5.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (719) hide show
  1. package/BUNDLED_NOTICES +1527 -0
  2. package/LICENSE +686 -0
  3. package/LICENSE-APACHE +202 -0
  4. package/NOTICE +18 -0
  5. package/README.md +24 -0
  6. package/dist/build/chunk-JS4D4XZ3.js +175 -0
  7. package/dist/build/chunk-JS4D4XZ3.js.map +7 -0
  8. package/dist/build/vite-plugin-product-contributions.js +100 -0
  9. package/dist/build/vite-plugin-product-contributions.js.map +7 -0
  10. package/dist/build/vite-plugin-shared-react.js +25 -0
  11. package/dist/build/vite-plugin-shared-react.js.map +7 -0
  12. package/dist/build/vite-plugin-shared-three.js +86 -0
  13. package/dist/build/vite-plugin-shared-three.js.map +7 -0
  14. package/dist/server/chunk-KMJC7AGO.js +98 -0
  15. package/dist/server/chunk-KMJC7AGO.js.map +7 -0
  16. package/dist/server/chunk-PSILUMGS.js +46 -0
  17. package/dist/server/chunk-PSILUMGS.js.map +7 -0
  18. package/dist/server/frame-proxy.js +255 -0
  19. package/dist/server/frame-proxy.js.map +7 -0
  20. package/dist/server/open-browser.js +53 -0
  21. package/dist/server/open-browser.js.map +7 -0
  22. package/dist/server/process-shutdown.js +127 -0
  23. package/dist/server/process-shutdown.js.map +7 -0
  24. package/dist/server/session-registry.js +141 -0
  25. package/dist/server/session-registry.js.map +7 -0
  26. package/dist/server/spawn-opener.js +11 -0
  27. package/dist/server/spawn-opener.js.map +7 -0
  28. package/dist/server/worktree-identity.js +11 -0
  29. package/dist/server/worktree-identity.js.map +7 -0
  30. package/dist-server/packaged.mjs +88816 -0
  31. package/package.json +118 -0
  32. package/scripts/build-plugins.mjs +14 -0
  33. package/scripts/build-server.mjs +26 -0
  34. package/scripts/build-session.mjs +9 -0
  35. package/server/account-credentials.ts +60 -0
  36. package/server/account-service.ts +1434 -0
  37. package/server/adapter-region-includes.ts +47 -0
  38. package/server/asset-catalog-v2.ts +175 -0
  39. package/server/asset-history-snapshots.ts +196 -0
  40. package/server/asset-ledger-store.ts +195 -0
  41. package/server/asset-library-routes.ts +1595 -0
  42. package/server/blender-wasm-artifact.ts +430 -0
  43. package/server/boot-timings.ts +106 -0
  44. package/server/canonical-path.ts +50 -0
  45. package/server/checkout-workspace-preflight.ts +349 -0
  46. package/server/cloud-asset-catalog.ts +158 -0
  47. package/server/coding-inference-launch.ts +237 -0
  48. package/server/collaboration-account-client.ts +202 -0
  49. package/server/collaboration-attribution.ts +122 -0
  50. package/server/collaboration-session.ts +934 -0
  51. package/server/comfyui-bridge.ts +248 -0
  52. package/server/console-ledger.ts +499 -0
  53. package/server/creation-site-transform.ts +304 -0
  54. package/server/creation-site-write.ts +648 -0
  55. package/server/data-file-serialize.ts +158 -0
  56. package/server/editor-brand-html.ts +72 -0
  57. package/server/editor-control-socket.ts +366 -0
  58. package/server/editor-server-options.ts +190 -0
  59. package/server/editor-server.ts +1483 -0
  60. package/server/editor-sse.ts +352 -0
  61. package/server/engine-provenance.ts +169 -0
  62. package/server/engine-source-restart.ts +117 -0
  63. package/server/frame-bridge.ts +115 -0
  64. package/server/frame-proxy.ts +490 -0
  65. package/server/frame-workbench.ts +259 -0
  66. package/server/frontend-handoff.ts +229 -0
  67. package/server/game-globals-shadow.ts +113 -0
  68. package/server/gameplay-session-retention.ts +149 -0
  69. package/server/gameplay-sessions.ts +123 -0
  70. package/server/generation-jobs.ts +201 -0
  71. package/server/generation-reconciler.ts +137 -0
  72. package/server/generative-execution-context.ts +59 -0
  73. package/server/git-workflow.ts +612 -0
  74. package/server/harness-chat-caller.ts +36 -0
  75. package/server/harness-chat-service.ts +1681 -0
  76. package/server/idle-shutdown.ts +170 -0
  77. package/server/js-profiling-policy.ts +52 -0
  78. package/server/launcher-settings.ts +84 -0
  79. package/server/local-asset-catalog.ts +410 -0
  80. package/server/managed-account-defaults.ts +22 -0
  81. package/server/mock-control-plane.ts +540 -0
  82. package/server/model-import-conversion.ts +345 -0
  83. package/server/native-credential-store.ts +162 -0
  84. package/server/open-browser.ts +118 -0
  85. package/server/packaged.ts +1192 -0
  86. package/server/play-stall.ts +126 -0
  87. package/server/process-shutdown.ts +218 -0
  88. package/server/product-presets.ts +55 -0
  89. package/server/project-build-artifact.ts +95 -0
  90. package/server/project-components.ts +100 -0
  91. package/server/project-dependency-invalidation.ts +114 -0
  92. package/server/project-file-scan.ts +235 -0
  93. package/server/project-hmr-files.ts +215 -0
  94. package/server/project-install-roots.ts +210 -0
  95. package/server/project-kinds.ts +71 -0
  96. package/server/project-mcp-servers.ts +82 -0
  97. package/server/project-module-freshness.ts +109 -0
  98. package/server/project-module-instance.ts +375 -0
  99. package/server/project-optimize-deps-entries.ts +827 -0
  100. package/server/project-output-writer.ts +679 -0
  101. package/server/project-package-origin.ts +63 -0
  102. package/server/project-root-surface.ts +831 -0
  103. package/server/project-scratch-path.ts +47 -0
  104. package/server/project-script-hmr.ts +619 -0
  105. package/server/project-serving-plugins.ts +236 -0
  106. package/server/project-tools.ts +653 -0
  107. package/server/project-validation.ts +250 -0
  108. package/server/project-verbs.ts +127 -0
  109. package/server/project-view.ts +52 -0
  110. package/server/project-watch.ts +1134 -0
  111. package/server/project-work-coordinator.ts +288 -0
  112. package/server/provider-credentials.ts +331 -0
  113. package/server/recent-projects-store.ts +98 -0
  114. package/server/redact-secrets.ts +71 -0
  115. package/server/repository-presence.ts +267 -0
  116. package/server/routes/account.ts +357 -0
  117. package/server/routes/agents.ts +60 -0
  118. package/server/routes/assets.ts +154 -0
  119. package/server/routes/build.ts +13 -0
  120. package/server/routes/collaboration.ts +595 -0
  121. package/server/routes/configurations.ts +419 -0
  122. package/server/routes/context.ts +264 -0
  123. package/server/routes/control-plane.ts +1811 -0
  124. package/server/routes/logs.ts +272 -0
  125. package/server/routes/project-identity.ts +302 -0
  126. package/server/routes/project-open.ts +429 -0
  127. package/server/routes/project-source.ts +686 -0
  128. package/server/routes/project-state.ts +383 -0
  129. package/server/routes/relay.ts +1029 -0
  130. package/server/routes/route-helpers.ts +29 -0
  131. package/server/routes/served-modules.ts +138 -0
  132. package/server/routes/session-tabs.ts +196 -0
  133. package/server/routes/settings.ts +121 -0
  134. package/server/routes/share-control.ts +486 -0
  135. package/server/routes/themes.ts +116 -0
  136. package/server/routes/tools.ts +230 -0
  137. package/server/routes/worktrees.ts +595 -0
  138. package/server/scoped-game-css.ts +461 -0
  139. package/server/server-utils.ts +1194 -0
  140. package/server/session-product.ts +112 -0
  141. package/server/session-registry.ts +230 -0
  142. package/server/share-claims.ts +138 -0
  143. package/server/share-host.ts +526 -0
  144. package/server/share-session-gateway.ts +1362 -0
  145. package/server/share-tunnel.ts +123 -0
  146. package/server/spawn-opener.ts +74 -0
  147. package/server/support/play/log-format.ts +76 -0
  148. package/server/support/play/session-record.ts +199 -0
  149. package/server/support/project/build-discipline.ts +718 -0
  150. package/server/support/project/inspection-node.ts +119 -0
  151. package/server/support/project/log-naming.ts +54 -0
  152. package/server/support/project/provenance.ts +290 -0
  153. package/server/support/project/run-name.ts +18 -0
  154. package/server/support/project/session-journal.ts +830 -0
  155. package/server/support/project/shared.ts +327 -0
  156. package/server/tab-bootstrap.ts +62 -0
  157. package/server/tab-heartbeat.ts +477 -0
  158. package/server/tab-lifecycle.ts +550 -0
  159. package/server/tab-presence.ts +1508 -0
  160. package/server/team-agent-mirror.ts +95 -0
  161. package/server/twin-auth.ts +138 -0
  162. package/server/vendored-lock-recorder.ts +968 -0
  163. package/server/worktree-identity.ts +121 -0
  164. package/server/worktree-management.ts +398 -0
  165. package/server/worktree-retention.d.mts +13 -0
  166. package/server/worktree-retention.mjs +205 -0
  167. package/src/EditorContext.tsx +169 -0
  168. package/src/account.ts +333 -0
  169. package/src/action-registry.ts +532 -0
  170. package/src/active-product.ts +74 -0
  171. package/src/active-project.ts +155 -0
  172. package/src/adapter-editor-config.ts +25 -0
  173. package/src/adapter-observation.ts +49 -0
  174. package/src/animation/stage-transport.ts +379 -0
  175. package/src/animation/three-clips-subject.ts +172 -0
  176. package/src/api/asset-library-wire.ts +45 -0
  177. package/src/api/assets.ts +365 -0
  178. package/src/api/base.ts +10 -0
  179. package/src/api/build.ts +99 -0
  180. package/src/api/git-wire.ts +56 -0
  181. package/src/api/logs.ts +92 -0
  182. package/src/api/project-identity.ts +74 -0
  183. package/src/api/project-open.ts +355 -0
  184. package/src/api/project-source.ts +162 -0
  185. package/src/api/project-state.ts +107 -0
  186. package/src/api/relay.ts +268 -0
  187. package/src/api/settings.ts +36 -0
  188. package/src/api/themes.ts +45 -0
  189. package/src/api/worktrees.ts +257 -0
  190. package/src/asset-compare-core.ts +171 -0
  191. package/src/asset-compare.ts +294 -0
  192. package/src/asset-editor-context.tsx +100 -0
  193. package/src/asset-events.ts +97 -0
  194. package/src/asset-inspector-actions.ts +87 -0
  195. package/src/asset-preview-framing.ts +357 -0
  196. package/src/asset-preview.ts +2802 -0
  197. package/src/asset-selection-viewer-registry.ts +113 -0
  198. package/src/asset-selection.ts +133 -0
  199. package/src/asset-workflow/asset-capabilities.ts +344 -0
  200. package/src/asset-workflow/asset-import-jobs.ts +106 -0
  201. package/src/asset-workflow/asset-ledger-backend.ts +126 -0
  202. package/src/asset-workflow/asset-ledger.ts +156 -0
  203. package/src/asset-workflow/asset-materialization-report.ts +140 -0
  204. package/src/asset-workflow/asset-pack-manifest.ts +320 -0
  205. package/src/asset-workflow/asset-types.ts +142 -0
  206. package/src/asset-workflow/asset-workflow-quality.ts +61 -0
  207. package/src/asset-workflow/audio-preview-player.ts +193 -0
  208. package/src/asset-workflow/audio-waveform.ts +22 -0
  209. package/src/asset-workflow/cloud-asset-client.ts +263 -0
  210. package/src/asset-workflow/folder-preview.ts +345 -0
  211. package/src/asset-workflow/hosted-asset-materialization.ts +236 -0
  212. package/src/asset-workflow/image-view-scale.ts +32 -0
  213. package/src/asset-workflow/import-contract.ts +124 -0
  214. package/src/asset-workflow/ledger-write-lock.ts +244 -0
  215. package/src/asset-workflow/model-inspection.ts +818 -0
  216. package/src/asset-workflow/pixi-spritesheet.ts +197 -0
  217. package/src/asset-workflow/preview-resource-lifetime.ts +44 -0
  218. package/src/asset-workflow/project-asset-commands.ts +23 -0
  219. package/src/asset-workflow/project-asset-health.ts +139 -0
  220. package/src/asset-workflow/project-asset-operations.ts +154 -0
  221. package/src/asset-workflow/project-asset-roots.ts +68 -0
  222. package/src/asset-workflow/project-content.ts +288 -0
  223. package/src/asset-workflow/project-source-index.ts +550 -0
  224. package/src/asset-workflow/thumbnail-system.ts +256 -0
  225. package/src/authoring/active-adapter.ts +199 -0
  226. package/src/authoring/active-systems.ts +422 -0
  227. package/src/authoring/adapter-key.ts +18 -0
  228. package/src/authoring/authoring-asset-url.ts +27 -0
  229. package/src/authoring/bootstrap-state.ts +49 -0
  230. package/src/authoring/boundary-authoring-adapter.ts +184 -0
  231. package/src/authoring/breakpoint-state.ts +43 -0
  232. package/src/authoring/canvas-scene-guides.ts +76 -0
  233. package/src/authoring/component-instance-root.ts +171 -0
  234. package/src/authoring/composite-authoring-adapter.ts +2110 -0
  235. package/src/authoring/consumer-actions.ts +520 -0
  236. package/src/authoring/css-numeric-style.ts +97 -0
  237. package/src/authoring/design-time-layers.ts +849 -0
  238. package/src/authoring/design-time-mount-registry.ts +235 -0
  239. package/src/authoring/design-time-settle.ts +343 -0
  240. package/src/authoring/edit-mode-authoring.ts +619 -0
  241. package/src/authoring/eyedropper-session.ts +60 -0
  242. package/src/authoring/instance-source-menu-register.ts +13 -0
  243. package/src/authoring/instance-source-menu.ts +135 -0
  244. package/src/authoring/layered-pick.ts +183 -0
  245. package/src/authoring/live-gesture-lock.ts +50 -0
  246. package/src/authoring/live-object-transform.ts +62 -0
  247. package/src/authoring/mount-failure-report.ts +154 -0
  248. package/src/authoring/mounted-root-subjects.ts +144 -0
  249. package/src/authoring/no-authoring-adapter.ts +55 -0
  250. package/src/authoring/null-inspection-subjects.tsx +75 -0
  251. package/src/authoring/object3d-document-persistence.ts +106 -0
  252. package/src/authoring/object3d-document-session-registry.ts +120 -0
  253. package/src/authoring/object3d-document-session.ts +1326 -0
  254. package/src/authoring/object3d-gesture-controller.ts +113 -0
  255. package/src/authoring/panel-authoring.ts +121 -0
  256. package/src/authoring/prefab-instance-inspector-section.tsx +237 -0
  257. package/src/authoring/prefab-instance-section-model.ts +55 -0
  258. package/src/authoring/project-authoring-session.ts +105 -0
  259. package/src/authoring/provenance.ts +99 -0
  260. package/src/authoring/quarks-particle-systems.ts +19 -0
  261. package/src/authoring/react-canvas-navigation.ts +255 -0
  262. package/src/authoring/react-design-canvas-style.ts +20 -0
  263. package/src/authoring/react-story-board.ts +937 -0
  264. package/src/authoring/selection-scope.ts +195 -0
  265. package/src/authoring/shell-document-ops.ts +169 -0
  266. package/src/authoring/shell-object3d-document-write-policy.ts +108 -0
  267. package/src/authoring/shell-viewport-policy.ts +48 -0
  268. package/src/authoring/source-object3d-authoring-adapter.ts +526 -0
  269. package/src/authoring/stories-scope.ts +35 -0
  270. package/src/authoring/story-board-chrome-fit.ts +107 -0
  271. package/src/authoring/story-board-presentation.ts +111 -0
  272. package/src/authoring/three-projection-core.ts +226 -0
  273. package/src/authoring/viewport-pick-context.ts +40 -0
  274. package/src/authoring/viewport-raycast.ts +240 -0
  275. package/src/authoring/viewport-tool-context.ts +73 -0
  276. package/src/authoring/world-canvas-viewport-state.ts +35 -0
  277. package/src/authoring/world-hidden-viewport.ts +152 -0
  278. package/src/authoring/world-pan-state.ts +198 -0
  279. package/src/authoring/world-session-state.ts +101 -0
  280. package/src/authoring/write-pipe.ts +173 -0
  281. package/src/availability-tick.ts +66 -0
  282. package/src/bitmap-label.ts +120 -0
  283. package/src/blender-tab-metrics.ts +161 -0
  284. package/src/board-open-actions.ts +20 -0
  285. package/src/boot-routing.ts +391 -0
  286. package/src/build-identity.ts +15 -0
  287. package/src/bytes-codec.ts +62 -0
  288. package/src/camera-authoring.ts +175 -0
  289. package/src/cancellation-reason.ts +58 -0
  290. package/src/canvas-preview-frames.ts +482 -0
  291. package/src/capture-camera-pose.ts +77 -0
  292. package/src/chrome-registry.ts +159 -0
  293. package/src/chrome-slot-registry.ts +91 -0
  294. package/src/collaboration-client.ts +264 -0
  295. package/src/collaboration-presence.ts +41 -0
  296. package/src/command-dispatch.ts +19 -0
  297. package/src/command-listener.ts +2743 -0
  298. package/src/command-registry.ts +70 -0
  299. package/src/component-board-registry.ts +205 -0
  300. package/src/component-states-registry.ts +200 -0
  301. package/src/components/AgentPresentationNotice.tsx +75 -0
  302. package/src/components/AlignToolbar.tsx +204 -0
  303. package/src/components/AppRoot.tsx +394 -0
  304. package/src/components/ApplicationMenus.tsx +415 -0
  305. package/src/components/AssetBrowser.tsx +2843 -0
  306. package/src/components/AssetEditorShell.tsx +216 -0
  307. package/src/components/AssetInspectorToolSection.tsx +124 -0
  308. package/src/components/BoardRulers.tsx +354 -0
  309. package/src/components/CameraInfo.tsx +81 -0
  310. package/src/components/CanvasSceneViewport.tsx +556 -0
  311. package/src/components/CapabilityCoverageSection.tsx +185 -0
  312. package/src/components/CenterDocuments.tsx +22 -0
  313. package/src/components/ChromeSlot.tsx +20 -0
  314. package/src/components/CodeView.tsx +470 -0
  315. package/src/components/CollaborationHeaderControl.css +74 -0
  316. package/src/components/CompactInspectorCard.tsx +190 -0
  317. package/src/components/CompactInspectorShell.tsx +39 -0
  318. package/src/components/ConsolePanel.css +55 -0
  319. package/src/components/ConsolePanel.tsx +270 -0
  320. package/src/components/DefaultEditorLayout.tsx +106 -0
  321. package/src/components/DocumentHeaderStrip.tsx +96 -0
  322. package/src/components/DocumentShelfRail.tsx +45 -0
  323. package/src/components/DocumentThumbnail.tsx +345 -0
  324. package/src/components/EditorLeaseGuard.tsx +372 -0
  325. package/src/components/ErrorBanner.tsx +123 -0
  326. package/src/components/FolderPreviewTile.tsx +159 -0
  327. package/src/components/GameHierarchy.tsx +3842 -0
  328. package/src/components/GameplaySessionTimeline.tsx +295 -0
  329. package/src/components/InspectionProjection.tsx +968 -0
  330. package/src/components/Inspector.tsx +233 -0
  331. package/src/components/InspectorCanvasPreview.tsx +35 -0
  332. package/src/components/InspectorFieldsSection.tsx +290 -0
  333. package/src/components/InspectorObjectPreview.tsx +57 -0
  334. package/src/components/InspectorStoriesSection.tsx +92 -0
  335. package/src/components/InspectorToolSection.tsx +96 -0
  336. package/src/components/InspectorTransformSection.tsx +245 -0
  337. package/src/components/LightExplorerPanel.tsx +433 -0
  338. package/src/components/MediaProperties.tsx +145 -0
  339. package/src/components/NonThreeAuthoringBootstrap.tsx +19 -0
  340. package/src/components/Object3DDocumentToolbar.css +100 -0
  341. package/src/components/Object3DDocumentToolbar.tsx +454 -0
  342. package/src/components/Object3DDocumentViewport.tsx +58 -0
  343. package/src/components/ProjectHeader.css +617 -0
  344. package/src/components/ProjectHeader.tsx +328 -0
  345. package/src/components/ProjectLayout.tsx +249 -0
  346. package/src/components/ReactCanvasControls.tsx +284 -0
  347. package/src/components/RootSelectionOverlay.tsx +3162 -0
  348. package/src/components/RootTextEditor.tsx +79 -0
  349. package/src/components/SaveStatus.tsx +70 -0
  350. package/src/components/StageHost.tsx +1954 -0
  351. package/src/components/StageOverlays.tsx +21 -0
  352. package/src/components/StartupErrorScreen.tsx +76 -0
  353. package/src/components/StartupLoadingScreen.tsx +65 -0
  354. package/src/components/StatsOverlay.tsx +78 -0
  355. package/src/components/SurfaceStateOverlay.tsx +24 -0
  356. package/src/components/ToolContributionSurfaces.tsx +65 -0
  357. package/src/components/ToolHost.tsx +370 -0
  358. package/src/components/ToolObject3DPreview.tsx +39 -0
  359. package/src/components/Toolbar.tsx +595 -0
  360. package/src/components/TransientHint.tsx +44 -0
  361. package/src/components/TransportStrip.tsx +174 -0
  362. package/src/components/VersionControlSection.tsx +470 -0
  363. package/src/components/VgaiLogo.css +83 -0
  364. package/src/components/VgaiLogo.tsx +35 -0
  365. package/src/components/ViewportControlsHint.tsx +60 -0
  366. package/src/components/ViewportFurniture.tsx +401 -0
  367. package/src/components/ViewportOverlay.tsx +145 -0
  368. package/src/components/ViewportOverlaysMenu.tsx +153 -0
  369. package/src/components/ViewportShadingMenu.tsx +300 -0
  370. package/src/components/ViewportViewMenu.tsx +101 -0
  371. package/src/components/WorkspaceDocumentSurface.tsx +204 -0
  372. package/src/components/WorkspaceUtilitySurface.tsx +22 -0
  373. package/src/components/WorktreeSwitcher.css +239 -0
  374. package/src/components/WorktreeSwitcher.tsx +890 -0
  375. package/src/components/account-documents.tsx +1162 -0
  376. package/src/components/asset-documents.tsx +812 -0
  377. package/src/components/asset-editor-persistence.ts +216 -0
  378. package/src/components/asset-selection-section.tsx +535 -0
  379. package/src/components/asset-thumbnails.tsx +312 -0
  380. package/src/components/asset-viewers/AudioViewer.tsx +201 -0
  381. package/src/components/asset-viewers/EntityModelDocument.tsx +122 -0
  382. package/src/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
  383. package/src/components/asset-viewers/GenericJsonViewer.tsx +102 -0
  384. package/src/components/asset-viewers/ImageViewer.tsx +300 -0
  385. package/src/components/asset-viewers/JsonAssetDocument.tsx +93 -0
  386. package/src/components/asset-viewers/LiveModuleDocument.tsx +417 -0
  387. package/src/components/asset-viewers/LutAssetDocument.tsx +444 -0
  388. package/src/components/asset-viewers/ModelAssetDocument.tsx +105 -0
  389. package/src/components/asset-viewers/Object3DPreview.tsx +359 -0
  390. package/src/components/asset-viewers/OnlineAssetDetail.tsx +421 -0
  391. package/src/components/asset-viewers/PasteboardModuleDocument.tsx +67 -0
  392. package/src/components/asset-viewers/QuarksAssetDocument.tsx +526 -0
  393. package/src/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
  394. package/src/components/asset-viewers/SourceAssetViewer.tsx +281 -0
  395. package/src/components/asset-viewers/SpritesheetSpriteView.tsx +102 -0
  396. package/src/components/asset-viewers/VideoViewer.tsx +101 -0
  397. package/src/components/asset-viewers/shader-source.ts +144 -0
  398. package/src/components/asset-workflow.css +731 -0
  399. package/src/components/board-guides.ts +150 -0
  400. package/src/components/compact-inspector.css +539 -0
  401. package/src/components/core-utilities.tsx +90 -0
  402. package/src/components/editor-notifications.css +40 -0
  403. package/src/components/engine-workspace.css +274 -0
  404. package/src/components/inspector-preview-section.tsx +238 -0
  405. package/src/components/inspector-property-grouping.ts +64 -0
  406. package/src/components/inspector-revert-label.ts +20 -0
  407. package/src/components/inspector-selection.ts +42 -0
  408. package/src/components/inspector-stories-gating.ts +171 -0
  409. package/src/components/inspector-transform-subject.ts +11 -0
  410. package/src/components/inspector-transform.ts +75 -0
  411. package/src/components/kind-documents.tsx +500 -0
  412. package/src/components/palette-action-publisher.tsx +140 -0
  413. package/src/components/primitives/DraftColorInput.tsx +74 -0
  414. package/src/components/product-shell.css +1829 -0
  415. package/src/components/project-tool-documents.tsx +410 -0
  416. package/src/components/scene-documents.tsx +223 -0
  417. package/src/components/stage-keyboard.tsx +37 -0
  418. package/src/components/stage-overlay-set.tsx +107 -0
  419. package/src/components/stage-presence-markers.ts +478 -0
  420. package/src/components/standard-viewport-dressing.ts +417 -0
  421. package/src/components/status-contributions.tsx +403 -0
  422. package/src/components/tool-documents.tsx +309 -0
  423. package/src/components/tool-schema-form.tsx +262 -0
  424. package/src/components/use-after-paint.ts +41 -0
  425. package/src/components/use-project-image-assets.ts +86 -0
  426. package/src/components/viewport-header-controls.css +90 -0
  427. package/src/components/viewport-surface-status.tsx +55 -0
  428. package/src/components/workspace-history.ts +32 -0
  429. package/src/components/workspace-static-panel-registry.tsx +152 -0
  430. package/src/components/workspace-surfaces.css +621 -0
  431. package/src/components/world-documents.tsx +586 -0
  432. package/src/components/world-overlay-gestures.ts +1694 -0
  433. package/src/composite-screenshot.ts +1641 -0
  434. package/src/console-sync.ts +131 -0
  435. package/src/constraint-helper.ts +338 -0
  436. package/src/content-entry-source-registry.ts +184 -0
  437. package/src/coverage/authoring-seam-evidence.ts +300 -0
  438. package/src/coverage/canvas-reveal.ts +192 -0
  439. package/src/coverage/design-time-surfaces.ts +101 -0
  440. package/src/coverage/live-seam-evidence.ts +11 -0
  441. package/src/coverage/ontology-invariants.ts +466 -0
  442. package/src/coverage/session-vitals.ts +501 -0
  443. package/src/coverage/system-seam-evidence.ts +72 -0
  444. package/src/crash-null-boundary.ts +36 -0
  445. package/src/creation-site-edit.ts +1479 -0
  446. package/src/creation-site-registry.ts +160 -0
  447. package/src/delegate-harness-registry.ts +143 -0
  448. package/src/document-context-registry.ts +177 -0
  449. package/src/document-open-registry.ts +200 -0
  450. package/src/document-preview-source.ts +20 -0
  451. package/src/document-renderer-session.ts +138 -0
  452. package/src/editor-api.ts +46 -0
  453. package/src/editor-chrome-capture.ts +139 -0
  454. package/src/editor-commands.ts +164 -0
  455. package/src/editor-console.ts +563 -0
  456. package/src/editor-current-view.ts +82 -0
  457. package/src/editor-document-probe.ts +881 -0
  458. package/src/editor-git-client.ts +115 -0
  459. package/src/editor-host-door.ts +462 -0
  460. package/src/editor-hotkeys.ts +842 -0
  461. package/src/editor-lease-view.ts +39 -0
  462. package/src/editor-lease.ts +415 -0
  463. package/src/editor-mode.ts +19 -0
  464. package/src/editor-notifications.ts +140 -0
  465. package/src/editor-presence.ts +563 -0
  466. package/src/editor-presentation-activity.ts +50 -0
  467. package/src/editor-presentation-notice.ts +42 -0
  468. package/src/editor-runtime.tsx +145 -0
  469. package/src/editor-server-response.ts +86 -0
  470. package/src/editor-session-attribution.ts +75 -0
  471. package/src/editor-session-mode.ts +54 -0
  472. package/src/editor-shell-store.ts +1304 -0
  473. package/src/editor-state-facets.ts +74 -0
  474. package/src/editor-styles.css +33 -0
  475. package/src/editor-view-presentation.ts +810 -0
  476. package/src/editor-viewport.ts +5302 -0
  477. package/src/entity-lod.ts +31 -0
  478. package/src/entity-object.ts +91 -0
  479. package/src/files/file-provider.ts +62 -0
  480. package/src/files/project-files.ts +264 -0
  481. package/src/finders/index.ts +136 -0
  482. package/src/finders/scenes-from-entrypoint-selection.ts +387 -0
  483. package/src/frame/bridge.tsx +1408 -0
  484. package/src/frame/product.ts +77 -0
  485. package/src/gameplay-dom-recording.ts +319 -0
  486. package/src/gameplay-export-state.ts +14 -0
  487. package/src/gameplay-replay.ts +417 -0
  488. package/src/gameplay-session-time.ts +9 -0
  489. package/src/gameplay-sessions.ts +204 -0
  490. package/src/harness-chat-types.ts +253 -0
  491. package/src/hierarchy-component-marks.ts +298 -0
  492. package/src/hierarchy-drop.ts +91 -0
  493. package/src/hierarchy-expansion-state.ts +80 -0
  494. package/src/hierarchy-header-slot.ts +52 -0
  495. package/src/hierarchy-internals.ts +197 -0
  496. package/src/hierarchy-kind-icon.ts +217 -0
  497. package/src/hierarchy-mark-reader.ts +73 -0
  498. package/src/hierarchy-menu-registry.ts +67 -0
  499. package/src/hierarchy-node-rows.ts +307 -0
  500. package/src/hierarchy-panel-view.ts +280 -0
  501. package/src/hierarchy-projection.ts +76 -0
  502. package/src/hierarchy-row-cache.ts +243 -0
  503. package/src/hierarchy-row-model.ts +308 -0
  504. package/src/hierarchy-rows.ts +11 -0
  505. package/src/hierarchy-walk.ts +86 -0
  506. package/src/history/editor-session.ts +25 -0
  507. package/src/history/history-commands.ts +147 -0
  508. package/src/history/history-delegate.ts +163 -0
  509. package/src/history/history-limit-notices.ts +43 -0
  510. package/src/history/history-service.ts +1173 -0
  511. package/src/history/persistence-coordinator.ts +35 -0
  512. package/src/history/project-file-history.ts +386 -0
  513. package/src/history/project-root-history-backends.ts +139 -0
  514. package/src/history/resource-registry.ts +209 -0
  515. package/src/history/snapshot-store.ts +103 -0
  516. package/src/history/source-history-backend.ts +546 -0
  517. package/src/history/types.ts +124 -0
  518. package/src/hmr-registration-group.ts +67 -0
  519. package/src/hmr-stable-react-context.ts +23 -0
  520. package/src/hotkeys.ts +188 -0
  521. package/src/inference-diagnostics.ts +69 -0
  522. package/src/initial-project.ts +80 -0
  523. package/src/inspection/active-subject.ts +578 -0
  524. package/src/inspection/active-surface.ts +142 -0
  525. package/src/inspection/compose.ts +1064 -0
  526. package/src/inspection/display.ts +170 -0
  527. package/src/inspection/document-subject.ts +109 -0
  528. package/src/inspection/game-subject.ts +85 -0
  529. package/src/inspection/model.ts +542 -0
  530. package/src/inspection/null-subject.ts +115 -0
  531. package/src/inspection/serialize.ts +357 -0
  532. package/src/inspection/use-active-inspection.ts +180 -0
  533. package/src/inspector-presentation.ts +201 -0
  534. package/src/inspector-section-registry.ts +221 -0
  535. package/src/instance-source-actions.ts +163 -0
  536. package/src/instanced-presentation.ts +164 -0
  537. package/src/js-heap.ts +71 -0
  538. package/src/key-actions.ts +91 -0
  539. package/src/keymap-presets.ts +401 -0
  540. package/src/layout-policy.ts +31 -0
  541. package/src/learn-links.ts +73 -0
  542. package/src/light-explorer-model.ts +134 -0
  543. package/src/live-canvas-frame.ts +55 -0
  544. package/src/live-document.ts +279 -0
  545. package/src/live-module-source.ts +230 -0
  546. package/src/live-session-registry.ts +220 -0
  547. package/src/live-transition.ts +633 -0
  548. package/src/manifest-project.ts +107 -0
  549. package/src/model-thumbnail.ts +523 -0
  550. package/src/native-selection-style.ts +202 -0
  551. package/src/object3d-document-write-policy.ts +137 -0
  552. package/src/packaged-runtime.ts +108 -0
  553. package/src/palettes/maya.palette.json +57 -0
  554. package/src/palettes/substance.palette.json +57 -0
  555. package/src/pasteboard-module.ts +81 -0
  556. package/src/performance-profiler.ts +367 -0
  557. package/src/performance-sources.ts +69 -0
  558. package/src/play-boot-phase.ts +145 -0
  559. package/src/presentation-surface.ts +248 -0
  560. package/src/project-adapter.ts +1143 -0
  561. package/src/project-asset-refresh.ts +26 -0
  562. package/src/project-declaration-refresh.ts +31 -0
  563. package/src/project-local-state.ts +118 -0
  564. package/src/project-manager.ts +243 -0
  565. package/src/project-module-changes.ts +163 -0
  566. package/src/project-module-split.ts +266 -0
  567. package/src/project-provenance.ts +115 -0
  568. package/src/project-ready.ts +42 -0
  569. package/src/project-session-reset.ts +67 -0
  570. package/src/project-shape.ts +68 -0
  571. package/src/project-tool-discovery.ts +66 -0
  572. package/src/project-tools.ts +107 -0
  573. package/src/project-work-types.ts +149 -0
  574. package/src/projection/three.ts +898 -0
  575. package/src/projection/types.ts +44 -0
  576. package/src/readiness.ts +113 -0
  577. package/src/reflection-probe-helper.ts +142 -0
  578. package/src/reported-play-state.ts +90 -0
  579. package/src/resolve-relative-specifier.ts +33 -0
  580. package/src/scene-document-plan.ts +315 -0
  581. package/src/scene-framing.ts +315 -0
  582. package/src/scene-live-open.ts +210 -0
  583. package/src/scene-view-fog.ts +89 -0
  584. package/src/scoped-game-css.ts +152 -0
  585. package/src/session-orphan-record.ts +193 -0
  586. package/src/session-tombstone.ts +126 -0
  587. package/src/settings/settings-provider.ts +82 -0
  588. package/src/settings-store.ts +345 -0
  589. package/src/shared-view-restore.ts +42 -0
  590. package/src/shell-store-door.ts +45 -0
  591. package/src/source-conflict.ts +122 -0
  592. package/src/spatial-handle-visuals.ts +332 -0
  593. package/src/stage-context.ts +341 -0
  594. package/src/stage-store-registry.ts +63 -0
  595. package/src/stale-chunk-recovery.ts +34 -0
  596. package/src/state-report-deferral.ts +73 -0
  597. package/src/storage/host-files-storage.ts +97 -0
  598. package/src/storage/http-storage.ts +174 -0
  599. package/src/storage/index.ts +75 -0
  600. package/src/storage/mem-storage.ts +149 -0
  601. package/src/storage/path-lock.ts +44 -0
  602. package/src/storage/paths.ts +26 -0
  603. package/src/storage/types.ts +116 -0
  604. package/src/stories/StoryComponentThumbnail.tsx +184 -0
  605. package/src/stories/StoryPreviewMount.tsx +306 -0
  606. package/src/stories/component-content-source.tsx +106 -0
  607. package/src/stories/component-states-source.ts +78 -0
  608. package/src/stories/compose-project-stories.ts +255 -0
  609. package/src/stories/pixi-story-model.ts +30 -0
  610. package/src/stories/prefabs-finder.ts +54 -0
  611. package/src/stories/prefabs-from-stories.ts +182 -0
  612. package/src/stories/project-story-discovery.ts +78 -0
  613. package/src/stories/project-story-regions.ts +24 -0
  614. package/src/stories/story-actions.ts +54 -0
  615. package/src/stories/story-capture-command.ts +124 -0
  616. package/src/stories/story-capture.ts +584 -0
  617. package/src/stories/story-declared-medium.ts +126 -0
  618. package/src/stories/story-discovery.ts +176 -0
  619. package/src/stories/story-document-openers.ts +36 -0
  620. package/src/stories/story-dom-runtime.ts +78 -0
  621. package/src/stories/story-grouping.ts +111 -0
  622. package/src/stories/story-lane.ts +88 -0
  623. package/src/stories/story-mount-turn.ts +27 -0
  624. package/src/stories/story-opener.ts +260 -0
  625. package/src/stories/story-pixi-preview.ts +408 -0
  626. package/src/stories/story-presentation.ts +215 -0
  627. package/src/stories/story-registry.ts +517 -0
  628. package/src/stories/story-three-preview.ts +807 -0
  629. package/src/stories/three-story-model.ts +96 -0
  630. package/src/story-three-preview-runtime.ts +56 -0
  631. package/src/surface-keyboard.ts +112 -0
  632. package/src/surface-state.ts +124 -0
  633. package/src/tab-bootstrap.js +628 -0
  634. package/src/tab-census.ts +198 -0
  635. package/src/tab-lifecycle-client.ts +196 -0
  636. package/src/theme-library.ts +844 -0
  637. package/src/theme-preference.ts +378 -0
  638. package/src/theme.css +3437 -0
  639. package/src/three-viewport/bone-selection-highlight.ts +119 -0
  640. package/src/three-viewport/camera-fit.ts +41 -0
  641. package/src/three-viewport/interactive-renderer.ts +132 -0
  642. package/src/three-viewport/selection-brackets.ts +310 -0
  643. package/src/three-viewport/selection-outline.ts +183 -0
  644. package/src/three-viewport/skeleton-helper.ts +61 -0
  645. package/src/three-viewport/source-color.ts +167 -0
  646. package/src/three-viewport/studio-environment.ts +96 -0
  647. package/src/three-viewport-presentation.ts +23 -0
  648. package/src/tool-contribution-play.ts +74 -0
  649. package/src/tool-loader.ts +1798 -0
  650. package/src/transform-mode-request.ts +57 -0
  651. package/src/transient-hint.ts +78 -0
  652. package/src/trigger-volume-helper.ts +116 -0
  653. package/src/ui-source/adapter-region-includes.ts +241 -0
  654. package/src/ui-source/ensure-import.ts +132 -0
  655. package/src/ui-source/file-region-resolver.ts +302 -0
  656. package/src/ui-source/inspect.ts +775 -0
  657. package/src/ui-source/oid-transform.ts +1845 -0
  658. package/src/ui-source/plan-csf-story.ts +196 -0
  659. package/src/ui-source/plan-extract-component.ts +421 -0
  660. package/src/ui-source/plan-fork-component.ts +621 -0
  661. package/src/ui-source/plan-named-style.ts +95 -0
  662. package/src/ui-source/plan-source-edit.ts +353 -0
  663. package/src/ui-source/r3f-contract-resolver.ts +327 -0
  664. package/src/ui-source/r3f-diagnostic-index.ts +165 -0
  665. package/src/ui-source/r3f-environment-binding.ts +133 -0
  666. package/src/ui-source/r3f-joint-binding.ts +251 -0
  667. package/src/ui-source/r3f-lod-binding.ts +91 -0
  668. package/src/ui-source/r3f-particle-binding.ts +235 -0
  669. package/src/ui-source/r3f-physics-binding.ts +483 -0
  670. package/src/ui-source/r3f-project-contracts.ts +444 -0
  671. package/src/ui-source/relative-import-specifier.ts +34 -0
  672. package/src/ui-source/reparent-guard.ts +544 -0
  673. package/src/ui-source/source-edit-request.ts +97 -0
  674. package/src/ui-source/source-write-backend.ts +618 -0
  675. package/src/ui-source/syntactic-prop-specs.ts +296 -0
  676. package/src/ui-source/tier-source-write-backend.ts +279 -0
  677. package/src/ui-source/ts-ast.ts +171 -0
  678. package/src/ui-source/utility-class-support.ts +169 -0
  679. package/src/ui-source/write-component-default.ts +133 -0
  680. package/src/ui-source/writer.ts +1989 -0
  681. package/src/viewport-activation-timings.ts +840 -0
  682. package/src/viewport-authoring-policy.ts +186 -0
  683. package/src/viewport-controls-hint.ts +57 -0
  684. package/src/viewport-door.ts +237 -0
  685. package/src/viewport-shading-boundary.ts +12 -0
  686. package/src/vite-error-surface.ts +141 -0
  687. package/src/wait-until.ts +37 -0
  688. package/src/workspace-areas.ts +156 -0
  689. package/src/workspace-aux-commands.ts +11 -0
  690. package/src/workspace-available-documents.ts +127 -0
  691. package/src/workspace-core-utilities.ts +31 -0
  692. package/src/workspace-document-ids.ts +59 -0
  693. package/src/workspace-document-registry.ts +612 -0
  694. package/src/workspace-document-restore.ts +145 -0
  695. package/src/workspace-host-commands.ts +141 -0
  696. package/src/workspace-persistence-gate.ts +40 -0
  697. package/src/workspace-play-utilities.ts +44 -0
  698. package/src/workspace-presets.ts +425 -0
  699. package/src/workspace-regions.ts +294 -0
  700. package/src/workspace-state-persistence.ts +536 -0
  701. package/src/workspace-static-panels.ts +73 -0
  702. package/src/workspace-status-registry.ts +121 -0
  703. package/src/workspace-style.ts +239 -0
  704. package/src/workspace-utility-commands.ts +74 -0
  705. package/src/workspace-utility-registry.ts +263 -0
  706. package/src/workspace-viewport-rect.ts +97 -0
  707. package/src/world-adoption.ts +115 -0
  708. package/src/world-document-routing.ts +104 -0
  709. package/vite-plugin-creation-site-write.ts +184 -0
  710. package/vite-plugin-creation-site.ts +80 -0
  711. package/vite-plugin-game-static.ts +303 -0
  712. package/vite-plugin-module-doorways.ts +336 -0
  713. package/vite-plugin-product-contributions.ts +197 -0
  714. package/vite-plugin-project-game-static.ts +125 -0
  715. package/vite-plugin-project-jsx-js.ts +65 -0
  716. package/vite-plugin-project-root-absolute-assets.ts +359 -0
  717. package/vite-plugin-shared-react.ts +420 -0
  718. package/vite-plugin-shared-three.ts +203 -0
  719. package/vite-plugin-ui-oid.ts +2106 -0
@@ -0,0 +1,2106 @@
1
+ /**
2
+ * Vite plugin: live OID instrumentation + source-write endpoints (C1/C2/C7/C8).
3
+ *
4
+ * The visual-edit "trick" made live for hand-authored React UI components: at dev
5
+ * time it stamps `data-oid` on every JSX element in files matched by `include` (a
6
+ * predicate, so the rest of the editor build is untouched), builds an OID→source
7
+ * index, and serves:
8
+ * GET /__ui-source/index → the whole OID index
9
+ * POST /__ui-source/write {oid,prop,value} → surgical inline-style edit to source
10
+ * POST /__ui-source/struct {oid,op} → structural source edit (delete)
11
+ * Edits run the SAME `writer.ts` used in unit tests, server-side, then Vite HMR
12
+ * reloads. `data-oid` exists only in transform output, never on disk.
13
+ *
14
+ * `include` widening (T6.2 slice 2): the original
15
+ * scope was ONLY `ui-editor/editable-components/*.tsx` (the visual-edit fixture dir,
16
+ * for `UIAuthoringAdapter`/`SourceEditPanel`). A react WORLD's entry graph is a real
17
+ * project's `.tsx` source — the mounted template project (`packages/editor/template/
18
+ * src/**`) or an externally-scaffolded project directory reached via the SAME
19
+ * dev-server `fs.allow` pipeline `server/dev.ts` already builds for project scripts
20
+ * (T3.3) — so `ReactRootAuthoringAdapter` needs those files instrumented too.
21
+ * `defaultProjectScopeInclude` widens to PROJECT scope, not repo-global: any `.tsx`
22
+ * file outside `node_modules`, EXCEPT the vgai tooling/engine source trees this repo
23
+ * itself is built from (`packages/engine/src`, `packages/create-vgai-project`,
24
+ * `packages/vgai-cli`, `packages/editor-sdk`, and `packages/editor/src` generally —
25
+ * carving OUT `ui-editor/editable-components` so the existing UI-edit-mode surface
26
+ * keeps working unchanged). This does NOT depend on which project happens to be
27
+ * open — `fs.allow` already bounds what Vite can even reach, and this predicate
28
+ * additionally keeps the vgai app's OWN react source un-instrumented.
29
+ *
30
+ * Vendored trees: a vendored GAME's source is STAMPED; every other `/vendor/`
31
+ * tree is not. `data-oid` stamping is what makes a `.tsx` file addressable —
32
+ * and, through `/__ui-source/write`, writable — so this predicate is where the
33
+ * question "may a vendored game be authored?" is actually decided.
34
+ *
35
+ * It used to answer no, for every path under a `/vendor/` segment, under the
36
+ * never-modify-vendored-source rule. The owner's code-or-data ruling
37
+ * superseded that rule (ARCHITECTURE-CORE §Editor "Every edit edits the game's
38
+ * own CODE or DATA", §Rules "there is no unwritable base"): ownership decides
39
+ * the RECORDER, not whether the edit may happen. A user's folder records in
40
+ * their git; a repo-vendored copy records in its own `UPSTREAM.lock`, written
41
+ * in the SAME gesture as the file by `server/vendored-lock-recorder.ts` and
42
+ * routed through {@link writeEditableSource} below. `verify-unaltered.mjs`'s
43
+ * bar was never zero diff — it is zero UNRECORDED diff — so it stays green
44
+ * with an authored edit present, as a patch that reverse-applies to upstream.
45
+ *
46
+ * What stays excluded is everything under `/vendor/` that is a DEPENDENCY
47
+ * rather than a game: `packages/threejs-runtime/vendor/realism-effects` (a vendored
48
+ * library) and the import analyzers' `vendor/` API dumps. See
49
+ * {@link VENDORED_GAME_SRC_RE} for the measured characterization behind that
50
+ * split.
51
+ *
52
+ * D-Y4 (wave 2, slice S3) —
53
+ * the write-back ban becomes IDENTITY-scoped, not just location-scoped: the
54
+ * `/vendor/` regex above only protects the repo's OWN vendored trees, but
55
+ * wave 2 lets an `ingest-react` world live in ANY external, user-owned
56
+ * folder (`vgai edit <folder>`) — an ingested-as-is foreign game there would
57
+ * otherwise be silently stamped/writable, reopening the never-modify-game-
58
+ * source rule by location rather than by identity. `nearestManifestExcludesIngestReact`
59
+ * walks up from a candidate `.tsx`'s own directory to the NEAREST
60
+ * `vgai.project.json`; if that manifest declares an `{ ingest }` react world,
61
+ * the whole manifest folder's subtree is excluded — exact, not coarse:
62
+ * `ingest-react` is single-world by construction (`binding-resolver.ts`'s
63
+ * `MULTI_WORLD_ALLOWED_IDENTITIES` exclusion), so "the manifest folder" IS
64
+ * the foreign game's whole tree, and D-N8 ships no authoring for it anyway —
65
+ * excluding it from stamping loses nothing. Cached per directory (hit AND
66
+ * miss, R-Y3) so a project with no manifest anywhere up the tree pays the
67
+ * walk cost once, ever. Malformed JSON at the manifest path is treated as
68
+ * "no exclusion" (never crash the transform hook over a broken file); a
69
+ * genuine fs error OTHER than "not found" (e.g. a permission error) is not
70
+ * swallowed — it propagates, same honesty bar the rest of this file holds.
71
+ */
72
+
73
+ import { createHash } from 'node:crypto';
74
+ import {
75
+ existsSync,
76
+ mkdirSync,
77
+ readdirSync,
78
+ readFileSync,
79
+ realpathSync,
80
+ writeFileSync,
81
+ } from 'node:fs';
82
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
83
+ import { fileURLToPath } from 'node:url';
84
+ import { Worker } from 'node:worker_threads';
85
+ import type { Plugin, ViteDevServer } from 'vite';
86
+ import { resolveManifestPath } from '@volter/editor-project/manifest/locate';
87
+ import { CollaborationConflictError, collaborationSession } from './server/collaboration-session';
88
+ import {
89
+ importersFromModuleGraph,
90
+ reportOidSurfaceDiagnostics,
91
+ resolveOidSurface,
92
+ } from './server/project-root-surface';
93
+ import {
94
+ editorChromeStampBoundary,
95
+ type HmrInvalidationGraph,
96
+ staleModuleWarning,
97
+ stampHmrInvalidation,
98
+ } from './server/project-script-hmr';
99
+ import {
100
+ findVendoredTarget,
101
+ settleVendoredWrite,
102
+ writeRecordedVendoredFile,
103
+ } from './server/vendored-lock-recorder';
104
+ import { cssTextForStyleValue } from './src/authoring/css-numeric-style';
105
+ import {
106
+ type ComponentPropSpec,
107
+ lineColToOffset,
108
+ type OidEntry,
109
+ OidStore,
110
+ oidAttributeForSurface,
111
+ type R3fComponentContract,
112
+ transformSource,
113
+ } from './src/ui-source/oid-transform';
114
+ import { planDeleteStory, planRenameStory, planSaveStory } from './src/ui-source/plan-csf-story';
115
+ import { planComponentExtraction } from './src/ui-source/plan-extract-component';
116
+ import { planComponentFork } from './src/ui-source/plan-fork-component';
117
+ import { planCreateClassRule } from './src/ui-source/plan-named-style';
118
+ import {
119
+ applyStyleWriteRequest,
120
+ planSourceEdit,
121
+ sourceEditFiles,
122
+ } from './src/ui-source/plan-source-edit';
123
+ import {
124
+ type EntryDiagnosticSelector,
125
+ fileDiagnosticJoin,
126
+ } from './src/ui-source/r3f-diagnostic-index';
127
+ import {
128
+ importedR3fContracts,
129
+ r3fAuthoringDiagnostics,
130
+ visibleR3fContracts,
131
+ } from './src/ui-source/r3f-project-contracts';
132
+ import type { SourceEditRequest } from './src/ui-source/source-edit-request';
133
+ import {
134
+ detectUtilityClassSupport,
135
+ isUtilityClassEvidenceFile,
136
+ UTILITY_CLASS_EVIDENCE_FILES,
137
+ type UtilityClassSupport,
138
+ } from './src/ui-source/utility-class-support';
139
+ import {
140
+ addClassNameToken,
141
+ collectLiteralInlineStyles,
142
+ cssCamelToKebab,
143
+ deleteElements,
144
+ editTextContent,
145
+ findTagEnd,
146
+ getEditableText,
147
+ groupSiblingElements,
148
+ removeClassNameToken,
149
+ removeInlineStyle,
150
+ removePropAttribute,
151
+ surgicalCssEdit,
152
+ surgicalCssEditInMedia,
153
+ writePropChange,
154
+ } from './src/ui-source/writer';
155
+
156
+ // `applyStyleWriteRequest` and `oidAttributeForSurface` live in browser-safe
157
+ // modules (`plan-source-edit.ts` / `oid-transform.ts`), because a PAGE reaches
158
+ // the same transform; re-exported here so this file stays the stable import
159
+ // site for both.
160
+ export { applyStyleWriteRequest, oidAttributeForSurface };
161
+
162
+ const NODE_MODULES_RE = /\/node_modules\//;
163
+ const TOOLING_SRC_RE = /\/packages\/(engine\/src|create-vgai-project|vgai-cli|editor-sdk)\//;
164
+ const EDITOR_SRC_RE = /\/packages\/editor\/src\//;
165
+ const EDITABLE_COMPONENTS_RE = /\/ui-editor\/editable-components\/.*\.tsx$/;
166
+ /** Track N, D-N4 item 2 — see the module doc comment's "Vendored-tree exclusion". */
167
+ const VENDOR_RE = /\/vendor\//;
168
+ /**
169
+ * A vendored GAME's own source (`…/vendor/games/<id>/…`) — the ONE tree under
170
+ * a `/vendor/` segment that is a game rather than a dependency.
171
+ *
172
+ * The distinction is what {@link defaultProjectScopeInclude} needs and the
173
+ * blanket `VENDOR_RE` could not express. Characterized before it was carved,
174
+ * because "what else does this regex protect?" is the question a scope change
175
+ * has to answer: under every `/vendor/` root in this repo, the only `.tsx` at
176
+ * all is `vendor/games/racing-game`'s 35 files. The rest is
177
+ * `packages/threejs-runtime/vendor/realism-effects` (a vendored LIBRARY — built `dist`
178
+ * JS and shaders, a dependency we happen to ship in-tree) and the two import
179
+ * analyzers' `vendor/` API dumps (`packages/gd-analyze`,
180
+ * `packages/rbx-analyze` — JSON only). None of those is a game, none is
181
+ * authorable, and all of them stay excluded here.
182
+ *
183
+ * The Godot lane is ARCHIVED off main — `git fetch origin archive/godot-lane`, tag `archive/godot-lane-2026-09-19`.
184
+ */
185
+ const VENDORED_GAME_SRC_RE = /\/vendor\/games\/[^/]+\//;
186
+
187
+ /** True if a parsed `vgai.project.json` body declares at least one
188
+ * React root with an `{ ingest }` adapter — the `{ ingest }` shape alone is
189
+ * the answer, because every ingest-react root is D-N8 no-authoring.
190
+ * Defensive against
191
+ * any shape (never trusts the manifest is even an object) — this is a
192
+ * best-effort identity probe for a WRITE-BACK BAN, not manifest validation
193
+ * (the real Zod schema/loader is `@vgai/project/manifest/load`, not reachable
194
+ * from this vite-config-time, dependency-light file by design). */
195
+ function manifestDeclaresIngestReactWorld(parsed: unknown): boolean {
196
+ if (!parsed || typeof parsed !== 'object') return false;
197
+ const roots = (parsed as { roots?: unknown }).roots;
198
+ if (!Array.isArray(roots)) return false;
199
+ return roots.some((entry) => {
200
+ if (!entry || typeof entry !== 'object') return false;
201
+ const root = entry as { adapter?: unknown };
202
+ const { adapter } = root;
203
+ return (
204
+ !!adapter &&
205
+ typeof adapter === 'object' &&
206
+ (adapter as { surface?: unknown }).surface === 'dom' &&
207
+ 'ingest' in adapter
208
+ );
209
+ });
210
+ }
211
+
212
+ /** Cache: absolute directory -> whether ITS OWN ancestor walk (starting AT
213
+ * this directory) hit an ingest-react-declaring manifest. Cached both ways
214
+ * (hit and miss, R-Y3) — see the module doc comment's D-Y4 section. */
215
+ const manifestAncestorExclusionCache = new Map<string, boolean>();
216
+
217
+ function nearestManifestExcludesIngestReact(dir: string): boolean {
218
+ const cached = manifestAncestorExclusionCache.get(dir);
219
+ if (cached !== undefined) return cached;
220
+
221
+ const manifestPath = resolveManifestPath(dir);
222
+ let raw: string | undefined;
223
+ try {
224
+ raw = readFileSync(manifestPath, 'utf8');
225
+ } catch (err) {
226
+ // ENOENT (no manifest at this level) -> keep walking up. Any OTHER fs
227
+ // error (e.g. EACCES) is a real environment problem, not "no manifest
228
+ // here" — never silently swallowed.
229
+ if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err;
230
+ }
231
+
232
+ let result: boolean;
233
+ if (raw === undefined) {
234
+ const parent = dirname(dir);
235
+ result = parent === dir ? false : nearestManifestExcludesIngestReact(parent);
236
+ } else {
237
+ try {
238
+ result = manifestDeclaresIngestReactWorld(JSON.parse(raw));
239
+ } catch {
240
+ // Malformed JSON: honest "no exclusion" (stamping stays on) — never
241
+ // crash the transform hook over a broken manifest file.
242
+ result = false;
243
+ }
244
+ }
245
+ manifestAncestorExclusionCache.set(dir, result);
246
+ return result;
247
+ }
248
+
249
+ /**
250
+ * Cap 2 (React visual-edit parity): the scope guard for the `/__ui-source/css` endpoint.
251
+ * A CSS file path (from a stylesheet's `data-vite-dev-id`) is editable iff it is a
252
+ * first-party PROJECT `.css` file — same carve-outs as `defaultProjectScopeInclude`
253
+ * (never node_modules, vendored trees, the vgai tooling/engine source, or the editor's
254
+ * own source). Paths are normalized to forward slashes so a Windows dev-id matches. This
255
+ * is the CSS analogue of the `.tsx` stamping scope: an out-of-scope path must not become
256
+ * writable through a client-supplied `file`.
257
+ */
258
+ export function isEditableCssFile(id: string): boolean {
259
+ const clean = (id.split('?')[0] ?? id).replace(/\\/g, '/');
260
+ if (!clean.endsWith('.css')) return false;
261
+ if (NODE_MODULES_RE.test(clean)) return false;
262
+ if (VENDOR_RE.test(clean)) return false;
263
+ if (TOOLING_SRC_RE.test(clean)) return false;
264
+ if (EDITOR_SRC_RE.test(clean)) return false;
265
+ return true;
266
+ }
267
+
268
+ /** D-1 (Phase D, spec27 §2): sha256 hex digest — the checksum guard the
269
+ * `/__ui-source/restore` endpoint (below) uses to refuse a stale write. Only
270
+ * ever computed SERVER-SIDE: the client (`ReactRootAuthoringAdapter`) never
271
+ * hashes anything itself, it only echoes back a digest this server already
272
+ * handed it in a prior `writeStruct`/`restoreSource` response. */
273
+ function sha256(text: string): string {
274
+ return createHash('sha256').update(text, 'utf8').digest('hex');
275
+ }
276
+
277
+ /** Stable, registry-safe identity for a source file. Project files retain their
278
+ * human-readable relative path. An explicitly allowed file outside the Vite
279
+ * root gets a non-leaking synthetic path instead of an invalid `../…` key. */
280
+ function sourceResourcePath(file: string, projectRoot: string): string {
281
+ const path = relative(projectRoot, file).replace(/\\/g, '/');
282
+ if (path && path !== '..' && !path.startsWith('../') && !isAbsolute(path)) return path;
283
+ const identity = createHash('sha256').update(file).digest('hex').slice(0, 16);
284
+ return `.vgai/external-source/${identity}/${basename(file)}`;
285
+ }
286
+
287
+ /**
288
+ * Utility-class support is a property of the PACKAGE that owns the edited file,
289
+ * not of the editor session — an ingested game's HUD lives in the game's own
290
+ * tree, with its own `package.json` and its own build, and it is THAT build
291
+ * that would have to compile a `bg-[#059669]`. So the probe walks up from the
292
+ * file to the nearest enclosing `package.json` (falling back to the served
293
+ * project root) and reads the evidence `utility-class-support.ts` names.
294
+ *
295
+ * Cached per resolved package directory, like `propResolvers` above: the walk
296
+ * and the reads happen once per package per dev-server lifetime.
297
+ */
298
+ const utilityClassSupport = new Map<string, UtilityClassSupport>();
299
+
300
+ /** The nearest ancestor of `file` holding a `package.json`, else `fallback`. */
301
+ function owningPackageDir(file: string, fallback: string): string {
302
+ let dir = dirname(isAbsolute(file) ? file : resolve(fallback, file));
303
+ for (let depth = 0; depth < 24; depth++) {
304
+ if (existsSync(join(dir, 'package.json'))) return dir;
305
+ const parent = dirname(dir);
306
+ if (parent === dir) break;
307
+ dir = parent;
308
+ }
309
+ return fallback;
310
+ }
311
+
312
+ /** Evidence files under `dir` — the named configs plus a bounded css/html scan. */
313
+ function utilityClassEvidence(dir: string): Map<string, string> {
314
+ const files = new Map<string, string>();
315
+ const read = (absolute: string, relativePath: string): void => {
316
+ try {
317
+ files.set(relativePath, readFileSync(absolute, 'utf8'));
318
+ } catch {
319
+ // Unreadable evidence is simply absent evidence — never a crash, and
320
+ // never an upgrade to "supported".
321
+ }
322
+ };
323
+ for (const name of UTILITY_CLASS_EVIDENCE_FILES) {
324
+ const absolute = join(dir, name);
325
+ if (existsSync(absolute)) read(absolute, name);
326
+ }
327
+ // Bounded walk for the css/html entry markers (`@tailwind`, a CDN script):
328
+ // shallow, skipping the directories that never hold a project's own entry.
329
+ const skip = new Set(['node_modules', 'dist', 'build', 'coverage', '.git', '.vgai', '.claude']);
330
+ let budget = 80;
331
+ const walk = (current: string, prefix: string, depth: number): void => {
332
+ if (depth > 3 || budget <= 0) return;
333
+ let entries: import('node:fs').Dirent[];
334
+ try {
335
+ entries = readdirSync(current, { withFileTypes: true });
336
+ } catch {
337
+ return;
338
+ }
339
+ for (const entry of entries) {
340
+ if (budget <= 0) return;
341
+ if (entry.name.startsWith('.') && entry.name !== '.') continue;
342
+ const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
343
+ if (entry.isDirectory()) {
344
+ if (!skip.has(entry.name)) walk(join(current, entry.name), relativePath, depth + 1);
345
+ continue;
346
+ }
347
+ if (files.has(relativePath) || !isUtilityClassEvidenceFile(relativePath)) continue;
348
+ budget--;
349
+ read(join(current, entry.name), relativePath);
350
+ }
351
+ };
352
+ walk(dir, '', 0);
353
+ return files;
354
+ }
355
+
356
+ function utilityClassSupportFor(projectRoot: string, file: string): UtilityClassSupport {
357
+ const dir = owningPackageDir(file, projectRoot);
358
+ const cached = utilityClassSupport.get(dir);
359
+ if (cached) return cached;
360
+ const support = detectUtilityClassSupport(utilityClassEvidence(dir));
361
+ utilityClassSupport.set(dir, support);
362
+ return support;
363
+ }
364
+
365
+ /** Declared props are a property of the (file, tag) PAIR, not of each
366
+ * occurrence — six `<Enemy>` tags resolve the component once per index read. */
367
+ function declaredPropsOf(
368
+ propsByTag: ReadonlyMap<string, ComponentPropSpec[]>,
369
+ file: string,
370
+ tag: string,
371
+ ): ComponentPropSpec[] | undefined {
372
+ const key = `${file}::${tag}`;
373
+ return propsByTag.get(key);
374
+ }
375
+
376
+ /** Everything one project file contributes to an index read, resolved from its
377
+ * CURRENT bytes exactly once per read. */
378
+ interface IndexFileAnalysis {
379
+ /** Component contracts visible in this module — `null` for a file this
380
+ * server does not stamp as R3F (or could not read). */
381
+ readonly contracts: ReadonlyMap<string, R3fComponentContract> | null;
382
+ /** H5 — which of this file's authorability diagnostics pertain to a given
383
+ * entry of this file. Inert for a non-R3F/unreadable file. */
384
+ readonly selectDiagnostics: EntryDiagnosticSelector;
385
+ }
386
+
387
+ const INERT_FILE_ANALYSIS: IndexFileAnalysis = {
388
+ contracts: null,
389
+ selectDiagnostics: () => undefined,
390
+ };
391
+
392
+ /** The index grouped by file, each bucket keyed by oid — the shape
393
+ * `fileDiagnosticJoin` needs so an entry's `parentOid` resolves. */
394
+ function indexEntriesByFile(store: OidStore): Map<string, Map<string, OidEntry>> {
395
+ const byFile = new Map<string, Map<string, OidEntry>>();
396
+ for (const [oid, entry] of store.index) {
397
+ const bucket = byFile.get(entry.file);
398
+ if (bucket) bucket.set(oid, entry);
399
+ else byFile.set(entry.file, new Map([[oid, entry]]));
400
+ }
401
+ return byFile;
402
+ }
403
+
404
+ /**
405
+ * Read one file's current bytes and derive everything an index read wants from
406
+ * it. Unreadable (deleted/renamed since it was transformed) or not stamped as
407
+ * R3F ⇒ the inert analysis: its entries ship exactly as recorded, which is a
408
+ * degradation, never a failure of the whole index read.
409
+ *
410
+ * COST, and why it went DOWN rather than up when H5 landed: this runs at most
411
+ * once per file per index read (the caller memoizes), whereas the previous
412
+ * shape re-read and re-surface-resolved a file for EVERY capitalized-tag entry
413
+ * whose surface turned out not to be `userData-oid`. H5 adds one
414
+ * `r3fAuthoringDiagnostics` pass per R3F file — the same order of work as the
415
+ * `visibleR3fContracts` pass beside it, and it early-returns for any file
416
+ * without an R3F reconciler import.
417
+ */
418
+ function analyzeIndexFile(
419
+ file: string,
420
+ fileEntries: ReadonlyMap<string, OidEntry>,
421
+ importersOf: ReturnType<typeof importersFromModuleGraph>,
422
+ ): IndexFileAnalysis {
423
+ let source: string;
424
+ try {
425
+ source = readFileSync(file, 'utf8');
426
+ } catch {
427
+ return INERT_FILE_ANALYSIS;
428
+ }
429
+ // The SAME decision the transform hook stamped with (task #45) — not a
430
+ // second derivation that could disagree with it.
431
+ if (resolveOidSurface(file, source, importersOf).attribute !== 'userData-oid') {
432
+ return INERT_FILE_ANALYSIS;
433
+ }
434
+ return {
435
+ contracts: visibleR3fContracts(source, file),
436
+ // The surface check above already established this file renders through
437
+ // the R3F reconciler, which is a stronger answer than the source-text
438
+ // sniff the analyzer falls back on — and the one a project that imports
439
+ // its prop shape from a shared module can actually pass.
440
+ selectDiagnostics: fileDiagnosticJoin(
441
+ fileEntries,
442
+ r3fAuthoringDiagnostics(source, file, { knownThreeSurface: true }),
443
+ ),
444
+ };
445
+ }
446
+
447
+ /**
448
+ * One recorded entry plus everything the current bytes add to it: its
449
+ * component's authoring contract, its declared props, and (H5) the
450
+ * authorability diagnostics that pertain to it. Returns the entry ITSELF when
451
+ * there is nothing to add, so an unenriched index read allocates nothing.
452
+ *
453
+ * Note that the diagnostic lookup runs for EVERY entry, including a lowercase
454
+ * host tag: R3F002/3/5 are ABOUT a component definition, and a definition's
455
+ * root is usually an ordinary `<group>`/`<mesh>` — that entry is precisely the
456
+ * one an instance row reaches through its boundary object's own `userData.oid`.
457
+ */
458
+ function enrichIndexEntry(
459
+ entry: OidEntry,
460
+ analysis: IndexFileAnalysis,
461
+ propsByTag: ReadonlyMap<string, ComponentPropSpec[]>,
462
+ ): OidEntry {
463
+ const diagnostics = analysis.selectDiagnostics(entry);
464
+ const custom = analysis.contracts !== null && /^[A-Z]/.test(entry.tag);
465
+ const contract = custom ? analysis.contracts?.get(entry.tag) : undefined;
466
+ const props = custom ? declaredPropsOf(propsByTag, entry.file, entry.tag) : undefined;
467
+ // `props !== undefined` means the resolver ANSWERED for this tag — an empty
468
+ // answer included. The flag is what lets the inspector's fallback tell a
469
+ // pending cold resolver from a genuine absence (see OidEntry.propsResolved).
470
+ const propsResolved = props !== undefined;
471
+ if (!contract && !props?.length && !diagnostics && !propsResolved) return entry;
472
+ return {
473
+ ...entry,
474
+ ...(contract ? { r3fAuthoring: contract } : {}),
475
+ ...(props?.length ? { props } : {}),
476
+ ...(propsResolved ? { propsResolved: true as const } : {}),
477
+ ...(diagnostics ? { diagnostics } : {}),
478
+ };
479
+ }
480
+
481
+ function snapshotRawOidIndex(store: OidStore): Record<string, unknown> {
482
+ const current: Record<string, unknown> = {};
483
+ for (const [oid, entry] of store.index) current[oid] = entry;
484
+ return current;
485
+ }
486
+
487
+ function buildEnrichedOidIndex(
488
+ store: OidStore,
489
+ devServer?: ViteDevServer,
490
+ propsByTag: ReadonlyMap<string, ComponentPropSpec[]> = new Map(),
491
+ ): Record<string, unknown> {
492
+ const importersOf = importersFromModuleGraph(devServer?.moduleGraph);
493
+ const entriesByFile = indexEntriesByFile(store);
494
+
495
+ const analyses = new Map<string, IndexFileAnalysis>();
496
+ const analysisFor = (file: string): IndexFileAnalysis => {
497
+ const cached = analyses.get(file);
498
+ if (cached) return cached;
499
+ const analysis = analyzeIndexFile(file, entriesByFile.get(file) ?? new Map(), importersOf);
500
+ analyses.set(file, analysis);
501
+ return analysis;
502
+ };
503
+
504
+ const current: Record<string, unknown> = {};
505
+ for (const [oid, entry] of store.index) {
506
+ current[oid] = enrichIndexEntry(entry, analysisFor(entry.file), propsByTag);
507
+ }
508
+ return current;
509
+ }
510
+
511
+ /**
512
+ * Props/tag/diagnostic enrich is the measured 2.6 s cold cost (repeat 391 ms)
513
+ * of first design attach: `ComponentPropResolver` builds a `ts.Program`. The Scene
514
+ * can render without it — oids, file/line, and tags are already on the raw
515
+ * stamp. Blocking GET /__ui-source/index on enrich froze the Vite thread
516
+ * (and every module load) for those seconds. Serve enrichment only for an
517
+ * unchanged source index; otherwise serve current stamps while it refreshes.
518
+ */
519
+ let lastEnrichedIndex: {
520
+ root: string | undefined;
521
+ sourceIndex: ReadonlyMap<string, OidEntry>;
522
+ index: Record<string, unknown>;
523
+ } | null = null;
524
+ let enrichQueued = false;
525
+ let propWorker: Worker | null = null;
526
+ let propRequestId = 0;
527
+ const pendingPropRequests = new Map<
528
+ number,
529
+ {
530
+ resolve: (props: ReadonlyMap<string, ComponentPropSpec[]>) => void;
531
+ reject: (error: Error) => void;
532
+ }
533
+ >();
534
+
535
+ function componentPropWorkerUrl(): URL {
536
+ // Source dev runs this plugin from `packages/editor/`; the published server
537
+ // runs its bundle from `dist-server/`, beside the separately-built worker.
538
+ const source = new URL('./server/component-prop-worker.ts', import.meta.url);
539
+ return existsSync(fileURLToPath(source))
540
+ ? source
541
+ : new URL('./component-prop-worker.mjs', import.meta.url);
542
+ }
543
+
544
+ function rejectPendingPropRequests(error: Error): void {
545
+ for (const pending of pendingPropRequests.values()) pending.reject(error);
546
+ pendingPropRequests.clear();
547
+ }
548
+
549
+ function declaredPropWorker(): Worker {
550
+ if (propWorker) return propWorker;
551
+ const worker = new Worker(componentPropWorkerUrl(), { name: 'vgai-component-props' });
552
+ worker.on(
553
+ 'message',
554
+ (message: { id?: unknown; props?: Record<string, ComponentPropSpec[]>; error?: unknown }) => {
555
+ if (typeof message.id !== 'number') return;
556
+ const pending = pendingPropRequests.get(message.id);
557
+ if (!pending) return;
558
+ pendingPropRequests.delete(message.id);
559
+ if (typeof message.error === 'string') {
560
+ pending.reject(new Error(message.error));
561
+ return;
562
+ }
563
+ pending.resolve(new Map(Object.entries(message.props ?? {})));
564
+ },
565
+ );
566
+ worker.on('error', (error: unknown) => {
567
+ if (propWorker === worker) propWorker = null;
568
+ rejectPendingPropRequests(error instanceof Error ? error : new Error(String(error)));
569
+ });
570
+ worker.on('exit', (code) => {
571
+ if (propWorker === worker) propWorker = null;
572
+ if (code !== 0) {
573
+ rejectPendingPropRequests(
574
+ new Error(`Declared-prop worker exited before answering (status ${code}).`),
575
+ );
576
+ }
577
+ });
578
+ propWorker = worker;
579
+ return worker;
580
+ }
581
+
582
+ function propRequestsFor(store: OidStore): Array<{ key: string; file: string; tag: string }> {
583
+ const requests = new Map<string, { key: string; file: string; tag: string }>();
584
+ for (const entry of store.index.values()) {
585
+ if (!/^[A-Z]/.test(entry.tag)) continue;
586
+ const key = `${entry.file}::${entry.tag}`;
587
+ requests.set(key, { key, file: entry.file, tag: entry.tag });
588
+ }
589
+ return [...requests.values()];
590
+ }
591
+
592
+ function resolveDeclaredProps(
593
+ projectRoot: string | undefined,
594
+ requests: Array<{ key: string; file: string; tag: string; definition?: true }>,
595
+ ): Promise<ReadonlyMap<string, ComponentPropSpec[]>> {
596
+ if (!projectRoot || requests.length === 0) {
597
+ return new Promise((resolveRequest) => setImmediate(() => resolveRequest(new Map())));
598
+ }
599
+ const id = ++propRequestId;
600
+ return new Promise((resolveRequest, rejectRequest) => {
601
+ pendingPropRequests.set(id, { resolve: resolveRequest, reject: rejectRequest });
602
+ declaredPropWorker().postMessage({ id, projectRoot, requests });
603
+ });
604
+ }
605
+
606
+ function scheduleOidIndexEnrich(
607
+ store: OidStore,
608
+ projectRoot?: string,
609
+ devServer?: ViteDevServer,
610
+ ): void {
611
+ if (enrichQueued) return;
612
+ enrichQueued = true;
613
+ const sourceIndex = new Map(store.index);
614
+ const requests = propRequestsFor(store);
615
+ void resolveDeclaredProps(projectRoot, requests)
616
+ .then((propsByTag) => {
617
+ lastEnrichedIndex = {
618
+ root: projectRoot,
619
+ sourceIndex,
620
+ index: buildEnrichedOidIndex(store, devServer, propsByTag),
621
+ };
622
+ })
623
+ .catch((error) => {
624
+ // A failed semantic resolver never removes source authoring. Contracts
625
+ // and diagnostics still enrich on the server thread; declared props
626
+ // degrade to the attributes physically present in source.
627
+ process.stderr.write(
628
+ `[ui-oid] Declared-prop analysis unavailable; using source attributes: ${String(error)}\n`,
629
+ );
630
+ lastEnrichedIndex = {
631
+ root: projectRoot,
632
+ sourceIndex,
633
+ index: buildEnrichedOidIndex(store, devServer),
634
+ };
635
+ })
636
+ .finally(() => {
637
+ enrichQueued = false;
638
+ });
639
+ }
640
+
641
+ function currentOidIndex(
642
+ store: OidStore,
643
+ projectRoot?: string,
644
+ devServer?: ViteDevServer,
645
+ ): Record<string, unknown> {
646
+ scheduleOidIndexEnrich(store, projectRoot, devServer);
647
+ const cached = lastEnrichedIndex;
648
+ // Transforms replace entries even when an OID stays stable. Old locations
649
+ // must never be paired with the newly edited source while enrichment runs.
650
+ if (
651
+ cached &&
652
+ cached.root === projectRoot &&
653
+ cached.sourceIndex.size === store.index.size &&
654
+ [...store.index].every(([oid, entry]) => cached.sourceIndex.get(oid) === entry)
655
+ ) {
656
+ return cached.index;
657
+ }
658
+ return snapshotRawOidIndex(store);
659
+ }
660
+
661
+ /** Test-only: wait for the in-flight background enrich, if any. */
662
+ export function __flushOidIndexEnrichForTest(): Promise<void> {
663
+ return new Promise((resolve) => {
664
+ const finish = (): void => {
665
+ if (!enrichQueued) {
666
+ resolve();
667
+ return;
668
+ }
669
+ setImmediate(finish);
670
+ };
671
+ setImmediate(finish);
672
+ });
673
+ }
674
+
675
+ /** Test-only reset. */
676
+ export function __resetOidIndexEnrichForTest(): void {
677
+ lastEnrichedIndex = null;
678
+ enrichQueued = false;
679
+ if (propWorker) {
680
+ void propWorker.terminate();
681
+ propWorker = null;
682
+ }
683
+ rejectPendingPropRequests(new Error('Declared-prop worker reset.'));
684
+ }
685
+
686
+ /** Test-only: whether the last GET returned without waiting for enrich. */
687
+ export function __oidIndexEnrichStateForTest(): { queued: boolean; hasEnriched: boolean } {
688
+ return { queued: enrichQueued, hasEnriched: lastEnrichedIndex !== null };
689
+ }
690
+
691
+ /**
692
+ * THE write point for every `/__ui-source/*` handler — and the reason a
693
+ * vendored game's source can be stamped without reopening silent drift.
694
+ *
695
+ * A source write lands in one of two recorders, decided by WHERE the file is,
696
+ * never by what the handler was doing:
697
+ *
698
+ * - inside a repo-vendored game → `writeRecordedVendoredFile`, which moves the
699
+ * file AND its `UPSTREAM.lock` entry as ONE journalled operation, so
700
+ * `verify-unaltered.mjs` is green with the edit present (the edit becomes a
701
+ * recorded patch that reverse-applies to upstream). Undo needs no special
702
+ * case: it comes back through here with the previous bytes, the lock
703
+ * reconciles from the bytes alone, and the edit that restores upstream's own
704
+ * content deletes the patch entry again.
705
+ * - anywhere else → the plain `writeFileSync` this file always did, because a
706
+ * user's own project records in the user's own git.
707
+ *
708
+ * Centralised deliberately. Nine handlers in this file write source; routing
709
+ * per-handler would mean nine chances to add a tenth that forgets, and a
710
+ * forgotten one is not a visible bug — it is a vendored tree that quietly
711
+ * stops matching its lock.
712
+ */
713
+ /**
714
+ * This repo's root — `<root>/packages/editor/vite-plugin-ui-oid.ts` is where
715
+ * this file lives, and `<root>/vendor/games/` is where the locks live. It is a
716
+ * property of the CHECKOUT, not of the opened project, so deriving it from the
717
+ * module's own URL is the right default for every real caller and none of them
718
+ * passes anything.
719
+ *
720
+ * It is a DEFAULT rather than a constant so the estate the recorder judges
721
+ * against is injectable — the same shape `server/creation-site-write.ts` has
722
+ * carried all along, where `engineRoot` is an ordinary parameter of
723
+ * `ingestSourceOwnership`/`resolveIngestSourceFile`. Without it, "does a
724
+ * `/__ui-source/*` write reach the lock recorder?" could only be asked against
725
+ * THIS checkout's real `vendor/games/`, i.e. by editing a shipped game — so the
726
+ * recorder's contract was provable for the ingest routes and unprovable for
727
+ * this family. `test/ui-source-vendored-recorder.test.ts` is the case that
728
+ * needed it.
729
+ */
730
+ const DEFAULT_ENGINE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
731
+
732
+ /**
733
+ * THE MODULE GRAPH THE WRITE POINT ABOVE MUST INVALIDATE — and why the editor
734
+ * cannot leave that to the file watcher.
735
+ *
736
+ * The editor writes the bytes itself. Making its OWN write visible to the next
737
+ * mount is therefore its own job, and routing that through the filesystem
738
+ * watcher makes it a property of the environment instead: Vite serves a module
739
+ * from `ModuleGraph.transformResult` until something invalidates it, and the
740
+ * only thing that ever did was the watcher's change event.
741
+ *
742
+ * Measured 2026-08-16 on `examples/third-person`, with a one-variable A/B: the
743
+ * root config's `server.watch.ignored` carries a `.claude` glob (it is there to
744
+ * stop one agent worktree's churn reloading every OTHER live session's tab), and
745
+ * with it in place
746
+ * a `position` prop written through `/__ui-source/prop` landed on disk, and the
747
+ * dev server then served a BYTE-IDENTICAL pre-edit transform of `src/world.tsx`
748
+ * for the rest of the session — to a cold `GET`, and to a `?t=`-busted one. A
749
+ * page reload therefore re-derived the world from the PRE-EDIT bytes and the
750
+ * authored value read back as the old one, with an honest-looking ack and a
751
+ * real diff on disk behind it. Dropping that one pattern made the very next
752
+ * fetch fresh. The write path may not depend on an ignore list it does not own:
753
+ * an unwatched project is a silently-discarded edit.
754
+ *
755
+ * Graph-only, deliberately: this stamps invalidation exactly the way
756
+ * `handleProjectScriptHotUpdate` does when it swallows Vite's HMR, and sends no
757
+ * client event. Hot-update behaviour on a watched project is unchanged
758
+ * (its watcher event still arrives and still drives the custom events), and
759
+ * stamping twice is idempotent — what is NOT recoverable is the mount that
760
+ * re-derives from bytes the server has already replaced.
761
+ */
762
+ let writtenSourceInvalidationGraph: HmrInvalidationGraph | undefined;
763
+ let writtenSourceStampBoundary: ((moduleFile: string) => boolean) | undefined;
764
+
765
+ /** Called once by `configureServer` — the plugin has exactly one dev server.
766
+ * `stampBoundary` bounds the stamp's importer propagation at editor-chrome
767
+ * modules: a written PROJECT module must re-evaluate on its next mount, and
768
+ * the editor modules that imported it must not be re-instanced mid-session.
769
+ * This route shipped UNBOUNDED while its creation-site sibling was bounded,
770
+ * and that one copy was the whole failure (measured on bubbo-bubbo,
771
+ * 2026-08-21): every creation-site write re-stamped 33 editor modules
772
+ * through the isolation-document import edge, the next lazy import fetched
773
+ * a second `play-mode.ts?t=…` instance, and every ▶ after a write refused
774
+ * with "Game container not mounted". See `editorChromeStampBoundary`. */
775
+ function bindWrittenSourceInvalidation(
776
+ graph: HmrInvalidationGraph,
777
+ stampBoundary?: (moduleFile: string) => boolean,
778
+ ): void {
779
+ writtenSourceInvalidationGraph = graph;
780
+ writtenSourceStampBoundary = stampBoundary;
781
+ }
782
+
783
+ function invalidateWrittenSource(file: string): void {
784
+ if (!writtenSourceInvalidationGraph) return;
785
+ const outcome = stampHmrInvalidation(
786
+ writtenSourceInvalidationGraph,
787
+ file,
788
+ undefined,
789
+ writtenSourceStampBoundary,
790
+ );
791
+ const warning = staleModuleWarning(file, outcome);
792
+ if (warning) console.warn(warning);
793
+ }
794
+
795
+ /**
796
+ * The path `findVendoredTarget` must judge, symlinks resolved.
797
+ *
798
+ * `realpathSync` is what makes the vendored check survive a symlinked checkout
799
+ * (`vendored-lock-recorder.test.ts` pins that), but it REQUIRES the path to
800
+ * exist — and one write path legitimately names a file that does not exist yet:
801
+ * `/__ui-source/fork-component` writes the fork's brand-new file (it refuses
802
+ * outright when that path already exists). So resolve the containing DIRECTORY
803
+ * and re-join the basename when the file itself is not there; a new file's
804
+ * vendored-ness is decided by the folder it lands in.
805
+ */
806
+ function realpathForWrite(file: string): string {
807
+ try {
808
+ return realpathSync(file);
809
+ } catch {
810
+ try {
811
+ return join(realpathSync(dirname(file)), basename(file));
812
+ } catch {
813
+ return file;
814
+ }
815
+ }
816
+ }
817
+
818
+ /**
819
+ * THE read point for every `/__ui-source/*` handler — the twin of
820
+ * {@link writeEditableSource} below, and centralised for the same reason it is.
821
+ *
822
+ * A crashed vendored write leaves a roll-forward journal, and the next request
823
+ * that touches the game is what completes it (`settleVendoredWrite`). A read
824
+ * that skips that step plans against bytes the crashed write has already
825
+ * superseded — and MEASURED, that is neither caught downstream nor loud:
826
+ *
827
+ * - Through the prepare/apply pair: `/__ui-source/read` returned the
828
+ * pre-settle bytes with a 200, `/__ui-source/prepare` planned from them, and
829
+ * `/__ui-source/apply` ACCEPTED, because the `ifMatchSha` gate compares the
830
+ * client's stale hash against the same stale bytes and so agrees with
831
+ * itself. A pending journal does not change the file's bytes — that is the
832
+ * whole point of rolling forward — so the 409 is unreachable in exactly the
833
+ * window it exists for.
834
+ * - Through any single-request write handler (`/prop` is the ordinary
835
+ * inspector "revert this prop" gesture): read → plan → `writeEditableSource`,
836
+ * which settles and THEN writes the stale-based edit over it. Strictly
837
+ * worse, because there is no `ifMatchSha` in the loop at all.
838
+ *
839
+ * Either way the journalled write vanishes, the lock comes out internally
840
+ * consistent, and `verify-unaltered.mjs` stays green over a lost update.
841
+ *
842
+ * So the settle is not a line each handler remembers to add — it is welded to
843
+ * the read, the way recorder routing is welded to the write below. Nine
844
+ * handlers read source here; the first fix of this defect added the settle to
845
+ * two of them and read as complete, which is the argument for a single point
846
+ * rather than a convention.
847
+ */
848
+ function readSettledSource(file: string, engineRoot: string): string {
849
+ settlePendingVendoredWrite(file, engineRoot);
850
+ return readFileSync(file, 'utf8');
851
+ }
852
+
853
+ /**
854
+ * Roll a crashed vendored write forward, and — when it moved bytes — stamp the
855
+ * invalidation that a write of our own would have stamped.
856
+ *
857
+ * Settling REPLACES the file's content, so leaving the module graph alone
858
+ * would serve the pre-settle module and leave the oid index on pre-settle
859
+ * offsets: the same silently-discarded-edit class {@link
860
+ * writtenSourceInvalidationGraph} exists to close, arriving through the settle
861
+ * instead of through the write.
862
+ */
863
+ function settlePendingVendoredWrite(file: string, engineRoot: string): void {
864
+ const vendored = findVendoredTarget(realpathForWrite(file), engineRoot);
865
+ if (vendored && settleVendoredWrite(vendored)) invalidateWrittenSource(file);
866
+ }
867
+
868
+ function writeEditableSource(
869
+ file: string,
870
+ code: string,
871
+ engineRoot: string = DEFAULT_ENGINE_ROOT,
872
+ ): void {
873
+ const target = findVendoredTarget(realpathForWrite(file), engineRoot);
874
+ if (!target) {
875
+ writeFileSync(file, code);
876
+ invalidateWrittenSource(file);
877
+ return;
878
+ }
879
+ settleVendoredWrite(target);
880
+ const result = writeRecordedVendoredFile(target, Buffer.from(code, 'utf8'), 'editor source edit');
881
+ if (!result.ok) {
882
+ // REFUSE LOUDLY rather than fall through to an unrecorded write: a lock
883
+ // that already disagrees with its folder is not a base anything may record
884
+ // onto, and writing anyway is precisely the silent drift the lock exists
885
+ // to make impossible.
886
+ throw new Error(
887
+ `vendored source write refused for ${target.id}/${target.rel}: ${result.error}`,
888
+ );
889
+ }
890
+ invalidateWrittenSource(file);
891
+ }
892
+
893
+ /** Default `include` predicate (see the module doc comment above for the reasoning). */
894
+ export function defaultProjectScopeInclude(id: string): boolean {
895
+ const clean = (id.split('?')[0] ?? id).replace(/\\/g, '/');
896
+ if (!clean.endsWith('.tsx')) return false;
897
+ if (NODE_MODULES_RE.test(clean)) return false;
898
+ // A vendored GAME's source IS stamped; every other `/vendor/` tree is not.
899
+ // The old blanket exclusion was written under the never-modify-vendored-
900
+ // source rule, which the owner's code-or-data ruling superseded
901
+ // (ARCHITECTURE-CORE §Editor "Every edit edits the game's own CODE or DATA",
902
+ // §Rules "there is no unwritable base"): ownership no longer decides WHETHER
903
+ // an edit may be written, only WHO RECORDS it. For a repo-vendored copy the
904
+ // recorder is its own `UPSTREAM.lock`, written in the same gesture as the
905
+ // file (`server/vendored-lock-recorder.ts`), so the zero-UNRECORDED-diff bar
906
+ // holds WITH the edit present rather than by forbidding the edit. Stamping is
907
+ // what makes the game's own TSX literals addressable by the gizmo and the
908
+ // inspector; refusing it here is what used to make a vendored game
909
+ // permanently unauthorable.
910
+ if (VENDOR_RE.test(clean) && !VENDORED_GAME_SRC_RE.test(clean)) return false;
911
+ if (TOOLING_SRC_RE.test(clean)) return false;
912
+ if (EDITOR_SRC_RE.test(clean) && !EDITABLE_COMPONENTS_RE.test(clean)) return false;
913
+ if (nearestManifestExcludesIngestReact(dirname(clean))) return false;
914
+ return true;
915
+ }
916
+
917
+ function resolveEditableSourceFile(id: string, projectRoot: string): string | null {
918
+ const clean = (id.split('?')[0] ?? id).replace(/\\/g, '/');
919
+ const candidate = isAbsolute(clean) ? clean : resolve(projectRoot, clean);
920
+ if (defaultProjectScopeInclude(candidate) || isEditableCssFile(candidate)) return candidate;
921
+
922
+ // Whole-file transactions also serve project-owned model source. This is
923
+ // deliberately narrower than a general project-file API: TypeScript below
924
+ // this project's src/ tree, never vendored or an ingest-react identity.
925
+ const projectPath = relative(projectRoot, candidate).replace(/\\/g, '/');
926
+ if (
927
+ projectPath.startsWith('src/') &&
928
+ (projectPath.endsWith('.ts') || projectPath.endsWith('.tsx')) &&
929
+ !VENDOR_RE.test(candidate) &&
930
+ !nearestManifestExcludesIngestReact(dirname(candidate))
931
+ ) {
932
+ return candidate;
933
+ }
934
+ return null;
935
+ }
936
+
937
+ function isCanonicalProjectSourcePath(projectRoot: string, file: string): boolean {
938
+ const absolute = isAbsolute(file) ? resolve(file) : resolve(projectRoot, file);
939
+ let ancestor = absolute;
940
+ while (!existsSync(ancestor)) {
941
+ const next = dirname(ancestor);
942
+ if (next === ancestor) return false;
943
+ ancestor = next;
944
+ }
945
+ try {
946
+ const root = realpathSync(projectRoot);
947
+ const candidate = realpathSync(ancestor);
948
+ return candidate === root || candidate.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
949
+ } catch {
950
+ return false;
951
+ }
952
+ }
953
+
954
+ /** The JSON response a source-endpoint handler produces (status defaults to 200). */
955
+ export interface HandlerResult {
956
+ status?: number;
957
+ body: unknown;
958
+ }
959
+
960
+ function preparedSource(
961
+ file: string,
962
+ prevSource: string,
963
+ newSource: string,
964
+ result: Record<string, unknown>,
965
+ projectRoot: string,
966
+ ): HandlerResult {
967
+ const changed = prevSource !== newSource && result['changed'] !== false;
968
+ return {
969
+ body: {
970
+ changed,
971
+ file,
972
+ resourcePath: sourceResourcePath(file, projectRoot),
973
+ ...(changed
974
+ ? {
975
+ prevSource,
976
+ newSource,
977
+ prevSha: sha256(prevSource),
978
+ newSha: sha256(newSource),
979
+ }
980
+ : {}),
981
+ result: { ...result, changed, file },
982
+ },
983
+ };
984
+ }
985
+
986
+ /**
987
+ * Pure source edit preparation: computes exact bytes and hashes, never writes.
988
+ *
989
+ * The edit itself is planned by the tier-agnostic `planSourceEdit`
990
+ * (`src/ui-source/plan-source-edit.ts`) — this handler only supplies what is
991
+ * genuinely server-side: the `node:fs` reads, the css scope guard, sha256, and
992
+ * the project-relative resource path. The hosted browser backend supplies its
993
+ * own equivalents around the SAME planner, which is what keeps the two tiers
994
+ * from drifting into different edit semantics.
995
+ */
996
+ export function handlePrepare(
997
+ store: OidStore,
998
+ body: Record<string, unknown>,
999
+ projectRoot = process.cwd(),
1000
+ engineRoot: string = DEFAULT_ENGINE_ROOT,
1001
+ ): HandlerResult {
1002
+ const request = body as unknown as SourceEditRequest;
1003
+ if (request.kind === 'css' && (!request.file || !isEditableCssFile(request.file))) {
1004
+ return {
1005
+ status: 403,
1006
+ body: { changed: false, result: { error: 'css file out of editable scope' } },
1007
+ };
1008
+ }
1009
+ const resolveOid = (oid: string) => store.index.get(oid);
1010
+ const sources = new Map<string, string>();
1011
+ const files = sourceEditFiles(request, resolveOid);
1012
+ for (const file of files) {
1013
+ sources.set(file, readSettledSource(file, engineRoot));
1014
+ }
1015
+ // The class-vs-inline gate is a question about the EDITED FILE's own project,
1016
+ // so it is answered here (the tier that has `node:fs`) and handed to the pure
1017
+ // planner — never inferred from the element's shape. Only a style request can
1018
+ // route to a class, so nothing else pays for the probe.
1019
+ const support =
1020
+ request.kind === 'style' && files[0]
1021
+ ? utilityClassSupportFor(projectRoot, files[0])
1022
+ : undefined;
1023
+ const plan = planSourceEdit(request, sources, resolveOid, support);
1024
+ if (plan.file === null) return { body: { changed: false, result: plan.result } };
1025
+ return preparedSource(plan.file, plan.prevSource, plan.newSource, plan.result, projectRoot);
1026
+ }
1027
+
1028
+ export function handleSourceRead(
1029
+ body: Record<string, unknown>,
1030
+ projectRoot = process.cwd(),
1031
+ engineRoot: string = DEFAULT_ENGINE_ROOT,
1032
+ ): HandlerResult {
1033
+ const file = body['file'];
1034
+ const resolved = typeof file === 'string' ? resolveEditableSourceFile(file, projectRoot) : null;
1035
+ if (!resolved) {
1036
+ return { status: 403, body: { error: 'file out of editable scope' } };
1037
+ }
1038
+ const source = readSettledSource(resolved, engineRoot);
1039
+ return {
1040
+ body: {
1041
+ source,
1042
+ sha: sha256(source),
1043
+ resourcePath: sourceResourcePath(resolved, projectRoot),
1044
+ },
1045
+ };
1046
+ }
1047
+
1048
+ export function handleSourceApply(
1049
+ body: Record<string, unknown>,
1050
+ projectRoot = process.cwd(),
1051
+ engineRoot: string = DEFAULT_ENGINE_ROOT,
1052
+ ): HandlerResult {
1053
+ const restored = handleRestore(body, projectRoot, engineRoot);
1054
+ const value = restored.body as { restored: boolean; sha?: string; error?: string };
1055
+ return {
1056
+ ...(restored.status ? { status: restored.status } : {}),
1057
+ body: { applied: value.restored, sha: value.sha, error: value.error },
1058
+ };
1059
+ }
1060
+
1061
+ /** POST `/__ui-source/write` — surgical inline-style/className edit (F5 routing).
1062
+ * D-A3 (wave 13): a `value: null` body is the REMOVAL sentinel — routed via
1063
+ * `applyStyleWriteRequest` to `removeInlineStyle` (append-aware undo); `appended`
1064
+ * is forwarded so the client's undo can remove vs. restore. */
1065
+ function handleWrite(
1066
+ store: OidStore,
1067
+ body: Record<string, unknown>,
1068
+ projectRoot = process.cwd(),
1069
+ engineRoot: string = DEFAULT_ENGINE_ROOT,
1070
+ ): HandlerResult {
1071
+ // A numeric `value` stays a NUMBER all the way to the writer — the JSX then
1072
+ // carries a bare `12` (React px-ifies it) instead of `'12'`, which React
1073
+ // passes through and the CSSOM rejects, keeping the previous value.
1074
+ const { oid, prop, value } = body as {
1075
+ oid: string;
1076
+ prop: string;
1077
+ value: string | number | null;
1078
+ };
1079
+ const entry = store.index.get(oid);
1080
+ if (!entry) return { body: { changed: false, error: 'unknown oid' } };
1081
+ const src = readSettledSource(entry.file, engineRoot);
1082
+ const off = lineColToOffset(src, entry.line, entry.col);
1083
+ const result = applyStyleWriteRequest(
1084
+ src,
1085
+ off,
1086
+ prop,
1087
+ value,
1088
+ utilityClassSupportFor(projectRoot, entry.file),
1089
+ );
1090
+ if (result.changed) writeEditableSource(entry.file, result.code, engineRoot);
1091
+ return {
1092
+ body: {
1093
+ changed: result.changed,
1094
+ dynamic: result.dynamic,
1095
+ route: result.route,
1096
+ appended: result.appended,
1097
+ ...(result.tokenRef ? { tokenRef: result.tokenRef } : {}),
1098
+ ...(result.error ? { error: result.error } : {}),
1099
+ file: entry.file,
1100
+ },
1101
+ };
1102
+ }
1103
+
1104
+ /**
1105
+ * POST `/__ui-source/css` — Cap 2 surgical CSS-FILE edit. The client (adapter) resolves
1106
+ * the target rule from live matched-rule data (`inspect.ts` getMatchedCssRules) and sends
1107
+ * the rule's source file + selector; this edits that file in place. The file path is
1108
+ * client-supplied, so it MUST pass the first-party CSS scope guard before any read/write
1109
+ * — an out-of-scope path is rejected, not touched. A selector not present in source is
1110
+ * generated CSS (`generated: true`); the client re-routes to class/inline.
1111
+ *
1112
+ * Exported (unlike its sibling handlers above, which close over the live
1113
+ * `OidStore`) so it's directly unit-testable without a dev server — it's pure
1114
+ * body-in/HandlerResult-out plus real fs I/O on a client-supplied, scope-
1115
+ * guarded path, no store/session state involved.
1116
+ */
1117
+ export function handleCss(
1118
+ body: Record<string, unknown>,
1119
+ engineRoot: string = DEFAULT_ENGINE_ROOT,
1120
+ ): HandlerResult {
1121
+ const { file, selector, prop, value, media } = body as {
1122
+ file: string;
1123
+ selector: string;
1124
+ prop: string;
1125
+ value: string;
1126
+ media?: string;
1127
+ };
1128
+ if (!file || !isEditableCssFile(file)) {
1129
+ return { body: { changed: false, error: 'css file out of editable scope' } };
1130
+ }
1131
+ const cleanFile = file.split('?')[0] ?? file;
1132
+ const src = readSettledSource(cleanFile, engineRoot);
1133
+ // `media` scopes the edit to a breakpoint: the rule inside `@media <media>`
1134
+ // (block and rule both created on demand — a breakpoint's first edit IS
1135
+ // what mints it). The base-path `generated` probe does not apply: the
1136
+ // media write can always land.
1137
+ const edited =
1138
+ typeof media === 'string' && media.trim().length > 0
1139
+ ? surgicalCssEditInMedia(src, media, selector, prop, value)
1140
+ : surgicalCssEdit(src, selector, prop, value);
1141
+ if (edited === null) return { body: { changed: false, generated: true, file: cleanFile } };
1142
+ if (edited !== src) writeEditableSource(cleanFile, edited, engineRoot);
1143
+ return { body: { changed: edited !== src, file: cleanFile } };
1144
+ }
1145
+
1146
+ /**
1147
+ * POST `/__ui-source/text` — Cap 3 text-content edit. Replaces a leaf element's pure-text
1148
+ * body in place (guarded: refuses a body with `{expression}` or child elements). Returns
1149
+ * `prevText` (the editable text before the edit) for the client's undo inverse.
1150
+ */
1151
+ function handleText(
1152
+ store: OidStore,
1153
+ body: Record<string, unknown>,
1154
+ engineRoot: string = DEFAULT_ENGINE_ROOT,
1155
+ ): HandlerResult {
1156
+ const { oid, text } = body as { oid: string; text: string };
1157
+ const entry = store.index.get(oid);
1158
+ if (!entry) return { body: { changed: false, error: 'unknown oid' } };
1159
+ const src = readSettledSource(entry.file, engineRoot);
1160
+ const off = lineColToOffset(src, entry.line, entry.col);
1161
+ const prevText = getEditableText(src, off);
1162
+ const result = editTextContent(src, off, text);
1163
+ if (result.changed) writeEditableSource(entry.file, result.code, engineRoot);
1164
+ return {
1165
+ body: { changed: result.changed, dynamic: result.dynamic, prevText, file: entry.file },
1166
+ };
1167
+ }
1168
+
1169
+ /**
1170
+ * POST `/__ui-source/prop` — Cap 4 component prop / attribute edit at the call site. The
1171
+ * client sends the CALL-SITE oid (resolved from the fiber, `inspect.ts` getCallSiteOid)
1172
+ * whose index entry points at the `<Component …>` tag. Guards a dynamic (non-literal) prop
1173
+ * and the event-handler / structural props.
1174
+ */
1175
+ function handleProp(
1176
+ store: OidStore,
1177
+ body: Record<string, unknown>,
1178
+ engineRoot: string = DEFAULT_ENGINE_ROOT,
1179
+ ): HandlerResult {
1180
+ const { oid, prop, value } = body as { oid: string; prop: string; value: string | null };
1181
+ const entry = store.index.get(oid);
1182
+ if (!entry) return { body: { changed: false, error: 'unknown oid' } };
1183
+ const src = readSettledSource(entry.file, engineRoot);
1184
+ const off = lineColToOffset(src, entry.line, entry.col);
1185
+ // `value: null` is the REMOVAL sentinel, the same shape `/__ui-source/write`
1186
+ // uses for `removeStyle` (D-A3). Removing a prop attribute is how the
1187
+ // inspector reverts a prop to the component's declared default.
1188
+ if (value === null) {
1189
+ const removed = removePropAttribute(src, off, prop);
1190
+ if (removed.changed) writeEditableSource(entry.file, removed.code, engineRoot);
1191
+ return { body: { changed: removed.changed, dynamic: removed.dynamic, file: entry.file } };
1192
+ }
1193
+ const result = writePropChange(src, off, prop, value, {
1194
+ addIfMissing: body['addIfMissing'] === true,
1195
+ // R2: opt-in scalar→tuple shape
1196
+ // upgrade — see writePropChange's own doc comment. Absent/false on every
1197
+ // pre-existing caller, so react-dom prop writes are byte-identical.
1198
+ allowShapeUpgrade: body['allowShapeUpgrade'] === true,
1199
+ });
1200
+ if (result.changed) writeEditableSource(entry.file, result.code, engineRoot);
1201
+ return { body: { changed: result.changed, dynamic: result.dynamic, file: entry.file } };
1202
+ }
1203
+
1204
+ /**
1205
+ * POST `/__ui-source/struct` — Cap 5 structural source edits. Ops: `delete`, `duplicate`,
1206
+ * `wrap` (+wrapperTag), `unwrap`, `create` (insert a `<tag/>` child of `oid`),
1207
+ * `create-sibling` (insert the `snippet` immediately AFTER `oid` — how a top-level element
1208
+ * is added to a world whose root JSX is an OID-less fragment), `reorder`
1209
+ * (move `oid` before `targetOid`, or to the end of `parentOid`), and `reparent` (move `oid`
1210
+ * into `parentOid`). An insert may carry `ensureImport` when its snippet names a component.
1211
+ * Multi-oid ops resolve every oid to an offset in the SAME file (a
1212
+ * cross-file move is refused) — OID-resolved, immune to index races.
1213
+ *
1214
+ * D-1 (Phase D, spec27 §2 "known limitation to schedule, not hide"): when the op actually
1215
+ * changes the file, the response also carries the WHOLE file's `prevSource`/`newSource`
1216
+ * (before/after this write) and their `prevSha`/`newSha` (sha256 digests, computed here —
1217
+ * the client never hashes anything itself). `ReactRootAuthoringAdapter.structOp` uses
1218
+ * these to push a checksum-guarded whole-file-snapshot undo/redo entry — the sound inverse
1219
+ * for a structural op (a per-OID inverse is unsound: OIDs are content-signature keyed,
1220
+ * reorder/delete reassign occurrence indices, and delete has no inverse payload). Exported
1221
+ * (like `handleCss`) so it's directly unit-testable without a running dev server.
1222
+ *
1223
+ * The op switch itself lives in `planSourceEdit` and NOWHERE else. It used to be written
1224
+ * out a second time here, and the two copies drifted the moment `reparent` grew rules of
1225
+ * its own (R1–R4): one caller routed through the planner and got them, this
1226
+ * one did not. Everything that remains below is what is genuinely server-side — the
1227
+ * `node:fs` read/write, sha256, and the response envelope.
1228
+ */
1229
+ export function handleStruct(
1230
+ store: OidStore,
1231
+ body: Record<string, unknown>,
1232
+ engineRoot: string = DEFAULT_ENGINE_ROOT,
1233
+ ): HandlerResult {
1234
+ const { oid, op } = body as { oid?: unknown; op?: unknown };
1235
+ if (typeof oid !== 'string' || typeof op !== 'string') {
1236
+ return { body: { changed: false, error: 'struct requests need an oid and an op' } };
1237
+ }
1238
+ const request = { ...body, kind: 'struct', oid, op } as unknown as SourceEditRequest;
1239
+ const resolveOid = (id: string): OidEntry | undefined => store.index.get(id);
1240
+ const sources = new Map<string, string>();
1241
+ for (const file of sourceEditFiles(request, resolveOid)) {
1242
+ sources.set(file, readSettledSource(file, engineRoot));
1243
+ }
1244
+ const plan = planSourceEdit(request, sources, resolveOid);
1245
+ if (plan.file === null) return { body: { changed: false, ...plan.result } };
1246
+ const { changed: _planned, ...detail } = plan.result;
1247
+ if (!plan.changed) return { body: { changed: false, file: plan.file, ...detail } };
1248
+ writeEditableSource(plan.file, plan.newSource, engineRoot);
1249
+ return {
1250
+ body: {
1251
+ changed: true,
1252
+ file: plan.file,
1253
+ ...detail,
1254
+ prevSource: plan.prevSource,
1255
+ newSource: plan.newSource,
1256
+ prevSha: sha256(plan.prevSource),
1257
+ newSha: sha256(plan.newSource),
1258
+ },
1259
+ };
1260
+ }
1261
+
1262
+ /**
1263
+ * POST `/__ui-source/struct-many` — one source snapshot and one history boundary
1264
+ * for structural operations involving several OIDs. `group` wraps source siblings
1265
+ * beneath an ordinary R3F group. `delete` retains the delete-order-residual fix
1266
+ * (50f90a6d): it resolves EVERY `oid` in `oids` to an offset against ONE shared
1267
+ * `readFileSync` snapshot, then applies them via `deleteElements` (`writer.ts`) —
1268
+ * highest offset first, so
1269
+ * no still-queued target's offset is ever shifted out from under it, REGARDLESS of
1270
+ * what order the caller listed `oids` in. This is what makes the batch immune to the
1271
+ * delete-order-residual: `deleteSelection` (`editor-hotkeys.ts`)'s per-id fallback
1272
+ * loop derives its delete order from the CALLER's own hierarchy walk (DOM order for
1273
+ * `ReactRootAuthoringAdapter` — see `walkOidTree`), which can diverge from true
1274
+ * SOURCE order when a component's live DOM child order is a runtime permutation of
1275
+ * its JSX (e.g. `{[...els].reverse()}`); this endpoint never consults that walk order
1276
+ * at all, only each oid's own registered `{line, col}` and ONE shared file read, so
1277
+ * caller order is irrelevant to correctness. Explicitly NOT a re-scan/re-transform of
1278
+ * the file per oid (see `deleteElements`'s doc comment for why that would be UNSOUND
1279
+ * here — a live re-transform mid-batch reassigns occurrence-index-based oid identity
1280
+ * out from under the very oids this batch is still trying to resolve). One write, one
1281
+ * `prevSource`/`newSource` pair — one undo entry for the whole batch (a bonus over
1282
+ * the N-entry per-id fallback, not the point of this fix, but free from reusing
1283
+ * `pushStructUndo`'s existing whole-file-snapshot inverse unchanged).
1284
+ */
1285
+ export function handleStructMany(
1286
+ store: OidStore,
1287
+ body: Record<string, unknown>,
1288
+ engineRoot: string = DEFAULT_ENGINE_ROOT,
1289
+ ): HandlerResult {
1290
+ const { oids, op, wrapperTag } = body as {
1291
+ oids?: unknown;
1292
+ op?: string;
1293
+ wrapperTag?: string;
1294
+ };
1295
+ if (op !== 'delete' && op !== 'group') {
1296
+ return { body: { changed: false, error: `unsupported batch op: ${op}` } };
1297
+ }
1298
+ if (!Array.isArray(oids) || oids.length === 0) {
1299
+ return { body: { changed: false, error: 'oids must be a non-empty array' } };
1300
+ }
1301
+ const uniqueOids = [...new Set(oids as string[])];
1302
+ const entries = uniqueOids.map((oid) => ({ oid, entry: store.index.get(oid) }));
1303
+ const missing = entries.find((e) => !e.entry);
1304
+ if (missing) return { body: { changed: false, error: `unknown oid: ${missing.oid}` } };
1305
+ const file = entries[0]!.entry!.file;
1306
+ const crossFile = entries.find((e) => e.entry!.file !== file);
1307
+ if (crossFile) return { body: { changed: false, error: 'cannot batch-edit across files' } };
1308
+
1309
+ const src = readSettledSource(file, engineRoot); // ONE snapshot every offset below resolves against
1310
+ const offsets = entries.map((e) => lineColToOffset(src, e.entry!.line, e.entry!.col));
1311
+ const result =
1312
+ op === 'group' ? groupSiblingElements(src, offsets, wrapperTag) : deleteElements(src, offsets);
1313
+ if (!result.changed) return { body: { changed: false, file } };
1314
+ const prevSource = src;
1315
+ const newSource = result.code;
1316
+ writeEditableSource(file, newSource, engineRoot);
1317
+ return {
1318
+ body: {
1319
+ changed: true,
1320
+ file,
1321
+ prevSource,
1322
+ newSource,
1323
+ prevSha: sha256(prevSource),
1324
+ newSha: sha256(newSource),
1325
+ },
1326
+ };
1327
+ }
1328
+
1329
+ /**
1330
+ * POST `/__ui-source/fork-component` — H7 "Fork Component…" (wave 6).
1331
+ *
1332
+ * The ONE thing this endpoint does that the client cannot: create a file. Every
1333
+ * other `/__ui-source/*` write targets a file the OID index already knows, and
1334
+ * the whole-file `apply`/`restore` pair is checksum-guarded against a file that
1335
+ * exists — there is no create path through the client write seam at all. So the
1336
+ * new module is written HERE with `node:fs`, and the CALLSITE edit is handed
1337
+ * back to the client unwritten, so it can go through project history like every
1338
+ * other source write (the fork is undoable at the callsite; the new file, being
1339
+ * outside history, survives an undo as an inert module nothing imports).
1340
+ *
1341
+ * ORDER AND ATOMICITY. `planComponentFork` computes BOTH edits from both files'
1342
+ * current bytes before anything is written, and refuses — with a sentence — on
1343
+ * every failure it can see (definition not found, tag no longer matches, name
1344
+ * or file collision). Only then is the new file written, and only if nothing is
1345
+ * at that path already: `existsSync` is checked here as well as in the planner,
1346
+ * because the planner reads a directory LISTING the caller supplied and this is
1347
+ * the last moment before the write.
1348
+ */
1349
+ export function handleForkComponent(
1350
+ store: OidStore,
1351
+ body: Record<string, unknown>,
1352
+ projectRoot = process.cwd(),
1353
+ engineRoot: string = DEFAULT_ENGINE_ROOT,
1354
+ ): HandlerResult {
1355
+ const { oid, definitionOid, name } = body as {
1356
+ oid?: string;
1357
+ definitionOid?: string;
1358
+ name?: string;
1359
+ };
1360
+ const callsite = typeof oid === 'string' ? store.index.get(oid) : undefined;
1361
+ if (!callsite) return { body: { ok: false, error: 'unknown oid' } };
1362
+ const definition = typeof definitionOid === 'string' ? store.index.get(definitionOid) : undefined;
1363
+ if (!definition) return { body: { ok: false, error: 'unknown definition oid' } };
1364
+
1365
+ const definitionDir = dirname(definition.file);
1366
+ let siblingFiles: string[] = [];
1367
+ try {
1368
+ siblingFiles = readdirSync(definitionDir);
1369
+ } catch {
1370
+ // An unreadable directory is not a reason to refuse: the collision checks
1371
+ // below (and `existsSync`) still hold, they are just less informed.
1372
+ }
1373
+ const planned = planComponentFork({
1374
+ callsiteFile: callsite.file,
1375
+ callsiteSource: readSettledSource(callsite.file, engineRoot),
1376
+ callsiteLine: callsite.line,
1377
+ callsiteCol: callsite.col,
1378
+ definitionFile: definition.file,
1379
+ definitionSource: readSettledSource(definition.file, engineRoot),
1380
+ tag: callsite.tag,
1381
+ nameSeed: typeof name === 'string' ? name : undefined,
1382
+ siblingFiles,
1383
+ });
1384
+ if (!planned.ok) return { body: { ok: false, error: planned.reason } };
1385
+
1386
+ const plan = planned.plan;
1387
+ const newFile = join(definitionDir, plan.newFileName);
1388
+ if (existsSync(newFile)) {
1389
+ return {
1390
+ body: {
1391
+ ok: false,
1392
+ error: `${sourceResourcePath(newFile, projectRoot)} already exists — fork will not overwrite a file.`,
1393
+ },
1394
+ };
1395
+ }
1396
+ writeEditableSource(newFile, plan.newFileSource, engineRoot);
1397
+ return {
1398
+ body: {
1399
+ ok: true,
1400
+ newName: plan.newName,
1401
+ tag: plan.tag,
1402
+ newFile,
1403
+ newResourcePath: sourceResourcePath(newFile, projectRoot),
1404
+ importSpecifier: plan.importSpecifier,
1405
+ label: plan.historyLabel,
1406
+ callsiteFile: plan.callsiteFile,
1407
+ callsiteResourcePath: sourceResourcePath(plan.callsiteFile, projectRoot),
1408
+ callsitePrevSource: plan.callsitePrevSource,
1409
+ callsiteNewSource: plan.callsiteNewSource,
1410
+ callsitePrevSha: sha256(plan.callsitePrevSource),
1411
+ callsiteNewSha: sha256(plan.callsiteNewSource),
1412
+ },
1413
+ };
1414
+ }
1415
+
1416
+ /**
1417
+ * POST `/__ui-source/extract-component` — P1 "Extract Component…".
1418
+ *
1419
+ * The fork route's sibling, and the SECOND file-creating seam on this
1420
+ * middleware (the header on `/__ui-source/fork-component` records why file
1421
+ * creation lives server-side at all). `planComponentExtraction` computes the
1422
+ * two new modules (component + portable CSF story under `src/prefabs/`) and
1423
+ * the callsite edit from current bytes before anything is written, refusing
1424
+ * with a sentence on every failure it can see (subtree gone, lexical capture,
1425
+ * module-local reference, name/file collision). Only then are the new files
1426
+ * written — `existsSync`-guarded here as well as in the planner, because the
1427
+ * planner reads a directory LISTING and this is the last moment before the
1428
+ * write — and the CALLSITE edit is handed back unwritten so the client pushes
1429
+ * it through project history: undo restores the callsite and leaves the two
1430
+ * new files behind as an inert module + story nothing imports.
1431
+ */
1432
+ export function handleExtractComponent(
1433
+ store: OidStore,
1434
+ body: Record<string, unknown>,
1435
+ projectRoot = process.cwd(),
1436
+ engineRoot: string = DEFAULT_ENGINE_ROOT,
1437
+ ): HandlerResult {
1438
+ const { oid, name } = body as { oid?: string; name?: string };
1439
+ const entry = typeof oid === 'string' ? store.index.get(oid) : undefined;
1440
+ if (!entry) return { body: { ok: false, error: 'unknown oid' } };
1441
+
1442
+ const prefabDir = join(projectRoot, 'src', 'prefabs');
1443
+ let siblingFiles: string[] = [];
1444
+ try {
1445
+ siblingFiles = readdirSync(prefabDir);
1446
+ } catch {
1447
+ // No prefab folder yet — the write below creates it.
1448
+ }
1449
+ const planned = planComponentExtraction({
1450
+ sourceFile: entry.file,
1451
+ source: readSettledSource(entry.file, engineRoot),
1452
+ line: entry.line,
1453
+ col: entry.col,
1454
+ nameSeed: typeof name === 'string' ? name : undefined,
1455
+ prefabDir,
1456
+ siblingFiles,
1457
+ });
1458
+ if (!planned.ok) return { body: { ok: false, error: planned.reason } };
1459
+
1460
+ const plan = planned.plan;
1461
+ for (const file of [plan.componentFile, plan.storyFile]) {
1462
+ if (existsSync(file)) {
1463
+ return {
1464
+ body: {
1465
+ ok: false,
1466
+ error: `${sourceResourcePath(file, projectRoot)} already exists — extraction will not overwrite a file.`,
1467
+ },
1468
+ };
1469
+ }
1470
+ }
1471
+ mkdirSync(prefabDir, { recursive: true });
1472
+ writeEditableSource(plan.componentFile, plan.componentSource, engineRoot);
1473
+ writeEditableSource(plan.storyFile, plan.storySource, engineRoot);
1474
+ return {
1475
+ body: {
1476
+ ok: true,
1477
+ newName: plan.newName,
1478
+ tag: plan.tag,
1479
+ componentFile: plan.componentFile,
1480
+ componentResourcePath: sourceResourcePath(plan.componentFile, projectRoot),
1481
+ storyFile: plan.storyFile,
1482
+ storyResourcePath: sourceResourcePath(plan.storyFile, projectRoot),
1483
+ importSpecifier: plan.importSpecifier,
1484
+ label: plan.historyLabel,
1485
+ callsiteFile: plan.callsiteFile,
1486
+ callsiteResourcePath: sourceResourcePath(plan.callsiteFile, projectRoot),
1487
+ callsitePrevSource: plan.callsitePrevSource,
1488
+ callsiteNewSource: plan.callsiteNewSource,
1489
+ callsitePrevSha: sha256(plan.callsitePrevSource),
1490
+ callsiteNewSha: sha256(plan.callsiteNewSource),
1491
+ },
1492
+ };
1493
+ }
1494
+
1495
+ /**
1496
+ * POST `/__ui-source/csf-story` — the CSF write half (design ledger:
1497
+ * stories composed and framed everywhere, written nowhere): save an arg
1498
+ * set as a new story export, rename one, or delete one. The grammar and
1499
+ * every refusal live in the pure planner (`ui-source/plan-csf-story.ts`);
1500
+ * this handler is scope-guard + read + plan + write.
1501
+ */
1502
+ export function handleCsfStory(
1503
+ body: Record<string, unknown>,
1504
+ projectRoot = process.cwd(),
1505
+ engineRoot: string = DEFAULT_ENGINE_ROOT,
1506
+ ): HandlerResult {
1507
+ const { op, file, name, newName, args } = body as {
1508
+ op?: 'save' | 'rename' | 'delete';
1509
+ file?: string;
1510
+ name?: string;
1511
+ newName?: string;
1512
+ args?: Record<string, unknown>;
1513
+ };
1514
+ if (typeof file !== 'string' || typeof name !== 'string' || !op) {
1515
+ return { status: 400, body: { changed: false, error: 'csf-story requires {op, file, name}.' } };
1516
+ }
1517
+ const resolved = resolveEditableSourceFile(file, projectRoot);
1518
+ if (!resolved) {
1519
+ return { status: 403, body: { changed: false, error: 'file out of editable scope' } };
1520
+ }
1521
+ const source = readSettledSource(resolved, engineRoot);
1522
+ const plan =
1523
+ op === 'save'
1524
+ ? planSaveStory(source, name, args ?? {})
1525
+ : op === 'rename'
1526
+ ? planRenameStory(source, name, typeof newName === 'string' ? newName : '')
1527
+ : planDeleteStory(source, name);
1528
+ if (!plan.ok) return { body: { changed: false, error: plan.reason } };
1529
+ writeEditableSource(resolved, plan.nextSource, engineRoot);
1530
+ return { body: { changed: true, summary: plan.summary, file } };
1531
+ }
1532
+
1533
+ /** A JSX literal style value as CSS TEXT (`'8px'` → `8px`, `8` → `8px` per the
1534
+ * react px-ifying rule in `css-numeric-style.ts`), or null for a literal that
1535
+ * has no CSS reading (a boolean) — such a member stays inline, unmoved. */
1536
+ function cssValueFromJsxLiteral(name: string, raw: string): string | null {
1537
+ const t = raw.trim();
1538
+ if (/^['"`]/.test(t)) return t.slice(1, -1);
1539
+ const num = Number(t);
1540
+ if (Number.isFinite(num)) return cssTextForStyleValue(name, num);
1541
+ return null;
1542
+ }
1543
+
1544
+ /**
1545
+ * POST `/__ui-source/named-style` — the design ledger's NAMED STYLES order
1546
+ * (Webflow prior art: a reusable style is a CSS CLASS in the project's own
1547
+ * stylesheet). Three ops on one door:
1548
+ *
1549
+ * - `create` — mint `.className` in the given first-party stylesheet
1550
+ * (`file`, guarded by {@link isEditableCssFile}), MOVING the element's
1551
+ * literal inline declarations into it (dynamic members stay inline — they
1552
+ * are not the writer's to move), and adding the class token to the
1553
+ * element. Both files are planned before either is written, so a refusal
1554
+ * (duplicate class, dynamic className) leaves nothing half-done.
1555
+ * - `apply` / `remove` — add/remove the bare class token on the element.
1556
+ *
1557
+ * Every refusal names its gate; a `className={expr}` is never rewritten.
1558
+ */
1559
+ export function handleNamedStyle(
1560
+ store: OidStore,
1561
+ body: Record<string, unknown>,
1562
+ engineRoot: string = DEFAULT_ENGINE_ROOT,
1563
+ ): HandlerResult {
1564
+ const { op, oid, className, file } = body as {
1565
+ op?: 'create' | 'apply' | 'remove';
1566
+ oid?: string;
1567
+ className?: string;
1568
+ file?: string;
1569
+ };
1570
+ if (
1571
+ (op !== 'create' && op !== 'apply' && op !== 'remove') ||
1572
+ typeof oid !== 'string' ||
1573
+ typeof className !== 'string'
1574
+ ) {
1575
+ return {
1576
+ status: 400,
1577
+ body: { changed: false, error: 'named-style requires {op, oid, className}.' },
1578
+ };
1579
+ }
1580
+ const entry = store.index.get(oid);
1581
+ if (!entry) return { body: { changed: false, error: 'unknown oid' } };
1582
+ const src = readSettledSource(entry.file, engineRoot);
1583
+ const off = lineColToOffset(src, entry.line, entry.col);
1584
+
1585
+ if (op === 'apply' || op === 'remove') {
1586
+ const r = (op === 'apply' ? addClassNameToken : removeClassNameToken)(src, off, className);
1587
+ if (r.dynamic) {
1588
+ return {
1589
+ body: {
1590
+ changed: false,
1591
+ error: "the element's className is a dynamic expression — refusing to rewrite it.",
1592
+ },
1593
+ };
1594
+ }
1595
+ if (r.changed) writeEditableSource(entry.file, r.code, engineRoot);
1596
+ return { body: { changed: r.changed, file: entry.file } };
1597
+ }
1598
+
1599
+ // No stylesheet named ⇒ MINT one beside the element's own module (and make
1600
+ // the module import it — an unimported stylesheet never paints, and a bare
1601
+ // `import './styles.css'` is exactly the ecosystem-native form Vite and the
1602
+ // game's own build both honor). No example or fresh scaffold ships project
1603
+ // CSS today, so without this the whole feature would open on a refusal.
1604
+ const cleanCss =
1605
+ typeof file === 'string'
1606
+ ? (file.split('?')[0] ?? file)
1607
+ : join(dirname(entry.file), 'styles.css');
1608
+ if (!isEditableCssFile(cleanCss)) {
1609
+ return { body: { changed: false, error: 'css file out of editable scope' } };
1610
+ }
1611
+ const cssSrc = existsSync(cleanCss) ? readSettledSource(cleanCss, engineRoot) : '';
1612
+ const decls: { prop: string; value: string }[] = [];
1613
+ const moved: string[] = [];
1614
+ for (const member of collectLiteralInlineStyles(src, off)) {
1615
+ const value = cssValueFromJsxLiteral(member.name, member.raw);
1616
+ if (value === null) continue;
1617
+ decls.push({ prop: cssCamelToKebab(member.name), value });
1618
+ moved.push(member.name);
1619
+ }
1620
+ const plan = planCreateClassRule(cssSrc, className, decls);
1621
+ if (!plan.ok) return { body: { changed: false, error: plan.reason } };
1622
+ let code = src;
1623
+ for (const name of moved) code = removeInlineStyle(code, off, name).code;
1624
+ const applied = addClassNameToken(code, off, className);
1625
+ if (applied.dynamic) {
1626
+ return {
1627
+ body: {
1628
+ changed: false,
1629
+ error: "the element's className is a dynamic expression — refusing to rewrite it.",
1630
+ },
1631
+ };
1632
+ }
1633
+ let elementCode = applied.code;
1634
+ // Bulk removal leaves each moved member's line as bare indentation — collapse
1635
+ // the blank lines inside the opening tag so the authored source stays clean.
1636
+ const tagEnd = findTagEnd(elementCode, off);
1637
+ if (tagEnd > 0) {
1638
+ const seg = elementCode.slice(off, tagEnd);
1639
+ const collapsed = seg.replace(/\n(?:[ \t]*\n)+/g, '\n');
1640
+ elementCode = elementCode.slice(0, off) + collapsed + elementCode.slice(off + seg.length);
1641
+ }
1642
+ const specifier = `./${basename(cleanCss)}`;
1643
+ if (
1644
+ dirname(cleanCss) === dirname(entry.file) &&
1645
+ !new RegExp(`import\\s+['"]\\.?/?${basename(cleanCss).replace('.', '\\.')}['"]`).test(
1646
+ elementCode,
1647
+ )
1648
+ ) {
1649
+ elementCode = `import '${specifier}';\n${elementCode}`;
1650
+ }
1651
+ writeEditableSource(cleanCss, plan.nextSource, engineRoot);
1652
+ if (elementCode !== src) writeEditableSource(entry.file, elementCode, engineRoot);
1653
+ return {
1654
+ body: { changed: true, file: cleanCss, elementFile: entry.file, moved, summary: plan.summary },
1655
+ };
1656
+ }
1657
+
1658
+ /**
1659
+ * POST `/__ui-source/create-file` — create a NEW project source file. The
1660
+ * pasteboard "materialize" action's door (extraction and fork keep their own
1661
+ * purpose-built routes); creation is the ONLY thing it does — edits go
1662
+ * through the surgical writers, and an existing file is a refusal, never an
1663
+ * overwrite. Same editable-scope guard as every other source route.
1664
+ */
1665
+ export function handleCreateSourceFile(
1666
+ body: Record<string, unknown>,
1667
+ projectRoot = process.cwd(),
1668
+ engineRoot: string = DEFAULT_ENGINE_ROOT,
1669
+ ): HandlerResult {
1670
+ const { file, source } = body as { file?: string; source?: string };
1671
+ if (typeof file !== 'string' || typeof source !== 'string') {
1672
+ return {
1673
+ status: 400,
1674
+ body: { created: false, error: 'create-file requires {file, source}.' },
1675
+ };
1676
+ }
1677
+ const resolved = resolveEditableSourceFile(file, projectRoot);
1678
+ if (!resolved) {
1679
+ return { status: 403, body: { created: false, error: 'file out of editable scope' } };
1680
+ }
1681
+ if (existsSync(resolved)) {
1682
+ return {
1683
+ body: {
1684
+ created: false,
1685
+ error: `${sourceResourcePath(resolved, projectRoot)} already exists — create-file never overwrites.`,
1686
+ },
1687
+ };
1688
+ }
1689
+ mkdirSync(dirname(resolved), { recursive: true });
1690
+ writeEditableSource(resolved, source, engineRoot);
1691
+ return { body: { created: true, file } };
1692
+ }
1693
+
1694
+ /**
1695
+ * POST `/__ui-source/restore` — D-1 (Phase D, spec27 §2): the checksum-guarded whole-file
1696
+ * restore that makes Cap-5 structural edits (delete/duplicate/wrap/unwrap/reorder/reparent/
1697
+ * create) undoable. `ReactRootAuthoringAdapter.structOp` captures `{prevSource, newSource,
1698
+ * prevSha, newSha}` from a struct write's response (`handleStruct`, above) and, on undo/
1699
+ * redo, posts the INVERSE `{file, source, ifMatchSha}` here: undo sends `source: prevSource,
1700
+ * ifMatchSha: newSha` (the hash of what SHOULD be on disk right now); redo sends the mirror
1701
+ * image. Refuses — WITHOUT writing — when the file's CURRENT sha256 doesn't match
1702
+ * `ifMatchSha` (an out-of-band change since: a hand-edit, a since-diverged reload, …);
1703
+ * `restored: false` is the caller's cue to surface a loud refusal instead of silently
1704
+ * corrupting the file. Scope-guarded the same way `handleCss`'s client-supplied `file` is
1705
+ * (`defaultProjectScopeInclude` — restore targets the SAME `.tsx` project files struct ops
1706
+ * do). Exported (like `handleCss`) so it's directly unit-testable without a dev server.
1707
+ */
1708
+ export function handleRestore(
1709
+ body: Record<string, unknown>,
1710
+ projectRoot = process.cwd(),
1711
+ engineRoot: string = DEFAULT_ENGINE_ROOT,
1712
+ ): HandlerResult {
1713
+ const { file, source, ifMatchSha } = body as {
1714
+ file: string;
1715
+ source: string;
1716
+ ifMatchSha: string;
1717
+ };
1718
+ const resolved = file ? resolveEditableSourceFile(file, projectRoot) : null;
1719
+ if (!resolved) {
1720
+ return { status: 403, body: { restored: false, error: 'file out of editable scope' } };
1721
+ }
1722
+ let current: string;
1723
+ try {
1724
+ current = readSettledSource(resolved, engineRoot);
1725
+ } catch (e) {
1726
+ return { status: 404, body: { restored: false, error: String(e) } };
1727
+ }
1728
+ const currentSha = sha256(current);
1729
+ if (currentSha !== ifMatchSha) {
1730
+ return {
1731
+ status: 409,
1732
+ body: {
1733
+ restored: false,
1734
+ error: 'stale: the file changed since this undo/redo entry was recorded',
1735
+ sha: currentSha,
1736
+ },
1737
+ };
1738
+ }
1739
+ writeEditableSource(resolved, source, engineRoot);
1740
+ return { body: { restored: true, sha: sha256(source) } };
1741
+ }
1742
+
1743
+ /**
1744
+ * This plugin's Vite name — the ONE spelling, so a host can ask its own
1745
+ * resolved plugin list whether it serves `/__ui-source/*` instead of declaring
1746
+ * the same fact a second time. See {@link servesUiSourceRoutes}.
1747
+ */
1748
+ export const UI_OID_PLUGIN_NAME = 'vgai-ui-oid';
1749
+
1750
+ /**
1751
+ * Does this Vite instance serve the `/__ui-source/*` read/write endpoints?
1752
+ *
1753
+ * Read off the RESOLVED plugin list, never declared: an editor host that boots
1754
+ * a Vite instance registers `uiOidPlugin()` or it does not, and that
1755
+ * registration is the only fact there is. A host with no Vite at all has no
1756
+ * plugins and correctly answers `false`.
1757
+ *
1758
+ * This is what `/__editor/project`'s `sourceWrite` reports and what the editor
1759
+ * client's `tier-source-write-backend.ts` reads to decide whether authoring
1760
+ * edits are recorded to the game's own source or are honestly live-only — the
1761
+ * axis that used to be (wrongly) `import.meta.env.DEV`.
1762
+ */
1763
+ export function servesUiSourceRoutes(plugins: readonly { name: string }[]): boolean {
1764
+ return plugins.some((plugin) => plugin.name === UI_OID_PLUGIN_NAME);
1765
+ }
1766
+
1767
+ export function uiOidPlugin(
1768
+ include: RegExp | ((id: string) => boolean) = defaultProjectScopeInclude,
1769
+ projectRoot?: string | (() => string | undefined),
1770
+ /** The vendored ESTATE this plugin's writes are recorded against — see
1771
+ * {@link DEFAULT_ENGINE_ROOT}. A parameter, not a config option: no shipped
1772
+ * caller passes it and none should, it exists so a test can stand up a
1773
+ * synthetic `vendor/games/` instead of editing a real one. */
1774
+ engineRoot: string = DEFAULT_ENGINE_ROOT,
1775
+ ): Plugin {
1776
+ const test = typeof include === 'function' ? include : (id: string) => include.test(id);
1777
+ const store = new OidStore();
1778
+ // Captured by `configureServer` below, read by `transform` — the dev
1779
+ // server's own `ModuleGraph` is what lets `declaredSurfaceForProjectFileViaGraph`
1780
+ // reach a root entry's surface through a NON-entry file's importer chain
1781
+ // (a child component with no direct fiber/world3d-react import of its
1782
+ // own). `undefined` in a build/SSR context without `configureServer`
1783
+ // (e.g. a one-off transform in a test) degrades to the direct-entry-only
1784
+ // answer, same as before this existed.
1785
+ let devServer: ViteDevServer | undefined;
1786
+ return {
1787
+ name: UI_OID_PLUGIN_NAME,
1788
+ enforce: 'pre',
1789
+ transform(code, id) {
1790
+ // A VIRTUAL MODULE IS NOT A FILE, and everything below this line treats
1791
+ // `clean` as one — `resolveOidSurface` walks up from it looking for the
1792
+ // owning `vgai.project.json`, and `transformSource` records it as the
1793
+ // stamped file's path. Vite's convention is that a `\0`-prefixed id was
1794
+ // synthesized by a plugin and has no path behind it, so a path plugin
1795
+ // skips it.
1796
+ //
1797
+ // The `test(clean)` below happens to reject today's virtual ids for the
1798
+ // wrong reason (they carry no extension); a synthesized id may legally
1799
+ // end in `.tsx`, and then the extension test passes and a NUL byte
1800
+ // reaches `readFileSync`. That exact throw was measured one layer down
1801
+ // on 2026-09-21 — see `importerFiles` in `server/project-root-surface.ts`
1802
+ // for the failure and what it looked like from the editor.
1803
+ if (id.startsWith('\0')) return null;
1804
+ const clean = id.split('?')[0] ?? id;
1805
+ if (!test(clean)) return null;
1806
+ // W1 — R3F-dialect files get ` userData-oid` (fiber pierces it into
1807
+ // `object.userData.oid`); react-dom files keep ` data-oid` unchanged.
1808
+ // WHICH REGION owns this exact file decides: a declaration the project
1809
+ // made (an `include` glob or a `mounts` entry in `vgai.adapter.ts`), the
1810
+ // manifest root whose `entry` it is, or reach from one through the live
1811
+ // import graph. Nothing is inferred from the file's own bytes; a file
1812
+ // nothing places gets the documented `data-oid` default, said out loud.
1813
+ // The project root is threaded because an INGEST root's sources live
1814
+ // outside its manifest's directory (see `manifestOwning`), so the walk
1815
+ // alone cannot find the project that declares them.
1816
+ const decision = resolveOidSurface(
1817
+ clean,
1818
+ code,
1819
+ importersFromModuleGraph(devServer?.moduleGraph),
1820
+ typeof projectRoot === 'function' ? projectRoot() : projectRoot,
1821
+ );
1822
+ const { code: out, entries } = transformSource(code, clean, store, {
1823
+ attribute: decision.attribute,
1824
+ canvasComponents: decision.surface === 'canvas',
1825
+ importedR3fContracts: importedR3fContracts(code, clean),
1826
+ });
1827
+ // Task #45: a wrong stamp must not be silent. Reported only when this
1828
+ // file actually GOT stamps — a module with no JSX has no selection to
1829
+ // break, so its (irrelevant) attribute is not worth a terminal line.
1830
+ if (entries.length > 0) reportOidSurfaceDiagnostics(decision);
1831
+ return { code: out, map: null };
1832
+ },
1833
+ configureServer(server) {
1834
+ devServer = server;
1835
+ const activeProjectRoot = (): string =>
1836
+ (typeof projectRoot === 'function' ? projectRoot() : projectRoot) ?? server.config.root;
1837
+ // The editor's own writes invalidate the modules they replace — see
1838
+ // `writtenSourceInvalidationGraph` for the measured stale-remount this
1839
+ // closes, and why the watcher is not allowed to be the only path. The
1840
+ // stamp is BOUNDED at editor chrome (see `bindWrittenSourceInvalidation`).
1841
+ bindWrittenSourceInvalidation(
1842
+ server.moduleGraph,
1843
+ editorChromeStampBoundary(engineRoot, activeProjectRoot),
1844
+ );
1845
+ // The Express host may apply express.json() app-wide, consuming the body and
1846
+ // leaving the parsed object on req.body. Prefer that; else read the raw stream.
1847
+ const readJson = async (
1848
+ req: import('node:http').IncomingMessage,
1849
+ ): Promise<Record<string, unknown>> => {
1850
+ const pre = (req as unknown as { body?: unknown }).body;
1851
+ if (pre && typeof pre === 'object') return pre as Record<string, unknown>;
1852
+ const chunks: Buffer[] = [];
1853
+ for await (const chunk of req) chunks.push(chunk as Buffer);
1854
+ const raw = Buffer.concat(chunks).toString('utf8');
1855
+ return raw ? JSON.parse(raw) : {};
1856
+ };
1857
+ const json = (res: import('node:http').ServerResponse, obj: unknown, code = 200): void => {
1858
+ res.statusCode = code;
1859
+ res.setHeader('Content-Type', 'application/json');
1860
+ res.end(JSON.stringify(obj));
1861
+ };
1862
+ const mutationFiles = (body: Record<string, unknown>, result?: HandlerResult): string[] => {
1863
+ const files = new Set<string>();
1864
+ const suppliedFile = body['file'];
1865
+ if (typeof suppliedFile === 'string') files.add(suppliedFile.split('?')[0] ?? suppliedFile);
1866
+ const oid = body['oid'];
1867
+ if (typeof oid === 'string') {
1868
+ const entry = store.index.get(oid);
1869
+ if (entry) files.add(entry.file);
1870
+ }
1871
+ const oids = body['oids'];
1872
+ if (Array.isArray(oids)) {
1873
+ for (const candidate of oids) {
1874
+ if (typeof candidate !== 'string') continue;
1875
+ const entry = store.index.get(candidate);
1876
+ if (entry) files.add(entry.file);
1877
+ }
1878
+ }
1879
+ const response = result?.body as Record<string, unknown> | undefined;
1880
+ for (const key of ['file', 'newFile', 'callsiteFile']) {
1881
+ const file = response?.[key];
1882
+ if (typeof file === 'string') files.add(file);
1883
+ }
1884
+ return [...files];
1885
+ };
1886
+ const resourcePath = (file: string) =>
1887
+ sourceResourcePath(
1888
+ isAbsolute(file) ? file : resolve(activeProjectRoot(), file),
1889
+ activeProjectRoot(),
1890
+ );
1891
+ /**
1892
+ * Record this write with the collaboration session and REPORT THE
1893
+ * REVISION IT PRODUCED.
1894
+ *
1895
+ * The revision is what the next mutation's optimistic-concurrency guard
1896
+ * (`assertSourceMutation`, below) compares against, and the client only
1897
+ * learned it from the collaboration SSE stream — one event loop later.
1898
+ * A gesture that writes the same file TWICE therefore sent a revision
1899
+ * that its own first write had already superseded, and the second write
1900
+ * was rejected: measured on a canvas transform drag, whose `position`
1901
+ * channel is two props (`x` and `y`) by construction, so EVERY drag lost
1902
+ * its whole gesture to the compensation path. An R3F pivot-rotate
1903
+ * (position + rotation) has the same shape.
1904
+ *
1905
+ * Returning it here closes the window without a second channel: the
1906
+ * writer already awaits this response, so adopting the number it carries
1907
+ * is exactly as fresh as the write itself (`source-write-backend.ts`'s
1908
+ * `postJson`).
1909
+ */
1910
+ const recordEditorMutation = (
1911
+ participantId: string,
1912
+ files: readonly string[],
1913
+ ): number | undefined => {
1914
+ const resources = files.map((file) => {
1915
+ const absolute = isAbsolute(file) ? file : resolve(activeProjectRoot(), file);
1916
+ return {
1917
+ path: resourcePath(absolute),
1918
+ sha: existsSync(absolute) ? sha256(readFileSync(absolute, 'utf8')).slice(0, 16) : null,
1919
+ };
1920
+ });
1921
+ if (resources.length === 0) return undefined;
1922
+ const session = collaborationSession(activeProjectRoot());
1923
+ // `null` means the bytes were already recorded (a filesystem observer
1924
+ // won the race) — the session's CURRENT revision is still the honest
1925
+ // answer to "what must the next write expect".
1926
+ return (
1927
+ session.recordSourceMutation({ authorId: participantId, source: 'editor', resources })
1928
+ ?.revision ?? session.snapshot().revision
1929
+ );
1930
+ };
1931
+ // Single middleware switching on the URL — avoids connect prefix-matching subtleties.
1932
+ // Each POST endpoint delegates to a small module-level handler (below) so this
1933
+ // dispatcher stays flat; the try/catch + JSON plumbing lives here once.
1934
+ const post = async (
1935
+ req: import('node:http').IncomingMessage,
1936
+ res: import('node:http').ServerResponse,
1937
+ url: string,
1938
+ handler: (body: Record<string, unknown>) => HandlerResult,
1939
+ ): Promise<void> => {
1940
+ try {
1941
+ const body = await readJson(req);
1942
+ const claimedParticipantId = body['participantId'];
1943
+ const trustedParticipantId = req.headers['x-vgai-share-participant-id'];
1944
+ const trustedRole = req.headers['x-vgai-share-role'];
1945
+ if (
1946
+ typeof trustedParticipantId === 'string' &&
1947
+ typeof claimedParticipantId === 'string' &&
1948
+ trustedParticipantId !== claimedParticipantId
1949
+ ) {
1950
+ throw new Error(
1951
+ 'The authenticated share participant does not match the source mutation author.',
1952
+ );
1953
+ }
1954
+ const participantId =
1955
+ typeof trustedParticipantId === 'string' ? trustedParticipantId : claimedParticipantId;
1956
+ const expectedRevision = body['expectedRevision'];
1957
+ const readOnlySourceRequest =
1958
+ url === '/__ui-source/prepare' || url === '/__ui-source/read';
1959
+ const beforeFiles = mutationFiles(body);
1960
+ if (
1961
+ typeof trustedParticipantId === 'string' &&
1962
+ beforeFiles.some((file) => !isCanonicalProjectSourcePath(activeProjectRoot(), file))
1963
+ ) {
1964
+ throw new Error('Shared source path leaves the project root.');
1965
+ }
1966
+ if (!readOnlySourceRequest && beforeFiles.length > 0) {
1967
+ if (typeof participantId !== 'string' || !Number.isInteger(expectedRevision)) {
1968
+ throw new Error('Source mutations require participantId and expectedRevision.');
1969
+ }
1970
+ if (
1971
+ typeof trustedRole === 'string' &&
1972
+ trustedRole !== 'editor' &&
1973
+ trustedRole !== 'terminal' &&
1974
+ trustedRole !== 'maintainer'
1975
+ ) {
1976
+ throw new Error(`The ${trustedRole} share role cannot edit source.`);
1977
+ }
1978
+ collaborationSession(activeProjectRoot()).assertSourceMutation(
1979
+ participantId,
1980
+ expectedRevision as number,
1981
+ beforeFiles.map(resourcePath),
1982
+ );
1983
+ }
1984
+ const result = handler(body);
1985
+ const response = result.body as Record<string, unknown>;
1986
+ const changed =
1987
+ response['changed'] === true ||
1988
+ response['applied'] === true ||
1989
+ response['restored'] === true ||
1990
+ ((url === '/__ui-source/fork-component' || url === '/__ui-source/extract-component') &&
1991
+ response['ok'] === true) ||
1992
+ (url === '/__ui-source/create-file' && response['created'] === true) ||
1993
+ (url === '/__ui-source/csf-story' && response['changed'] === true);
1994
+ const revision =
1995
+ !readOnlySourceRequest && changed && typeof participantId === 'string'
1996
+ ? recordEditorMutation(participantId, mutationFiles(body, result))
1997
+ : undefined;
1998
+ json(
1999
+ res,
2000
+ revision === undefined ? result.body : { ...response, revision },
2001
+ result.status ?? 200,
2002
+ );
2003
+ } catch (e) {
2004
+ json(
2005
+ res,
2006
+ { changed: false, error: e instanceof Error ? e.message : String(e) },
2007
+ e instanceof CollaborationConflictError ? 409 : 400,
2008
+ );
2009
+ }
2010
+ };
2011
+ // POST route table — keeps the dispatcher flat (one lookup, no per-endpoint branch).
2012
+ const postRoutes: Record<string, (body: Record<string, unknown>) => HandlerResult> = {
2013
+ '/__ui-source/prepare': (b) => handlePrepare(store, b, activeProjectRoot(), engineRoot),
2014
+ '/__ui-source/read': (b) => handleSourceRead(b, activeProjectRoot(), engineRoot),
2015
+ '/__ui-source/apply': (b) => handleSourceApply(b, activeProjectRoot(), engineRoot),
2016
+ '/__ui-source/write': (b) => handleWrite(store, b, activeProjectRoot(), engineRoot),
2017
+ '/__ui-source/css': (b) => handleCss(b, engineRoot),
2018
+ '/__ui-source/text': (b) => handleText(store, b, engineRoot),
2019
+ '/__ui-source/prop': (b) => handleProp(store, b, engineRoot),
2020
+ '/__ui-source/struct': (b) => handleStruct(store, b, engineRoot),
2021
+ '/__ui-source/struct-many': (b) => handleStructMany(store, b, engineRoot),
2022
+ '/__ui-source/create-file': (b) => handleCreateSourceFile(b, activeProjectRoot()),
2023
+ '/__ui-source/csf-story': (b) => handleCsfStory(b, activeProjectRoot()),
2024
+ '/__ui-source/named-style': (b) => handleNamedStyle(store, b, engineRoot),
2025
+ '/__ui-source/fork-component': (b) =>
2026
+ handleForkComponent(store, b, activeProjectRoot(), engineRoot),
2027
+ '/__ui-source/extract-component': (b) =>
2028
+ handleExtractComponent(store, b, activeProjectRoot(), engineRoot),
2029
+ };
2030
+ server.middlewares.use(async (req, res, next) => {
2031
+ const url = (req.url ?? '').split('?')[0] ?? '';
2032
+ if (url === '/__ui-source/index') {
2033
+ // Re-resolve contracts from current project bytes on every index
2034
+ // read — but OFF the request: a cold enrich is a 2.6 s ts.Program
2035
+ // and must not freeze first paint. The handler returns the last
2036
+ // enriched map (or the raw stamp) immediately; see currentOidIndex.
2037
+ //
2038
+ // H5 (wave 4): the R3F
2039
+ // authorability DIAGNOSTICS ride this same response, per entry.
2040
+ // Deliberately not a channel of their own — attaching them to the
2041
+ // payload the R3F adapter's `refreshSourceState()` already fetches is
2042
+ // what makes their freshness structurally identical to the index's,
2043
+ // with no second fetch, no polling loop and no cache to invalidate.
2044
+
2045
+ const indexStarted = Date.now();
2046
+ const index = currentOidIndex(store, activeProjectRoot(), server);
2047
+ res.setHeader('Server-Timing', `index;dur=${Date.now() - indexStarted}`);
2048
+ res.setHeader(
2049
+ 'X-Vgai-Oid-Index-Enriched',
2050
+ index === lastEnrichedIndex?.index ? '1' : '0',
2051
+ );
2052
+ if (typeof req.headers['x-vgai-share-participant-id'] !== 'string') {
2053
+ return json(res, index);
2054
+ }
2055
+ return json(
2056
+ res,
2057
+ Object.fromEntries(
2058
+ Object.entries(index).filter(([, value]) => {
2059
+ const file = (value as { file?: unknown } | null)?.file;
2060
+ return (
2061
+ typeof file === 'string' &&
2062
+ isCanonicalProjectSourcePath(activeProjectRoot(), file)
2063
+ );
2064
+ }),
2065
+ ),
2066
+ );
2067
+ }
2068
+ if (url === '/__ui-source/component-props' && req.method === 'GET') {
2069
+ // THE PER-DEFINITION DOOR: the props a prefab DECLARES, asked before
2070
+ // any callsite exists. The index above only knows tags already used
2071
+ // in a file; a drop needs the declaration itself so a REQUIRED enum
2072
+ // prop can be written with its first option instead of nothing
2073
+ // (`<WeaponPickup>` rendered NOTHING until `kind` was set — runhuman
2074
+ // pass 123). Same worker, same checker, one request.
2075
+ const params = new URL(req.url ?? '', 'http://localhost').searchParams;
2076
+ const file = params.get('file') ?? '';
2077
+ const name = params.get('name') ?? '';
2078
+ const root = activeProjectRoot();
2079
+ if (!root || !file || !name) return json(res, { props: [] });
2080
+ const absolute = isAbsolute(file) ? resolve(file) : resolve(root, file);
2081
+ if (
2082
+ !isCanonicalProjectSourcePath(root, absolute) ||
2083
+ resolveEditableSourceFile(absolute, root) === null
2084
+ ) {
2085
+ return json(res, { props: [] });
2086
+ }
2087
+ const key = `${absolute}::${name}::definition`;
2088
+ try {
2089
+ const props = await resolveDeclaredProps(root, [
2090
+ { key, file: absolute, tag: name, definition: true },
2091
+ ]);
2092
+ return json(res, { props: props.get(key) ?? [] });
2093
+ } catch (error) {
2094
+ return json(res, {
2095
+ props: [],
2096
+ error: error instanceof Error ? error.message : String(error),
2097
+ });
2098
+ }
2099
+ }
2100
+ const handler = req.method === 'POST' ? postRoutes[url] : undefined;
2101
+ if (handler) return post(req, res, url, handler);
2102
+ next();
2103
+ });
2104
+ },
2105
+ };
2106
+ }