@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,1175 @@
1
+ /**
2
+ * Surgical source writer (C7/C8) — the visual-edit "edit source by offset, not by
3
+ * AST rewrite" trick. Given the original source and an element's location (from the
4
+ * OID index), it edits the inline `style={{…}}` object IN PLACE, preserving all
5
+ * other properties (including dynamic ones), formatting, and comments — and refuses
6
+ * to touch expression-bound (dynamic) values (the literal-vs-dynamic guard). Plus a
7
+ * structural delete. Pure string ops (no parser, like visual-edit/writer.ts).
8
+ */
9
+
10
+ export interface StyleProperty {
11
+ name: string;
12
+ /** Raw value text as it appears in source. */
13
+ raw: string;
14
+ literal: boolean;
15
+ valueStart: number;
16
+ valueEnd: number;
17
+ /**
18
+ * The full `key: value` member span — from the first char of the key through the
19
+ * end of the (trimmed) value, EXCLUDING any leading/trailing whitespace or the
20
+ * separating comma (D-A2, `removeInlineStyle`). `memberEnd` always equals
21
+ * `valueEnd` (the member ends exactly where its value ends); kept as a distinct
22
+ * field for readability at removeInlineStyle's call site.
23
+ */
24
+ memberStart: number;
25
+ memberEnd: number;
26
+ }
27
+
28
+ /** Find the `>` that closes the opening tag starting at `start` (a `<`). */
29
+ export function findTagEnd(code: string, start: number): number {
30
+ let depth = 0;
31
+ let str: string | null = null;
32
+ for (let i = start; i < code.length; i++) {
33
+ const c = code[i];
34
+ if (str) {
35
+ if (c === str && code[i - 1] !== '\\') str = null;
36
+ continue;
37
+ }
38
+ if (c === '"' || c === "'" || c === '`') str = c;
39
+ else if (c === '{') depth++;
40
+ else if (c === '}') depth--;
41
+ else if (c === '>' && depth === 0) return i;
42
+ }
43
+ return -1;
44
+ }
45
+
46
+ /** Locate the inner range of `style={{ … }}` within the opening tag, or null. */
47
+ function findStyleObject(
48
+ code: string,
49
+ tagStart: number,
50
+ tagEnd: number,
51
+ ): { innerStart: number; innerEnd: number } | null {
52
+ const seg = code.slice(tagStart, tagEnd);
53
+ const m = seg.match(/style\s*=\s*\{\{/);
54
+ if (!m || m.index == null) return null;
55
+ const openBraceOuter = tagStart + m.index + m[0].length - 2; // position of first `{` of `{{`
56
+ // inner object starts after the two `{`
57
+ const innerStart = openBraceOuter + 2;
58
+ // find the matching `}}` for the object: track brace depth from innerStart
59
+ let depth = 2; // we are inside two open braces
60
+ let str: string | null = null;
61
+ for (let i = innerStart; i < tagEnd + 2 && i < code.length; i++) {
62
+ const c = code[i];
63
+ if (str) {
64
+ if (c === str && code[i - 1] !== '\\') str = null;
65
+ continue;
66
+ }
67
+ if (c === '"' || c === "'" || c === '`') str = c;
68
+ else if (c === '{') depth++;
69
+ else if (c === '}') {
70
+ depth--;
71
+ if (depth === 1) {
72
+ // this `}` closes the object (one brace — the expression brace — remains)
73
+ return { innerStart, innerEnd: i };
74
+ }
75
+ }
76
+ }
77
+ return null;
78
+ }
79
+
80
+ const LITERAL_RE = /^\s*('[^']*'|"[^"]*"|`[^`]*`|-?\d+(\.\d+)?(px|em|rem|%|vh|vw)?)\s*$/;
81
+
82
+ /** Parse the top-level properties of a style object body (between the braces). */
83
+ export function analyzeStyleProperties(objBody: string, baseOffset: number): StyleProperty[] {
84
+ const props: StyleProperty[] = [];
85
+ let i = 0;
86
+ const n = objBody.length;
87
+ let str: string | null = null;
88
+ let depth = 0;
89
+ let keyStart = -1;
90
+ let colon = -1;
91
+ const flush = (end: number): void => {
92
+ if (keyStart >= 0 && colon >= 0) {
93
+ const name = objBody.slice(keyStart, colon).replace(/['"]/g, '').trim();
94
+ const raw = objBody.slice(colon + 1, end);
95
+ if (name) {
96
+ const trimmed = raw.trim();
97
+ const lead = raw.length - raw.trimStart().length;
98
+ // A template literal with `${...}` interpolation is DYNAMIC, not literal,
99
+ // even though it is delimited by backticks — never let it be editable.
100
+ const isLiteral = LITERAL_RE.test(raw) && !trimmed.includes('${');
101
+ const valueStart = baseOffset + colon + 1 + lead;
102
+ const valueEnd = valueStart + trimmed.length;
103
+ props.push({
104
+ name,
105
+ raw: trimmed,
106
+ literal: isLiteral,
107
+ valueStart,
108
+ valueEnd,
109
+ memberStart: baseOffset + keyStart,
110
+ memberEnd: valueEnd,
111
+ });
112
+ }
113
+ }
114
+ keyStart = -1;
115
+ colon = -1;
116
+ };
117
+ for (; i < n; i++) {
118
+ const c = objBody[i] as string;
119
+ if (str) {
120
+ if (c === str && objBody[i - 1] !== '\\') str = null;
121
+ continue;
122
+ }
123
+ if (c === '"' || c === "'" || c === '`') {
124
+ str = c;
125
+ continue;
126
+ }
127
+ if (c === '{' || c === '(' || c === '[') depth++;
128
+ else if (c === '}' || c === ')' || c === ']') depth--;
129
+ else if (depth === 0) {
130
+ if (c === ':' && colon < 0) colon = i;
131
+ else if (c === ',') {
132
+ flush(i);
133
+ } else if (keyStart < 0 && /\S/.test(c)) keyStart = i;
134
+ }
135
+ }
136
+ flush(n);
137
+ return props;
138
+ }
139
+
140
+ export interface StyleEditResult {
141
+ code: string;
142
+ changed: boolean;
143
+ /** True if the edit was refused because the existing value is a JS expression. */
144
+ dynamic: boolean;
145
+ /**
146
+ * D-A1 (wave 13, `docs/WAVE13-JSX-SPREAD-UNDO-DESIGN.md`): true ONLY when
147
+ * `setInlineStyle` took the APPEND branch — the element's style object had no
148
+ * literal (and no dynamic expression) for this prop before the write, e.g. it
149
+ * was absent entirely or spread-derived (`style={{ ...vars }}`). False/absent on
150
+ * a replace, a dynamic-value refusal, or a no-op. Undo must know this: replaying
151
+ * the write with the prior value would find the NOW-appended literal and REPLACE
152
+ * it — restoring a hardcoded value into a spot the source never had one — instead
153
+ * of removing the prop to truly restore the pre-edit source.
154
+ */
155
+ appended?: boolean;
156
+ }
157
+
158
+ /**
159
+ * Set an inline style property on the element at `elementStart` (a `<`). Replaces a
160
+ * literal value, refuses a dynamic one (guard), or appends a new property; inserts a
161
+ * `style={{…}}` if none exists. Other properties are untouched.
162
+ */
163
+ export function setInlineStyle(
164
+ code: string,
165
+ elementStart: number,
166
+ prop: string,
167
+ value: string | number,
168
+ ): StyleEditResult {
169
+ const tagEnd = findTagEnd(code, elementStart);
170
+ if (tagEnd < 0) return { code, changed: false, dynamic: false };
171
+ // Escape the value so a quote/backslash can't break source syntax.
172
+ const valueText =
173
+ typeof value === 'number'
174
+ ? String(value)
175
+ : `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
176
+ const styleObj = findStyleObject(code, elementStart, tagEnd);
177
+
178
+ if (styleObj) {
179
+ const body = code.slice(styleObj.innerStart, styleObj.innerEnd);
180
+ const props = analyzeStyleProperties(body, styleObj.innerStart);
181
+ const existing = props.find((p) => p.name === prop);
182
+ if (existing) {
183
+ if (!existing.literal) return { code, changed: false, dynamic: true }; // guard
184
+ const next = code.slice(0, existing.valueStart) + valueText + code.slice(existing.valueEnd);
185
+ return { code: next, changed: true, dynamic: false };
186
+ }
187
+ // append a property, preserving existing content
188
+ const trimmedBody = body.trimEnd();
189
+ const sep = trimmedBody.length === 0 ? '' : trimmedBody.endsWith(',') ? ' ' : ', ';
190
+ const insertAt = styleObj.innerStart + trimmedBody.length;
191
+ const next = `${code.slice(0, insertAt)}${sep}${prop}: ${valueText}${code.slice(insertAt)}`;
192
+ return { code: next, changed: true, dynamic: false, appended: true };
193
+ }
194
+
195
+ // A whole-attribute dynamic style (`style={expr}`, single brace) exists but is
196
+ // not an editable object literal — refuse rather than appending a duplicate attr.
197
+ const openSeg = code.slice(elementStart, tagEnd);
198
+ if (/style\s*=\s*\{/.test(openSeg)) {
199
+ return { code, changed: false, dynamic: true };
200
+ }
201
+
202
+ // no style attr — insert before the tag end (handle self-closing `/>`)
203
+ const selfClose = code[tagEnd - 1] === '/';
204
+ const insertAt = selfClose ? tagEnd - 1 : tagEnd;
205
+ const next = `${code.slice(0, insertAt)} style={{ ${prop}: ${valueText} }}${code.slice(insertAt)}`;
206
+ return { code: next, changed: true, dynamic: false };
207
+ }
208
+
209
+ /**
210
+ * D-A2 (wave 13, `docs/WAVE13-JSX-SPREAD-UNDO-DESIGN.md`): surgically REMOVE a
211
+ * literal `prop: value` entry from the element's `style={{…}}` object at
212
+ * `elementStart` — the correct undo for a write that took `setInlineStyle`'s
213
+ * APPEND branch (`appended: true`). Fixes up the neighbouring separator/trailing
214
+ * comma so the remaining object stays syntactically valid; if `prop` was the
215
+ * object's ONLY content, collapses to an empty `style={{}}` rather than stripping
216
+ * the attribute entirely (keeps redo — a plain re-append — trivially reversible,
217
+ * and avoids re-deriving the whole-attribute insertion position `setInlineStyle`'s
218
+ * no-style-attr branch uses). `changed: false` (and no-op) if `prop` isn't present
219
+ * as a literal member at all — including a DYNAMIC member (mirrors
220
+ * `setInlineStyle`'s literal-vs-dynamic guard: never remove an expression-bound
221
+ * value the caller didn't write).
222
+ */
223
+ export function removeInlineStyle(
224
+ code: string,
225
+ elementStart: number,
226
+ prop: string,
227
+ ): StyleEditResult {
228
+ const tagEnd = findTagEnd(code, elementStart);
229
+ if (tagEnd < 0) return { code, changed: false, dynamic: false };
230
+ const styleObj = findStyleObject(code, elementStart, tagEnd);
231
+ if (!styleObj) return { code, changed: false, dynamic: false }; // no style attr — nothing to remove
232
+
233
+ const body = code.slice(styleObj.innerStart, styleObj.innerEnd);
234
+ const props = analyzeStyleProperties(body, styleObj.innerStart);
235
+ const existing = props.find((p) => p.name === prop);
236
+ if (!existing) return { code, changed: false, dynamic: false }; // absent — nothing to remove
237
+ if (!existing.literal) return { code, changed: false, dynamic: true }; // guard, mirrors setInlineStyle
238
+
239
+ // Prefer consuming a comma that FOLLOWS this member (it precedes others in the
240
+ // list); else one that PRECEDES it (it is last); else this was the ONLY thing in
241
+ // the object — collapse to `{}` instead.
242
+ let after = existing.memberEnd;
243
+ while (after < styleObj.innerEnd && /\s/.test(code[after] ?? '')) after++;
244
+ if (code[after] === ',') {
245
+ let end = after + 1;
246
+ if (code[end] === ' ') end++;
247
+ return {
248
+ code: code.slice(0, existing.memberStart) + code.slice(end),
249
+ changed: true,
250
+ dynamic: false,
251
+ };
252
+ }
253
+
254
+ let before = existing.memberStart - 1;
255
+ while (before >= styleObj.innerStart && /\s/.test(code[before] ?? '')) before--;
256
+ if (code[before] === ',') {
257
+ return {
258
+ code: code.slice(0, before) + code.slice(existing.memberEnd),
259
+ changed: true,
260
+ dynamic: false,
261
+ };
262
+ }
263
+
264
+ return {
265
+ code: code.slice(0, styleObj.innerStart) + code.slice(styleObj.innerEnd),
266
+ changed: true,
267
+ dynamic: false,
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Find the end (exclusive) of a full JSX element starting at `<Tag` — balancing
273
+ * nested same-tag elements and skipping `{…}` expressions and strings (C8).
274
+ */
275
+ export function findElementEnd(code: string, start: number): number {
276
+ const tagEnd = findTagEnd(code, start);
277
+ if (tagEnd < 0) return -1;
278
+ if (code[tagEnd - 1] === '/') return tagEnd + 1; // self-closing
279
+ const tagNameMatch = code.slice(start + 1, tagEnd).match(/^([A-Za-z0-9_.]+)/);
280
+ const tag = tagNameMatch?.[1];
281
+ if (!tag) return -1;
282
+ let depth = 1;
283
+ let i = tagEnd + 1;
284
+ let str: string | null = null;
285
+ while (i < code.length && depth > 0) {
286
+ const c = code[i];
287
+ if (str) {
288
+ if (c === str && code[i - 1] !== '\\') str = null;
289
+ i++;
290
+ continue;
291
+ }
292
+ if (c === '"' || c === "'" || c === '`') {
293
+ str = c;
294
+ i++;
295
+ continue;
296
+ }
297
+ if (code.startsWith(`<${tag}`, i) && /[\s/>]/.test(code[i + 1 + tag.length] ?? '')) {
298
+ // A nested SAME-TAG element: only a non-self-closing one increments depth.
299
+ // A self-closing `<tag .../>` opens+closes in one tag and must NOT bump depth
300
+ // (else the balancer runs past the real closing tag and over-deletes).
301
+ const nestedTagEnd = findTagEnd(code, i);
302
+ if (nestedTagEnd > 0 && code[nestedTagEnd - 1] === '/') {
303
+ i = nestedTagEnd + 1; // skip the self-closing nested element whole
304
+ } else {
305
+ depth++;
306
+ i += 1 + tag.length;
307
+ }
308
+ } else if (code.startsWith(`</${tag}`, i)) {
309
+ depth--;
310
+ const close = code.indexOf('>', i);
311
+ i = close + 1;
312
+ if (depth === 0) return i;
313
+ } else {
314
+ i++;
315
+ }
316
+ }
317
+ return i;
318
+ }
319
+
320
+ // --- F5: multi-path style routing (inline handled above; className below) ---
321
+
322
+ /**
323
+ * Encode a CSS prop:value as a Tailwind-style utility class. Numeric/color values
324
+ * use ARBITRARY values (`w-[200px]`, `bg-[#059669]`) so the round-trip is LOSSLESS
325
+ * (a named utility like `bg-emerald-600` would NOT recover the exact hex). Enums map
326
+ * to named utilities. This is the lossless-encoding rule from visual-edit/tailwind-map.
327
+ */
328
+ // Cap 6 (React visual-edit parity): broadened to the grouped-inspector property set. Enum
329
+ // values map to NAMED utilities; each named utility string is unique across groups so the
330
+ // ENUM_GROUP reverse map (dedupe) stays unambiguous.
331
+ const ENUM_UTILITIES: Record<string, Record<string, string>> = {
332
+ display: {
333
+ flex: 'flex',
334
+ block: 'block',
335
+ grid: 'grid',
336
+ inline: 'inline',
337
+ 'inline-block': 'inline-block',
338
+ 'inline-flex': 'inline-flex',
339
+ none: 'hidden',
340
+ },
341
+ flexDirection: {
342
+ row: 'flex-row',
343
+ column: 'flex-col',
344
+ 'row-reverse': 'flex-row-reverse',
345
+ 'column-reverse': 'flex-col-reverse',
346
+ },
347
+ flexWrap: { wrap: 'flex-wrap', nowrap: 'flex-nowrap', 'wrap-reverse': 'flex-wrap-reverse' },
348
+ justifyContent: {
349
+ 'flex-start': 'justify-start',
350
+ 'flex-end': 'justify-end',
351
+ center: 'justify-center',
352
+ 'space-between': 'justify-between',
353
+ 'space-around': 'justify-around',
354
+ 'space-evenly': 'justify-evenly',
355
+ },
356
+ alignItems: {
357
+ 'flex-start': 'items-start',
358
+ 'flex-end': 'items-end',
359
+ center: 'items-center',
360
+ baseline: 'items-baseline',
361
+ stretch: 'items-stretch',
362
+ },
363
+ textAlign: {
364
+ left: 'text-left',
365
+ center: 'text-center',
366
+ right: 'text-right',
367
+ justify: 'text-justify',
368
+ },
369
+ position: {
370
+ relative: 'relative',
371
+ absolute: 'absolute',
372
+ fixed: 'fixed',
373
+ sticky: 'sticky',
374
+ static: 'static',
375
+ },
376
+ overflow: {
377
+ visible: 'overflow-visible',
378
+ hidden: 'overflow-hidden',
379
+ scroll: 'overflow-scroll',
380
+ auto: 'overflow-auto',
381
+ },
382
+ borderStyle: {
383
+ none: 'border-none',
384
+ solid: 'border-solid',
385
+ dashed: 'border-dashed',
386
+ dotted: 'border-dotted',
387
+ double: 'border-double',
388
+ },
389
+ textTransform: {
390
+ uppercase: 'uppercase',
391
+ lowercase: 'lowercase',
392
+ capitalize: 'capitalize',
393
+ none: 'normal-case',
394
+ },
395
+ whiteSpace: {
396
+ normal: 'whitespace-normal',
397
+ nowrap: 'whitespace-nowrap',
398
+ pre: 'whitespace-pre',
399
+ 'pre-wrap': 'whitespace-pre-wrap',
400
+ },
401
+ fontStyle: { italic: 'italic', normal: 'not-italic' },
402
+ mixBlendMode: {
403
+ normal: 'mix-blend-normal',
404
+ multiply: 'mix-blend-multiply',
405
+ screen: 'mix-blend-screen',
406
+ overlay: 'mix-blend-overlay',
407
+ },
408
+ };
409
+ // Reverse map: a named utility -> its logical group (for dedupe of enum utilities).
410
+ const ENUM_GROUP: Record<string, string> = (() => {
411
+ const out: Record<string, string> = {};
412
+ for (const [group, m] of Object.entries(ENUM_UTILITIES))
413
+ for (const u of Object.values(m)) out[u] = group;
414
+ return out;
415
+ })();
416
+ // Arbitrary-value utilities. `color`/`fontSize` both map to `text-`, so they use
417
+ // Tailwind TYPE HINTS (`text-[color:..]` vs `text-[length:..]`) to disambiguate —
418
+ // which also makes property-based dedupe unambiguous.
419
+ const ARB_MAP: Record<string, { prefix: string; hint?: 'color' | 'length' }> = {
420
+ backgroundColor: { prefix: 'bg' },
421
+ // `border` covers both border-color and border-width, so — like `text-` — they use type
422
+ // hints to stay unambiguous for property-based dedupe (Cap 6).
423
+ borderColor: { prefix: 'border', hint: 'color' },
424
+ borderWidth: { prefix: 'border', hint: 'length' },
425
+ color: { prefix: 'text', hint: 'color' },
426
+ fontSize: { prefix: 'text', hint: 'length' },
427
+ width: { prefix: 'w' },
428
+ height: { prefix: 'h' },
429
+ minWidth: { prefix: 'min-w' },
430
+ minHeight: { prefix: 'min-h' },
431
+ maxWidth: { prefix: 'max-w' },
432
+ maxHeight: { prefix: 'max-h' },
433
+ padding: { prefix: 'p' },
434
+ paddingTop: { prefix: 'pt' },
435
+ paddingRight: { prefix: 'pr' },
436
+ paddingBottom: { prefix: 'pb' },
437
+ paddingLeft: { prefix: 'pl' },
438
+ margin: { prefix: 'm' },
439
+ marginTop: { prefix: 'mt' },
440
+ marginRight: { prefix: 'mr' },
441
+ marginBottom: { prefix: 'mb' },
442
+ marginLeft: { prefix: 'ml' },
443
+ top: { prefix: 'top' },
444
+ right: { prefix: 'right' },
445
+ bottom: { prefix: 'bottom' },
446
+ left: { prefix: 'left' },
447
+ borderRadius: { prefix: 'rounded' },
448
+ borderTopLeftRadius: { prefix: 'rounded-tl' },
449
+ borderTopRightRadius: { prefix: 'rounded-tr' },
450
+ borderBottomLeftRadius: { prefix: 'rounded-bl' },
451
+ borderBottomRightRadius: { prefix: 'rounded-br' },
452
+ gap: { prefix: 'gap' },
453
+ fontWeight: { prefix: 'font' },
454
+ lineHeight: { prefix: 'leading' },
455
+ letterSpacing: { prefix: 'tracking' },
456
+ flexBasis: { prefix: 'basis' },
457
+ opacity: { prefix: 'opacity' },
458
+ zIndex: { prefix: 'z' },
459
+ boxShadow: { prefix: 'shadow' },
460
+ };
461
+
462
+ const sanitizeClass = (s: string): string => s.replace(/\s+/g, '_');
463
+
464
+ /** Encode a CSS prop:value as a lossless Tailwind-style utility class. */
465
+ export function cssToUtilityClass(prop: string, value: string | number): string {
466
+ const v = String(value);
467
+ if (ENUM_UTILITIES[prop]?.[v]) return ENUM_UTILITIES[prop][v] as string;
468
+ const arb = ARB_MAP[prop];
469
+ if (!arb) return `[${sanitizeClass(prop)}:${sanitizeClass(v)}]`; // arbitrary property — lossless
470
+ const val = typeof value === 'number' ? `${value}px` : v;
471
+ const inner = arb.hint ? `${arb.hint}:${sanitizeClass(val)}` : sanitizeClass(val);
472
+ return `${arb.prefix}-[${inner}]`;
473
+ }
474
+
475
+ /** The dedupe group of an existing utility class (so a re-set replaces same-property classes). */
476
+ function classGroup(cls: string): string {
477
+ if (ENUM_GROUP[cls]) return ENUM_GROUP[cls];
478
+ const arb =
479
+ cls.indexOf('-[') >= 0 ? cls.slice(0, cls.indexOf('-[') + 2) : cls.startsWith('[') ? '[' : '';
480
+ if (!arb) return cls;
481
+ // include a type hint (color:/length:) in the group so text-[color:..] != text-[length:..]
482
+ const hintMatch = cls.match(/\[(color|length):/);
483
+ return hintMatch ? `${arb}${hintMatch[1]}:` : arb;
484
+ }
485
+
486
+ /**
487
+ * Merge a class into the element's `className="..."` literal, or add the
488
+ * attribute.
489
+ *
490
+ * D-A1 assessment (wave 13, `docs/WAVE13-JSX-SPREAD-UNDO-DESIGN.md`): unlike
491
+ * `setInlineStyle`, this function has NO distinguishable append-vs-replace
492
+ * branch to surface as `appended` — it always does the SAME operation (filter
493
+ * out any class in the target's dedupe GROUP, then push the new class),
494
+ * regardless of whether a same-group class existed before. Scoped OUT of this
495
+ * wave's fix: see the design doc's SHIPPED note for why the className route's
496
+ * undo correctness is a separate, broader pre-existing gap, not this bug.
497
+ */
498
+ export function setClassName(code: string, elementStart: number, cls: string): StyleEditResult {
499
+ const tagEnd = findTagEnd(code, elementStart);
500
+ if (tagEnd < 0) return { code, changed: false, dynamic: false };
501
+ const openSeg = code.slice(elementStart, tagEnd);
502
+ const m = openSeg.match(/className\s*=\s*"([^"]*)"/);
503
+ if (m && m.index != null) {
504
+ // dedupe by PROPERTY GROUP (not raw prefix) so re-setting any prop replaces its
505
+ // prior class — incl. color-vs-fontSize (both `text-`) and enum/arbitrary-prop.
506
+ const group = classGroup(cls);
507
+ const existing = (m[1] ?? '')
508
+ .split(/\s+/)
509
+ .filter((c) => c && c !== cls && classGroup(c) !== group);
510
+ const merged = [...existing, cls].join(' ').trim();
511
+ const valStart = elementStart + (m.index ?? 0) + m[0].indexOf('"') + 1;
512
+ const valEnd = valStart + (m[1]?.length ?? 0);
513
+ return {
514
+ code: `${code.slice(0, valStart)}${merged}${code.slice(valEnd)}`,
515
+ changed: true,
516
+ dynamic: false,
517
+ };
518
+ }
519
+ if (/className\s*=\s*\{/.test(openSeg)) return { code, changed: false, dynamic: true }; // dynamic className
520
+ const selfClose = code[tagEnd - 1] === '/';
521
+ const insertAt = selfClose ? tagEnd - 1 : tagEnd;
522
+ return {
523
+ code: `${code.slice(0, insertAt)} className="${cls}"${code.slice(insertAt)}`,
524
+ changed: true,
525
+ dynamic: false,
526
+ };
527
+ }
528
+
529
+ /**
530
+ * Route a style edit to the right target (F5): if the element has a literal
531
+ * `className`, write a (lossless) utility class; otherwise write inline style.
532
+ */
533
+ export function writeStyleAuto(
534
+ code: string,
535
+ elementStart: number,
536
+ prop: string,
537
+ value: string | number,
538
+ ): StyleEditResult & { route: 'class' | 'inline' } {
539
+ const tagEnd = findTagEnd(code, elementStart);
540
+ const openSeg = tagEnd > 0 ? code.slice(elementStart, tagEnd) : '';
541
+ if (/className\s*=\s*"/.test(openSeg)) {
542
+ return { ...setClassName(code, elementStart, cssToUtilityClass(prop, value)), route: 'class' };
543
+ }
544
+ return { ...setInlineStyle(code, elementStart, prop, value), route: 'inline' };
545
+ }
546
+
547
+ // --- Cap 2: CSS-file editing (visual-edit writer.ts surgicalCssEdit :1109) ---
548
+
549
+ /** camelCase CSS prop -> kebab-case, for matching a declaration in a CSS rule body. */
550
+ function cssCamelToKebab(s: string): string {
551
+ return s.replace(/([A-Z])/g, '-$1').toLowerCase();
552
+ }
553
+
554
+ /** Find a standalone occurrence of `selectorText` in CSS source (not a substring of a
555
+ * longer selector). Returns the index or -1. */
556
+ function findSelectorIndex(cssSource: string, selectorText: string): number {
557
+ let searchFrom = 0;
558
+ while (searchFrom < cssSource.length) {
559
+ const idx = cssSource.indexOf(selectorText, searchFrom);
560
+ if (idx === -1) break;
561
+ const before = idx > 0 ? cssSource[idx - 1] : '\n';
562
+ if (idx === 0 || /[\s\n{},;]/.test(before ?? '')) {
563
+ const after = cssSource[idx + selectorText.length];
564
+ if (after === '{' || after === ' ' || after === '\n' || after === '\r' || after === '\t') {
565
+ return idx;
566
+ }
567
+ }
568
+ searchFrom = idx + 1;
569
+ }
570
+ return -1;
571
+ }
572
+
573
+ /** The `{ … }` body range of the rule whose selector starts at `selectorIdx`, or null. */
574
+ function findRuleBody(
575
+ cssSource: string,
576
+ selectorIdx: number,
577
+ selectorText: string,
578
+ ): { bodyStart: number; bodyEnd: number } | null {
579
+ let i = selectorIdx + selectorText.length;
580
+ while (i < cssSource.length && cssSource[i] !== '{') i++;
581
+ if (i >= cssSource.length) return null;
582
+ const bodyStart = i + 1;
583
+ let depth = 1;
584
+ i++;
585
+ while (i < cssSource.length && depth > 0) {
586
+ if (cssSource[i] === '{') depth++;
587
+ else if (cssSource[i] === '}') depth--;
588
+ if (depth > 0) i++;
589
+ }
590
+ return { bodyStart, bodyEnd: i };
591
+ }
592
+
593
+ /**
594
+ * Surgically replace (or append) a property value inside a CSS RULE BODY, editing the
595
+ * source in place and preserving all other declarations/formatting (visual-edit
596
+ * `surgicalCssEdit` :1109). Returns the edited source, or `null` when the selector is not
597
+ * present in the file (i.e. it's GENERATED CSS — a Tailwind utility, styled-components —
598
+ * that must not be hand-edited; the caller falls back to class/inline routing).
599
+ */
600
+ export function surgicalCssEdit(
601
+ cssSource: string,
602
+ selectorText: string,
603
+ propName: string,
604
+ newValue: string,
605
+ ): string | null {
606
+ const selectorIdx = findSelectorIndex(cssSource, selectorText);
607
+ if (selectorIdx === -1) return null;
608
+ const rng = findRuleBody(cssSource, selectorIdx, selectorText);
609
+ if (!rng) return null;
610
+ const { bodyStart, bodyEnd } = rng;
611
+ const body = cssSource.slice(bodyStart, bodyEnd);
612
+
613
+ const kebabProp = cssCamelToKebab(propName);
614
+ const escaped = kebabProp.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
615
+ const propRegex = new RegExp(`(^|[;\\n\\s])\\s*(${escaped})\\s*:`, 'm');
616
+ const match = propRegex.exec(body);
617
+
618
+ if (match) {
619
+ const matchStart = bodyStart + match.index + match[0].length; // right after the ':'
620
+ let valEnd = matchStart;
621
+ while (valEnd < bodyEnd && cssSource[valEnd] !== ';' && cssSource[valEnd] !== '}') valEnd++;
622
+ return `${cssSource.slice(0, matchStart)} ${newValue}${cssSource.slice(valEnd)}`;
623
+ }
624
+
625
+ // Property absent — append it before the closing '}', matching existing indentation.
626
+ let indent = ' ';
627
+ for (const line of body.split('\n')) {
628
+ if (line.trim().includes(':')) {
629
+ indent = line.match(/^(\s*)/)?.[1] ?? indent;
630
+ break;
631
+ }
632
+ }
633
+ const trimmed = body.trimEnd();
634
+ const needsSemi = trimmed.length > 0 && !trimmed.endsWith(';');
635
+ const insertion = `${needsSemi ? ';\n' : '\n'}${indent}${kebabProp}: ${newValue};\n`;
636
+ return cssSource.slice(0, bodyEnd) + insertion + cssSource.slice(bodyEnd);
637
+ }
638
+
639
+ /** A matched CSS rule as produced by `inspect.ts`'s `getMatchedCssRules` — the subset the
640
+ * CSS write path needs. */
641
+ export interface CssRuleTarget {
642
+ selectorText: string;
643
+ sourceFile: string;
644
+ properties: Record<string, string>;
645
+ }
646
+
647
+ /**
648
+ * Pick the target rule for a property write: the LAST matched rule that declares the
649
+ * property (cascade — last declaration wins), mirroring visual-edit `writeCssRuleChange`
650
+ * :1198. Returns the rule + the prior value (for the undo inverse), or null when no
651
+ * matched rule declares the property (⇒ caller routes to class/inline instead).
652
+ */
653
+ export function pickCssRuleTarget(
654
+ matchedRules: readonly CssRuleTarget[],
655
+ prop: string,
656
+ ): { rule: CssRuleTarget; prevValue: string } | null {
657
+ const kebabProp = cssCamelToKebab(prop);
658
+ let target: CssRuleTarget | null = null;
659
+ for (const rule of matchedRules) {
660
+ if (kebabProp in rule.properties) target = rule;
661
+ }
662
+ if (!target) return null;
663
+ return { rule: target, prevValue: target.properties[kebabProp] ?? '' };
664
+ }
665
+
666
+ // --- Cap 3: text-content editing (visual-edit writer.ts getEditableText :817 /
667
+ // editTextContent :861) ---
668
+
669
+ /** The `<`-relative content range (between the opening tag's `>` and the closing `</`)
670
+ * of the element at `elementStart`, plus whether it is a pure-text (editable) body. */
671
+ function findTextContentRange(
672
+ code: string,
673
+ elementStart: number,
674
+ ): { contentStart: number; closingTagStart: number; content: string } | null {
675
+ if (code[elementStart] !== '<') return null;
676
+ const tagEnd = findTagEnd(code, elementStart);
677
+ if (tagEnd < 0 || code[tagEnd - 1] === '/') return null; // self-closing ⇒ no text body
678
+ const contentStart = tagEnd + 1;
679
+ const elementEnd = findElementEnd(code, elementStart);
680
+ if (elementEnd < 0) return null;
681
+ // The closing tag is the last `</` before elementEnd.
682
+ let closingTagStart = elementEnd - 1;
683
+ while (
684
+ closingTagStart > contentStart &&
685
+ !(code[closingTagStart] === '<' && code[closingTagStart + 1] === '/')
686
+ ) {
687
+ closingTagStart--;
688
+ }
689
+ return { contentStart, closingTagStart, content: code.slice(contentStart, closingTagStart) };
690
+ }
691
+
692
+ /**
693
+ * Return an element's editable text — its content trimmed — IFF the body is pure text (no
694
+ * `{expression}` and no child `<element>`); else null (visual-edit `getEditableText` :817).
695
+ * This is the double-click-to-edit gate: only a leaf text node is editable in place.
696
+ */
697
+ export function getEditableText(code: string, elementStart: number): string | null {
698
+ const rng = findTextContentRange(code, elementStart);
699
+ if (!rng) return null;
700
+ if (rng.content.includes('{') || rng.content.includes('<')) return null; // dynamic/children
701
+ const trimmed = rng.content.trim();
702
+ return trimmed ? trimmed : null;
703
+ }
704
+
705
+ /** Escape the JSX-structural characters so replacement text can't break the source tree
706
+ * (`<`/`{`/`}` become entities; `&` first so we don't double-encode). */
707
+ function escapeJsxText(text: string): string {
708
+ return text
709
+ .replace(/&/g, '&amp;')
710
+ .replace(/</g, '&lt;')
711
+ .replace(/\{/g, '&#123;')
712
+ .replace(/\}/g, '&#125;');
713
+ }
714
+
715
+ /**
716
+ * Replace an element's text content in place, preserving the surrounding whitespace
717
+ * (only the trimmed text portion is swapped — visual-edit `editTextContent` :861). Refuses
718
+ * (guard) when the body is not pure text (`dynamic: true`), exactly like the read gate.
719
+ */
720
+ export function editTextContent(
721
+ code: string,
722
+ elementStart: number,
723
+ newText: string,
724
+ ): StyleEditResult {
725
+ const rng = findTextContentRange(code, elementStart);
726
+ if (!rng) return { code, changed: false, dynamic: false };
727
+ const { contentStart, closingTagStart, content } = rng;
728
+ if (content.includes('{') || content.includes('<'))
729
+ return { code, changed: false, dynamic: true };
730
+ const leading = content.length - content.trimStart().length;
731
+ const trailing = content.length - content.trimEnd().length;
732
+ const replaceStart = contentStart + leading;
733
+ const replaceEnd = closingTagStart - trailing;
734
+ const next = code.slice(0, replaceStart) + escapeJsxText(newText) + code.slice(replaceEnd);
735
+ return { code: next, changed: next !== code, dynamic: false };
736
+ }
737
+
738
+ // --- Cap 4: JSX prop / attribute editing (visual-edit writer.ts analyzeJsxAttributes
739
+ // :913 / writePropChange :1018 / fetchPropAnalysis :1069) ---
740
+
741
+ export interface JsxAttrInfo {
742
+ name: string;
743
+ /** Value char range in `code` (exclusive of quotes/braces). -1 for a boolean shorthand. */
744
+ valueStart: number;
745
+ valueEnd: number;
746
+ rawValue: string;
747
+ /** True if the value is a `{…}` expression container (vs a `"…"` string). */
748
+ isExpression: boolean;
749
+ /** True if the value is a literal we can safely rewrite (string, number, boolean). */
750
+ isLiteral: boolean;
751
+ }
752
+
753
+ /** Skip a `{…}` region (spread or expression), respecting nested braces + strings. Returns
754
+ * the index just past the matching `}`. */
755
+ function skipBraces(code: string, from: number): number {
756
+ let i = from + 1;
757
+ let depth = 1;
758
+ let inStr: string | null = null;
759
+ while (i < code.length && depth > 0) {
760
+ const ch = code[i];
761
+ if (inStr) {
762
+ if (ch === inStr && code[i - 1] !== '\\') inStr = null;
763
+ } else if (ch === "'" || ch === '"' || ch === '`') inStr = ch;
764
+ else if (ch === '{') depth++;
765
+ else if (ch === '}') depth--;
766
+ i++;
767
+ }
768
+ return i;
769
+ }
770
+
771
+ /** Read a quoted string value starting at the opening quote; returns [valueStart, valueEnd,
772
+ * indexAfterClosingQuote]. */
773
+ function readQuotedValue(code: string, openQuote: number, delim: string): [number, number, number] {
774
+ let i = openQuote + 1;
775
+ const valueStart = i;
776
+ while (i < code.length && code[i] !== delim) {
777
+ if (code[i] === '\\') i++;
778
+ i++;
779
+ }
780
+ return [valueStart, i, i + 1];
781
+ }
782
+
783
+ const LITERAL_EXPR_RE = /^(\d+(\.\d+)?|true|false|'[^']*'|"[^"]*")$/;
784
+
785
+ /**
786
+ * Parse the attributes of a JSX opening tag `<Tag …>` (visual-edit `analyzeJsxAttributes`
787
+ * :913). Handles string values, `{expression}` values (literal detection), boolean
788
+ * shorthand, and `{...spread}` (skipped). Pure string scan — no parser.
789
+ */
790
+ export function analyzeJsxAttributes(
791
+ code: string,
792
+ tagStart: number,
793
+ tagEnd: number,
794
+ ): JsxAttrInfo[] {
795
+ const attrs: JsxAttrInfo[] = [];
796
+ const skipWs = (i: number): number => {
797
+ while (i <= tagEnd && /\s/.test(code[i] ?? '')) i++;
798
+ return i;
799
+ };
800
+ let i = tagStart + 1; // skip '<'
801
+ while (i <= tagEnd && !/[\s>/]/.test(code[i] ?? '>')) i++; // skip tag name
802
+
803
+ while (i <= tagEnd) {
804
+ i = skipWs(i);
805
+ if (i > tagEnd || code[i] === '>' || (code[i] === '/' && code[i + 1] === '>')) break;
806
+ if (code[i] === '{') {
807
+ i = skipBraces(code, i); // {...spread}
808
+ continue;
809
+ }
810
+ const nameStart = i;
811
+ while (i <= tagEnd && /[\w-]/.test(code[i] ?? '')) i++;
812
+ const name = code.slice(nameStart, i);
813
+ if (!name) {
814
+ i++;
815
+ continue;
816
+ }
817
+ i = skipWs(i);
818
+ if (code[i] !== '=') {
819
+ attrs.push(booleanShorthand(name)); // e.g. `disabled`
820
+ continue;
821
+ }
822
+ i = skipWs(i + 1); // skip '=' + whitespace
823
+ const parsed = readAttrValue(code, i);
824
+ if (!parsed) {
825
+ i++;
826
+ continue;
827
+ }
828
+ attrs.push(makeAttr(code, name, parsed));
829
+ i = parsed.next;
830
+ }
831
+ return attrs;
832
+ }
833
+
834
+ /** A boolean-shorthand attribute descriptor (`disabled` ⇒ `true`, no value range). */
835
+ function booleanShorthand(name: string): JsxAttrInfo {
836
+ return {
837
+ name,
838
+ valueStart: -1,
839
+ valueEnd: -1,
840
+ rawValue: 'true',
841
+ isExpression: false,
842
+ isLiteral: true,
843
+ };
844
+ }
845
+
846
+ /** Read an attribute value starting at `i` (the delimiter after `=`). Returns the value
847
+ * range + whether it's an expression + the index past it, or null for an unrecognized
848
+ * delimiter (the caller advances one char). */
849
+ function readAttrValue(
850
+ code: string,
851
+ i: number,
852
+ ): { valueStart: number; valueEnd: number; isExpression: boolean; next: number } | null {
853
+ const delim = code[i];
854
+ if (delim === '"' || delim === "'") {
855
+ const [valueStart, valueEnd, next] = readQuotedValue(code, i, delim);
856
+ return { valueStart, valueEnd, isExpression: false, next };
857
+ }
858
+ if (delim === '{') {
859
+ const valueEnd = skipBraces(code, i) - 1;
860
+ return { valueStart: i + 1, valueEnd, isExpression: true, next: valueEnd + 1 };
861
+ }
862
+ return null;
863
+ }
864
+
865
+ /** Build a `JsxAttrInfo` from a parsed value range (computes rawValue + literal flag). */
866
+ function makeAttr(
867
+ code: string,
868
+ name: string,
869
+ parsed: { valueStart: number; valueEnd: number; isExpression: boolean },
870
+ ): JsxAttrInfo {
871
+ const rawValue = code.slice(parsed.valueStart, parsed.valueEnd).trim();
872
+ const isLiteral = !parsed.isExpression || LITERAL_EXPR_RE.test(rawValue);
873
+ return {
874
+ name,
875
+ valueStart: parsed.valueStart,
876
+ valueEnd: parsed.valueEnd,
877
+ rawValue,
878
+ isExpression: parsed.isExpression,
879
+ isLiteral,
880
+ };
881
+ }
882
+
883
+ /** Props never surfaced as editable component props (visual-edit `fetchPropAnalysis` skip
884
+ * set + on* handlers — event handlers are always dynamic). */
885
+ const PROP_SKIP = new Set(['key', 'ref', 'data-oid', 'style', 'className', 'children']);
886
+ const isSkippedProp = (name: string): boolean => PROP_SKIP.has(name) || /^on[A-Z]/.test(name);
887
+
888
+ /** Split a component's JSX attributes into editable (literal) vs dynamic (expression) —
889
+ * the Props inspector uses this to gate which props can be written (visual-edit
890
+ * `fetchPropAnalysis` :1069). */
891
+ export function analyzeProps(
892
+ code: string,
893
+ elementStart: number,
894
+ ): { editable: string[]; dynamic: string[] } {
895
+ const empty = { editable: [], dynamic: [] };
896
+ if (code[elementStart] !== '<') return empty;
897
+ const tagEnd = findTagEnd(code, elementStart);
898
+ if (tagEnd < 0) return empty;
899
+ const editable: string[] = [];
900
+ const dynamic: string[] = [];
901
+ for (const attr of analyzeJsxAttributes(code, elementStart, tagEnd)) {
902
+ if (isSkippedProp(attr.name)) continue;
903
+ (attr.isLiteral ? editable : dynamic).push(attr.name);
904
+ }
905
+ return { editable, dynamic };
906
+ }
907
+
908
+ /** Format the replacement for a prop write, matching the original value's shape (number/
909
+ * boolean/quoted expression, or a plain string attribute). */
910
+ function formatPropReplacement(attr: JsxAttrInfo, newValue: string): string {
911
+ if (!attr.isExpression) return newValue; // string attribute — content between the quotes
912
+ const raw = attr.rawValue;
913
+ if (/^\d+(\.\d+)?$/.test(raw)) {
914
+ const num = Number(newValue);
915
+ return Number.isNaN(num) ? `'${newValue}'` : String(num);
916
+ }
917
+ if (raw === 'true' || raw === 'false') {
918
+ return newValue === 'true' || newValue === 'false' ? newValue : `'${newValue}'`;
919
+ }
920
+ if (/^"[^"]*"$/.test(raw)) return `"${newValue.replace(/"/g, '\\"')}"`;
921
+ return `'${newValue.replace(/'/g, "\\'")}'`;
922
+ }
923
+
924
+ /**
925
+ * Write a component prop change back to source at the call site (visual-edit
926
+ * `writePropChange` :1018). Refuses (guards) a dynamic (non-literal) prop and skips the
927
+ * event-handler / structural props. `elementStart` is the call-site element's `<`.
928
+ */
929
+ export function writePropChange(
930
+ code: string,
931
+ elementStart: number,
932
+ propName: string,
933
+ newValue: string,
934
+ ): StyleEditResult {
935
+ if (code[elementStart] !== '<') return { code, changed: false, dynamic: false };
936
+ const tagEnd = findTagEnd(code, elementStart);
937
+ if (tagEnd < 0) return { code, changed: false, dynamic: false };
938
+ if (isSkippedProp(propName)) return { code, changed: false, dynamic: false };
939
+ const attr = analyzeJsxAttributes(code, elementStart, tagEnd).find((a) => a.name === propName);
940
+ if (!attr) return { code, changed: false, dynamic: false };
941
+ if (!attr.isLiteral || attr.valueStart < 0) return { code, changed: false, dynamic: true };
942
+ const replacement = formatPropReplacement(attr, newValue);
943
+ const next = code.slice(0, attr.valueStart) + replacement + code.slice(attr.valueEnd);
944
+ return { code: next, changed: next !== code, dynamic: false };
945
+ }
946
+
947
+ // --- Cap 5: source structural ops (visual-edit writer.ts reorderChild :483 /
948
+ // duplicateElement :559 / wrapElement :634 / unwrapElement :679 /
949
+ // reparentElement :743) ---
950
+
951
+ export interface StructEditResult {
952
+ code: string;
953
+ changed: boolean;
954
+ }
955
+
956
+ /** Expand a JSX element at `elementStart` to its full LINE block (leading whitespace on the
957
+ * first line, trailing newline on the last) — the unit these line-based ops move/copy. */
958
+ function lineBlockRange(
959
+ code: string,
960
+ elementStart: number,
961
+ ): { lineStart: number; lineEnd: number } | null {
962
+ if (code[elementStart] !== '<') return null;
963
+ const end = findElementEnd(code, elementStart);
964
+ if (end < 0) return null;
965
+ let lineStart = elementStart;
966
+ while (lineStart > 0 && code[lineStart - 1] !== '\n') lineStart--;
967
+ let lineEnd = end;
968
+ while (lineEnd < code.length && code[lineEnd] !== '\n') lineEnd++;
969
+ if (lineEnd < code.length && code[lineEnd] === '\n') lineEnd++;
970
+ return { lineStart, lineEnd };
971
+ }
972
+
973
+ /** The `<` of a parent's closing tag, given the parent's opening `<` (visual-edit's
974
+ * backwards scan from the element end). */
975
+ function closingTagStartOf(code: string, parentStart: number): number | null {
976
+ const parentEnd = findElementEnd(code, parentStart);
977
+ if (parentEnd < 0) return null;
978
+ let i = parentEnd;
979
+ while (i > parentStart && code[i - 1] !== '<') i--;
980
+ return i - 1; // point at the '<'
981
+ }
982
+
983
+ /** Duplicate the element's line block, inserting the copy immediately after it (visual-edit
984
+ * `duplicateElement` :559). */
985
+ export function duplicateElement(code: string, elementStart: number): StructEditResult {
986
+ const r = lineBlockRange(code, elementStart);
987
+ if (!r) return { code, changed: false };
988
+ const block = code.slice(r.lineStart, r.lineEnd);
989
+ return { code: code.slice(0, r.lineEnd) + block + code.slice(r.lineEnd), changed: true };
990
+ }
991
+
992
+ /** Wrap the element in a new `<wrapperTag>…</wrapperTag>` container, re-indenting the
993
+ * wrapped block one level (visual-edit `wrapElement` :634). */
994
+ export function wrapElement(
995
+ code: string,
996
+ elementStart: number,
997
+ wrapperTag = 'div',
998
+ ): StructEditResult {
999
+ const r = lineBlockRange(code, elementStart);
1000
+ if (!r) return { code, changed: false };
1001
+ const block = code.slice(r.lineStart, r.lineEnd);
1002
+ const indent = block.match(/^(\s*)/)?.[1] ?? '';
1003
+ const reindented = block
1004
+ .split('\n')
1005
+ .map((line) => (line.length > 0 ? ` ${line}` : line))
1006
+ .join('\n');
1007
+ const wrapper = `${indent}<${wrapperTag}>\n${reindented}${indent}</${wrapperTag}>\n`;
1008
+ return { code: code.slice(0, r.lineStart) + wrapper + code.slice(r.lineEnd), changed: true };
1009
+ }
1010
+
1011
+ /** Unwrap the element — replace it with its children, dedented one level (visual-edit
1012
+ * `unwrapElement` :679). A self-closing element simply deletes. */
1013
+ export function unwrapElement(code: string, elementStart: number): StructEditResult {
1014
+ if (code[elementStart] !== '<') return { code, changed: false };
1015
+ const end = findElementEnd(code, elementStart);
1016
+ const openTagEnd = findTagEnd(code, elementStart);
1017
+ if (end < 0 || openTagEnd < 0) return { code, changed: false };
1018
+ if (code[openTagEnd - 1] === '/') return deleteElement(code, elementStart); // self-closing
1019
+ let closingTagStart = end;
1020
+ while (closingTagStart > elementStart && code[closingTagStart - 1] !== '<') closingTagStart--;
1021
+ closingTagStart--; // point at '<'
1022
+ const children = code.slice(openTagEnd + 1, closingTagStart);
1023
+ const r = lineBlockRange(code, elementStart);
1024
+ if (!r) return { code, changed: false };
1025
+ const dedented = children
1026
+ .split('\n')
1027
+ .map((line) =>
1028
+ line.startsWith(' ') ? line.slice(2) : line.startsWith('\t') ? line.slice(1) : line,
1029
+ )
1030
+ .join('\n');
1031
+ return { code: code.slice(0, r.lineStart) + dedented + code.slice(r.lineEnd), changed: true };
1032
+ }
1033
+
1034
+ /** Insert a new child element `<tag />` just before `parentStart`'s closing tag, indented
1035
+ * to match the parent's children (the "create" primitive). */
1036
+ export function insertChildElement(
1037
+ code: string,
1038
+ parentStart: number,
1039
+ tag = 'div',
1040
+ ): StructEditResult {
1041
+ const closingTagStart = closingTagStartOf(code, parentStart);
1042
+ if (closingTagStart == null) return { code, changed: false };
1043
+ let parentLineStart = parentStart;
1044
+ while (parentLineStart > 0 && code[parentLineStart - 1] !== '\n') parentLineStart--;
1045
+ const parentIndent = code.slice(parentLineStart, parentStart).match(/^(\s*)/)?.[1] ?? '';
1046
+ let insertPos = closingTagStart;
1047
+ while (insertPos > 0 && code[insertPos - 1] !== '\n') insertPos--;
1048
+ const child = `${parentIndent} <${tag} />\n`;
1049
+ return { code: code.slice(0, insertPos) + child + code.slice(insertPos), changed: true };
1050
+ }
1051
+
1052
+ /** Reorder the element among its siblings: before `beforeStart` (an OID-resolved sibling
1053
+ * offset), or — when `beforeStart` is null — at the end of `parentStart` (visual-edit
1054
+ * `reorderChild` :483). OID-resolved offsets, immune to index races. */
1055
+ export function reorderChild(
1056
+ code: string,
1057
+ elementStart: number,
1058
+ beforeStart: number | null,
1059
+ parentStart: number | null,
1060
+ ): StructEditResult {
1061
+ const r = lineBlockRange(code, elementStart);
1062
+ if (!r) return { code, changed: false };
1063
+ const { lineStart, lineEnd } = r;
1064
+ const block = code.slice(lineStart, lineEnd);
1065
+ const removedLength = lineEnd - lineStart;
1066
+
1067
+ let targetLineStart: number;
1068
+ if (beforeStart != null) {
1069
+ targetLineStart = beforeStart;
1070
+ while (targetLineStart > 0 && code[targetLineStart - 1] !== '\n') targetLineStart--;
1071
+ if (targetLineStart === lineStart) return { code, changed: false }; // same position
1072
+ } else if (parentStart != null) {
1073
+ const closingTagStart = closingTagStartOf(code, parentStart);
1074
+ if (closingTagStart == null) return { code, changed: false };
1075
+ targetLineStart = closingTagStart;
1076
+ while (targetLineStart > 0 && code[targetLineStart - 1] !== '\n') targetLineStart--;
1077
+ if (lineStart >= targetLineStart) return { code, changed: false }; // already at end
1078
+ } else {
1079
+ return { code, changed: false };
1080
+ }
1081
+
1082
+ const without = code.slice(0, lineStart) + code.slice(lineEnd);
1083
+ const adj = targetLineStart > lineStart ? targetLineStart - removedLength : targetLineStart;
1084
+ return { code: without.slice(0, adj) + block + without.slice(adj), changed: true };
1085
+ }
1086
+
1087
+ /** Reparent the element to be the last child of `parentStart`, re-indented to the parent's
1088
+ * child level (visual-edit `reparentElement` :743). Same-file only (the caller guards). */
1089
+ export function reparentElement(
1090
+ code: string,
1091
+ elementStart: number,
1092
+ parentStart: number,
1093
+ ): StructEditResult {
1094
+ const r = lineBlockRange(code, elementStart);
1095
+ if (!r) return { code, changed: false };
1096
+ const { lineStart: elLineStart, lineEnd: elLineEnd } = r;
1097
+ const block = code.slice(elLineStart, elLineEnd);
1098
+ const closingTagStart = closingTagStartOf(code, parentStart);
1099
+ if (closingTagStart == null) return { code, changed: false };
1100
+
1101
+ let parentLineStart = parentStart;
1102
+ while (parentLineStart > 0 && code[parentLineStart - 1] !== '\n') parentLineStart--;
1103
+ const parentIndent = code.slice(parentLineStart, parentStart).match(/^(\s*)/)?.[1] ?? '';
1104
+ const childIndent = `${parentIndent} `;
1105
+ const reindented = block
1106
+ .split('\n')
1107
+ .map((line) => {
1108
+ const trimmed = line.replace(/^\s*/, '');
1109
+ return trimmed ? childIndent + trimmed : '';
1110
+ })
1111
+ .join('\n');
1112
+
1113
+ const without = code.slice(0, elLineStart) + code.slice(elLineEnd);
1114
+ let adjustedClosing = closingTagStart;
1115
+ if (elLineStart < closingTagStart) adjustedClosing -= elLineEnd - elLineStart;
1116
+ let insertPos = adjustedClosing;
1117
+ while (insertPos > 0 && without[insertPos - 1] !== '\n') insertPos--;
1118
+ return {
1119
+ code: without.slice(0, insertPos) + reindented + without.slice(insertPos),
1120
+ changed: true,
1121
+ };
1122
+ }
1123
+
1124
+ /** Delete the element starting at `elementStart` from source (C8). */
1125
+ export function deleteElement(
1126
+ code: string,
1127
+ elementStart: number,
1128
+ ): { code: string; changed: boolean } {
1129
+ const end = findElementEnd(code, elementStart);
1130
+ if (end < 0) return { code, changed: false };
1131
+ // also consume leading whitespace on the line + trailing newline
1132
+ let s = elementStart;
1133
+ while (s > 0 && (code[s - 1] === ' ' || code[s - 1] === '\t')) s--;
1134
+ let e = end;
1135
+ if (code[e] === '\n') e++;
1136
+ return { code: code.slice(0, s) + code.slice(e), changed: true };
1137
+ }
1138
+
1139
+ /**
1140
+ * Delete MULTIPLE elements from a SINGLE `code` snapshot, given every target's
1141
+ * `elementStart` offset resolved against that SAME snapshot (delete-order-residual
1142
+ * fix — `vite-plugin-ui-oid.ts`'s `handleStructMany`). Sound and caller-order-
1143
+ * independent: every offset is resolved from the file ONCE, up front, then applied
1144
+ * highest-offset-first — each `deleteElement` call only ever removes text AT OR
1145
+ * AFTER its own target, so every offset still queued (all of them numerically
1146
+ * SMALLER, since we process descending) is untouched by it and stays valid. A
1147
+ * nested descendant's `elementStart` is always numerically greater than its
1148
+ * ancestor's (it appears later in source), so this single offset-value rule also
1149
+ * deletes a selected descendant before a selected ancestor, with no separate
1150
+ * ancestor/descendant case to reason about. Unlike re-resolving each target by
1151
+ * re-scanning/re-transforming the file between deletes, this never recomputes an
1152
+ * OID's identity mid-batch — it only ever reads the ALREADY-RESOLVED offsets handed
1153
+ * to it, so it cannot suffer the occurrence-index churn a live re-transform would
1154
+ * introduce (deleting one same-tag sibling shifts every later sibling's own
1155
+ * `component:tag:nthOccurrence` signature, which would silently relabel it as a
1156
+ * DIFFERENT, already-used oid if resolution were re-derived from a fresh transform
1157
+ * mid-batch — the reason `handleStruct`'s per-call resolution is not simply looped
1158
+ * here without change).
1159
+ */
1160
+ export function deleteElements(
1161
+ code: string,
1162
+ elementStarts: readonly number[],
1163
+ ): { code: string; changed: boolean } {
1164
+ const sorted = [...elementStarts].sort((a, b) => b - a); // highest offset first
1165
+ let cur = code;
1166
+ let changed = false;
1167
+ for (const off of sorted) {
1168
+ const result = deleteElement(cur, off);
1169
+ if (result.changed) {
1170
+ cur = result.code;
1171
+ changed = true;
1172
+ }
1173
+ }
1174
+ return { code: cur, changed };
1175
+ }