@vgai/editor 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (519) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +35 -0
  3. package/dist/a/b/c/test-nested.json +1 -0
  4. package/dist/assets/CCDIKSolver-Dz-ysxZF.js +1 -0
  5. package/dist/assets/SelectionBox-BXsv_6aH.js +1 -0
  6. package/dist/assets/browser-project-scripts-DOru4L1x.js +2 -0
  7. package/dist/assets/browserAll-CEkWYlFk.js +1 -0
  8. package/dist/assets/collision-world-DniMB0qq.glb +0 -0
  9. package/dist/assets/create-runtime-DYIWr3va.js +1 -0
  10. package/dist/assets/data-asset-Bb9sIM7i.js +5 -0
  11. package/dist/assets/esbuild-BHljloGq.wasm +0 -0
  12. package/dist/assets/fake-three-BMTQ9rl_.js +1 -0
  13. package/dist/assets/game-8SDZPPgH.js +1 -0
  14. package/dist/assets/game-BI111o5f.js +10 -0
  15. package/dist/assets/game-CevorxyV.js +1 -0
  16. package/dist/assets/game-Di1H0gFj.js +1 -0
  17. package/dist/assets/game-Di7XlywD.js +1 -0
  18. package/dist/assets/game-DjnCaw8R.js +1 -0
  19. package/dist/assets/game-Q9A0aXSV.js +1 -0
  20. package/dist/assets/game-pt_1lQLv.js +1 -0
  21. package/dist/assets/game-source-CvidN7AB.js +1 -0
  22. package/dist/assets/game-xsOho_wg.js +83 -0
  23. package/dist/assets/index-Bp5cecD6.js +12 -0
  24. package/dist/assets/index-CPVV6pjw.js +1 -0
  25. package/dist/assets/index-CpOnYyeD.js +213 -0
  26. package/dist/assets/index-Cs8xwv3e.js +1 -0
  27. package/dist/assets/index-DL7RcQ0n.js +27 -0
  28. package/dist/assets/index-Dr8H_6Rl.js +1 -0
  29. package/dist/assets/index-_bCaipUk.js +13650 -0
  30. package/dist/assets/ingest-siblings-Dz8eEqrV.js +1 -0
  31. package/dist/assets/mount-manifest-Bhk0ohsl.js +1 -0
  32. package/dist/assets/recast-navigation.wasm-compat-CvP4UGK6.js +1 -0
  33. package/dist/assets/scene-query-BivAHhJu.js +1 -0
  34. package/dist/assets/stats.module--VATS4Kh.js +1 -0
  35. package/dist/assets/webworkerAll-BLrH0F6F.js +1 -0
  36. package/dist/assets/xstate-animation-binding-1NGLmw6y.js +1 -0
  37. package/dist/assets/xstate.esm-BgUD33nl.js +3 -0
  38. package/dist/index.html +37 -0
  39. package/dist/ingest/racing-game/UPSTREAM.md +68 -0
  40. package/dist/ingest/racing-game/a/Inter-Black.woff +0 -0
  41. package/dist/ingest/racing-game/a/Inter-Black.woff2 +0 -0
  42. package/dist/ingest/racing-game/a/Inter-BlackItalic.woff +0 -0
  43. package/dist/ingest/racing-game/a/Inter-BlackItalic.woff2 +0 -0
  44. package/dist/ingest/racing-game/a/Inter-Bold.woff +0 -0
  45. package/dist/ingest/racing-game/a/Inter-Bold.woff2 +0 -0
  46. package/dist/ingest/racing-game/a/Inter-BoldItalic.woff +0 -0
  47. package/dist/ingest/racing-game/a/Inter-BoldItalic.woff2 +0 -0
  48. package/dist/ingest/racing-game/a/Inter-ExtraBold.woff +0 -0
  49. package/dist/ingest/racing-game/a/Inter-ExtraBold.woff2 +0 -0
  50. package/dist/ingest/racing-game/a/Inter-ExtraBoldItalic.woff +0 -0
  51. package/dist/ingest/racing-game/a/Inter-ExtraBoldItalic.woff2 +0 -0
  52. package/dist/ingest/racing-game/a/Inter-ExtraLight.woff +0 -0
  53. package/dist/ingest/racing-game/a/Inter-ExtraLight.woff2 +0 -0
  54. package/dist/ingest/racing-game/a/Inter-ExtraLightItalic.woff +0 -0
  55. package/dist/ingest/racing-game/a/Inter-ExtraLightItalic.woff2 +0 -0
  56. package/dist/ingest/racing-game/a/Inter-Italic.woff +0 -0
  57. package/dist/ingest/racing-game/a/Inter-Italic.woff2 +0 -0
  58. package/dist/ingest/racing-game/a/Inter-Light.woff +0 -0
  59. package/dist/ingest/racing-game/a/Inter-Light.woff2 +0 -0
  60. package/dist/ingest/racing-game/a/Inter-LightItalic.woff +0 -0
  61. package/dist/ingest/racing-game/a/Inter-LightItalic.woff2 +0 -0
  62. package/dist/ingest/racing-game/a/Inter-Medium.woff +0 -0
  63. package/dist/ingest/racing-game/a/Inter-Medium.woff2 +0 -0
  64. package/dist/ingest/racing-game/a/Inter-MediumItalic.woff +0 -0
  65. package/dist/ingest/racing-game/a/Inter-MediumItalic.woff2 +0 -0
  66. package/dist/ingest/racing-game/a/Inter-Regular.woff +0 -0
  67. package/dist/ingest/racing-game/a/Inter-Regular.woff2 +0 -0
  68. package/dist/ingest/racing-game/a/Inter-SemiBold.woff +0 -0
  69. package/dist/ingest/racing-game/a/Inter-SemiBold.woff2 +0 -0
  70. package/dist/ingest/racing-game/a/Inter-SemiBoldItalic.woff +0 -0
  71. package/dist/ingest/racing-game/a/Inter-SemiBoldItalic.woff2 +0 -0
  72. package/dist/ingest/racing-game/a/Inter-Thin.woff +0 -0
  73. package/dist/ingest/racing-game/a/Inter-Thin.woff2 +0 -0
  74. package/dist/ingest/racing-game/a/Inter-ThinItalic.woff +0 -0
  75. package/dist/ingest/racing-game/a/Inter-ThinItalic.woff2 +0 -0
  76. package/dist/ingest/racing-game/a/Inter-italic.var.woff2 +0 -0
  77. package/dist/ingest/racing-game/a/Inter-roman.var.woff2 +0 -0
  78. package/dist/ingest/racing-game/a/Inter.var.woff2 +0 -0
  79. package/dist/ingest/racing-game/a/index.css +1 -0
  80. package/dist/ingest/racing-game/images/bronze.png +0 -0
  81. package/dist/ingest/racing-game/images/gold.png +0 -0
  82. package/dist/ingest/racing-game/images/silver.png +0 -0
  83. package/dist/ingest/racing-game/index.html +15 -0
  84. package/dist/ingest/racing-game/models/chassis-draco.glb +0 -0
  85. package/dist/ingest/racing-game/models/track-draco.glb +0 -0
  86. package/dist/ingest/racing-game/models/wheel-draco.glb +0 -0
  87. package/dist/ingest/racing-game/rg-entry.js +1 -0
  88. package/dist/ingest/racing-game/sounds/accelerate.mp3 +0 -0
  89. package/dist/ingest/racing-game/sounds/boost.mp3 +0 -0
  90. package/dist/ingest/racing-game/sounds/crash.mp3 +0 -0
  91. package/dist/ingest/racing-game/sounds/engine.mp3 +0 -0
  92. package/dist/ingest/racing-game/sounds/honk.mp3 +0 -0
  93. package/dist/ingest/racing-game/sounds/tire-brake.mp3 +0 -0
  94. package/dist/ingest/racing-game/sounds/train.mp3 +0 -0
  95. package/dist/ingest/racing-game/sounds/water.mp3 +0 -0
  96. package/dist/ingest/racing-game/textures/cursor.svg +3 -0
  97. package/dist/ingest/racing-game/textures/dikhololo_night_1k.hdr +0 -0
  98. package/dist/ingest/racing-game/textures/heightmap_1024.png +0 -0
  99. package/dist/ingest/racing-game/textures/mask.svg +11 -0
  100. package/dist/ingest/racing-game/textures/no_sound.png +0 -0
  101. package/dist/ingest/racing-game/three-r139.module.js +50617 -0
  102. package/dist/ingest/racing-game/vendor.js +179 -0
  103. package/dist/ingest/racing-game/vgai.game.json +20 -0
  104. package/dist/ingest/simcity/UPSTREAM.md +65 -0
  105. package/dist/ingest/simcity/favicon.ico +0 -0
  106. package/dist/ingest/simcity/fonts/Bebas-Regular.otf +0 -0
  107. package/dist/ingest/simcity/fonts/Digitalt.otf +0 -0
  108. package/dist/ingest/simcity/fonts/Stanberry.ttf +0 -0
  109. package/dist/ingest/simcity/icons/bulldozer-color.png +0 -0
  110. package/dist/ingest/simcity/icons/bulldozer.png +0 -0
  111. package/dist/ingest/simcity/icons/calendar.png +0 -0
  112. package/dist/ingest/simcity/icons/factory-color.png +0 -0
  113. package/dist/ingest/simcity/icons/factory.png +0 -0
  114. package/dist/ingest/simcity/icons/house-color.png +0 -0
  115. package/dist/ingest/simcity/icons/house.png +0 -0
  116. package/dist/ingest/simcity/icons/job.png +0 -0
  117. package/dist/ingest/simcity/icons/office.png +0 -0
  118. package/dist/ingest/simcity/icons/pause-color.png +0 -0
  119. package/dist/ingest/simcity/icons/pause.png +0 -0
  120. package/dist/ingest/simcity/icons/person.png +0 -0
  121. package/dist/ingest/simcity/icons/play-color.png +0 -0
  122. package/dist/ingest/simcity/icons/play.png +0 -0
  123. package/dist/ingest/simcity/icons/power-color.png +0 -0
  124. package/dist/ingest/simcity/icons/power-line-color.png +0 -0
  125. package/dist/ingest/simcity/icons/power.png +0 -0
  126. package/dist/ingest/simcity/icons/road-color.png +0 -0
  127. package/dist/ingest/simcity/icons/road.png +0 -0
  128. package/dist/ingest/simcity/icons/select-color.png +0 -0
  129. package/dist/ingest/simcity/icons/select.png +0 -0
  130. package/dist/ingest/simcity/icons/store-color.png +0 -0
  131. package/dist/ingest/simcity/index.html +94 -0
  132. package/dist/ingest/simcity/index.js +58 -0
  133. package/dist/ingest/simcity/main.css +233 -0
  134. package/dist/ingest/simcity/models/armored-truck.glb +0 -0
  135. package/dist/ingest/simcity/models/building-block-4floor-corner.glb +0 -0
  136. package/dist/ingest/simcity/models/building-block-4floor-short.glb +0 -0
  137. package/dist/ingest/simcity/models/building-block-5floor.glb +0 -0
  138. package/dist/ingest/simcity/models/building-burger-joint.glb +0 -0
  139. package/dist/ingest/simcity/models/building-cafe.glb +0 -0
  140. package/dist/ingest/simcity/models/building-casino.glb +0 -0
  141. package/dist/ingest/simcity/models/building-cinema.glb +0 -0
  142. package/dist/ingest/simcity/models/building-house-block-big.glb +0 -0
  143. package/dist/ingest/simcity/models/building-house-family-large.glb +0 -0
  144. package/dist/ingest/simcity/models/building-house-family-small.glb +0 -0
  145. package/dist/ingest/simcity/models/building-office-balcony.glb +0 -0
  146. package/dist/ingest/simcity/models/building-office-big.glb +0 -0
  147. package/dist/ingest/simcity/models/building-office-pyramid.glb +0 -0
  148. package/dist/ingest/simcity/models/building-office-tall.glb +0 -0
  149. package/dist/ingest/simcity/models/building-office.glb +0 -0
  150. package/dist/ingest/simcity/models/building-restaurant.glb +0 -0
  151. package/dist/ingest/simcity/models/building-skyscraper.glb +0 -0
  152. package/dist/ingest/simcity/models/car-ambulance-pickup.glb +0 -0
  153. package/dist/ingest/simcity/models/car-baywatch.glb +0 -0
  154. package/dist/ingest/simcity/models/car-hippie-van.glb +0 -0
  155. package/dist/ingest/simcity/models/car-passenger-race.glb +0 -0
  156. package/dist/ingest/simcity/models/car-passenger.glb +0 -0
  157. package/dist/ingest/simcity/models/car-police.glb +0 -0
  158. package/dist/ingest/simcity/models/car-taxi.glb +0 -0
  159. package/dist/ingest/simcity/models/car-tow-truck.glb +0 -0
  160. package/dist/ingest/simcity/models/car-truck-dump.glb +0 -0
  161. package/dist/ingest/simcity/models/car-veteran.glb +0 -0
  162. package/dist/ingest/simcity/models/construction-small.glb +0 -0
  163. package/dist/ingest/simcity/models/data-center.glb +0 -0
  164. package/dist/ingest/simcity/models/industry-factory-old.glb +0 -0
  165. package/dist/ingest/simcity/models/industry-factory.glb +0 -0
  166. package/dist/ingest/simcity/models/industry-refinery.glb +0 -0
  167. package/dist/ingest/simcity/models/industry-warehouse.glb +0 -0
  168. package/dist/ingest/simcity/models/power_line_pole_modified.glb +0 -0
  169. package/dist/ingest/simcity/models/tile-plain_grass.glb +0 -0
  170. package/dist/ingest/simcity/models/tile-road-curve.glb +0 -0
  171. package/dist/ingest/simcity/models/tile-road-end.glb +0 -0
  172. package/dist/ingest/simcity/models/tile-road-intersection-t.glb +0 -0
  173. package/dist/ingest/simcity/models/tile-road-intersection.glb +0 -0
  174. package/dist/ingest/simcity/models/tile-road-straight.glb +0 -0
  175. package/dist/ingest/simcity/models/truck.glb +0 -0
  176. package/dist/ingest/simcity/statusIcons/no-power.png +0 -0
  177. package/dist/ingest/simcity/statusIcons/no-road-access.png +0 -0
  178. package/dist/ingest/simcity/textures/base.png +0 -0
  179. package/dist/ingest/simcity/textures/grid.png +0 -0
  180. package/dist/ingest/simcity/textures/specular.png +0 -0
  181. package/dist/ingest/simcity/three-r155.module.js +51466 -0
  182. package/dist/ingest/simcity/vgai.game.json +20 -0
  183. package/dist/ingest/tanks/UPSTREAM.md +69 -0
  184. package/dist/ingest/tanks/index.html +214 -0
  185. package/dist/ingest/tanks/models/pixelTank.fbx +0 -0
  186. package/dist/ingest/tanks/tanks-entry.js +92 -0
  187. package/dist/ingest/tanks/three-r170.module.js +54571 -0
  188. package/dist/ingest/tanks/vgai.game.json +20 -0
  189. package/dist/inputmaps/default.inputmap.json +41 -0
  190. package/dist/jsm/libs/draco/gltf/draco_decoder.js +33 -0
  191. package/dist/jsm/libs/draco/gltf/draco_decoder.wasm +0 -0
  192. package/dist/jsm/libs/draco/gltf/draco_wasm_wrapper.js +116 -0
  193. package/dist/scenes/untitled.vscn.json +29 -0
  194. package/dist/textures/waternormals.jpg +0 -0
  195. package/dist-server/packaged.mjs +18155 -0
  196. package/package.json +36 -0
  197. package/server/asset-library-routes.ts +833 -0
  198. package/server/canonical-path.ts +50 -0
  199. package/server/cloud-asset-catalog.ts +101 -0
  200. package/server/dev.ts +350 -0
  201. package/server/editor-server.ts +1537 -0
  202. package/server/editor-sse.ts +73 -0
  203. package/server/game-globals-shadow.ts +58 -0
  204. package/server/local-asset-catalog.ts +163 -0
  205. package/server/packaged.ts +571 -0
  206. package/server/prod.ts +75 -0
  207. package/server/project-optimize-deps-entries.ts +149 -0
  208. package/server/project-view.ts +65 -0
  209. package/server/server-utils.ts +430 -0
  210. package/server/session-registry.ts +93 -0
  211. package/src/EditorContext.tsx +87 -0
  212. package/src/achieved-tier.ts +126 -0
  213. package/src/action-registry.ts +268 -0
  214. package/src/adapter-resolver.ts +1858 -0
  215. package/src/asset-events.ts +100 -0
  216. package/src/authoring/active-adapter.ts +98 -0
  217. package/src/authoring/active-systems.ts +50 -0
  218. package/src/authoring/boundary-authoring-adapter.ts +178 -0
  219. package/src/authoring/composite-authoring-adapter.ts +891 -0
  220. package/src/authoring/design-time-layers.ts +776 -0
  221. package/src/authoring/edit-mode-authoring.ts +643 -0
  222. package/src/authoring/embed-only-adapter.ts +57 -0
  223. package/src/authoring/ephemeral-persistence.ts +26 -0
  224. package/src/authoring/eyedropper-session.ts +69 -0
  225. package/src/authoring/first-party-hierarchy-menu.ts +74 -0
  226. package/src/authoring/first-party-inspector/AddSectionMenu.tsx +118 -0
  227. package/src/authoring/first-party-inspector/EnvironmentSection.tsx +550 -0
  228. package/src/authoring/first-party-inspector/HeaderSection.tsx +329 -0
  229. package/src/authoring/first-party-inspector/is-first-party-selection.ts +44 -0
  230. package/src/authoring/first-party-inspector/register.tsx +108 -0
  231. package/src/authoring/ingest-authoring-adapter.ts +475 -0
  232. package/src/authoring/ingest-capture.ts +94 -0
  233. package/src/authoring/ingest-game-adapter.ts +291 -0
  234. package/src/authoring/ingest-iframe-adapter.ts +38 -0
  235. package/src/authoring/ingest-iframe-reachable-adapter.ts +401 -0
  236. package/src/authoring/ingest-overlay.ts +193 -0
  237. package/src/authoring/layered-pick.ts +87 -0
  238. package/src/authoring/mount-failure-report.ts +112 -0
  239. package/src/authoring/overlay-report.ts +52 -0
  240. package/src/authoring/project-scenes.ts +111 -0
  241. package/src/authoring/react-dom-authoring-adapter.ts +795 -0
  242. package/src/authoring/react-inspector-section.tsx +1041 -0
  243. package/src/authoring/react-world-authoring-adapter.ts +2400 -0
  244. package/src/authoring/root-activation.ts +61 -0
  245. package/src/authoring/scene-ui-layer.ts +485 -0
  246. package/src/authoring/story-overlay.ts +48 -0
  247. package/src/authoring/ui-authoring-adapter.ts +325 -0
  248. package/src/authoring/ui-hierarchy-menu.ts +40 -0
  249. package/src/authoring/ui-inspector-sections.tsx +156 -0
  250. package/src/authoring/undo-timeline.ts +145 -0
  251. package/src/authoring/vgai-scene-authoring-adapter.ts +746 -0
  252. package/src/authoring/viewport-pick-context.ts +31 -0
  253. package/src/authoring/viewport-raycast.ts +80 -0
  254. package/src/authoring/world-overlay.ts +76 -0
  255. package/src/authoring/world-pan-state.ts +115 -0
  256. package/src/authoring/world-session-state.ts +102 -0
  257. package/src/authoring/world2d-authoring-adapter.ts +313 -0
  258. package/src/browser-external-watch.ts +98 -0
  259. package/src/browser-play-entry.ts +112 -0
  260. package/src/browser-project-scripts.ts +112 -0
  261. package/src/browser-transpile.ts +246 -0
  262. package/src/browser-watch-reload.ts +100 -0
  263. package/src/command-listener.ts +326 -0
  264. package/src/components/AlignToolbar.tsx +189 -0
  265. package/src/components/AppRoot.tsx +73 -0
  266. package/src/components/AssetBrowser.tsx +502 -0
  267. package/src/components/BottomPanel.tsx +326 -0
  268. package/src/components/BuildPanel.tsx +455 -0
  269. package/src/components/CameraInfo.tsx +56 -0
  270. package/src/components/CommandPalette.tsx +265 -0
  271. package/src/components/ConsolePanel.tsx +272 -0
  272. package/src/components/DataPanel.tsx +1268 -0
  273. package/src/components/DefaultEditorLayout.tsx +509 -0
  274. package/src/components/FolderTree.tsx +179 -0
  275. package/src/components/GameHierarchy.tsx +1349 -0
  276. package/src/components/Inspector.tsx +671 -0
  277. package/src/components/InspectorToolSection.tsx +74 -0
  278. package/src/components/MountFailureBanner.tsx +110 -0
  279. package/src/components/NetworkSection.tsx +109 -0
  280. package/src/components/OnlineAssetBrowser.tsx +448 -0
  281. package/src/components/OverlayReportBanner.tsx +122 -0
  282. package/src/components/PlayBar.tsx +356 -0
  283. package/src/components/PrefabSaveDialog.tsx +201 -0
  284. package/src/components/ProjectHeader.tsx +174 -0
  285. package/src/components/ProjectScreen.tsx +1004 -0
  286. package/src/components/RightPanel.tsx +166 -0
  287. package/src/components/RootActivateDialog.tsx +105 -0
  288. package/src/components/SaveDialog.tsx +197 -0
  289. package/src/components/SaveStatus.tsx +69 -0
  290. package/src/components/StatsOverlay.tsx +35 -0
  291. package/src/components/ToolHost.tsx +80 -0
  292. package/src/components/Toolbar.tsx +307 -0
  293. package/src/components/ViewportOverlay.tsx +234 -0
  294. package/src/components/ViewportPanel.tsx +369 -0
  295. package/src/components/WorldContextMenu.tsx +257 -0
  296. package/src/components/WorldSelectionOverlay.tsx +2003 -0
  297. package/src/components/WorldTextEditor.tsx +86 -0
  298. package/src/components/asset-viewers/AssetViewer.tsx +38 -0
  299. package/src/components/asset-viewers/AudioViewer.tsx +149 -0
  300. package/src/components/asset-viewers/GenericJsonViewer.tsx +77 -0
  301. package/src/components/asset-viewers/ImageViewer.tsx +65 -0
  302. package/src/components/asset-viewers/InputMapViewer.tsx +206 -0
  303. package/src/components/asset-viewers/ModelViewer.tsx +180 -0
  304. package/src/components/asset-viewers/OnlineAssetDetail.tsx +236 -0
  305. package/src/components/data-csv.ts +301 -0
  306. package/src/components/entity-name-commit.ts +18 -0
  307. package/src/components/inspector-property-grouping.ts +65 -0
  308. package/src/components/inspector-widgets/AlignmentGrid.tsx +188 -0
  309. package/src/components/inspector-widgets/BorderEditor.tsx +305 -0
  310. package/src/components/inspector-widgets/ColorPicker.tsx +548 -0
  311. package/src/components/inspector-widgets/FilterEditor.tsx +108 -0
  312. package/src/components/inspector-widgets/FontPicker.tsx +196 -0
  313. package/src/components/inspector-widgets/GradientEditor.tsx +277 -0
  314. package/src/components/inspector-widgets/ScrubbableInput.tsx +152 -0
  315. package/src/components/inspector-widgets/ShadowEditor.tsx +332 -0
  316. package/src/components/inspector-widgets/color-utils.ts +202 -0
  317. package/src/components/inspector-widgets/gallery-main.tsx +167 -0
  318. package/src/components/inspector-widgets/index.ts +22 -0
  319. package/src/components/inspector-widgets/shared.tsx +135 -0
  320. package/src/components/inspectors/AnimationSection.tsx +143 -0
  321. package/src/components/inspectors/AudioSection.tsx +215 -0
  322. package/src/components/inspectors/BoneAttachmentSection.tsx +82 -0
  323. package/src/components/inspectors/CameraSection.tsx +105 -0
  324. package/src/components/inspectors/ComponentsSection.tsx +358 -0
  325. package/src/components/inspectors/JointsSection.tsx +255 -0
  326. package/src/components/inspectors/LightSection.tsx +116 -0
  327. package/src/components/inspectors/MaterialSection.tsx +1189 -0
  328. package/src/components/inspectors/MeshSection.tsx +99 -0
  329. package/src/components/inspectors/NavigationSection.tsx +58 -0
  330. package/src/components/inspectors/ParticleSection.tsx +681 -0
  331. package/src/components/inspectors/PhysicsSection.tsx +326 -0
  332. package/src/components/inspectors/PostProcessingSection.tsx +1290 -0
  333. package/src/components/inspectors/RenderingSection.tsx +109 -0
  334. package/src/components/inspectors/ShadowSection.tsx +84 -0
  335. package/src/components/inspectors/SplineSection.tsx +191 -0
  336. package/src/components/inspectors/TransformSection.tsx +191 -0
  337. package/src/components/inspectors/json-schema-fields.ts +281 -0
  338. package/src/components/inspectors/multi-edit-utils.ts +49 -0
  339. package/src/components/inspectors/zod-fields.ts +184 -0
  340. package/src/components/primitives/ColorInput.tsx +109 -0
  341. package/src/components/primitives/JsonInput.tsx +98 -0
  342. package/src/components/primitives/NumberInput.tsx +164 -0
  343. package/src/components/primitives/Panel.tsx +68 -0
  344. package/src/components/primitives/SectionHeader.tsx +45 -0
  345. package/src/components/primitives/Tooltip.tsx +82 -0
  346. package/src/components/primitives/Vec3Input.tsx +62 -0
  347. package/src/components/world-overlay-gestures.ts +1438 -0
  348. package/src/doctor/data-checks.ts +285 -0
  349. package/src/doctor/report.ts +386 -0
  350. package/src/editor-api.ts +700 -0
  351. package/src/editor-console.ts +194 -0
  352. package/src/editor-hotkeys.ts +441 -0
  353. package/src/editor-layers.ts +2 -0
  354. package/src/editor-mode.ts +28 -0
  355. package/src/editor-store.ts +2225 -0
  356. package/src/editor-viewport.ts +1924 -0
  357. package/src/entity-factory.ts +221 -0
  358. package/src/game-globals-prelude.ts +27 -0
  359. package/src/gated-globals.ts +131 -0
  360. package/src/generated/setup-by-example.generated.ts +53 -0
  361. package/src/gizmo-registry.ts +608 -0
  362. package/src/handle-system.ts +221 -0
  363. package/src/hierarchy-menu-registry.ts +70 -0
  364. package/src/hierarchy-rows.ts +73 -0
  365. package/src/hosted-example.ts +160 -0
  366. package/src/hotkeys.ts +142 -0
  367. package/src/ingest/discovery-react.ts +132 -0
  368. package/src/ingest/discovery.ts +205 -0
  369. package/src/ingest/discovery2d.ts +204 -0
  370. package/src/ingest/esm-entry-resolution.ts +102 -0
  371. package/src/ingest/games/bloom-composer/game.js +99 -0
  372. package/src/ingest/games/bloom-composer/vgai.game.json +21 -0
  373. package/src/ingest/games/embed-bundle/embed.html +10 -0
  374. package/src/ingest/games/embed-bundle/vgai.game.json +21 -0
  375. package/src/ingest/games/games-fps/assets/collision-world.glb +0 -0
  376. package/src/ingest/games/games-fps/game.js +479 -0
  377. package/src/ingest/games/games-fps/vgai.game.json +24 -0
  378. package/src/ingest/games/iframe-reachable/game-source.js +58 -0
  379. package/src/ingest/games/iframe-reachable/vgai.game.json +21 -0
  380. package/src/ingest/games/interactive-cubes/game.js +139 -0
  381. package/src/ingest/games/interactive-cubes/vgai.game.json +21 -0
  382. package/src/ingest/games/mismatched-three/fake-three.js +38 -0
  383. package/src/ingest/games/mismatched-three/game.js +10 -0
  384. package/src/ingest/games/mismatched-three/vgai.game.json +22 -0
  385. package/src/ingest/games/own-three-bundled/game.js +43 -0
  386. package/src/ingest/games/own-three-bundled/vgai.game.json +21 -0
  387. package/src/ingest/games/pointerlock-maze/game.js +288 -0
  388. package/src/ingest/games/pointerlock-maze/vgai.game.json +22 -0
  389. package/src/ingest/games-2d/_fixtures/first-party-scene.scn2d.json +124 -0
  390. package/src/ingest/games-2d/bubbo-bubbo/vgai.game.json +25 -0
  391. package/src/ingest/games-2d/bunnymark/game.ts +88 -0
  392. package/src/ingest/games-2d/bunnymark/vgai.game.json +21 -0
  393. package/src/ingest/games-2d/flappy/game.ts +148 -0
  394. package/src/ingest/games-2d/flappy/vgai.game.json +21 -0
  395. package/src/ingest/games-2d/flappy-pixi/vgai.game.json +25 -0
  396. package/src/ingest/games-2d/puzzling-potions/vgai.game.json +25 -0
  397. package/src/ingest/games-2d/tilemap-camera/game.ts +115 -0
  398. package/src/ingest/games-2d/tilemap-camera/vgai.game.json +21 -0
  399. package/src/ingest/games-react/react-rpg/entry.tsx +185 -0
  400. package/src/ingest/games-react/react-rpg/shims.d.ts +26 -0
  401. package/src/ingest/games-react/react-rpg/src/ui/game-state.tsx +129 -0
  402. package/src/ingest/games-react/react-rpg/vgai.game.json +21 -0
  403. package/src/ingest/host-namespace-registry.ts +196 -0
  404. package/src/ingest/types.ts +127 -0
  405. package/src/ingest-hierarchy-rows.ts +207 -0
  406. package/src/ingest-mode.ts +2092 -0
  407. package/src/ingest-siblings.ts +466 -0
  408. package/src/initial-scene.ts +194 -0
  409. package/src/inspector-section-registry.ts +117 -0
  410. package/src/interactive-edit-scope.ts +34 -0
  411. package/src/main.tsx +23 -0
  412. package/src/manifest-project.ts +43 -0
  413. package/src/model-thumbnail.ts +156 -0
  414. package/src/module-mode.ts +513 -0
  415. package/src/navmesh-handler.ts +94 -0
  416. package/src/packaged-runtime.ts +49 -0
  417. package/src/play-mode.ts +1148 -0
  418. package/src/playground/main.ts +206 -0
  419. package/src/project-manager.ts +173 -0
  420. package/src/scene-diff.ts +112 -0
  421. package/src/scene-serializer.ts +144 -0
  422. package/src/scene-sync.ts +587 -0
  423. package/src/scene-ui-mode/active-scene-ui.ts +58 -0
  424. package/src/scene-ui-mode/scene-ui-palette-panel.tsx +152 -0
  425. package/src/scene-ui-mode/scene-ui-timeline-tab.tsx +29 -0
  426. package/src/scene-ui-mode/ui-prefab-dialog-host.tsx +74 -0
  427. package/src/scene-ui-mode/ui-prefab-hierarchy-menu.ts +49 -0
  428. package/src/scene-ui-mode/ui-prefab-io.ts +76 -0
  429. package/src/script-loader.ts +143 -0
  430. package/src/storage/fsa-storage.ts +55 -0
  431. package/src/storage/handle-storage.ts +150 -0
  432. package/src/storage/handle-store.ts +116 -0
  433. package/src/storage/http-storage.ts +137 -0
  434. package/src/storage/index.ts +147 -0
  435. package/src/storage/mem-storage.ts +135 -0
  436. package/src/storage/opfs-storage.ts +25 -0
  437. package/src/storage/paths.ts +26 -0
  438. package/src/storage/seed.ts +106 -0
  439. package/src/storage/types.ts +110 -0
  440. package/src/tool-loader.ts +291 -0
  441. package/src/ui-editor/inspector.tsx +563 -0
  442. package/src/ui-editor/node-ops.ts +266 -0
  443. package/src/ui-editor/overlay.tsx +380 -0
  444. package/src/ui-editor/palette.tsx +114 -0
  445. package/src/ui-editor/react-store.ts +78 -0
  446. package/src/ui-editor/timeline-panel.tsx +443 -0
  447. package/src/ui-editor/ui-edit-store.ts +552 -0
  448. package/src/ui-editor/ui-prefab.ts +95 -0
  449. package/src/ui-source/inspect.ts +544 -0
  450. package/src/ui-source/oid-transform.ts +133 -0
  451. package/src/ui-source/source-write-backend.ts +203 -0
  452. package/src/ui-source/tailwind-map.ts +174 -0
  453. package/src/ui-source/writer.ts +1175 -0
  454. package/src/widgets/index.ts +73 -0
  455. package/template/.claude/skills/editor/SKILL.md +252 -0
  456. package/template/CLAUDE.md +512 -0
  457. package/template/LICENSE +30 -0
  458. package/template/emit-schemas.ts +42 -0
  459. package/template/index.html +17 -0
  460. package/template/package.json +53 -0
  461. package/template/project.json +9 -0
  462. package/template/public/audio/ambient-arena.wav +0 -0
  463. package/template/public/audio/ambient-music.wav +0 -0
  464. package/template/public/audio/ambient-nature.wav +0 -0
  465. package/template/public/audio/ambient-wind.wav +0 -0
  466. package/template/public/inputmaps/default.inputmap.json +41 -0
  467. package/template/public/jsm/libs/draco/gltf/draco_decoder.js +33 -0
  468. package/template/public/jsm/libs/draco/gltf/draco_decoder.wasm +0 -0
  469. package/template/public/jsm/libs/draco/gltf/draco_encoder.js +33 -0
  470. package/template/public/jsm/libs/draco/gltf/draco_wasm_wrapper.js +116 -0
  471. package/template/public/scenes/default.vscn.json +43 -0
  472. package/template/public/scenes/untitled.vscn.json +29 -0
  473. package/template/public/textures/waternormals.jpg +0 -0
  474. package/template/server/colyseus-setup.ts +85 -0
  475. package/template/server/main.ts +34 -0
  476. package/template/server/rooms/arena-room.ts +420 -0
  477. package/template/server/rooms/game-room.ts +162 -0
  478. package/template/server/rooms.ts +15 -0
  479. package/template/server/tsconfig.json +19 -0
  480. package/template/src/data/README.md +40 -0
  481. package/template/src/data/assets.ts +31 -0
  482. package/template/src/data/tuning.data.json +5 -0
  483. package/template/src/data/tuning.schema.json +21 -0
  484. package/template/src/data/tuning.schema.ts +34 -0
  485. package/template/src/data/tuning.ts +24 -0
  486. package/template/src/main.ts +157 -0
  487. package/template/src/scripts/components/data-spinner.ts +45 -0
  488. package/template/src/scripts/components/scene-camera.ts +40 -0
  489. package/template/src/scripts/main.ts +94 -0
  490. package/template/src/scripts/registry.ts +46 -0
  491. package/template/src/tools/README.md +14 -0
  492. package/template/src/tools/example.tool.tsx +91 -0
  493. package/template/src/tools/spin.tool.tsx +101 -0
  494. package/template/src/ui/Button.tsx +27 -0
  495. package/template/src/ui/DefaultHud.tsx +40 -0
  496. package/template/src/ui/KeyHint.tsx +25 -0
  497. package/template/src/ui/Panel.tsx +52 -0
  498. package/template/src/ui/RebindPanel.tsx +129 -0
  499. package/template/src/ui/Slider.tsx +28 -0
  500. package/template/src/ui/StatusDot.tsx +22 -0
  501. package/template/src/ui/Toggle.tsx +19 -0
  502. package/template/src/ui/game-state.tsx +37 -0
  503. package/template/src/ui/index.ts +7 -0
  504. package/template/src/ui/mount-default-hud.tsx +19 -0
  505. package/template/src/ui/mount-ui.ts +15 -0
  506. package/template/tsconfig.json +36 -0
  507. package/template/validate-asset-content.ts +78 -0
  508. package/template/validate-manifest.ts +55 -0
  509. package/template/validate-scenes.ts +211 -0
  510. package/template/vgai.game.json +21 -0
  511. package/template/vite.config.ts +128 -0
  512. package/vite-plugin-example-assets.ts +60 -0
  513. package/vite-plugin-game-globals.ts +46 -0
  514. package/vite-plugin-game-static.ts +71 -0
  515. package/vite-plugin-project-game-static.ts +84 -0
  516. package/vite-plugin-react-game-static.ts +98 -0
  517. package/vite-plugin-react-world-provider.ts +81 -0
  518. package/vite-plugin-react-world-runtime.ts +76 -0
  519. package/vite-plugin-ui-oid.ts +561 -0
