@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,2003 @@
1
+ /**
2
+ * WorldSelectionOverlay — spec 27 §3 A3
3
+ * (docs/unified-world-editor/27-visual-react-editing.md), MIGRATE target of
4
+ * `ui-editor/overlay.tsx`'s `SelectionOverlay` (§9's consolidation table),
5
+ * generalized off that file's editor-store-and-stage-ref coupling onto the
6
+ * format-neutral authoring contract so it works over ANY DOM-capable world
7
+ * (react `data-oid`, ingested `react-dom`), not just the scene-UI silo.
8
+ *
9
+ * MOUNT SITE / COORDINATES (critical — see this file's `ViewportPanel.tsx`
10
+ * call site): this component is rendered as a CHILD of `ViewportPanel`'s own
11
+ * `containerRef` div — the EXACT SAME DOM element `design-time-layers.ts`'s
12
+ * `mountDesignTimeLayers(container, …)` appends each react/pixi world's own
13
+ * `position:absolute; inset:0` layer div into (`createLayerElement`,
14
+ * `container.appendChild(layer)`). This component's own outer wrapper is
15
+ * ALSO `position:absolute; inset:0` inside that SAME parent, so its
16
+ * `getBoundingClientRect()` is IDENTICAL to every world layer's — a
17
+ * HOST-RELATIVE rect from `RectProvider.rect(id)` (host = the owning
18
+ * adapter's own `this.root`, which IS one of those layer divs) lands on this
19
+ * overlay's local coordinate space with ZERO correction. Deliberately NOT
20
+ * wired one level up in `DefaultEditorLayout.tsx` (a sibling of
21
+ * `<ViewportPanel/>` there sits in a DIFFERENT, merely same-SIZED parent —
22
+ * co-location would be incidental, not structural); mounting inside
23
+ * `ViewportPanel` makes the co-location a DOM fact, not a CSS coincidence.
24
+ *
25
+ * `PickProvider.pick` (unlike `RectProvider.rect`) takes CLIENT (viewport) px
26
+ * per its own contract doc comment — pointer handlers below pass
27
+ * `e.clientX`/`e.clientY` straight through, no conversion.
28
+ *
29
+ * Driven ONLY by the authoring contract (`getActiveAuthoring(store)` +
30
+ * `store.selectedEntityIds` + `pickTopmost` + `CompositeAuthoringAdapter
31
+ * .childAdapters()/ownerOf()`) — rule zero (spec §0): this file talks to no
32
+ * concrete document/rendering library and no other editor silo's store or
33
+ * DOM ref directly; hit-testing goes through the contract's own pick
34
+ * provider, never a raw point-under-cursor DOM query.
35
+ *
36
+ * Scope: A3's hover highlight, selection box + `name·kind` label, and marquee
37
+ * are unchanged below. Phase B (`docs/unified-world-editor/27-visual-react-
38
+ * editing.md` §4 B2/B3) adds, in this same file: 8 resize handles + a rotate
39
+ * handle + a dimension label (B2), drag-to-move for absolutely/fixed-
40
+ * positioned nodes (B2), and padding/margin spacing bands (B3) — all gated on
41
+ * EXACTLY one node selected whose OWNER exposes `boxEdit` (see
42
+ * `world-overlay-gestures.ts`'s `boxEditForId`; multi-select keeps only the
43
+ * plain A3 boxes, no handles). The gesture PATCH MATH itself lives in the
44
+ * sibling `world-overlay-gestures.ts` module (pure, unit-tested); this file
45
+ * owns only the impure orchestration — reading the live rect/spacing values,
46
+ * driving the `begin`/`apply`/`end` bracket, and rendering.
47
+ *
48
+ * Phase D2 (§6) adds, also in this file: a drag-ghost (a semi-transparent
49
+ * follow-cursor copy of the dragged element, live during a B2 move gesture OR
50
+ * a D2.b reorder drag) and the reorder clone-preview itself — dragging an
51
+ * already-selected FLOW/LIST child (no `boxEdit` move applies to it; it's
52
+ * neither absolute nor fixed) among ≥3 total siblings live-previews the
53
+ * insertion gap the drop would land in and commits ONE `structure.reorder(id,
54
+ * beforeSiblingId)` call on drop (capability-gated on the owner exposing
55
+ * `structure.reorder` — absent for the ingested react-dom adapter, so no
56
+ * preview/commit there; the drag-ghost is visual-only and may still show).
57
+ * The gap math (`computeReorderGap`) and ghost math (`computeDragGhost`) are
58
+ * pure, in `world-overlay-gestures.ts`, same split as B2/B3.
59
+ */
60
+ import type {
61
+ AuthoringAdapter,
62
+ BoxEditProvider,
63
+ DOMRectLike,
64
+ StructureProvider,
65
+ } from '@engine/adapter';
66
+ import {
67
+ type MouseEvent as ReactMouseEvent,
68
+ type PointerEvent as ReactPointerEvent,
69
+ useCallback,
70
+ useEffect,
71
+ useRef,
72
+ useState,
73
+ useSyncExternalStore,
74
+ } from 'react';
75
+ import { collectAllNodeIds, getActiveAuthoring } from '../authoring/active-adapter';
76
+ import { CompositeAuthoringAdapter } from '../authoring/composite-authoring-adapter';
77
+ import {
78
+ isEyedropperSessionActive,
79
+ resolveEyedropperSession,
80
+ subscribeEyedropperSession,
81
+ } from '../authoring/eyedropper-session';
82
+ import { pickTopmost } from '../authoring/layered-pick';
83
+ import {
84
+ getWorldPan,
85
+ panTransformValue,
86
+ setWorldPan,
87
+ subscribeWorldPan,
88
+ } from '../authoring/world-pan-state';
89
+ import { isWorldHidden } from '../authoring/world-session-state';
90
+ import { useEditorStore } from '../EditorContext';
91
+ import { isIngestActive } from '../ingest-mode';
92
+ import { isModuleModeActive } from '../module-mode';
93
+ import { isPlayModeActive } from '../play-mode';
94
+ import { effectiveColorFromChain } from '../ui-source/inspect';
95
+ import { AlignToolbar } from './AlignToolbar';
96
+ import { WorldContextMenu, type WorldContextMenuState } from './WorldContextMenu';
97
+ import { WorldTextEditor } from './WorldTextEditor';
98
+ import {
99
+ type AlignEntry,
100
+ boxEditForId,
101
+ colorSampleForId,
102
+ computeDragGhost,
103
+ computeMeasureLines,
104
+ computeMovePatch,
105
+ computeMoveSnapGuides,
106
+ computeReorderGap,
107
+ computeResizePatch,
108
+ computeResizeSnapGuides,
109
+ computeRotatePatch,
110
+ computeSpacingBands,
111
+ computeSpacingPatch,
112
+ contextRectsForId,
113
+ emptyContainerHintsFor,
114
+ type HandlePos,
115
+ handlePosition,
116
+ type MeasureLine,
117
+ type PositionBadgeInfo,
118
+ RESIZE_HANDLES,
119
+ type ReorderGap,
120
+ readSpacingValues,
121
+ // D4 — moved here from a local definition in THIS file (was `export
122
+ // function rectForId`): `editor-hotkeys.ts`'s D4.c arrow-nudge action
123
+ // needed it too, and importing it off THIS component file pulled the
124
+ // whole `WorldSelectionOverlay`/postprocessing-viewport dependency graph
125
+ // into `editor-hotkeys.ts` (breaks a headless/vitest unit test of the
126
+ // hotkey actions, which has no business mounting a viewport). Re-exported
127
+ // below for the existing `WorldSelectionOverlay.test.tsx` import site.
128
+ rectForId,
129
+ resolvePositionBadge,
130
+ type SiblingRectEntry,
131
+ type SnapGuide,
132
+ type SpacingBand,
133
+ type SpacingSide,
134
+ siblingIdsForId,
135
+ structureForId,
136
+ textForId,
137
+ } from './world-overlay-gestures';
138
+
139
+ export { rectForId } from './world-overlay-gestures';
140
+
141
+ const ACCENT = '#0d99ff';
142
+ /** Strictly below `PlayBar`'s z100 / `ToolStrip`+`ViewportOverlay`'s z1000 —
143
+ * mirrors `design-time-layers.ts`'s own `BASE_Z_INDEX` doc comment ("so
144
+ * gizmo/orbit-controls/marquee chrome is never occluded") — and strictly
145
+ * above the design-time layers themselves (`BASE_Z_INDEX + i`, small N),
146
+ * so this overlay's boxes/marquee always paint over the world content they
147
+ * describe. */
148
+ const Z_INDEX = 50;
149
+ const DRAG_THRESHOLD_PX = 5;
150
+
151
+ interface Rect {
152
+ x: number;
153
+ y: number;
154
+ width: number;
155
+ height: number;
156
+ }
157
+
158
+ /** `name·kind` label for a node. `hierarchy.node` IS merged/routed on the
159
+ * composite (unlike `rects`/`pickable`), so no owner lookup is needed here. */
160
+ export function labelForId(adapter: AuthoringAdapter, id: string): string {
161
+ const node = adapter.hierarchy.node(id);
162
+ return node ? `${node.label}·${node.kind}` : id;
163
+ }
164
+
165
+ /**
166
+ * True when `adapter` (or, for a composite, ANY of its children) exposes a
167
+ * `RectProvider` — the DOM-overlay capability signal (the contract's own doc
168
+ * comment on `AuthoringAdapter.rects`: "Absent ⇒ this adapter produces no
169
+ * rects (no overlay)"). Gates whether `WorldSelectionOverlay` renders
170
+ * anything at all, so a pure-3D session (no react/pixi/DOM world in the
171
+ * active composite) is completely unaffected — matches spec §3 A3's wiring
172
+ * instruction ("gate on the active adapter exposing rects/pickable on any
173
+ * child, so 3D-only sessions are unaffected").
174
+ */
175
+ export function hasRectCapableChild(adapter: AuthoringAdapter): boolean {
176
+ if (adapter instanceof CompositeAuthoringAdapter) {
177
+ return adapter.childAdapters().some((c) => !!c.adapter.rects);
178
+ }
179
+ return !!adapter.rects;
180
+ }
181
+
182
+ /**
183
+ * Every (id, rect) pair reachable from `adapter`'s own `rects` provider — or,
184
+ * for a composite, from each VISIBLE child's own `rects` provider — the
185
+ * marquee's candidate pool. Walks each CHILD's OWN hierarchy
186
+ * (`collectAllNodeIds`), never the composite's synthetic `world:<id>`
187
+ * wrapper, so a group/game-root id is never a candidate. A session-hidden
188
+ * world (`world-session-state.ts`'s eye toggle, B1) contributes no
189
+ * candidates — consistent with it being invisible/unpickable.
190
+ *
191
+ * D4.R1 — a LOCKED node (`child.inspector.get(id, 'locked') === true`, the
192
+ * same session-local `lockedIds` the layer-tree lock toggle writes) is also
193
+ * excluded: a marquee that merely overlaps a locked node's rect must not
194
+ * sweep it into the selection, matching the click-pick gate each override
195
+ * adapter's own `pickable.pick` now applies (`ReactWorldAuthoringAdapter`/
196
+ * `ReactDomAuthoringAdapter`/`UIAuthoringAdapter`). The first-party threejs
197
+ * adapter has no `rects` provider, so it never reaches this function at all
198
+ * — its own locked-skip lives entirely in `viewport-raycast.ts`.
199
+ */
200
+ export function collectMarqueeCandidates(
201
+ adapter: AuthoringAdapter,
202
+ ): Array<{ id: string; rect: DOMRectLike }> {
203
+ const children: ReadonlyArray<{ worldId: string; adapter: AuthoringAdapter }> =
204
+ adapter instanceof CompositeAuthoringAdapter
205
+ ? adapter.childAdapters()
206
+ : [{ worldId: '', adapter }];
207
+ const out: Array<{ id: string; rect: DOMRectLike }> = [];
208
+ for (const { worldId, adapter: child } of children) {
209
+ if (!child.rects) continue;
210
+ if (worldId && isWorldHidden(worldId)) continue;
211
+ for (const id of collectAllNodeIds(child)) {
212
+ if (child.inspector?.get(id, 'locked') === true) continue;
213
+ const r = child.rects.rect(id);
214
+ if (r) out.push({ id, rect: r });
215
+ }
216
+ }
217
+ return out;
218
+ }
219
+
220
+ /** Axis-aligned overlap test (ANY overlap selects — not full containment;
221
+ * spec §3 A3: "select all nodes whose rect intersects"). */
222
+ export function rectsIntersect(a: Rect, b: DOMRectLike): boolean {
223
+ return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
224
+ }
225
+
226
+ /** Pure selection-set math for a click (not drag) on a hit node — a plain
227
+ * click REPLACES the selection with just `hitId`; shift-click ADDS it if
228
+ * absent or TOGGLES it off if already selected. Factored out of the
229
+ * pointer-up handler so it's directly unit-testable (spec §3 A3's own
230
+ * testing note: "shift-click add/toggle vs replace"). */
231
+ export function resolveClickSelection(
232
+ current: ReadonlySet<string>,
233
+ hitId: string,
234
+ shiftKey: boolean,
235
+ ): string[] {
236
+ if (!shiftKey) return [hitId];
237
+ const next = new Set(current);
238
+ if (next.has(hitId)) next.delete(hitId);
239
+ else next.add(hitId);
240
+ return [...next];
241
+ }
242
+
243
+ /**
244
+ * Pure selection resolution for a gesture that STARTED on empty space (no
245
+ * pick at pointerdown): a real marquee drag (past the drag threshold)
246
+ * selects every candidate whose rect intersects the rubber-band — possibly
247
+ * none, which clears; a plain click (no meaningful drag) clears the
248
+ * selection, UNLESS shift is held, in which case it's a no-op (returns
249
+ * `null` — "leave the selection untouched" — so an accidental shift-click on
250
+ * empty space doesn't wipe out an existing multi-select).
251
+ */
252
+ export function resolveEmptySpaceSelection(
253
+ active: AuthoringAdapter,
254
+ moved: boolean,
255
+ marquee: Rect | null,
256
+ shiftKey: boolean,
257
+ ): string[] | null {
258
+ const isRealDrag =
259
+ moved && !!marquee && (marquee.width > DRAG_THRESHOLD_PX || marquee.height > DRAG_THRESHOLD_PX);
260
+ if (isRealDrag && marquee) {
261
+ const candidates = collectMarqueeCandidates(active);
262
+ return candidates.filter((c) => rectsIntersect(marquee, c.rect)).map((c) => c.id);
263
+ }
264
+ return shiftKey ? null : [];
265
+ }
266
+
267
+ /** D4 (spec27 §8 "space-pan" row) — the `interactionRef` cursor for the
268
+ * current pan/eyedropper/marquee state, pure (a plain priority chain, no
269
+ * DOM/store reads) — factored out purely to keep the component's own
270
+ * cognitive complexity down, same reason `computeSingleSelectionBoxEdit`/
271
+ * `computeD1Affordances` above are factored out. */
272
+ function interactionCursor(
273
+ isPanning: boolean,
274
+ spaceHeld: boolean,
275
+ eyedropperActive: boolean,
276
+ hasMarquee: boolean,
277
+ ): React.CSSProperties['cursor'] {
278
+ if (isPanning) return 'grabbing';
279
+ if (spaceHeld) return 'grab';
280
+ if (eyedropperActive || hasMarquee) return 'crosshair';
281
+ return 'default';
282
+ }
283
+
284
+ function boxStyle(r: DOMRectLike, solid: boolean): React.CSSProperties {
285
+ return {
286
+ position: 'absolute',
287
+ left: r.x,
288
+ top: r.y,
289
+ width: r.width,
290
+ height: r.height,
291
+ border: `${solid ? 2 : 1}px solid ${ACCENT}`,
292
+ background: solid ? 'rgba(13, 153, 255, 0.06)' : 'transparent',
293
+ boxSizing: 'border-box',
294
+ pointerEvents: 'none',
295
+ };
296
+ }
297
+
298
+ function labelStyle(r: DOMRectLike): React.CSSProperties {
299
+ return {
300
+ position: 'absolute',
301
+ left: r.x,
302
+ top: Math.max(0, r.y - 18),
303
+ padding: '2px 6px',
304
+ fontSize: 11,
305
+ fontFamily: "'SF Mono', 'Fira Code', Menlo, Consolas, monospace",
306
+ fontWeight: 600,
307
+ color: '#fff',
308
+ background: ACCENT,
309
+ borderRadius: 3,
310
+ pointerEvents: 'none',
311
+ whiteSpace: 'nowrap',
312
+ };
313
+ }
314
+
315
+ const HANDLE_SIZE = 10;
316
+
317
+ /** One of the 8 B2 resize handles — a small white/accent square centered on
318
+ * its position on the selection rect (spec:317/§5 :465). */
319
+ function handleStyle(center: { x: number; y: number }, cursor: string): React.CSSProperties {
320
+ return {
321
+ position: 'absolute',
322
+ left: center.x - HANDLE_SIZE / 2,
323
+ top: center.y - HANDLE_SIZE / 2,
324
+ width: HANDLE_SIZE,
325
+ height: HANDLE_SIZE,
326
+ background: '#fff',
327
+ border: `1px solid ${ACCENT}`,
328
+ boxSizing: 'border-box',
329
+ cursor,
330
+ pointerEvents: 'auto',
331
+ };
332
+ }
333
+
334
+ const ROTATE_SIZE = 12;
335
+ const ROTATE_OFFSET = 24;
336
+
337
+ /** The rotate handle — a small circle ~24px above top-center (spec:322/§5
338
+ * :465 "rotate"). */
339
+ function rotateHandleStyle(rect: DOMRectLike): React.CSSProperties {
340
+ return {
341
+ position: 'absolute',
342
+ left: rect.x + rect.width / 2 - ROTATE_SIZE / 2,
343
+ top: rect.y - ROTATE_OFFSET - ROTATE_SIZE / 2,
344
+ width: ROTATE_SIZE,
345
+ height: ROTATE_SIZE,
346
+ borderRadius: '50%',
347
+ background: '#fff',
348
+ border: `1px solid ${ACCENT}`,
349
+ boxSizing: 'border-box',
350
+ cursor: 'grab',
351
+ pointerEvents: 'auto',
352
+ };
353
+ }
354
+
355
+ /** The B2 dimension label (spec:460 "dimension label") — `W×H`, centered
356
+ * below the selection box (reuses `labelStyle`'s look, different anchor). */
357
+ function dimensionLabelStyle(rect: DOMRectLike): React.CSSProperties {
358
+ return {
359
+ position: 'absolute',
360
+ left: rect.x,
361
+ top: rect.y + rect.height + 4,
362
+ padding: '2px 6px',
363
+ fontSize: 11,
364
+ fontFamily: "'SF Mono', 'Fira Code', Menlo, Consolas, monospace",
365
+ fontWeight: 600,
366
+ color: '#fff',
367
+ background: ACCENT,
368
+ borderRadius: 3,
369
+ pointerEvents: 'none',
370
+ whiteSpace: 'nowrap',
371
+ };
372
+ }
373
+
374
+ const PADDING_BAND_COLOR = 'rgba(46, 204, 113, 0.25)';
375
+ const MARGIN_BAND_COLOR = 'rgba(230, 126, 34, 0.25)';
376
+
377
+ /** One B3 padding/margin spacing band (spec:332-335/§5 :460/:465-466). */
378
+ function spacingBandStyle(band: SpacingBand): React.CSSProperties {
379
+ return {
380
+ position: 'absolute',
381
+ left: band.x,
382
+ top: band.y,
383
+ width: band.width,
384
+ height: band.height,
385
+ background: band.kind === 'padding' ? PADDING_BAND_COLOR : MARGIN_BAND_COLOR,
386
+ boxSizing: 'border-box',
387
+ cursor: band.cursor,
388
+ pointerEvents: 'auto',
389
+ };
390
+ }
391
+
392
+ /** The B3 numeric label centered in a nonzero band — monospace ~10px,
393
+ * porting `ui-editor/overlay.tsx`'s dashed gap-measure label styling. */
394
+ function spacingLabelStyle(band: SpacingBand): React.CSSProperties {
395
+ return {
396
+ position: 'absolute',
397
+ left: band.labelX,
398
+ top: band.labelY,
399
+ transform: 'translate(-50%, -50%)',
400
+ fontSize: 10,
401
+ fontFamily: 'monospace',
402
+ color: band.kind === 'padding' ? '#1b7a3d' : '#a35a12',
403
+ background: 'rgba(255,255,255,0.85)',
404
+ padding: '0 2px',
405
+ borderRadius: 2,
406
+ pointerEvents: 'none',
407
+ whiteSpace: 'nowrap',
408
+ };
409
+ }
410
+
411
+ /** D2.a — the drag-ghost: a semi-transparent, INERT copy of the dragged
412
+ * element at its ghost rect (spec:399). Deliberately dashed + low-opacity
413
+ * fill so it reads as a "preview copy", never confusable with the live
414
+ * selection box (`boxStyle`'s solid ACCENT border) it's drawn alongside. */
415
+ function dragGhostStyle(rect: Rect): React.CSSProperties {
416
+ return {
417
+ position: 'absolute',
418
+ left: rect.x,
419
+ top: rect.y,
420
+ width: rect.width,
421
+ height: rect.height,
422
+ border: `2px dashed ${ACCENT}`,
423
+ background: 'rgba(13, 153, 255, 0.15)',
424
+ boxSizing: 'border-box',
425
+ pointerEvents: 'none',
426
+ opacity: 0.7,
427
+ };
428
+ }
429
+
430
+ /** D2.b — the live reorder-preview: a thin marker line at the gap the drop
431
+ * would land in (spec:398-400). Distinct color from the drag-ghost/ACCENT
432
+ * selection chrome so the two affordances (which render SIMULTANEOUSLY
433
+ * during a reorder drag) stay visually distinguishable. */
434
+ function reorderMarkerStyle(marker: Rect): React.CSSProperties {
435
+ return {
436
+ position: 'absolute',
437
+ left: marker.x,
438
+ top: marker.y,
439
+ width: marker.width,
440
+ height: marker.height,
441
+ background: '#ff5fa2',
442
+ borderRadius: 2,
443
+ pointerEvents: 'none',
444
+ };
445
+ }
446
+
447
+ // --- D1 — measurement + snap + alignment guides + badges (spec §6 D1)
448
+ // ---------------------------------------------------------------------
449
+
450
+ const MEASURE_COLOR = '#ff2d92';
451
+ const MEASURE_CAP_PX = 6;
452
+
453
+ /** D1.a — the measure line itself: a 1px pink segment (figma "hold-and-
454
+ * measure" affordance, spec §6 "pink, end-caps, value labels"). */
455
+ function measureLineStyle(line: MeasureLine): React.CSSProperties {
456
+ const horizontal = line.orientation === 'horizontal';
457
+ return {
458
+ position: 'absolute',
459
+ left: Math.min(line.x1, line.x2),
460
+ top: Math.min(line.y1, line.y2),
461
+ width: horizontal ? Math.abs(line.x2 - line.x1) : 1,
462
+ height: horizontal ? 1 : Math.abs(line.y2 - line.y1),
463
+ background: MEASURE_COLOR,
464
+ pointerEvents: 'none',
465
+ };
466
+ }
467
+
468
+ /** D1.a — one of the measure line's two end-caps: a short perpendicular
469
+ * tick at `(x,y)`. */
470
+ function measureCapStyle(x: number, y: number, horizontal: boolean): React.CSSProperties {
471
+ return {
472
+ position: 'absolute',
473
+ left: horizontal ? x : x - MEASURE_CAP_PX / 2,
474
+ top: horizontal ? y - MEASURE_CAP_PX / 2 : y,
475
+ width: horizontal ? 1 : MEASURE_CAP_PX,
476
+ height: horizontal ? MEASURE_CAP_PX : 1,
477
+ background: MEASURE_COLOR,
478
+ pointerEvents: 'none',
479
+ };
480
+ }
481
+
482
+ /** D1.a — the measure line's px value label, centered on the line. */
483
+ function measureLabelStyle(line: MeasureLine): React.CSSProperties {
484
+ return {
485
+ position: 'absolute',
486
+ left: line.labelX,
487
+ top: line.labelY,
488
+ transform: 'translate(-50%, -50%)',
489
+ padding: '1px 4px',
490
+ fontSize: 10,
491
+ fontFamily: 'monospace',
492
+ fontWeight: 600,
493
+ color: '#fff',
494
+ background: MEASURE_COLOR,
495
+ borderRadius: 2,
496
+ pointerEvents: 'none',
497
+ whiteSpace: 'nowrap',
498
+ };
499
+ }
500
+
501
+ const SNAP_GUIDE_COLOR = '#f24822';
502
+
503
+ /** D1.b — one active alignment/snap guide, live only while the underlying
504
+ * gesture's own edge-snap is engaged (spec §6 "guides appear only within
505
+ * the snap threshold; disappear when not snapping"). Dashed so it reads as
506
+ * a transient alignment aid, distinct from the solid pink measure line. */
507
+ function snapGuideStyle(guide: SnapGuide): React.CSSProperties {
508
+ const vertical = guide.orientation === 'vertical';
509
+ return {
510
+ position: 'absolute',
511
+ left: vertical ? guide.at : guide.start,
512
+ top: vertical ? guide.start : guide.at,
513
+ width: vertical ? 1 : guide.end - guide.start,
514
+ height: vertical ? guide.end - guide.start : 1,
515
+ borderLeft: vertical ? `1px dashed ${SNAP_GUIDE_COLOR}` : undefined,
516
+ borderTop: !vertical ? `1px dashed ${SNAP_GUIDE_COLOR}` : undefined,
517
+ pointerEvents: 'none',
518
+ };
519
+ }
520
+
521
+ /** D1.c — the position/parent-layout badge, anchored just above-left of the
522
+ * selection (mirrors `labelStyle`'s anchor, offset further up so it never
523
+ * overlaps the `name·kind` label A3 already renders there). */
524
+ function positionBadgeStyle(r: DOMRectLike): React.CSSProperties {
525
+ return {
526
+ position: 'absolute',
527
+ left: r.x,
528
+ top: Math.max(0, r.y - 36),
529
+ display: 'flex',
530
+ gap: 4,
531
+ pointerEvents: 'none',
532
+ };
533
+ }
534
+
535
+ const BADGE_SEGMENT_STYLE: React.CSSProperties = {
536
+ padding: '2px 6px',
537
+ fontSize: 10,
538
+ fontFamily: "'SF Mono', 'Fira Code', Menlo, Consolas, monospace",
539
+ fontWeight: 600,
540
+ color: '#fff',
541
+ background: 'rgba(13, 153, 255, 0.85)',
542
+ borderRadius: 3,
543
+ whiteSpace: 'nowrap',
544
+ };
545
+
546
+ /** D1.c — the parent-container outline: a subtle dashed stroke around
547
+ * `rect` (spec §6 "outline the parent container rect ... with a subtle
548
+ * stroke"). */
549
+ function parentOutlineStyle(r: DOMRectLike): React.CSSProperties {
550
+ return {
551
+ position: 'absolute',
552
+ left: r.x,
553
+ top: r.y,
554
+ width: r.width,
555
+ height: r.height,
556
+ border: '1px dashed rgba(13, 153, 255, 0.45)',
557
+ boxSizing: 'border-box',
558
+ pointerEvents: 'none',
559
+ };
560
+ }
561
+
562
+ /**
563
+ * A single active B2/B3 gesture — resize/move/rotate/spacing — bracketed by
564
+ * `ownerBoxEdit.begin/end`. Kept SEPARATE from {@link DragState} (A3's click/
565
+ * marquee tracking): a gesture and a click/marquee-drag are mutually
566
+ * exclusive per pointer session and want independent lifecycles (spec §4 B2
567
+ * step 4's own instruction).
568
+ */
569
+ interface GestureState {
570
+ kind: 'resize' | 'move' | 'rotate' | 'spacing';
571
+ id: string;
572
+ ownerBoxEdit: BoxEditProvider;
573
+ /** Gesture start point, in the SAME coordinate frame as `origRect`/`center`
574
+ * (host-local) — see `computeRotatePatch`'s doc comment on frame
575
+ * consistency. Resize/move only need the DELTA (frame-invariant under a
576
+ * fixed additive offset), so they tolerate either frame; rotate does not,
577
+ * so this file always seeds it host-local for every kind, uniformly. */
578
+ startLocal: { x: number; y: number };
579
+ origRect: DOMRectLike;
580
+ pos?: HandlePos;
581
+ isPositioned?: boolean;
582
+ center?: { x: number; y: number };
583
+ context?: ReturnType<typeof contextRectsForId>;
584
+ side?: SpacingSide;
585
+ origValue?: number;
586
+ }
587
+
588
+ /** D2 — promote a deferred move CANDIDATE (`DragState.moveCandidate`) into a
589
+ * live `move` gesture once the drag threshold is crossed: begins the
590
+ * gesture on the owner (one side effect) and constructs the `GestureState`,
591
+ * seeded from the pointer-DOWN point (`startLocal`) so the move delta stays
592
+ * continuous. Factored out of `onPointerMove` (module-level, not a nested
593
+ * closure) purely to keep that handler's own cognitive complexity down. */
594
+ function promoteMoveCandidate(
595
+ candidate: NonNullable<DragState['moveCandidate']>,
596
+ startLocal: { x: number; y: number },
597
+ ): GestureState {
598
+ candidate.ownerBoxEdit.begin(candidate.id);
599
+ return {
600
+ kind: 'move',
601
+ id: candidate.id,
602
+ ownerBoxEdit: candidate.ownerBoxEdit,
603
+ startLocal,
604
+ origRect: candidate.origRect,
605
+ context: candidate.context,
606
+ };
607
+ }
608
+
609
+ interface DragState {
610
+ readonly downClientX: number;
611
+ readonly downClientY: number;
612
+ readonly downLocal: { x: number; y: number };
613
+ /** The pick result AT pointerdown — decides click-select vs. marquee. */
614
+ readonly hitAtDown: string | null;
615
+ moved: boolean;
616
+ /**
617
+ * D2 — a DEFERRED move-gesture candidate, recorded at pointerdown when the
618
+ * press landed on an already-selected, absolutely/fixed-positioned,
619
+ * boxEdit-capable node, but NOT promoted to a live gesture until the pointer
620
+ * crosses the drag threshold (`onPointerMove`). A pointerup BEFORE the
621
+ * threshold falls through to the ordinary click-select path — so a plain
622
+ * click still narrows a multi-selection to the clicked node and a shift-click
623
+ * still toggles it (the regression Fable found: the old code began the move
624
+ * gesture at pointerdown unconditionally, and `endGesture()` on pointerup
625
+ * skipped `resolveClickSelection`, swallowing every click on a positioned
626
+ * node). `null` when the press was not a move candidate. */
627
+ readonly moveCandidate: {
628
+ id: string;
629
+ ownerBoxEdit: BoxEditProvider;
630
+ origRect: DOMRectLike;
631
+ context?: ReturnType<typeof contextRectsForId>;
632
+ } | null;
633
+ /**
634
+ * D2.b (spec:397-401) — a DEFERRED reorder-preview candidate, recorded at
635
+ * pointerdown under the SAME "already selected" gate {@link moveCandidate}
636
+ * uses, but for the opposite geometry: a FLOW/LIST child (no `boxEdit`
637
+ * move applies — {@link resolveMoveCandidate} already returned `null` for
638
+ * it, since it's neither absolute nor fixed) whose owner exposes
639
+ * `structure.reorder` and has ≥3 total siblings (itself + ≥2 others —
640
+ * spec:398 "drag a child among ≥3 siblings"). Only ONE of
641
+ * `moveCandidate`/`reorderCandidate` is ever non-null for a given press —
642
+ * see `onPointerDown`'s own ordering (move is checked first; a positioned
643
+ * node's owner may ALSO have `structure`, but this spec's move/reorder
644
+ * split is by POSITIONING, not by capability overlap). `null` when the
645
+ * press was not a reorder candidate. */
646
+ readonly reorderCandidate: {
647
+ id: string;
648
+ structure: StructureProvider;
649
+ origRect: DOMRectLike;
650
+ siblingRects: SiblingRectEntry[];
651
+ } | null;
652
+ }
653
+
654
+ /**
655
+ * D2 (spec:322) — resolve a pointerdown hit into a deferred move-gesture
656
+ * CANDIDATE (see `DragState.moveCandidate`'s doc comment for the full
657
+ * rationale): only when `hitId` is already selected, its owner exposes
658
+ * `boxEdit`, and it is absolutely/fixed-positioned. Factored out of
659
+ * `onPointerDown` (a module-level function, not a nested closure) purely to
660
+ * keep that handler's own cognitive complexity down — pulls no state, reads
661
+ * only its arguments.
662
+ */
663
+ function resolveMoveCandidate(active: AuthoringAdapter, hitId: string): DragState['moveCandidate'] {
664
+ const owner = boxEditForId(active, hitId);
665
+ if (!owner) return null;
666
+ const posValue = active.inspector?.get(hitId, 'style.position');
667
+ if (posValue !== 'absolute' && posValue !== 'fixed') return null;
668
+ const rect = rectForId(active, hitId);
669
+ if (!rect) return null;
670
+ return {
671
+ id: hitId,
672
+ ownerBoxEdit: owner,
673
+ origRect: rect,
674
+ context: contextRectsForId(active, hitId),
675
+ };
676
+ }
677
+
678
+ /**
679
+ * D2.b (spec:397-401) — resolve a pointerdown hit into a deferred
680
+ * reorder-preview CANDIDATE (see `DragState.reorderCandidate`'s doc comment):
681
+ * only when `hitId`'s OWNING adapter exposes `structure.reorder` (capability
682
+ * gate — the ingested react-dom adapter has no `structure` at all, so this
683
+ * always returns `null` there, per this task's own capability-gating
684
+ * precedent) AND it has ≥3 total siblings (itself + ≥2 others). Sibling ids
685
+ * come from {@link siblingIdsForId} (the owner's OWN `hierarchy`, ordered);
686
+ * each sibling's CURRENT rect is read via {@link rectForId} (same
687
+ * owner-routing `resolveMoveCandidate` uses) — a sibling with no rect
688
+ * (unmounted/offscreen) is simply excluded from the gap math, never a throw.
689
+ * Factored out of `onPointerDown` for the same cognitive-complexity reason
690
+ * `resolveMoveCandidate` is.
691
+ */
692
+ function resolveReorderCandidate(
693
+ active: AuthoringAdapter,
694
+ hitId: string,
695
+ ): DragState['reorderCandidate'] {
696
+ const structure = structureForId(active, hitId);
697
+ if (!structure?.reorder) return null;
698
+ const siblingIds = siblingIdsForId(active, hitId);
699
+ if (!siblingIds || siblingIds.length < 3) return null;
700
+ const origRect = rectForId(active, hitId);
701
+ if (!origRect) return null;
702
+ const siblingRects: SiblingRectEntry[] = [];
703
+ for (const sid of siblingIds) {
704
+ if (sid === hitId) continue;
705
+ const r = rectForId(active, sid);
706
+ if (r) siblingRects.push({ id: sid, rect: r });
707
+ }
708
+ if (siblingRects.length === 0) return null;
709
+ return { id: hitId, structure, origRect, siblingRects };
710
+ }
711
+
712
+ /** True while a REAL game (Play, an ingested unmodified game, or an
713
+ * auto-mounted `{ module }` adapter world) owns the canvas and needs its own
714
+ * pointer events — the overlay must be fully inert then (see the call
715
+ * site's doc comment for the regression this guards). Factored into its own
716
+ * function purely to keep the caller's cognitive complexity down. */
717
+ function isCanvasOwnedByARunningGame(): boolean {
718
+ return isPlayModeActive() || isIngestActive() || isModuleModeActive();
719
+ }
720
+
721
+ /**
722
+ * B2/B3 — the single-selection box-edit bundle (handles/rotate/dimension-
723
+ * label/spacing-bands), or `null` when it doesn't apply: these render ONLY
724
+ * for EXACTLY one selected node whose OWNER exposes `boxEdit` (spec:317).
725
+ * Factored out of the component purely to keep ITS cognitive complexity
726
+ * down — pure, reads only its arguments.
727
+ */
728
+ function computeSingleSelectionBoxEdit(
729
+ adapter: AuthoringAdapter,
730
+ selectedIds: ReadonlySet<string>,
731
+ ): {
732
+ id: string;
733
+ rect: DOMRectLike;
734
+ ownerBoxEdit: BoxEditProvider;
735
+ spacingBands: SpacingBand[];
736
+ } | null {
737
+ if (selectedIds.size !== 1) return null;
738
+ const id = [...selectedIds][0]!;
739
+ const ownerBoxEdit = boxEditForId(adapter, id);
740
+ if (!ownerBoxEdit) return null;
741
+ const rect = rectForId(adapter, id);
742
+ if (!rect) return null;
743
+ const spacingBands = computeSpacingBands(rect, readSpacingValues(adapter, id));
744
+ return { id, rect, ownerBoxEdit, spacingBands };
745
+ }
746
+
747
+ /**
748
+ * D4.b (spec §6 D4 "a multi-select align/distribute toolbar ... shown when
749
+ * ≥2 nodes are selected") — the `AlignToolbar`'s entries, or `null` when it
750
+ * shouldn't render: fewer than 2 selected, OR any selected id fails to
751
+ * resolve BOTH a rect and an owning `boxEdit` (spec:305's own "capability-
752
+ * gated: only when adapter.boxEdit is present" — an all-or-nothing gate, so
753
+ * a mixed selection spanning a boxEdit-less world never offers a partially-
754
+ * broken toolbar). Factored out purely to keep the component's own
755
+ * cognitive complexity down, same reason `computeSingleSelectionBoxEdit`
756
+ * above is.
757
+ */
758
+ function computeMultiSelectionAlign(
759
+ adapter: AuthoringAdapter,
760
+ selectedIds: ReadonlySet<string>,
761
+ ): AlignEntry[] | null {
762
+ if (selectedIds.size < 2) return null;
763
+ const entries: AlignEntry[] = [];
764
+ for (const id of selectedIds) {
765
+ const rect = rectForId(adapter, id);
766
+ if (!rect) return null;
767
+ if (!boxEditForId(adapter, id)) return null;
768
+ entries.push({ id, rect });
769
+ }
770
+ return entries;
771
+ }
772
+
773
+ /**
774
+ * D1 (spec §6) — measure lines, position/parent-layout badge, and
775
+ * parent-container outline, all READ-only (rects + `inspector.get`) so —
776
+ * unlike {@link computeSingleSelectionBoxEdit}'s handles/bands — available
777
+ * for ANY adapter exposing `rects`, not gated on the owner also exposing
778
+ * `boxEdit`. Only for EXACTLY one selected node (spec §6's own framing:
779
+ * "between selection and hover" / "a small badge near the selection" both
780
+ * presume a single selection). Factored out of the component purely to keep
781
+ * ITS cognitive complexity down — pure given its arguments.
782
+ */
783
+ function computeD1Affordances(
784
+ adapter: AuthoringAdapter,
785
+ selectedIds: ReadonlySet<string>,
786
+ hoverRect: DOMRectLike | null,
787
+ ): {
788
+ selectedSingleRect: DOMRectLike | null;
789
+ measureLines: MeasureLine[];
790
+ positionBadge: PositionBadgeInfo | null;
791
+ showPositionBadge: boolean;
792
+ parentRect: DOMRectLike | null;
793
+ } {
794
+ const selectedSingleId = selectedIds.size === 1 ? [...selectedIds][0]! : null;
795
+ const selectedSingleRect = selectedSingleId ? rectForId(adapter, selectedSingleId) : null;
796
+ // D1.a — sibling-distance measure lines between the selection and the
797
+ // CURRENTLY HOVERED sibling (spec §6 "between selection and hover"). `[]`
798
+ // whenever either rect is missing or the two rects don't leave a gap on
799
+ // either axis (`computeMeasureLines`'s own honest-empty-result doc
800
+ // comment) — never a fabricated line.
801
+ const measureLines =
802
+ selectedSingleRect && hoverRect ? computeMeasureLines(selectedSingleRect, hoverRect) : [];
803
+ // D1.c — each segment resolves independently; `null`/`null` renders no
804
+ // badge at all (never a fabricated default — spec's own honesty rule).
805
+ const positionBadge: PositionBadgeInfo | null = selectedSingleId
806
+ ? resolvePositionBadge(adapter, selectedSingleId)
807
+ : null;
808
+ const showPositionBadge = !!(
809
+ positionBadge &&
810
+ (positionBadge.position || positionBadge.parentLayout)
811
+ );
812
+ // D1.c — the parent container's own rect (`rects.contextRects(id).parent`,
813
+ // routed through the SAME owner-child helper the B2 move/resize gestures
814
+ // already use for this exact field) — `null` when the selection has no
815
+ // resolvable parent rect (a top-level node, or the owner has no
816
+ // `contextRects`), in which case no outline renders at all.
817
+ const parentRect = selectedSingleId
818
+ ? (contextRectsForId(adapter, selectedSingleId)?.parent ?? null)
819
+ : null;
820
+ return { selectedSingleRect, measureLines, positionBadge, showPositionBadge, parentRect };
821
+ }
822
+
823
+ export function WorldSelectionOverlay(): React.ReactNode {
824
+ const store = useEditorStore();
825
+ const storeVersion = useSyncExternalStore(store.subscribe, store.getSnapshot);
826
+
827
+ const interactionRef = useRef<HTMLDivElement>(null);
828
+ const dragRef = useRef<DragState | null>(null);
829
+ const marqueeRef = useRef<Rect | null>(null);
830
+ const [marquee, setMarqueeState] = useState<Rect | null>(null);
831
+ const [hoverId, setHoverId] = useState<string | null>(null);
832
+
833
+ // D4 (spec27 §8 "space-pan" row) — the shared pan translate, applied to
834
+ // this component's own outer wrapper below (`world-pan-state.ts` applies
835
+ // the identical value to every design-time world-layer host — see that
836
+ // module's doc comment for the lockstep-translate soundness argument).
837
+ // `useSyncExternalStore` so a pan change (from THIS component's own
838
+ // space-drag below, or a reset) re-renders immediately.
839
+ const pan = useSyncExternalStore(subscribeWorldPan, getWorldPan);
840
+ // True while the Space key is physically held (and not typing — see the
841
+ // keydown/keyup effect below), the hold-to-pan gesture's arm switch.
842
+ // `spaceHeldRef` mirrors the state for a synchronous read in the pointer
843
+ // handlers (same "ref mirrors state" convention `marqueeRef`/`textEditRef`
844
+ // already use in this file).
845
+ const spaceHeldRef = useRef(false);
846
+ const [spaceHeld, setSpaceHeld] = useState(false);
847
+ // The active space-drag pan gesture, or `null` between drags — kept
848
+ // SEPARATE from `dragRef`/`gestureRef` (mutually exclusive per pointer
849
+ // session, same reasoning `GestureState`'s own doc comment gives for being
850
+ // separate from `DragState`): a pan-drag never picks/selects/resizes, it
851
+ // only ever writes `world-pan-state.ts`.
852
+ const panDragRef = useRef<{
853
+ startClientX: number;
854
+ startClientY: number;
855
+ startPan: { x: number; y: number };
856
+ } | null>(null);
857
+ const [isPanning, setIsPanning] = useState(false);
858
+
859
+ // B2/B3 — the currently-active resize/move/rotate/spacing gesture, or
860
+ // `null` between gestures (see `GestureState`'s doc comment for why this is
861
+ // a SEPARATE ref from `dragRef`/A3's click-marquee tracking).
862
+ const gestureRef = useRef<GestureState | null>(null);
863
+ // Bumped on every `apply` so the box/handles/bands re-render against the
864
+ // LIVE rect `boxEdit.apply`'s preview mutation just produced (step 5).
865
+ const [, setGestureTick] = useState(0);
866
+
867
+ // D2.a — the drag-ghost rect (a semi-transparent follow-cursor copy of the
868
+ // dragged element, spec:399), live during EITHER a B2 move gesture or a
869
+ // D2.b reorder-preview drag — "dragging a selected element on the canvas"
870
+ // per the spec's own D2 framing. Purely visual: it never feeds a commit
871
+ // path itself (`computeDragGhost` is pure geometry — see its doc comment).
872
+ const [dragGhost, setDragGhost] = useState<Rect | null>(null);
873
+
874
+ // D1.b — the currently-engaged alignment/snap guide(s), live during EITHER
875
+ // a B2 move or resize gesture (spec §6 "guides appear only within the
876
+ // snap threshold; disappear when not snapping"). `[]` between gestures and
877
+ // whenever the live gesture's own edge-snap isn't currently engaged —
878
+ // never a stale guide left over from a moment ago.
879
+ const [snapGuides, setSnapGuides] = useState<SnapGuide[]>([]);
880
+
881
+ // D2.b — the live reorder-preview: which gap the pointer currently resolves
882
+ // to among the dragged node's siblings, or `null` between reorder drags.
883
+ // `reorderPreviewRef` mirrors the state for a synchronous read in
884
+ // `onPointerUp` (same "ref mirrors state" convention `textEditRef`/
885
+ // `marqueeRef` already use) — NOT tracked via `gestureRef`/`GestureState`:
886
+ // a reorder-preview has no `BoxEditProvider` begin/apply/end bracket, it is
887
+ // a single `structure.reorder` commit at drop, so it needs its own
888
+ // independent lifecycle (mirrors `GestureState`'s own doc comment on why
889
+ // IT is separate from `DragState`).
890
+ const reorderPreviewRef = useRef<{
891
+ id: string;
892
+ structure: StructureProvider;
893
+ gap: ReorderGap;
894
+ } | null>(null);
895
+ const [reorderPreview, setReorderPreviewState] = useState<{
896
+ id: string;
897
+ structure: StructureProvider;
898
+ gap: ReorderGap;
899
+ } | null>(null);
900
+ const setReorderPreview = useCallback(
901
+ (v: { id: string; structure: StructureProvider; gap: ReorderGap } | null) => {
902
+ reorderPreviewRef.current = v;
903
+ setReorderPreviewState(v);
904
+ },
905
+ [],
906
+ );
907
+
908
+ // D3.a — the open canvas right-click context menu, or `null`.
909
+ const [contextMenu, setContextMenu] = useState<WorldContextMenuState | null>(null);
910
+ // D3.b — the currently-open inline text editor (double-clicked node), or
911
+ // `null`. `textEditRef` mirrors the state for synchronous reads from
912
+ // callbacks (same "ref mirrors state" convention `marqueeRef`/`setMarquee`
913
+ // already use above).
914
+ const textEditRef = useRef<{ id: string; rect: DOMRectLike; initialText: string } | null>(null);
915
+ const [textEdit, setTextEditState] = useState<{
916
+ id: string;
917
+ rect: DOMRectLike;
918
+ initialText: string;
919
+ } | null>(null);
920
+ // D3.b — a double-click that was REFUSED (dynamic body/has children): no
921
+ // textarea, just a loud, honest "not editable" indicator at the rect,
922
+ // mirroring the store's existing dynamic-refusal pattern (console.warn +
923
+ // a visible degraded state, never a silent no-op).
924
+ const [textEditRefused, setTextEditRefused] = useState<{ id: string; rect: DOMRectLike } | null>(
925
+ null,
926
+ );
927
+ const setTextEdit = useCallback(
928
+ (v: { id: string; rect: DOMRectLike; initialText: string } | null) => {
929
+ textEditRef.current = v;
930
+ setTextEditState(v);
931
+ },
932
+ [],
933
+ );
934
+ // D3.R1 (reopen fix) — `TextProvider.set` is fire-and-forget (the write is an
935
+ // async round trip to the dev server); the textarea closes immediately on
936
+ // commit, so whether it landed or was refused can only be learned from a
937
+ // LATER re-render. Stash the committed id+rect here the instant `commitTextEdit`
938
+ // fires; the effect below consumes it on the next store notify (`editText`
939
+ // calls `store.notifyIngestEdit()` unconditionally on both the success and the
940
+ // refusal path) and checks `text.get` — which reports `null` for a refused id
941
+ // (via the adapter's own `dynamicPaths` mark) — to decide whether to surface
942
+ // the refused indicator instead of letting the edit silently vanish.
943
+ const pendingTextCommitRef = useRef<{ id: string; rect: DOMRectLike } | null>(null);
944
+
945
+ // Perf debt note (reviewer, D3 reopen review) — `emptyContainerHintsFor`
946
+ // walks every node in the active adapter's tree calling
947
+ // `getBoundingClientRect()` on each; it was previously recomputed on EVERY
948
+ // render, including the plain hover `pointermove` path (`setHoverId` is
949
+ // component-LOCAL state, so it re-renders this component without touching
950
+ // `store` at all). Cached here keyed on `[adapter, storeVersion]` — `store`
951
+ // only bumps `storeVersion` on an actual structural/selection/edit notify
952
+ // (`notifyIngestEdit`/`setSelection` etc.), never on a bare hover move — so
953
+ // a mousemove-only re-render now reuses the prior render's hints instead of
954
+ // re-walking the whole tree.
955
+ const emptyHintsCacheRef = useRef<{
956
+ adapter: AuthoringAdapter;
957
+ version: number;
958
+ hints: ReturnType<typeof emptyContainerHintsFor>;
959
+ } | null>(null);
960
+
961
+ // D3.R1 — consume a pending commit on the next store tick (see
962
+ // `pendingTextCommitRef` doc comment). Runs at most once per commit: it always
963
+ // clears the pending ref, whether or not the write was refused.
964
+ useEffect(() => {
965
+ const pending = pendingTextCommitRef.current;
966
+ if (!pending) return;
967
+ pendingTextCommitRef.current = null;
968
+ const active = getActiveAuthoring(store);
969
+ const provider = textForId(active, pending.id);
970
+ if (provider && provider.get(pending.id) === null) {
971
+ setTextEditRefused({ id: pending.id, rect: pending.rect });
972
+ }
973
+ // `storeVersion` is the only intentional trigger — `store` is stable and
974
+ // `pending` is read off the ref fresh each tick, per `useExhaustiveDependencies: off` (biome.json).
975
+ }, [storeVersion]);
976
+
977
+ // D3.R1 — the refused indicator is a TRANSIENT affordance, not a permanent
978
+ // mark: it must clear on a selection change (the user moved on to something
979
+ // else) or after a reasonable timeout (they didn't), so it never sticks
980
+ // forever the way it did pre-fix (only another double-click cleared it).
981
+ const textEditRefusedSelectionRef = useRef<string>('');
982
+ useEffect(() => {
983
+ const signature = [...store.selectedEntityIds].sort().join(',');
984
+ if (signature !== textEditRefusedSelectionRef.current) {
985
+ textEditRefusedSelectionRef.current = signature;
986
+ if (textEditRefused) setTextEditRefused(null);
987
+ }
988
+ });
989
+ useEffect(() => {
990
+ if (!textEditRefused) return;
991
+ const timer = window.setTimeout(() => setTextEditRefused(null), 4000);
992
+ return () => window.clearTimeout(timer);
993
+ }, [textEditRefused]);
994
+
995
+ // D3.d — the fallback eyedropper session (native EyeDropper needs none of
996
+ // this — it samples real rendered pixels itself). Re-renders whenever a
997
+ // session begins/ends so the cursor swatch appears/disappears immediately.
998
+ const eyedropperActive = useSyncExternalStore(
999
+ subscribeEyedropperSession,
1000
+ isEyedropperSessionActive,
1001
+ );
1002
+ const [eyedropperPreview, setEyedropperPreview] = useState<{
1003
+ x: number;
1004
+ y: number;
1005
+ color: string | null;
1006
+ } | null>(null);
1007
+
1008
+ const setMarquee = useCallback((r: Rect | null) => {
1009
+ marqueeRef.current = r;
1010
+ setMarqueeState(r);
1011
+ }, []);
1012
+
1013
+ const toHostLocal = useCallback((clientX: number, clientY: number): { x: number; y: number } => {
1014
+ const host = interactionRef.current?.getBoundingClientRect();
1015
+ return { x: clientX - (host?.left ?? 0), y: clientY - (host?.top ?? 0) };
1016
+ }, []);
1017
+
1018
+ const bumpGesture = useCallback(() => setGestureTick((t) => t + 1), []);
1019
+
1020
+ /** Common gesture-end path for `onPointerUp`/`onPointerCancel` (step 6):
1021
+ * `ownerBoxEdit.end(id)`, clear the ref, force one more re-render so the
1022
+ * handles snap to the FINAL committed rect. */
1023
+ const endGesture = useCallback((): boolean => {
1024
+ const gesture = gestureRef.current;
1025
+ if (!gesture) return false;
1026
+ gestureRef.current = null;
1027
+ gesture.ownerBoxEdit.end(gesture.id);
1028
+ // D2.a — the drag-ghost (if any) belonged to this gesture; clear it now
1029
+ // that the FINAL committed rect is what the box/handles snap back to.
1030
+ setDragGhost(null);
1031
+ // D1.b — likewise, any live alignment guide belonged to this gesture.
1032
+ setSnapGuides([]);
1033
+ bumpGesture();
1034
+ return true;
1035
+ }, [bumpGesture]);
1036
+
1037
+ const startResizeGesture = useCallback(
1038
+ (pos: HandlePos, e: ReactPointerEvent) => {
1039
+ e.stopPropagation();
1040
+ if (e.button !== 0) return;
1041
+ const active = getActiveAuthoring(store);
1042
+ const selected = store.selectedEntityIds;
1043
+ if (selected.size !== 1) return;
1044
+ const id = [...selected][0]!;
1045
+ const owner = boxEditForId(active, id);
1046
+ const rect = rectForId(active, id);
1047
+ if (!owner || !rect) return;
1048
+ const posValue = active.inspector?.get(id, 'style.position');
1049
+ owner.begin(id);
1050
+ gestureRef.current = {
1051
+ kind: 'resize',
1052
+ id,
1053
+ ownerBoxEdit: owner,
1054
+ startLocal: toHostLocal(e.clientX, e.clientY),
1055
+ origRect: rect,
1056
+ pos,
1057
+ isPositioned: posValue === 'absolute' || posValue === 'fixed',
1058
+ // D1.b — same `contextRectsForId` call the move gesture already
1059
+ // seeds `context` from (`resolveMoveCandidate`), so a resize handle
1060
+ // drag gets edge-snapping + guides too (spec §6's own instruction:
1061
+ // "wire the resize handle drag to computeSnapTargets too").
1062
+ context: contextRectsForId(active, id),
1063
+ };
1064
+ },
1065
+ [store, toHostLocal],
1066
+ );
1067
+
1068
+ const startRotateGesture = useCallback(
1069
+ (e: ReactPointerEvent) => {
1070
+ e.stopPropagation();
1071
+ if (e.button !== 0) return;
1072
+ const active = getActiveAuthoring(store);
1073
+ const selected = store.selectedEntityIds;
1074
+ if (selected.size !== 1) return;
1075
+ const id = [...selected][0]!;
1076
+ const owner = boxEditForId(active, id);
1077
+ const rect = rectForId(active, id);
1078
+ if (!owner || !rect) return;
1079
+ owner.begin(id);
1080
+ gestureRef.current = {
1081
+ kind: 'rotate',
1082
+ id,
1083
+ ownerBoxEdit: owner,
1084
+ startLocal: toHostLocal(e.clientX, e.clientY),
1085
+ origRect: rect,
1086
+ center: { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 },
1087
+ };
1088
+ },
1089
+ [store, toHostLocal],
1090
+ );
1091
+
1092
+ const startSpacingGesture = useCallback(
1093
+ (band: SpacingBand, e: ReactPointerEvent) => {
1094
+ e.stopPropagation();
1095
+ if (e.button !== 0) return;
1096
+ const active = getActiveAuthoring(store);
1097
+ const selected = store.selectedEntityIds;
1098
+ if (selected.size !== 1) return;
1099
+ const id = [...selected][0]!;
1100
+ const owner = boxEditForId(active, id);
1101
+ const rect = rectForId(active, id);
1102
+ if (!owner || !rect) return;
1103
+ owner.begin(id);
1104
+ gestureRef.current = {
1105
+ kind: 'spacing',
1106
+ id,
1107
+ ownerBoxEdit: owner,
1108
+ startLocal: toHostLocal(e.clientX, e.clientY),
1109
+ origRect: rect,
1110
+ side: band.side,
1111
+ origValue: band.value,
1112
+ };
1113
+ },
1114
+ [store, toHostLocal],
1115
+ );
1116
+
1117
+ /** Compute this gesture's patch from the current pointer position and apply
1118
+ * it live, bumping the re-render counter so box/handles/bands track the
1119
+ * preview. Shared by the active-gesture branch of `onPointerMove` and the
1120
+ * D2 threshold-promotion of a deferred move candidate. */
1121
+ const applyGesturePatch = useCallback(
1122
+ (gesture: GestureState, e: ReactPointerEvent<HTMLDivElement>) => {
1123
+ const local = toHostLocal(e.clientX, e.clientY);
1124
+ const dx = local.x - gesture.startLocal.x;
1125
+ const dy = local.y - gesture.startLocal.y;
1126
+ let patch: Record<string, number>;
1127
+ if (gesture.kind === 'resize') {
1128
+ patch = computeResizePatch(
1129
+ gesture.pos!,
1130
+ dx,
1131
+ dy,
1132
+ gesture.origRect,
1133
+ !!gesture.isPositioned,
1134
+ e.altKey,
1135
+ gesture.context,
1136
+ );
1137
+ // D1.b — the same edge-snap decision `computeResizePatch` just
1138
+ // applied to the patch, rendered as a guide (spec §6).
1139
+ setSnapGuides(
1140
+ computeResizeSnapGuides(
1141
+ gesture.pos!,
1142
+ dx,
1143
+ dy,
1144
+ gesture.origRect,
1145
+ e.altKey,
1146
+ gesture.context,
1147
+ ),
1148
+ );
1149
+ } else if (gesture.kind === 'move') {
1150
+ patch = computeMovePatch(dx, dy, gesture.origRect, e.altKey, gesture.context);
1151
+ // D2.a — the move gesture is "dragging a selected element on the
1152
+ // canvas" (spec:399); track the drag-ghost alongside the live
1153
+ // boxEdit preview. Resize/rotate/spacing gestures don't get a ghost
1154
+ // — the spec's D2 framing is specifically about MOVING an element.
1155
+ setDragGhost(computeDragGhost(gesture.origRect, dx, dy));
1156
+ // D1.b — same relationship as the resize branch above.
1157
+ setSnapGuides(computeMoveSnapGuides(dx, dy, gesture.origRect, e.altKey, gesture.context));
1158
+ } else if (gesture.kind === 'rotate') {
1159
+ patch = computeRotatePatch(gesture.center!, gesture.startLocal, local, e.altKey);
1160
+ } else {
1161
+ patch = computeSpacingPatch(gesture.side!, dx, dy, gesture.origValue ?? 0);
1162
+ }
1163
+ gesture.ownerBoxEdit.apply(gesture.id, patch);
1164
+ bumpGesture();
1165
+ },
1166
+ [toHostLocal, bumpGesture],
1167
+ );
1168
+
1169
+ // D3.d — sample the effective background color at a client point: pick the
1170
+ // topmost node (the SAME `pickTopmost` A3 already uses), route to ITS
1171
+ // owner's `colorSample` (owner-routed, not merged on the composite — see
1172
+ // `colorSampleForId`'s doc comment), then reduce the raw ancestor chain
1173
+ // through the pure `effectiveColorFromChain`. `null` when nothing is hit or
1174
+ // the owner has no `colorSample` — the caller renders the honest "unknown"
1175
+ // swatch state for that, never a fabricated color.
1176
+ const sampleColorAt = useCallback(
1177
+ (clientX: number, clientY: number): string | null => {
1178
+ const active = getActiveAuthoring(store);
1179
+ const hitId = pickTopmost(store, clientX, clientY);
1180
+ if (!hitId) return null;
1181
+ const chain = colorSampleForId(active, hitId)?.backgroundChainAt(clientX, clientY) ?? null;
1182
+ return chain ? effectiveColorFromChain(chain) : null;
1183
+ },
1184
+ [store],
1185
+ );
1186
+
1187
+ // D3.d — Escape cancels an in-flight fallback eyedropper session (never a
1188
+ // fabricated color — `resolveEyedropperSession(null)` is the honest
1189
+ // "cancelled" outcome the widget's `apply` callback already understands).
1190
+ useEffect(() => {
1191
+ if (!eyedropperActive) return;
1192
+ const onKeyDown = (e: KeyboardEvent): void => {
1193
+ if (e.key === 'Escape') resolveEyedropperSession(null);
1194
+ };
1195
+ window.addEventListener('keydown', onKeyDown);
1196
+ return () => window.removeEventListener('keydown', onKeyDown);
1197
+ }, [eyedropperActive]);
1198
+
1199
+ // D4 (spec27 §8 "space-pan" row) — the Space-held ARM SWITCH for the pan
1200
+ // gesture below (`onPointerDown`'s own early branch). A raw `window`
1201
+ // keydown/keyup pair, mounted once — NOT routed through `registerHotkeys`/
1202
+ // `editor-hotkeys.ts` (that registry fires once per keydown and has no
1203
+ // "held" concept; this mirrors the existing vertex-snap V-key pattern in
1204
+ // `registerEditorHotkeys` instead, which is the established precedent for a
1205
+ // literal key-HOLD state in this codebase). Matches `hotkeys.ts`'s own
1206
+ // not-typing guard exactly (input/textarea/select/contentEditable) so Space
1207
+ // still types a literal space character in any of those, never arming pan.
1208
+ // A focused `<button>` is ALSO excluded so Space keeps its native
1209
+ // accessible "activate the focused control" behavior instead of arming pan
1210
+ // out from under it. `e.repeat` is ignored so an OS key-repeat storm is a
1211
+ // single no-op transition, not thousands of redundant `preventDefault`s.
1212
+ useEffect(() => {
1213
+ const isTypingTarget = (t: EventTarget | null): boolean =>
1214
+ t instanceof HTMLInputElement ||
1215
+ t instanceof HTMLTextAreaElement ||
1216
+ t instanceof HTMLSelectElement ||
1217
+ (t instanceof HTMLElement && t.isContentEditable);
1218
+ const onKeyDown = (e: KeyboardEvent): void => {
1219
+ if (e.code !== 'Space' || e.repeat) return;
1220
+ if (isTypingTarget(e.target) || e.target instanceof HTMLButtonElement) return;
1221
+ // Space's native action is page-scroll (or activating a focused
1222
+ // button, excluded above) — the hold-to-pan gesture replaces it, so
1223
+ // suppress the default here rather than fighting a scrolled page mid-drag.
1224
+ e.preventDefault();
1225
+ spaceHeldRef.current = true;
1226
+ setSpaceHeld(true);
1227
+ };
1228
+ const onKeyUp = (e: KeyboardEvent): void => {
1229
+ if (e.code !== 'Space') return;
1230
+ spaceHeldRef.current = false;
1231
+ setSpaceHeld(false);
1232
+ // Releasing Space mid-drag ends the pan exactly like a pointerup would
1233
+ // (see `onPointerUp`'s own early branch) — the pointer may still be
1234
+ // down, but with the arm switch off a further move must fall through
1235
+ // to ordinary hover/drag handling, never keep panning.
1236
+ if (panDragRef.current) {
1237
+ panDragRef.current = null;
1238
+ setIsPanning(false);
1239
+ }
1240
+ };
1241
+ window.addEventListener('keydown', onKeyDown);
1242
+ window.addEventListener('keyup', onKeyUp);
1243
+ return () => {
1244
+ window.removeEventListener('keydown', onKeyDown);
1245
+ window.removeEventListener('keyup', onKeyUp);
1246
+ };
1247
+ }, []);
1248
+
1249
+ const onPointerDown = useCallback(
1250
+ (e: ReactPointerEvent<HTMLDivElement>) => {
1251
+ if (e.button !== 0) return;
1252
+ // D4 (spec27 §8 "space-pan" row) — a press while Space is held ARMS a
1253
+ // pan-drag instead of the ordinary pick/select/marquee/eyedropper flow
1254
+ // below, which it skips entirely (never fights another gesture for the
1255
+ // same pointer session — same precedent `endGesture`/`reorderPreview`'s
1256
+ // early returns in `onPointerUp` already set). Seeds from the CURRENT
1257
+ // shared pan (`getWorldPan()`) so the drag delta composes onto whatever
1258
+ // pan was already in effect.
1259
+ if (spaceHeldRef.current) {
1260
+ panDragRef.current = {
1261
+ startClientX: e.clientX,
1262
+ startClientY: e.clientY,
1263
+ startPan: getWorldPan(),
1264
+ };
1265
+ setIsPanning(true);
1266
+ return;
1267
+ }
1268
+ // D3.d — a click while an eyedropper session is open SAMPLES AND ENDS
1269
+ // the session instead of the ordinary click-select flow below (which it
1270
+ // deliberately skips entirely — picking a color is not a selection
1271
+ // gesture).
1272
+ if (eyedropperActive) {
1273
+ resolveEyedropperSession(sampleColorAt(e.clientX, e.clientY));
1274
+ setEyedropperPreview(null);
1275
+ return;
1276
+ }
1277
+ const hitAtDown = pickTopmost(store, e.clientX, e.clientY);
1278
+
1279
+ // D2 (spec:322): a press on an ALREADY-SELECTED, boxEdit-capable,
1280
+ // absolutely/fixed-positioned node RECORDS a move CANDIDATE — it does NOT
1281
+ // begin the gesture here. Promotion waits for the drag threshold
1282
+ // (`onPointerMove`), so a plain click (or shift-click) still falls
1283
+ // through to `resolveClickSelection` on pointerup (multi-select narrow /
1284
+ // shift-toggle preserved). A non-positioned node, or a hit that isn't yet
1285
+ // selected, records no candidate and keeps today's click-select behavior.
1286
+ const moveCandidate: DragState['moveCandidate'] =
1287
+ hitAtDown !== null && store.selectedEntityIds.has(hitAtDown)
1288
+ ? resolveMoveCandidate(getActiveAuthoring(store), hitAtDown)
1289
+ : null;
1290
+
1291
+ // D2.b: the SAME "already selected" gate, checked ONLY when there's no
1292
+ // move candidate — a positioned node moves (writes x/y); a flow/list
1293
+ // child (never absolute/fixed, so `moveCandidate` above is always
1294
+ // `null` for it) reorders instead (writes source order via
1295
+ // `structure.reorder`). The two are mutually exclusive per press.
1296
+ const reorderCandidate: DragState['reorderCandidate'] =
1297
+ !moveCandidate && hitAtDown !== null && store.selectedEntityIds.has(hitAtDown)
1298
+ ? resolveReorderCandidate(getActiveAuthoring(store), hitAtDown)
1299
+ : null;
1300
+
1301
+ dragRef.current = {
1302
+ downClientX: e.clientX,
1303
+ downClientY: e.clientY,
1304
+ downLocal: toHostLocal(e.clientX, e.clientY),
1305
+ hitAtDown,
1306
+ moved: false,
1307
+ moveCandidate,
1308
+ reorderCandidate,
1309
+ };
1310
+ setHoverId(null);
1311
+ setMarquee(null);
1312
+ },
1313
+ [store, toHostLocal, setMarquee, eyedropperActive, sampleColorAt],
1314
+ );
1315
+
1316
+ /** The active-drag half of `onPointerMove` (drag-threshold promotion +
1317
+ * move-gesture promotion + marquee update) — factored into its OWN
1318
+ * `useCallback` purely to keep `onPointerMove` itself under the
1319
+ * cognitive-complexity ceiling; behavior is unchanged. */
1320
+ const handleActiveDrag = useCallback(
1321
+ (drag: DragState, e: ReactPointerEvent<HTMLDivElement>) => {
1322
+ const dx = e.clientX - drag.downClientX;
1323
+ const dy = e.clientY - drag.downClientY;
1324
+ if (!drag.moved && Math.hypot(dx, dy) > DRAG_THRESHOLD_PX) drag.moved = true;
1325
+ if (!drag.moved) return;
1326
+
1327
+ // D2: threshold crossed with a deferred move candidate — promote it to a
1328
+ // live move gesture NOW (not at pointerdown).
1329
+ if (drag.moveCandidate) {
1330
+ const g = promoteMoveCandidate(drag.moveCandidate, drag.downLocal);
1331
+ gestureRef.current = g;
1332
+ applyGesturePatch(g, e);
1333
+ return;
1334
+ }
1335
+ // D2.b: threshold crossed with a deferred reorder candidate — compute
1336
+ // the live insertion gap for the CURRENT pointer position and update
1337
+ // the preview (+ its drag-ghost). This is NOT a `GestureState` (no
1338
+ // `boxEdit` bracket — see `reorderPreviewRef`'s doc comment) so it has
1339
+ // no `promote*` step; every move while it's live just recomputes the
1340
+ // gap. `gap` can be `null` only when `siblingRects` is somehow empty
1341
+ // (the capability gate above should make this unreachable) — in that
1342
+ // case this deliberately falls through to NEITHER a preview NOR the
1343
+ // marquee branch below (guarded by the `return`), matching "does not
1344
+ // trigger marquee" for a reorder-candidate drag regardless.
1345
+ if (drag.reorderCandidate) {
1346
+ const local = toHostLocal(e.clientX, e.clientY);
1347
+ const gap = computeReorderGap(drag.reorderCandidate.siblingRects, local);
1348
+ if (gap) {
1349
+ setReorderPreview({
1350
+ id: drag.reorderCandidate.id,
1351
+ structure: drag.reorderCandidate.structure,
1352
+ gap,
1353
+ });
1354
+ setDragGhost(
1355
+ computeDragGhost(
1356
+ drag.reorderCandidate.origRect,
1357
+ local.x - drag.downLocal.x,
1358
+ local.y - drag.downLocal.y,
1359
+ ),
1360
+ );
1361
+ }
1362
+ return;
1363
+ }
1364
+ // Marquee only starts when the drag began over EMPTY space (no pick at
1365
+ // pointerdown) — a drag on a NON-candidate hit (unselected, or a
1366
+ // non-positioned node) is today's plain click-select, no marquee.
1367
+ if (drag.hitAtDown === null) {
1368
+ const local = toHostLocal(e.clientX, e.clientY);
1369
+ setMarquee({
1370
+ x: Math.min(drag.downLocal.x, local.x),
1371
+ y: Math.min(drag.downLocal.y, local.y),
1372
+ width: Math.abs(local.x - drag.downLocal.x),
1373
+ height: Math.abs(local.y - drag.downLocal.y),
1374
+ });
1375
+ }
1376
+ },
1377
+ [toHostLocal, setMarquee, applyGesturePatch, setReorderPreview],
1378
+ );
1379
+
1380
+ const onPointerMove = useCallback(
1381
+ (e: ReactPointerEvent<HTMLDivElement>) => {
1382
+ // D4 — an armed pan-drag just writes the shared pan translate from the
1383
+ // client-space delta since pointerdown (client px, NOT `toHostLocal` —
1384
+ // the pan target IS the frame `toHostLocal` reads through, so this must
1385
+ // stay in the frame-independent client space); nothing else runs
1386
+ // underneath it.
1387
+ const panDrag = panDragRef.current;
1388
+ if (panDrag) {
1389
+ setWorldPan(
1390
+ panDrag.startPan.x + (e.clientX - panDrag.startClientX),
1391
+ panDrag.startPan.y + (e.clientY - panDrag.startClientY),
1392
+ );
1393
+ return;
1394
+ }
1395
+ // D3.d — while an eyedropper session is open, every move just updates
1396
+ // the cursor-following preview swatch; no hover/drag/gesture logic runs
1397
+ // underneath it (a real color-at-point pick, not a selection gesture).
1398
+ if (eyedropperActive) {
1399
+ const local = toHostLocal(e.clientX, e.clientY);
1400
+ setEyedropperPreview({ ...local, color: sampleColorAt(e.clientX, e.clientY) });
1401
+ return;
1402
+ }
1403
+ const gesture = gestureRef.current;
1404
+ if (gesture) {
1405
+ applyGesturePatch(gesture, e);
1406
+ return;
1407
+ }
1408
+ // No button held — plain hover: pick under the cursor, draw a highlight.
1409
+ if ((e.buttons & 1) === 0) {
1410
+ setHoverId(pickTopmost(store, e.clientX, e.clientY));
1411
+ return;
1412
+ }
1413
+ const drag = dragRef.current;
1414
+ if (!drag) return;
1415
+ handleActiveDrag(drag, e);
1416
+ },
1417
+ [store, toHostLocal, applyGesturePatch, eyedropperActive, sampleColorAt, handleActiveDrag],
1418
+ );
1419
+
1420
+ const onPointerUp = useCallback(
1421
+ (e: ReactPointerEvent<HTMLDivElement>) => {
1422
+ // D4 — releasing the pointer ends an armed pan-drag here, same early-
1423
+ // return precedent as `endGesture()`/the reorder-preview branch below:
1424
+ // a pan-drag never falls through to click-select.
1425
+ if (panDragRef.current) {
1426
+ panDragRef.current = null;
1427
+ setIsPanning(false);
1428
+ return;
1429
+ }
1430
+ // A gesture in progress commits+ends here and skips the ordinary
1431
+ // click-select logic below entirely (step 6).
1432
+ if (endGesture()) return;
1433
+ // D2.b — a live reorder-preview commits here: ONE `structure.reorder`
1434
+ // call (on-contract since T0/D-1, so this lands on the existing
1435
+ // checksum-guarded whole-file undo/redo timeline — the same "one undo
1436
+ // step" property every other structural op in this file already has),
1437
+ // then clears the preview + ghost + drag state, skipping click-select
1438
+ // entirely (mirrors `endGesture`'s own early return above).
1439
+ const reorderPreviewNow = reorderPreviewRef.current;
1440
+ if (reorderPreviewNow) {
1441
+ setReorderPreview(null);
1442
+ setDragGhost(null);
1443
+ dragRef.current = null;
1444
+ reorderPreviewNow.structure.reorder?.(
1445
+ reorderPreviewNow.id,
1446
+ reorderPreviewNow.gap.beforeSiblingId,
1447
+ );
1448
+ return;
1449
+ }
1450
+ const drag = dragRef.current;
1451
+ dragRef.current = null;
1452
+ if (!drag) return;
1453
+ const active = getActiveAuthoring(store);
1454
+
1455
+ const nextSelection =
1456
+ drag.hitAtDown !== null
1457
+ ? resolveClickSelection(store.selectedEntityIds, drag.hitAtDown, e.shiftKey)
1458
+ : resolveEmptySpaceSelection(active, drag.moved, marqueeRef.current, e.shiftKey);
1459
+ if (nextSelection !== null) active.selection?.set(nextSelection);
1460
+ setMarquee(null);
1461
+ },
1462
+ [store, setMarquee, endGesture, setReorderPreview],
1463
+ );
1464
+
1465
+ const onPointerCancel = useCallback(() => {
1466
+ // D4 — a cancelled pan-drag (e.g. pointer capture lost) just disarms;
1467
+ // there is nothing to commit (the pan writes were already live, same as
1468
+ // every other gesture's `apply` — cancelling a pan simply stops moving it
1469
+ // further, it does not revert, matching resize/move's own no-revert
1470
+ // cancel semantics below).
1471
+ if (panDragRef.current) {
1472
+ panDragRef.current = null;
1473
+ setIsPanning(false);
1474
+ return;
1475
+ }
1476
+ if (endGesture()) return;
1477
+ // D2.b — a CANCELLED reorder drag (e.g. pointer capture lost) clears the
1478
+ // preview/ghost WITHOUT committing — unlike `onPointerUp` above, this is
1479
+ // never a "drop", so no `structure.reorder` call here.
1480
+ if (reorderPreviewRef.current) {
1481
+ setReorderPreview(null);
1482
+ setDragGhost(null);
1483
+ }
1484
+ dragRef.current = null;
1485
+ setMarquee(null);
1486
+ }, [endGesture, setMarquee, setReorderPreview]);
1487
+
1488
+ const onPointerLeave = useCallback(() => {
1489
+ setHoverId(null);
1490
+ }, []);
1491
+
1492
+ // D3.a — right-click picks the node (rule zero: through `pickTopmost`, the
1493
+ // SAME primitive A3's click-select uses — never `elementFromPoint`) and
1494
+ // opens the canvas context menu at the click point. A miss (empty space)
1495
+ // opens nothing.
1496
+ const onContextMenu = useCallback(
1497
+ (e: ReactMouseEvent<HTMLDivElement>) => {
1498
+ e.preventDefault();
1499
+ const hitId = pickTopmost(store, e.clientX, e.clientY);
1500
+ if (!hitId) {
1501
+ setContextMenu(null);
1502
+ return;
1503
+ }
1504
+ setContextMenu({ x: e.clientX, y: e.clientY, nodeId: hitId });
1505
+ },
1506
+ [store],
1507
+ );
1508
+
1509
+ // D3.b — double-click picks the node and opens the inline text editor
1510
+ // through `adapter.text` (owner-routed — `textForId`), rule zero: no DOM
1511
+ // read of the node's own text happens here, only the contract's own
1512
+ // `TextProvider.get`. Absent `text` (the ingested react-dom adapter has
1513
+ // none) ⇒ silently nothing, matching D3.a's identical capability-gating
1514
+ // stance for structural items. A `null` get (dynamic body / has child
1515
+ // elements) is a LOUD, honest refusal — no textarea, a visible indicator +
1516
+ // a console.warn, mirroring the store's existing dynamic-write-refusal
1517
+ // pattern (`ReactWorldAuthoringAdapter`'s own `console.warn` + re-render).
1518
+ const onDoubleClick = useCallback(
1519
+ (e: ReactMouseEvent<HTMLDivElement>) => {
1520
+ const active = getActiveAuthoring(store);
1521
+ const hitId = pickTopmost(store, e.clientX, e.clientY);
1522
+ if (!hitId) return;
1523
+ const provider = textForId(active, hitId);
1524
+ if (!provider) return;
1525
+ const rect = rectForId(active, hitId);
1526
+ if (!rect) return;
1527
+ const text = provider.get(hitId);
1528
+ if (text === null) {
1529
+ // biome-ignore lint/suspicious/noConsole: deliberate, greppable refusal report — mirrors ReactWorldAuthoringAdapter's own console.warn refusal pattern
1530
+ console.warn(
1531
+ `[WorldSelectionOverlay] text edit refused for "${hitId}": dynamic body or has ` +
1532
+ 'child elements — not inline-editable.',
1533
+ );
1534
+ setTextEdit(null);
1535
+ setTextEditRefused({ id: hitId, rect });
1536
+ return;
1537
+ }
1538
+ setTextEditRefused(null);
1539
+ setTextEdit({ id: hitId, rect, initialText: text });
1540
+ },
1541
+ [store, setTextEdit],
1542
+ );
1543
+
1544
+ const commitTextEdit = useCallback(
1545
+ (text: string) => {
1546
+ const edit = textEditRef.current;
1547
+ if (!edit) return;
1548
+ const active = getActiveAuthoring(store);
1549
+ const provider = textForId(active, edit.id);
1550
+ if (provider) {
1551
+ // D3.R1 (reopen fix) — `set` is fire-and-forget; stash id+rect so the
1552
+ // pending-commit effect above can surface the refused indicator once
1553
+ // the async write's own refusal-notify lands, instead of the edit
1554
+ // silently vanishing (the pre-fix bug: this closed the textarea
1555
+ // unconditionally and never checked the result at all).
1556
+ pendingTextCommitRef.current = { id: edit.id, rect: edit.rect };
1557
+ provider.set(edit.id, text);
1558
+ }
1559
+ setTextEdit(null);
1560
+ },
1561
+ [store, setTextEdit],
1562
+ );
1563
+
1564
+ const cancelTextEdit = useCallback(() => setTextEdit(null), [setTextEdit]);
1565
+
1566
+ const adapter = getActiveAuthoring(store);
1567
+ if (!hasRectCapableChild(adapter)) return null;
1568
+ // The overlay is a DESIGN-TIME affordance and must be INERT whenever a real
1569
+ // game owns its own DOM/canvas and needs pointer events — Play, an ingested
1570
+ // unmodified game, or an auto-mounted `{ module }` adapter world — so this
1571
+ // component's full-cover `pointerEvents:'auto'` interaction layer never
1572
+ // swallows that game's own clicks (that regressed 20-ingest-react-rpg: the
1573
+ // menu→dungeon flow never advanced, the sprite never moved).
1574
+ //
1575
+ // Gate on the SAME "is a session like that actually active" predicates
1576
+ // `isPlayModeActive`/`isIngestActive`/`isModuleModeActive` already expose
1577
+ // (play-mode.ts / ingest-mode.ts / module-mode.ts) — NOT the coarser
1578
+ // `store.playState !== 'stopped'` flag a prior fix used here. That flag is
1579
+ // shared, multi-purpose editor state (it also drives tab-switching,
1580
+ // autosave suppression, etc.) and is `'playing'` for the ENTIRE lifetime of
1581
+ // those sessions, exactly like these predicates — so swapping in the
1582
+ // narrower, purpose-built signal changes nothing for the cases the prior
1583
+ // fix targeted. What it fixes: `mountDesignTimeLayers` (B1) mounts a LIVE,
1584
+ // editable `ReactWorldAuthoringAdapter` for every non-focused react/pixi
1585
+ // world while the editor sits in ordinary stopped design-time editing
1586
+ // (27a's own scenario: select a react element, edit it, watch HMR settle)
1587
+ // — a session `playState` alone cannot distinguish from "a game is
1588
+ // running" the moment anything else in this growing multi-purpose flag's
1589
+ // surface touches it. Routing through the actual session predicates keeps
1590
+ // this component's own inertness tied to the ONE thing it actually cares
1591
+ // about (a foreign/first-party game owning the canvas), decoupled from
1592
+ // every other reason `playState` might change.
1593
+ if (isCanvasOwnedByARunningGame()) return null;
1594
+
1595
+ const selectedIds = store.selectedEntityIds;
1596
+ const hoverRect =
1597
+ hoverId !== null && !selectedIds.has(hoverId) ? rectForId(adapter, hoverId) : null;
1598
+
1599
+ // B2/B3 — handles/rotate/dimension-label/spacing-bands render ONLY for
1600
+ // EXACTLY one selected node whose OWNER exposes `boxEdit` (spec:317 "render
1601
+ // ONLY when EXACTLY one node is selected AND its owner has boxEdit" —
1602
+ // multi-select keeps just the plain A3 boxes above). Computed by a
1603
+ // module-level helper purely to keep THIS function's own cognitive
1604
+ // complexity down.
1605
+ const single = computeSingleSelectionBoxEdit(adapter, selectedIds);
1606
+ const singleRect = single?.rect ?? null;
1607
+ const singleOwnerBoxEdit = single?.ownerBoxEdit ?? null;
1608
+ const spacingBands = single?.spacingBands ?? [];
1609
+
1610
+ // D1 — measure lines, position/parent-layout badge, parent-container
1611
+ // outline (all READ-only — spec §6). Computed by a module-level helper,
1612
+ // same "keep THIS function's own cognitive complexity down" reason
1613
+ // `computeSingleSelectionBoxEdit` above is factored out.
1614
+ const d1 = computeD1Affordances(adapter, selectedIds, hoverRect);
1615
+
1616
+ // D4.b — the multi-select align/distribute toolbar's entries, or `null`
1617
+ // when it shouldn't render (see `computeMultiSelectionAlign`'s doc
1618
+ // comment for the capability gate).
1619
+ const alignEntries = computeMultiSelectionAlign(adapter, selectedIds);
1620
+
1621
+ // D3.c — every currently-empty container hint reachable from the active
1622
+ // adapter (owner-routed per child, session-hidden worlds excluded — see
1623
+ // `emptyContainerHintsFor`'s doc comment). Cached (see `emptyHintsCacheRef`
1624
+ // above) so a hover-only re-render reuses the last computed hints instead
1625
+ // of re-walking + re-measuring the whole tree.
1626
+ const emptyHintsCache = emptyHintsCacheRef.current;
1627
+ const emptyHints =
1628
+ emptyHintsCache &&
1629
+ emptyHintsCache.adapter === adapter &&
1630
+ emptyHintsCache.version === storeVersion
1631
+ ? emptyHintsCache.hints
1632
+ : (() => {
1633
+ const hints = emptyContainerHintsFor(adapter);
1634
+ emptyHintsCacheRef.current = { adapter, version: storeVersion, hints };
1635
+ return hints;
1636
+ })();
1637
+
1638
+ return (
1639
+ <div
1640
+ style={{
1641
+ position: 'absolute',
1642
+ inset: 0,
1643
+ zIndex: Z_INDEX,
1644
+ pointerEvents: 'none',
1645
+ // D4 (spec27 §8 "space-pan" row) — the lockstep half of the pan
1646
+ // feature: THIS wrapper is the sibling `world-pan-state.ts`'s doc
1647
+ // comment describes, co-located with every design-time world-layer
1648
+ // host inside the SAME `ViewportPanel` containerRef parent. Every
1649
+ // descendant below (including `interactionRef`, also `inset:0`)
1650
+ // inherits this transform, so the whole overlay pans as one rigid
1651
+ // unit with the world content it describes — `undefined` at rest
1652
+ // (pan=0,0) keeps this byte-identical to pre-pan markup.
1653
+ transform: panTransformValue(pan),
1654
+ }}
1655
+ >
1656
+ <div
1657
+ ref={interactionRef}
1658
+ data-testid="world-selection-overlay-interaction"
1659
+ style={{
1660
+ position: 'absolute',
1661
+ inset: 0,
1662
+ pointerEvents: 'auto',
1663
+ cursor: interactionCursor(isPanning, spaceHeld, eyedropperActive, !!marquee),
1664
+ }}
1665
+ onPointerDown={onPointerDown}
1666
+ onPointerMove={onPointerMove}
1667
+ onPointerUp={onPointerUp}
1668
+ onPointerCancel={onPointerCancel}
1669
+ onPointerLeave={onPointerLeave}
1670
+ onContextMenu={onContextMenu}
1671
+ onDoubleClick={onDoubleClick}
1672
+ />
1673
+ {/* D3.c — dashed empty-container placeholders, visual hints only. */}
1674
+ {emptyHints.map((hint) => (
1675
+ <div
1676
+ key={hint.id}
1677
+ data-testid="world-empty-container-hint"
1678
+ data-entity-id={hint.id}
1679
+ title={hint.displayName}
1680
+ style={{
1681
+ position: 'absolute',
1682
+ left: hint.rect.x,
1683
+ top: hint.rect.y,
1684
+ width: hint.rect.width,
1685
+ height: hint.rect.height,
1686
+ border: '1px dashed rgba(154, 160, 166, 0.7)',
1687
+ boxSizing: 'border-box',
1688
+ pointerEvents: 'none',
1689
+ }}
1690
+ />
1691
+ ))}
1692
+ {hoverRect && <div data-testid="world-selection-hover" style={boxStyle(hoverRect, false)} />}
1693
+ {[...selectedIds].map((id) => {
1694
+ const r = rectForId(adapter, id);
1695
+ if (!r) return null;
1696
+ return (
1697
+ <div key={id}>
1698
+ <div data-testid="world-selection-box" data-entity-id={id} style={boxStyle(r, true)} />
1699
+ <div data-testid="world-selection-label" style={labelStyle(r)}>
1700
+ {labelForId(adapter, id)}
1701
+ </div>
1702
+ </div>
1703
+ );
1704
+ })}
1705
+ {marquee && (
1706
+ <div
1707
+ data-testid="world-selection-marquee"
1708
+ style={{
1709
+ position: 'absolute',
1710
+ left: marquee.x,
1711
+ top: marquee.y,
1712
+ width: marquee.width,
1713
+ height: marquee.height,
1714
+ border: `1px solid ${ACCENT}`,
1715
+ background: 'rgba(13, 153, 255, 0.1)',
1716
+ pointerEvents: 'none',
1717
+ }}
1718
+ />
1719
+ )}
1720
+ <D1Affordances
1721
+ measureLines={d1.measureLines}
1722
+ snapGuides={snapGuides}
1723
+ positionBadge={d1.positionBadge}
1724
+ showPositionBadge={d1.showPositionBadge}
1725
+ badgeAnchorRect={d1.selectedSingleRect}
1726
+ parentRect={d1.parentRect}
1727
+ />
1728
+ <D2Affordances dragGhost={dragGhost} reorderPreview={reorderPreview} />
1729
+ {alignEntries && <AlignToolbar adapter={adapter} entries={alignEntries} />}
1730
+ {singleRect && singleOwnerBoxEdit && (
1731
+ <>
1732
+ {/* B-fix (D1-visual + D4 review): the B3 spacing bands render
1733
+ * BEFORE the resize handles below — deliberately, so the handles
1734
+ * paint ON TOP wherever their small hit box overlaps a band. A
1735
+ * zero-value side still renders a `ZERO_STRIP_PX` grab-strip
1736
+ * (spec B3) flush against the rect edge, and a mid-edge handle's
1737
+ * clickable CENTER point sits exactly on that same edge — pre-fix,
1738
+ * the bands rendered AFTER the handles (reverse of this order) and
1739
+ * so sat on top in DOM/paint order, silently eating the
1740
+ * pointerdown meant for the handle (n/s/e/w all affected; the
1741
+ * corner handles were not — a corner's point always lands on the
1742
+ * EXCLUSIVE far edge of its two adjacent bands). This is a pure
1743
+ * DOM-order swap: the band geometry/hit area is UNCHANGED, so
1744
+ * every band remains fully draggable everywhere it isn't directly
1745
+ * shadowed by a handle's own small box (`27b-handles.spec.ts`'s
1746
+ * B3 coexistence regression + `27b-spacing.spec.ts` both cover
1747
+ * this). */}
1748
+ {spacingBands.map((band) => (
1749
+ <div key={band.side}>
1750
+ <div
1751
+ data-testid={`world-spacing-${band.kind}-${spacingDirection(band.side)}`}
1752
+ style={spacingBandStyle(band)}
1753
+ onPointerDown={(e) => startSpacingGesture(band, e)}
1754
+ onPointerMove={onPointerMove}
1755
+ onPointerUp={onPointerUp}
1756
+ onPointerCancel={onPointerCancel}
1757
+ />
1758
+ {!band.isZero && <div style={spacingLabelStyle(band)}>{Math.round(band.value)}</div>}
1759
+ </div>
1760
+ ))}
1761
+ {RESIZE_HANDLES.map(({ pos, cursor }) => (
1762
+ <div
1763
+ key={pos}
1764
+ data-testid={`world-resize-handle-${pos}`}
1765
+ style={handleStyle(handlePosition(singleRect, pos), cursor)}
1766
+ onPointerDown={(e) => startResizeGesture(pos, e)}
1767
+ onPointerMove={onPointerMove}
1768
+ onPointerUp={onPointerUp}
1769
+ onPointerCancel={onPointerCancel}
1770
+ />
1771
+ ))}
1772
+ <div data-testid="world-resize-dimension-label" style={dimensionLabelStyle(singleRect)}>
1773
+ {`${Math.round(singleRect.width)}×${Math.round(singleRect.height)}`}
1774
+ </div>
1775
+ <div
1776
+ data-testid="world-rotate-handle"
1777
+ style={rotateHandleStyle(singleRect)}
1778
+ onPointerDown={startRotateGesture}
1779
+ onPointerMove={onPointerMove}
1780
+ onPointerUp={onPointerUp}
1781
+ onPointerCancel={onPointerCancel}
1782
+ />
1783
+ </>
1784
+ )}
1785
+ <D3Affordances
1786
+ adapter={adapter}
1787
+ contextMenu={contextMenu}
1788
+ onCloseContextMenu={() => setContextMenu(null)}
1789
+ textEdit={textEdit}
1790
+ onCommitTextEdit={commitTextEdit}
1791
+ onCancelTextEdit={cancelTextEdit}
1792
+ textEditRefused={textEditRefused}
1793
+ eyedropperActive={eyedropperActive}
1794
+ eyedropperPreview={eyedropperPreview}
1795
+ />
1796
+ </div>
1797
+ );
1798
+ }
1799
+
1800
+ /**
1801
+ * D2Affordances — the D2.a drag-ghost + D2.b reorder-preview marker,
1802
+ * factored into its OWN component for the SAME reason `D3Affordances` below
1803
+ * is: keeps `WorldSelectionOverlay`'s own cognitive complexity down (two
1804
+ * independent conditional renders, bundled here at zero cost to the parent
1805
+ * beyond one element reference). No behavior change from having them inline.
1806
+ */
1807
+ /**
1808
+ * D1Affordances — the D1 measurement + snap-guide + badge + parent-outline
1809
+ * overlay bits, factored into its OWN component for the SAME reason
1810
+ * `D2Affordances`/`D3Affordances` are (keeps `WorldSelectionOverlay`'s own
1811
+ * cognitive complexity down; no behavior change from having them inline).
1812
+ * All four are READ-only visual affordances (spec §6 D1) — none of them
1813
+ * drives a commit path.
1814
+ */
1815
+ function D1Affordances(props: {
1816
+ measureLines: MeasureLine[];
1817
+ snapGuides: SnapGuide[];
1818
+ positionBadge: PositionBadgeInfo | null;
1819
+ showPositionBadge: boolean;
1820
+ badgeAnchorRect: DOMRectLike | null;
1821
+ parentRect: DOMRectLike | null;
1822
+ }): React.ReactNode {
1823
+ const {
1824
+ measureLines,
1825
+ snapGuides,
1826
+ positionBadge,
1827
+ showPositionBadge,
1828
+ badgeAnchorRect,
1829
+ parentRect,
1830
+ } = props;
1831
+ return (
1832
+ <>
1833
+ {/* D1.c — the parent-container outline, drawn BEHIND the selection/
1834
+ hover boxes (DOM order) so it never visually competes with them. */}
1835
+ {parentRect && (
1836
+ <div data-testid="world-parent-outline" style={parentOutlineStyle(parentRect)} />
1837
+ )}
1838
+ {/* D1.a — sibling-distance measure line(s) between the selection and
1839
+ the hovered sibling, each with two end-caps + a px value label. */}
1840
+ {measureLines.map((line) => (
1841
+ <div key={line.orientation} data-testid="world-measure-line">
1842
+ <div style={measureLineStyle(line)} />
1843
+ <div style={measureCapStyle(line.x1, line.y1, line.orientation === 'horizontal')} />
1844
+ <div style={measureCapStyle(line.x2, line.y2, line.orientation === 'horizontal')} />
1845
+ <div data-testid="world-measure-line-label" style={measureLabelStyle(line)}>
1846
+ {line.distance}
1847
+ </div>
1848
+ </div>
1849
+ ))}
1850
+ {/* D1.b — the currently-engaged alignment/snap guide(s), live only
1851
+ while a B2 move/resize gesture's own edge-snap is engaged. */}
1852
+ {snapGuides.map((guide) => (
1853
+ // At most ONE guide per orientation (`computeMoveSnapGuides`/
1854
+ // `computeResizeSnapGuides` each push ≤1 vertical + ≤1 horizontal),
1855
+ // so `orientation` alone is a stable, unique key — no array index.
1856
+ <div key={guide.orientation} data-testid="world-snap-guide" style={snapGuideStyle(guide)} />
1857
+ ))}
1858
+ {/* D1.c — the position/parent-layout badge; each segment renders only
1859
+ when its own value resolved (never a fabricated default). */}
1860
+ {showPositionBadge && badgeAnchorRect && positionBadge && (
1861
+ <div data-testid="world-position-badge" style={positionBadgeStyle(badgeAnchorRect)}>
1862
+ {positionBadge.position && (
1863
+ <span data-testid="world-position-badge-mode" style={BADGE_SEGMENT_STYLE}>
1864
+ {positionBadge.position}
1865
+ </span>
1866
+ )}
1867
+ {positionBadge.parentLayout && (
1868
+ <span data-testid="world-position-badge-layout" style={BADGE_SEGMENT_STYLE}>
1869
+ {positionBadge.parentLayout}
1870
+ </span>
1871
+ )}
1872
+ </div>
1873
+ )}
1874
+ </>
1875
+ );
1876
+ }
1877
+
1878
+ function D2Affordances(props: {
1879
+ dragGhost: Rect | null;
1880
+ reorderPreview: { gap: ReorderGap } | null;
1881
+ }): React.ReactNode {
1882
+ const { dragGhost, reorderPreview } = props;
1883
+ return (
1884
+ <>
1885
+ {/* D2.a — the drag-ghost, live during a B2 move gesture OR a D2.b
1886
+ reorder-preview drag. Pure visual, never a commit path. */}
1887
+ {dragGhost && <div data-testid="world-drag-ghost" style={dragGhostStyle(dragGhost)} />}
1888
+ {/* D2.b — the live reorder-preview marker at the gap the drop would
1889
+ land in. */}
1890
+ {reorderPreview && (
1891
+ <div
1892
+ data-testid="world-reorder-preview"
1893
+ style={reorderMarkerStyle(reorderPreview.gap.marker)}
1894
+ />
1895
+ )}
1896
+ </>
1897
+ );
1898
+ }
1899
+
1900
+ /**
1901
+ * D3Affordances — the D3.a/b/d overlay bits (context menu, inline text
1902
+ * editor + its refusal indicator, eyedropper swatch), factored into its OWN
1903
+ * component purely to keep `WorldSelectionOverlay`'s own cognitive
1904
+ * complexity down (each is an independent, mutually-exclusive-in-practice
1905
+ * conditional render — bundling them here costs the PARENT nothing beyond
1906
+ * one element reference). No behavior change from having them inline.
1907
+ */
1908
+ function D3Affordances(props: {
1909
+ adapter: AuthoringAdapter;
1910
+ contextMenu: WorldContextMenuState | null;
1911
+ onCloseContextMenu: () => void;
1912
+ textEdit: { id: string; rect: DOMRectLike; initialText: string } | null;
1913
+ onCommitTextEdit: (text: string) => void;
1914
+ onCancelTextEdit: () => void;
1915
+ textEditRefused: { id: string; rect: DOMRectLike } | null;
1916
+ eyedropperActive: boolean;
1917
+ eyedropperPreview: { x: number; y: number; color: string | null } | null;
1918
+ }): React.ReactNode {
1919
+ const {
1920
+ adapter,
1921
+ contextMenu,
1922
+ onCloseContextMenu,
1923
+ textEdit,
1924
+ onCommitTextEdit,
1925
+ onCancelTextEdit,
1926
+ textEditRefused,
1927
+ eyedropperActive,
1928
+ eyedropperPreview,
1929
+ } = props;
1930
+ return (
1931
+ <>
1932
+ {/* D3.a — canvas right-click context menu. `WorldContextMenu` portals
1933
+ itself to `document.body` (pan-seam fix — see its own doc comment),
1934
+ so unlike the other D3 affordances below it needs no
1935
+ `pointerEvents: 'auto'` wrapper here: it never actually mounts as a
1936
+ DOM descendant of this (`pointerEvents: 'none'`) overlay tree. */}
1937
+ {contextMenu && (
1938
+ <WorldContextMenu adapter={adapter} state={contextMenu} onClose={onCloseContextMenu} />
1939
+ )}
1940
+ {/* D3.b — the inline text editor (double-click), or the loud "not
1941
+ editable" refusal indicator for a dynamic/child-bearing body. */}
1942
+ {textEdit && (
1943
+ <div style={{ pointerEvents: 'auto' }}>
1944
+ <WorldTextEditor
1945
+ rect={textEdit.rect}
1946
+ initialText={textEdit.initialText}
1947
+ onCommit={onCommitTextEdit}
1948
+ onCancel={onCancelTextEdit}
1949
+ />
1950
+ </div>
1951
+ )}
1952
+ {textEditRefused && (
1953
+ <div
1954
+ data-testid="world-text-edit-refused"
1955
+ title="dynamic body — not inline-editable"
1956
+ style={{
1957
+ position: 'absolute',
1958
+ left: textEditRefused.rect.x,
1959
+ top: textEditRefused.rect.y,
1960
+ width: Math.max(textEditRefused.rect.width, 40),
1961
+ height: Math.max(textEditRefused.rect.height, 20),
1962
+ border: '2px dashed #ff6b6b',
1963
+ boxSizing: 'border-box',
1964
+ background: 'rgba(255, 107, 107, 0.12)',
1965
+ pointerEvents: 'none',
1966
+ }}
1967
+ />
1968
+ )}
1969
+ {/* D3.d — the fallback eyedropper cursor swatch: a resolved color, or an
1970
+ explicit "unknown" state — NEVER a fabricated color. */}
1971
+ {eyedropperActive && eyedropperPreview && (
1972
+ <div
1973
+ data-testid="world-eyedropper-swatch"
1974
+ data-color-state={eyedropperPreview.color ? 'resolved' : 'unknown'}
1975
+ style={{
1976
+ position: 'absolute',
1977
+ left: eyedropperPreview.x + 12,
1978
+ top: eyedropperPreview.y + 12,
1979
+ width: 24,
1980
+ height: 24,
1981
+ borderRadius: 4,
1982
+ border: '2px solid #fff',
1983
+ boxShadow: '0 1px 4px rgba(0,0,0,0.5)',
1984
+ background:
1985
+ eyedropperPreview.color ??
1986
+ 'repeating-linear-gradient(45deg, #999, #999 4px, #ccc 4px, #ccc 8px)',
1987
+ pointerEvents: 'none',
1988
+ }}
1989
+ />
1990
+ )}
1991
+ </>
1992
+ );
1993
+ }
1994
+
1995
+ /** `paddingTop`/`marginTop` → `'top'`, etc. — the direction suffix in each
1996
+ * spacing band's `data-testid` (spec's own `world-spacing-{padding|margin}-
1997
+ * {top|right|bottom|left}` naming). */
1998
+ function spacingDirection(side: SpacingSide): 'top' | 'right' | 'bottom' | 'left' {
1999
+ if (side.endsWith('Top')) return 'top';
2000
+ if (side.endsWith('Right')) return 'right';
2001
+ if (side.endsWith('Bottom')) return 'bottom';
2002
+ return 'left';
2003
+ }