@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,1041 @@
1
+ /**
2
+ * React-world inspector section (C2, spec 27 §5 C2,
3
+ * docs/unified-world-editor/27-visual-react-editing.md) — the "wire the
4
+ * widgets" half of Phase C. C1 (`components/inspector-widgets/`) built a
5
+ * PURE widget kit with no wiring; this module is the adapter-module
6
+ * contribution that plugs it into the ONE inspector shell
7
+ * (`components/Inspector.tsx`) via `inspector-section-registry.ts`, the SAME
8
+ * seam `authoring/first-party-inspector/register.tsx` (threejs) and
9
+ * `authoring/ui-inspector-sections.tsx` (scene-UI) already use — see those
10
+ * two files for the sanctioned pattern this one follows.
11
+ *
12
+ * Matches any node owned by a react/DOM adapter — `ReactWorldAuthoringAdapter`
13
+ * (Lane B, `data-oid` component-source writes, T6.2) or
14
+ * `ReactDomAuthoringAdapter` (the ingested/overlay DOM adapter, wave 10) —
15
+ * whether the active adapter IS one of those directly (a bare single-world
16
+ * session) or is a `CompositeAuthoringAdapter` routing to one as `id`'s
17
+ * owner (multi-world edit mode). Renders every `PropertyDescriptor` the
18
+ * adapter's `InspectorProvider.properties(id)` reports, GROUPED by
19
+ * `PropertyDescriptor.group` (falling back to one flat "Style" group for an
20
+ * adapter, like `ReactDomAuthoringAdapter`, whose descriptors declare none),
21
+ * mapping each descriptor to the right C1 widget instead of a generic
22
+ * `<input>` — see `widgetKindFor`'s doc comment for the exact mapping table.
23
+ * `suppresses: ['properties']` replaces the shell's generic property grid
24
+ * for a matched node (Rule zero: this section still talks to the node ONLY
25
+ * through `adapter.inspector`/`adapter.hierarchy`, never the DOM/fiber the
26
+ * adapter itself holds).
27
+ *
28
+ * `onChange` (every interaction tick) and `onChangeEnd` (the commit) both
29
+ * route through the SAME `adapter.inspector.set` call — unlike
30
+ * `BoxEditProvider`'s begin/apply/end split (a spatial-gesture concern,
31
+ * Phase B), `ReactWorldAuthoringAdapter.inspector.set` has exactly ONE write
32
+ * path (T0 §2/A4): it echoes the value into its optimistic cache
33
+ * synchronously (so the field never snaps back) and fires the async
34
+ * source-write itself. There is no separate "preview without writing" mode
35
+ * to route `onChange` through, so both handlers call `commit`, mirroring the
36
+ * shell's own pre-existing `setProp` helper (`components/Inspector.tsx`)
37
+ * which already does the same on every keystroke of the generic grid this
38
+ * section replaces.
39
+ */
40
+
41
+ import type { AuthoringAdapter, EditorNode, PropertyDescriptor } from '@engine/adapter';
42
+ import { useMemo } from 'react';
43
+ import { groupProperties } from '../components/inspector-property-grouping';
44
+ import {
45
+ AlignmentGrid,
46
+ type AlignmentValue,
47
+ BorderEditor,
48
+ BorderRadiusEditor,
49
+ type BorderRadiusValue,
50
+ type BorderSide,
51
+ type BorderValue,
52
+ ColorSwatch,
53
+ composeFilter,
54
+ composeGradient,
55
+ composeShadows,
56
+ FilterEditor,
57
+ type FilterFunc,
58
+ FONT_MONO,
59
+ FontPicker,
60
+ GradientEditor,
61
+ type GradientValue,
62
+ inputStyle,
63
+ isUniformBorder,
64
+ isUniformRadius,
65
+ parseFilterFunctions,
66
+ parseGradient,
67
+ parseShadows,
68
+ rowStyle,
69
+ ScrubbableInput,
70
+ ShadowEditor,
71
+ TextShadowEditor,
72
+ THEME,
73
+ } from '../components/inspector-widgets';
74
+ import { useEditorStore } from '../EditorContext';
75
+ import {
76
+ type InspectorSectionProps,
77
+ registerInspectorSections,
78
+ } from '../inspector-section-registry';
79
+ import { effectiveColorFromChain, getAvailableFonts } from '../ui-source/inspect';
80
+ import { CompositeAuthoringAdapter } from './composite-authoring-adapter';
81
+ import { beginEyedropperSession } from './eyedropper-session';
82
+ import { ReactDomAuthoringAdapter } from './react-dom-authoring-adapter';
83
+ import {
84
+ cssColorToHex,
85
+ numericStyleValue,
86
+ ReactWorldAuthoringAdapter,
87
+ } from './react-world-authoring-adapter';
88
+
89
+ /**
90
+ * The child adapter that actually owns `node.id` — a bare react/DOM adapter
91
+ * itself, or (via a `CompositeAuthoringAdapter`) whichever child's
92
+ * `ownerOf`/`childAdapters()` claims the id (the SAME public T0 query
93
+ * `components/Inspector.tsx`'s `worldGroupChildAdapter`/`catalogNodeOwnerAdapter`
94
+ * use — never decode a reserved id here). `null` for anything not owned by a
95
+ * react/DOM adapter (a threejs/scene-UI node, or an unresolved id).
96
+ */
97
+ export function resolveReactOwner(
98
+ node: EditorNode | null,
99
+ adapter: AuthoringAdapter,
100
+ ): ReactWorldAuthoringAdapter | ReactDomAuthoringAdapter | null {
101
+ if (!node) return null;
102
+ const target: AuthoringAdapter | null = (() => {
103
+ if (!(adapter instanceof CompositeAuthoringAdapter)) return adapter;
104
+ const owner = adapter.ownerOf(node.id);
105
+ if (owner === null) return null;
106
+ return adapter.childAdapters().find((c) => c.worldId === owner)?.adapter ?? null;
107
+ })();
108
+ if (target instanceof ReactWorldAuthoringAdapter) return target;
109
+ if (target instanceof ReactDomAuthoringAdapter) return target;
110
+ return null;
111
+ }
112
+
113
+ /** The registry `match`: any real selection owned by a react/DOM adapter. */
114
+ export const isReactDomSelection = (node: EditorNode | null, adapter: AuthoringAdapter): boolean =>
115
+ resolveReactOwner(node, adapter) !== null;
116
+
117
+ // --- widget mapping (the C2 "map PropertyDescriptor.type/group -> widget" table) ---
118
+
119
+ export type ReactPropertyWidgetKind =
120
+ | 'color'
121
+ | 'font'
122
+ | 'gradient'
123
+ | 'shadow'
124
+ | 'text-shadow'
125
+ | 'filter'
126
+ | 'backdrop-filter'
127
+ | 'radius'
128
+ | 'transform'
129
+ | 'scrub'
130
+ | 'select'
131
+ | 'checkbox'
132
+ | 'json'
133
+ | 'text';
134
+
135
+ function propNameFromPath(path: string): string {
136
+ const idx = path.indexOf('.');
137
+ return idx === -1 ? path : path.slice(idx + 1);
138
+ }
139
+
140
+ /**
141
+ * A single `PropertyDescriptor` -> the C1 widget that renders it. Structured
142
+ * CSS values (gradient/shadow/filter/radius) are matched by their well-known
143
+ * style-prop NAME (the react adapters' `STYLE_PROPERTIES` tables are the
144
+ * only source of these paths today — `PropertyDescriptor.type` alone can't
145
+ * distinguish a plain string from a composable CSS function list); every
146
+ * other descriptor maps off its plain `type`.
147
+ */
148
+ export function widgetKindFor(prop: PropertyDescriptor): ReactPropertyWidgetKind {
149
+ const name = propNameFromPath(prop.path);
150
+ if (prop.type === 'color') return 'color';
151
+ if (/^fontFamily$/i.test(name)) return 'font';
152
+ if (name === 'background' || name === 'backgroundImage') return 'gradient';
153
+ if (name === 'boxShadow') return 'shadow';
154
+ if (name === 'textShadow') return 'text-shadow';
155
+ if (name === 'filter') return 'filter';
156
+ if (name === 'backdropFilter') return 'backdrop-filter';
157
+ if (name === 'borderRadius') return 'radius';
158
+ // Scoped to `type: 'string'` (the react-world `style.transform` CSS-string
159
+ // descriptor) so an unrelated `transform` name of a DIFFERENT type — e.g. a
160
+ // threejs `vec3` position/transform prop — still falls through to its own
161
+ // generic widget instead of being misread as a CSS transform string.
162
+ if (prop.type === 'string' && name === 'transform') return 'transform';
163
+ if (prop.type === 'boolean') return 'checkbox';
164
+ if (prop.type === 'enum') return 'select';
165
+ if (prop.type === 'json') return 'json';
166
+ if (prop.type === 'number') return 'scrub';
167
+ // 'string' / 'vec3' / 'asset' — no dedicated widget in this task's mapping;
168
+ // falls through to a plain text input (see the report's "no widget" list).
169
+ return 'text';
170
+ }
171
+
172
+ /** A composed row: either one property, or a "combo" of several sibling
173
+ * properties this section renders as ONE richer widget (per-side border,
174
+ * 3x3 flex alignment) instead of N separate fields. */
175
+ export type PropertyRow =
176
+ | {
177
+ kind: 'border';
178
+ width: PropertyDescriptor;
179
+ style: PropertyDescriptor;
180
+ color: PropertyDescriptor;
181
+ }
182
+ | { kind: 'align'; justify: PropertyDescriptor; align: PropertyDescriptor }
183
+ | { kind: 'single'; prop: PropertyDescriptor };
184
+
185
+ /**
186
+ * Groups the react-world adapter's separate `borderWidth`/`borderStyle`/
187
+ * `borderColor` scalars into one `BorderEditor` row, and `justifyContent`/
188
+ * `alignItems` into one `AlignmentGrid` row — order-preserving single pass
189
+ * (a combo is emitted at the position of its FIRST member; already-consumed
190
+ * members are skipped later in the pass). Any property list missing one or
191
+ * more combo members (e.g. `ReactDomAuthoringAdapter`'s flatter list) simply
192
+ * never forms that combo — every member renders as its own `single` row.
193
+ */
194
+ /** Detects the `borderWidth`/`borderStyle`/`borderColor` trio starting at
195
+ * `p` and, if all three are present, pushes ONE `border` combo row. Split
196
+ * out of {@link buildPropertyRows} to keep its own cognitive complexity
197
+ * under biome's ceiling (same discipline as `GradientEditor.tsx`'s helper
198
+ * extraction). Returns `true` only when a combo row was actually formed (so
199
+ * the caller can `continue` past all three consumed members); `false` when
200
+ * `p` isn't a border-trio member, or is one but its siblings aren't present
201
+ * (falls through to an ordinary `single` row instead). */
202
+ function tryBorderCombo(
203
+ p: PropertyDescriptor,
204
+ byPath: ReadonlyMap<string, PropertyDescriptor>,
205
+ consumed: Set<string>,
206
+ rows: PropertyRow[],
207
+ ): boolean {
208
+ const isBorderProp =
209
+ p.path === 'style.borderWidth' ||
210
+ p.path === 'style.borderStyle' ||
211
+ p.path === 'style.borderColor';
212
+ if (!isBorderProp) return false;
213
+ const width = byPath.get('style.borderWidth');
214
+ const style = byPath.get('style.borderStyle');
215
+ const color = byPath.get('style.borderColor');
216
+ if (!width || !style || !color) return false;
217
+ rows.push({ kind: 'border', width, style, color });
218
+ consumed.add(width.path);
219
+ consumed.add(style.path);
220
+ consumed.add(color.path);
221
+ return true;
222
+ }
223
+
224
+ /** Detects the `justifyContent`/`alignItems` pair starting at `p` and, if
225
+ * both are present, pushes ONE `align` combo row. See {@link tryBorderCombo}'s
226
+ * doc comment for why this is its own function. */
227
+ function tryAlignCombo(
228
+ p: PropertyDescriptor,
229
+ byPath: ReadonlyMap<string, PropertyDescriptor>,
230
+ consumed: Set<string>,
231
+ rows: PropertyRow[],
232
+ ): boolean {
233
+ const isAlignProp = p.path === 'style.justifyContent' || p.path === 'style.alignItems';
234
+ if (!isAlignProp) return false;
235
+ const justify = byPath.get('style.justifyContent');
236
+ const align = byPath.get('style.alignItems');
237
+ if (!justify || !align) return false;
238
+ rows.push({ kind: 'align', justify, align });
239
+ consumed.add(justify.path);
240
+ consumed.add(align.path);
241
+ return true;
242
+ }
243
+
244
+ export function buildPropertyRows(props: readonly PropertyDescriptor[]): PropertyRow[] {
245
+ const byPath = new Map(props.map((p) => [p.path, p] as const));
246
+ const consumed = new Set<string>();
247
+ const rows: PropertyRow[] = [];
248
+ for (const p of props) {
249
+ if (consumed.has(p.path)) continue;
250
+ if (tryBorderCombo(p, byPath, consumed, rows)) continue;
251
+ if (tryAlignCombo(p, byPath, consumed, rows)) continue;
252
+ rows.push({ kind: 'single', prop: p });
253
+ }
254
+ return rows;
255
+ }
256
+
257
+ const UNITLESS_NUMBER_PROPS = new Set(['opacity', 'zIndex', 'flexGrow', 'flexShrink']);
258
+
259
+ function numericValue(v: unknown): number {
260
+ return typeof v === 'number' && Number.isFinite(v) ? v : 0;
261
+ }
262
+
263
+ function testIdFor(path: string): string {
264
+ return `react-prop-${path.replace(/\./g, '-')}`;
265
+ }
266
+
267
+ const DEFAULT_GRADIENT: GradientValue = {
268
+ type: 'linear',
269
+ angle: 180,
270
+ stops: [
271
+ { color: '#ffffff', position: 0 },
272
+ { color: '#000000', position: 1 },
273
+ ],
274
+ };
275
+
276
+ /** A labeled row shell, matching `components/Inspector.tsx`'s own generic
277
+ * property row layout (`NUM_STYLE`'s spacing/label conventions, ported to
278
+ * this kit's `THEME`/`rowStyle`) so a react section slots in visually next
279
+ * to the sections it replaces. The wrapper's own test id is the row's
280
+ * `${testId}-row` — DISTINCT from the interactive control inside it (which
281
+ * carries the bare `testId`), so a select/text/json control and its row
282
+ * wrapper never collide (spec 27 §5 C2, S1). */
283
+ function Field({
284
+ label,
285
+ testId,
286
+ children,
287
+ }: {
288
+ label: string;
289
+ testId?: string | undefined;
290
+ children: React.ReactNode;
291
+ }): React.ReactElement {
292
+ return (
293
+ <div
294
+ data-testid={testId ? `${testId}-row` : undefined}
295
+ style={{ ...rowStyle, padding: '4px 8px' }}
296
+ >
297
+ <span style={{ width: 76, flexShrink: 0, fontSize: 11, color: THEME.textMuted }}>
298
+ {label}
299
+ </span>
300
+ <div style={{ flex: 1, minWidth: 0 }}>{children}</div>
301
+ </div>
302
+ );
303
+ }
304
+
305
+ /** {@link PropertyRow}'s two combo variants — excludes `single` so
306
+ * {@link ComboRow} (only ever called for a combo row, see `GroupBlock`)
307
+ * narrows on `row.kind` without TS widening back to the full union. */
308
+ type ComboPropertyRow = Extract<PropertyRow, { kind: 'border' } | { kind: 'align' }>;
309
+
310
+ interface ComboRowProps {
311
+ adapter: AuthoringAdapter;
312
+ id: string;
313
+ row: ComboPropertyRow;
314
+ }
315
+
316
+ /** Every per-kind row renderer's shared inputs — `raw` is the CURRENT value
317
+ * read off `adapter.inspector.get`, `commit` writes a (possibly different)
318
+ * path through the SAME `adapter.inspector.set` + `notifyIngestEdit` pair
319
+ * (see the file doc comment) so a combo write-back (border/align, above)
320
+ * and a plain single-prop write share one code path. */
321
+ interface KindRowProps {
322
+ prop: PropertyDescriptor;
323
+ raw: unknown;
324
+ disabled: boolean | undefined;
325
+ testId: string;
326
+ commit: (path: string, value: unknown) => void;
327
+ fonts: readonly string[];
328
+ }
329
+
330
+ function ColorRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
331
+ return (
332
+ <Field label={prop.label} testId={testId}>
333
+ <ColorSwatch
334
+ testId={testId}
335
+ value={String(raw ?? '#ffffff')}
336
+ disabled={disabled}
337
+ onChange={(c) => commit(prop.path, c)}
338
+ onChangeEnd={(c) => commit(prop.path, c)}
339
+ eyedropperFallback={() => effectiveColorFromChain([String(raw ?? '')])}
340
+ // D3.d (spec 27 §6) — no native EyeDropper: begin an interactive
341
+ // canvas-pick session (`WorldSelectionOverlay` renders the
342
+ // cursor-following swatch and resolves it) instead of the static
343
+ // synchronous fallback above, which this session-based path replaces
344
+ // whenever the host (this section, mounted inside the real editor)
345
+ // is present to wire it.
346
+ onEyedropperPick={(apply) => beginEyedropperSession(apply)}
347
+ />
348
+ </Field>
349
+ );
350
+ }
351
+
352
+ function FontRow({ prop, raw, disabled, fonts, testId, commit }: KindRowProps): React.ReactElement {
353
+ return (
354
+ <Field label={prop.label} testId={testId}>
355
+ <FontPicker
356
+ testId={testId}
357
+ value={String(raw ?? '')}
358
+ fonts={fonts}
359
+ disabled={disabled}
360
+ onChange={(f) => commit(prop.path, f)}
361
+ />
362
+ </Field>
363
+ );
364
+ }
365
+
366
+ function GradientRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
367
+ const parsed = parseGradient(String(raw ?? 'none'));
368
+ // S2 (anti-shim, CLAUDE.md "adapters never fabricate first-party data"): when
369
+ // the element has NO real gradient (`parseGradient` -> null), render an honest
370
+ // empty state — NOT a fabricated white→black `DEFAULT_GRADIENT` preview that
371
+ // misrepresents the source as already having a gradient. `DEFAULT_GRADIENT` is
372
+ // still the SEED for an explicit user "add" (a real, source-writing action),
373
+ // never a passive display value.
374
+ if (!parsed) {
375
+ return (
376
+ <Field label={prop.label} testId={testId}>
377
+ <button
378
+ type="button"
379
+ data-testid={testId}
380
+ disabled={disabled}
381
+ onClick={() => commit(prop.path, composeGradient(DEFAULT_GRADIENT))}
382
+ style={{
383
+ ...inputStyle(disabled),
384
+ width: '100%',
385
+ textAlign: 'left',
386
+ cursor: disabled ? 'not-allowed' : 'pointer',
387
+ color: THEME.textMuted,
388
+ }}
389
+ >
390
+ No gradient — click to add
391
+ </button>
392
+ </Field>
393
+ );
394
+ }
395
+ return (
396
+ <Field label={prop.label} testId={testId}>
397
+ <GradientEditor
398
+ testId={testId}
399
+ value={parsed}
400
+ disabled={disabled}
401
+ onChange={(g) => commit(prop.path, composeGradient(g))}
402
+ onChangeEnd={(g) => commit(prop.path, composeGradient(g))}
403
+ />
404
+ </Field>
405
+ );
406
+ }
407
+
408
+ function ShadowRow({
409
+ prop,
410
+ raw,
411
+ disabled,
412
+ testId,
413
+ commit,
414
+ withSpreadInset,
415
+ }: KindRowProps & { withSpreadInset: boolean }): React.ReactElement {
416
+ const parsed = parseShadows(String(raw ?? 'none'), withSpreadInset);
417
+ const Widget = withSpreadInset ? ShadowEditor : TextShadowEditor;
418
+ return (
419
+ <Field label={prop.label} testId={testId}>
420
+ <Widget
421
+ value={parsed}
422
+ disabled={disabled}
423
+ onChange={(list) => commit(prop.path, composeShadows(list, withSpreadInset))}
424
+ onChangeEnd={(list) => commit(prop.path, composeShadows(list, withSpreadInset))}
425
+ />
426
+ </Field>
427
+ );
428
+ }
429
+
430
+ function FilterRow({
431
+ prop,
432
+ raw,
433
+ disabled,
434
+ testId,
435
+ commit,
436
+ label,
437
+ }: KindRowProps & { label: string }): React.ReactElement {
438
+ const parsed = parseFilterFunctions(String(raw ?? 'none'));
439
+ return (
440
+ <Field label={prop.label} testId={testId}>
441
+ <FilterEditor
442
+ testId={testId}
443
+ label={label}
444
+ value={parsed}
445
+ disabled={disabled}
446
+ onChange={(f: FilterFunc[]) => commit(prop.path, composeFilter(f))}
447
+ onChangeEnd={(f: FilterFunc[]) => commit(prop.path, composeFilter(f))}
448
+ />
449
+ </Field>
450
+ );
451
+ }
452
+
453
+ /** Corner suffixes (`borderTopLeftRadius`, …) shared by {@link RadiusRow}'s
454
+ * read + write-back — the per-corner longhand CSS the shorthand `borderRadius`
455
+ * descriptor (`prop`/`raw`/`commit` above) never had a slot for. */
456
+ const RADIUS_CORNERS: ReadonlyArray<{ key: keyof BorderRadiusValue; suffix: string }> = [
457
+ { key: 'topLeft', suffix: 'TopLeft' },
458
+ { key: 'topRight', suffix: 'TopRight' },
459
+ { key: 'bottomLeft', suffix: 'BottomLeft' },
460
+ { key: 'bottomRight', suffix: 'BottomRight' },
461
+ ];
462
+
463
+ /** U2 (spec 27 §5 "Effects: radius per-corner") — true per-corner write-back.
464
+ * Reads each corner's own `style.border<Corner>Radius` longhand through
465
+ * `adapter.inspector.get` (falling back to the shorthand `raw` when a
466
+ * corner's own computed value is unset/unparseable — the vitest `node` env
467
+ * resolver falls back to inline `.style`, which has no implicit per-corner
468
+ * values the way a REAL computed style always does) so the widget reflects
469
+ * a PRIOR non-uniform edit instead of collapsing back to uniform on every
470
+ * re-render. Write-back keeps the uniform shorthand fast-path (`commit`
471
+ * the same `borderRadius` path this row already used) but writes the four
472
+ * longhand corners when the widget reports a non-uniform value — removing
473
+ * the documented `:397-409` limitation (per-corner UI, uniform-only write). */
474
+ function RadiusRow({
475
+ adapter,
476
+ id,
477
+ prop,
478
+ raw,
479
+ disabled,
480
+ testId,
481
+ commit,
482
+ removeStyle,
483
+ }: KindRowProps & {
484
+ adapter: AuthoringAdapter;
485
+ id: string;
486
+ removeStyle: (path: string) => void;
487
+ }): React.ReactElement {
488
+ const fallback = numericValue(raw);
489
+ const value: BorderRadiusValue = RADIUS_CORNERS.reduce((acc, { key, suffix }) => {
490
+ const cornerRaw = adapter.inspector?.get(id, `style.border${suffix}Radius`);
491
+ acc[key] = numericStyleValue(cornerRaw) ?? fallback;
492
+ return acc;
493
+ }, {} as BorderRadiusValue);
494
+ const writeBack = (v: BorderRadiusValue): void => {
495
+ if (isUniformRadius(v)) {
496
+ commit(prop.path, v.topLeft);
497
+ // D1 — a uniform edit must also CLEAR any per-corner longhands a prior
498
+ // non-uniform edit wrote, or the stale longhand silently overrides the
499
+ // shorthand on reload. `removeStyle` no-ops when the longhand isn't
500
+ // authored, so this is safe on a clean element (never pollutes source).
501
+ for (const { suffix } of RADIUS_CORNERS) {
502
+ removeStyle(`style.border${suffix}Radius`);
503
+ }
504
+ return;
505
+ }
506
+ for (const { key, suffix } of RADIUS_CORNERS) {
507
+ commit(`style.border${suffix}Radius`, v[key]);
508
+ }
509
+ };
510
+ return (
511
+ <Field label={prop.label} testId={testId}>
512
+ <BorderRadiusEditor
513
+ value={value}
514
+ disabled={disabled}
515
+ onChange={writeBack}
516
+ onChangeEnd={writeBack}
517
+ />
518
+ </Field>
519
+ );
520
+ }
521
+
522
+ function ScrubRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
523
+ const unit = UNITLESS_NUMBER_PROPS.has(propNameFromPath(prop.path)) ? '' : 'px';
524
+ return (
525
+ <ScrubbableInput
526
+ testId={testId}
527
+ label={prop.label}
528
+ value={numericValue(raw)}
529
+ unit={unit}
530
+ disabled={disabled}
531
+ onChange={(v) => commit(prop.path, v)}
532
+ onChangeEnd={(v) => commit(prop.path, v)}
533
+ style={{ padding: '4px 8px' }}
534
+ />
535
+ );
536
+ }
537
+
538
+ function SelectRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
539
+ return (
540
+ <Field label={prop.label} testId={testId}>
541
+ <select
542
+ data-testid={testId}
543
+ value={String(raw ?? '')}
544
+ disabled={disabled}
545
+ onChange={(e) => commit(prop.path, e.target.value)}
546
+ style={{
547
+ ...inputStyle(disabled),
548
+ width: '100%',
549
+ cursor: disabled ? 'not-allowed' : 'pointer',
550
+ }}
551
+ >
552
+ {(prop.options ?? []).map((o) => (
553
+ <option key={String(o)} value={String(o)}>
554
+ {String(o)}
555
+ </option>
556
+ ))}
557
+ </select>
558
+ </Field>
559
+ );
560
+ }
561
+
562
+ function CheckboxRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
563
+ return (
564
+ <label
565
+ style={{ display: 'flex', gap: 6, alignItems: 'center', padding: '6px 8px', fontSize: 12 }}
566
+ >
567
+ <input
568
+ type="checkbox"
569
+ data-testid={testId}
570
+ checked={raw !== false}
571
+ disabled={disabled}
572
+ onChange={(e) => commit(prop.path, e.target.checked)}
573
+ />
574
+ {prop.label}
575
+ </label>
576
+ );
577
+ }
578
+
579
+ function JsonRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
580
+ return (
581
+ <Field label={prop.label} testId={testId}>
582
+ <textarea
583
+ data-testid={testId}
584
+ defaultValue={JSON.stringify(raw ?? null)}
585
+ disabled={disabled}
586
+ style={{ ...inputStyle(disabled), width: '100%', minHeight: 40, fontFamily: FONT_MONO }}
587
+ onBlur={(e) => {
588
+ try {
589
+ commit(prop.path, JSON.parse(e.target.value));
590
+ } catch {
591
+ // Invalid JSON — leave the field as typed, don't write garbage.
592
+ }
593
+ }}
594
+ />
595
+ </Field>
596
+ );
597
+ }
598
+
599
+ function TextRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
600
+ return (
601
+ <Field label={prop.label} testId={testId}>
602
+ <input
603
+ type="text"
604
+ data-testid={testId}
605
+ readOnly={disabled}
606
+ value={String(raw ?? '')}
607
+ onChange={(e) => commit(prop.path, e.target.value)}
608
+ style={{ ...inputStyle(disabled), width: '100%' }}
609
+ />
610
+ </Field>
611
+ );
612
+ }
613
+
614
+ /** U3 (spec 27 §5 "Transform: rotate/scale") — a structured `{rotateDeg, scale}`
615
+ * pair, when `style.transform` is losslessly representable that way. */
616
+ export interface TransformValue {
617
+ rotateDeg: number;
618
+ scale: number;
619
+ }
620
+
621
+ const TRANSFORM_ROTATE_SCALE_RE = /^rotate\(\s*(-?[\d.]+)deg\s*\)\s*scale\(\s*(-?[\d.]+)\s*\)$/i;
622
+ const TRANSFORM_SCALE_ROTATE_RE = /^scale\(\s*(-?[\d.]+)\s*\)\s*rotate\(\s*(-?[\d.]+)deg\s*\)$/i;
623
+ // S3 — single-function literals (a JSX-authored `rotate(45deg)` or `scale(1.5)`
624
+ // alone, missing axis defaulted): computed style is always a matrix in the real
625
+ // browser, but these are exactly what an author hand-writes, so give them the
626
+ // structured row too rather than the raw TextRow.
627
+ const TRANSFORM_ROTATE_ONLY_RE = /^rotate\(\s*(-?[\d.]+)deg\s*\)$/i;
628
+ const TRANSFORM_SCALE_ONLY_RE = /^scale\(\s*(-?[\d.]+)\s*\)$/i;
629
+ const TRANSFORM_MATRIX_RE =
630
+ /^matrix\(\s*(-?[\d.eE+-]+)\s*,\s*(-?[\d.eE+-]+)\s*,\s*(-?[\d.eE+-]+)\s*,\s*(-?[\d.eE+-]+)\s*,\s*(-?[\d.eE+-]+)\s*,\s*(-?[\d.eE+-]+)\s*\)$/;
631
+ const TRANSFORM_EPS = 1e-4;
632
+
633
+ /**
634
+ * Parse a `style.transform` value (either the author's literal
635
+ * `rotate(<deg>deg) scale(<n>)` — the exact form this row writes — or a
636
+ * REAL browser's computed 2x2 `matrix(a, b, c, d, e, f)`, since Chromium
637
+ * always normalizes an authored transform to matrix form, unlike this
638
+ * repo's headless/vitest fallback which echoes the literal string straight
639
+ * from inline style) into a structured `{rotateDeg, scale}` pair, or `null`
640
+ * when the value can't be represented that way LOSSLESSLY — a nonzero
641
+ * translate (`e`/`f`), a skew/non-uniform scale (`c !== -b` or `d !== a`),
642
+ * `matrix3d(...)`, or any other transform function list. `null` is the
643
+ * signal {@link TransformRow} uses to fall back to the plain `TextRow` —
644
+ * every value this doesn't understand stays editable as raw text, so no
645
+ * expressiveness is ever silently dropped.
646
+ */
647
+ export function parseTransform(raw: string): TransformValue | null {
648
+ const v = raw.trim();
649
+ if (v === '' || v === 'none') return { rotateDeg: 0, scale: 1 };
650
+ const lit = TRANSFORM_ROTATE_SCALE_RE.exec(v);
651
+ if (lit) return { rotateDeg: Number(lit[1]), scale: Number(lit[2]) };
652
+ const swapped = TRANSFORM_SCALE_ROTATE_RE.exec(v);
653
+ if (swapped) return { rotateDeg: Number(swapped[2]), scale: Number(swapped[1]) };
654
+ const rotOnly = TRANSFORM_ROTATE_ONLY_RE.exec(v);
655
+ if (rotOnly) return { rotateDeg: Number(rotOnly[1]), scale: 1 }; // S3 — missing scale defaults 1
656
+ const scaleOnly = TRANSFORM_SCALE_ONLY_RE.exec(v);
657
+ if (scaleOnly) return { rotateDeg: 0, scale: Number(scaleOnly[1]) }; // S3 — missing rotate defaults 0
658
+ const m = TRANSFORM_MATRIX_RE.exec(v);
659
+ if (!m) return null; // matrix3d / translate / skew / any other function list
660
+ const [a, b, c, d, e, f] = m.slice(1, 7).map(Number) as [
661
+ number,
662
+ number,
663
+ number,
664
+ number,
665
+ number,
666
+ number,
667
+ ];
668
+ if (Math.abs(e) > TRANSFORM_EPS || Math.abs(f) > TRANSFORM_EPS) return null; // has a translate
669
+ if (Math.abs(c + b) > TRANSFORM_EPS || Math.abs(d - a) > TRANSFORM_EPS) return null; // skew / non-uniform scale
670
+ const scale = Math.hypot(a, b);
671
+ if (scale < TRANSFORM_EPS) return null; // degenerate (zero scale)
672
+ const rotateDeg = (Math.atan2(b, a) * 180) / Math.PI;
673
+ return {
674
+ rotateDeg: Math.round(rotateDeg * 100) / 100,
675
+ scale: Math.round(scale * 1000) / 1000,
676
+ };
677
+ }
678
+
679
+ /** The inverse of {@link parseTransform}'s literal fast-path — always
680
+ * composes the readable `rotate()/scale()` form, never a matrix, so the
681
+ * written source stays human-editable. */
682
+ export function composeTransform(v: TransformValue): string {
683
+ return `rotate(${v.rotateDeg}deg) scale(${v.scale})`;
684
+ }
685
+
686
+ function TransformRow(props: KindRowProps): React.ReactElement {
687
+ const { prop, raw, disabled, testId, commit } = props;
688
+ const parsed = parseTransform(String(raw ?? 'none'));
689
+ if (!parsed) return <TextRow {...props} />; // safety net — no expressiveness lost
690
+ const writeBack = (next: TransformValue): void => commit(prop.path, composeTransform(next));
691
+ return (
692
+ <Field label={prop.label} testId={testId}>
693
+ <div style={{ display: 'flex', gap: 6 }}>
694
+ <ScrubbableInput
695
+ testId={`${testId}-rotate`}
696
+ label="Rotate"
697
+ value={parsed.rotateDeg}
698
+ unit="deg"
699
+ disabled={disabled}
700
+ onChange={(v) => writeBack({ ...parsed, rotateDeg: v })}
701
+ onChangeEnd={(v) => writeBack({ ...parsed, rotateDeg: v })}
702
+ style={{ flex: 1 }}
703
+ />
704
+ <ScrubbableInput
705
+ testId={`${testId}-scale`}
706
+ label="Scale"
707
+ value={parsed.scale}
708
+ unit="x"
709
+ step={0.1}
710
+ disabled={disabled}
711
+ onChange={(v) => writeBack({ ...parsed, scale: v })}
712
+ onChangeEnd={(v) => writeBack({ ...parsed, scale: v })}
713
+ style={{ flex: 1 }}
714
+ />
715
+ </div>
716
+ </Field>
717
+ );
718
+ }
719
+
720
+ /** One `single`-kind row: reads the current value, picks the widget via
721
+ * {@link widgetKindFor}, and delegates to the matching `*Row` component
722
+ * above — each extracted so this dispatcher's own branching stays well
723
+ * under biome's complexity ceiling (same discipline `GradientEditor.tsx`/
724
+ * `ShadowEditor.tsx` document for their own helper splits). Both
725
+ * `onChange`/`onChangeEnd` route through the SAME `commit` (file doc
726
+ * comment: this adapter has one write path, not a separate preview mode). */
727
+ function SingleRow({
728
+ adapter,
729
+ id,
730
+ prop,
731
+ fonts,
732
+ }: {
733
+ adapter: AuthoringAdapter;
734
+ id: string;
735
+ prop: PropertyDescriptor;
736
+ fonts: readonly string[];
737
+ }): React.ReactElement {
738
+ const store = useEditorStore();
739
+ const commit = (path: string, value: unknown): void => {
740
+ adapter.inspector?.set(id, path, value);
741
+ store.notifyIngestEdit();
742
+ };
743
+ // D1 — remove a stale CSS longhand override (no-op when unauthored); optional
744
+ // on the inspector, so a non-react adapter simply skips the cleanup.
745
+ const removeStyle = (path: string): void => {
746
+ adapter.inspector?.remove?.(id, path);
747
+ store.notifyIngestEdit();
748
+ };
749
+ const rowProps: KindRowProps = {
750
+ prop,
751
+ raw: adapter.inspector?.get(id, prop.path),
752
+ disabled: prop.readonly,
753
+ testId: testIdFor(prop.path),
754
+ commit,
755
+ fonts,
756
+ };
757
+ switch (widgetKindFor(prop)) {
758
+ case 'color':
759
+ return <ColorRow {...rowProps} />;
760
+ case 'font':
761
+ return <FontRow {...rowProps} />;
762
+ case 'gradient':
763
+ return <GradientRow {...rowProps} />;
764
+ case 'shadow':
765
+ return <ShadowRow {...rowProps} withSpreadInset />;
766
+ case 'text-shadow':
767
+ return <ShadowRow {...rowProps} withSpreadInset={false} />;
768
+ case 'filter':
769
+ return <FilterRow {...rowProps} label="Filter" />;
770
+ case 'backdrop-filter':
771
+ return <FilterRow {...rowProps} label="Backdrop Filter" />;
772
+ case 'radius':
773
+ return <RadiusRow {...rowProps} adapter={adapter} id={id} removeStyle={removeStyle} />;
774
+ case 'transform':
775
+ return <TransformRow {...rowProps} />;
776
+ case 'scrub':
777
+ return <ScrubRow {...rowProps} />;
778
+ case 'select':
779
+ return <SelectRow {...rowProps} />;
780
+ case 'checkbox':
781
+ return <CheckboxRow {...rowProps} />;
782
+ case 'json':
783
+ return <JsonRow {...rowProps} />;
784
+ default:
785
+ return <TextRow {...rowProps} />;
786
+ }
787
+ }
788
+
789
+ /** A `border`/`align` combo row — reads all its member props, composes the
790
+ * widget's structured value, and writes every member back on change. */
791
+ function ComboRow({ adapter, id, row }: ComboRowProps): React.ReactElement | null {
792
+ const store = useEditorStore();
793
+ const commit = (path: string, value: unknown): void => {
794
+ adapter.inspector?.set(id, path, value);
795
+ store.notifyIngestEdit();
796
+ };
797
+ const removeStyle = (path: string): void => {
798
+ adapter.inspector?.remove?.(id, path);
799
+ store.notifyIngestEdit();
800
+ };
801
+ if (row.kind === 'border') {
802
+ // U2 (spec 27 §5 "Stroke: per-side border") — the shorthand descriptors
803
+ // (`row.width`/`row.style`/`row.color`) are the uniform fallback; each
804
+ // side's OWN longhand (`borderTopWidth`, …) is read through the same
805
+ // `adapter.inspector.get` and wins when present, so a prior per-side
806
+ // edit survives the next re-render instead of collapsing back to
807
+ // uniform (mirrors `RadiusRow`'s corner read, same fallback rationale:
808
+ // the vitest `node` env resolver has no implicit per-side computed
809
+ // values the way a real `CSSStyleDeclaration` always does).
810
+ const fallback: BorderSide = {
811
+ width: numericValue(adapter.inspector?.get(id, row.width.path)),
812
+ style: String(adapter.inspector?.get(id, row.style.path) ?? 'solid'),
813
+ color: String(adapter.inspector?.get(id, row.color.path) ?? '#000000'),
814
+ };
815
+ const readSide = (suffix: 'Top' | 'Right' | 'Bottom' | 'Left'): BorderSide => {
816
+ const w = numericStyleValue(adapter.inspector?.get(id, `style.border${suffix}Width`));
817
+ const st = adapter.inspector?.get(id, `style.border${suffix}Style`);
818
+ const c = adapter.inspector?.get(id, `style.border${suffix}Color`);
819
+ // D1 — normalize the per-side color longhand through `cssColorToHex`: it's
820
+ // not in `STYLE_PROPERTY_TYPE`, so `inspector.get` hands back the RAW
821
+ // computed value (`rgb(…)`), and letting that flow into a later uniform
822
+ // commit would rewrite the author's hex literal as an `rgb()` string.
823
+ const cHex = typeof c === 'string' ? cssColorToHex(c) : undefined;
824
+ return {
825
+ width: w ?? fallback.width,
826
+ style: typeof st === 'string' && st ? st : fallback.style,
827
+ color: cHex ?? fallback.color,
828
+ };
829
+ };
830
+ const value: BorderValue = {
831
+ top: readSide('Top'),
832
+ right: readSide('Right'),
833
+ bottom: readSide('Bottom'),
834
+ left: readSide('Left'),
835
+ };
836
+ // Uniform edits keep the pre-existing shorthand fast-path (3 commits to
837
+ // the `borderWidth`/`borderStyle`/`borderColor` descriptors this row
838
+ // always had); a non-uniform widget value now writes the 4 per-side
839
+ // CSS longhands instead of collapsing to `.top` — removing the
840
+ // documented limitation this file used to note here.
841
+ const sideSuffixes = ['Top', 'Right', 'Bottom', 'Left'] as const;
842
+ const writeBack = (v: BorderValue): void => {
843
+ if (isUniformBorder(v)) {
844
+ commit(row.width.path, v.top.width);
845
+ commit(row.style.path, v.top.style);
846
+ commit(row.color.path, v.top.color);
847
+ // D1 — clear any per-side longhands a prior non-uniform edit wrote, so
848
+ // the uniform shorthand actually wins on reload (removeStyle no-ops
849
+ // when a longhand isn't authored — safe on a clean element).
850
+ for (const suffix of sideSuffixes) {
851
+ removeStyle(`style.border${suffix}Width`);
852
+ removeStyle(`style.border${suffix}Style`);
853
+ removeStyle(`style.border${suffix}Color`);
854
+ }
855
+ return;
856
+ }
857
+ const sides: ReadonlyArray<{
858
+ suffix: 'Top' | 'Right' | 'Bottom' | 'Left';
859
+ side: BorderSide;
860
+ }> = sideSuffixes.map((suffix) => ({
861
+ suffix,
862
+ side: v[suffix.toLowerCase() as 'top' | 'right' | 'bottom' | 'left'],
863
+ }));
864
+ for (const { suffix, side } of sides) {
865
+ commit(`style.border${suffix}Width`, side.width);
866
+ commit(`style.border${suffix}Style`, side.style);
867
+ commit(`style.border${suffix}Color`, side.color);
868
+ }
869
+ };
870
+ return (
871
+ <Field label="Border" testId="react-prop-border">
872
+ <BorderEditor value={value} onChange={writeBack} onChangeEnd={writeBack} />
873
+ </Field>
874
+ );
875
+ }
876
+ const value: AlignmentValue = {
877
+ justify:
878
+ (adapter.inspector?.get(id, row.justify.path) as AlignmentValue['justify']) ?? 'flex-start',
879
+ align: (adapter.inspector?.get(id, row.align.path) as AlignmentValue['align']) ?? 'flex-start',
880
+ };
881
+ return (
882
+ <Field label="Align" testId="react-prop-align">
883
+ <AlignmentGrid
884
+ value={value}
885
+ onChange={(v) => {
886
+ commit(row.justify.path, v.justify);
887
+ commit(row.align.path, v.align);
888
+ }}
889
+ />
890
+ </Field>
891
+ );
892
+ }
893
+
894
+ function rowKey(row: PropertyRow): string {
895
+ if (row.kind === 'single') return row.prop.path;
896
+ if (row.kind === 'border') return `border:${row.width.path}`;
897
+ return `align:${row.justify.path}`;
898
+ }
899
+
900
+ function GroupBlock({
901
+ name,
902
+ groupId,
903
+ props,
904
+ adapter,
905
+ id,
906
+ fonts,
907
+ }: {
908
+ name: string;
909
+ groupId: string;
910
+ props: readonly PropertyDescriptor[];
911
+ adapter: AuthoringAdapter;
912
+ id: string;
913
+ fonts: readonly string[];
914
+ }): React.ReactElement {
915
+ const rows = buildPropertyRows(props);
916
+ return (
917
+ <div
918
+ data-testid={`react-inspector-group-${groupId}`}
919
+ style={{ borderTop: `1px solid ${THEME.border}`, marginTop: 4 }}
920
+ >
921
+ <div
922
+ style={{
923
+ fontSize: 11,
924
+ fontWeight: 600,
925
+ color: THEME.textMuted,
926
+ padding: '6px 8px 2px',
927
+ textTransform: 'uppercase',
928
+ letterSpacing: 0.4,
929
+ }}
930
+ >
931
+ {name}
932
+ </div>
933
+ {rows.map((row) =>
934
+ row.kind === 'single' ? (
935
+ <SingleRow key={rowKey(row)} adapter={adapter} id={id} prop={row.prop} fonts={fonts} />
936
+ ) : (
937
+ <ComboRow key={rowKey(row)} adapter={adapter} id={id} row={row} />
938
+ ),
939
+ )}
940
+ </div>
941
+ );
942
+ }
943
+
944
+ /** The Lane-B ("edits component source -> every instance") disclosure —
945
+ * ports `ui-editor/source-edit-panel.tsx`'s header COPY, not the component
946
+ * itself (that one is bound to `UIEditStore`/an ad hoc `/__ui-source/index`
947
+ * fetch — Rule zero bans importing `UIEditStore` into this shell-registered
948
+ * section). Shown only for a `ReactWorldAuthoringAdapter` (real Lane B,
949
+ * `data-oid` component-source writes); a `ReactDomAuthoringAdapter`
950
+ * (ingested, overlay-only — never touches source) gets its own honest note
951
+ * instead, so neither lane's write model is ever misrepresented. */
952
+ function LaneDisclosure({
953
+ owner,
954
+ node,
955
+ }: {
956
+ owner: ReactWorldAuthoringAdapter | ReactDomAuthoringAdapter;
957
+ node: EditorNode | null;
958
+ }): React.ReactElement {
959
+ const isLaneB = owner instanceof ReactWorldAuthoringAdapter;
960
+ return (
961
+ <div
962
+ data-testid={isLaneB ? 'react-lane-b-disclosure' : 'react-overlay-disclosure'}
963
+ style={{
964
+ padding: 8,
965
+ borderBottom: `1px solid ${THEME.border}`,
966
+ background: isLaneB ? 'rgba(59,130,246,0.08)' : 'rgba(255,255,255,0.04)',
967
+ color: THEME.text,
968
+ fontSize: 11,
969
+ lineHeight: 1.5,
970
+ }}
971
+ >
972
+ {isLaneB ? (
973
+ <>
974
+ <strong style={{ color: THEME.accent }}>Component source.</strong> Edits change{' '}
975
+ <code style={{ fontFamily: FONT_MONO }}>{node?.label ?? 'this component'}</code>'s source
976
+ — every rendered instance updates.
977
+ </>
978
+ ) : (
979
+ <>
980
+ <strong style={{ color: THEME.textMuted }}>Session overlay (ingested game).</strong> Edits
981
+ are NOT written to source — they persist as a per-project overlay only.
982
+ </>
983
+ )}
984
+ </div>
985
+ );
986
+ }
987
+
988
+ /** The registered section itself (C2). Renders every `PropertyDescriptor`
989
+ * from `adapter.inspector.properties(nodeId)`, grouped and widget-mapped —
990
+ * see the file doc comment for the write-routing contract. */
991
+ function ReactPropertiesSection({
992
+ adapter,
993
+ nodeId,
994
+ }: InspectorSectionProps): React.ReactElement | null {
995
+ // Re-render is driven by the shell's own `useSyncExternalStore` subscription
996
+ // (`components/Inspector.tsx`) — every `SingleRow`/`ComboRow` below calls
997
+ // `store.notifyIngestEdit()` itself on commit (its own `useEditorStore()`),
998
+ // which re-renders the whole Inspector tree including this section; no
999
+ // separate subscription is needed here.
1000
+ const fonts = useMemo(() => getAvailableFonts(), []);
1001
+ if (!nodeId) return null;
1002
+ const node = adapter.hierarchy.node(nodeId);
1003
+ const owner = resolveReactOwner(node, adapter);
1004
+ if (!owner) return null;
1005
+
1006
+ const props = (adapter.inspector?.properties(nodeId) ?? []).filter((p) => p.path !== 'name');
1007
+ const { ungrouped, groups } = groupProperties(props);
1008
+
1009
+ return (
1010
+ <div data-testid="react-inspector-section">
1011
+ <LaneDisclosure owner={owner} node={node} />
1012
+ {ungrouped.length > 0 && (
1013
+ <GroupBlock
1014
+ name="Style"
1015
+ groupId="style"
1016
+ props={ungrouped}
1017
+ adapter={adapter}
1018
+ id={nodeId}
1019
+ fonts={fonts}
1020
+ />
1021
+ )}
1022
+ {groups.map((g) => (
1023
+ <GroupBlock
1024
+ key={g.groupId}
1025
+ name={g.name}
1026
+ groupId={g.groupId}
1027
+ props={g.properties}
1028
+ adapter={adapter}
1029
+ id={nodeId}
1030
+ fonts={fonts}
1031
+ />
1032
+ ))}
1033
+ </div>
1034
+ );
1035
+ }
1036
+
1037
+ registerInspectorSections({
1038
+ match: isReactDomSelection,
1039
+ Section: ReactPropertiesSection,
1040
+ suppresses: ['properties'],
1041
+ });