@@ -0,0 +1,2400 @@
1
+ /**
2
+ * ReactWorldAuthoringAdapter — the editor's {@link AuthoringAdapter} for a react-kind
3
+ * world (T6.2 slice 2, `docs/REACT-WORLD-DESIGN.md` §1.D/§1.E/§3 slice 2).
4
+ *
5
+ * A react world has no GameComponents, no ticking, no mirror (D8) — its authoring
6
+ * entities ARE the `data-oid`-stamped elements of its rendered DOM (the OID
7
+ * instrumentation from the UI visual-edit program, `../ui-source/oid-transform.ts`,
8
+ * whose vite-plugin include this slice widens from the `editable-components` fixture
9
+ * dir to project scope — see `../../vite-plugin-ui-oid.ts`). This adapter derives its
10
+ * hierarchy by walking the world's live DOM root (`WorldInstance.reactRoot()`) for
11
+ * `data-oid`-carrying elements, tree-ordered; there is no cached/mirrored state to
12
+ * fall out of sync — every `hierarchy` call re-walks the live DOM.
13
+ *
14
+ * Writes (style/className/delete) reuse the EXISTING T3.2-slice-3 source-write seam
15
+ * verbatim (`../ui-source/source-write-backend.ts`'s `SourceWriteBackend`, the same
16
+ * `/__ui-source/write` + `/__ui-source/struct` dev-server endpoints
17
+ * `vite-plugin-ui-oid.ts` serves for `UIAuthoringAdapter`/`SourceEditPanel`) — this
18
+ * file does NOT invent a second source-writer. What IS new here (§1.E): edits enter
19
+ * the T3.2 global undo timeline as INVERSE-WRITE entries — the prior literal (read
20
+ * off the live DOM before the write) is captured and written back on undo. Structural
21
+ * ops (D-1, Phase D, spec27 §2) use a DIFFERENT inverse — a per-OID one is unsound for
22
+ * them (OIDs are content-signature keyed; `reorder`/`delete` reassign occurrence
23
+ * indices; `delete` has no inverse payload at all) — a checksum-guarded WHOLE-FILE
24
+ * snapshot restore instead (`pushStructUndo`/`restoreStructSnapshot`,
25
+ * `SourceWriteBackend.restoreSource`), so every structural op (including `delete`) now
26
+ * pushes exactly one undo entry.
27
+ *
28
+ * Persistence mirrors `UIAuthoringAdapter`'s de-stubbed pattern exactly: writes are
29
+ * immediate (server-side, on commit), so `save()` is an honest no-op; `destination`
30
+ * reports whether a source-write backend even exists in this session (absent in a
31
+ * hosted/no-dev-server build — selection/inspection still work, writes report
32
+ * unavailable via a loud console warning instead of silently no-op'ing).
33
+ */
34
+ import type {
35
+ AuthoringAdapter,
36
+ AuthoringCapabilities,
37
+ BoxEditProvider,
38
+ ColorSampleProvider,
39
+ DOMRectLike,
40
+ EditorNode,
41
+ HierarchyProvider,
42
+ InspectorProvider,
43
+ PersistenceProvider,
44
+ PickProvider,
45
+ PropertyDescriptor,
46
+ RectProvider,
47
+ SelectionProvider,
48
+ StoriesProvider,
49
+ StoryRef,
50
+ StructureProvider,
51
+ TextProvider,
52
+ } from '@engine/adapter';
53
+ import type { UIStory } from '@vgai/scene-ui/registry';
54
+ import type { UIComponentRegistry } from '@vgai/scene-ui/SceneUI';
55
+ import type { EditorStore } from '../editor-store';
56
+ import {
57
+ browserOrInlineResolver,
58
+ type ComputedStyleResolver,
59
+ type DesignToken,
60
+ type EmptyCandidate,
61
+ findEmptyContainers,
62
+ getComponentProps,
63
+ getComputedStyleValue,
64
+ getDesignTokens,
65
+ getMatchedCssRules,
66
+ getReactComponentName,
67
+ type MatchableElement,
68
+ } from '../ui-source/inspect';
69
+ import type { OidEntry } from '../ui-source/oid-transform';
70
+ import type { SourceWriteBackend } from '../ui-source/source-write-backend';
71
+ import { type CssRuleTarget, pickCssRuleTarget } from '../ui-source/writer';
72
+ import { getStoryOverlay, setStoryOverlay } from './story-overlay';
73
+ import { notifyUndoableOp, registerUndoAdapter } from './undo-timeline';
74
+
75
+ /**
76
+ * The minimal structural shape this adapter needs from a live DOM element —
77
+ * deliberately NOT `HTMLElement` so it stays testable with a plain-object fixture
78
+ * headlessly (this repo's vitest environment is `node`, no jsdom — see
79
+ * `docs/REACT-WORLD-DESIGN.md`'s slice-2 dispatch: "use the repo's DOM-stub +
80
+ * fixture patterns"). A real `HTMLElement` satisfies this structurally: `tagName`
81
+ * (uppercase, per the DOM spec — lower-cased for labels/kind below), `children`
82
+ * (an `HTMLCollection`, `Array.from`-able), and `getAttribute` reading the REAL
83
+ * `data-oid="…"` attribute `transformSource` stamped into the JSX (a genuine DOM
84
+ * attribute at runtime, not a mirror).
85
+ */
86
+ export interface OidElementLike {
87
+ readonly tagName: string;
88
+ readonly children: ArrayLike<OidElementLike>;
89
+ getAttribute(name: string): string | null;
90
+ /**
91
+ * `unknown` rather than a structural record — a real `CSSStyleDeclaration` has
92
+ * NO string index signature (TS models it as a fixed set of named properties
93
+ * plus methods), so it can't structurally satisfy `Record<string, unknown>`.
94
+ * Read through {@link styleProp} below, which handles both shapes.
95
+ */
96
+ readonly style?: unknown;
97
+ /**
98
+ * D12 (B4) — the element's live viewport rect, for `pickable.pick`'s
99
+ * geometric hit-test (see that provider's doc comment for why NOT
100
+ * `elementFromPoint`). Optional so existing plain-object test fixtures
101
+ * (which never provide one) keep type-checking unchanged — a node with no
102
+ * `getBoundingClientRect` simply never wins a pick (its rect is treated as
103
+ * absent, never a fabricated 0×0 that could win a tie).
104
+ */
105
+ getBoundingClientRect?(): {
106
+ left: number;
107
+ top: number;
108
+ right: number;
109
+ bottom: number;
110
+ width: number;
111
+ height: number;
112
+ };
113
+ /**
114
+ * T0 (spec 27 §2) — the element's rendered text, read for `TextProvider.get`'s
115
+ * "has child elements" gate below. Optional so pre-existing `OidElementLike`
116
+ * fixtures (none of which set it) keep satisfying the interface unchanged.
117
+ */
118
+ readonly textContent?: string | null;
119
+ }
120
+
121
+ /** Read one style property off an `OidElementLike.style` of either shape (a real
122
+ * `CSSStyleDeclaration` or a plain-object test fixture). */
123
+ export function styleProp(style: unknown, prop: string): unknown {
124
+ return style ? (style as Record<string, unknown>)[prop] : undefined;
125
+ }
126
+
127
+ /**
128
+ * Parse a real `CSSStyleDeclaration`'s length-property VALUE (always a
129
+ * unit-suffixed STRING, e.g. `"16px"` — CSSOM never hands back a bare
130
+ * number) into the plain number a `type: 'number'` {@link PropertyDescriptor}
131
+ * needs to render/edit correctly (`Inspector.tsx`'s generic renderer does
132
+ * `typeof v === 'number' ? … : 0` — without this parse, EVERY numeric style
133
+ * field would silently display `0` against a real browser DOM, even though
134
+ * the property test fixtures (`react-world-authoring-adapter.test.ts`'s
135
+ * plain-object `style: {}`) never caught it, because a hand-built fixture
136
+ * can hold a bare JS number directly). Strips a trailing CSS unit
137
+ * (`px`/`em`/`rem`/`%`/`vh`/`vw`, mirroring `writer.ts`'s own `LITERAL_RE`
138
+ * unit set); a value this repo's fixtures already hand over AS a number
139
+ * (or `undefined`/unset) passes through unchanged.
140
+ */
141
+ export function numericStyleValue(raw: unknown): number | undefined {
142
+ if (typeof raw === 'number') return raw;
143
+ if (typeof raw !== 'string' || raw === '') return undefined;
144
+ const parsed = Number.parseFloat(raw);
145
+ return Number.isNaN(parsed) ? undefined : parsed;
146
+ }
147
+
148
+ const RGB_RE = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+\s*)?\)$/;
149
+
150
+ /**
151
+ * Normalize a CSS color VALUE into `#rrggbb` for a `type: 'color'`
152
+ * {@link PropertyDescriptor} (`<input type="color">` only accepts exactly
153
+ * that shape — an unparseable value makes the browser silently coerce the
154
+ * input to black). A real `CSSStyleDeclaration` always serializes an inline
155
+ * color property as `rgb(r, g, b)`/`rgba(r, g, b, a)` — EVEN WHEN the author
156
+ * wrote a hex literal in JSX (`el.style.color = '#3ddc65'` reads back as
157
+ * `"rgb(61, 220, 101)"`, verified empirically against a real Chromium page,
158
+ * not assumed) — never hex. Without this normalization every color-typed
159
+ * style field would show black regardless of its real value against a real
160
+ * browser DOM (the plain-object test fixtures never caught this: a
161
+ * hand-built fixture's `style` can hold the author's hex string directly,
162
+ * which real CSSOM never does). An already-hex value (a fixture, or a
163
+ * property this repo's CSSOM happens to serialize as hex) passes through
164
+ * unchanged; a value matching neither shape returns `undefined` (the
165
+ * generic inspector's own '#ffffff' fallback) rather than handing the input
166
+ * an invalid string.
167
+ */
168
+ export function cssColorToHex(raw: unknown): string | undefined {
169
+ if (typeof raw !== 'string' || raw === '') return undefined;
170
+ if (raw.startsWith('#')) return raw;
171
+ const m = RGB_RE.exec(raw);
172
+ if (!m) return undefined;
173
+ const toHex = (n: string) => Number.parseInt(n, 10).toString(16).padStart(2, '0');
174
+ return `#${toHex(m[1]!)}${toHex(m[2]!)}${toHex(m[3]!)}`;
175
+ }
176
+
177
+ /** One resolved `BoxEditProvider` patch-key mapping (spec 27 §4, B1). */
178
+ export interface BoxEditPropMapping {
179
+ /** The CSS style prop to write (already the target, e.g. `x` → `left`). */
180
+ prop: string;
181
+ /** Live-preview CSS VALUE for a raw patch number — always px-suffixed for
182
+ * spatial/spacing props, the `rotate(<deg>deg)` transform string for `rotate`. */
183
+ cssValue: (v: number) => string;
184
+ }
185
+
186
+ /**
187
+ * `BoxEditProvider` patch-key → CSS-prop mapping (spec 27 B1), shared by BOTH
188
+ * authoring adapters' `boxEdit.apply/end` (`react-dom-authoring-adapter.ts`
189
+ * imports this rather than redefining it — same DRY reuse as
190
+ * {@link cssColorToHex}/{@link numericStyleValue}/{@link styleProp} below).
191
+ * `width`/`height`/`margin*`/`padding*` map 1:1 to their same-named style
192
+ * prop. `x`/`y` map to `left`/`top` ONLY when the node is
193
+ * absolutely/fixed-positioned (there is no `left`/`top` to move on a
194
+ * static/relative node) — `isPositioned` is a caller-resolved boolean (each
195
+ * adapter reads its own computed-style resolver) — a mismatched key returns
196
+ * `null` to mean "drop this key"; the CALLER does its own loud
197
+ * `console.warn` so the adapter's own name appears in the message. `rotate`
198
+ * (deg) maps to the `transform` prop as a `rotate(<deg>deg)` string.
199
+ */
200
+ export function mapBoxEditPatchKey(key: string, isPositioned: boolean): BoxEditPropMapping | null {
201
+ switch (key) {
202
+ case 'width':
203
+ case 'height':
204
+ case 'marginTop':
205
+ case 'marginRight':
206
+ case 'marginBottom':
207
+ case 'marginLeft':
208
+ case 'paddingTop':
209
+ case 'paddingRight':
210
+ case 'paddingBottom':
211
+ case 'paddingLeft':
212
+ return { prop: key, cssValue: (v) => `${v}px` };
213
+ case 'x':
214
+ return isPositioned ? { prop: 'left', cssValue: (v) => `${v}px` } : null;
215
+ case 'y':
216
+ return isPositioned ? { prop: 'top', cssValue: (v) => `${v}px` } : null;
217
+ case 'rotate':
218
+ return { prop: 'transform', cssValue: (v) => `rotate(${v}deg)` };
219
+ default:
220
+ return null;
221
+ }
222
+ }
223
+
224
+ const OID_ATTR = 'data-oid';
225
+
226
+ /** One OID-tagged DOM element resolved into the adapter's internal tree. */
227
+ interface OidNode {
228
+ /** Disambiguated entity id — see {@link walkOidTree}'s doc comment. */
229
+ id: string;
230
+ /** The raw OID (may repeat across sibling `OidNode`s — see below). */
231
+ oid: string;
232
+ tag: string;
233
+ el: OidElementLike;
234
+ parentId: string | null;
235
+ childIds: string[];
236
+ }
237
+
238
+ export interface OidTree {
239
+ /** Every OID-tagged node, keyed by its (disambiguated) entity id. */
240
+ nodes: Map<string, OidNode>;
241
+ /** Top-level entity ids (no OID-tagged ancestor) in DOM/tree order. */
242
+ rootIds: string[];
243
+ }
244
+
245
+ /**
246
+ * Walk a react world's live DOM root for `data-oid`-carrying elements, tree-ordered
247
+ * (§1.D: "React entities = the OID tree; the live DOM carries it").
248
+ *
249
+ * OID → entity id disambiguation: the OID is a SOURCE-content signature
250
+ * (`component:tag:nthOccurrence` — see `oid-transform.ts`), not a per-DOM-instance
251
+ * id — a list rendered from ONE JSX line (`items.map(...)`) stamps the SAME `data-
252
+ * oid` on every rendered item. Since an `EditorNode.id` must be unique per
253
+ * SELECTABLE entity, repeats are disambiguated by DOM-order OCCURRENCE within this
254
+ * walk: the first element carrying a given oid keeps `id === oid`; the Nth repeat
255
+ * (0-based) gets `id === "${oid}#${n}"`. This is deliberately NOT persisted/hashed
256
+ * across renders beyond "same relative position, same id" — stable enough for
257
+ * selection/inspection within a session, honestly component-global (not
258
+ * per-instance) for writes, exactly like every other OID-keyed edit in this
259
+ * program (a style/delete write on ANY repeat edits the ONE shared JSX source).
260
+ *
261
+ * An element with no `data-oid` (e.g. DOM emitted by an un-instrumented library,
262
+ * or a plain intrinsic a Fragment wrapped) is transparently skipped — its own
263
+ * children attach to the nearest OID-tagged ANCESTOR, not to it, so a gap in
264
+ * instrumentation never breaks the tree into disconnected pieces.
265
+ */
266
+ export function walkOidTree(root: OidElementLike): OidTree {
267
+ const nodes = new Map<string, OidNode>();
268
+ const rootIds: string[] = [];
269
+ const occurrence = new Map<string, number>();
270
+
271
+ function visit(el: OidElementLike, parentId: string | null): void {
272
+ const oid = el.getAttribute(OID_ATTR);
273
+ let effectiveParent = parentId;
274
+ if (oid) {
275
+ const n = occurrence.get(oid) ?? 0;
276
+ occurrence.set(oid, n + 1);
277
+ const id = n === 0 ? oid : `${oid}#${n}`;
278
+ const node: OidNode = {
279
+ id,
280
+ oid,
281
+ tag: el.tagName.toLowerCase(),
282
+ el,
283
+ parentId,
284
+ childIds: [],
285
+ };
286
+ nodes.set(id, node);
287
+ if (parentId) nodes.get(parentId)?.childIds.push(id);
288
+ else rootIds.push(id);
289
+ effectiveParent = id;
290
+ }
291
+ for (const child of Array.from(el.children)) visit(child, effectiveParent);
292
+ }
293
+
294
+ for (const child of Array.from(root.children)) visit(child, null);
295
+ return { nodes, rootIds };
296
+ }
297
+
298
+ /**
299
+ * Cap 6 (React visual-edit parity): the RICH GROUPED inspector model — the figma-style
300
+ * panels (Layout / Position / Spacing / Type / Fill / Stroke / Effects / Transform), each
301
+ * property tagged with a `group` the generic inspector renders as a titled sub-section
302
+ * (`PropertyDescriptor.group`). Every property here is covered by the writer's broadened
303
+ * `ARB_MAP`/`ENUM_UTILITIES` class routing (or inline style), so a set writes real source.
304
+ * (Bespoke widgets — HSV picker, scrub inputs, gradient/multi-shadow editors — are the
305
+ * one descoped part of Cap 6; the generic color/number/enum/string inputs render each of
306
+ * these functionally.)
307
+ */
308
+ const STYLE_PROPERTIES: ReadonlyArray<{
309
+ prop: string;
310
+ label: string;
311
+ type: PropertyDescriptor['type'];
312
+ group: string;
313
+ options?: string[];
314
+ }> = [
315
+ // -- Layout --
316
+ {
317
+ prop: 'display',
318
+ label: 'Display',
319
+ type: 'enum',
320
+ group: 'Layout',
321
+ options: ['block', 'flex', 'grid', 'inline', 'inline-block', 'inline-flex', 'none'],
322
+ },
323
+ {
324
+ prop: 'flexDirection',
325
+ label: 'Direction',
326
+ type: 'enum',
327
+ group: 'Layout',
328
+ options: ['row', 'column', 'row-reverse', 'column-reverse'],
329
+ },
330
+ {
331
+ prop: 'flexWrap',
332
+ label: 'Wrap',
333
+ type: 'enum',
334
+ group: 'Layout',
335
+ options: ['nowrap', 'wrap', 'wrap-reverse'],
336
+ },
337
+ {
338
+ prop: 'justifyContent',
339
+ label: 'Justify',
340
+ type: 'enum',
341
+ group: 'Layout',
342
+ options: ['flex-start', 'center', 'flex-end', 'space-between', 'space-around', 'space-evenly'],
343
+ },
344
+ {
345
+ prop: 'alignItems',
346
+ label: 'Align',
347
+ type: 'enum',
348
+ group: 'Layout',
349
+ options: ['stretch', 'flex-start', 'center', 'flex-end', 'baseline'],
350
+ },
351
+ { prop: 'gap', label: 'Gap', type: 'number', group: 'Layout' },
352
+ {
353
+ prop: 'overflow',
354
+ label: 'Overflow',
355
+ type: 'enum',
356
+ group: 'Layout',
357
+ options: ['visible', 'hidden', 'scroll', 'auto'],
358
+ },
359
+ { prop: 'width', label: 'Width', type: 'number', group: 'Layout' },
360
+ { prop: 'height', label: 'Height', type: 'number', group: 'Layout' },
361
+ { prop: 'minWidth', label: 'Min W', type: 'number', group: 'Layout' },
362
+ { prop: 'minHeight', label: 'Min H', type: 'number', group: 'Layout' },
363
+ { prop: 'maxWidth', label: 'Max W', type: 'number', group: 'Layout' },
364
+ { prop: 'maxHeight', label: 'Max H', type: 'number', group: 'Layout' },
365
+ // -- Position --
366
+ {
367
+ prop: 'position',
368
+ label: 'Position',
369
+ type: 'enum',
370
+ group: 'Position',
371
+ options: ['static', 'relative', 'absolute', 'fixed', 'sticky'],
372
+ },
373
+ { prop: 'top', label: 'Top', type: 'number', group: 'Position' },
374
+ { prop: 'right', label: 'Right', type: 'number', group: 'Position' },
375
+ { prop: 'bottom', label: 'Bottom', type: 'number', group: 'Position' },
376
+ { prop: 'left', label: 'Left', type: 'number', group: 'Position' },
377
+ { prop: 'zIndex', label: 'Z Index', type: 'number', group: 'Position' },
378
+ { prop: 'flexGrow', label: 'Grow', type: 'number', group: 'Position' },
379
+ { prop: 'flexShrink', label: 'Shrink', type: 'number', group: 'Position' },
380
+ // -- Spacing (per-side) --
381
+ { prop: 'marginTop', label: 'Margin T', type: 'number', group: 'Spacing' },
382
+ { prop: 'marginRight', label: 'Margin R', type: 'number', group: 'Spacing' },
383
+ { prop: 'marginBottom', label: 'Margin B', type: 'number', group: 'Spacing' },
384
+ { prop: 'marginLeft', label: 'Margin L', type: 'number', group: 'Spacing' },
385
+ { prop: 'paddingTop', label: 'Padding T', type: 'number', group: 'Spacing' },
386
+ { prop: 'paddingRight', label: 'Padding R', type: 'number', group: 'Spacing' },
387
+ { prop: 'paddingBottom', label: 'Padding B', type: 'number', group: 'Spacing' },
388
+ { prop: 'paddingLeft', label: 'Padding L', type: 'number', group: 'Spacing' },
389
+ // -- Type --
390
+ { prop: 'color', label: 'Color', type: 'color', group: 'Type' },
391
+ { prop: 'fontSize', label: 'Size', type: 'number', group: 'Type' },
392
+ { prop: 'fontWeight', label: 'Weight', type: 'string', group: 'Type' },
393
+ { prop: 'lineHeight', label: 'Line H', type: 'string', group: 'Type' },
394
+ { prop: 'letterSpacing', label: 'Spacing', type: 'string', group: 'Type' },
395
+ {
396
+ prop: 'textAlign',
397
+ label: 'Align',
398
+ type: 'enum',
399
+ group: 'Type',
400
+ options: ['left', 'center', 'right', 'justify'],
401
+ },
402
+ {
403
+ prop: 'textTransform',
404
+ label: 'Transform',
405
+ type: 'enum',
406
+ group: 'Type',
407
+ options: ['none', 'uppercase', 'lowercase', 'capitalize'],
408
+ },
409
+ { prop: 'fontStyle', label: 'Style', type: 'enum', group: 'Type', options: ['normal', 'italic'] },
410
+ { prop: 'fontFamily', label: 'Font', type: 'string', group: 'Type' },
411
+ {
412
+ prop: 'textDecoration',
413
+ label: 'Decoration',
414
+ type: 'enum',
415
+ group: 'Type',
416
+ options: ['none', 'underline', 'line-through', 'overline'],
417
+ },
418
+ // -- Fill --
419
+ { prop: 'backgroundColor', label: 'Background', type: 'color', group: 'Fill' },
420
+ // Cap 6 (§5 gap-fill): computed style never round-trips the `background`
421
+ // shorthand — `backgroundImage` is the prop that actually reads back
422
+ // (see `inspector.get`'s computed-style path below), so the gradient
423
+ // widget for a STYLE property must key off it, not `background`.
424
+ { prop: 'backgroundImage', label: 'Gradient', type: 'string', group: 'Fill' },
425
+ // -- Stroke --
426
+ { prop: 'borderColor', label: 'Border Color', type: 'color', group: 'Stroke' },
427
+ { prop: 'borderWidth', label: 'Border Width', type: 'number', group: 'Stroke' },
428
+ {
429
+ prop: 'borderStyle',
430
+ label: 'Border Style',
431
+ type: 'enum',
432
+ group: 'Stroke',
433
+ options: ['none', 'solid', 'dashed', 'dotted', 'double'],
434
+ },
435
+ { prop: 'borderRadius', label: 'Radius', type: 'number', group: 'Stroke' },
436
+ // -- Effects --
437
+ { prop: 'opacity', label: 'Opacity', type: 'number', group: 'Effects' },
438
+ { prop: 'boxShadow', label: 'Box Shadow', type: 'string', group: 'Effects' },
439
+ {
440
+ prop: 'mixBlendMode',
441
+ label: 'Blend',
442
+ type: 'enum',
443
+ group: 'Effects',
444
+ options: ['normal', 'multiply', 'screen', 'overlay'],
445
+ },
446
+ { prop: 'filter', label: 'Filter', type: 'string', group: 'Effects' },
447
+ { prop: 'backdropFilter', label: 'Backdrop', type: 'string', group: 'Effects' },
448
+ { prop: 'textShadow', label: 'Text Shadow', type: 'string', group: 'Effects' },
449
+ {
450
+ prop: 'cursor',
451
+ label: 'Cursor',
452
+ type: 'enum',
453
+ group: 'Effects',
454
+ options: ['auto', 'default', 'pointer', 'text', 'move', 'grab', 'not-allowed'],
455
+ },
456
+ // -- Transform --
457
+ { prop: 'transform', label: 'Transform', type: 'string', group: 'Transform' },
458
+ ];
459
+ const STYLE_PATH_PREFIX = 'style.';
460
+ /** Cap 4: inspector path prefix for a component's editable props (`prop.<name>`). */
461
+ const PROP_PATH_PREFIX = 'prop.';
462
+ /** Cap 7: inspector path prefix for the document's design tokens (`token.<name>`). */
463
+ const TOKEN_PATH_PREFIX = 'token.';
464
+ /** D3.R1 (spec 27 §2/§5 U4 precedent, reopen fix) — the `dynamicPaths` key for
465
+ * Cap 3's text edit (`editText`), so a dynamic-body refusal is recorded and
466
+ * surfaced through the SAME session-scoped after-touch mechanism the
467
+ * style/prop U4 widgets use — not a real inspector path (text has no
468
+ * `properties()` descriptor), just a stable key for `` `${id}|${TEXT_PATH}` ``. */
469
+ const TEXT_PATH = 'text';
470
+ /** B3 — catalog node id prefix (`catalog:<registryKey>`) and the one
471
+ * `entry (live preview)` node id, collision-proof against OID-derived ids
472
+ * (`o<counter36><hash36>`, never containing `:`) and composite ids
473
+ * (`world:<id>`, only ever seen by the COMPOSITE, never by this adapter). */
474
+ const CATALOG_ID_PREFIX = 'catalog:';
475
+ const CATALOG_ENTRY_ID = 'catalog-entry';
476
+ /** B3 — inspector path prefix for a catalog node's active-story props
477
+ * (`storyProp.<name>`) — deliberately distinct from {@link PROP_PATH_PREFIX}
478
+ * (a DOM node's live fiber props): a catalog node has no call site to read a
479
+ * fiber from (see `inspector.properties`'s catalog branch doc comment). */
480
+ const STORY_PROP_PATH_PREFIX = 'storyProp.';
481
+
482
+ /** `prop -> declared type`, so `inspector.get()` knows when to run a style
483
+ * value through {@link numericStyleValue} instead of handing it back raw
484
+ * (a real CSSOM length value is always a unit-suffixed string). */
485
+ const STYLE_PROPERTY_TYPE: ReadonlyMap<string, PropertyDescriptor['type']> = new Map(
486
+ STYLE_PROPERTIES.map(({ prop, type }) => [prop, type]),
487
+ );
488
+
489
+ /**
490
+ * U2 (spec 27 §5 C2) — a per-side/per-corner CSS LONGHAND → the SHORTHAND that
491
+ * governs it in the longhand's absence. Used by `inspector.remove` (below): when
492
+ * a uniform border/radius edit removes a stale longhand override, the removed
493
+ * longhand's optimistic echo is set to the shorthand's current (just-committed)
494
+ * value — so an `inspector.get(longhand)` right after removal reports the value
495
+ * the corner actually renders at (the shorthand), not a stale override or an
496
+ * empty computed read, and it stays consistent once HMR clears the echo (the
497
+ * longhand is gone from source, so the shorthand cascades to it). Purely the
498
+ * longhands the C2 combo rows can write — the shorthand set itself is uniform.
499
+ */
500
+ const LONGHAND_TO_SHORTHAND: Readonly<Record<string, string>> = {
501
+ borderTopLeftRadius: 'borderRadius',
502
+ borderTopRightRadius: 'borderRadius',
503
+ borderBottomLeftRadius: 'borderRadius',
504
+ borderBottomRightRadius: 'borderRadius',
505
+ borderTopWidth: 'borderWidth',
506
+ borderRightWidth: 'borderWidth',
507
+ borderBottomWidth: 'borderWidth',
508
+ borderLeftWidth: 'borderWidth',
509
+ borderTopStyle: 'borderStyle',
510
+ borderRightStyle: 'borderStyle',
511
+ borderBottomStyle: 'borderStyle',
512
+ borderLeftStyle: 'borderStyle',
513
+ borderTopColor: 'borderColor',
514
+ borderRightColor: 'borderColor',
515
+ borderBottomColor: 'borderColor',
516
+ borderLeftColor: 'borderColor',
517
+ };
518
+
519
+ /**
520
+ * D3.e (spec 27 §2 T0 leftover, §6 D3 "Insert-child submenu") — the kinds
521
+ * `structure.create`'s `wrapperTag` genuinely inserts. `insertChildElement`
522
+ * (`ui-source/writer.ts:1036`) writes `<tag />` VERBATIM for whatever tag
523
+ * string it is given — it has no allow-list of its own, and no void/non-void
524
+ * element distinction (every insert is self-closing, syntactically valid
525
+ * JSX for every one of these real HTML element names). So this is a CURATED
526
+ * subset, not a writer-enforced ceiling — the same "small sensible palette,
527
+ * not the writer's full permissiveness" stance `ui-authoring-adapter.ts`'s
528
+ * own `CREATABLE_KINDS` doc comment takes (there for UINode kinds; here for
529
+ * literal HTML tag names, since a react-world node's "kind" IS its tag).
530
+ */
531
+ const CREATABLE_KINDS: ReadonlyArray<{ kind: string; label: string }> = [
532
+ { kind: 'div', label: 'Container' },
533
+ { kind: 'span', label: 'Text' },
534
+ { kind: 'p', label: 'Paragraph' },
535
+ { kind: 'button', label: 'Button' },
536
+ { kind: 'a', label: 'Link' },
537
+ { kind: 'img', label: 'Image' },
538
+ { kind: 'ul', label: 'List' },
539
+ { kind: 'li', label: 'List Item' },
540
+ ];
541
+
542
+ interface PendingUndo {
543
+ undo: () => void;
544
+ redo: () => void;
545
+ }
546
+
547
+ export interface ReactWorldAuthoringOptions {
548
+ /** T3.2 slice-3 write seam. Absent ⇒ no dev-server backend in this session (a
549
+ * hosted/browser build) — selection/inspection still work; writes report
550
+ * unavailable (see the class doc comment). */
551
+ writeBackend?: SourceWriteBackend | undefined;
552
+ /** Cap 1 (React visual-edit parity): resolves an element to its COMPUTED style so
553
+ * `inspector.get` reflects class- and CSS-file-styled properties, not just inline
554
+ * style. Defaults to `window.getComputedStyle` in a browser, or the element's inline
555
+ * `.style` under vitest's `node` env (so headless fixtures work). Injectable for
556
+ * headless tests that want to drive the real computed-value path. */
557
+ computedStyle?: ComputedStyleResolver | undefined;
558
+ /** Cap 2 (React visual-edit parity): resolves an element to the first-party CSS rules
559
+ * that match it (source file + selector + declared properties), so a style edit whose
560
+ * property lives in a CSS FILE routes to that file instead of inline/class. Defaults to
561
+ * `inspect.ts`'s `getMatchedCssRules` against the live document. Injectable for headless
562
+ * tests. */
563
+ matchedCssRules?: ((el: unknown) => CssRuleTarget[]) | undefined;
564
+ /** Cap 7 (React visual-edit parity): the document's design tokens (`:root` custom
565
+ * properties). Defaults to `inspect.ts`'s `getDesignTokens` against the live `:root`
566
+ * (empty under vitest's `node` env). Injectable for headless tests. */
567
+ designTokens?: (() => DesignToken[]) | undefined;
568
+ /**
569
+ * D4 — ordered stories sources (Q2 decoupling: the provider takes stories
570
+ * as a constructor input, a project's `src/ui/registry.ts` is merely the
571
+ * DEFAULT source — an editor-side overlay, `../authoring/story-overlay.ts`,
572
+ * plugs into the same parameter). Later sources win over earlier ones,
573
+ * per story id — see {@link ReactWorldAuthoringAdapter.stories}. Absent/
574
+ * empty ⇒ `storiesFor` returns `[]` for every node (today's B1 behavior,
575
+ * no registry case).
576
+ */
577
+ storySources?: ReadonlyArray<Record<string, UIStory>> | undefined;
578
+ /**
579
+ * D4 — invoked whenever `stories.apply` changes the active story
580
+ * (including clearing it to `null`). The caller (`design-time-layers.ts`'s
581
+ * `mountReactLayer`) re-renders the layer root against the story's `data`/
582
+ * `props` — this adapter never touches the DOM/React tree itself, it only
583
+ * tracks WHICH story is active and reports the change.
584
+ */
585
+ onStoryApplied?: ((story: (UIStory & { id: string }) | null) => void) | undefined;
586
+ /**
587
+ * B3 — the react world's registered components (a project's
588
+ * `src/ui/registry.ts` `components`, `SceneUI.tsx`'s flat
589
+ * `Record<string, ComponentType>`, NO metadata). Absent/EMPTY ⇒ this
590
+ * adapter's hierarchy is the plain DOM walk, byte-identical to pre-B3
591
+ * (§0's gate — `play-mode.ts`'s `installMultiWorldAuthoring` never passes
592
+ * this option, so PLAY mode's react-world hierarchy is untouched by B3's
593
+ * construction). Non-empty ⇒ `hierarchy.roots()` becomes the CATALOG: one
594
+ * `kind:'component'` node per registered key, plus one `'entry (live
595
+ * preview)'` node nesting the ordinary DOM-walk roots one level down (see
596
+ * the class doc comment's storybook-model rationale and
597
+ * `docs/unified-world-editor/26-unified-editor-spec.md` §3 B3).
598
+ */
599
+ catalogComponents?: UIComponentRegistry | undefined;
600
+ /**
601
+ * B3 — invoked whenever `stories.isolate` changes the isolated view
602
+ * (including clearing it to `null`, restoring the entry). The caller
603
+ * (`design-time-layers.ts`'s `mountReactLayer`) re-renders the layer root
604
+ * against the isolated component + story (or back to the entry) — this
605
+ * adapter never touches the DOM/React tree itself, it only tracks WHICH
606
+ * catalog node is isolated and reports the change.
607
+ */
608
+ onIsolate?:
609
+ | ((v: { componentKey: string; story: (UIStory & { id: string }) | null } | null) => void)
610
+ | undefined;
611
+ /**
612
+ * B3 — this world's manifest id. Needed ONLY to key a catalog node's
613
+ * writable story-prop edits into the B2 story-overlay
614
+ * (`../authoring/story-overlay.ts`'s per-worldId slot) — every OTHER
615
+ * option/method in this adapter is worldId-agnostic. Absent ⇒ catalog
616
+ * inspector prop writes report unavailable (there is no world to key the
617
+ * overlay by) rather than silently no-op'ing — see `writeStoryProp`.
618
+ */
619
+ worldId?: string | undefined;
620
+ }
621
+
622
+ export class ReactWorldAuthoringAdapter implements AuthoringAdapter {
623
+ readonly capabilities: AuthoringCapabilities;
624
+ private readonly writeBackend: SourceWriteBackend | undefined;
625
+ private readonly computedStyle: ComputedStyleResolver;
626
+ private readonly matchedCssRules: (el: unknown) => CssRuleTarget[];
627
+ private readonly designTokens: () => DesignToken[];
628
+ /** OID → {component, tag, …} labels, fetched once (if a backend exists) from the
629
+ * SAME `/__ui-source/index` the OID store already serves — see `index()` on
630
+ * `SourceWriteBackend`. Absent/unresolved ⇒ nodes label from the DOM tag alone
631
+ * (honest degradation, not a second index). */
632
+ private oidIndex: Map<string, OidEntry> = new Map();
633
+ private dirty = false;
634
+ /**
635
+ * A4 (spec 27 §3) — the optimistic value echo. `inspector.set` writes the just-committed
636
+ * value here BEFORE its async source-write returns; `inspector.get` reads THROUGH it (a
637
+ * hit wins over the live-DOM walk), so an edited field shows the NEW value within one
638
+ * frame of commit instead of snapping back to the old value until the source-write + Vite
639
+ * HMR re-render land (that timing WAS the desync bug). Keyed by OID-signature entity id
640
+ * (which survives the react remount — see {@link walkOidTree}) → full inspector path →
641
+ * value. Cleared per-entry the instant a write is refused/coerced-away (so it can't go
642
+ * stale showing a value the source never took), and wholesale once HMR actually lands
643
+ * ({@link reconcileEchoAfterReload}) so the now-updated live DOM is authoritative again.
644
+ */
645
+ private readonly valueEcho = new Map<string, Map<string, unknown>>();
646
+ /**
647
+ * U4 (spec 27 §5 "Widgets: dynamic-expression read-only indicator") — the
648
+ * smallest honest version of the indicator: no new backend literality
649
+ * PROBE (that's real new `SourceWriteBackend` surface, deferred to Phase-E
650
+ * scale). Instead this surfaces the write path's EXISTING refusal signal —
651
+ * `writeStyleEntry`/`writePropEdit` already set `res.dynamic` when the
652
+ * backend refuses because the target is a dynamic `{expression}`, clearing
653
+ * the optimistic echo and warning. Once THAT has happened for a given
654
+ * `id|path`, `properties()` marks its descriptor `readonly: true` on every
655
+ * subsequent call, so the widget disables itself (`KindRowProps.disabled`)
656
+ * instead of silently re-offering an edit the source will refuse again.
657
+ * This is PREDICTIVE-AFTER-TOUCH, not predictive-BEFORE-touch (the field is
658
+ * still editable — and will visibly refuse once — the very first time);
659
+ * a pre-touch indicator needs the backend probe noted above. Session-scoped
660
+ * (never persisted) — cleared for an id on `dispose()`-adjacent resets only
661
+ * via normal adapter lifetime, same scope as `valueEcho`.
662
+ */
663
+ private readonly dynamicPaths = new Set<string>();
664
+ /**
665
+ * D4 (spec27 §6 D4, layer-tree "lock" toggle) — SESSION-LOCAL node ids the
666
+ * layer tree/overlay has marked locked. Deliberately NOT persisted/written
667
+ * to source: unlike the first-party threejs adapter's `locked` (a real
668
+ * `SceneEntity.locked` field consumed by the viewport's raycaster), a react
669
+ * component has no equivalent schema field, and authoring one with no
670
+ * runtime reader would violate this repo's "no described field without a
671
+ * consumer" rule (CLAUDE.md; the SAME reason `ui.ts` dropped its dead
672
+ * `locked` field). Mirrors `world-session-state.ts`'s per-world pick-lock.
673
+ * D4.R1 — now READ by `pickable.pick` below (a locked node is skipped by
674
+ * canvas click/marquee pick, matching the first-party threejs raycast's
675
+ * own locked-skip in `viewport-raycast.ts`) and by
676
+ * `WorldSelectionOverlay.collectMarqueeCandidates` (via
677
+ * `inspector.get(id, 'locked')`, which reads this Set — see the
678
+ * `inspector.get`/`set` `'locked'` case below) for the marquee pool.
679
+ * Deliberately NOT consulted by `hierarchy`/`selection.set` — a locked
680
+ * node stays selectable/unlockable from the layer tree, mirroring the
681
+ * first-party adapter's own "locked blocks the raycast, not the
682
+ * hierarchy" behavior. Cleared only by adapter disposal (new Set per
683
+ * adapter instance/mount).
684
+ */
685
+ private readonly lockedIds = new Set<string>();
686
+ /**
687
+ * D3.R4 (reopen fix), widened by D3.R5 — a COUNTER (not a boolean) of source writes
688
+ * whose own pre-HMR `notifyIngestEdit()` carries no fresh DOM shape, still awaiting
689
+ * their post-HMR "reload landed" reconcile. Incremented by every SUCCESSFUL write on
690
+ * a path with no `valueEcho` of its own: `structOp`, `removeElement`,
691
+ * `restoreStructSnapshot` (the struct-undo/redo inverse — success only, never on a
692
+ * refused/sha-mismatched restore, which writes nothing and has no reload to await),
693
+ * `editText`'s success path, and `applyTextWrite` (the text-undo/redo inverse —
694
+ * again success only). D3.R4 originally covered only `structOp`/`removeElement`; the
695
+ * D3.R5 reopen found the SAME staleness, unflagged, at the struct-undo/redo closure
696
+ * (every `structOp`/`removeElement` undo routes through `restoreStructSnapshot`) and
697
+ * at a successful text write (`editText`/`applyTextWrite` flip `hasText`, itself a
698
+ * `findEmptyContainers` hint-eligibility criterion — text populates no `valueEcho`,
699
+ * so neither existing reconcile branch fired for it).
700
+ *
701
+ * A structural/text op's own `notifyIngestEdit()` (right after the write response
702
+ * lands) fires BEFORE the HMR remount that actually changes the DOM — so a memoized
703
+ * hover-render cache keyed on that notify's `storeVersion`
704
+ * (`WorldSelectionOverlay`'s `emptyHintsCacheRef`) pins the PRE-HMR tree shape.
705
+ * `reconcileEchoAfterReload` is the "HMR actually landed" signal; it used to no-op
706
+ * whenever `valueEcho` was empty — true for every one of the sites above, since only
707
+ * A4's value-echo path (style/prop) ever populates it.
708
+ *
709
+ * COUNTER, not boolean (the D3.R5 shape): a boolean cleared unconditionally on the
710
+ * FIRST reconcile after it was set, so two rapid writes each of whose OWN HMR fires a
711
+ * separate `vite:afterUpdate` would reconcile once and then no-op on the second
712
+ * reload-landed signal — the second write's hint delta going stale forever (a
713
+ * residual the boolean shape left undocumented). Each successful qualifying write
714
+ * increments this counter; `reconcileEchoAfterReload` notifies and decrements by
715
+ * exactly one whenever it is above zero (in addition to notifying whenever `valueEcho`
716
+ * is non-empty), so N pending writes need N reconciles to fully drain — matching N
717
+ * real `vite:afterUpdate` events in production.
718
+ */
719
+ private pendingSourceReconcile = 0;
720
+ /** A4 — unsubscribes this adapter's `vite:afterUpdate` reconcile hook (see the constructor);
721
+ * `undefined` when there is no HMR context (hosted build / vitest `node` env). */
722
+ private readonly disposeReloadSignal: (() => void) | undefined;
723
+ private readonly undoStack: PendingUndo[] = [];
724
+ private readonly redoStack: PendingUndo[] = [];
725
+ private readonly storySources: ReadonlyArray<Record<string, UIStory>>;
726
+ private readonly onStoryApplied: ((story: (UIStory & { id: string }) | null) => void) | undefined;
727
+ /** D4 — the currently-applied story id for this world (world-level in B2,
728
+ * not per-node — see {@link stories}'s doc comment); `null` = no story
729
+ * applied (the layer mounts against empty data, today's B1 behavior). */
730
+ private activeStoryId: string | null = null;
731
+ /** B3 — the registered components catalog (see
732
+ * {@link ReactWorldAuthoringOptions.catalogComponents}'s doc comment).
733
+ * Empty ⇒ {@link catalogOn} is `false` ⇒ pure DOM-walk hierarchy. */
734
+ private readonly catalogComponents: UIComponentRegistry;
735
+ private readonly onIsolate:
736
+ | ((v: { componentKey: string; story: (UIStory & { id: string }) | null } | null) => void)
737
+ | undefined;
738
+ private readonly worldId: string | undefined;
739
+ /** B3 — the currently-isolated catalog node, or `null` (the entry preview
740
+ * is rendered). See {@link stories}'s `isolate`. */
741
+ private isolated: { nodeId: string; componentKey: string; storyId: string | undefined } | null =
742
+ null;
743
+ /**
744
+ * T0 (spec 27 §4, B1) — the currently-open `boxEdit` begin/apply×N/end gesture (a
745
+ * drag), or `null` between gestures. `touched` maps the RESOLVED CSS prop (already
746
+ * patch-key-mapped, e.g. `x` → `left`) to the FINAL value `end` should commit — a
747
+ * `Map` so the last `apply` in the gesture wins, matching an in-flight drag's most
748
+ * recent pointer position. `priorInline` captures each touched prop's ORIGINAL inline
749
+ * value, LAZILY on the prop's first `apply` in this gesture (i.e. BEFORE `apply`
750
+ * mutates the live style) — `apply` writes the live DOM directly for zero-latency
751
+ * preview, which would otherwise corrupt `writeStyleEntry`'s own prior-value capture
752
+ * (it reads `n.el.style` fresh, and by `end` time that already holds the LAST applied
753
+ * preview value, not the true pre-gesture one) — this is why `writeStyleEntry` takes
754
+ * an explicit override rather than re-deriving `prev` itself for a box-edit commit.
755
+ */
756
+ private boxEditSession: {
757
+ id: string;
758
+ touched: Map<string, string | number>;
759
+ priorInline: Map<string, string>;
760
+ } | null = null;
761
+
762
+ constructor(
763
+ private readonly root: OidElementLike,
764
+ private readonly store: EditorStore,
765
+ opts: ReactWorldAuthoringOptions = {},
766
+ ) {
767
+ this.writeBackend = opts.writeBackend;
768
+ this.computedStyle = opts.computedStyle ?? browserOrInlineResolver;
769
+ this.matchedCssRules =
770
+ opts.matchedCssRules ??
771
+ ((el) => getMatchedCssRules(el as MatchableElement) as CssRuleTarget[]);
772
+ this.designTokens = opts.designTokens ?? (() => getDesignTokens());
773
+ this.storySources = opts.storySources ?? [];
774
+ this.onStoryApplied = opts.onStoryApplied;
775
+ this.catalogComponents = opts.catalogComponents ?? {};
776
+ this.onIsolate = opts.onIsolate;
777
+ this.worldId = opts.worldId;
778
+ this.capabilities = {
779
+ transform: false, // no 3D gizmo — DOM has no Object3D pose (§1.F)
780
+ material: false,
781
+ inspectorFields: true,
782
+ create: true, // Cap 5: insert-child / duplicate / wrap are real source ops
783
+ delete: true,
784
+ reparent: true, // Cap 5: real source reparent (drag-to-reparent)
785
+ persist: true,
786
+ };
787
+ // T3.2 slice 2: this adapter IS the undo/redo target the editor-global
788
+ // timeline dispatches to for entries it records (`pushUndo`). The caller
789
+ // (`play-mode.ts`'s `exitMultiWorldAuthoring`) unregisters it on teardown,
790
+ // mirroring `IngestAuthoringAdapter`.
791
+ registerUndoAdapter(this);
792
+ // A4 (spec 27 §3) — re-sync the optimistic echo once HMR has actually re-rendered the
793
+ // react tree. Vite fires `vite:afterUpdate` after it applies an HMR update — here, the
794
+ // dev-server source-write this adapter triggered (→ file watcher → react-refresh
795
+ // re-render) — so that event IS the "reload landed" signal (the adapter otherwise never
796
+ // learns about reloads; play-mode.ts owns the component-class HMR handler, not this
797
+ // per-world adapter). Absent under a hosted/no-dev-server build and under vitest's `node`
798
+ // env (no `import.meta.hot`), where the unit test drives `reconcileEchoAfterReload()`
799
+ // directly to simulate a landed reload.
800
+ const hot = import.meta.hot;
801
+ if (hot) {
802
+ const onAfterUpdate = (): void => this.reconcileEchoAfterReload();
803
+ hot.on('vite:afterUpdate', onAfterUpdate);
804
+ this.disposeReloadSignal = () => hot.off('vite:afterUpdate', onAfterUpdate);
805
+ }
806
+ if (this.writeBackend?.index) {
807
+ this.writeBackend
808
+ .index()
809
+ .then((idx) => {
810
+ this.oidIndex = new Map(Object.entries(idx));
811
+ this.store.notifyIngestEdit();
812
+ })
813
+ .catch(() => {
814
+ // Honest degradation: labels stay tag-only if the index can't be fetched.
815
+ });
816
+ }
817
+ }
818
+
819
+ private snapshot(): OidTree {
820
+ return walkOidTree(this.root);
821
+ }
822
+
823
+ // --- A4: optimistic value echo (see {@link valueEcho}) ---
824
+
825
+ /** Record the value just committed by `inspector.set`, so `inspector.get(id, path)`
826
+ * returns it immediately (before the async source-write + HMR land). */
827
+ private setEcho(id: string, path: string, value: unknown): void {
828
+ let byPath = this.valueEcho.get(id);
829
+ if (!byPath) {
830
+ byPath = new Map();
831
+ this.valueEcho.set(id, byPath);
832
+ }
833
+ byPath.set(path, value);
834
+ }
835
+
836
+ /** Drop one echoed entry — used the instant a write is refused/no-op'd, so the field
837
+ * reverts to the live-DOM (unchanged) value rather than sticking on a value the source
838
+ * never took (the "stale in the other direction" guard for rejected writes). */
839
+ private clearEcho(id: string, path: string): void {
840
+ const byPath = this.valueEcho.get(id);
841
+ if (!byPath) return;
842
+ byPath.delete(path);
843
+ if (byPath.size === 0) this.valueEcho.delete(id);
844
+ }
845
+
846
+ /** U4 — record that `id`'s `path` was refused as a dynamic expression, so the
847
+ * NEXT `properties()` call marks its descriptor `readonly`. */
848
+ private markDynamic(id: string, path: string): void {
849
+ this.dynamicPaths.add(`${id}|${path}`);
850
+ }
851
+
852
+ /** Read-through for `inspector.get`: `{hit:true}` when this id+path was optimistically
853
+ * echoed and not yet reconciled; `{hit:false}` otherwise (fall back to the live DOM). A
854
+ * distinct `hit` flag (not a sentinel value) so a legitimately-`undefined` echoed value
855
+ * still wins over the live-DOM walk. */
856
+ private readEcho(id: string, path: string): { hit: boolean; value: unknown } {
857
+ const byPath = this.valueEcho.get(id);
858
+ if (byPath?.has(path)) return { hit: true, value: byPath.get(path) };
859
+ return { hit: false, value: undefined };
860
+ }
861
+
862
+ /**
863
+ * A4 — the "HMR reload landed" reconcile (fired by the constructor's `vite:afterUpdate`
864
+ * hook, or called directly to simulate a landed reload). The echo held the freshly-set
865
+ * values while the async source-write + HMR re-render were in flight; once HMR has
866
+ * re-rendered the react tree the LIVE DOM is authoritative again — including any
867
+ * server-side value coercion — so drop the whole echo and notify, and every open inspector
868
+ * re-reads the now-updated DOM. (An unrelated module's `afterUpdate` that fires before THIS
869
+ * edit's HMR is a benign race: the field momentarily re-reads the old DOM, then this
870
+ * edit's own `afterUpdate` reconciles it — the echo self-heals on the next tick.) Selection
871
+ * is untouched: it lives as OID-signature ids in the store, re-resolved by `walkOidTree`
872
+ * against the remounted tree, never as a cached element reference.
873
+ *
874
+ * D3.R4 (reopen fix), widened by D3.R5 — ALSO the "landed" signal for every pending
875
+ * source write counted by {@link pendingSourceReconcile} (see its doc comment for the
876
+ * full site list and the counter-vs-boolean rationale): each of those writes' own
877
+ * `notifyIngestEdit()` fires before the HMR remount lands, so it never carries fresh
878
+ * DOM shape on its own; this reconcile is what does, once the remount has actually
879
+ * happened. Decrements the counter by exactly one per call (never resets it to zero)
880
+ * so N pending writes drain over N reconciles, each one notifying — not just the
881
+ * first.
882
+ */
883
+ reconcileEchoAfterReload(): void {
884
+ if (this.valueEcho.size === 0 && this.pendingSourceReconcile === 0) return;
885
+ this.valueEcho.clear();
886
+ if (this.pendingSourceReconcile > 0) this.pendingSourceReconcile--;
887
+ this.store.notifyIngestEdit();
888
+ }
889
+
890
+ /** A4 — release the `vite:afterUpdate` reconcile subscription. Idempotent; safe when no
891
+ * HMR context was present (the disposer is `undefined`). */
892
+ disposeReactWorldAdapter(): void {
893
+ this.disposeReloadSignal?.();
894
+ }
895
+
896
+ private toEditorNode(n: OidNode): EditorNode {
897
+ // Component label priority (Cap 1): the OID source index (`component:tag`), then the
898
+ // LIVE React fiber's component name (`getReactComponentName` — resolves for a
899
+ // component's root element even before/without the index fetch), then the DOM tag.
900
+ const entry = this.oidIndex.get(n.oid);
901
+ const component = entry?.component ?? getReactComponentName(n.el);
902
+ const label = component ? `${component}:${n.tag}` : n.tag;
903
+ const node: EditorNode = {
904
+ id: n.id,
905
+ label,
906
+ kind: n.tag,
907
+ parentId: n.parentId,
908
+ childIds: n.childIds,
909
+ flags: { runtimeOnly: true },
910
+ };
911
+ // B3 — a former DOM ROOT (`parentId === null`) nests one level under the
912
+ // catalog's entry-preview node when the catalog is on (§0's reconciliation:
913
+ // GameHierarchy's sibling/drag math keys off `parentId`, so a root that
914
+ // kept `parentId: null` while `roots()` stopped returning it would corrupt
915
+ // top-level DOM drag-reorder). Only ROOTS are remapped — a non-root node's
916
+ // `parentId` already points at its real DOM parent, untouched.
917
+ if (this.catalogOn && node.parentId === null) return { ...node, parentId: CATALOG_ENTRY_ID };
918
+ return node;
919
+ }
920
+
921
+ /** B3 gate (§0/§1): the catalog hierarchy is ON only when constructed with a
922
+ * NON-EMPTY `catalogComponents` — absent/empty keeps today's plain DOM-walk
923
+ * hierarchy byte-identical (play mode never passes this option at all). */
924
+ private get catalogOn(): boolean {
925
+ return Object.keys(this.catalogComponents).length > 0;
926
+ }
927
+
928
+ /** B3 — one `kind:'component'` node per registered key. `object3D`/
929
+ * `idForObject3D` stay `null` for these ids (no DOM/Object3D backing). */
930
+ private catalogNode(key: string): EditorNode {
931
+ return {
932
+ id: `${CATALOG_ID_PREFIX}${key}`,
933
+ label: key,
934
+ kind: 'component',
935
+ parentId: null,
936
+ childIds: [],
937
+ flags: { runtimeOnly: true },
938
+ };
939
+ }
940
+
941
+ /** B3 — the one `'entry (live preview)'` node; its children are the
942
+ * DOM-walk's OWN root ids (unchanged, still resolved by `hierarchy.node()`
943
+ * through the ordinary DOM path — see {@link toEditorNode}'s remap). */
944
+ private entryNode(rootIds: string[]): EditorNode {
945
+ return {
946
+ id: CATALOG_ENTRY_ID,
947
+ label: 'entry (live preview)',
948
+ kind: 'group',
949
+ parentId: null,
950
+ childIds: rootIds,
951
+ flags: { runtimeOnly: true },
952
+ };
953
+ }
954
+
955
+ readonly hierarchy: HierarchyProvider = {
956
+ roots: () => {
957
+ const { nodes, rootIds } = this.snapshot();
958
+ if (!this.catalogOn) return rootIds.map((id) => this.toEditorNode(nodes.get(id)!));
959
+ return [
960
+ ...Object.keys(this.catalogComponents).map((key) => this.catalogNode(key)),
961
+ this.entryNode(rootIds),
962
+ ];
963
+ },
964
+ node: (id) => {
965
+ if (this.catalogOn) {
966
+ if (id === CATALOG_ENTRY_ID) return this.entryNode(this.snapshot().rootIds);
967
+ if (id.startsWith(CATALOG_ID_PREFIX)) {
968
+ const key = id.slice(CATALOG_ID_PREFIX.length);
969
+ return key in this.catalogComponents ? this.catalogNode(key) : null;
970
+ }
971
+ }
972
+ const n = this.snapshot().nodes.get(id);
973
+ return n ? this.toEditorNode(n) : null;
974
+ },
975
+ object3D: () => null, // react entities are DOM, not Object3D — no 3D gizmo binding
976
+ idForObject3D: () => null,
977
+ };
978
+
979
+ readonly selection: SelectionProvider = {
980
+ get: () => [...this.store.selectedEntityIds],
981
+ set: (ids) => this.store.selectMultiple(ids),
982
+ };
983
+
984
+ /**
985
+ * D12 (B4) — GEOMETRIC rect hit-test over the live OID tree, NOT
986
+ * `document.elementFromPoint` (which SKIPS a `pointer-events:none` wrapper —
987
+ * this layer's resting CSS state at design time, `design-time-layers.ts`'s
988
+ * `applySessionStyle`). Reuses `ui-editor/react-store.ts`'s established
989
+ * `hitTestAttr` ranking rule (smallest-area element containing the point
990
+ * wins — the deepest/most-specific node; ties broken by later tree-walk
991
+ * order — the topmost) over each OID node's own `getBoundingClientRect()`,
992
+ * inlined here rather than sharing that function directly since this walks
993
+ * `OidNode`s already resolved by `walkOidTree`, not a live `querySelectorAll`
994
+ * over a DOM attribute string.
995
+ *
996
+ * The winning id is the SAME disambiguated id `hierarchy`/`selection`
997
+ * already use (this IS `walkOidTree`'s own id space) — a hit routes
998
+ * straight into `composite.selection.set([id])` with no translation. A
999
+ * catalog node (`catalog:<key>`) has no DOM element behind it at all, so it
1000
+ * can never be a candidate here — only ordinary OID nodes are walked
1001
+ * (correct: nothing on screen corresponds to a bare catalog entry).
1002
+ */
1003
+ readonly pickable: PickProvider = {
1004
+ pick: (clientX, clientY) => {
1005
+ const { nodes } = this.snapshot();
1006
+ let bestId: string | null = null;
1007
+ let bestArea = Number.POSITIVE_INFINITY;
1008
+ let bestOrder = -1;
1009
+ let order = -1;
1010
+ for (const node of nodes.values()) {
1011
+ order++;
1012
+ // D4.R1 — a locked node is SKIPPED, not returned: exactly the
1013
+ // `viewport-raycast.ts` first-party semantics ("skip locked
1014
+ // entities in viewport selection", falling through to whatever
1015
+ // unlocked node is behind/around it). See `lockedIds`'s doc
1016
+ // comment for why this Set, not a real inspector-backed field.
1017
+ if (this.lockedIds.has(node.id)) continue;
1018
+ const rect = node.el.getBoundingClientRect?.();
1019
+ if (!rect) continue; // no live rect (test fixture, or unmounted) — never a candidate
1020
+ if (
1021
+ clientX < rect.left ||
1022
+ clientX > rect.right ||
1023
+ clientY < rect.top ||
1024
+ clientY > rect.bottom
1025
+ ) {
1026
+ continue;
1027
+ }
1028
+ const area = rect.width * rect.height;
1029
+ // smallest-area (deepest) wins; ties broken by later tree-order (topmost)
1030
+ if (area < bestArea || (area === bestArea && order > bestOrder)) {
1031
+ bestId = node.id;
1032
+ bestArea = area;
1033
+ bestOrder = order;
1034
+ }
1035
+ }
1036
+ return bestId;
1037
+ },
1038
+ };
1039
+
1040
+ /** Host rect to subtract for {@link rects}' HOST-RELATIVE geometry — `this.root`
1041
+ * IS the world's own mounted DOM layer (`world.reactRoot()` / the ingest
1042
+ * sibling's `layer`, see the constructor call sites), i.e. exactly the
1043
+ * `position:absolute; inset:0` per-world surface the overlay (Phase A3) is
1044
+ * itself hosted over. No new constructor option is needed — reusing the
1045
+ * proven `react-store.ts` `nodeRect` pattern (element rect minus stage/host
1046
+ * rect) against the field this adapter already holds. */
1047
+ private hostRect(): { left: number; top: number } {
1048
+ const r = this.root.getBoundingClientRect?.();
1049
+ return { left: r?.left ?? 0, top: r?.top ?? 0 };
1050
+ }
1051
+
1052
+ private toHostRelative(r: {
1053
+ left: number;
1054
+ top: number;
1055
+ width: number;
1056
+ height: number;
1057
+ }): DOMRectLike {
1058
+ const host = this.hostRect();
1059
+ return { x: r.left - host.left, y: r.top - host.top, width: r.width, height: r.height };
1060
+ }
1061
+
1062
+ /**
1063
+ * T0 (spec 27 §2) — per-node screen geometry for the DOM visual editor's
1064
+ * overlay/snap/measure math. `rect`/`contextRects` return HOST-RELATIVE
1065
+ * coordinates (see {@link hostRect}'s doc comment) — the overlay this feeds
1066
+ * (Phase A3, `WorldSelectionOverlay`) is itself mounted as a
1067
+ * `position:absolute; inset:0` layer over the same per-world DOM host, so a
1068
+ * host-relative rect is exactly what it can draw against with no further
1069
+ * translation (matching the established `ui-editor/react-store.ts`
1070
+ * `nodeRect` pattern this reuses).
1071
+ */
1072
+ readonly rects: RectProvider = {
1073
+ rect: (id) => {
1074
+ const n = this.snapshot().nodes.get(id);
1075
+ const r = n?.el.getBoundingClientRect?.();
1076
+ return r ? this.toHostRelative(r) : null;
1077
+ },
1078
+ contextRects: (id) => {
1079
+ const { nodes } = this.snapshot();
1080
+ const n = nodes.get(id);
1081
+ if (!n) return {};
1082
+ const parentNode = n.parentId ? nodes.get(n.parentId) : undefined;
1083
+ const parentRect = parentNode?.el.getBoundingClientRect?.();
1084
+ const siblingIds = (parentNode ? parentNode.childIds : this.snapshot().rootIds).filter(
1085
+ (sid) => sid !== id,
1086
+ );
1087
+ const siblings = siblingIds
1088
+ .map((sid) => nodes.get(sid)?.el.getBoundingClientRect?.())
1089
+ .filter((r): r is NonNullable<typeof r> => r != null)
1090
+ .map((r) => this.toHostRelative(r));
1091
+ // Padding box (CSS box model, inside the border) — border widths read off
1092
+ // the same computed-style resolver the inspector uses; absent/unparsable
1093
+ // border widths degrade to 0 (padding box === border box), never thrown.
1094
+ const el = n.el;
1095
+ const rect = el.getBoundingClientRect?.();
1096
+ let paddingBox: DOMRectLike | undefined;
1097
+ if (rect) {
1098
+ const bt =
1099
+ numericStyleValue(getComputedStyleValue(el, 'borderTopWidth', this.computedStyle)) ?? 0;
1100
+ const br =
1101
+ numericStyleValue(getComputedStyleValue(el, 'borderRightWidth', this.computedStyle)) ?? 0;
1102
+ const bb =
1103
+ numericStyleValue(getComputedStyleValue(el, 'borderBottomWidth', this.computedStyle)) ??
1104
+ 0;
1105
+ const bl =
1106
+ numericStyleValue(getComputedStyleValue(el, 'borderLeftWidth', this.computedStyle)) ?? 0;
1107
+ paddingBox = this.toHostRelative({
1108
+ left: rect.left + bl,
1109
+ top: rect.top + bt,
1110
+ width: Math.max(0, rect.width - bl - br),
1111
+ height: Math.max(0, rect.height - bt - bb),
1112
+ });
1113
+ }
1114
+ return {
1115
+ ...(parentRect ? { parent: this.toHostRelative(parentRect) } : {}),
1116
+ ...(siblings.length ? { siblings } : {}),
1117
+ ...(paddingBox ? { paddingBox } : {}),
1118
+ };
1119
+ },
1120
+ // D3.c (spec 27 §6) — every currently-empty OID container: no visible
1121
+ // (OID or non-OID) child ELEMENT, no text, feeding the pure
1122
+ // `findEmptyContainers` math (`ui-source/inspect.ts:463`) unchanged. Rule
1123
+ // zero stays intact — the DOM READ happens HERE, in the adapter; the
1124
+ // overlay/shell only ever sees the already-filtered `{id, rect,
1125
+ // displayName}` result.
1126
+ emptyContainers: () => {
1127
+ const { nodes } = this.snapshot();
1128
+ const candidates: EmptyCandidate[] = [];
1129
+ for (const n of nodes.values()) {
1130
+ const r = n.el.getBoundingClientRect?.();
1131
+ if (!r) continue;
1132
+ candidates.push({
1133
+ oid: n.id,
1134
+ rect: this.toHostRelative(r),
1135
+ displayName: n.tag,
1136
+ hasVisibleChildren: n.el.children.length > 0,
1137
+ hasText: (n.el.textContent ?? '').trim().length > 0,
1138
+ });
1139
+ }
1140
+ return findEmptyContainers(candidates).map((c) => ({
1141
+ id: c.oid,
1142
+ rect: c.rect,
1143
+ displayName: c.displayName,
1144
+ }));
1145
+ },
1146
+ };
1147
+
1148
+ /**
1149
+ * D3.d (spec 27 §6) — the eyedropper FALLBACK color-sample path: pick the
1150
+ * topmost OID node at the point (reusing this adapter's OWN `pickable.pick`,
1151
+ * never `elementFromPoint`), then walk its ancestor chain collecting each
1152
+ * node's RAW (un-normalized) computed `background-color` — hit-element
1153
+ * first, matching `effectiveColorFromChain`'s expected order. Raw, not
1154
+ * `cssColorToHex`-normalized, so a `transparent`/`rgba(0,0,0,0)` background
1155
+ * is recognizable as such by `isTransparentBackground` (the hex form would
1156
+ * lose that signal — see the contract's own doc comment on
1157
+ * `ColorSampleProvider`).
1158
+ */
1159
+ readonly colorSample: ColorSampleProvider = {
1160
+ backgroundChainAt: (clientX, clientY) => {
1161
+ const hitId = this.pickable.pick(clientX, clientY);
1162
+ if (!hitId) return null;
1163
+ const { nodes } = this.snapshot();
1164
+ const chain: string[] = [];
1165
+ let cur: OidNode | undefined = nodes.get(hitId);
1166
+ while (cur) {
1167
+ chain.push(getComputedStyleValue(cur.el, 'backgroundColor', this.computedStyle));
1168
+ cur = cur.parentId ? nodes.get(cur.parentId) : undefined;
1169
+ }
1170
+ return chain;
1171
+ },
1172
+ };
1173
+
1174
+ /**
1175
+ * T0 (spec 27 §4, B1) — spatial drag-resize/move/spacing → source write, for
1176
+ * non-Object3D (DOM) nodes. `apply` is LIVE PREVIEW ONLY: it mutates the live
1177
+ * element's inline style directly, with ZERO backend traffic (acceptance:311 —
1178
+ * a resize drag must not spam the dev server with a write per frame). `end`
1179
+ * commits every touched prop ONCE through the existing {@link writeStyleEntry}
1180
+ * write pipeline (CSS-file routing + append-aware undo preserved unchanged),
1181
+ * composed into exactly ONE undo entry per gesture (acceptance:310) even when
1182
+ * the gesture touched multiple props (e.g. a corner-resize writes both `width`
1183
+ * and `height`). See {@link boxEditSession}'s doc comment for the
1184
+ * `priorInline` capture this depends on for a correct undo inverse.
1185
+ */
1186
+ readonly boxEdit: BoxEditProvider = {
1187
+ begin: (id) => {
1188
+ // A stale, never-`end`ed session (caller bug) is simply replaced — its
1189
+ // preview mutations are already live on the DOM either way.
1190
+ this.boxEditSession = { id, touched: new Map(), priorInline: new Map() };
1191
+ },
1192
+ apply: (id, patch) => {
1193
+ const session = this.boxEditSession;
1194
+ if (!session || session.id !== id) return; // no open gesture for this id
1195
+ const n = this.snapshot().nodes.get(id);
1196
+ if (!n) return; // unresolved id — no-op (per contract)
1197
+ const pos = getComputedStyleValue(n.el, 'position', this.computedStyle);
1198
+ const isPositioned = pos === 'absolute' || pos === 'fixed';
1199
+ for (const [key, v] of Object.entries(patch)) {
1200
+ const mapped = mapBoxEditPatchKey(key, isPositioned);
1201
+ if (!mapped) {
1202
+ console.warn(
1203
+ `[ReactWorldAuthoringAdapter] boxEdit: dropping patch key "${key}" for "${id}" — ` +
1204
+ (key === 'x' || key === 'y'
1205
+ ? `node is not absolutely/fixed positioned (computed position: "${pos}"), ` +
1206
+ 'no left/top to move'
1207
+ : 'unrecognized box-edit patch key'),
1208
+ );
1209
+ continue;
1210
+ }
1211
+ // Lazily snapshot the TRUE pre-gesture inline value the first time THIS
1212
+ // prop is touched in this gesture — before mutating it — see
1213
+ // `boxEditSession`'s doc comment for why this can't be re-derived later.
1214
+ if (!session.priorInline.has(mapped.prop)) {
1215
+ const prevRaw = styleProp(n.el.style, mapped.prop);
1216
+ session.priorInline.set(mapped.prop, prevRaw == null ? '' : String(prevRaw));
1217
+ }
1218
+ const cssValue = mapped.cssValue(v);
1219
+ if (n.el.style) (n.el.style as Record<string, unknown>)[mapped.prop] = cssValue;
1220
+ // Commit numerics as plain numbers (matching every other numeric style
1221
+ // write in this adapter — see `writeStyle`'s callers), transform as a
1222
+ // string — NOT the px-suffixed preview string, which is preview-only.
1223
+ session.touched.set(mapped.prop, mapped.prop === 'transform' ? cssValue : v);
1224
+ }
1225
+ },
1226
+ end: (id) => {
1227
+ const session = this.boxEditSession;
1228
+ this.boxEditSession = null;
1229
+ if (!session || session.id !== id || session.touched.size === 0) return;
1230
+ void this.commitBoxEdit(id, session.touched, session.priorInline);
1231
+ },
1232
+ };
1233
+
1234
+ /**
1235
+ * T0 (spec 27 §4, B1) — commit every prop touched by one `boxEdit` gesture, each
1236
+ * through {@link writeStyleEntry} (so CSS-file routing / append-aware undo are
1237
+ * unchanged), then compose all resulting entries into exactly ONE undo entry
1238
+ * (acceptance:310) whose inverse runs in REVERSE order and whose redo runs in
1239
+ * gesture order — mirroring how a multi-statement edit undoes as one unit
1240
+ * elsewhere in this adapter.
1241
+ */
1242
+ private async commitBoxEdit(
1243
+ id: string,
1244
+ touched: Map<string, string | number>,
1245
+ priorInline: Map<string, string>,
1246
+ ): Promise<void> {
1247
+ // A4 — echo every touched prop BEFORE any write, so the inspector field shows
1248
+ // the dragged value at once (same discipline as `inspector.set`). The echo holds
1249
+ // the BARE numeric (a `type:'number'` descriptor's `Inspector` field needs an
1250
+ // actual number — see `inspector.get`'s `numericStyleValue` note), NOT the
1251
+ // px-suffixed source form D1 writes below.
1252
+ for (const [prop, value] of touched) {
1253
+ this.setEcho(id, `${STYLE_PATH_PREFIX}${prop}`, value);
1254
+ }
1255
+ const entries: PendingUndo[] = [];
1256
+ for (const [prop, value] of touched) {
1257
+ // D1 (spec 27 §4 B2/B3 reload-safety) — a numeric length-prop value must
1258
+ // persist to JSX SOURCE in a form React honors on REMOUNT. React 19 DROPS a
1259
+ // bare UNITLESS numeric STRING (the writer quotes `String(152)` → `width:
1260
+ // '152'`) on reload — the element collapses to content size (verified in real
1261
+ // Chromium + React 19) — but HONORS a quoted CSS length (`width: '152px'`).
1262
+ // `boxEdit.apply` stores length props as bare NUMBERS and CSS-string props
1263
+ // (`transform` → `'rotate(90deg)'`) as strings, so a numeric value here is
1264
+ // EXACTLY the set of length props (width/height/left/top/margin*/padding*)
1265
+ // needing a `px` unit — suffix only those, leaving `transform` untouched.
1266
+ // (The live-preview `el.style` path already applied a px-suffixed string via
1267
+ // `mapBoxEditPatchKey.cssValue`; only the persisted-source form was unitless.)
1268
+ // Localized to this box-edit commit — the color/`inspector.set` write path is
1269
+ // deliberately NOT changed.
1270
+ const writeValue = typeof value === 'number' ? `${value}px` : value;
1271
+ const entry = await this.writeStyleEntry(id, prop, writeValue, priorInline.get(prop));
1272
+ if (entry) entries.push(entry);
1273
+ }
1274
+ if (entries.length === 0) return;
1275
+ this.pushUndo({
1276
+ undo: () => {
1277
+ for (let i = entries.length - 1; i >= 0; i--) entries[i]!.undo();
1278
+ },
1279
+ redo: () => {
1280
+ for (const entry of entries) entry.redo();
1281
+ },
1282
+ });
1283
+ }
1284
+
1285
+ /**
1286
+ * T0 (spec 27 §2) — brings the react adapter's existing off-contract `editText`
1287
+ * (below) onto the contract. `get` is a best-effort CLIENT-side read: the live
1288
+ * DOM can only rule out "has child elements" (`el.children.length > 0`), not a
1289
+ * dynamic `{expression}` body — that guard is source-side and already enforced
1290
+ * at write time (`editText`'s `res.dynamic`, surfaced as a loud console warning
1291
+ * on refusal). `set` fires the existing undo-tracked write path unchanged.
1292
+ */
1293
+ readonly text: TextProvider = {
1294
+ get: (id) => {
1295
+ // D3.R1 — a node already refused once as a dynamic-body text edit
1296
+ // (`markDynamic(id, TEXT_PATH)`, set from `editText`'s `res.dynamic`
1297
+ // refusal below) reports null from here on this session, mirroring
1298
+ // U4's style/prop after-touch marker — so a SECOND double-click reports
1299
+ // the refusal immediately instead of re-opening the textarea only to
1300
+ // have the source refuse it again.
1301
+ if (this.dynamicPaths.has(`${id}|${TEXT_PATH}`)) return null;
1302
+ const n = this.snapshot().nodes.get(id);
1303
+ if (!n || n.el.children.length > 0) return null;
1304
+ const raw = n.el.textContent;
1305
+ if (raw == null) return null;
1306
+ const trimmed = raw.trim();
1307
+ return trimmed ? trimmed : null;
1308
+ },
1309
+ set: (id, text) => {
1310
+ void this.editText(id, text);
1311
+ },
1312
+ };
1313
+
1314
+ readonly inspector: InspectorProvider = {
1315
+ properties: (id: string): PropertyDescriptor[] => {
1316
+ // B3 — a catalog node (`catalog:<key>`) has no DOM element/fiber behind
1317
+ // it at all (`object3D`/`idForObject3D` are null for these ids too) —
1318
+ // style/Cap-4-props/tokens would be garbage for it. Its ONE inspector
1319
+ // surface is the active isolation story's props (see
1320
+ // `storyPropDescriptors`'s doc comment) — deliberately NOT routed
1321
+ // through `getComponentProps`/`writePropEdit` (Cap 4), which reads a
1322
+ // real call-site fiber that doesn't exist for a catalog entry.
1323
+ if (this.isCatalogId(id)) return this.storyPropDescriptors(id);
1324
+ const style = STYLE_PROPERTIES.map(({ prop, label, type, group, options }) => ({
1325
+ path: `${STYLE_PATH_PREFIX}${prop}`,
1326
+ label,
1327
+ type,
1328
+ group,
1329
+ ...(options ? { options } : {}),
1330
+ }));
1331
+ // Cap 4 (React visual-edit parity): append a "Props" section for this node's component
1332
+ // call site — its editable primitive props, read live off the React fiber
1333
+ // (`getComponentProps`). A write is guarded server-side (a dynamic prop is refused),
1334
+ // so surfacing every primitive prop here is safe. Cap 7 appends a read-only "Tokens"
1335
+ // section — the document's design tokens (`:root` custom properties).
1336
+ const all = [...style, ...this.propDescriptors(id), ...this.tokenDescriptors()];
1337
+ // U4 — a path already refused once as a dynamic expression (`markDynamic`,
1338
+ // set from `writeStyleEntry`/`writePropEdit`'s `res.dynamic` refusal) reports
1339
+ // `readonly: true` from here on, so the widget disables itself instead of
1340
+ // re-offering an edit the source will refuse again. See `dynamicPaths`'s
1341
+ // doc comment for why this is after-touch, not a pre-touch predictor.
1342
+ if (this.dynamicPaths.size === 0) return all;
1343
+ return all.map((p) =>
1344
+ this.dynamicPaths.has(`${id}|${p.path}`) ? { ...p, readonly: true } : p,
1345
+ );
1346
+ },
1347
+ get: (id, path) => {
1348
+ // A4 — read THROUGH the optimistic echo: a value just set (style/prop) wins over the
1349
+ // live-DOM walk until the source-write + HMR land, so the field never snaps back.
1350
+ const echoed = this.readEcho(id, path);
1351
+ if (echoed.hit) return echoed.value;
1352
+ if (this.isCatalogId(id)) {
1353
+ if (!path.startsWith(STORY_PROP_PATH_PREFIX)) return undefined;
1354
+ return this.activeIsolationStoryProps(id)?.[path.slice(STORY_PROP_PATH_PREFIX.length)];
1355
+ }
1356
+ // D4 (spec27 §6 D4, layer-tree visibility/lock) — the two reserved
1357
+ // paths GameHierarchy's row reads generically off ANY adapter's
1358
+ // `inspector`. `locked` is session-local (see `lockedIds`'s doc
1359
+ // comment — no source-backed equivalent). `visible` IS source-backed:
1360
+ // sugar for the `style.visibility` prop (read THROUGH that same prop's
1361
+ // own optimistic echo, so the eye icon never snap-backs while the
1362
+ // async write is in flight — same A4 discipline every other style
1363
+ // path gets).
1364
+ if (path === 'locked') return this.lockedIds.has(id);
1365
+ if (path === 'visible') {
1366
+ const echoedStyle = this.readEcho(id, `${STYLE_PATH_PREFIX}visibility`);
1367
+ if (echoedStyle.hit) return echoedStyle.value !== 'hidden';
1368
+ const n = this.snapshot().nodes.get(id);
1369
+ if (!n) return undefined;
1370
+ const raw = getComputedStyleValue(n.el, 'visibility', this.computedStyle);
1371
+ return raw !== 'hidden';
1372
+ }
1373
+ if (path.startsWith(TOKEN_PATH_PREFIX)) {
1374
+ const name = path.slice(TOKEN_PATH_PREFIX.length);
1375
+ return this.designTokens().find((t) => t.name === name)?.value;
1376
+ }
1377
+ if (path.startsWith(PROP_PATH_PREFIX)) {
1378
+ const n = this.snapshot().nodes.get(id);
1379
+ const cp = n ? getComponentProps(n.el) : null;
1380
+ return cp?.props[path.slice(PROP_PATH_PREFIX.length)];
1381
+ }
1382
+ if (!path.startsWith(STYLE_PATH_PREFIX)) return undefined;
1383
+ const prop = path.slice(STYLE_PATH_PREFIX.length);
1384
+ const n = this.snapshot().nodes.get(id);
1385
+ if (!n) return undefined;
1386
+ // Cap 1 (React visual-edit parity): read the COMPUTED value via the resolver, so a
1387
+ // property styled through a className utility (F5's class routing) OR a CSS file
1388
+ // resolves too — fixing the "class-styled props show blank" read-back gap. Under
1389
+ // vitest's `node` env the default resolver falls back to the element's inline
1390
+ // `.style`, so headless fixtures keep working unchanged.
1391
+ const raw = getComputedStyleValue(n.el, prop, this.computedStyle);
1392
+ const declaredType = STYLE_PROPERTY_TYPE.get(prop);
1393
+ // A `type: 'number'` descriptor (fontSize/width/height/padding/margin/
1394
+ // borderRadius/gap) needs an actual number for the generic numeric
1395
+ // input to render/edit correctly — a REAL `CSSStyleDeclaration` always
1396
+ // hands these back as a unit-suffixed STRING (e.g. `"16px"`, never a
1397
+ // bare `16`), which `Inspector.tsx`'s `typeof v === 'number'` check
1398
+ // would otherwise silently read as `0` (this repo's own
1399
+ // `react-world-authoring-adapter.test.ts` fixtures never caught this —
1400
+ // a hand-built plain-object `style: {}` can hold a bare JS number
1401
+ // directly, which a real DOM element's `style` never does). A
1402
+ // `type: 'color'` descriptor needs `#rrggbb` for the same reason —
1403
+ // see `cssColorToHex`'s doc comment.
1404
+ if (declaredType === 'number') return numericStyleValue(raw);
1405
+ if (declaredType === 'color') return cssColorToHex(raw);
1406
+ return raw || undefined; // an unset computed value reads '' — surface as blank
1407
+ },
1408
+ set: (id, path, value) => {
1409
+ if (this.isCatalogId(id)) {
1410
+ if (path.startsWith(STORY_PROP_PATH_PREFIX)) {
1411
+ this.writeStoryProp(id, path.slice(STORY_PROP_PATH_PREFIX.length), value);
1412
+ }
1413
+ return;
1414
+ }
1415
+ // D4 — see the matching `get` branch's doc comment.
1416
+ if (path === 'locked') {
1417
+ if (value) this.lockedIds.add(id);
1418
+ else this.lockedIds.delete(id);
1419
+ this.store.notifyIngestEdit();
1420
+ return;
1421
+ }
1422
+ if (path === 'visible') {
1423
+ const cssValue = value ? 'visible' : 'hidden';
1424
+ this.setEcho(id, `${STYLE_PATH_PREFIX}visibility`, cssValue);
1425
+ void this.writeStyle(id, 'visibility', cssValue);
1426
+ return;
1427
+ }
1428
+ if (path.startsWith(PROP_PATH_PREFIX)) {
1429
+ // A4 — echo BEFORE the async write so `inspector.get` shows the new value at once.
1430
+ this.setEcho(id, path, value);
1431
+ void this.writePropEdit(id, path.slice(PROP_PATH_PREFIX.length), String(value));
1432
+ return;
1433
+ }
1434
+ if (!path.startsWith(STYLE_PATH_PREFIX)) return;
1435
+ const prop = path.slice(STYLE_PATH_PREFIX.length);
1436
+ // A4 — echo BEFORE the async write (the desync fix): the field reflects the commit
1437
+ // within one frame; the write path clears this echo if the write is refused, and HMR
1438
+ // clears it once the re-render lands.
1439
+ this.setEcho(id, path, value);
1440
+ void this.writeStyle(id, prop, value as string | number);
1441
+ },
1442
+ remove: (id, path) => {
1443
+ // U2 — remove a stale CSS longhand override so its shorthand actually
1444
+ // wins (a uniform border/radius edit clears the per-side/per-corner
1445
+ // longhands a prior non-uniform edit wrote; without this the longhand
1446
+ // silently overrides the shorthand on reload — the D1 defect). Style
1447
+ // paths only; a `prop.`/`token.`/catalog path has no removable-override
1448
+ // meaning here (no-op). `removeStyle` is itself a source no-op when the
1449
+ // longhand isn't authored, so calling this unconditionally for all four
1450
+ // corners / twelve side-props is safe and never pollutes clean source.
1451
+ if (this.isCatalogId(id)) return;
1452
+ if (!path.startsWith(STYLE_PATH_PREFIX)) return;
1453
+ const prop = path.slice(STYLE_PATH_PREFIX.length);
1454
+ // Point the removed longhand's echo at the value it actually renders at
1455
+ // once the override is gone — the shorthand's current (just-committed)
1456
+ // value — so `inspector.get(longhand)` reports the resolved corner value,
1457
+ // not a stale override or an empty computed read, and stays consistent
1458
+ // after HMR clears the echo (the shorthand then cascades to it).
1459
+ const shorthand = LONGHAND_TO_SHORTHAND[prop];
1460
+ if (shorthand)
1461
+ this.setEcho(id, path, this.inspector.get(id, `${STYLE_PATH_PREFIX}${shorthand}`));
1462
+ else this.clearEcho(id, path);
1463
+ void this.removeStyleProp(id, prop);
1464
+ },
1465
+ };
1466
+
1467
+ /** B3 — true for a catalog node id (`catalog:<key>`), never for the entry
1468
+ * node or an ordinary DOM node id. */
1469
+ private isCatalogId(id: string): boolean {
1470
+ return id.startsWith(CATALOG_ID_PREFIX);
1471
+ }
1472
+
1473
+ /** B3 — the active isolation story's `props` for catalog node `id`, or
1474
+ * `null` when `id` isn't the CURRENTLY isolated node (no active story to
1475
+ * show props for — v1's inspector surface only has meaning once isolated,
1476
+ * see the class doc comment on `isolate`). An isolation with no story
1477
+ * picked (`storyId` undefined) or an unresolvable story both read as `{}`
1478
+ * (no props), not `null` — `null` means "not this node". */
1479
+ private activeIsolationStoryProps(id: string): Record<string, unknown> | null {
1480
+ if (!this.isolated || this.isolated.nodeId !== id) return null;
1481
+ const storyId = this.isolated.storyId;
1482
+ const story = storyId ? this.mergedStories().get(storyId) : undefined;
1483
+ return story?.props ?? {};
1484
+ }
1485
+
1486
+ /** B3 — PropertyDescriptors for the active isolation story's props (see
1487
+ * {@link activeIsolationStoryProps}), grouped under "Story Props". Every
1488
+ * code-authored prop the component receives is otherwise read-only (spec:
1489
+ * "there is nowhere to persist an edit to") — these ARE writable because
1490
+ * they resolve through the B2 story-overlay (`writeStoryProp`), a
1491
+ * session-scoped seam, not the component's JSX call site. */
1492
+ private storyPropDescriptors(id: string): PropertyDescriptor[] {
1493
+ const props = this.activeIsolationStoryProps(id);
1494
+ if (!props) return [];
1495
+ return Object.keys(props).map((name) => {
1496
+ // Derive the descriptor type from the story value so the generic
1497
+ // inspector renders the right widget: a boolean story prop (e.g. a
1498
+ // modal's `open: true`) as a checkbox, a number as a number input, a
1499
+ // nested object/array as JSON — NOT a text input that would stringify
1500
+ // every edit (typing "false" into a text field writes the truthy string
1501
+ // `'false'`, so a bool could never be toggled off from the UI).
1502
+ const value = props[name];
1503
+ const type: PropertyDescriptor['type'] =
1504
+ typeof value === 'boolean'
1505
+ ? 'boolean'
1506
+ : typeof value === 'number'
1507
+ ? 'number'
1508
+ : value !== null && typeof value === 'object'
1509
+ ? 'json'
1510
+ : 'string';
1511
+ return {
1512
+ path: `${STORY_PROP_PATH_PREFIX}${name}`,
1513
+ label: name,
1514
+ type,
1515
+ group: 'Story Props',
1516
+ };
1517
+ });
1518
+ }
1519
+
1520
+ /**
1521
+ * B3 — write a catalog node's active-isolation-story prop through the B2
1522
+ * story-overlay (`../authoring/story-overlay.ts`'s `setStoryOverlay`) —
1523
+ * the one write seam that doesn't require a JSX call site (a catalog
1524
+ * component has none — see `inspector.properties`'s catalog branch doc
1525
+ * comment). Clones the active story, patches ONE prop, writes the overlay
1526
+ * for THIS world (keyed by the required `worldId` constructor option — no
1527
+ * world id ⇒ nowhere to key the overlay, a loud warning, no silent no-op),
1528
+ * then re-invokes `stories.isolate` with the SAME node/story so the layer
1529
+ * re-renders against the patched props immediately (mirrors `stories.apply`'s
1530
+ * "re-render in place" contract — never a remount).
1531
+ */
1532
+ private writeStoryProp(nodeId: string, propName: string, value: unknown): void {
1533
+ if (!this.isolated || this.isolated.nodeId !== nodeId) {
1534
+ console.warn(
1535
+ `[ReactWorldAuthoringAdapter] cannot write story prop "${propName}" on "${nodeId}": ` +
1536
+ 'not currently isolated.',
1537
+ );
1538
+ return;
1539
+ }
1540
+ if (!this.worldId) {
1541
+ console.warn(
1542
+ `[ReactWorldAuthoringAdapter] cannot write story prop "${propName}" on "${nodeId}": ` +
1543
+ 'no world id configured for the story overlay (session-only writes unavailable).',
1544
+ );
1545
+ return;
1546
+ }
1547
+ const { storyId } = this.isolated;
1548
+ if (!storyId) {
1549
+ console.warn(
1550
+ `[ReactWorldAuthoringAdapter] cannot write story prop "${propName}" on "${nodeId}": ` +
1551
+ 'no story is currently applied to isolate against.',
1552
+ );
1553
+ return;
1554
+ }
1555
+ const base = this.mergedStories().get(storyId);
1556
+ const patched: UIStory = {
1557
+ label: base?.label ?? storyId,
1558
+ data: base?.data ?? {},
1559
+ props: { ...(base?.props ?? {}), [propName]: value },
1560
+ };
1561
+ setStoryOverlay(this.worldId, { ...(getStoryOverlay(this.worldId) ?? {}), [storyId]: patched });
1562
+ this.doIsolate(nodeId, storyId); // re-render against the patched story
1563
+ }
1564
+
1565
+ /** Cap 4: PropertyDescriptors for a node's editable component props (read off the fiber). */
1566
+ private propDescriptors(id: string): PropertyDescriptor[] {
1567
+ const n = this.snapshot().nodes.get(id);
1568
+ const cp = n ? getComponentProps(n.el) : null;
1569
+ if (!cp) return [];
1570
+ return Object.keys(cp.props).map((name) => ({
1571
+ path: `${PROP_PATH_PREFIX}${name}`,
1572
+ label: name,
1573
+ type: 'string' as const,
1574
+ group: 'Props',
1575
+ }));
1576
+ }
1577
+
1578
+ /** Cap 7: read-only PropertyDescriptors for the document's design tokens (`:root` custom
1579
+ * properties, incl. Tailwind v4 @theme/oklch), shown under a "Tokens" section. */
1580
+ private tokenDescriptors(): PropertyDescriptor[] {
1581
+ return this.designTokens().map((t) => ({
1582
+ path: `${TOKEN_PATH_PREFIX}${t.name}`,
1583
+ label: t.name,
1584
+ type: 'string' as const,
1585
+ group: 'Tokens',
1586
+ readonly: true,
1587
+ }));
1588
+ }
1589
+
1590
+ /**
1591
+ * All configured story sources merged, later sources winning per id (see
1592
+ * {@link ReactWorldAuthoringOptions.storySources}'s doc comment). B3 — the
1593
+ * story-overlay slot (`../authoring/story-overlay.ts`) is ALSO re-read
1594
+ * fresh here (not only whatever snapshot `design-time-layers.ts` baked
1595
+ * into `storySources` at mount time) so a catalog node's story-prop write
1596
+ * (`writeStoryProp`, which calls `setStoryOverlay`) is visible on the very
1597
+ * next call — still LAST, so it wins on a collision, same precedence the
1598
+ * constructor's `storySources` array already documents. A no-op when
1599
+ * `worldId` wasn't configured (no overlay to key).
1600
+ */
1601
+ private mergedStories(): Map<string, UIStory> {
1602
+ const out = new Map<string, UIStory>();
1603
+ for (const source of this.storySources) {
1604
+ for (const [id, story] of Object.entries(source)) out.set(id, story);
1605
+ }
1606
+ if (this.worldId) {
1607
+ const overlay = getStoryOverlay(this.worldId);
1608
+ if (overlay) for (const [id, story] of Object.entries(overlay)) out.set(id, story);
1609
+ }
1610
+ return out;
1611
+ }
1612
+
1613
+ /**
1614
+ * D4 — storybook stories (`docs/unified-world-editor/26-unified-editor-spec.md`
1615
+ * §3 B2). B2 scope is WORLD-LEVEL: `storiesFor`/`active` ignore `nodeId`
1616
+ * entirely (every node in this world reports the SAME merged story list —
1617
+ * B3 is what differentiates a per-component catalog via `isolate`, which
1618
+ * this slice deliberately OMITS). `apply` is the one write path: it
1619
+ * validates the id (a loud console warning on an unknown id — never a
1620
+ * silent no-op that leaves the picker looking like nothing happened),
1621
+ * updates `activeStoryId`, invokes `onStoryApplied` (the design-time
1622
+ * layer's re-render hook), and notifies the store so the inspector's
1623
+ * dropdown re-reads `active()` immediately.
1624
+ */
1625
+ readonly stories: StoriesProvider = {
1626
+ storiesFor: (_nodeId): StoryRef[] =>
1627
+ [...this.mergedStories().entries()].map(([id, story]) => ({ id, label: story.label })),
1628
+ active: (_nodeId) => this.activeStoryId,
1629
+ apply: (_nodeId, storyId) => {
1630
+ if (storyId === null) {
1631
+ this.activeStoryId = null;
1632
+ this.onStoryApplied?.(null);
1633
+ this.store.notifyIngestEdit();
1634
+ return;
1635
+ }
1636
+ const story = this.mergedStories().get(storyId);
1637
+ if (!story) {
1638
+ console.warn(
1639
+ `[ReactWorldAuthoringAdapter] stories.apply: unknown story id "${storyId}" — ignoring.`,
1640
+ );
1641
+ return;
1642
+ }
1643
+ this.activeStoryId = storyId;
1644
+ this.onStoryApplied?.({ id: storyId, ...story });
1645
+ this.store.notifyIngestEdit();
1646
+ },
1647
+ // B3 — per-component catalog isolation (see `doIsolate`'s doc comment).
1648
+ isolate: (nodeId, storyId) => this.doIsolate(nodeId, storyId),
1649
+ };
1650
+
1651
+ /** B3 — the currently-isolated catalog node id, or `null` (the entry
1652
+ * preview is rendered). Read by `design-time-layers.ts`'s store
1653
+ * subscription (auto-exit when the selection no longer contains the
1654
+ * isolated node) and by `Inspector.tsx`'s catalog Isolate-toggle state
1655
+ * (same "ask the owning child adapter" discipline as `worldGroupChildAdapter`). */
1656
+ get isolatedNodeId(): string | null {
1657
+ return this.isolated?.nodeId ?? null;
1658
+ }
1659
+
1660
+ /**
1661
+ * B3 — `stories.isolate(nodeId, storyId?)`: render ONLY a catalog
1662
+ * component against a story (the storybook canvas), or restore the entry
1663
+ * preview.
1664
+ * - `nodeId === null` → clear the isolation, invoke `onIsolate(null)`
1665
+ * (the layer's re-render hook — see `design-time-layers.ts`), notify.
1666
+ * - a non-catalog id, or a catalog id whose key isn't actually registered
1667
+ * → a loud `console.warn`, no-op (never a silent no-op that leaves the
1668
+ * UI looking like nothing happened — house style, `stories.apply`'s
1669
+ * unknown-id case above).
1670
+ * - a valid catalog id → resolve the story from the merged sources (`{}`
1671
+ * props when no story/an unresolvable story id was given — isolating
1672
+ * with NO story is a valid "just show the component" state), invoke
1673
+ * `onIsolate`, notify the store so any open inspector re-reads state.
1674
+ */
1675
+ private doIsolate(nodeId: string | null, storyId?: string): void {
1676
+ if (nodeId === null) {
1677
+ this.isolated = null;
1678
+ this.onIsolate?.(null);
1679
+ this.store.notifyIngestEdit();
1680
+ return;
1681
+ }
1682
+ if (!this.isCatalogId(nodeId)) {
1683
+ console.warn(
1684
+ `[ReactWorldAuthoringAdapter] stories.isolate: "${nodeId}" is not a catalog component ` +
1685
+ 'node — ignoring.',
1686
+ );
1687
+ return;
1688
+ }
1689
+ const key = nodeId.slice(CATALOG_ID_PREFIX.length);
1690
+ if (!(key in this.catalogComponents)) {
1691
+ console.warn(
1692
+ `[ReactWorldAuthoringAdapter] stories.isolate: unknown catalog component "${key}" — ` +
1693
+ 'ignoring.',
1694
+ );
1695
+ return;
1696
+ }
1697
+ const story = storyId ? this.mergedStories().get(storyId) : undefined;
1698
+ this.isolated = { nodeId, componentKey: key, storyId };
1699
+ this.onIsolate?.({ componentKey: key, story: story ? { id: storyId!, ...story } : null });
1700
+ this.store.notifyIngestEdit();
1701
+ }
1702
+
1703
+ readonly structure: StructureProvider = {
1704
+ // Cap 5 (React visual-edit parity): real source structural ops. D-1 (Phase D,
1705
+ // spec27 §2): EVERY structural op — including `create`/`duplicate`, whose new
1706
+ // element has no OID until the next transform/HMR — IS pushed to the undo
1707
+ // timeline, via a checksum-guarded WHOLE-FILE snapshot inverse (`pushStructUndo`)
1708
+ // rather than a per-OID one. That sidesteps the OID problem entirely: undo/redo
1709
+ // never needs to address the new/moved element by OID, it just restores the
1710
+ // file's prior/next full text. (Previously irreversible in this slice — see
1711
+ // `pushStructUndo`'s doc comment for the full rationale and the delete case.)
1712
+ create: (kind, parentId) => {
1713
+ void this.structOp(parentId ?? '', 'create', { wrapperTag: kind });
1714
+ return '';
1715
+ },
1716
+ // Returns `removeElement`'s own `Promise<void>` (not `void this....`) so
1717
+ // `deleteSelection` (`editor-hotkeys.ts`) can `await` each id's write
1718
+ // before firing the next — see the CORRECTNESS INVARIANT comment below
1719
+ // for why that serialization (plus deletion ORDER) is what makes the
1720
+ // per-id fallback loop sound for an adapter WITHOUT `removeMany` (any
1721
+ // adapter override lacking `structure.removeMany` — `deleteSelection`
1722
+ // always prefers a batched `removeMany` when present, see below).
1723
+ remove: (id) => this.removeElement(id),
1724
+ // delete-order-residual fix (bug-panel follow-up to the multi-delete
1725
+ // corruption fix, 50f90a6d) — SOUND batched delete, ONE undo entry for
1726
+ // the whole selection. D4.R2 originally investigated and REJECTED
1727
+ // `removeMany` here as unsound because the OBVIOUS implementation is N
1728
+ // separate `structOp`-style calls, each targeting its OID's `{file,
1729
+ // line, col}` from the SERVER's `OidStore.index`
1730
+ // (`vite-plugin-ui-oid.ts`'s `handleStruct`) — exactly the per-id
1731
+ // `remove` loop's own stale-offset hazard (see the CORRECTNESS INVARIANT
1732
+ // comment below), just without the ordering discipline that loop needs
1733
+ // to stay sound. This implementation is NOT that: `removeManyElements`
1734
+ // posts every id's raw oid in ONE request to `/__ui-source/struct-many`
1735
+ // (`handleStructMany`, `vite-plugin-ui-oid.ts`), which resolves every
1736
+ // oid's offset against a SINGLE shared `readFileSync` snapshot (never a
1737
+ // per-id re-read, so no write-to-write staleness is even possible) and
1738
+ // applies them highest-offset-first in one pass, one write. That is also
1739
+ // why this is the fix for the DOM-reordered-vs-source residual the
1740
+ // per-id fallback below still carries: this batch never consults
1741
+ // `collectAllNodeIds`/hierarchy-walk order at all, so it is correct
1742
+ // regardless of whether the live DOM's child order matches the .tsx
1743
+ // source order. (Explicitly NOT a live re-transform/re-scan per id
1744
+ // either — see `deleteElements`'s doc comment in `writer.ts` for why
1745
+ // that would reopen a DIFFERENT unsoundness: occurrence-index-based oid
1746
+ // identity churns when same-tag siblings are removed mid-batch.)
1747
+ // Returns `removeManyElements`'s own `Promise<void>` (not `void
1748
+ // this....`) so `deleteSelection` can `await` the whole batch's write
1749
+ // landing before it returns, same reason `remove` above does.
1750
+ removeMany: (ids) => this.removeManyElements(ids),
1751
+ //
1752
+ // CORRECTNESS INVARIANT for the per-id `remove` loop (bug-panel reopen,
1753
+ // post-D4.R2) — `deleteSelection` (`editor-hotkeys.ts`) falls back to
1754
+ // this loop only when `removeMany` is ABSENT (a different adapter
1755
+ // override); for THIS adapter `removeMany` above is always preferred, so
1756
+ // this loop is dead code for react-world today, kept sound and
1757
+ // documented for any future override without a batched delete. It is
1758
+ // safe ONLY under TWO conditions `deleteSelection` enforces together —
1759
+ // neither held before the 50f90a6d fix (an unsorted, unawaited loop was
1760
+ // a real source-corruption hazard, reachable by an ordinary top-first
1761
+ // marquee/multi-select delete) — AND both assume the caller's walk order
1762
+ // (`collectAllNodeIds`, DOM order for this adapter) matches true SOURCE
1763
+ // order (the DOM-reordered-vs-source residual this file's `removeMany`
1764
+ // fixes for THIS adapter specifically):
1765
+ // 1. REVERSE (walk-)DOCUMENT ORDER — delete the bottom-most element
1766
+ // first, per the caller's own hierarchy walk. Every element's
1767
+ // `{line, col}` in `OidStore.index` is a snapshot from the LAST
1768
+ // real parse and is never refreshed between same-file writes (only
1769
+ // by a real Vite `transform()` re-run — the async HMR round trip
1770
+ // `pendingSourceReconcile` already tracks). Deleting bottom-up
1771
+ // means every write only ever removes source that sits BELOW every
1772
+ // id still queued — nothing ABOVE a queued id's own offset ever
1773
+ // shifts, so that offset stays valid no matter how many of its
1774
+ // later-walked siblings/descendants have already been removed. A
1775
+ // descendant is later in DFS pre-order than its ancestor, so this
1776
+ // same reverse-pre-order rule also deletes a selected descendant
1777
+ // before a selected ancestor — required, since deleting the
1778
+ // ancestor first would remove the descendant's own source out from
1779
+ // under it before its turn. This is sound ONLY when walk order ==
1780
+ // source order (true for ordinary JSX; false when a component's
1781
+ // live DOM child order is a runtime permutation of its JSX source,
1782
+ // e.g. `{[...els].reverse()}` over an array of DISTINCT element
1783
+ // values — see `test/delete-selection-dom-order-residual.test.ts`).
1784
+ // 2. SERIALIZED WRITES — `deleteSelection` `await`s each `remove(id)`
1785
+ // (this method returns `removeElement`'s promise instead of firing
1786
+ // it `void`) before starting the next. `writeStruct` is a real
1787
+ // network POST in production (`source-write-backend.ts`); two
1788
+ // in-flight, un-awaited requests for the SAME file can each read it
1789
+ // BEFORE either has written back, so whichever write lands second
1790
+ // silently clobbers (loses) the first — a lost-update race
1791
+ // independent of ordering. Awaiting each id in turn guarantees
1792
+ // request N only starts once request N-1's write has landed.
1793
+ // Together, every write in the sequence reads a file that already
1794
+ // reflects every prior delete in the sequence, and targets an offset
1795
+ // still valid against that file — the "safe" claim this comment used to
1796
+ // make unconditionally, which was FALSE for the raw (unsorted, top-first)
1797
+ // selection order `deleteSelection` used to iterate in
1798
+ // (`test/react-world-struct-undo.test.ts`'s own "offset-staleness
1799
+ // hazard" case, reachable through the real `deleteSelection` path, not
1800
+ // just a hand-picked unsafe direct-adapter call).
1801
+ duplicate: (id) => {
1802
+ void this.structOp(id, 'duplicate');
1803
+ return id;
1804
+ },
1805
+ reparent: (id, newParentId) => {
1806
+ if (!newParentId) return;
1807
+ const parentOid = this.oidOf(newParentId);
1808
+ if (parentOid) void this.structOp(id, 'reparent', { parentOid });
1809
+ },
1810
+ // T0 (spec 27 §2): bring the pre-existing off-contract `reorder(id, beforeId,
1811
+ // parentId)` method (below) onto the contract. D2.b (spec 27 §6) fix: a
1812
+ // `null` `beforeSiblingId` means "move to the end of id's OWN current
1813
+ // parent" (this contract method's own doc comment) — that needs a REAL
1814
+ // `parentOid` to target (`reorder`'s own `parentStart != null` end-of-list
1815
+ // branch), so resolve `id`'s CURRENT parent from the live snapshot for
1816
+ // that case. A non-null `beforeSiblingId` already fully determines the
1817
+ // target position via `targetOid` alone, so `parentId` stays `null` there
1818
+ // (matching this method's pre-D2 behavior — no other caller of this
1819
+ // contract method existed before D2.b's canvas drag-to-reorder, which is
1820
+ // the first to actually exercise the null/"move to end" case).
1821
+ reorder: (id, beforeSiblingId) => {
1822
+ const parentId =
1823
+ beforeSiblingId === null ? (this.snapshot().nodes.get(id)?.parentId ?? null) : null;
1824
+ this.reorder(id, beforeSiblingId, parentId);
1825
+ },
1826
+ // D3.e (spec 27 §2 T0 leftover) — the generic HTML kinds `create`
1827
+ // genuinely supports (see `CREATABLE_KINDS`'s doc comment below).
1828
+ // `parentId` is ignored: every OID node accepts any of these as a plain
1829
+ // child, mirroring `ui-authoring-adapter.ts`'s identically-parentId-
1830
+ // agnostic `creatableKinds`.
1831
+ creatableKinds: () => [...CREATABLE_KINDS],
1832
+ // D3.a (spec 27 §6) — bring the pre-existing off-contract `wrap`/`unwrap`
1833
+ // methods (below) onto the contract so the canvas context menu can reach
1834
+ // them through the adapter interface alone (rule zero).
1835
+ wrap: (id, wrapperTag) => this.wrap(id, wrapperTag),
1836
+ unwrap: (id) => this.unwrap(id),
1837
+ };
1838
+
1839
+ /** Wrap the element in a new container (Cap 5). */
1840
+ wrap(id: string, wrapperTag = 'div'): void {
1841
+ void this.structOp(id, 'wrap', { wrapperTag });
1842
+ }
1843
+
1844
+ /** Replace the element with its children (Cap 5). */
1845
+ unwrap(id: string): void {
1846
+ void this.structOp(id, 'unwrap');
1847
+ }
1848
+
1849
+ /** Reorder the element before a sibling (`beforeId`), or to the end of `parentId` when
1850
+ * `beforeId` is null (Cap 5, layer-tree drag-to-reorder). */
1851
+ reorder(id: string, beforeId: string | null, parentId: string | null): void {
1852
+ const opts: { targetOid?: string; parentOid?: string } = {};
1853
+ const targetOid = beforeId ? this.oidOf(beforeId) : undefined;
1854
+ const parentOid = parentId ? this.oidOf(parentId) : undefined;
1855
+ if (targetOid) opts.targetOid = targetOid;
1856
+ if (parentOid) opts.parentOid = parentOid;
1857
+ void this.structOp(id, 'reorder', opts);
1858
+ }
1859
+
1860
+ /** The underlying (component-global) OID for an entity id (strips the `#n` repeat tag). */
1861
+ private oidOf(id: string): string | undefined {
1862
+ return this.snapshot().nodes.get(id)?.oid;
1863
+ }
1864
+
1865
+ /** Run a structural op through the write backend (loud degradation without one). */
1866
+ private async structOp(
1867
+ id: string,
1868
+ op: string,
1869
+ opts?: { targetOid?: string; parentOid?: string; wrapperTag?: string },
1870
+ ): Promise<void> {
1871
+ const oid = this.oidOf(id);
1872
+ if (!oid) return;
1873
+ if (!this.writeBackend) {
1874
+ console.warn(
1875
+ `[ReactWorldAuthoringAdapter] cannot "${op}" on "${id}": no source-write backend in ` +
1876
+ 'this session (hosted/no dev server).',
1877
+ );
1878
+ return;
1879
+ }
1880
+ const res = await this.writeBackend.writeStruct(oid, op, opts);
1881
+ if (!res.changed) {
1882
+ console.warn(
1883
+ `[ReactWorldAuthoringAdapter] struct "${op}" refused/no-op for oid "${oid}": ${res.error ?? 'no change'}`,
1884
+ );
1885
+ return;
1886
+ }
1887
+ this.dirty = true;
1888
+ // D-1 (Phase D, spec27 §2): push the whole-file-snapshot undo/redo entry —
1889
+ // see `pushStructUndo`'s doc comment.
1890
+ this.pushStructUndo(op, res);
1891
+ // D3.R4 (reopen fix) — this notify's `storeVersion` still reflects the PRE-HMR DOM (see
1892
+ // `pendingSourceReconcile`'s doc comment); queue a pending reconcile so the upcoming
1893
+ // `vite:afterUpdate` (or a test's direct `reconcileEchoAfterReload()` call) notifies AGAIN
1894
+ // once the remount has actually happened.
1895
+ this.pendingSourceReconcile++;
1896
+ this.store.notifyIngestEdit();
1897
+ }
1898
+
1899
+ /**
1900
+ * D-1 (Phase D, spec27 §2 "known limitation to schedule, not hide"): push the
1901
+ * checksum-guarded whole-file-snapshot undo/redo entry for a successful structural
1902
+ * write — shared by {@link structOp} (create/duplicate/wrap/unwrap/reorder/reparent)
1903
+ * and {@link removeElement} (delete). A naive per-OID inverse is UNSOUND for a
1904
+ * structural op: OIDs are `file:component:tag:nthOccurrence` content signatures,
1905
+ * `reorder`/`delete` reassign occurrence indices out from under any OID captured
1906
+ * before the op, and `delete` has no inverse payload of its own (there is nothing
1907
+ * left to re-target). Instead the inverse is the WHOLE touched file's prior/next
1908
+ * text (`res.prevSource`/`res.newSource`, captured server-side by `handleStruct` —
1909
+ * `vite-plugin-ui-oid.ts`) replayed through a checksum-guarded restore
1910
+ * (`SourceWriteBackend.restoreSource`) — a plain overwrite the server refuses,
1911
+ * WITHOUT writing, if the file's current sha256 doesn't match the hash the caller
1912
+ * expects (an out-of-band change since, e.g. a hand-edit). Degrades to "not
1913
+ * undoable" (console.warn, no entry pushed) when the backend lacks `restoreSource`
1914
+ * or the write response is missing a snapshot field (an old/mocked backend) — an
1915
+ * honest limitation, never a crash.
1916
+ */
1917
+ private pushStructUndo(
1918
+ op: string,
1919
+ res: {
1920
+ file?: string;
1921
+ prevSource?: string;
1922
+ newSource?: string;
1923
+ prevSha?: string;
1924
+ newSha?: string;
1925
+ },
1926
+ ): void {
1927
+ const { file, prevSource, newSource, prevSha, newSha } = res;
1928
+ if (
1929
+ !this.writeBackend?.restoreSource ||
1930
+ file === undefined ||
1931
+ prevSource === undefined ||
1932
+ newSource === undefined ||
1933
+ prevSha === undefined ||
1934
+ newSha === undefined
1935
+ ) {
1936
+ console.warn(
1937
+ `[ReactWorldAuthoringAdapter] struct "${op}" is not undoable: the write backend ` +
1938
+ 'does not support whole-file restore (missing restoreSource or a snapshot field).',
1939
+ );
1940
+ return;
1941
+ }
1942
+ this.pushUndo({
1943
+ undo: () => void this.restoreStructSnapshot(file, prevSource, newSha),
1944
+ redo: () => void this.restoreStructSnapshot(file, newSource, prevSha),
1945
+ });
1946
+ }
1947
+
1948
+ /**
1949
+ * D-1 — the structural undo/redo inverse: POST the checksum-guarded whole-file
1950
+ * restore. A refusal (`res.restored === false` — the file's CURRENT sha256 didn't
1951
+ * match `ifMatchSha`, e.g. it changed out-of-band since this entry was recorded) is
1952
+ * a LOUD no-op: `restoreSource` never writes on refusal, and this still notifies the
1953
+ * store (same pattern as the D2 dynamic-write-refusal notify, `writeStyleEntry`/
1954
+ * `writePropEdit`) so the UI ticks and can render the refusal instead of silently
1955
+ * doing nothing.
1956
+ *
1957
+ * D3.R5 (reopen fix) — every `structOp`/`removeElement` undo/redo closure routes
1958
+ * here (see `pushStructUndo`), so a SUCCESSFUL restore is itself a structural DOM
1959
+ * change with the exact same pre-HMR-notify timing gap `structOp`/`removeElement`
1960
+ * have (see `pendingSourceReconcile`'s doc comment) — queue a pending reconcile so
1961
+ * hint-cache consumers reconcile once the restore's own remount actually lands. NOT
1962
+ * queued on a refused restore: `restoreSource` never writes when refused, so there is
1963
+ * no reload to await.
1964
+ */
1965
+ private async restoreStructSnapshot(
1966
+ file: string,
1967
+ source: string,
1968
+ ifMatchSha: string,
1969
+ ): Promise<void> {
1970
+ if (!this.writeBackend?.restoreSource) return;
1971
+ const res = await this.writeBackend.restoreSource(file, source, ifMatchSha);
1972
+ if (!res.restored) {
1973
+ console.warn(
1974
+ `[ReactWorldAuthoringAdapter] structural undo/redo refused for "${file}": ` +
1975
+ `${res.error ?? 'the file changed since this entry was recorded'}`,
1976
+ );
1977
+ } else {
1978
+ this.pendingSourceReconcile++;
1979
+ }
1980
+ this.store.notifyIngestEdit();
1981
+ }
1982
+
1983
+ /**
1984
+ * T0 (spec 27 §4, B1) — the write body extracted from {@link writeStyle}, RETURNING
1985
+ * the resulting {@link PendingUndo} entry instead of pushing it, so a caller that
1986
+ * writes MULTIPLE props for one logical gesture (`boxEdit`'s {@link commitBoxEdit})
1987
+ * can compose them into a single undo entry itself. `writeStyle` (below) is now a
1988
+ * one-line wrapper: identical behavior for every existing single-prop caller
1989
+ * (`inspector.set`). `priorInlineOverride`, when given, is used as the captured
1990
+ * PRE-gesture inline value instead of re-reading `n.el.style` — needed because
1991
+ * `boxEdit.apply` already mutated the live inline style for live preview before
1992
+ * `end` gets here, so a fresh DOM read would see the LAST previewed value, not the
1993
+ * true original (see {@link boxEditSession}'s doc comment). Unused by the plain CSS
1994
+ * cascade branch below (`pickCssRuleTarget`'s own `prevValue` comes from the matched
1995
+ * rule's text, not inline style, and is unaffected either way).
1996
+ */
1997
+ private async writeStyleEntry(
1998
+ id: string,
1999
+ prop: string,
2000
+ value: string | number,
2001
+ priorInlineOverride?: string,
2002
+ ): Promise<PendingUndo | null> {
2003
+ // A4 — the echo key `inspector.set` populated for this write; cleared on any failure
2004
+ // return below so a refused/unbacked write can't leave the field stuck on a value the
2005
+ // source never took.
2006
+ const echoPath = `${STYLE_PATH_PREFIX}${prop}`;
2007
+ const n = this.snapshot().nodes.get(id);
2008
+ if (!n) {
2009
+ this.clearEcho(id, echoPath);
2010
+ return null;
2011
+ }
2012
+ if (!this.writeBackend) {
2013
+ this.clearEcho(id, echoPath);
2014
+ console.warn(
2015
+ `[ReactWorldAuthoringAdapter] cannot write "${prop}" on "${id}": no source-write ` +
2016
+ 'backend in this session (hosted/no dev server) — selection/inspection still work.',
2017
+ );
2018
+ return null;
2019
+ }
2020
+ // Cap 2 (React visual-edit parity): if a first-party CSS RULE declares this property,
2021
+ // edit that CSS FILE (cascade-correct: the last matched rule wins) instead of writing
2022
+ // inline/class. A `generated: true` response means the selector isn't in source
2023
+ // (Tailwind/styled-components) — fall through to the inline/class path below.
2024
+ if (this.writeBackend.writeCss) {
2025
+ const cssTarget = pickCssRuleTarget(this.matchedCssRules(n.el), prop);
2026
+ if (cssTarget) {
2027
+ const { rule, prevValue } = cssTarget;
2028
+ const res = await this.writeBackend.writeCss(
2029
+ rule.sourceFile,
2030
+ rule.selectorText,
2031
+ prop,
2032
+ String(value),
2033
+ );
2034
+ if (res.changed) {
2035
+ this.dirty = true;
2036
+ this.store.notifyIngestEdit();
2037
+ return {
2038
+ undo: () => void this.applyCssWrite(rule, prop, prevValue),
2039
+ redo: () => void this.applyCssWrite(rule, prop, String(value)),
2040
+ };
2041
+ }
2042
+ if (!res.generated) {
2043
+ this.clearEcho(id, echoPath);
2044
+ console.warn(
2045
+ `[ReactWorldAuthoringAdapter] CSS write refused/no-op for selector ` +
2046
+ `"${rule.selectorText}" prop "${prop}": ${res.error ?? 'no change'}`,
2047
+ );
2048
+ return null;
2049
+ }
2050
+ // res.generated ⇒ selector is generated CSS — fall through to inline/class routing.
2051
+ }
2052
+ }
2053
+ // Capture the prior literal from the LIVE DOM before writing — the inverse
2054
+ // write for undo (§1.E: "store the prior literal, write it back on undo") —
2055
+ // unless the caller already captured the TRUE pre-gesture value itself.
2056
+ const prevRaw =
2057
+ priorInlineOverride !== undefined ? priorInlineOverride : styleProp(n.el.style, prop);
2058
+ const prev: string | number = typeof prevRaw === 'number' ? prevRaw : String(prevRaw ?? '');
2059
+ const res = await this.writeBackend.writeStyle(n.oid, prop, String(value));
2060
+ if (!res.changed) {
2061
+ this.clearEcho(id, echoPath);
2062
+ if (res.dynamic) this.markDynamic(id, echoPath);
2063
+ // D2 — notify so the refusal actually RE-RENDERS: the cleared echo (field
2064
+ // snaps back off the stale value) and, for a dynamic refusal, the now
2065
+ // `readonly: true` descriptor only reach the UI on a store tick. Without
2066
+ // this the widget keeps showing the refused value, enabled, until some
2067
+ // unrelated event happens to re-render.
2068
+ this.store.notifyIngestEdit();
2069
+ console.warn(
2070
+ `[ReactWorldAuthoringAdapter] style write refused/no-op for oid "${n.oid}" ` +
2071
+ `prop "${prop}": ${res.dynamic ? 'value is a dynamic expression (guarded)' : (res.error ?? 'no change')}`,
2072
+ );
2073
+ return null;
2074
+ }
2075
+ this.dirty = true;
2076
+ // D-A4 (wave 13, docs/WAVE13-JSX-SPREAD-UNDO-DESIGN.md): if this write took the
2077
+ // writer's APPEND branch (no prior literal for `prop`, e.g. spread-derived
2078
+ // `style={{ ...vars }}`), undo must REMOVE the appended prop rather than
2079
+ // replay the write with `prev` — replaying would find the NOW-appended
2080
+ // literal and REPLACE it with a hardcoded runtime value, baking a literal
2081
+ // into a spot the source never had one. Redo is a plain write either way (a
2082
+ // re-append is just a write).
2083
+ const appended = res.appended === true;
2084
+ // B1-parity live preview for single-prop inspector writes. The `boxEdit`
2085
+ // gesture patches `n.el.style` live during the drag (see `boxEdit.apply`),
2086
+ // but a plain single-prop write (a color / any inspector field) only wrote
2087
+ // SOURCE — so the live element didn't repaint until HMR/reload, which never
2088
+ // lands in-harness (27a-overlay.spec.ts:238: color edit's element stayed the
2089
+ // old color forever). Optimistically apply the committed value to the live
2090
+ // inline style here — mirroring `react-dom-authoring-adapter`'s own
2091
+ // `applyStyleToElement` — so the edit is visible immediately; HMR then
2092
+ // converges on the same value from source and the A4 echo re-syncs. Skipped
2093
+ // for the box-edit caller (`priorInlineOverride` set), which already applied
2094
+ // its own live preview and whose committed `value` can differ from the inline
2095
+ // CSS (e.g. a unitless length vs the `px` string it painted). INLINE branch
2096
+ // only — the CSS-cascade branch above returns early; patching inline there
2097
+ // would shadow the rule and stick past later edits.
2098
+ if (priorInlineOverride === undefined && n.el.style) {
2099
+ (n.el.style as Record<string, unknown>)[prop] = String(value);
2100
+ }
2101
+ this.store.notifyIngestEdit();
2102
+ return {
2103
+ undo: appended
2104
+ ? () => void this.applyStyleRemove(n.oid, prop)
2105
+ : () => void this.applyStyleWrite(n.oid, prop, prev),
2106
+ redo: () => void this.applyStyleWrite(n.oid, prop, value),
2107
+ };
2108
+ }
2109
+
2110
+ private async writeStyle(id: string, prop: string, value: string | number): Promise<void> {
2111
+ const entry = await this.writeStyleEntry(id, prop, value);
2112
+ if (entry) this.pushUndo(entry);
2113
+ }
2114
+
2115
+ /**
2116
+ * U2 (spec 27 §5 C2) — surgically REMOVE a style property's source override
2117
+ * (`inspector.remove`'s style path). Captures the prior source literal first
2118
+ * so the removal is undoable (undo re-writes it, redo re-removes); a source
2119
+ * no-op (`changed: false`, e.g. the prop wasn't authored) pushes NO undo
2120
+ * entry and touches nothing — which is what makes it safe to call for every
2121
+ * corner/side unconditionally on a uniform edit. Also drops the live inline
2122
+ * override so the element re-cascades to the shorthand immediately (mirrors
2123
+ * `writeStyleEntry`'s inline live-preview, inverse direction).
2124
+ */
2125
+ private async removeStyleProp(id: string, prop: string): Promise<void> {
2126
+ const n = this.snapshot().nodes.get(id);
2127
+ if (!n || !this.writeBackend) return;
2128
+ const prevRaw = styleProp(n.el.style, prop);
2129
+ const prev: string | number = typeof prevRaw === 'number' ? prevRaw : String(prevRaw ?? '');
2130
+ const res = await this.writeBackend.removeStyle(n.oid, prop);
2131
+ if (!res.changed) return; // longhand wasn't authored — nothing removed, no undo
2132
+ this.dirty = true;
2133
+ if (n.el.style) delete (n.el.style as Record<string, unknown>)[prop];
2134
+ this.store.notifyIngestEdit();
2135
+ this.pushUndo({
2136
+ undo: () => void this.applyStyleWrite(n.oid, prop, prev),
2137
+ redo: () => void this.applyStyleRemove(n.oid, prop),
2138
+ });
2139
+ }
2140
+
2141
+ private async applyStyleWrite(oid: string, prop: string, value: string | number): Promise<void> {
2142
+ if (!this.writeBackend) return;
2143
+ await this.writeBackend.writeStyle(oid, prop, String(value));
2144
+ this.store.notifyIngestEdit();
2145
+ }
2146
+
2147
+ /** D-A4: the append-aware undo target — removes a prop that had no prior source
2148
+ * literal, instead of writing a captured runtime value back into it. */
2149
+ private async applyStyleRemove(oid: string, prop: string): Promise<void> {
2150
+ if (!this.writeBackend) return;
2151
+ await this.writeBackend.removeStyle(oid, prop);
2152
+ this.store.notifyIngestEdit();
2153
+ }
2154
+
2155
+ /** Re-apply a CSS-file write (used by the undo/redo inverse for a Cap 2 CSS edit). */
2156
+ private async applyCssWrite(rule: CssRuleTarget, prop: string, value: string): Promise<void> {
2157
+ if (!this.writeBackend?.writeCss) return;
2158
+ await this.writeBackend.writeCss(rule.sourceFile, rule.selectorText, prop, value);
2159
+ this.store.notifyIngestEdit();
2160
+ }
2161
+
2162
+ /**
2163
+ * Cap 3 (React visual-edit parity): replace a leaf element's pure-text content in source
2164
+ * (double-click-to-edit). Refused (guarded) when the body has an expression or child
2165
+ * elements. Undoable: the prior text (returned by the backend) is written back on undo.
2166
+ */
2167
+ async editText(id: string, newText: string): Promise<void> {
2168
+ const n = this.snapshot().nodes.get(id);
2169
+ if (!n) return;
2170
+ if (!this.writeBackend?.writeText) {
2171
+ console.warn(
2172
+ `[ReactWorldAuthoringAdapter] cannot edit text on "${id}": no source-write backend ` +
2173
+ 'with text support in this session (hosted/no dev server).',
2174
+ );
2175
+ return;
2176
+ }
2177
+ const res = await this.writeBackend.writeText(n.oid, newText);
2178
+ if (!res.changed) {
2179
+ // D3.R1 (reopen fix) — a dynamic-body refusal marks this id readonly for
2180
+ // text edits (U4's session-scoped `dynamicPaths`, read by `text.get`
2181
+ // above) and — mirroring D2's style/prop precedent — notifies the store
2182
+ // UNCONDITIONALLY so the refusal (and the overlay's refused indicator,
2183
+ // which polls `text.get` off this same notify) renders at refusal time
2184
+ // instead of silently vanishing until an unrelated event ticks the store.
2185
+ if (res.dynamic) this.markDynamic(id, TEXT_PATH);
2186
+ this.store.notifyIngestEdit();
2187
+ console.warn(
2188
+ `[ReactWorldAuthoringAdapter] text edit refused/no-op for oid "${n.oid}": ` +
2189
+ `${res.dynamic ? 'body has an expression/children (guarded)' : (res.error ?? 'no change')}`,
2190
+ );
2191
+ return;
2192
+ }
2193
+ this.dirty = true;
2194
+ const prev = res.prevText ?? '';
2195
+ this.pushUndo({
2196
+ undo: () => void this.applyTextWrite(n.oid, prev),
2197
+ redo: () => void this.applyTextWrite(n.oid, newText),
2198
+ });
2199
+ // D3.R5 (reopen fix) — a successful text write flips `hasText`, itself a
2200
+ // `findEmptyContainers` hint-eligibility criterion (`ui-source/inspect.ts`), yet
2201
+ // populates no `valueEcho` (echo is only ever set by the inspector style/prop
2202
+ // paths) — same pre-HMR-notify timing gap as a structural op (see
2203
+ // `pendingSourceReconcile`'s doc comment): queue a pending reconcile.
2204
+ this.pendingSourceReconcile++;
2205
+ this.store.notifyIngestEdit();
2206
+ }
2207
+
2208
+ private async applyTextWrite(oid: string, text: string): Promise<void> {
2209
+ if (!this.writeBackend?.writeText) return;
2210
+ const res = await this.writeBackend.writeText(oid, text);
2211
+ // D3.R5 (reopen fix) — the text-undo/redo inverse is itself a text write with the
2212
+ // same `hasText` reconcile need as `editText`'s success path above; queued on
2213
+ // success only — a refused/no-op write never touches the counter.
2214
+ if (res.changed) this.pendingSourceReconcile++;
2215
+ this.store.notifyIngestEdit();
2216
+ }
2217
+
2218
+ /**
2219
+ * Cap 4 (React visual-edit parity): write a component prop at its CALL SITE (the
2220
+ * `<Component …>` tag), resolved from the live fiber (`getComponentProps` → callSiteOid).
2221
+ * Refused (guarded) when the prop is a dynamic expression. Undoable via the prior fiber
2222
+ * value.
2223
+ */
2224
+ private async writePropEdit(id: string, prop: string, value: string): Promise<void> {
2225
+ // A4 — the echo key `inspector.set` populated for this prop write; cleared on any
2226
+ // failure return so a refused write can't leave the field stuck.
2227
+ const echoPath = `${PROP_PATH_PREFIX}${prop}`;
2228
+ const n = this.snapshot().nodes.get(id);
2229
+ if (!n) {
2230
+ this.clearEcho(id, echoPath);
2231
+ return;
2232
+ }
2233
+ if (!this.writeBackend?.writeProp) {
2234
+ this.clearEcho(id, echoPath);
2235
+ console.warn(
2236
+ `[ReactWorldAuthoringAdapter] cannot write prop "${prop}" on "${id}": no source-write ` +
2237
+ 'backend with prop support in this session (hosted/no dev server).',
2238
+ );
2239
+ return;
2240
+ }
2241
+ const cp = getComponentProps(n.el);
2242
+ if (!cp) {
2243
+ this.clearEcho(id, echoPath);
2244
+ console.warn(
2245
+ `[ReactWorldAuthoringAdapter] "${id}" is not a component call site with props — ` +
2246
+ `cannot write prop "${prop}".`,
2247
+ );
2248
+ return;
2249
+ }
2250
+ const prev = cp.props[prop] ?? '';
2251
+ const res = await this.writeBackend.writeProp(cp.callSiteOid, prop, value);
2252
+ if (!res.changed) {
2253
+ this.clearEcho(id, echoPath);
2254
+ if (res.dynamic) this.markDynamic(id, echoPath);
2255
+ // D2 — notify so the cleared echo + (dynamic) new `readonly: true`
2256
+ // descriptor render at refusal time, not on the next unrelated event.
2257
+ this.store.notifyIngestEdit();
2258
+ console.warn(
2259
+ `[ReactWorldAuthoringAdapter] prop write refused/no-op for oid "${cp.callSiteOid}" ` +
2260
+ `prop "${prop}": ${res.dynamic ? 'value is a dynamic expression (guarded)' : (res.error ?? 'no change')}`,
2261
+ );
2262
+ return;
2263
+ }
2264
+ this.dirty = true;
2265
+ this.pushUndo({
2266
+ undo: () => void this.applyPropWrite(cp.callSiteOid, prop, prev),
2267
+ redo: () => void this.applyPropWrite(cp.callSiteOid, prop, value),
2268
+ });
2269
+ this.store.notifyIngestEdit();
2270
+ }
2271
+
2272
+ private async applyPropWrite(callSiteOid: string, prop: string, value: string): Promise<void> {
2273
+ if (!this.writeBackend?.writeProp) return;
2274
+ await this.writeBackend.writeProp(callSiteOid, prop, value);
2275
+ this.store.notifyIngestEdit();
2276
+ }
2277
+
2278
+ private async removeElement(id: string): Promise<void> {
2279
+ const n = this.snapshot().nodes.get(id);
2280
+ if (!n) return;
2281
+ if (!this.writeBackend) {
2282
+ console.warn(
2283
+ `[ReactWorldAuthoringAdapter] cannot delete "${id}": no source-write backend in ` +
2284
+ 'this session (hosted/no dev server).',
2285
+ );
2286
+ return;
2287
+ }
2288
+ const res = await this.writeBackend.writeStruct(n.oid, 'delete');
2289
+ if (!res.changed) {
2290
+ console.warn(
2291
+ `[ReactWorldAuthoringAdapter] delete refused/no-op for oid "${n.oid}": ${res.error ?? 'no change'}`,
2292
+ );
2293
+ return;
2294
+ }
2295
+ this.dirty = true;
2296
+ // D-1 (Phase D, spec27 §2): delete IS undoable now, via the SAME checksum-guarded
2297
+ // whole-file-snapshot inverse `structOp` uses (`pushStructUndo`) — it sidesteps
2298
+ // "no element-insertion primitive" entirely by never re-inserting the deleted
2299
+ // element from an OID; it replays the whole file's PRIOR text instead.
2300
+ this.pushStructUndo('delete', res);
2301
+ // D3.R4 (reopen fix) — same pre-HMR-notify timing gap `structOp` has (see
2302
+ // `pendingSourceReconcile`'s doc comment); `delete` is one of the two triggering
2303
+ // scenarios the reviewer's contrast probe reproduced.
2304
+ this.pendingSourceReconcile++;
2305
+ this.store.notifyIngestEdit();
2306
+ }
2307
+
2308
+ /**
2309
+ * delete-order-residual fix — the `structure.removeMany` backing. Resolves every
2310
+ * `id` to its raw (non-disambiguated) oid, dedupes (a repeated-OID `.map` list's
2311
+ * `#n` siblings share ONE raw oid — deleting it once is correct, per
2312
+ * `walkOidTree`'s own disambiguation comment), and posts them ALL in ONE
2313
+ * `writeStructMany` call — see that method's doc comment on `SourceWriteBackend`
2314
+ * (`source-write-backend.ts`) and `handleStructMany`'s (`vite-plugin-ui-oid.ts`)
2315
+ * for the soundness argument (one shared file snapshot, highest-offset-first,
2316
+ * caller-order-independent). Degrades to a loud no-op — never a silent partial
2317
+ * delete — when this session's backend hasn't implemented `writeStructMany` (a
2318
+ * hosted/no-dev-server session, or a legacy test mock): `deleteSelection`
2319
+ * (`editor-hotkeys.ts`) only reaches this method because `structure.removeMany`
2320
+ * is present at all, so there is no further per-id fallback to drop into here.
2321
+ */
2322
+ private async removeManyElements(ids: readonly string[]): Promise<void> {
2323
+ const snapshot = this.snapshot();
2324
+ const oids = [
2325
+ ...new Set(
2326
+ ids.map((id) => snapshot.nodes.get(id)?.oid).filter((oid): oid is string => !!oid),
2327
+ ),
2328
+ ];
2329
+ if (oids.length === 0) return;
2330
+ if (!this.writeBackend?.writeStructMany) {
2331
+ console.warn(
2332
+ '[ReactWorldAuthoringAdapter] cannot batch-delete: no source-write backend in this ' +
2333
+ 'session supports writeStructMany (hosted/no dev server, or a legacy mock).',
2334
+ );
2335
+ return;
2336
+ }
2337
+ const res = await this.writeBackend.writeStructMany(oids, 'delete');
2338
+ if (!res.changed) {
2339
+ console.warn(
2340
+ `[ReactWorldAuthoringAdapter] batch delete refused/no-op for oids [${oids.join(', ')}]: ` +
2341
+ `${res.error ?? 'no change'}`,
2342
+ );
2343
+ return;
2344
+ }
2345
+ this.dirty = true;
2346
+ // D-1 (Phase D, spec27 §2): same checksum-guarded whole-file-snapshot inverse
2347
+ // `removeElement`/`structOp` use (`pushStructUndo`) — ONE entry for the whole
2348
+ // batch (a bonus of batching, not the point of this fix).
2349
+ this.pushStructUndo('delete-many', res);
2350
+ this.pendingSourceReconcile++;
2351
+ this.store.notifyIngestEdit();
2352
+ }
2353
+
2354
+ private pushUndo(entry: PendingUndo): void {
2355
+ this.undoStack.push(entry);
2356
+ this.redoStack.length = 0;
2357
+ notifyUndoableOp(this); // T3.2 slice 2 — tag this op on the global timeline
2358
+ }
2359
+
2360
+ undo(): boolean {
2361
+ const e = this.undoStack.pop();
2362
+ if (!e) return false;
2363
+ e.undo();
2364
+ this.redoStack.push(e);
2365
+ return true;
2366
+ }
2367
+
2368
+ redo(): boolean {
2369
+ const e = this.redoStack.pop();
2370
+ if (!e) return false;
2371
+ e.redo();
2372
+ this.undoStack.push(e);
2373
+ return true;
2374
+ }
2375
+
2376
+ subscribe(listener: () => void): () => void {
2377
+ return this.store.subscribe(listener);
2378
+ }
2379
+
2380
+ // A class GETTER, not a field initializer — see `ui-authoring-adapter.ts`/
2381
+ // `vgai-scene-authoring-adapter.ts` for why (field initializers run before the
2382
+ // constructor body assigns `this.writeBackend`).
2383
+ get persistence(): PersistenceProvider {
2384
+ const backend = this.writeBackend;
2385
+ return {
2386
+ isDirty: () => this.dirty,
2387
+ save: async () => {
2388
+ // Immediate-write architecture (same as UIAuthoringAdapter/
2389
+ // SourceWriteBackend's doc comment) — every edit already landed on disk
2390
+ // the instant it was made; nothing is pending to flush.
2391
+ },
2392
+ serialize: () => ({
2393
+ note: 'react world edits write directly to JSX source — nothing is serialized here',
2394
+ }),
2395
+ destination: backend
2396
+ ? 'component source (JSX, via /__ui-source — writes are immediate; nothing pending to flush)'
2397
+ : 'component source (JSX) — no source-write backend in this session (hosted/no dev server)',
2398
+ };
2399
+ }
2400
+ }