@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,1268 @@
1
+ /**
2
+ * DATA dock tab (W2, docs/DATA-TOOLS-DESIGN.md §2.4) — lists every
3
+ * `src/data/*.data.json` of the open project and edits it in place.
4
+ *
5
+ * Architecture (spec §2.1): widgets are generated from the EMITTED
6
+ * `*.schema.json` (JSON Schema, the interchange artifact) via
7
+ * `jsonSchemaToFieldDescriptors` — the editor never executes the project's
8
+ * `.schema.ts`. The two blessed shapes render differently:
9
+ * - **singleton** → a form, one widget row per field (same look as the
10
+ * component inspector — same `FieldDescriptor` IR, same widget primitives).
11
+ * - **table** (`z.record`) → a grid: one row per key in the data file, one
12
+ * column per row-schema field, every cell a widget.
13
+ *
14
+ * Writes (D1, §2.5): every widget commit POSTs the whole updated file to
15
+ * `/__editor/data-file` immediately — no local buffering, no save button;
16
+ * undo is git. The `"$schema"` key is held out of editing everywhere and
17
+ * re-serialized first so git diffs stay minimal.
18
+ *
19
+ * Table rows can be added/removed (W6b) — see `TableEditor` below, which also
20
+ * carries CSV export/import (W6c, `data-csv.ts`) — a spreadsheet-friendly
21
+ * bulk-edit path for the same table. The empty state (`EmptyState`, W6d)
22
+ * teaches the create-a-data-asset recipe with a "Copy starter files" button;
23
+ * no file-creating scaffold button this wave (deferred — needs a
24
+ * purpose-scoped write verb, a security-surface decision). Still pending:
25
+ * adding a whole new FIELD to a schema (a `.schema.ts` code change, not an
26
+ * editor concern).
27
+ *
28
+ * Cross-asset refs (§2.2): a field declared with `dataRef(target)`
29
+ * (`@engine/data/data-ref`) renders as a `stem#key` PICKER instead of free
30
+ * text — `RefOptionsContext` reduces this panel's already-loaded `files`
31
+ * state into `stem -> key[]` once per change and threads it to `Widget`'s
32
+ * `'ref'` case (deep in `FieldList`/`TableEditor`) without prop-drilling.
33
+ */
34
+
35
+ import {
36
+ createContext,
37
+ Fragment,
38
+ useCallback,
39
+ useContext,
40
+ useEffect,
41
+ useMemo,
42
+ useRef,
43
+ useState,
44
+ } from 'react';
45
+ import { connectDataFileEvents } from '../asset-events';
46
+ import { csvToTable, tableToCsv } from './data-csv';
47
+ import {
48
+ type DataAssetShape,
49
+ type DataFieldDescriptor,
50
+ defaultValueForDescriptor,
51
+ describeDataSchema,
52
+ } from './inspectors/json-schema-fields';
53
+ import { ColorInput } from './primitives/ColorInput';
54
+ import { NumberInput } from './primitives/NumberInput';
55
+ import { Tooltip } from './primitives/Tooltip';
56
+
57
+ /** One file as served by GET /__editor/data-files (raw text, client parses). */
58
+ interface ServerDataFile {
59
+ path: string;
60
+ content: string;
61
+ schema: string | null;
62
+ }
63
+
64
+ /** One file after client-side parsing + schema classification. */
65
+ interface LoadedDataFile {
66
+ /** Project-relative path, e.g. `src/data/tuning.data.json`. */
67
+ path: string;
68
+ /** Parsed data object (`"$schema"` entry included), or null on parse error. */
69
+ data: Record<string, unknown> | null;
70
+ /**
71
+ * The file's own top-level key order, read from the RAW TEXT (D1
72
+ * diff-contract, #6): a JS object's own `Object.keys` order is not the
73
+ * file's textual order — integer-LIKE keys ("1", "2", "10") are always
74
+ * enumerated numerically-ascending by the engine, regardless of where they
75
+ * sat in the source JSON, so a table keyed by numeric strings would get a
76
+ * whole-file reorder on the very first edit if we ever serialized from
77
+ * `Object.keys(data)`. Captured once at load, then carried forward
78
+ * (new/removed keys folded in) by `nextKeyOrder` on every commit — see
79
+ * `writeFile`. `undefined` only when `data` is null (unparseable file).
80
+ */
81
+ keyOrder?: string[];
82
+ /** Widget plan derived from the emitted schema; null = no/unusable schema. */
83
+ shape: DataAssetShape | null;
84
+ hasSchema: boolean;
85
+ error?: string;
86
+ }
87
+
88
+ /**
89
+ * Every loaded file's own top-level keys, by asset stem — the `'ref'` widget
90
+ * kind's picker options. `DataPanel` already fetches and parses every data
91
+ * file's raw text (`GET /__editor/data-files`); this just reduces that same
92
+ * state into `stem -> key[]` once per `files` change and threads it to
93
+ * `Widget` (deep inside `FieldList`/`TableEditor`) via context instead of a
94
+ * prop through every intermediate component — the SAME shape a `dataRef`
95
+ * field's `refTarget` addresses (`json-schema-fields.ts`).
96
+ */
97
+ const RefOptionsContext = createContext<ReadonlyMap<string, string[]>>(new Map());
98
+
99
+ const inputStyle: React.CSSProperties = {
100
+ width: '100%',
101
+ background: '#1e1e1e',
102
+ color: '#c5c8ce',
103
+ border: '1px solid #333',
104
+ borderRadius: 2,
105
+ padding: '2px 4px',
106
+ fontSize: 12,
107
+ boxSizing: 'border-box',
108
+ };
109
+
110
+ const labelStyle: React.CSSProperties = {
111
+ fontSize: 11,
112
+ color: '#8c8c8c',
113
+ width: 140,
114
+ flexShrink: 0,
115
+ };
116
+
117
+ /** Scan a (valid-JSON) string literal starting at the opening quote; returns the index just past the closing quote. */
118
+ function skipJsonString(text: string, i: number): number {
119
+ let j = i + 1;
120
+ while (j < text.length) {
121
+ const ch = text[j];
122
+ if (ch === '\\') {
123
+ j += 2;
124
+ continue;
125
+ }
126
+ if (ch === '"') return j + 1;
127
+ j++;
128
+ }
129
+ return j;
130
+ }
131
+
132
+ /** Scan one JSON value (string/object/array/number/bool/null) starting at `i`; returns the index just past it. Assumes well-formed JSON (only ever called on text `JSON.parse` already accepted). */
133
+ function skipJsonValue(text: string, i: number): number {
134
+ const ch = text[i];
135
+ if (ch === '"') return skipJsonString(text, i);
136
+ if (ch === '{' || ch === '[') {
137
+ const close = ch === '{' ? '}' : ']';
138
+ let depth = 1;
139
+ let j = i + 1;
140
+ while (j < text.length && depth > 0) {
141
+ const c = text[j];
142
+ if (c === '"') {
143
+ j = skipJsonString(text, j);
144
+ continue;
145
+ }
146
+ if (c === '{' || c === '[') depth++;
147
+ else if (c === close || c === '}' || c === ']') depth--;
148
+ j++;
149
+ }
150
+ return j;
151
+ }
152
+ // number / true / false / null — run until a structural character.
153
+ let j = i;
154
+ while (j < text.length && !/[,}\]\s]/.test(text[j]!)) j++;
155
+ return j;
156
+ }
157
+
158
+ /**
159
+ * Read a top-level JSON object's key order straight off the SOURCE TEXT
160
+ * (D1 diff-contract, #6) — `JSON.parse` builds a JS object whose own
161
+ * enumeration order already reindexed any integer-like keys, so recovering
162
+ * "the order the file had on disk" has to happen before that reindexing, on
163
+ * the raw characters. Only ever called with text that `JSON.parse` already
164
+ * accepted, so this can assume well-formed JSON throughout.
165
+ */
166
+ function extractTopLevelKeyOrder(text: string): string[] {
167
+ const keys: string[] = [];
168
+ let i = 0;
169
+ const n = text.length;
170
+ const skipWs = () => {
171
+ while (i < n && /\s/.test(text[i]!)) i++;
172
+ };
173
+ skipWs();
174
+ if (text[i] !== '{') return keys;
175
+ i++;
176
+ skipWs();
177
+ while (i < n && text[i] !== '}') {
178
+ if (text[i] !== '"') break;
179
+ const keyEnd = skipJsonString(text, i);
180
+ const key = JSON.parse(text.slice(i, keyEnd)) as string;
181
+ if (!keys.includes(key)) keys.push(key);
182
+ i = keyEnd;
183
+ skipWs();
184
+ if (text[i] !== ':') break;
185
+ i++;
186
+ skipWs();
187
+ i = skipJsonValue(text, i);
188
+ skipWs();
189
+ if (text[i] === ',') {
190
+ i++;
191
+ skipWs();
192
+ continue;
193
+ }
194
+ break;
195
+ }
196
+ return keys;
197
+ }
198
+
199
+ function parseServerFile(f: ServerDataFile): LoadedDataFile {
200
+ let data: Record<string, unknown> | null = null;
201
+ let error: string | undefined;
202
+ try {
203
+ const parsed = JSON.parse(f.content) as unknown;
204
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
205
+ data = parsed as Record<string, unknown>;
206
+ } else {
207
+ error = 'Data file is not a JSON object.';
208
+ }
209
+ } catch (e) {
210
+ error = `Data file is not valid JSON: ${e}`;
211
+ }
212
+
213
+ let shape: DataAssetShape | null = null;
214
+ if (f.schema !== null) {
215
+ try {
216
+ shape = describeDataSchema(JSON.parse(f.schema));
217
+ } catch {
218
+ /* malformed emitted schema — fall through to the no-schema message */
219
+ }
220
+ }
221
+
222
+ const out: LoadedDataFile = { path: f.path, data, shape, hasSchema: f.schema !== null };
223
+ if (data) {
224
+ try {
225
+ out.keyOrder = extractTopLevelKeyOrder(f.content);
226
+ } catch {
227
+ out.keyOrder = Object.keys(data); // defensive fallback — never block loading over this
228
+ }
229
+ }
230
+ if (error !== undefined) out.error = error;
231
+ return out;
232
+ }
233
+
234
+ /**
235
+ * Fold a commit's resulting keys into a stable order: `prevOrder` (whatever
236
+ * this file's keys were last serialized in — from disk on first load, from
237
+ * the previous commit after that) survives for every key still present, and
238
+ * any BRAND NEW key (add-row, a freshly-set nested field) is appended at the
239
+ * end in `Object.keys` order. This is what makes #6's fix stick across a
240
+ * SESSION of edits, not just the first one: each commit's output order
241
+ * becomes the next commit's `prevOrder`.
242
+ */
243
+ function nextKeyOrder(prevOrder: string[] | undefined, obj: Record<string, unknown>): string[] {
244
+ const existing = prevOrder ?? Object.keys(obj);
245
+ const kept = [...new Set(existing)].filter((k) => k in obj);
246
+ const added = Object.keys(obj).filter((k) => !existing.includes(k));
247
+ return [...kept, ...added];
248
+ }
249
+
250
+ /**
251
+ * Serialize per D1's diff-minimal contract: `"$schema"` first, then every
252
+ * other key in `keyOrder` (falling back to `Object.keys(obj)` if the caller
253
+ * has none — e.g. a brand-new file), 2-space indent, trailing newline.
254
+ *
255
+ * Deliberately NOT `JSON.stringify(orderedObject, null, 2)` on a
256
+ * spread-reordered plain object (#6, D1 diff-contract): building a plain
257
+ * object and letting the engine enumerate its keys reorders any INTEGER-LIKE
258
+ * key ("1", "2", "10") to numeric-ascending regardless of insertion order —
259
+ * so a table keyed by numeric strings would get its whole file reordered on
260
+ * the very first value edit. Emitting the object manually, key by key, in
261
+ * the exact order the caller hands us, sidesteps that reindexing entirely.
262
+ */
263
+ function serializeDataFile(obj: Record<string, unknown>, keyOrder?: string[]): string {
264
+ const hasSchema = '$schema' in obj;
265
+ const rest = (keyOrder ?? Object.keys(obj)).filter((k) => k !== '$schema' && k in obj);
266
+ const order = hasSchema ? ['$schema', ...rest] : rest;
267
+ if (order.length === 0) return '{}\n';
268
+ const lines = order.map((k) => {
269
+ const valueJson = JSON.stringify(obj[k], null, 2);
270
+ return ` ${JSON.stringify(k)}: ${valueJson.split('\n').join('\n ')}`;
271
+ });
272
+ return `{\n${lines.join(',\n')}\n}\n`;
273
+ }
274
+
275
+ /**
276
+ * Immutable set-at-path (W2 hardening, path-aware commits): spreads at every
277
+ * level the path touches, so untouched sibling keys — AND their original
278
+ * declaration order — survive unchanged (D1's diff-minimal contract; only
279
+ * the leaf's value actually changes in the serialized JSON). A path
280
+ * segment whose current value isn't an object (e.g. the field didn't exist
281
+ * yet, or a schema/data mismatch) starts that level fresh from `{}` rather
282
+ * than throwing — the write always succeeds, matching the panel's other
283
+ * commit paths (`setCell` has the same defensive fallback).
284
+ */
285
+ function setDeep(
286
+ obj: Record<string, unknown>,
287
+ path: string[],
288
+ value: unknown,
289
+ ): Record<string, unknown> {
290
+ const [head, ...rest] = path;
291
+ if (head === undefined) return obj;
292
+ if (rest.length === 0) return { ...obj, [head]: value };
293
+ const child = obj[head];
294
+ const childObj =
295
+ child && typeof child === 'object' && !Array.isArray(child)
296
+ ? (child as Record<string, unknown>)
297
+ : {};
298
+ return { ...obj, [head]: setDeep(childObj, rest, value) };
299
+ }
300
+
301
+ export function DataPanel({ active }: { active: boolean }) {
302
+ const [files, setFiles] = useState<LoadedDataFile[]>([]);
303
+ const [selectedPath, setSelectedPath] = useState<string | null>(null);
304
+
305
+ const refresh = useCallback(async () => {
306
+ try {
307
+ const res = await fetch('/__editor/data-files');
308
+ if (!res.ok) return;
309
+ const { files: raw } = (await res.json()) as { files: ServerDataFile[] };
310
+ setFiles(raw.map(parseServerFile));
311
+ } catch {
312
+ /* server unreachable — keep whatever we last had */
313
+ }
314
+ }, []);
315
+
316
+ // Re-scan whenever the tab is shown (D3: discovery IS the folder scan —
317
+ // never a cached manifest, so activating the tab always reflects disk).
318
+ useEffect(() => {
319
+ if (active) void refresh();
320
+ }, [active, refresh]);
321
+
322
+ // W6a: a NEW `src/data/*.data.json`/`*.schema.json` (or a deleted one)
323
+ // appears/disappears in the master list without switching tabs — the
324
+ // server's `src/`-scoped watcher broadcasts add/unlink only (a `change`
325
+ // event is deliberately excluded: it would echo this panel's own
326
+ // `/__editor/data-file` POST writes back into a refetch, clobbering
327
+ // in-flight drafts). `refresh` is the same rescan the tab-activation
328
+ // effect above already runs.
329
+ useEffect(() => connectDataFileEvents(() => void refresh()), [refresh]);
330
+
331
+ const selected = files.find((f) => f.path === selectedPath) ?? files[0] ?? null;
332
+
333
+ // `'ref'` widget picker options (§2.2's designer payoff): every loaded
334
+ // file's own top-level keys, keyed by asset stem — recomputed only when
335
+ // `files` actually changes (a fresh fetch, or a local commit updating one
336
+ // file's `data` in place).
337
+ const refOptionsByStem = useMemo(() => {
338
+ const map = new Map<string, string[]>();
339
+ for (const f of files) {
340
+ if (!f.data) continue;
341
+ const stem = f.path.replace(/^src\/data\//, '').replace(/\.data\.json$/, '');
342
+ map.set(
343
+ stem,
344
+ Object.keys(f.data).filter((k) => k !== '$schema'),
345
+ );
346
+ }
347
+ return map;
348
+ }, [files]);
349
+
350
+ /**
351
+ * D1 (spec §2.5): commit → write the file IMMEDIATELY, no buffering. The
352
+ * local copy is updated in the same tick so widgets stay live; disk is the
353
+ * document. D5 resolution: this deliberately BYPASSES the play-mode overlay
354
+ * (scene-entity-only) — a running game receives the change through the
355
+ * shipped `.data.json` HMR path instead (see the `/__editor/data-file`
356
+ * route's doc comment in editor-server.ts).
357
+ *
358
+ * Takes the whole `LoadedDataFile` (not just its path, #6) so it can read
359
+ * that file's CURRENT `keyOrder` straight from the caller's own prop/state
360
+ * closure — no stale-closure risk from reaching into the panel's `files`
361
+ * state inside this `useCallback([])`. `nextKeyOrder` folds the commit's
362
+ * keys into that order once, and the result becomes this file's `keyOrder`
363
+ * going forward, so a whole session of edits keeps the file's original
364
+ * on-disk key order (integer-like keys included) stable throughout.
365
+ */
366
+ const writeFile = useCallback((file: LoadedDataFile, nextData: Record<string, unknown>) => {
367
+ const order = nextKeyOrder(file.keyOrder, nextData);
368
+ const content = serializeDataFile(nextData, order);
369
+ setFiles((prev) =>
370
+ prev.map((f) => (f.path === file.path ? { ...f, data: nextData, keyOrder: order } : f)),
371
+ );
372
+ void fetch('/__editor/data-file', {
373
+ method: 'POST',
374
+ headers: { 'Content-Type': 'application/json' },
375
+ body: JSON.stringify({ path: file.path, content }),
376
+ });
377
+ }, []);
378
+
379
+ if (files.length === 0) {
380
+ return <EmptyState />;
381
+ }
382
+
383
+ return (
384
+ <RefOptionsContext.Provider value={refOptionsByStem}>
385
+ <div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
386
+ {/* File list (master) */}
387
+ <div
388
+ style={{
389
+ width: 200,
390
+ flexShrink: 0,
391
+ borderRight: '1px solid #333',
392
+ overflowY: 'auto',
393
+ padding: '4px 0',
394
+ }}
395
+ >
396
+ {files.map((f) => {
397
+ const isSelected = f.path === selected?.path;
398
+ const name = f.path.replace(/^src\/data\//, '').replace(/\.data\.json$/, '');
399
+ return (
400
+ <button
401
+ key={f.path}
402
+ type="button"
403
+ data-testid={`data-file-${name}`}
404
+ onClick={() => setSelectedPath(f.path)}
405
+ title={f.path}
406
+ style={{
407
+ display: 'block',
408
+ width: '100%',
409
+ textAlign: 'left',
410
+ background: isSelected ? '#2c2c2c' : 'transparent',
411
+ color: isSelected ? '#c5c8ce' : '#8c8c8c',
412
+ border: 'none',
413
+ borderLeft: isSelected ? '2px solid #579EFF' : '2px solid transparent',
414
+ padding: '4px 10px',
415
+ cursor: 'pointer',
416
+ fontSize: 12,
417
+ }}
418
+ >
419
+ {name}
420
+ </button>
421
+ );
422
+ })}
423
+ </div>
424
+
425
+ {/* Editor (detail) */}
426
+ <div style={{ flex: 1, minWidth: 0, overflow: 'auto', padding: 10 }}>
427
+ {selected && <DataFileEditor file={selected} writeFile={writeFile} />}
428
+ </div>
429
+ </div>
430
+ </RefOptionsContext.Provider>
431
+ );
432
+ }
433
+
434
+ /**
435
+ * A minimal, paste-ready `.schema.ts` + `.data.json` pair (W6d) — mirrors the
436
+ * shape of the shipped `tuning.*` trio (template/src/data/README.md), just
437
+ * renamed/generic so it's obviously a starting point to rename, not the real
438
+ * thing to keep verbatim. No file-creating server verb this wave (deferred —
439
+ * today's write routes are purpose-scoped, `src/data/**\/*.data.json` only;
440
+ * a general project-file write API needs its own security ruling), so this
441
+ * is copy-paste, not a button that writes to disk.
442
+ */
443
+ const STARTER_FILES = `// src/data/example.schema.ts
444
+ import { z } from 'zod';
445
+
446
+ export const ExampleSchema = z.object({
447
+ speed: z.number().min(0).max(10).default(1).describe('Example tunable — rename me'),
448
+ label: z.string().default('hello').describe('Example string field'),
449
+ });
450
+
451
+ export type Example = z.infer<typeof ExampleSchema>;
452
+
453
+ // src/data/example.data.json
454
+ {
455
+ "$schema": "./example.schema.json",
456
+ "speed": 1,
457
+ "label": "hello"
458
+ }
459
+ `;
460
+
461
+ /**
462
+ * DATA tab empty state (W6d, docs/DATA-TOOLS-DESIGN.md §3.5): the project has
463
+ * no `src/data/*.data.json` yet. Teaches the concrete recipe rather than just
464
+ * naming the shape, plus a one-click "Copy starter files" so the first asset
465
+ * doesn't require memorizing the trio from scratch. Once the files exist +
466
+ * `npm run emit-schemas` has run, the new asset appears here automatically —
467
+ * no reload, W6a's src-watcher SSE channel picks it up.
468
+ */
469
+ function EmptyState() {
470
+ const [copied, setCopied] = useState(false);
471
+
472
+ const copyStarter = () => {
473
+ void navigator.clipboard.writeText(STARTER_FILES).then(() => {
474
+ setCopied(true);
475
+ setTimeout(() => setCopied(false), 1500);
476
+ });
477
+ };
478
+
479
+ return (
480
+ <div style={{ padding: 12, fontSize: 12, color: '#8c8c8c', maxWidth: 480 }}>
481
+ <p style={{ margin: '0 0 8px' }}>
482
+ No data assets yet — a data asset is a <code>src/data/*.data.json</code> file governed by a
483
+ Zod schema (docs/DATA-TOOLS-DESIGN.md §2).
484
+ </p>
485
+ <ol style={{ margin: '0 0 10px', paddingLeft: 18 }}>
486
+ <li>
487
+ Create <code>src/data/&lt;name&gt;.schema.ts</code> — a Zod object (or{' '}
488
+ <code>z.record(...)</code> for a table) exporting the schema.
489
+ </li>
490
+ <li>
491
+ Create <code>src/data/&lt;name&gt;.data.json</code> with a <code>"$schema"</code> line
492
+ pointing at the emitted schema, plus starting values.
493
+ </li>
494
+ <li>
495
+ Run <code>npm run emit-schemas</code> in the project — it writes the emitted{' '}
496
+ <code>.schema.json</code> and validates the data file against it.
497
+ </li>
498
+ </ol>
499
+ <p style={{ margin: '0 0 8px' }}>
500
+ See the project's <code>src/data/README.md</code> for the full trio doctrine and the shipped{' '}
501
+ <code>tuning.*</code> worked example.
502
+ </p>
503
+ <button
504
+ type="button"
505
+ data-testid="data-empty-copy-starter"
506
+ onClick={copyStarter}
507
+ style={{
508
+ background: '#2c2c2c',
509
+ color: '#c5c8ce',
510
+ border: '1px solid #444',
511
+ borderRadius: 2,
512
+ padding: '4px 10px',
513
+ fontSize: 12,
514
+ cursor: 'pointer',
515
+ }}
516
+ >
517
+ {copied ? 'Copied' : 'Copy starter files'}
518
+ </button>
519
+ </div>
520
+ );
521
+ }
522
+
523
+ function DataFileEditor({
524
+ file,
525
+ writeFile,
526
+ }: {
527
+ file: LoadedDataFile;
528
+ writeFile: (file: LoadedDataFile, nextData: Record<string, unknown>) => void;
529
+ }) {
530
+ if (file.error) {
531
+ return <Message text={`${file.path}: ${file.error}`} tone="error" />;
532
+ }
533
+ if (!file.hasSchema) {
534
+ // Teaching message, house style: name the fix (D4 — emitted schemas are
535
+ // the artifact the editor consumes; without one there are no widget types).
536
+ return (
537
+ <Message
538
+ text={`${file.path} has no emitted schema (${file.path.replace(/\.data\.json$/, '.schema.json')}). Run \`npm run emit-schemas\` in the project to generate it.`}
539
+ tone="warn"
540
+ />
541
+ );
542
+ }
543
+ if (!file.shape || !file.data) {
544
+ return (
545
+ <Message
546
+ text={`${file.path}: emitted schema is not one of the two blessed shapes (singleton object or z.record table — spec §2.1).`}
547
+ tone="warn"
548
+ />
549
+ );
550
+ }
551
+
552
+ const data = file.data;
553
+
554
+ if (file.shape.shape === 'singleton') {
555
+ // Path-aware write (W2 hardening): a nested `group` field commits through
556
+ // the SAME immutable `setDeep`, so an edit three levels down (e.g.
557
+ // `enemyKinds.scout.hp`) only ever replaces the leaf — every other level
558
+ // is spread, preserving key order (D1's diff-minimal contract). This is
559
+ // what keeps a nested singleton's serialized shape an OBJECT at every
560
+ // level instead of ever collapsing a branch to a JSON string.
561
+ const onCommitAt = (path: string[], value: unknown) => {
562
+ writeFile(file, setDeep(data, path, value));
563
+ };
564
+ return (
565
+ <div style={{ maxWidth: 480 }}>
566
+ <FieldList fields={file.shape.fields} value={data} path={[]} onCommitAt={onCommitAt} />
567
+ </div>
568
+ );
569
+ }
570
+
571
+ // Table: one grid row per data key ("$schema" is plumbing, never a row),
572
+ // one column per row-schema field, plus row add/remove (W6b) — a separate
573
+ // component because add-row needs its own draft-key state (hooks can't
574
+ // live in this early-return-heavy function; see `TableEditor`'s comment).
575
+ return (
576
+ <TableEditor file={file} data={data} rowFields={file.shape.rowFields} writeFile={writeFile} />
577
+ );
578
+ }
579
+
580
+ function Message({ text, tone }: { text: string; tone: 'error' | 'warn' }) {
581
+ return (
582
+ <div style={{ fontSize: 12, color: tone === 'error' ? '#FF6B6B' : '#FFD866', maxWidth: 560 }}>
583
+ {text}
584
+ </div>
585
+ );
586
+ }
587
+
588
+ /**
589
+ * Table-shape data-file editor (W6b, docs/DATA-TOOLS-DESIGN.md §7): the grid
590
+ * itself (one row per data key, one column per row-schema field — unchanged
591
+ * from W2) plus row add/remove. Pulled out of `DataFileEditor` because it's
592
+ * the only shape needing its own hook state (the add-row key draft + inline
593
+ * error) — `DataFileEditor` stays a plain function with early-return teaching
594
+ * branches (no conditional hooks).
595
+ */
596
+ function TableEditor({
597
+ file,
598
+ data,
599
+ rowFields,
600
+ writeFile,
601
+ }: {
602
+ file: LoadedDataFile;
603
+ data: Record<string, unknown>;
604
+ rowFields: DataFieldDescriptor[];
605
+ writeFile: (file: LoadedDataFile, nextData: Record<string, unknown>) => void;
606
+ }) {
607
+ const rowKeys = Object.keys(data).filter((k) => k !== '$schema');
608
+ // Any successful edit through the grid/add-row clears a stale CSV import
609
+ // error block (W6c) — it describes the LAST import attempt, not the
610
+ // current state of the file.
611
+ const [importErrors, setImportErrors] = useState<string[]>([]);
612
+
613
+ const setCell = (rowKey: string, fieldName: string, value: unknown) => {
614
+ const row = data[rowKey];
615
+ const rowObj = row && typeof row === 'object' ? (row as Record<string, unknown>) : {};
616
+ writeFile(file, { ...data, [rowKey]: { ...rowObj, [fieldName]: value } });
617
+ if (importErrors.length > 0) setImportErrors([]);
618
+ };
619
+
620
+ // Remove: no confirm dialog (D1's "undo is git" stance) — `$schema`
621
+ // survives in `rest` (re-ordered first by `serializeDataFile`), and the
622
+ // spread preserves every other row's key order.
623
+ const removeRow = (rowKey: string) => {
624
+ const { [rowKey]: _removed, ...rest } = data;
625
+ writeFile(file, rest);
626
+ if (importErrors.length > 0) setImportErrors([]);
627
+ };
628
+
629
+ const [newKey, setNewKey] = useState('');
630
+ const [addError, setAddError] = useState<string | null>(null);
631
+
632
+ const addRow = () => {
633
+ const trimmed = newKey.trim();
634
+ if (!trimmed) {
635
+ setAddError('Row key cannot be empty.');
636
+ return;
637
+ }
638
+ if (trimmed === '$schema') {
639
+ setAddError('"$schema" is reserved and cannot be a row key.');
640
+ return;
641
+ }
642
+ if (trimmed in data) {
643
+ setAddError(`Row "${trimmed}" already exists.`);
644
+ return;
645
+ }
646
+ const row = Object.fromEntries(rowFields.map((f) => [f.name, defaultValueForDescriptor(f)]));
647
+ writeFile(file, { ...data, [trimmed]: row }); // appends — key order preserved
648
+ setNewKey('');
649
+ setAddError(null);
650
+ if (importErrors.length > 0) setImportErrors([]);
651
+ };
652
+
653
+ // ---- CSV round-trip (W6c) ----
654
+ const fileInputRef = useRef<HTMLInputElement>(null);
655
+
656
+ const exportCsv = () => {
657
+ const csv = tableToCsv(data, rowFields);
658
+ const blob = new Blob([csv], { type: 'text/csv' });
659
+ const url = URL.createObjectURL(blob);
660
+ const baseName = file.path.replace(/^src\/data\//, '').replace(/\.data\.json$/, '');
661
+ const a = document.createElement('a');
662
+ a.href = url;
663
+ a.download = `${baseName}.csv`;
664
+ a.click();
665
+ URL.revokeObjectURL(url);
666
+ };
667
+
668
+ const importCsv = async (fileList: FileList | null) => {
669
+ const picked = fileList?.[0];
670
+ if (fileInputRef.current) fileInputRef.current.value = ''; // allow re-picking the same file
671
+ if (!picked) return;
672
+ const text = await picked.text();
673
+ const { rows, errors } = csvToTable(text, rowFields);
674
+ if (errors.length > 0) {
675
+ setImportErrors(errors);
676
+ return; // atomic — nothing written
677
+ }
678
+ setImportErrors([]);
679
+ // Whole-table replace: a CSV IS the table. `$schema` is plumbing, carried
680
+ // over from the current file (never itself a CSV column).
681
+ const next: Record<string, unknown> = '$schema' in data ? { $schema: data['$schema'] } : {};
682
+ writeFile(file, { ...next, ...rows });
683
+ };
684
+
685
+ return (
686
+ <div>
687
+ <div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
688
+ <button
689
+ type="button"
690
+ data-testid="data-csv-export"
691
+ onClick={exportCsv}
692
+ title="Download this table as a CSV file"
693
+ style={{
694
+ background: '#2c2c2c',
695
+ color: '#c5c8ce',
696
+ border: '1px solid #444',
697
+ borderRadius: 2,
698
+ padding: '3px 10px',
699
+ fontSize: 12,
700
+ cursor: 'pointer',
701
+ }}
702
+ >
703
+ Export CSV
704
+ </button>
705
+ <button
706
+ type="button"
707
+ data-testid="data-csv-import"
708
+ onClick={() => fileInputRef.current?.click()}
709
+ title="Import replaces every row in this file (undo is git)."
710
+ style={{
711
+ background: '#2c2c2c',
712
+ color: '#c5c8ce',
713
+ border: '1px solid #444',
714
+ borderRadius: 2,
715
+ padding: '3px 10px',
716
+ fontSize: 12,
717
+ cursor: 'pointer',
718
+ }}
719
+ >
720
+ Import CSV
721
+ </button>
722
+ <input
723
+ ref={fileInputRef}
724
+ type="file"
725
+ accept=".csv,text/csv"
726
+ style={{ display: 'none' }}
727
+ onChange={(e) => void importCsv(e.target.files)}
728
+ />
729
+ </div>
730
+ {importErrors.length > 0 && (
731
+ <div data-testid="data-csv-errors" style={{ marginBottom: 8 }}>
732
+ {importErrors.map((err) => (
733
+ <Message key={err} text={err} tone="error" />
734
+ ))}
735
+ </div>
736
+ )}
737
+ <div
738
+ style={{
739
+ display: 'grid',
740
+ gridTemplateColumns: `110px repeat(${rowFields.length}, minmax(100px, 200px)) 24px`,
741
+ gap: '3px 6px',
742
+ alignItems: 'center',
743
+ width: 'max-content',
744
+ }}
745
+ >
746
+ {/* Header row */}
747
+ <span style={{ ...labelStyle, width: 'auto' }} />
748
+ {rowFields.map((f) => {
749
+ const head = (
750
+ <span key={f.name} style={{ ...labelStyle, width: 'auto', fontWeight: 600 }}>
751
+ {f.name}
752
+ </span>
753
+ );
754
+ return f.description ? (
755
+ <Tooltip key={f.name} text={f.description} position="bottom">
756
+ {head}
757
+ </Tooltip>
758
+ ) : (
759
+ head
760
+ );
761
+ })}
762
+ <span />
763
+
764
+ {rowKeys.map((rowKey) => {
765
+ const row = data[rowKey];
766
+ const rowObj = row && typeof row === 'object' ? (row as Record<string, unknown>) : {};
767
+ return (
768
+ <Fragment key={rowKey}>
769
+ <span style={{ ...labelStyle, width: 'auto', color: '#b0b0b0', fontWeight: 600 }}>
770
+ {rowKey}
771
+ </span>
772
+ {rowFields.map((f) => (
773
+ <Widget
774
+ key={f.name}
775
+ field={f}
776
+ value={f.name in rowObj ? rowObj[f.name] : f.default}
777
+ onCommit={(v) => setCell(rowKey, f.name, v)}
778
+ testId={`data-cell-${rowKey}-${f.name}`}
779
+ />
780
+ ))}
781
+ <button
782
+ type="button"
783
+ data-testid={`data-row-remove-${rowKey}`}
784
+ title="Remove row (undo is git)"
785
+ onClick={() => removeRow(rowKey)}
786
+ style={{
787
+ background: 'transparent',
788
+ border: 'none',
789
+ color: '#8c8c8c',
790
+ cursor: 'pointer',
791
+ fontSize: 13,
792
+ lineHeight: 1,
793
+ padding: '2px 4px',
794
+ }}
795
+ >
796
+ ×
797
+ </button>
798
+ </Fragment>
799
+ );
800
+ })}
801
+ </div>
802
+
803
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 8 }}>
804
+ <input
805
+ data-testid="data-row-add-key"
806
+ placeholder="new-row-key"
807
+ value={newKey}
808
+ onChange={(e) => {
809
+ setNewKey(e.target.value);
810
+ if (addError) setAddError(null);
811
+ }}
812
+ onKeyDown={(e) => {
813
+ if (e.key === 'Enter') addRow();
814
+ }}
815
+ style={{ ...inputStyle, width: 160 }}
816
+ />
817
+ <button
818
+ type="button"
819
+ data-testid="data-row-add"
820
+ onClick={addRow}
821
+ style={{
822
+ background: '#2c2c2c',
823
+ color: '#c5c8ce',
824
+ border: '1px solid #444',
825
+ borderRadius: 2,
826
+ padding: '3px 10px',
827
+ fontSize: 12,
828
+ cursor: 'pointer',
829
+ }}
830
+ >
831
+ Add row
832
+ </button>
833
+ </div>
834
+ {addError && (
835
+ <div style={{ marginTop: 4 }}>
836
+ <Message text={addError} tone="error" />
837
+ </div>
838
+ )}
839
+ </div>
840
+ );
841
+ }
842
+
843
+ function FieldRow({
844
+ name,
845
+ description,
846
+ children,
847
+ }: {
848
+ name: string;
849
+ description?: string | undefined;
850
+ children: React.ReactNode;
851
+ }) {
852
+ const label = <span style={labelStyle}>{name}</span>;
853
+ return (
854
+ <div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 3 }}>
855
+ {description ? (
856
+ <Tooltip text={description} position="right">
857
+ {label}
858
+ </Tooltip>
859
+ ) : (
860
+ label
861
+ )}
862
+ <div style={{ flex: 1, minWidth: 0 }}>{children}</div>
863
+ </div>
864
+ );
865
+ }
866
+
867
+ /**
868
+ * Recursive singleton-form renderer (W2 hardening §3.2): a `'group'`
869
+ * descriptor renders as an indented, bordered sub-form (a nested `FieldList`
870
+ * over its `children`) instead of falling back to a raw-JSON cell; every
871
+ * other kind renders the existing `Widget`. `path` accumulates the field
872
+ * names from the form root down to this row so `onCommitAt` can write
873
+ * exactly the touched leaf via `setDeep` — nothing above or beside it in the
874
+ * nested object is ever re-typed as a string.
875
+ */
876
+ function FieldList({
877
+ fields,
878
+ value,
879
+ path,
880
+ onCommitAt,
881
+ }: {
882
+ fields: DataFieldDescriptor[];
883
+ value: Record<string, unknown>;
884
+ path: string[];
885
+ onCommitAt: (path: string[], value: unknown) => void;
886
+ }) {
887
+ return (
888
+ <>
889
+ {fields.map((f) => {
890
+ const fieldPath = [...path, f.name];
891
+ const raw = f.name in value ? value[f.name] : f.default;
892
+ const testId = `data-field-${fieldPath.join('.')}`;
893
+
894
+ if (f.kind === 'group') {
895
+ const childValue =
896
+ raw && typeof raw === 'object' && !Array.isArray(raw)
897
+ ? (raw as Record<string, unknown>)
898
+ : {};
899
+ return (
900
+ <div
901
+ key={f.name}
902
+ data-testid={testId}
903
+ style={{
904
+ border: '1px solid #333',
905
+ borderRadius: 3,
906
+ padding: '6px 8px',
907
+ marginBottom: 6,
908
+ marginLeft: Math.min(path.length, 3) * 10,
909
+ }}
910
+ >
911
+ <GroupHeader name={f.name} description={f.description} />
912
+ <FieldList
913
+ fields={f.children ?? []}
914
+ value={childValue}
915
+ path={fieldPath}
916
+ onCommitAt={onCommitAt}
917
+ />
918
+ </div>
919
+ );
920
+ }
921
+
922
+ return (
923
+ <FieldRow key={f.name} name={f.name} description={f.description}>
924
+ <Widget
925
+ field={f}
926
+ value={raw}
927
+ onCommit={(v) => onCommitAt(fieldPath, v)}
928
+ testId={testId}
929
+ />
930
+ </FieldRow>
931
+ );
932
+ })}
933
+ </>
934
+ );
935
+ }
936
+
937
+ function GroupHeader({ name, description }: { name: string; description?: string | undefined }) {
938
+ const label = (
939
+ <div style={{ fontSize: 11, color: '#a0a0a0', fontWeight: 600, marginBottom: 4 }}>{name}</div>
940
+ );
941
+ return description ? (
942
+ <Tooltip text={description} position="right">
943
+ {label}
944
+ </Tooltip>
945
+ ) : (
946
+ label
947
+ );
948
+ }
949
+
950
+ /**
951
+ * One typed cell/field widget — the SAME primitives the component inspector
952
+ * renders `FieldDescriptor`s with (`ComponentsSection.renderWidget`), so the
953
+ * Data panel looks native. Number edits clamp to the schema's
954
+ * `minimum`/`maximum` (the JSON-Schema extra `DataFieldDescriptor` carries).
955
+ */
956
+ function Widget({
957
+ field,
958
+ value,
959
+ onCommit,
960
+ testId,
961
+ }: {
962
+ field: DataFieldDescriptor;
963
+ value: unknown;
964
+ onCommit: (value: unknown) => void;
965
+ testId: string;
966
+ }) {
967
+ // Read unconditionally (hook rules) even though only the `'ref'` case below
968
+ // uses it — cheap, and `RefOptionsContext`'s default (empty map) makes a
969
+ // ref field outside the panel's own provider degrade to the "target not
970
+ // loaded" fallback rather than crash.
971
+ const refOptions = useContext(RefOptionsContext);
972
+
973
+ switch (field.kind) {
974
+ case 'number':
975
+ return <NumberCell field={field} value={value} onCommit={onCommit} testId={testId} />;
976
+ case 'boolean':
977
+ return (
978
+ <input
979
+ type="checkbox"
980
+ data-testid={testId}
981
+ checked={Boolean(value)}
982
+ onChange={(e) => onCommit(e.target.checked)}
983
+ />
984
+ );
985
+ case 'color':
986
+ return (
987
+ <div data-testid={testId}>
988
+ <ColorInput value={typeof value === 'string' ? value : '#ffffff'} onChange={onCommit} />
989
+ </div>
990
+ );
991
+ case 'enum':
992
+ return (
993
+ <select
994
+ data-testid={testId}
995
+ value={value == null ? '' : String(value)}
996
+ onChange={(e) => onCommit(e.target.value)}
997
+ style={{ ...inputStyle, cursor: 'pointer' }}
998
+ >
999
+ {(field.options ?? []).map((opt) => (
1000
+ <option key={opt} value={opt}>
1001
+ {opt}
1002
+ </option>
1003
+ ))}
1004
+ </select>
1005
+ );
1006
+ case 'string':
1007
+ return <StringCell value={value} onCommit={onCommit} testId={testId} />;
1008
+ case 'ref': {
1009
+ // The designer payoff (§2.2): a declared `dataRef(target)` field
1010
+ // renders as a `stem#key` PICKER, sourced from the target file's own
1011
+ // top-level keys (already loaded — every data file's raw text is
1012
+ // fetched by this same panel, see `RefOptionsContext` above), instead
1013
+ // of free text. Falls back to `StringCell` when the target file isn't
1014
+ // loaded (renamed/deleted/never existed — `vgai doctor`'s
1015
+ // `data-ref-missing-file` is the enforcement side of that) or the
1016
+ // current value carries a deeper `key.field` path (v1 scope: deep ref
1017
+ // addresses stay free-text, per the design note this module's history
1018
+ // carries).
1019
+ const targetStem = field.refTarget;
1020
+ const targetKeys = targetStem !== undefined ? refOptions.get(targetStem) : undefined;
1021
+ const current = typeof value === 'string' ? value : '';
1022
+ const hasDeepPath = current.includes('#') && (current.split('#')[1]?.includes('.') ?? false);
1023
+ if (targetStem === undefined || targetKeys === undefined || hasDeepPath) {
1024
+ return <StringCell value={value} onCommit={onCommit} testId={testId} />;
1025
+ }
1026
+ const options = targetKeys.map((k) => `${targetStem}#${k}`);
1027
+ const isDangling = current !== '' && !options.includes(current);
1028
+ return (
1029
+ <select
1030
+ data-testid={testId}
1031
+ value={current}
1032
+ onChange={(e) => onCommit(e.target.value)}
1033
+ style={{ ...inputStyle, cursor: 'pointer' }}
1034
+ >
1035
+ <option value="">—</option>
1036
+ {options.map((opt) => (
1037
+ <option key={opt} value={opt}>
1038
+ {opt}
1039
+ </option>
1040
+ ))}
1041
+ {isDangling && <option value={current}>{current} (dangling)</option>}
1042
+ </select>
1043
+ );
1044
+ }
1045
+ default:
1046
+ // 'group' (only reachable here from a TABLE cell — nested sub-forms
1047
+ // inside a grid cell are deferred, W6) and 'json' (arrays, unions,
1048
+ // anything past the group depth cap) both fall back to the
1049
+ // type-preserving `JsonCell`. This is the correctness floor (W2
1050
+ // hardening): `StringCell` commits raw text, which is only ever safe
1051
+ // for an actual `type: 'string'` field — every other degraded case
1052
+ // MUST go through a parse-or-refuse cell, never a bare string commit.
1053
+ return <JsonCell value={value} onCommit={onCommit} testId={testId} />;
1054
+ }
1055
+ }
1056
+
1057
+ /**
1058
+ * Number field/cell: buffers a drag scrub into ONE commit at release, instead
1059
+ * of one per pointermove (#3). `NumberInput` still calls `onChange` on every
1060
+ * intermediate move (so the widget's OWN displayed number stays live-scrub
1061
+ * responsive) — but here that per-move callback only updates `live`, local
1062
+ * state that overrides the authoritative `value` prop for display purposes.
1063
+ * The actual `onCommit` (a `writeFile` → POST, non-atomic disk truncate-write
1064
+ * per D1) fires from `onChangeEnd` instead, which `NumberInput` invokes
1065
+ * exactly once per gesture: on pointerup after a real drag, or once for a
1066
+ * typed blur/Enter edit (already single-shot, unaffected either way).
1067
+ */
1068
+ function NumberCell({
1069
+ field,
1070
+ value,
1071
+ onCommit,
1072
+ testId,
1073
+ }: {
1074
+ field: DataFieldDescriptor;
1075
+ value: unknown;
1076
+ onCommit: (value: unknown) => void;
1077
+ testId: string;
1078
+ }) {
1079
+ const clamp = (v: number) => {
1080
+ let out = v;
1081
+ if (field.min !== undefined) out = Math.max(field.min, out);
1082
+ if (field.max !== undefined) out = Math.min(field.max, out);
1083
+ return out;
1084
+ };
1085
+ const authoritative = typeof value === 'number' ? value : Number(value) || 0;
1086
+ const [live, setLive] = useState<number | null>(null);
1087
+
1088
+ // Once the authoritative (committed/file) value changes underneath us —
1089
+ // our own commit landing, another widget, a reload — drop the live
1090
+ // override so we go back to reflecting real state.
1091
+ useEffect(() => {
1092
+ setLive(null);
1093
+ }, [authoritative]);
1094
+
1095
+ return (
1096
+ <NumberInput
1097
+ value={live ?? authoritative}
1098
+ onChange={(v) => setLive(clamp(v))}
1099
+ onChangeEnd={(v) => {
1100
+ setLive(null);
1101
+ onCommit(clamp(v));
1102
+ }}
1103
+ testId={testId}
1104
+ />
1105
+ );
1106
+ }
1107
+
1108
+ /**
1109
+ * Draft-buffered text cell: commits on blur/Enter, reverts on Escape — unlike
1110
+ * the inspector's keystroke-live `RawInput`, because HERE every commit is a
1111
+ * FILE write (D1) and per-keystroke writes would spam disk + HMR.
1112
+ *
1113
+ * `kind: 'string'` ONLY (W2 hardening) — a real string field can never be
1114
+ * widened by committing raw text, because the field already IS a string.
1115
+ * Non-string fallbacks render `JsonCell` instead (see `Widget`'s `default`).
1116
+ */
1117
+ function StringCell({
1118
+ value,
1119
+ onCommit,
1120
+ testId,
1121
+ }: {
1122
+ value: unknown;
1123
+ onCommit: (value: unknown) => void;
1124
+ testId: string;
1125
+ }) {
1126
+ const display = value === null || value === undefined ? '' : String(value);
1127
+ const [draft, setDraft] = useState(display);
1128
+ // Escape below calls `.blur()` imperatively, which fires the native
1129
+ // blur/focusout event SYNCHRONOUSLY inside the same handler — so the
1130
+ // resulting `onBlur={commit}` closes over the PRE-revert `draft` (React
1131
+ // hasn't re-rendered yet) and would re-commit the just-discarded edit. Same
1132
+ // fix as `primitives/JsonInput.tsx`'s `revertingRef`: Escape sets it before
1133
+ // blurring, `commit()` checks it first and no-ops instead of racing it.
1134
+ const revertingRef = useRef(false);
1135
+
1136
+ // Re-sync when the underlying value changes (file re-list, another widget).
1137
+ useEffect(() => {
1138
+ setDraft(display);
1139
+ }, [display]);
1140
+
1141
+ const commit = () => {
1142
+ if (revertingRef.current) {
1143
+ revertingRef.current = false;
1144
+ return;
1145
+ }
1146
+ if (draft !== display) onCommit(draft);
1147
+ };
1148
+
1149
+ return (
1150
+ <input
1151
+ data-testid={testId}
1152
+ value={draft}
1153
+ onChange={(e) => setDraft(e.target.value)}
1154
+ onBlur={commit}
1155
+ onKeyDown={(e) => {
1156
+ if (e.key === 'Enter') {
1157
+ commit();
1158
+ (e.target as HTMLInputElement).blur();
1159
+ } else if (e.key === 'Escape') {
1160
+ revertingRef.current = true;
1161
+ setDraft(display);
1162
+ (e.target as HTMLInputElement).blur();
1163
+ }
1164
+ }}
1165
+ style={inputStyle}
1166
+ />
1167
+ );
1168
+ }
1169
+
1170
+ /**
1171
+ * Type-preserving fallback cell (W2 hardening — the correctness floor, spec
1172
+ * §2.1). Used for anything the panel can't render a typed widget for: a
1173
+ * nested `group` past the depth cap, arrays, unions — and (until §2.1's
1174
+ * variant picker ships) discriminated unions.
1175
+ *
1176
+ * The invariant this cell exists to hold: **a commit must never widen a
1177
+ * typed value into a JSON string.** `commit()` runs `JSON.parse(draft)` and
1178
+ * hands `onCommit` the PARSED value, never the raw text. On parse failure it
1179
+ * REFUSES the commit outright — the draft stays exactly as typed, the input
1180
+ * shows an error state, and no write/POST fires — so a mid-edit invalid JSON
1181
+ * string can never reach disk (which is exactly how the live-observed
1182
+ * corruption happened before this cell existed: a raw string committed
1183
+ * straight over a typed object field).
1184
+ */
1185
+ function JsonCell({
1186
+ value,
1187
+ onCommit,
1188
+ testId,
1189
+ }: {
1190
+ value: unknown;
1191
+ onCommit: (value: unknown) => void;
1192
+ testId: string;
1193
+ }) {
1194
+ const display = value === undefined ? '' : JSON.stringify(value);
1195
+ const [draft, setDraft] = useState(display);
1196
+ const [invalid, setInvalid] = useState(false);
1197
+ // Same race as `primitives/JsonInput.tsx`'s `revertingRef`: Escape's
1198
+ // imperative `.blur()` fires `onBlur={commit}` synchronously, before React
1199
+ // re-renders with the reverted draft — so `commit`'s closure still sees the
1200
+ // pre-revert (possibly invalid, always discarded) draft and would commit or
1201
+ // re-flag it right after Escape cleared it. This ref lets Escape tell the
1202
+ // nested commit to no-op instead of racing it.
1203
+ const revertingRef = useRef(false);
1204
+
1205
+ // Re-sync when the underlying value changes (file re-list, another widget).
1206
+ useEffect(() => {
1207
+ setDraft(display);
1208
+ setInvalid(false);
1209
+ }, [display]);
1210
+
1211
+ const commit = () => {
1212
+ if (revertingRef.current) {
1213
+ revertingRef.current = false;
1214
+ return;
1215
+ }
1216
+ if (draft === display) {
1217
+ setInvalid(false);
1218
+ return;
1219
+ }
1220
+ let parsed: unknown;
1221
+ try {
1222
+ parsed = JSON.parse(draft);
1223
+ } catch {
1224
+ setInvalid(true); // refuse: keep the draft, no onCommit, no write
1225
+ return;
1226
+ }
1227
+ setInvalid(false);
1228
+ onCommit(parsed);
1229
+ };
1230
+
1231
+ return (
1232
+ <div>
1233
+ <input
1234
+ data-testid={testId}
1235
+ value={draft}
1236
+ onChange={(e) => {
1237
+ setDraft(e.target.value);
1238
+ if (invalid) setInvalid(false);
1239
+ }}
1240
+ onBlur={commit}
1241
+ onKeyDown={(e) => {
1242
+ if (e.key === 'Enter') {
1243
+ commit();
1244
+ } else if (e.key === 'Escape') {
1245
+ revertingRef.current = true;
1246
+ setDraft(display);
1247
+ setInvalid(false);
1248
+ (e.target as HTMLInputElement).blur();
1249
+ }
1250
+ }}
1251
+ style={invalid ? { ...inputStyle, borderColor: '#FF6B6B', color: '#FF6B6B' } : inputStyle}
1252
+ title={
1253
+ invalid
1254
+ ? 'Not valid JSON — nothing written. Fix the JSON or press Escape to revert.'
1255
+ : undefined
1256
+ }
1257
+ />
1258
+ {invalid && (
1259
+ <div
1260
+ data-testid={`${testId}-error`}
1261
+ style={{ fontSize: 10, color: '#FF6B6B', marginTop: 2 }}
1262
+ >
1263
+ Not valid JSON — nothing written. Fix the JSON or press Escape to revert.
1264
+ </div>
1265
+ )}
1266
+ </div>
1267
+ );
1268
+ }