@volter/editor-core 0.5.57

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 (719) hide show
  1. package/BUNDLED_NOTICES +1527 -0
  2. package/LICENSE +686 -0
  3. package/LICENSE-APACHE +202 -0
  4. package/NOTICE +18 -0
  5. package/README.md +24 -0
  6. package/dist/build/chunk-JS4D4XZ3.js +175 -0
  7. package/dist/build/chunk-JS4D4XZ3.js.map +7 -0
  8. package/dist/build/vite-plugin-product-contributions.js +100 -0
  9. package/dist/build/vite-plugin-product-contributions.js.map +7 -0
  10. package/dist/build/vite-plugin-shared-react.js +25 -0
  11. package/dist/build/vite-plugin-shared-react.js.map +7 -0
  12. package/dist/build/vite-plugin-shared-three.js +86 -0
  13. package/dist/build/vite-plugin-shared-three.js.map +7 -0
  14. package/dist/server/chunk-KMJC7AGO.js +98 -0
  15. package/dist/server/chunk-KMJC7AGO.js.map +7 -0
  16. package/dist/server/chunk-PSILUMGS.js +46 -0
  17. package/dist/server/chunk-PSILUMGS.js.map +7 -0
  18. package/dist/server/frame-proxy.js +255 -0
  19. package/dist/server/frame-proxy.js.map +7 -0
  20. package/dist/server/open-browser.js +53 -0
  21. package/dist/server/open-browser.js.map +7 -0
  22. package/dist/server/process-shutdown.js +127 -0
  23. package/dist/server/process-shutdown.js.map +7 -0
  24. package/dist/server/session-registry.js +141 -0
  25. package/dist/server/session-registry.js.map +7 -0
  26. package/dist/server/spawn-opener.js +11 -0
  27. package/dist/server/spawn-opener.js.map +7 -0
  28. package/dist/server/worktree-identity.js +11 -0
  29. package/dist/server/worktree-identity.js.map +7 -0
  30. package/dist-server/packaged.mjs +88816 -0
  31. package/package.json +118 -0
  32. package/scripts/build-plugins.mjs +14 -0
  33. package/scripts/build-server.mjs +26 -0
  34. package/scripts/build-session.mjs +9 -0
  35. package/server/account-credentials.ts +60 -0
  36. package/server/account-service.ts +1434 -0
  37. package/server/adapter-region-includes.ts +47 -0
  38. package/server/asset-catalog-v2.ts +175 -0
  39. package/server/asset-history-snapshots.ts +196 -0
  40. package/server/asset-ledger-store.ts +195 -0
  41. package/server/asset-library-routes.ts +1595 -0
  42. package/server/blender-wasm-artifact.ts +430 -0
  43. package/server/boot-timings.ts +106 -0
  44. package/server/canonical-path.ts +50 -0
  45. package/server/checkout-workspace-preflight.ts +349 -0
  46. package/server/cloud-asset-catalog.ts +158 -0
  47. package/server/coding-inference-launch.ts +237 -0
  48. package/server/collaboration-account-client.ts +202 -0
  49. package/server/collaboration-attribution.ts +122 -0
  50. package/server/collaboration-session.ts +934 -0
  51. package/server/comfyui-bridge.ts +248 -0
  52. package/server/console-ledger.ts +499 -0
  53. package/server/creation-site-transform.ts +304 -0
  54. package/server/creation-site-write.ts +648 -0
  55. package/server/data-file-serialize.ts +158 -0
  56. package/server/editor-brand-html.ts +72 -0
  57. package/server/editor-control-socket.ts +366 -0
  58. package/server/editor-server-options.ts +190 -0
  59. package/server/editor-server.ts +1483 -0
  60. package/server/editor-sse.ts +352 -0
  61. package/server/engine-provenance.ts +169 -0
  62. package/server/engine-source-restart.ts +117 -0
  63. package/server/frame-bridge.ts +115 -0
  64. package/server/frame-proxy.ts +490 -0
  65. package/server/frame-workbench.ts +259 -0
  66. package/server/frontend-handoff.ts +229 -0
  67. package/server/game-globals-shadow.ts +113 -0
  68. package/server/gameplay-session-retention.ts +149 -0
  69. package/server/gameplay-sessions.ts +123 -0
  70. package/server/generation-jobs.ts +201 -0
  71. package/server/generation-reconciler.ts +137 -0
  72. package/server/generative-execution-context.ts +59 -0
  73. package/server/git-workflow.ts +612 -0
  74. package/server/harness-chat-caller.ts +36 -0
  75. package/server/harness-chat-service.ts +1681 -0
  76. package/server/idle-shutdown.ts +170 -0
  77. package/server/js-profiling-policy.ts +52 -0
  78. package/server/launcher-settings.ts +84 -0
  79. package/server/local-asset-catalog.ts +410 -0
  80. package/server/managed-account-defaults.ts +22 -0
  81. package/server/mock-control-plane.ts +540 -0
  82. package/server/model-import-conversion.ts +345 -0
  83. package/server/native-credential-store.ts +162 -0
  84. package/server/open-browser.ts +118 -0
  85. package/server/packaged.ts +1192 -0
  86. package/server/play-stall.ts +126 -0
  87. package/server/process-shutdown.ts +218 -0
  88. package/server/product-presets.ts +55 -0
  89. package/server/project-build-artifact.ts +95 -0
  90. package/server/project-components.ts +100 -0
  91. package/server/project-dependency-invalidation.ts +114 -0
  92. package/server/project-file-scan.ts +235 -0
  93. package/server/project-hmr-files.ts +215 -0
  94. package/server/project-install-roots.ts +210 -0
  95. package/server/project-kinds.ts +71 -0
  96. package/server/project-mcp-servers.ts +82 -0
  97. package/server/project-module-freshness.ts +109 -0
  98. package/server/project-module-instance.ts +375 -0
  99. package/server/project-optimize-deps-entries.ts +827 -0
  100. package/server/project-output-writer.ts +679 -0
  101. package/server/project-package-origin.ts +63 -0
  102. package/server/project-root-surface.ts +831 -0
  103. package/server/project-scratch-path.ts +47 -0
  104. package/server/project-script-hmr.ts +619 -0
  105. package/server/project-serving-plugins.ts +236 -0
  106. package/server/project-tools.ts +653 -0
  107. package/server/project-validation.ts +250 -0
  108. package/server/project-verbs.ts +127 -0
  109. package/server/project-view.ts +52 -0
  110. package/server/project-watch.ts +1134 -0
  111. package/server/project-work-coordinator.ts +288 -0
  112. package/server/provider-credentials.ts +331 -0
  113. package/server/recent-projects-store.ts +98 -0
  114. package/server/redact-secrets.ts +71 -0
  115. package/server/repository-presence.ts +267 -0
  116. package/server/routes/account.ts +357 -0
  117. package/server/routes/agents.ts +60 -0
  118. package/server/routes/assets.ts +154 -0
  119. package/server/routes/build.ts +13 -0
  120. package/server/routes/collaboration.ts +595 -0
  121. package/server/routes/configurations.ts +419 -0
  122. package/server/routes/context.ts +264 -0
  123. package/server/routes/control-plane.ts +1811 -0
  124. package/server/routes/logs.ts +272 -0
  125. package/server/routes/project-identity.ts +302 -0
  126. package/server/routes/project-open.ts +429 -0
  127. package/server/routes/project-source.ts +686 -0
  128. package/server/routes/project-state.ts +383 -0
  129. package/server/routes/relay.ts +1029 -0
  130. package/server/routes/route-helpers.ts +29 -0
  131. package/server/routes/served-modules.ts +138 -0
  132. package/server/routes/session-tabs.ts +196 -0
  133. package/server/routes/settings.ts +121 -0
  134. package/server/routes/share-control.ts +486 -0
  135. package/server/routes/themes.ts +116 -0
  136. package/server/routes/tools.ts +230 -0
  137. package/server/routes/worktrees.ts +595 -0
  138. package/server/scoped-game-css.ts +461 -0
  139. package/server/server-utils.ts +1194 -0
  140. package/server/session-product.ts +112 -0
  141. package/server/session-registry.ts +230 -0
  142. package/server/share-claims.ts +138 -0
  143. package/server/share-host.ts +526 -0
  144. package/server/share-session-gateway.ts +1362 -0
  145. package/server/share-tunnel.ts +123 -0
  146. package/server/spawn-opener.ts +74 -0
  147. package/server/support/play/log-format.ts +76 -0
  148. package/server/support/play/session-record.ts +199 -0
  149. package/server/support/project/build-discipline.ts +718 -0
  150. package/server/support/project/inspection-node.ts +119 -0
  151. package/server/support/project/log-naming.ts +54 -0
  152. package/server/support/project/provenance.ts +290 -0
  153. package/server/support/project/run-name.ts +18 -0
  154. package/server/support/project/session-journal.ts +830 -0
  155. package/server/support/project/shared.ts +327 -0
  156. package/server/tab-bootstrap.ts +62 -0
  157. package/server/tab-heartbeat.ts +477 -0
  158. package/server/tab-lifecycle.ts +550 -0
  159. package/server/tab-presence.ts +1508 -0
  160. package/server/team-agent-mirror.ts +95 -0
  161. package/server/twin-auth.ts +138 -0
  162. package/server/vendored-lock-recorder.ts +968 -0
  163. package/server/worktree-identity.ts +121 -0
  164. package/server/worktree-management.ts +398 -0
  165. package/server/worktree-retention.d.mts +13 -0
  166. package/server/worktree-retention.mjs +205 -0
  167. package/src/EditorContext.tsx +169 -0
  168. package/src/account.ts +333 -0
  169. package/src/action-registry.ts +532 -0
  170. package/src/active-product.ts +74 -0
  171. package/src/active-project.ts +155 -0
  172. package/src/adapter-editor-config.ts +25 -0
  173. package/src/adapter-observation.ts +49 -0
  174. package/src/animation/stage-transport.ts +379 -0
  175. package/src/animation/three-clips-subject.ts +172 -0
  176. package/src/api/asset-library-wire.ts +45 -0
  177. package/src/api/assets.ts +365 -0
  178. package/src/api/base.ts +10 -0
  179. package/src/api/build.ts +99 -0
  180. package/src/api/git-wire.ts +56 -0
  181. package/src/api/logs.ts +92 -0
  182. package/src/api/project-identity.ts +74 -0
  183. package/src/api/project-open.ts +355 -0
  184. package/src/api/project-source.ts +162 -0
  185. package/src/api/project-state.ts +107 -0
  186. package/src/api/relay.ts +268 -0
  187. package/src/api/settings.ts +36 -0
  188. package/src/api/themes.ts +45 -0
  189. package/src/api/worktrees.ts +257 -0
  190. package/src/asset-compare-core.ts +171 -0
  191. package/src/asset-compare.ts +294 -0
  192. package/src/asset-editor-context.tsx +100 -0
  193. package/src/asset-events.ts +97 -0
  194. package/src/asset-inspector-actions.ts +87 -0
  195. package/src/asset-preview-framing.ts +357 -0
  196. package/src/asset-preview.ts +2802 -0
  197. package/src/asset-selection-viewer-registry.ts +113 -0
  198. package/src/asset-selection.ts +133 -0
  199. package/src/asset-workflow/asset-capabilities.ts +344 -0
  200. package/src/asset-workflow/asset-import-jobs.ts +106 -0
  201. package/src/asset-workflow/asset-ledger-backend.ts +126 -0
  202. package/src/asset-workflow/asset-ledger.ts +156 -0
  203. package/src/asset-workflow/asset-materialization-report.ts +140 -0
  204. package/src/asset-workflow/asset-pack-manifest.ts +320 -0
  205. package/src/asset-workflow/asset-types.ts +142 -0
  206. package/src/asset-workflow/asset-workflow-quality.ts +61 -0
  207. package/src/asset-workflow/audio-preview-player.ts +193 -0
  208. package/src/asset-workflow/audio-waveform.ts +22 -0
  209. package/src/asset-workflow/cloud-asset-client.ts +263 -0
  210. package/src/asset-workflow/folder-preview.ts +345 -0
  211. package/src/asset-workflow/hosted-asset-materialization.ts +236 -0
  212. package/src/asset-workflow/image-view-scale.ts +32 -0
  213. package/src/asset-workflow/import-contract.ts +124 -0
  214. package/src/asset-workflow/ledger-write-lock.ts +244 -0
  215. package/src/asset-workflow/model-inspection.ts +818 -0
  216. package/src/asset-workflow/pixi-spritesheet.ts +197 -0
  217. package/src/asset-workflow/preview-resource-lifetime.ts +44 -0
  218. package/src/asset-workflow/project-asset-commands.ts +23 -0
  219. package/src/asset-workflow/project-asset-health.ts +139 -0
  220. package/src/asset-workflow/project-asset-operations.ts +154 -0
  221. package/src/asset-workflow/project-asset-roots.ts +68 -0
  222. package/src/asset-workflow/project-content.ts +288 -0
  223. package/src/asset-workflow/project-source-index.ts +550 -0
  224. package/src/asset-workflow/thumbnail-system.ts +256 -0
  225. package/src/authoring/active-adapter.ts +199 -0
  226. package/src/authoring/active-systems.ts +422 -0
  227. package/src/authoring/adapter-key.ts +18 -0
  228. package/src/authoring/authoring-asset-url.ts +27 -0
  229. package/src/authoring/bootstrap-state.ts +49 -0
  230. package/src/authoring/boundary-authoring-adapter.ts +184 -0
  231. package/src/authoring/breakpoint-state.ts +43 -0
  232. package/src/authoring/canvas-scene-guides.ts +76 -0
  233. package/src/authoring/component-instance-root.ts +171 -0
  234. package/src/authoring/composite-authoring-adapter.ts +2110 -0
  235. package/src/authoring/consumer-actions.ts +520 -0
  236. package/src/authoring/css-numeric-style.ts +97 -0
  237. package/src/authoring/design-time-layers.ts +849 -0
  238. package/src/authoring/design-time-mount-registry.ts +235 -0
  239. package/src/authoring/design-time-settle.ts +343 -0
  240. package/src/authoring/edit-mode-authoring.ts +619 -0
  241. package/src/authoring/eyedropper-session.ts +60 -0
  242. package/src/authoring/instance-source-menu-register.ts +13 -0
  243. package/src/authoring/instance-source-menu.ts +135 -0
  244. package/src/authoring/layered-pick.ts +183 -0
  245. package/src/authoring/live-gesture-lock.ts +50 -0
  246. package/src/authoring/live-object-transform.ts +62 -0
  247. package/src/authoring/mount-failure-report.ts +154 -0
  248. package/src/authoring/mounted-root-subjects.ts +144 -0
  249. package/src/authoring/no-authoring-adapter.ts +55 -0
  250. package/src/authoring/null-inspection-subjects.tsx +75 -0
  251. package/src/authoring/object3d-document-persistence.ts +106 -0
  252. package/src/authoring/object3d-document-session-registry.ts +120 -0
  253. package/src/authoring/object3d-document-session.ts +1326 -0
  254. package/src/authoring/object3d-gesture-controller.ts +113 -0
  255. package/src/authoring/panel-authoring.ts +121 -0
  256. package/src/authoring/prefab-instance-inspector-section.tsx +237 -0
  257. package/src/authoring/prefab-instance-section-model.ts +55 -0
  258. package/src/authoring/project-authoring-session.ts +105 -0
  259. package/src/authoring/provenance.ts +99 -0
  260. package/src/authoring/quarks-particle-systems.ts +19 -0
  261. package/src/authoring/react-canvas-navigation.ts +255 -0
  262. package/src/authoring/react-design-canvas-style.ts +20 -0
  263. package/src/authoring/react-story-board.ts +937 -0
  264. package/src/authoring/selection-scope.ts +195 -0
  265. package/src/authoring/shell-document-ops.ts +169 -0
  266. package/src/authoring/shell-object3d-document-write-policy.ts +108 -0
  267. package/src/authoring/shell-viewport-policy.ts +48 -0
  268. package/src/authoring/source-object3d-authoring-adapter.ts +526 -0
  269. package/src/authoring/stories-scope.ts +35 -0
  270. package/src/authoring/story-board-chrome-fit.ts +107 -0
  271. package/src/authoring/story-board-presentation.ts +111 -0
  272. package/src/authoring/three-projection-core.ts +226 -0
  273. package/src/authoring/viewport-pick-context.ts +40 -0
  274. package/src/authoring/viewport-raycast.ts +240 -0
  275. package/src/authoring/viewport-tool-context.ts +73 -0
  276. package/src/authoring/world-canvas-viewport-state.ts +35 -0
  277. package/src/authoring/world-hidden-viewport.ts +152 -0
  278. package/src/authoring/world-pan-state.ts +198 -0
  279. package/src/authoring/world-session-state.ts +101 -0
  280. package/src/authoring/write-pipe.ts +173 -0
  281. package/src/availability-tick.ts +66 -0
  282. package/src/bitmap-label.ts +120 -0
  283. package/src/blender-tab-metrics.ts +161 -0
  284. package/src/board-open-actions.ts +20 -0
  285. package/src/boot-routing.ts +391 -0
  286. package/src/build-identity.ts +15 -0
  287. package/src/bytes-codec.ts +62 -0
  288. package/src/camera-authoring.ts +175 -0
  289. package/src/cancellation-reason.ts +58 -0
  290. package/src/canvas-preview-frames.ts +482 -0
  291. package/src/capture-camera-pose.ts +77 -0
  292. package/src/chrome-registry.ts +159 -0
  293. package/src/chrome-slot-registry.ts +91 -0
  294. package/src/collaboration-client.ts +264 -0
  295. package/src/collaboration-presence.ts +41 -0
  296. package/src/command-dispatch.ts +19 -0
  297. package/src/command-listener.ts +2743 -0
  298. package/src/command-registry.ts +70 -0
  299. package/src/component-board-registry.ts +205 -0
  300. package/src/component-states-registry.ts +200 -0
  301. package/src/components/AgentPresentationNotice.tsx +75 -0
  302. package/src/components/AlignToolbar.tsx +204 -0
  303. package/src/components/AppRoot.tsx +394 -0
  304. package/src/components/ApplicationMenus.tsx +415 -0
  305. package/src/components/AssetBrowser.tsx +2843 -0
  306. package/src/components/AssetEditorShell.tsx +216 -0
  307. package/src/components/AssetInspectorToolSection.tsx +124 -0
  308. package/src/components/BoardRulers.tsx +354 -0
  309. package/src/components/CameraInfo.tsx +81 -0
  310. package/src/components/CanvasSceneViewport.tsx +556 -0
  311. package/src/components/CapabilityCoverageSection.tsx +185 -0
  312. package/src/components/CenterDocuments.tsx +22 -0
  313. package/src/components/ChromeSlot.tsx +20 -0
  314. package/src/components/CodeView.tsx +470 -0
  315. package/src/components/CollaborationHeaderControl.css +74 -0
  316. package/src/components/CompactInspectorCard.tsx +190 -0
  317. package/src/components/CompactInspectorShell.tsx +39 -0
  318. package/src/components/ConsolePanel.css +55 -0
  319. package/src/components/ConsolePanel.tsx +270 -0
  320. package/src/components/DefaultEditorLayout.tsx +106 -0
  321. package/src/components/DocumentHeaderStrip.tsx +96 -0
  322. package/src/components/DocumentShelfRail.tsx +45 -0
  323. package/src/components/DocumentThumbnail.tsx +345 -0
  324. package/src/components/EditorLeaseGuard.tsx +372 -0
  325. package/src/components/ErrorBanner.tsx +123 -0
  326. package/src/components/FolderPreviewTile.tsx +159 -0
  327. package/src/components/GameHierarchy.tsx +3842 -0
  328. package/src/components/GameplaySessionTimeline.tsx +295 -0
  329. package/src/components/InspectionProjection.tsx +968 -0
  330. package/src/components/Inspector.tsx +233 -0
  331. package/src/components/InspectorCanvasPreview.tsx +35 -0
  332. package/src/components/InspectorFieldsSection.tsx +290 -0
  333. package/src/components/InspectorObjectPreview.tsx +57 -0
  334. package/src/components/InspectorStoriesSection.tsx +92 -0
  335. package/src/components/InspectorToolSection.tsx +96 -0
  336. package/src/components/InspectorTransformSection.tsx +245 -0
  337. package/src/components/LightExplorerPanel.tsx +433 -0
  338. package/src/components/MediaProperties.tsx +145 -0
  339. package/src/components/NonThreeAuthoringBootstrap.tsx +19 -0
  340. package/src/components/Object3DDocumentToolbar.css +100 -0
  341. package/src/components/Object3DDocumentToolbar.tsx +454 -0
  342. package/src/components/Object3DDocumentViewport.tsx +58 -0
  343. package/src/components/ProjectHeader.css +617 -0
  344. package/src/components/ProjectHeader.tsx +328 -0
  345. package/src/components/ProjectLayout.tsx +249 -0
  346. package/src/components/ReactCanvasControls.tsx +284 -0
  347. package/src/components/RootSelectionOverlay.tsx +3162 -0
  348. package/src/components/RootTextEditor.tsx +79 -0
  349. package/src/components/SaveStatus.tsx +70 -0
  350. package/src/components/StageHost.tsx +1954 -0
  351. package/src/components/StageOverlays.tsx +21 -0
  352. package/src/components/StartupErrorScreen.tsx +76 -0
  353. package/src/components/StartupLoadingScreen.tsx +65 -0
  354. package/src/components/StatsOverlay.tsx +78 -0
  355. package/src/components/SurfaceStateOverlay.tsx +24 -0
  356. package/src/components/ToolContributionSurfaces.tsx +65 -0
  357. package/src/components/ToolHost.tsx +370 -0
  358. package/src/components/ToolObject3DPreview.tsx +39 -0
  359. package/src/components/Toolbar.tsx +595 -0
  360. package/src/components/TransientHint.tsx +44 -0
  361. package/src/components/TransportStrip.tsx +174 -0
  362. package/src/components/VersionControlSection.tsx +470 -0
  363. package/src/components/VgaiLogo.css +83 -0
  364. package/src/components/VgaiLogo.tsx +35 -0
  365. package/src/components/ViewportControlsHint.tsx +60 -0
  366. package/src/components/ViewportFurniture.tsx +401 -0
  367. package/src/components/ViewportOverlay.tsx +145 -0
  368. package/src/components/ViewportOverlaysMenu.tsx +153 -0
  369. package/src/components/ViewportShadingMenu.tsx +300 -0
  370. package/src/components/ViewportViewMenu.tsx +101 -0
  371. package/src/components/WorkspaceDocumentSurface.tsx +204 -0
  372. package/src/components/WorkspaceUtilitySurface.tsx +22 -0
  373. package/src/components/WorktreeSwitcher.css +239 -0
  374. package/src/components/WorktreeSwitcher.tsx +890 -0
  375. package/src/components/account-documents.tsx +1162 -0
  376. package/src/components/asset-documents.tsx +812 -0
  377. package/src/components/asset-editor-persistence.ts +216 -0
  378. package/src/components/asset-selection-section.tsx +535 -0
  379. package/src/components/asset-thumbnails.tsx +312 -0
  380. package/src/components/asset-viewers/AudioViewer.tsx +201 -0
  381. package/src/components/asset-viewers/EntityModelDocument.tsx +122 -0
  382. package/src/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
  383. package/src/components/asset-viewers/GenericJsonViewer.tsx +102 -0
  384. package/src/components/asset-viewers/ImageViewer.tsx +300 -0
  385. package/src/components/asset-viewers/JsonAssetDocument.tsx +93 -0
  386. package/src/components/asset-viewers/LiveModuleDocument.tsx +417 -0
  387. package/src/components/asset-viewers/LutAssetDocument.tsx +444 -0
  388. package/src/components/asset-viewers/ModelAssetDocument.tsx +105 -0
  389. package/src/components/asset-viewers/Object3DPreview.tsx +359 -0
  390. package/src/components/asset-viewers/OnlineAssetDetail.tsx +421 -0
  391. package/src/components/asset-viewers/PasteboardModuleDocument.tsx +67 -0
  392. package/src/components/asset-viewers/QuarksAssetDocument.tsx +526 -0
  393. package/src/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
  394. package/src/components/asset-viewers/SourceAssetViewer.tsx +281 -0
  395. package/src/components/asset-viewers/SpritesheetSpriteView.tsx +102 -0
  396. package/src/components/asset-viewers/VideoViewer.tsx +101 -0
  397. package/src/components/asset-viewers/shader-source.ts +144 -0
  398. package/src/components/asset-workflow.css +731 -0
  399. package/src/components/board-guides.ts +150 -0
  400. package/src/components/compact-inspector.css +539 -0
  401. package/src/components/core-utilities.tsx +90 -0
  402. package/src/components/editor-notifications.css +40 -0
  403. package/src/components/engine-workspace.css +274 -0
  404. package/src/components/inspector-preview-section.tsx +238 -0
  405. package/src/components/inspector-property-grouping.ts +64 -0
  406. package/src/components/inspector-revert-label.ts +20 -0
  407. package/src/components/inspector-selection.ts +42 -0
  408. package/src/components/inspector-stories-gating.ts +171 -0
  409. package/src/components/inspector-transform-subject.ts +11 -0
  410. package/src/components/inspector-transform.ts +75 -0
  411. package/src/components/kind-documents.tsx +500 -0
  412. package/src/components/palette-action-publisher.tsx +140 -0
  413. package/src/components/primitives/DraftColorInput.tsx +74 -0
  414. package/src/components/product-shell.css +1829 -0
  415. package/src/components/project-tool-documents.tsx +410 -0
  416. package/src/components/scene-documents.tsx +223 -0
  417. package/src/components/stage-keyboard.tsx +37 -0
  418. package/src/components/stage-overlay-set.tsx +107 -0
  419. package/src/components/stage-presence-markers.ts +478 -0
  420. package/src/components/standard-viewport-dressing.ts +417 -0
  421. package/src/components/status-contributions.tsx +403 -0
  422. package/src/components/tool-documents.tsx +309 -0
  423. package/src/components/tool-schema-form.tsx +262 -0
  424. package/src/components/use-after-paint.ts +41 -0
  425. package/src/components/use-project-image-assets.ts +86 -0
  426. package/src/components/viewport-header-controls.css +90 -0
  427. package/src/components/viewport-surface-status.tsx +55 -0
  428. package/src/components/workspace-history.ts +32 -0
  429. package/src/components/workspace-static-panel-registry.tsx +152 -0
  430. package/src/components/workspace-surfaces.css +621 -0
  431. package/src/components/world-documents.tsx +586 -0
  432. package/src/components/world-overlay-gestures.ts +1694 -0
  433. package/src/composite-screenshot.ts +1641 -0
  434. package/src/console-sync.ts +131 -0
  435. package/src/constraint-helper.ts +338 -0
  436. package/src/content-entry-source-registry.ts +184 -0
  437. package/src/coverage/authoring-seam-evidence.ts +300 -0
  438. package/src/coverage/canvas-reveal.ts +192 -0
  439. package/src/coverage/design-time-surfaces.ts +101 -0
  440. package/src/coverage/live-seam-evidence.ts +11 -0
  441. package/src/coverage/ontology-invariants.ts +466 -0
  442. package/src/coverage/session-vitals.ts +501 -0
  443. package/src/coverage/system-seam-evidence.ts +72 -0
  444. package/src/crash-null-boundary.ts +36 -0
  445. package/src/creation-site-edit.ts +1479 -0
  446. package/src/creation-site-registry.ts +160 -0
  447. package/src/delegate-harness-registry.ts +143 -0
  448. package/src/document-context-registry.ts +177 -0
  449. package/src/document-open-registry.ts +200 -0
  450. package/src/document-preview-source.ts +20 -0
  451. package/src/document-renderer-session.ts +138 -0
  452. package/src/editor-api.ts +46 -0
  453. package/src/editor-chrome-capture.ts +139 -0
  454. package/src/editor-commands.ts +164 -0
  455. package/src/editor-console.ts +563 -0
  456. package/src/editor-current-view.ts +82 -0
  457. package/src/editor-document-probe.ts +881 -0
  458. package/src/editor-git-client.ts +115 -0
  459. package/src/editor-host-door.ts +462 -0
  460. package/src/editor-hotkeys.ts +842 -0
  461. package/src/editor-lease-view.ts +39 -0
  462. package/src/editor-lease.ts +415 -0
  463. package/src/editor-mode.ts +19 -0
  464. package/src/editor-notifications.ts +140 -0
  465. package/src/editor-presence.ts +563 -0
  466. package/src/editor-presentation-activity.ts +50 -0
  467. package/src/editor-presentation-notice.ts +42 -0
  468. package/src/editor-runtime.tsx +145 -0
  469. package/src/editor-server-response.ts +86 -0
  470. package/src/editor-session-attribution.ts +75 -0
  471. package/src/editor-session-mode.ts +54 -0
  472. package/src/editor-shell-store.ts +1304 -0
  473. package/src/editor-state-facets.ts +74 -0
  474. package/src/editor-styles.css +33 -0
  475. package/src/editor-view-presentation.ts +810 -0
  476. package/src/editor-viewport.ts +5302 -0
  477. package/src/entity-lod.ts +31 -0
  478. package/src/entity-object.ts +91 -0
  479. package/src/files/file-provider.ts +62 -0
  480. package/src/files/project-files.ts +264 -0
  481. package/src/finders/index.ts +136 -0
  482. package/src/finders/scenes-from-entrypoint-selection.ts +387 -0
  483. package/src/frame/bridge.tsx +1408 -0
  484. package/src/frame/product.ts +77 -0
  485. package/src/gameplay-dom-recording.ts +319 -0
  486. package/src/gameplay-export-state.ts +14 -0
  487. package/src/gameplay-replay.ts +417 -0
  488. package/src/gameplay-session-time.ts +9 -0
  489. package/src/gameplay-sessions.ts +204 -0
  490. package/src/harness-chat-types.ts +253 -0
  491. package/src/hierarchy-component-marks.ts +298 -0
  492. package/src/hierarchy-drop.ts +91 -0
  493. package/src/hierarchy-expansion-state.ts +80 -0
  494. package/src/hierarchy-header-slot.ts +52 -0
  495. package/src/hierarchy-internals.ts +197 -0
  496. package/src/hierarchy-kind-icon.ts +217 -0
  497. package/src/hierarchy-mark-reader.ts +73 -0
  498. package/src/hierarchy-menu-registry.ts +67 -0
  499. package/src/hierarchy-node-rows.ts +307 -0
  500. package/src/hierarchy-panel-view.ts +280 -0
  501. package/src/hierarchy-projection.ts +76 -0
  502. package/src/hierarchy-row-cache.ts +243 -0
  503. package/src/hierarchy-row-model.ts +308 -0
  504. package/src/hierarchy-rows.ts +11 -0
  505. package/src/hierarchy-walk.ts +86 -0
  506. package/src/history/editor-session.ts +25 -0
  507. package/src/history/history-commands.ts +147 -0
  508. package/src/history/history-delegate.ts +163 -0
  509. package/src/history/history-limit-notices.ts +43 -0
  510. package/src/history/history-service.ts +1173 -0
  511. package/src/history/persistence-coordinator.ts +35 -0
  512. package/src/history/project-file-history.ts +386 -0
  513. package/src/history/project-root-history-backends.ts +139 -0
  514. package/src/history/resource-registry.ts +209 -0
  515. package/src/history/snapshot-store.ts +103 -0
  516. package/src/history/source-history-backend.ts +546 -0
  517. package/src/history/types.ts +124 -0
  518. package/src/hmr-registration-group.ts +67 -0
  519. package/src/hmr-stable-react-context.ts +23 -0
  520. package/src/hotkeys.ts +188 -0
  521. package/src/inference-diagnostics.ts +69 -0
  522. package/src/initial-project.ts +80 -0
  523. package/src/inspection/active-subject.ts +578 -0
  524. package/src/inspection/active-surface.ts +142 -0
  525. package/src/inspection/compose.ts +1064 -0
  526. package/src/inspection/display.ts +170 -0
  527. package/src/inspection/document-subject.ts +109 -0
  528. package/src/inspection/game-subject.ts +85 -0
  529. package/src/inspection/model.ts +542 -0
  530. package/src/inspection/null-subject.ts +115 -0
  531. package/src/inspection/serialize.ts +357 -0
  532. package/src/inspection/use-active-inspection.ts +180 -0
  533. package/src/inspector-presentation.ts +201 -0
  534. package/src/inspector-section-registry.ts +221 -0
  535. package/src/instance-source-actions.ts +163 -0
  536. package/src/instanced-presentation.ts +164 -0
  537. package/src/js-heap.ts +71 -0
  538. package/src/key-actions.ts +91 -0
  539. package/src/keymap-presets.ts +401 -0
  540. package/src/layout-policy.ts +31 -0
  541. package/src/learn-links.ts +73 -0
  542. package/src/light-explorer-model.ts +134 -0
  543. package/src/live-canvas-frame.ts +55 -0
  544. package/src/live-document.ts +279 -0
  545. package/src/live-module-source.ts +230 -0
  546. package/src/live-session-registry.ts +220 -0
  547. package/src/live-transition.ts +633 -0
  548. package/src/manifest-project.ts +107 -0
  549. package/src/model-thumbnail.ts +523 -0
  550. package/src/native-selection-style.ts +202 -0
  551. package/src/object3d-document-write-policy.ts +137 -0
  552. package/src/packaged-runtime.ts +108 -0
  553. package/src/palettes/maya.palette.json +57 -0
  554. package/src/palettes/substance.palette.json +57 -0
  555. package/src/pasteboard-module.ts +81 -0
  556. package/src/performance-profiler.ts +367 -0
  557. package/src/performance-sources.ts +69 -0
  558. package/src/play-boot-phase.ts +145 -0
  559. package/src/presentation-surface.ts +248 -0
  560. package/src/project-adapter.ts +1143 -0
  561. package/src/project-asset-refresh.ts +26 -0
  562. package/src/project-declaration-refresh.ts +31 -0
  563. package/src/project-local-state.ts +118 -0
  564. package/src/project-manager.ts +243 -0
  565. package/src/project-module-changes.ts +163 -0
  566. package/src/project-module-split.ts +266 -0
  567. package/src/project-provenance.ts +115 -0
  568. package/src/project-ready.ts +42 -0
  569. package/src/project-session-reset.ts +67 -0
  570. package/src/project-shape.ts +68 -0
  571. package/src/project-tool-discovery.ts +66 -0
  572. package/src/project-tools.ts +107 -0
  573. package/src/project-work-types.ts +149 -0
  574. package/src/projection/three.ts +898 -0
  575. package/src/projection/types.ts +44 -0
  576. package/src/readiness.ts +113 -0
  577. package/src/reflection-probe-helper.ts +142 -0
  578. package/src/reported-play-state.ts +90 -0
  579. package/src/resolve-relative-specifier.ts +33 -0
  580. package/src/scene-document-plan.ts +315 -0
  581. package/src/scene-framing.ts +315 -0
  582. package/src/scene-live-open.ts +210 -0
  583. package/src/scene-view-fog.ts +89 -0
  584. package/src/scoped-game-css.ts +152 -0
  585. package/src/session-orphan-record.ts +193 -0
  586. package/src/session-tombstone.ts +126 -0
  587. package/src/settings/settings-provider.ts +82 -0
  588. package/src/settings-store.ts +345 -0
  589. package/src/shared-view-restore.ts +42 -0
  590. package/src/shell-store-door.ts +45 -0
  591. package/src/source-conflict.ts +122 -0
  592. package/src/spatial-handle-visuals.ts +332 -0
  593. package/src/stage-context.ts +341 -0
  594. package/src/stage-store-registry.ts +63 -0
  595. package/src/stale-chunk-recovery.ts +34 -0
  596. package/src/state-report-deferral.ts +73 -0
  597. package/src/storage/host-files-storage.ts +97 -0
  598. package/src/storage/http-storage.ts +174 -0
  599. package/src/storage/index.ts +75 -0
  600. package/src/storage/mem-storage.ts +149 -0
  601. package/src/storage/path-lock.ts +44 -0
  602. package/src/storage/paths.ts +26 -0
  603. package/src/storage/types.ts +116 -0
  604. package/src/stories/StoryComponentThumbnail.tsx +184 -0
  605. package/src/stories/StoryPreviewMount.tsx +306 -0
  606. package/src/stories/component-content-source.tsx +106 -0
  607. package/src/stories/component-states-source.ts +78 -0
  608. package/src/stories/compose-project-stories.ts +255 -0
  609. package/src/stories/pixi-story-model.ts +30 -0
  610. package/src/stories/prefabs-finder.ts +54 -0
  611. package/src/stories/prefabs-from-stories.ts +182 -0
  612. package/src/stories/project-story-discovery.ts +78 -0
  613. package/src/stories/project-story-regions.ts +24 -0
  614. package/src/stories/story-actions.ts +54 -0
  615. package/src/stories/story-capture-command.ts +124 -0
  616. package/src/stories/story-capture.ts +584 -0
  617. package/src/stories/story-declared-medium.ts +126 -0
  618. package/src/stories/story-discovery.ts +176 -0
  619. package/src/stories/story-document-openers.ts +36 -0
  620. package/src/stories/story-dom-runtime.ts +78 -0
  621. package/src/stories/story-grouping.ts +111 -0
  622. package/src/stories/story-lane.ts +88 -0
  623. package/src/stories/story-mount-turn.ts +27 -0
  624. package/src/stories/story-opener.ts +260 -0
  625. package/src/stories/story-pixi-preview.ts +408 -0
  626. package/src/stories/story-presentation.ts +215 -0
  627. package/src/stories/story-registry.ts +517 -0
  628. package/src/stories/story-three-preview.ts +807 -0
  629. package/src/stories/three-story-model.ts +96 -0
  630. package/src/story-three-preview-runtime.ts +56 -0
  631. package/src/surface-keyboard.ts +112 -0
  632. package/src/surface-state.ts +124 -0
  633. package/src/tab-bootstrap.js +628 -0
  634. package/src/tab-census.ts +198 -0
  635. package/src/tab-lifecycle-client.ts +196 -0
  636. package/src/theme-library.ts +844 -0
  637. package/src/theme-preference.ts +378 -0
  638. package/src/theme.css +3437 -0
  639. package/src/three-viewport/bone-selection-highlight.ts +119 -0
  640. package/src/three-viewport/camera-fit.ts +41 -0
  641. package/src/three-viewport/interactive-renderer.ts +132 -0
  642. package/src/three-viewport/selection-brackets.ts +310 -0
  643. package/src/three-viewport/selection-outline.ts +183 -0
  644. package/src/three-viewport/skeleton-helper.ts +61 -0
  645. package/src/three-viewport/source-color.ts +167 -0
  646. package/src/three-viewport/studio-environment.ts +96 -0
  647. package/src/three-viewport-presentation.ts +23 -0
  648. package/src/tool-contribution-play.ts +74 -0
  649. package/src/tool-loader.ts +1798 -0
  650. package/src/transform-mode-request.ts +57 -0
  651. package/src/transient-hint.ts +78 -0
  652. package/src/trigger-volume-helper.ts +116 -0
  653. package/src/ui-source/adapter-region-includes.ts +241 -0
  654. package/src/ui-source/ensure-import.ts +132 -0
  655. package/src/ui-source/file-region-resolver.ts +302 -0
  656. package/src/ui-source/inspect.ts +775 -0
  657. package/src/ui-source/oid-transform.ts +1845 -0
  658. package/src/ui-source/plan-csf-story.ts +196 -0
  659. package/src/ui-source/plan-extract-component.ts +421 -0
  660. package/src/ui-source/plan-fork-component.ts +621 -0
  661. package/src/ui-source/plan-named-style.ts +95 -0
  662. package/src/ui-source/plan-source-edit.ts +353 -0
  663. package/src/ui-source/r3f-contract-resolver.ts +327 -0
  664. package/src/ui-source/r3f-diagnostic-index.ts +165 -0
  665. package/src/ui-source/r3f-environment-binding.ts +133 -0
  666. package/src/ui-source/r3f-joint-binding.ts +251 -0
  667. package/src/ui-source/r3f-lod-binding.ts +91 -0
  668. package/src/ui-source/r3f-particle-binding.ts +235 -0
  669. package/src/ui-source/r3f-physics-binding.ts +483 -0
  670. package/src/ui-source/r3f-project-contracts.ts +444 -0
  671. package/src/ui-source/relative-import-specifier.ts +34 -0
  672. package/src/ui-source/reparent-guard.ts +544 -0
  673. package/src/ui-source/source-edit-request.ts +97 -0
  674. package/src/ui-source/source-write-backend.ts +618 -0
  675. package/src/ui-source/syntactic-prop-specs.ts +296 -0
  676. package/src/ui-source/tier-source-write-backend.ts +279 -0
  677. package/src/ui-source/ts-ast.ts +171 -0
  678. package/src/ui-source/utility-class-support.ts +169 -0
  679. package/src/ui-source/write-component-default.ts +133 -0
  680. package/src/ui-source/writer.ts +1989 -0
  681. package/src/viewport-activation-timings.ts +840 -0
  682. package/src/viewport-authoring-policy.ts +186 -0
  683. package/src/viewport-controls-hint.ts +57 -0
  684. package/src/viewport-door.ts +237 -0
  685. package/src/viewport-shading-boundary.ts +12 -0
  686. package/src/vite-error-surface.ts +141 -0
  687. package/src/wait-until.ts +37 -0
  688. package/src/workspace-areas.ts +156 -0
  689. package/src/workspace-aux-commands.ts +11 -0
  690. package/src/workspace-available-documents.ts +127 -0
  691. package/src/workspace-core-utilities.ts +31 -0
  692. package/src/workspace-document-ids.ts +59 -0
  693. package/src/workspace-document-registry.ts +612 -0
  694. package/src/workspace-document-restore.ts +145 -0
  695. package/src/workspace-host-commands.ts +141 -0
  696. package/src/workspace-persistence-gate.ts +40 -0
  697. package/src/workspace-play-utilities.ts +44 -0
  698. package/src/workspace-presets.ts +425 -0
  699. package/src/workspace-regions.ts +294 -0
  700. package/src/workspace-state-persistence.ts +536 -0
  701. package/src/workspace-static-panels.ts +73 -0
  702. package/src/workspace-status-registry.ts +121 -0
  703. package/src/workspace-style.ts +239 -0
  704. package/src/workspace-utility-commands.ts +74 -0
  705. package/src/workspace-utility-registry.ts +263 -0
  706. package/src/workspace-viewport-rect.ts +97 -0
  707. package/src/world-adoption.ts +115 -0
  708. package/src/world-document-routing.ts +104 -0
  709. package/vite-plugin-creation-site-write.ts +184 -0
  710. package/vite-plugin-creation-site.ts +80 -0
  711. package/vite-plugin-game-static.ts +303 -0
  712. package/vite-plugin-module-doorways.ts +336 -0
  713. package/vite-plugin-product-contributions.ts +197 -0
  714. package/vite-plugin-project-game-static.ts +125 -0
  715. package/vite-plugin-project-jsx-js.ts +65 -0
  716. package/vite-plugin-project-root-absolute-assets.ts +359 -0
  717. package/vite-plugin-shared-react.ts +420 -0
  718. package/vite-plugin-shared-three.ts +203 -0
  719. package/vite-plugin-ui-oid.ts +2106 -0
@@ -0,0 +1,3162 @@
1
+ /**
2
+ * RootSelectionOverlay — spec 27 §3 A3, MIGRATE target of
3
+ * `ui-editor/overlay.tsx`'s `SelectionOverlay` (§9's consolidation table),
4
+ * generalized off that file's editor-store-and-stage-ref coupling onto the
5
+ * format-neutral authoring contract so it works over ANY DOM-capable world
6
+ * (react `data-oid`, ingested `react-dom`), not just the scene-UI silo.
7
+ *
8
+ * MOUNT SITE / COORDINATES (critical): DOM worlds render this component as a
9
+ * child of their world document's host — the EXACT SAME DOM element
10
+ * `design-time-layers.ts` appends that world's `position:absolute; inset:0`
11
+ * layer into. The native Three scene retains a separate instance inside the
12
+ * STAGE HOST's own container (`StageHost.tsx`, beside its canvas, mounted by
13
+ * `stage-overlay-set.tsx`). This component's own outer wrapper is
14
+ * ALSO `position:absolute; inset:0` inside that SAME parent, so its
15
+ * `getBoundingClientRect()` is IDENTICAL to every world layer's — a
16
+ * HOST-RELATIVE rect from `RectProvider.rect(id)` (host = the owning
17
+ * adapter's own `this.root`, which IS one of those layer divs) lands on this
18
+ * overlay's local coordinate space with ZERO correction. Deliberately NOT
19
+ * wired one level up in `DefaultEditorLayout.tsx` (a sibling of the stage
20
+ * there sits in a DIFFERENT, merely same-SIZED parent — co-location would be
21
+ * incidental, not structural); mounting inside the stage's own container
22
+ * makes the co-location a DOM fact, not a CSS coincidence.
23
+ *
24
+ * `PickProvider.pick` (unlike `RectProvider.rect`) takes CLIENT (viewport) px
25
+ * per its own contract doc comment — pointer handlers below pass
26
+ * `e.clientX`/`e.clientY` straight through, no conversion.
27
+ *
28
+ * Driven ONLY by the authoring contract (`getActiveAuthoring(store)` +
29
+ * `store.selectedEntityIds` + `pickTopmost` + `CompositeAuthoringAdapter
30
+ * .childAdapters()/ownerOf()`) — rule zero (spec §0): this file talks to no
31
+ * concrete document/rendering library and no other editor silo's store or
32
+ * DOM ref directly; hit-testing goes through the contract's own pick
33
+ * provider, never a raw point-under-cursor DOM query.
34
+ *
35
+ * Scope: A3's hover highlight, selection box + `name·kind` label, and marquee
36
+ * are unchanged below. Also in this same file: 8 resize handles + a
37
+ * rotate handle + a dimension label (B2), drag-to-move for
38
+ * absolutely/fixed-positioned nodes (B2), and padding/margin spacing bands
39
+ * (B3) — all gated on EXACTLY one node selected whose OWNER exposes `boxEdit`
40
+ * (see `world-overlay-gestures.ts`'s `boxEditForId`; multi-select keeps only
41
+ * the plain A3 boxes, no handles). The gesture PATCH MATH itself lives in the
42
+ * sibling `world-overlay-gestures.ts` module (pure, unit-tested); this file
43
+ * owns only the impure orchestration — reading the live rect/spacing values,
44
+ * driving the `begin`/`apply`/`end` bracket, and rendering.
45
+ *
46
+ * Phase D2 (§6) adds, also in this file: a drag-ghost (a semi-transparent
47
+ * follow-cursor copy of the dragged element, live during a B2 move gesture OR
48
+ * a D2.b reorder drag) and the reorder clone-preview itself — dragging an
49
+ * already-selected FLOW/LIST child (no `boxEdit` move applies to it; it's
50
+ * neither absolute nor fixed) among ≥3 total siblings live-previews the
51
+ * insertion gap the drop would land in and commits ONE `structure.reorder(id,
52
+ * beforeSiblingId)` call on drop (capability-gated on the owner exposing
53
+ * `structure.reorder` — absent for the ingested react-dom adapter, so no
54
+ * preview/commit there; the drag-ghost is visual-only and may still show).
55
+ * The gap math (`computeReorderGap`) and ghost math (`computeDragGhost`) are
56
+ * pure, in `world-overlay-gestures.ts`, same split as B2/B3.
57
+ */
58
+
59
+ import { Menu, MenuItem, ThemeRootPortal, themeVars, zIndex } from '@volter/editor-sdk/widgets';
60
+ import type {
61
+ AssetDropContext,
62
+ AuthoringAdapter,
63
+ BoxEditProvider,
64
+ BoxEditReferencePoint,
65
+ DOMRectLike,
66
+ SpatialDragHandle,
67
+ SpatialHandlesProvider,
68
+ } from '@volter/editor-project/adapter';
69
+ import {
70
+ type DragEvent as ReactDragEvent,
71
+ type MouseEvent as ReactMouseEvent,
72
+ type PointerEvent as ReactPointerEvent,
73
+ useCallback,
74
+ useEffect,
75
+ useRef,
76
+ useState,
77
+ useSyncExternalStore,
78
+ } from 'react';
79
+ import { collectAllNodeIds, getActiveAuthoring } from '../authoring/active-adapter';
80
+ import {
81
+ canvasSceneGuideRevision,
82
+ canvasSceneGuides,
83
+ subscribeCanvasSceneGuides,
84
+ } from '../authoring/canvas-scene-guides';
85
+ import { CompositeAuthoringAdapter } from '../authoring/composite-authoring-adapter';
86
+ import {
87
+ dropAuthoringAsset,
88
+ reorderAuthoringNode,
89
+ setAuthoringSelection,
90
+ } from '../authoring/consumer-actions';
91
+ import {
92
+ isEyedropperSessionActive,
93
+ resolveEyedropperSession,
94
+ subscribeEyedropperSession,
95
+ } from '../authoring/eyedropper-session';
96
+ import { pickCandidates, pickTopmost } from '../authoring/layered-pick';
97
+ import {
98
+ selectReactStoryFrameAtPoint,
99
+ zoomReactStoryFrameAtPoint,
100
+ } from '../authoring/react-story-board';
101
+ import { getViewportPickContext } from '../authoring/viewport-pick-context';
102
+ import {
103
+ panTransformValue,
104
+ type RootViewController,
105
+ sharedRootViewController,
106
+ } from '../authoring/world-pan-state';
107
+ import { isRootHidden } from '../authoring/world-session-state';
108
+ import { useEditorStore } from '../editor-runtime';
109
+ import { anyLiveSessionMounted } from '../live-session-registry';
110
+ import { effectiveColorFromChain } from '../ui-source/inspect';
111
+ import { AlignToolbar } from './AlignToolbar';
112
+ import { transformDimensionsFor } from './inspector-transform-subject';
113
+ import { RootTextEditor } from './RootTextEditor';
114
+ import {
115
+ type AlignEntry,
116
+ boxEditForId,
117
+ colorSampleForId,
118
+ computeDragGhost,
119
+ computeMeasureLines,
120
+ computeMovePatch,
121
+ computeMoveSnapGuides,
122
+ computeNativeMoveSnap,
123
+ computePointSnap,
124
+ computeReorderGap,
125
+ computeResizePatch,
126
+ computeResizeSnapGuides,
127
+ computeRotatePatch,
128
+ computeSpacingBands,
129
+ computeSpacingPatch,
130
+ contextRectsForId,
131
+ EDGE_SNAP_THRESHOLD_PX,
132
+ emptyContainerHintsFor,
133
+ type HandlePos,
134
+ handlePosition,
135
+ type MeasureLine,
136
+ type PositionBadgeInfo,
137
+ RESIZE_HANDLES,
138
+ type ReorderGap,
139
+ readSpacingValues,
140
+ // D4 — moved here from a local definition in THIS file (was `export
141
+ // function rectForId`): `editor-hotkeys.ts`'s D4.c arrow-nudge action
142
+ // needed it too, and importing it off THIS component file pulled the
143
+ // whole `RootSelectionOverlay`/postprocessing-viewport dependency graph
144
+ // into `editor-hotkeys.ts` (breaks a headless/vitest unit test of the
145
+ // hotkey actions, which has no business mounting a viewport). Re-exported
146
+ // below for the existing import site.
147
+ rectForId,
148
+ resolvePositionBadge,
149
+ type SiblingRectEntry,
150
+ type SnapGuide,
151
+ type SpacingBand,
152
+ type SpacingSide,
153
+ siblingIdsForId,
154
+ spatialHandlesForId,
155
+ structureForId,
156
+ textForId,
157
+ } from './world-overlay-gestures';
158
+
159
+ export { rectForId } from './world-overlay-gestures';
160
+
161
+ // Canvas selection is an editor action state, so it follows the selected
162
+ // palette's semantic accent just like toolbars and drop overlays. Material
163
+ // changes never inject a hue of their own.
164
+ const ACCENT = themeVars.accent.default;
165
+ /** Strictly below `ToolStrip`+`ViewportOverlay`'s z1000 — mirrors
166
+ * `design-time-layers.ts`'s own `BASE_Z_INDEX` doc comment ("so canvas-local
167
+ * editing chrome is never occluded") — and strictly
168
+ * above the design-time layers themselves (`BASE_Z_INDEX + i`, small N),
169
+ * so this overlay's boxes/marquee always paint over the world content they
170
+ * describe. V-12 — pure rename onto the canonical z-index scale
171
+ * (`zIndex.overlayLow`), same numeric value (50), no behavior change. */
172
+ const Z_INDEX = zIndex.overlayLow;
173
+ const DRAG_THRESHOLD_PX = 5;
174
+
175
+ type EditorControlCursor = 'pointer' | 'not-allowed' | null;
176
+
177
+ function activateViewportEditorControl(
178
+ scopedAdapter: AuthoringAdapter | undefined,
179
+ clientX: number,
180
+ clientY: number,
181
+ ): boolean {
182
+ if (scopedAdapter) return false;
183
+ return getViewportPickContext()?.editorControls?.activate(clientX, clientY) ?? false;
184
+ }
185
+
186
+ function updateViewportEditorControlHover(
187
+ scopedAdapter: AuthoringAdapter | undefined,
188
+ clientX: number,
189
+ clientY: number,
190
+ pickAt: (clientX: number, clientY: number) => string | null,
191
+ setCursor: (cursor: EditorControlCursor) => void,
192
+ setHoverId: (id: string | null) => void,
193
+ ): void {
194
+ const cursor = scopedAdapter
195
+ ? null
196
+ : (getViewportPickContext()?.editorControls?.hover(clientX, clientY) ?? null);
197
+ setCursor(cursor);
198
+ setHoverId(cursor ? null : pickAt(clientX, clientY));
199
+ }
200
+
201
+ interface Rect {
202
+ x: number;
203
+ y: number;
204
+ width: number;
205
+ height: number;
206
+ }
207
+
208
+ /** `name·kind` label for a node. `hierarchy.node` IS merged/routed on the
209
+ * composite (unlike `rects`/`pickable`), so no owner lookup is needed here. */
210
+ export function labelForId(adapter: AuthoringAdapter, id: string): string {
211
+ const node = adapter.hierarchy.node(id);
212
+ return node ? `${node.label}·${node.kind}` : id;
213
+ }
214
+
215
+ /**
216
+ * True when `adapter` (or, for a composite, ANY of its children) exposes a
217
+ * `RectProvider` — the DOM-overlay capability signal (the contract's own doc
218
+ * comment on `AuthoringAdapter.rects`: "Absent ⇒ this adapter produces no
219
+ * rects (no overlay)"). Gates whether `RootSelectionOverlay` renders
220
+ * anything at all, so a pure-3D session (no react/pixi/DOM world in the
221
+ * active composite) is completely unaffected — matches spec §3 A3's wiring
222
+ * instruction ("gate on the active adapter exposing rects/pickable on any
223
+ * child, so 3D-only sessions are unaffected").
224
+ */
225
+ export function hasRectCapableChild(adapter: AuthoringAdapter): boolean {
226
+ if (adapter instanceof CompositeAuthoringAdapter) {
227
+ return adapter.childAdapters().some((c) => !!c.adapter.rects);
228
+ }
229
+ return !!adapter.rects;
230
+ }
231
+
232
+ /**
233
+ * Every (id, rect) pair reachable from `adapter`'s own `rects` provider — or,
234
+ * for a composite, from each VISIBLE child's own `rects` provider — the
235
+ * marquee's candidate pool. Walks each CHILD's OWN hierarchy
236
+ * (`collectAllNodeIds`), never the composite's synthetic `world:<id>`
237
+ * wrapper, so a group/organization id is never a candidate. A session-hidden
238
+ * world (`world-session-state.ts`'s eye toggle, B1) contributes no
239
+ * candidates — consistent with it being invisible/unpickable.
240
+ *
241
+ * D4.R1 — a LOCKED node (`child.inspector.get(id, 'locked') === true`, the
242
+ * same session-local `lockedIds` the layer-tree lock toggle writes) is also
243
+ * excluded: a marquee that merely overlaps a locked node's rect must not
244
+ * sweep it into the selection, matching the click-pick gate each override
245
+ * adapter's own `pickable.pick` now applies (`ReactRootAuthoringAdapter`/
246
+ * `DomAuthoringAdapter`/`UIAuthoringAdapter`). The first-party three
247
+ * adapter has no `rects` provider, so it never reaches this function at all
248
+ * — its own locked-skip lives entirely in `viewport-raycast.ts`.
249
+ */
250
+ export function collectMarqueeCandidates(
251
+ adapter: AuthoringAdapter,
252
+ ): Array<{ id: string; rect: DOMRectLike }> {
253
+ const children: ReadonlyArray<{ worldId: string; adapter: AuthoringAdapter }> =
254
+ adapter instanceof CompositeAuthoringAdapter
255
+ ? adapter.childAdapters()
256
+ : [{ worldId: '', adapter }];
257
+ const out: Array<{ id: string; rect: DOMRectLike }> = [];
258
+ for (const { worldId, adapter: child } of children) {
259
+ if (!child.rects) continue;
260
+ if (worldId && isRootHidden(worldId)) continue;
261
+ for (const id of collectAllNodeIds(child)) {
262
+ if (child.inspector?.get(id, 'locked') === true) continue;
263
+ const r = child.rects.rect(id);
264
+ if (r) out.push({ id, rect: r });
265
+ }
266
+ }
267
+ return out;
268
+ }
269
+
270
+ /** Axis-aligned overlap test (ANY overlap selects — not full containment;
271
+ * spec §3 A3: "select all nodes whose rect intersects"). */
272
+ export function rectsIntersect(a: Rect, b: DOMRectLike): boolean {
273
+ 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;
274
+ }
275
+
276
+ /** Pure selection-set math for a click (not drag) on a hit node — a plain
277
+ * click REPLACES the selection with just `hitId`; shift-click ADDS it if
278
+ * absent or TOGGLES it off if already selected. Factored out of the
279
+ * pointer-up handler so it's directly unit-testable (spec §3 A3's own
280
+ * testing note: "shift-click add/toggle vs replace"). */
281
+ export function resolveClickSelection(
282
+ current: ReadonlySet<string>,
283
+ hitId: string,
284
+ shiftKey: boolean,
285
+ ): string[] {
286
+ if (!shiftKey) return [hitId];
287
+ const next = new Set(current);
288
+ if (next.has(hitId)) next.delete(hitId);
289
+ else next.add(hitId);
290
+ return [...next];
291
+ }
292
+
293
+ /**
294
+ * Pure selection resolution for a gesture that STARTED on empty space (no
295
+ * pick at pointerdown): a real marquee drag (past the drag threshold)
296
+ * selects every candidate whose rect intersects the rubber-band — possibly
297
+ * none, which clears; a plain click (no meaningful drag) clears the
298
+ * selection, UNLESS shift is held, in which case it's a no-op (returns
299
+ * `null` — "leave the selection untouched" — so an accidental shift-click on
300
+ * empty space doesn't wipe out an existing multi-select).
301
+ */
302
+ export function resolveEmptySpaceSelection(
303
+ active: AuthoringAdapter,
304
+ moved: boolean,
305
+ marquee: Rect | null,
306
+ shiftKey: boolean,
307
+ ): string[] | null {
308
+ const isRealDrag =
309
+ moved && !!marquee && (marquee.width > DRAG_THRESHOLD_PX || marquee.height > DRAG_THRESHOLD_PX);
310
+ if (isRealDrag && marquee) {
311
+ const candidates = collectMarqueeCandidates(active);
312
+ return candidates.filter((c) => rectsIntersect(marquee, c.rect)).map((c) => c.id);
313
+ }
314
+ return shiftKey ? null : [];
315
+ }
316
+
317
+ /** D4 (spec27 §8 "space-pan" row) — the `interactionRef` cursor for the
318
+ * current pan/eyedropper/marquee state, pure (a plain priority chain, no
319
+ * DOM/store reads) — factored out purely to keep the component's own
320
+ * cognitive complexity down, same reason `computeSingleSelectionBoxEdit`/
321
+ * `computeD1Affordances` above are factored out. */
322
+ function interactionCursor(
323
+ isPanning: boolean,
324
+ spaceHeld: boolean,
325
+ eyedropperActive: boolean,
326
+ hasMarquee: boolean,
327
+ ): React.CSSProperties['cursor'] {
328
+ if (isPanning) return 'grabbing';
329
+ if (spaceHeld) return 'grab';
330
+ if (eyedropperActive || hasMarquee) return 'crosshair';
331
+ return 'default';
332
+ }
333
+
334
+ /** Pointer capture is unavailable for synthetic accessibility/test events and
335
+ * can also be rejected when a browser has already cancelled the native
336
+ * pointer. Gesture ownership still works while the pointer stays in bounds,
337
+ * so capture is a best-effort enhancement rather than a reason to abort. */
338
+ function capturePointer(e: ReactPointerEvent<Element>): void {
339
+ try {
340
+ e.currentTarget.setPointerCapture?.(e.pointerId);
341
+ } catch {
342
+ // Continue without capture; pointerup/cancel still completes in bounds.
343
+ }
344
+ }
345
+
346
+ /** Release an interaction pointer without adding another decision branch to
347
+ * each already-dense gesture completion path. */
348
+ function releaseCapturedPointer(e: ReactPointerEvent<HTMLElement>): void {
349
+ if (e.currentTarget.hasPointerCapture?.(e.pointerId)) {
350
+ e.currentTarget.releasePointerCapture?.(e.pointerId);
351
+ }
352
+ }
353
+
354
+ /** Resolve the board-level target that exists outside the active adapter's
355
+ * deliberately single-story DOM projection. */
356
+ function selectInactiveStoryFrame(
357
+ e: ReactPointerEvent<HTMLElement>,
358
+ hitAtDown: string | null,
359
+ ): boolean {
360
+ if (hitAtDown !== null) return false;
361
+ const overlayContainer = e.currentTarget.parentElement?.parentElement ?? null;
362
+ return selectReactStoryFrameAtPoint(overlayContainer, e.clientX, e.clientY);
363
+ }
364
+
365
+ function boxStyle(r: DOMRectLike, solid: boolean): React.CSSProperties {
366
+ return {
367
+ position: 'absolute',
368
+ left: r.x,
369
+ top: r.y,
370
+ width: r.width,
371
+ height: r.height,
372
+ border: `${solid ? 2 : 1}px solid ${ACCENT}`,
373
+ background: solid ? `color-mix(in srgb, ${ACCENT} 6%, transparent)` : 'transparent',
374
+ boxSizing: 'border-box',
375
+ pointerEvents: 'none',
376
+ };
377
+ }
378
+
379
+ function labelStyle(r: DOMRectLike): React.CSSProperties {
380
+ return {
381
+ position: 'absolute',
382
+ left: r.x,
383
+ top: Math.max(0, r.y - 18),
384
+ padding: '2px 6px',
385
+ fontSize: 11,
386
+ fontFamily: "'SF Mono', 'Fira Code', Menlo, Consolas, monospace",
387
+ fontWeight: 600,
388
+ color: '#fff',
389
+ background: ACCENT,
390
+ borderRadius: 3,
391
+ pointerEvents: 'none',
392
+ whiteSpace: 'nowrap',
393
+ };
394
+ }
395
+
396
+ const HANDLE_SIZE = 10;
397
+ const NATIVE_GIZMO_LENGTH_PX = 48;
398
+
399
+ /** One of the 8 B2 resize handles — a small white/themeVars.accent.default square centered on
400
+ * its position on the selection rect (spec:317/§5 :465). */
401
+ function handleStyle(center: { x: number; y: number }, cursor: string): React.CSSProperties {
402
+ return {
403
+ position: 'absolute',
404
+ left: center.x - HANDLE_SIZE / 2,
405
+ top: center.y - HANDLE_SIZE / 2,
406
+ width: HANDLE_SIZE,
407
+ height: HANDLE_SIZE,
408
+ background: '#fff',
409
+ border: `1px solid ${ACCENT}`,
410
+ boxSizing: 'border-box',
411
+ cursor,
412
+ pointerEvents: 'auto',
413
+ };
414
+ }
415
+
416
+ const ROTATE_SIZE = 12;
417
+ const ROTATE_OFFSET = 24;
418
+
419
+ /** The rotate handle — a small circle ~24px above top-center (spec:322/§5
420
+ * :465 "rotate"). */
421
+ function rotateHandleStyle(rect: DOMRectLike): React.CSSProperties {
422
+ return {
423
+ position: 'absolute',
424
+ left: rect.x + rect.width / 2 - ROTATE_SIZE / 2,
425
+ top: rect.y - ROTATE_OFFSET - ROTATE_SIZE / 2,
426
+ width: ROTATE_SIZE,
427
+ height: ROTATE_SIZE,
428
+ borderRadius: '50%',
429
+ background: '#fff',
430
+ border: `1px solid ${ACCENT}`,
431
+ boxSizing: 'border-box',
432
+ cursor: 'grab',
433
+ padding: 0,
434
+ pointerEvents: 'auto',
435
+ };
436
+ }
437
+
438
+ const SPATIAL_HANDLE_SIZE = 12;
439
+
440
+ /** One adapter-owned spatial handle: a small ring in the COLOR THE ADAPTER
441
+ * CHOSE, centered on the point it reported. The shell picks none of that —
442
+ * it draws the contract's own data — and a non-writable handle is drawn
443
+ * hollow and inert rather than hidden, because "this exists and you cannot
444
+ * move it" is the honest state (its `reason` is the title). */
445
+ function spatialHandleStyle(handle: SpatialDragHandle): React.CSSProperties {
446
+ return {
447
+ position: 'absolute',
448
+ left: handle.position[0],
449
+ top: handle.position[1],
450
+ width: SPATIAL_HANDLE_SIZE,
451
+ height: SPATIAL_HANDLE_SIZE,
452
+ borderRadius: '50%',
453
+ background: handle.writable ? handle.color : 'transparent',
454
+ border: `2px solid ${handle.color}`,
455
+ boxSizing: 'border-box',
456
+ cursor: handle.writable ? 'grab' : 'not-allowed',
457
+ padding: 0,
458
+ pointerEvents: handle.writable ? 'auto' : 'none',
459
+ touchAction: 'none',
460
+ };
461
+ }
462
+
463
+ function referencePointStyle(point: BoxEditReferencePoint): React.CSSProperties {
464
+ return {
465
+ position: 'absolute',
466
+ left: point.x - 8,
467
+ top: point.y - 8,
468
+ width: 16,
469
+ height: 16,
470
+ display: 'grid',
471
+ placeItems: 'center',
472
+ borderRadius: point.kind === 'anchor' ? '50%' : 2,
473
+ background: '#fff',
474
+ border: `1px solid ${ACCENT}`,
475
+ boxSizing: 'border-box',
476
+ color: ACCENT,
477
+ cursor: 'crosshair',
478
+ fontSize: 9,
479
+ fontFamily: "'SF Mono', 'Fira Code', Menlo, Consolas, monospace",
480
+ fontWeight: 700,
481
+ lineHeight: 1,
482
+ padding: 0,
483
+ pointerEvents: 'auto',
484
+ touchAction: 'none',
485
+ };
486
+ }
487
+
488
+ /** The B2 dimension label (spec:460 "dimension label") — `W×H`, centered
489
+ * below the selection box (reuses `labelStyle`'s look, different anchor). */
490
+ function dimensionLabelStyle(rect: DOMRectLike): React.CSSProperties {
491
+ return {
492
+ position: 'absolute',
493
+ left: rect.x,
494
+ top: rect.y + rect.height + 4,
495
+ padding: '2px 6px',
496
+ fontSize: 11,
497
+ fontFamily: "'SF Mono', 'Fira Code', Menlo, Consolas, monospace",
498
+ fontWeight: 600,
499
+ color: '#fff',
500
+ background: ACCENT,
501
+ borderRadius: 3,
502
+ pointerEvents: 'none',
503
+ whiteSpace: 'nowrap',
504
+ };
505
+ }
506
+
507
+ // Deliberate literals (U6 census): the padding/margin band pair, their label
508
+ // inks, the measurement pinks (`#ff2d92`/`#ff5fa2`) and the snap-guide red
509
+ // (`#f24822`) below are the industry-convention CANVAS-GUIDE language (green
510
+ // padding / orange margin / pink measure / red snap — Figma & devtools
511
+ // grammar), each deliberately distinct from the semantic selection accent so
512
+ // guides never read as selection or chrome.
513
+ const PADDING_BAND_COLOR = 'rgba(46, 204, 113, 0.25)';
514
+ const MARGIN_BAND_COLOR = 'rgba(230, 126, 34, 0.25)';
515
+
516
+ /** One B3 padding/margin spacing band (spec:332-335/§5 :460/:465-466). */
517
+ function spacingBandStyle(band: SpacingBand): React.CSSProperties {
518
+ return {
519
+ position: 'absolute',
520
+ left: band.x,
521
+ top: band.y,
522
+ width: band.width,
523
+ height: band.height,
524
+ background: band.kind === 'padding' ? PADDING_BAND_COLOR : MARGIN_BAND_COLOR,
525
+ boxSizing: 'border-box',
526
+ cursor: band.cursor,
527
+ pointerEvents: 'auto',
528
+ };
529
+ }
530
+
531
+ /** The B3 numeric label centered in a nonzero band — monospace ~10px,
532
+ * porting `ui-editor/overlay.tsx`'s dashed gap-measure label styling. */
533
+ function spacingLabelStyle(band: SpacingBand): React.CSSProperties {
534
+ return {
535
+ position: 'absolute',
536
+ left: band.labelX,
537
+ top: band.labelY,
538
+ transform: 'translate(-50%, -50%)',
539
+ fontSize: 10,
540
+ fontFamily: 'monospace',
541
+ color: band.kind === 'padding' ? '#1b7a3d' : '#a35a12',
542
+ background: 'rgba(255,255,255,0.85)',
543
+ padding: '0 2px',
544
+ borderRadius: 2,
545
+ pointerEvents: 'none',
546
+ whiteSpace: 'nowrap',
547
+ };
548
+ }
549
+
550
+ /** D2.a — the drag-ghost: a semi-transparent, INERT copy of the dragged
551
+ * element at its ghost rect (spec:399). Deliberately dashed + low-opacity
552
+ * fill so it reads as a "preview copy", never confusable with the live
553
+ * selection box (`boxStyle`'s solid ACCENT border) it's drawn alongside. */
554
+ function dragGhostStyle(rect: Rect): React.CSSProperties {
555
+ return {
556
+ position: 'absolute',
557
+ left: rect.x,
558
+ top: rect.y,
559
+ width: rect.width,
560
+ height: rect.height,
561
+ border: `2px dashed ${ACCENT}`,
562
+ background: `color-mix(in srgb, ${ACCENT} 15%, transparent)`,
563
+ boxSizing: 'border-box',
564
+ pointerEvents: 'none',
565
+ opacity: 0.7,
566
+ };
567
+ }
568
+
569
+ /** D2.b — the live reorder-preview: a thin marker line at the gap the drop
570
+ * would land in (spec:398-400). Distinct color from the drag-ghost/ACCENT
571
+ * selection chrome so the two affordances (which render SIMULTANEOUSLY
572
+ * during a reorder drag) stay visually distinguishable. */
573
+ function reorderMarkerStyle(marker: Rect): React.CSSProperties {
574
+ return {
575
+ position: 'absolute',
576
+ left: marker.x,
577
+ top: marker.y,
578
+ width: marker.width,
579
+ height: marker.height,
580
+ background: '#ff5fa2',
581
+ borderRadius: 2,
582
+ pointerEvents: 'none',
583
+ };
584
+ }
585
+
586
+ // --- D1 — measurement + snap + alignment guides + badges (spec §6 D1)
587
+ // ---------------------------------------------------------------------
588
+
589
+ const MEASURE_COLOR = '#ff2d92';
590
+ const MEASURE_CAP_PX = 6;
591
+
592
+ /** D1.a — the measure line itself: a 1px pink segment (figma "hold-and-
593
+ * measure" affordance, spec §6 "pink, end-caps, value labels"). */
594
+ function measureLineStyle(line: MeasureLine): React.CSSProperties {
595
+ const horizontal = line.orientation === 'horizontal';
596
+ return {
597
+ position: 'absolute',
598
+ left: Math.min(line.x1, line.x2),
599
+ top: Math.min(line.y1, line.y2),
600
+ width: horizontal ? Math.abs(line.x2 - line.x1) : 1,
601
+ height: horizontal ? 1 : Math.abs(line.y2 - line.y1),
602
+ background: MEASURE_COLOR,
603
+ pointerEvents: 'none',
604
+ };
605
+ }
606
+
607
+ /** D1.a — one of the measure line's two end-caps: a short perpendicular
608
+ * tick at `(x,y)`. */
609
+ function measureCapStyle(x: number, y: number, horizontal: boolean): React.CSSProperties {
610
+ return {
611
+ position: 'absolute',
612
+ left: horizontal ? x : x - MEASURE_CAP_PX / 2,
613
+ top: horizontal ? y - MEASURE_CAP_PX / 2 : y,
614
+ width: horizontal ? 1 : MEASURE_CAP_PX,
615
+ height: horizontal ? MEASURE_CAP_PX : 1,
616
+ background: MEASURE_COLOR,
617
+ pointerEvents: 'none',
618
+ };
619
+ }
620
+
621
+ /** D1.a — the measure line's px value label, centered on the line. */
622
+ function measureLabelStyle(line: MeasureLine): React.CSSProperties {
623
+ return {
624
+ position: 'absolute',
625
+ left: line.labelX,
626
+ top: line.labelY,
627
+ transform: 'translate(-50%, -50%)',
628
+ padding: '1px 4px',
629
+ fontSize: 10,
630
+ fontFamily: 'monospace',
631
+ fontWeight: 600,
632
+ color: '#fff',
633
+ background: MEASURE_COLOR,
634
+ borderRadius: 2,
635
+ pointerEvents: 'none',
636
+ whiteSpace: 'nowrap',
637
+ };
638
+ }
639
+
640
+ const SNAP_GUIDE_COLOR = '#f24822';
641
+
642
+ /** D1.b — one active alignment/snap guide, live only while the underlying
643
+ * gesture's own edge-snap is engaged (spec §6 "guides appear only within
644
+ * the snap threshold; disappear when not snapping"). Dashed so it reads as
645
+ * a transient alignment aid, distinct from the solid pink measure line. */
646
+ function snapGuideStyle(guide: SnapGuide): React.CSSProperties {
647
+ const vertical = guide.orientation === 'vertical';
648
+ return {
649
+ position: 'absolute',
650
+ left: vertical ? guide.at : guide.start,
651
+ top: vertical ? guide.start : guide.at,
652
+ width: vertical ? 1 : guide.end - guide.start,
653
+ height: vertical ? guide.end - guide.start : 1,
654
+ borderLeft: vertical ? `1px dashed ${SNAP_GUIDE_COLOR}` : undefined,
655
+ borderTop: !vertical ? `1px dashed ${SNAP_GUIDE_COLOR}` : undefined,
656
+ pointerEvents: 'none',
657
+ };
658
+ }
659
+
660
+ /** D1.c — the position/parent-layout badge, anchored just above-left of the
661
+ * selection (mirrors `labelStyle`'s anchor, offset further up so it never
662
+ * overlaps the `name·kind` label A3 already renders there). */
663
+ function positionBadgeStyle(r: DOMRectLike): React.CSSProperties {
664
+ return {
665
+ position: 'absolute',
666
+ left: r.x,
667
+ top: Math.max(0, r.y - 36),
668
+ display: 'flex',
669
+ gap: 4,
670
+ pointerEvents: 'none',
671
+ };
672
+ }
673
+
674
+ const BADGE_SEGMENT_STYLE: React.CSSProperties = {
675
+ padding: '2px 6px',
676
+ fontSize: 10,
677
+ fontFamily: "'SF Mono', 'Fira Code', Menlo, Consolas, monospace",
678
+ fontWeight: 600,
679
+ color: themeVars.content.onAccent,
680
+ background: `color-mix(in srgb, ${ACCENT} 85%, transparent)`,
681
+ borderRadius: 3,
682
+ whiteSpace: 'nowrap',
683
+ };
684
+
685
+ /** D1.c — the parent-container outline: a subtle dashed stroke around
686
+ * `rect` (spec §6 "outline the parent container rect ... with a subtle
687
+ * stroke"). */
688
+ function parentOutlineStyle(r: DOMRectLike): React.CSSProperties {
689
+ return {
690
+ position: 'absolute',
691
+ left: r.x,
692
+ top: r.y,
693
+ width: r.width,
694
+ height: r.height,
695
+ border: `1px dashed color-mix(in srgb, ${ACCENT} 45%, transparent)`,
696
+ boxSizing: 'border-box',
697
+ pointerEvents: 'none',
698
+ };
699
+ }
700
+
701
+ /**
702
+ * A single active B2/B3 gesture — resize/move/rotate/spacing — bracketed by
703
+ * `ownerBoxEdit.begin/end`. Kept SEPARATE from {@link DragState} (A3's click/
704
+ * marquee tracking): a gesture and a click/marquee-drag are mutually
705
+ * exclusive per pointer session and want independent lifecycles (spec §4 B2
706
+ * step 4's own instruction).
707
+ */
708
+ interface GestureState {
709
+ kind: 'resize' | 'move' | 'native-scale' | 'reference' | 'rotate' | 'spacing';
710
+ id: string;
711
+ ownerBoxEdit: BoxEditProvider;
712
+ /** Gesture start point, in the SAME coordinate frame as `origRect`/`center`
713
+ * (host-local) — see `computeRotatePatch`'s doc comment on frame
714
+ * consistency. Resize/move only need the DELTA (frame-invariant under a
715
+ * fixed additive offset), so they tolerate either frame; rotate does not,
716
+ * so this file always seeds it host-local for every kind, uniformly. */
717
+ startLocal: { x: number; y: number };
718
+ origRect: DOMRectLike;
719
+ pos?: HandlePos;
720
+ isPositioned?: boolean;
721
+ center?: { x: number; y: number };
722
+ context?: ReturnType<typeof contextRectsForId>;
723
+ side?: SpacingSide;
724
+ origValue?: number;
725
+ pointerId?: number;
726
+ referencePoint?: BoxEditReferencePoint;
727
+ /** Native 2D move gizmo constraint. Board drags omit it and move freely. */
728
+ moveAxis?: 'x' | 'y' | 'both';
729
+ /** Native display-object position in stage space. Unlike a visual bounds
730
+ * center, this remains the actual rotate/scale origin for anchored sprites
731
+ * and pivoted containers. */
732
+ nativeOrigin?: { x: number; y: number };
733
+ nativeScaleAxis?: 'x' | 'y' | 'both';
734
+ nativeHandleSpan?: number;
735
+ /** Other selected nodes moved by the same direct-manipulation gesture. */
736
+ movePeers?: readonly MovePeer[];
737
+ }
738
+
739
+ interface MovePeer {
740
+ readonly id: string;
741
+ readonly ownerBoxEdit: BoxEditProvider;
742
+ readonly origRect: DOMRectLike;
743
+ readonly nativeOrigin?: { x: number; y: number };
744
+ }
745
+
746
+ function snapNativeTransform(value: number, enabled: boolean, step: number): number {
747
+ return enabled && step > 0 ? Math.round(value / step) * step : value;
748
+ }
749
+
750
+ function nativeScalePatch(
751
+ gesture: GestureState,
752
+ dx: number,
753
+ dy: number,
754
+ snapEnabled: boolean,
755
+ altKey: boolean,
756
+ scaleStep: number,
757
+ ): Record<string, number> {
758
+ const span = gesture.nativeHandleSpan ?? 1;
759
+ const axis = gesture.nativeScaleAxis ?? 'both';
760
+ let scaleXFactor: number;
761
+ let scaleYFactor: number;
762
+ if (axis === 'x') {
763
+ scaleXFactor = 1 + dx / span;
764
+ scaleYFactor = 1;
765
+ } else if (axis === 'y') {
766
+ scaleXFactor = 1;
767
+ scaleYFactor = 1 + dy / span;
768
+ } else {
769
+ // The uniform handle sits one `span` from the origin on a 45-degree
770
+ // axis. Project the drag onto that axis so moving the handle one
771
+ // handle-length doubles the scale, just like either axis handle.
772
+ const factor = 1 + (dx + dy) / (Math.SQRT2 * span);
773
+ scaleXFactor = factor;
774
+ scaleYFactor = factor;
775
+ }
776
+ return {
777
+ ...(axis !== 'y' ? { scaleXFactor } : {}),
778
+ ...(axis !== 'x' ? { scaleYFactor } : {}),
779
+ ...(snapEnabled && !altKey ? { scaleStep } : {}),
780
+ };
781
+ }
782
+
783
+ function nativeMovePatch(
784
+ gesture: GestureState,
785
+ dx: number,
786
+ dy: number,
787
+ snapEnabled: boolean,
788
+ altKey: boolean,
789
+ translateStep: number,
790
+ ): Record<string, number> {
791
+ const origin = gesture.nativeOrigin;
792
+ if (!origin) return {};
793
+ const snap = snapEnabled && !altKey;
794
+ return {
795
+ ...(gesture.moveAxis !== 'y'
796
+ ? { originX: snapNativeTransform(origin.x + dx, snap, translateStep) }
797
+ : {}),
798
+ ...(gesture.moveAxis !== 'x'
799
+ ? { originY: snapNativeTransform(origin.y + dy, snap, translateStep) }
800
+ : {}),
801
+ };
802
+ }
803
+
804
+ function nativeScaleHandlePoint(
805
+ axis: 'x' | 'y' | 'both',
806
+ origin: { x: number; y: number },
807
+ span: number,
808
+ ): { x: number; y: number } {
809
+ if (axis === 'x') return { x: origin.x + span, y: origin.y };
810
+ if (axis === 'y') return { x: origin.x, y: origin.y + span };
811
+ return { x: origin.x + span / Math.SQRT2, y: origin.y + span / Math.SQRT2 };
812
+ }
813
+
814
+ function constrainedMoveDelta(
815
+ axis: GestureState['moveAxis'],
816
+ dx: number,
817
+ dy: number,
818
+ ): { dx: number; dy: number } {
819
+ if (axis === 'x') return { dx, dy: 0 };
820
+ if (axis === 'y') return { dx: 0, dy };
821
+ return { dx, dy };
822
+ }
823
+
824
+ /** D2 — promote a deferred move CANDIDATE (`DragState.moveCandidate`) into a
825
+ * live `move` gesture once the drag threshold is crossed: begins the
826
+ * gesture on the owner (one side effect) and constructs the `GestureState`,
827
+ * seeded from the pointer-DOWN point (`startLocal`) so the move delta stays
828
+ * continuous. Factored out of `onPointerMove` (module-level, not a nested
829
+ * closure) purely to keep that handler's own cognitive complexity down. */
830
+ function promoteMoveCandidate(
831
+ candidate: NonNullable<DragState['moveCandidate']>,
832
+ startLocal: { x: number; y: number },
833
+ ): GestureState {
834
+ candidate.ownerBoxEdit.begin(candidate.id);
835
+ for (const peer of candidate.peers) peer.ownerBoxEdit.begin(peer.id);
836
+ return {
837
+ kind: 'move',
838
+ id: candidate.id,
839
+ ownerBoxEdit: candidate.ownerBoxEdit,
840
+ startLocal,
841
+ origRect: candidate.origRect,
842
+ context: candidate.context,
843
+ ...(candidate.nativeOrigin ? { nativeOrigin: candidate.nativeOrigin } : {}),
844
+ movePeers: candidate.peers,
845
+ };
846
+ }
847
+
848
+ interface DragState {
849
+ readonly downClientX: number;
850
+ readonly downClientY: number;
851
+ readonly downLocal: { x: number; y: number };
852
+ /** The pick result AT pointerdown — decides click-select vs. marquee. */
853
+ readonly hitAtDown: string | null;
854
+ moved: boolean;
855
+ /**
856
+ * D2 — a DEFERRED move-gesture candidate, recorded at pointerdown when the
857
+ * press landed on an already-selected, absolutely/fixed-positioned,
858
+ * boxEdit-capable node, but NOT promoted to a live gesture until the pointer
859
+ * crosses the drag threshold (`onPointerMove`). A pointerup BEFORE the
860
+ * threshold falls through to the ordinary click-select path — so a plain
861
+ * click still narrows a multi-selection to the clicked node and a shift-click
862
+ * still toggles it (the regression Fable found: the old code began the move
863
+ * gesture at pointerdown unconditionally, and `endGesture()` on pointerup
864
+ * skipped `resolveClickSelection`, swallowing every click on a positioned
865
+ * node). `null` when the press was not a move candidate. */
866
+ readonly moveCandidate: {
867
+ id: string;
868
+ ownerBoxEdit: BoxEditProvider;
869
+ origRect: DOMRectLike;
870
+ context?: ReturnType<typeof contextRectsForId>;
871
+ nativeOrigin?: { x: number; y: number };
872
+ peers: readonly MovePeer[];
873
+ } | null;
874
+ /**
875
+ * D2.b (spec:397-401) — a DEFERRED reorder-preview candidate, recorded at
876
+ * pointerdown under the SAME "already selected" gate {@link moveCandidate}
877
+ * uses, but for the opposite geometry: a FLOW/LIST child (no `boxEdit`
878
+ * move applies — {@link resolveMoveCandidate} already returned `null` for
879
+ * it, since it's neither absolute nor fixed) whose owner exposes
880
+ * `structure.reorder` and has ≥3 total siblings (itself + ≥2 others —
881
+ * spec:398 "drag a child among ≥3 siblings"). Only ONE of
882
+ * `moveCandidate`/`reorderCandidate` is ever non-null for a given press —
883
+ * see `onPointerDown`'s own ordering (move is checked first; a positioned
884
+ * node's owner may ALSO have `structure`, but this spec's move/reorder
885
+ * split is by POSITIONING, not by capability overlap). `null` when the
886
+ * press was not a reorder candidate. */
887
+ readonly reorderCandidate: {
888
+ id: string;
889
+ adapter: AuthoringAdapter;
890
+ origRect: DOMRectLike;
891
+ siblingRects: SiblingRectEntry[];
892
+ } | null;
893
+ }
894
+
895
+ /**
896
+ * D2 (spec:322) — resolve a pointerdown hit into a deferred move-gesture
897
+ * CANDIDATE (see `DragState.moveCandidate`'s doc comment for the full
898
+ * rationale): only when `hitId` is already selected, its owner exposes
899
+ * `boxEdit`, and it is absolutely/fixed-positioned or a native 2D transform
900
+ * subject. Factored out of
901
+ * `onPointerDown` (a module-level function, not a nested closure) purely to
902
+ * keep that handler's own cognitive complexity down — pulls no state, reads
903
+ * only its arguments.
904
+ */
905
+ /**
906
+ * Figma semantics for a press INSIDE a selection: the deepest pick still wins
907
+ * for click-select (a release before the drag threshold descends as today),
908
+ * but a DRAG grabs the SELECTED node whose subtree the press landed in. The
909
+ * DOM board made this the common case — `pickable.pick` is
910
+ * smallest-area-wins, so a second press on an already-selected panel resolves
911
+ * to one of its static children and `selectedEntityIds.has(hitAtDown)` never
912
+ * held: no move (or reorder) candidate could form on any node with children
913
+ * (measured on the parity re-walk, 2026-08-30 — six drag variants on an
914
+ * absolutely-positioned HUD panel all fell through to click-select).
915
+ */
916
+ function nearestSelectedAncestor(
917
+ adapter: AuthoringAdapter,
918
+ hitId: string,
919
+ selectedIds: ReadonlySet<string>,
920
+ ): string | null {
921
+ let cur: string | null = hitId;
922
+ for (let hops = 0; cur !== null && hops < 256; hops++) {
923
+ if (selectedIds.has(cur)) return cur;
924
+ cur = adapter.hierarchy.node(cur)?.parentId ?? null;
925
+ }
926
+ return null;
927
+ }
928
+
929
+ function resolveMoveCandidate(
930
+ active: AuthoringAdapter,
931
+ hitId: string,
932
+ selectedIds: ReadonlySet<string>,
933
+ ): DragState['moveCandidate'] {
934
+ const owner = boxEditForId(active, hitId);
935
+ if (!owner) return null;
936
+ const posValue = active.inspector?.get(hitId, 'style.position');
937
+ const native2D = transformDimensionsFor(active, hitId) === '2d';
938
+ if (posValue !== 'absolute' && posValue !== 'fixed' && !native2D) return null;
939
+ const rect = rectForId(active, hitId);
940
+ if (!rect) return null;
941
+ const nativeOrigin = native2D ? (owner.gizmoOrigin?.(hitId) ?? undefined) : undefined;
942
+ const peers: MovePeer[] = [];
943
+ for (const id of selectedIds) {
944
+ if (id === hitId) continue;
945
+ const peerOwner = boxEditForId(active, id);
946
+ const peerRect = rectForId(active, id);
947
+ if (!peerOwner || !peerRect) continue;
948
+ const peerPosition = active.inspector?.get(id, 'style.position');
949
+ const peerNative = transformDimensionsFor(active, id) === '2d';
950
+ if (peerPosition !== 'absolute' && peerPosition !== 'fixed' && !peerNative) continue;
951
+ const peerOrigin = peerNative ? (peerOwner.gizmoOrigin?.(id) ?? undefined) : undefined;
952
+ peers.push({
953
+ id,
954
+ ownerBoxEdit: peerOwner,
955
+ origRect: peerRect,
956
+ ...(peerOrigin ? { nativeOrigin: peerOrigin } : {}),
957
+ });
958
+ }
959
+ return {
960
+ id: hitId,
961
+ ownerBoxEdit: owner,
962
+ origRect: rect,
963
+ context: contextRectsForId(active, hitId),
964
+ ...(nativeOrigin ? { nativeOrigin } : {}),
965
+ peers,
966
+ };
967
+ }
968
+
969
+ /**
970
+ * D2.b (spec:397-401) — resolve a pointerdown hit into a deferred
971
+ * reorder-preview CANDIDATE (see `DragState.reorderCandidate`'s doc comment):
972
+ * only when `hitId`'s OWNING adapter exposes `structure.reorder` (capability
973
+ * gate — the ingested react-dom adapter has no `structure` at all, so this
974
+ * always returns `null` there, per this task's own capability-gating
975
+ * precedent) AND it has ≥3 total siblings (itself + ≥2 others). Sibling ids
976
+ * come from {@link siblingIdsForId} (the owner's OWN `hierarchy`, ordered);
977
+ * each sibling's CURRENT rect is read via {@link rectForId} (same
978
+ * owner-routing `resolveMoveCandidate` uses) — a sibling with no rect
979
+ * (unmounted/offscreen) is simply excluded from the gap math, never a throw.
980
+ * Factored out of `onPointerDown` for the same cognitive-complexity reason
981
+ * `resolveMoveCandidate` is.
982
+ */
983
+ function resolveReorderCandidate(
984
+ active: AuthoringAdapter,
985
+ hitId: string,
986
+ ): DragState['reorderCandidate'] {
987
+ const structure = structureForId(active, hitId);
988
+ if (!structure?.reorder) return null;
989
+ const siblingIds = siblingIdsForId(active, hitId);
990
+ if (!siblingIds || siblingIds.length < 3) return null;
991
+ const origRect = rectForId(active, hitId);
992
+ if (!origRect) return null;
993
+ const siblingRects: SiblingRectEntry[] = [];
994
+ for (const sid of siblingIds) {
995
+ if (sid === hitId) continue;
996
+ const r = rectForId(active, sid);
997
+ if (r) siblingRects.push({ id: sid, rect: r });
998
+ }
999
+ if (siblingRects.length === 0) return null;
1000
+ return { id: hitId, adapter: active, origRect, siblingRects };
1001
+ }
1002
+
1003
+ /** True while a REAL game (Play, an ingested unmodified game, or an
1004
+ * auto-mounted `{ module }` adapter world) owns the canvas and needs its own
1005
+ * pointer events — the overlay must be fully inert then (see the call
1006
+ * site's doc comment for the regression this guards). Factored into its own
1007
+ * function purely to keep the caller's cognitive complexity down. */
1008
+ function isCanvasOwnedByARunningGame(): boolean {
1009
+ return anyLiveSessionMounted();
1010
+ }
1011
+
1012
+ /**
1013
+ * B2/B3 — the single-selection box-edit bundle (handles/rotate/dimension-
1014
+ * label/spacing-bands), or `null` when it doesn't apply: these render ONLY
1015
+ * for EXACTLY one selected node whose OWNER exposes `boxEdit` (spec:317).
1016
+ * Factored out of the component purely to keep ITS cognitive complexity
1017
+ * down — pure, reads only its arguments.
1018
+ */
1019
+ function computeSingleSelectionBoxEdit(
1020
+ adapter: AuthoringAdapter,
1021
+ selectedIds: ReadonlySet<string>,
1022
+ ): {
1023
+ id: string;
1024
+ rect: DOMRectLike;
1025
+ ownerBoxEdit: BoxEditProvider;
1026
+ spacingBands: SpacingBand[];
1027
+ } | null {
1028
+ if (selectedIds.size !== 1) return null;
1029
+ const id = [...selectedIds][0]!;
1030
+ const ownerBoxEdit = boxEditForId(adapter, id);
1031
+ if (!ownerBoxEdit) return null;
1032
+ const rect = rectForId(adapter, id);
1033
+ if (!rect) return null;
1034
+ const spacingBands = computeSpacingBands(rect, readSpacingValues(adapter, id));
1035
+ return { id, rect, ownerBoxEdit, spacingBands };
1036
+ }
1037
+
1038
+ /**
1039
+ * D4.b (spec §6 D4 "a multi-select align/distribute toolbar ... shown when
1040
+ * ≥2 nodes are selected") — the `AlignToolbar`'s entries, or `null` when it
1041
+ * shouldn't render: fewer than 2 selected, OR any selected id fails to
1042
+ * resolve BOTH a rect and an owning `boxEdit` (spec:305's own "capability-
1043
+ * gated: only when adapter.boxEdit is present" — an all-or-nothing gate, so
1044
+ * a mixed selection spanning a boxEdit-less world never offers a partially-
1045
+ * broken toolbar). Factored out purely to keep the component's own
1046
+ * cognitive complexity down, same reason `computeSingleSelectionBoxEdit`
1047
+ * above is.
1048
+ */
1049
+ function computeMultiSelectionAlign(
1050
+ adapter: AuthoringAdapter,
1051
+ selectedIds: ReadonlySet<string>,
1052
+ ): AlignEntry[] | null {
1053
+ if (selectedIds.size < 2) return null;
1054
+ const entries: AlignEntry[] = [];
1055
+ for (const id of selectedIds) {
1056
+ const rect = rectForId(adapter, id);
1057
+ if (!rect) return null;
1058
+ if (!boxEditForId(adapter, id)) return null;
1059
+ entries.push({ id, rect });
1060
+ }
1061
+ return entries;
1062
+ }
1063
+
1064
+ /**
1065
+ * D1 (spec §6) — measure lines, position/parent-layout badge, and
1066
+ * parent-container outline, all READ-only (rects + `inspector.get`) so —
1067
+ * unlike {@link computeSingleSelectionBoxEdit}'s handles/bands — available
1068
+ * for ANY adapter exposing `rects`, not gated on the owner also exposing
1069
+ * `boxEdit`. Only for EXACTLY one selected node (spec §6's own framing:
1070
+ * "between selection and hover" / "a small badge near the selection" both
1071
+ * presume a single selection). Factored out of the component purely to keep
1072
+ * ITS cognitive complexity down — pure given its arguments.
1073
+ */
1074
+ function computeD1Affordances(
1075
+ adapter: AuthoringAdapter,
1076
+ selectedIds: ReadonlySet<string>,
1077
+ hoverRect: DOMRectLike | null,
1078
+ ): {
1079
+ selectedSingleRect: DOMRectLike | null;
1080
+ measureLines: MeasureLine[];
1081
+ positionBadge: PositionBadgeInfo | null;
1082
+ showPositionBadge: boolean;
1083
+ parentRect: DOMRectLike | null;
1084
+ } {
1085
+ const selectedSingleId = selectedIds.size === 1 ? [...selectedIds][0]! : null;
1086
+ const selectedSingleRect = selectedSingleId ? rectForId(adapter, selectedSingleId) : null;
1087
+ // D1.a — sibling-distance measure lines between the selection and the
1088
+ // CURRENTLY HOVERED sibling (spec §6 "between selection and hover"). `[]`
1089
+ // whenever either rect is missing or the two rects don't leave a gap on
1090
+ // either axis (`computeMeasureLines`'s own honest-empty-result doc
1091
+ // comment) — never a fabricated line.
1092
+ const measureLines =
1093
+ selectedSingleRect && hoverRect ? computeMeasureLines(selectedSingleRect, hoverRect) : [];
1094
+ // D1.c — each segment resolves independently; `null`/`null` renders no
1095
+ // badge at all (never a fabricated default — spec's own honesty rule).
1096
+ const positionBadge: PositionBadgeInfo | null = selectedSingleId
1097
+ ? resolvePositionBadge(adapter, selectedSingleId)
1098
+ : null;
1099
+ const showPositionBadge = !!(
1100
+ positionBadge &&
1101
+ (positionBadge.position || positionBadge.parentLayout)
1102
+ );
1103
+ // D1.c — the parent container's own rect (`rects.contextRects(id).parent`,
1104
+ // routed through the SAME owner-child helper the B2 move/resize gestures
1105
+ // already use for this exact field) — `null` when the selection has no
1106
+ // resolvable parent rect (a top-level node, or the owner has no
1107
+ // `contextRects`), in which case no outline renders at all.
1108
+ const parentRect = selectedSingleId
1109
+ ? (contextRectsForId(adapter, selectedSingleId)?.parent ?? null)
1110
+ : null;
1111
+ return { selectedSingleRect, measureLines, positionBadge, showPositionBadge, parentRect };
1112
+ }
1113
+
1114
+ function ViewportPickMenu({
1115
+ state,
1116
+ adapter,
1117
+ onClose,
1118
+ }: {
1119
+ state: { x: number; y: number; ids: readonly string[] };
1120
+ adapter: AuthoringAdapter;
1121
+ onClose: () => void;
1122
+ }): React.ReactNode {
1123
+ const ref = useRef<HTMLDivElement>(null);
1124
+ useEffect(() => {
1125
+ const onPointerDown = (event: PointerEvent) => {
1126
+ if (!ref.current?.contains(event.target as Node)) onClose();
1127
+ };
1128
+ const onKeyDown = (event: KeyboardEvent) => {
1129
+ if (event.key === 'Escape') onClose();
1130
+ };
1131
+ document.addEventListener('pointerdown', onPointerDown);
1132
+ document.addEventListener('keydown', onKeyDown);
1133
+ return () => {
1134
+ document.removeEventListener('pointerdown', onPointerDown);
1135
+ document.removeEventListener('keydown', onKeyDown);
1136
+ };
1137
+ }, [onClose]);
1138
+
1139
+ return (
1140
+ <ThemeRootPortal>
1141
+ <Menu
1142
+ ref={ref}
1143
+ aria-label="Pick object under pointer"
1144
+ data-testid="viewport-pick-menu"
1145
+ style={{
1146
+ position: 'fixed',
1147
+ left: state.x,
1148
+ top: state.y,
1149
+ minWidth: 190,
1150
+ maxWidth: 320,
1151
+ zIndex: zIndex.dropdown,
1152
+ }}
1153
+ >
1154
+ {state.ids.map((id) => {
1155
+ const node = adapter.hierarchy.node(id);
1156
+ return (
1157
+ <MenuItem
1158
+ key={id}
1159
+ data-testid="viewport-pick-menu-item"
1160
+ onSelect={() => {
1161
+ setAuthoringSelection(adapter, [id]);
1162
+ onClose();
1163
+ }}
1164
+ style={{ justifyContent: 'space-between', gap: 20 }}
1165
+ >
1166
+ <span>{node?.label ?? id}</span>
1167
+ <span style={{ color: themeVars.content.muted, fontSize: 11 }}>
1168
+ {node?.kind ?? ''}
1169
+ </span>
1170
+ </MenuItem>
1171
+ );
1172
+ })}
1173
+ </Menu>
1174
+ </ThemeRootPortal>
1175
+ );
1176
+ }
1177
+
1178
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one cohesive overlay owns the shared pointer-state machine and its adapter-routed affordances
1179
+ export function RootSelectionOverlay({
1180
+ adapter: scopedAdapter,
1181
+ view: suppliedView,
1182
+ transformModeAware = false,
1183
+ }: {
1184
+ adapter?: AuthoringAdapter;
1185
+ /** Presentation camera for this document. Native Canvas scenes pass their
1186
+ * own controller; DOM component boards use the shared board camera. */
1187
+ view?: RootViewController;
1188
+ /** Native scene toolbars use explicit Move/Rotate/Scale modes. Component
1189
+ * boards retain their Figma-style all-handles-at-once interaction. */
1190
+ transformModeAware?: boolean;
1191
+ } = {}): React.ReactNode {
1192
+ const store = useEditorStore();
1193
+ const storeVersion = useSyncExternalStore(store.subscribe, store.getSnapshot);
1194
+ const adapter = scopedAdapter ?? getActiveAuthoring(store);
1195
+ const pickAt = useCallback(
1196
+ (clientX: number, clientY: number): string | null =>
1197
+ scopedAdapter
1198
+ ? (scopedAdapter.pickable?.pick(clientX, clientY) ?? null)
1199
+ : pickTopmost(store, clientX, clientY),
1200
+ [scopedAdapter, store],
1201
+ );
1202
+
1203
+ const interactionRef = useRef<HTMLDivElement>(null);
1204
+ const dragRef = useRef<DragState | null>(null);
1205
+ const marqueeRef = useRef<Rect | null>(null);
1206
+ const [marquee, setMarqueeState] = useState<Rect | null>(null);
1207
+ const [hoverId, setHoverId] = useState<string | null>(null);
1208
+ const [editorControlCursor, setEditorControlCursor] = useState<EditorControlCursor>(null);
1209
+ const [pickMenu, setPickMenu] = useState<{
1210
+ x: number;
1211
+ y: number;
1212
+ ids: readonly string[];
1213
+ } | null>(null);
1214
+ // Right-button navigation belongs to the spatial viewport underneath this
1215
+ // overlay (Three OrbitControls or the native Canvas camera). Track only the
1216
+ // click-vs-drag distinction here so the shared overlap menu never opens at
1217
+ // the end of an orbit/pan. PointerEvent button ids and movement are the same
1218
+ // on macOS and Windows; no platform modifier belongs in this decision.
1219
+ const secondaryPressRef = useRef<{
1220
+ pointerId: number;
1221
+ startClientX: number;
1222
+ startClientY: number;
1223
+ moved: boolean;
1224
+ } | null>(null);
1225
+ const openPickMenuAt = useCallback(
1226
+ (clientX: number, clientY: number) => {
1227
+ const ids = pickCandidates(store, clientX, clientY, {
1228
+ ...(scopedAdapter ? { adapter: scopedAdapter } : {}),
1229
+ });
1230
+ if (ids.length === 0) {
1231
+ setPickMenu(null);
1232
+ return;
1233
+ }
1234
+ setPickMenu({ x: clientX, y: clientY, ids });
1235
+ },
1236
+ [scopedAdapter, store],
1237
+ );
1238
+
1239
+ useEffect(() => {
1240
+ const onPointerMove = (event: PointerEvent): void => {
1241
+ const press = secondaryPressRef.current;
1242
+ if (!press || press.pointerId !== event.pointerId || press.moved) return;
1243
+ if (
1244
+ Math.hypot(event.clientX - press.startClientX, event.clientY - press.startClientY) >
1245
+ DRAG_THRESHOLD_PX
1246
+ ) {
1247
+ press.moved = true;
1248
+ }
1249
+ };
1250
+ const finish = (event: PointerEvent): void => {
1251
+ const press = secondaryPressRef.current;
1252
+ if (!press || press.pointerId !== event.pointerId) return;
1253
+ secondaryPressRef.current = null;
1254
+ // Chromium does not order `contextmenu` consistently across desktop
1255
+ // platforms: macOS can emit it as soon as the secondary button goes
1256
+ // down, before this press has had a chance to become a drag. Opening the
1257
+ // editor menu from that event therefore makes right-drag panning
1258
+ // impossible on macOS. The native event is suppressed below; release is
1259
+ // the one cross-platform decision point. A cancelled press opens
1260
+ // nothing, a moved press was navigation, and only a stationary release
1261
+ // is the overlap menu gesture.
1262
+ if (event.type === 'pointerup' && !press.moved) {
1263
+ openPickMenuAt(event.clientX, event.clientY);
1264
+ }
1265
+ };
1266
+ window.addEventListener('pointermove', onPointerMove);
1267
+ window.addEventListener('pointerup', finish);
1268
+ window.addEventListener('pointercancel', finish);
1269
+ return () => {
1270
+ window.removeEventListener('pointermove', onPointerMove);
1271
+ window.removeEventListener('pointerup', finish);
1272
+ window.removeEventListener('pointercancel', finish);
1273
+ };
1274
+ }, [openPickMenuAt]);
1275
+
1276
+ const onContextMenu = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
1277
+ // This event is prevention-only. Stationary-click versus pan is decided
1278
+ // on pointer release above, after the movement threshold has an honest
1279
+ // answer on both macOS and Windows.
1280
+ event.preventDefault();
1281
+ event.stopPropagation();
1282
+ }, []);
1283
+
1284
+ // D4 (spec27 §8 "space-pan" row) — the shared pan + zoom, applied to the
1285
+ // visual-chrome wrapper below (`world-pan-state.ts` applies the identical
1286
+ // value to every design-time world-layer host — see that module's doc
1287
+ // comment for the lockstep-transform soundness argument). The interaction
1288
+ // surface deliberately stays OUTSIDE that transformed wrapper: input must
1289
+ // cover the complete viewport at every zoom level, like a design canvas.
1290
+ // `useSyncExternalStore` so a pan change (from THIS component's own
1291
+ // space-drag below, or a reset) re-renders immediately.
1292
+ const view = suppliedView ?? sharedRootViewController;
1293
+ const pan = useSyncExternalStore(view.subscribe, view.get, view.get);
1294
+ useSyncExternalStore(
1295
+ useCallback((listener) => subscribeCanvasSceneGuides(view, listener), [view]),
1296
+ useCallback(() => canvasSceneGuideRevision(view), [view]),
1297
+ );
1298
+ const nativeGuides = transformModeAware ? canvasSceneGuides(view) : [];
1299
+ // Native scene chrome is measured in SCREEN pixels even though its anchors
1300
+ // live in world space: zooming should enlarge the subject, not its labels or
1301
+ // grab targets. DOM boards keep their historical canvas-scaled chrome.
1302
+ const chromeScale = transformModeAware ? 1 / pan.zoom : 1;
1303
+ // True while the Space key is physically held (and not typing — see the
1304
+ // keydown/keyup effect below), the hold-to-pan gesture's arm switch.
1305
+ // `spaceHeldRef` mirrors the state for a synchronous read in the pointer
1306
+ // handlers (same "ref mirrors state" convention `marqueeRef`/`textEditRef`
1307
+ // already use in this file).
1308
+ const spaceHeldRef = useRef(false);
1309
+ const [spaceHeld, setSpaceHeld] = useState(false);
1310
+ // The active space-drag pan gesture, or `null` between drags — kept
1311
+ // SEPARATE from `dragRef`/`gestureRef` (mutually exclusive per pointer
1312
+ // session, same reasoning `GestureState`'s own doc comment gives for being
1313
+ // separate from `DragState`): a pan-drag never picks/selects/resizes, it
1314
+ // only ever writes `world-pan-state.ts`.
1315
+ const panDragRef = useRef<{
1316
+ startClientX: number;
1317
+ startClientY: number;
1318
+ startPan: { x: number; y: number };
1319
+ } | null>(null);
1320
+ const [isPanning, setIsPanning] = useState(false);
1321
+
1322
+ // B2/B3 — the currently-active resize/move/rotate/spacing gesture, or
1323
+ // `null` between gestures (see `GestureState`'s doc comment for why this is
1324
+ // a SEPARATE ref from `dragRef`/A3's click-marquee tracking).
1325
+ const gestureRef = useRef<GestureState | null>(null);
1326
+ // Adapter replacement/unmount must close the old provider's edit bracket,
1327
+ // even when no pointer-up arrives. Transient interaction state then dies
1328
+ // with this overlay rather than leaking into the replacement document.
1329
+ useEffect(
1330
+ () => () => {
1331
+ const gesture = gestureRef.current;
1332
+ gestureRef.current = null;
1333
+ if (!gesture) return;
1334
+ gesture.ownerBoxEdit.end(gesture.id);
1335
+ for (const peer of gesture.movePeers ?? []) peer.ownerBoxEdit.end(peer.id);
1336
+ },
1337
+ [],
1338
+ );
1339
+
1340
+ // Bumped on every `apply` so the box/handles/bands re-render against the
1341
+ // LIVE rect `boxEdit.apply`'s preview mutation just produced (step 5).
1342
+ const [, setGestureTick] = useState(0);
1343
+
1344
+ // D2.a — the drag-ghost rect (a semi-transparent follow-cursor copy of the
1345
+ // dragged element, spec:399), live during EITHER a B2 move gesture or a
1346
+ // D2.b reorder-preview drag — "dragging a selected element on the canvas"
1347
+ // per the spec's own D2 framing. Purely visual: it never feeds a commit
1348
+ // path itself (`computeDragGhost` is pure geometry — see its doc comment).
1349
+ const [dragGhost, setDragGhost] = useState<Rect | null>(null);
1350
+
1351
+ // D1.b — the currently-engaged alignment/snap guide(s), live during EITHER
1352
+ // a B2 move or resize gesture (spec §6 "guides appear only within the
1353
+ // snap threshold; disappear when not snapping"). `[]` between gestures and
1354
+ // whenever the live gesture's own edge-snap isn't currently engaged —
1355
+ // never a stale guide left over from a moment ago.
1356
+ const [snapGuides, setSnapGuides] = useState<SnapGuide[]>([]);
1357
+
1358
+ // D2.b — the live reorder-preview: which gap the pointer currently resolves
1359
+ // to among the dragged node's siblings, or `null` between reorder drags.
1360
+ // `reorderPreviewRef` mirrors the state for a synchronous read in
1361
+ // `onPointerUp` (same "ref mirrors state" convention `textEditRef`/
1362
+ // `marqueeRef` already use) — NOT tracked via `gestureRef`/`GestureState`:
1363
+ // a reorder-preview has no `BoxEditProvider` begin/apply/end bracket, it is
1364
+ // a single `structure.reorder` commit at drop, so it needs its own
1365
+ // independent lifecycle (mirrors `GestureState`'s own doc comment on why
1366
+ // IT is separate from `DragState`).
1367
+ const reorderPreviewRef = useRef<{
1368
+ id: string;
1369
+ adapter: AuthoringAdapter;
1370
+ gap: ReorderGap;
1371
+ } | null>(null);
1372
+ const [reorderPreview, setReorderPreviewState] = useState<{
1373
+ id: string;
1374
+ adapter: AuthoringAdapter;
1375
+ gap: ReorderGap;
1376
+ } | null>(null);
1377
+ const setReorderPreview = useCallback(
1378
+ (v: { id: string; adapter: AuthoringAdapter; gap: ReorderGap } | null) => {
1379
+ reorderPreviewRef.current = v;
1380
+ setReorderPreviewState(v);
1381
+ },
1382
+ [],
1383
+ );
1384
+
1385
+ // D3.b — the currently-open inline text editor (double-clicked node), or
1386
+ // `null`. `textEditRef` mirrors the state for synchronous reads from
1387
+ // callbacks (same "ref mirrors state" convention `marqueeRef`/`setMarquee`
1388
+ // already use above).
1389
+ const textEditRef = useRef<{ id: string; rect: DOMRectLike; initialText: string } | null>(null);
1390
+ const [textEdit, setTextEditState] = useState<{
1391
+ id: string;
1392
+ rect: DOMRectLike;
1393
+ initialText: string;
1394
+ } | null>(null);
1395
+ // D3.b — a double-click that was REFUSED (dynamic body/has children): no
1396
+ // textarea, just a loud, honest "not editable" indicator at the rect,
1397
+ // mirroring the store's existing dynamic-refusal pattern (console.warn +
1398
+ // a visible degraded state, never a silent no-op).
1399
+ const [textEditRefused, setTextEditRefused] = useState<{ id: string; rect: DOMRectLike } | null>(
1400
+ null,
1401
+ );
1402
+ const setTextEdit = useCallback(
1403
+ (v: { id: string; rect: DOMRectLike; initialText: string } | null) => {
1404
+ textEditRef.current = v;
1405
+ setTextEditState(v);
1406
+ },
1407
+ [],
1408
+ );
1409
+ // D3.R1 (reopen fix) — `TextProvider.set` is fire-and-forget (the write is an
1410
+ // async round trip to the dev server); the textarea closes immediately on
1411
+ // commit, so whether it landed or was refused can only be learned from a
1412
+ // LATER re-render. Stash the committed id+rect here the instant `commitTextEdit`
1413
+ // fires; the effect below consumes it on the next store notify (`editText`
1414
+ // calls `store.notifyIngestEdit()` unconditionally on both the themeVars.semantic.success and the
1415
+ // refusal path) and checks `text.get` — which reports `null` for a refused id
1416
+ // (via the adapter's own `dynamicPaths` mark) — to decide whether to surface
1417
+ // the refused indicator instead of letting the edit silently vanish.
1418
+ const pendingTextCommitRef = useRef<{ id: string; rect: DOMRectLike } | null>(null);
1419
+
1420
+ // Perf debt note (reviewer, D3 reopen review) — `emptyContainerHintsFor`
1421
+ // walks every node in the active adapter's tree calling
1422
+ // `getBoundingClientRect()` on each; it was previously recomputed on EVERY
1423
+ // render, including the plain hover `pointermove` path (`setHoverId` is
1424
+ // component-LOCAL state, so it re-renders this component without touching
1425
+ // `store` at all). Cached here keyed on `[adapter, storeVersion]` — `store`
1426
+ // only bumps `storeVersion` on an actual structural/selection/edit notify
1427
+ // (`notifyIngestEdit`/`setSelection` etc.), never on a bare hover move — so
1428
+ // a mousemove-only re-render now reuses the prior render's hints instead of
1429
+ // re-walking the whole tree.
1430
+ const emptyHintsCacheRef = useRef<{
1431
+ adapter: AuthoringAdapter;
1432
+ version: number;
1433
+ hints: ReturnType<typeof emptyContainerHintsFor>;
1434
+ } | null>(null);
1435
+
1436
+ // D3.R1 — consume a pending commit on the next store tick (see
1437
+ // `pendingTextCommitRef` doc comment). Runs at most once per commit: it always
1438
+ // clears the pending ref, whether or not the write was refused.
1439
+ useEffect(() => {
1440
+ const pending = pendingTextCommitRef.current;
1441
+ if (!pending) return;
1442
+ pendingTextCommitRef.current = null;
1443
+ const provider = textForId(adapter, pending.id);
1444
+ if (provider && provider.get(pending.id) === null) {
1445
+ setTextEditRefused({ id: pending.id, rect: pending.rect });
1446
+ }
1447
+ // `storeVersion` is the only intentional trigger — `store` is stable and
1448
+ // `pending` is read off the ref fresh each tick, per `useExhaustiveDependencies: off` (biome.json).
1449
+ }, [storeVersion]);
1450
+
1451
+ // D3.R1 — the refused indicator is a TRANSIENT affordance, not a permanent
1452
+ // mark: it must clear on a selection change (the user moved on to something
1453
+ // else) or after a reasonable timeout (they didn't), so it never sticks
1454
+ // forever the way it did pre-fix (only another double-click cleared it).
1455
+ const textEditRefusedSelectionRef = useRef<string>('');
1456
+ useEffect(() => {
1457
+ const signature = [...store.selectedEntityIds].sort().join(',');
1458
+ if (signature !== textEditRefusedSelectionRef.current) {
1459
+ textEditRefusedSelectionRef.current = signature;
1460
+ if (textEditRefused) setTextEditRefused(null);
1461
+ }
1462
+ });
1463
+ useEffect(() => {
1464
+ if (!textEditRefused) return;
1465
+ const timer = window.setTimeout(() => setTextEditRefused(null), 4000);
1466
+ return () => window.clearTimeout(timer);
1467
+ }, [textEditRefused]);
1468
+
1469
+ // D3.d — the fallback eyedropper session (native EyeDropper needs none of
1470
+ // this — it samples real rendered pixels itself). Re-renders whenever a
1471
+ // session begins/ends so the cursor swatch appears/disappears immediately.
1472
+ const eyedropperActive = useSyncExternalStore(
1473
+ subscribeEyedropperSession,
1474
+ isEyedropperSessionActive,
1475
+ );
1476
+ const [eyedropperPreview, setEyedropperPreview] = useState<{
1477
+ x: number;
1478
+ y: number;
1479
+ color: string | null;
1480
+ } | null>(null);
1481
+
1482
+ const setMarquee = useCallback((r: Rect | null) => {
1483
+ marqueeRef.current = r;
1484
+ setMarqueeState(r);
1485
+ }, []);
1486
+
1487
+ const toHostLocal = useCallback(
1488
+ (clientX: number, clientY: number): { x: number; y: number } => {
1489
+ const host = interactionRef.current?.getBoundingClientRect();
1490
+ return {
1491
+ x: (clientX - (host?.left ?? 0) - pan.x) / pan.zoom,
1492
+ y: (clientY - (host?.top ?? 0) - pan.y) / pan.zoom,
1493
+ };
1494
+ },
1495
+ [pan.x, pan.y, pan.zoom],
1496
+ );
1497
+
1498
+ const onAssetDragOver = useCallback(
1499
+ (event: ReactDragEvent<HTMLDivElement>) => {
1500
+ if (
1501
+ !suppliedView ||
1502
+ !transformModeAware ||
1503
+ !adapter.assetDrop ||
1504
+ !event.dataTransfer.types.includes('application/x-editor-asset')
1505
+ ) {
1506
+ return;
1507
+ }
1508
+ event.preventDefault();
1509
+ event.dataTransfer.dropEffect = 'copy';
1510
+ },
1511
+ [adapter.assetDrop, suppliedView, transformModeAware],
1512
+ );
1513
+
1514
+ const onAssetDrop = useCallback(
1515
+ (event: ReactDragEvent<HTMLDivElement>) => {
1516
+ if (!suppliedView || !transformModeAware || !adapter.assetDrop) return;
1517
+ const raw = event.dataTransfer.getData('application/x-editor-asset');
1518
+ if (!raw) return;
1519
+ let data: {
1520
+ path: string;
1521
+ name?: string;
1522
+ component?: AssetDropContext['item'];
1523
+ };
1524
+ try {
1525
+ data = JSON.parse(raw);
1526
+ } catch {
1527
+ return;
1528
+ }
1529
+ const point = toHostLocal(event.clientX, event.clientY);
1530
+ const context: AssetDropContext = {
1531
+ position: [point.x, point.y, 0],
1532
+ item: data.component ?? {
1533
+ kind: 'file',
1534
+ name: data.name ?? data.path.split('/').pop() ?? data.path,
1535
+ },
1536
+ };
1537
+ if (!adapter.assetDrop.accepts('', data.path, context)) return;
1538
+ event.preventDefault();
1539
+ event.stopPropagation();
1540
+ void dropAuthoringAsset(adapter, '', data.path, context);
1541
+ },
1542
+ [adapter.assetDrop, suppliedView, toHostLocal, transformModeAware],
1543
+ );
1544
+
1545
+ const bumpGesture = useCallback(() => setGestureTick((t) => t + 1), []);
1546
+
1547
+ /** Common gesture-end path for `onPointerUp`/`onPointerCancel` (step 6):
1548
+ * `ownerBoxEdit.end(id)`, clear the ref, force one more re-render so the
1549
+ * handles snap to the FINAL committed rect. */
1550
+ const endGesture = useCallback((): boolean => {
1551
+ const gesture = gestureRef.current;
1552
+ if (!gesture) return false;
1553
+ gestureRef.current = null;
1554
+ gesture.ownerBoxEdit.end(gesture.id);
1555
+ for (const peer of gesture.movePeers ?? []) peer.ownerBoxEdit.end(peer.id);
1556
+ // D2.a — the drag-ghost (if any) belonged to this gesture; clear it now
1557
+ // that the FINAL committed rect is what the box/handles snap back to.
1558
+ setDragGhost(null);
1559
+ // D1.b — likewise, any live alignment guide belonged to this gesture.
1560
+ setSnapGuides([]);
1561
+ bumpGesture();
1562
+ return true;
1563
+ }, [bumpGesture]);
1564
+
1565
+ const startResizeGesture = useCallback(
1566
+ (pos: HandlePos, e: ReactPointerEvent) => {
1567
+ e.stopPropagation();
1568
+ if (e.button !== 0) return;
1569
+ const selected = store.selectedEntityIds;
1570
+ if (selected.size !== 1) return;
1571
+ const id = [...selected][0]!;
1572
+ const owner = boxEditForId(adapter, id);
1573
+ const rect = rectForId(adapter, id);
1574
+ if (!owner || !rect) return;
1575
+ const posValue = adapter.inspector?.get(id, 'style.position');
1576
+ const native2D = transformDimensionsFor(adapter, id) === '2d';
1577
+ capturePointer(e);
1578
+ owner.begin(id);
1579
+ gestureRef.current = {
1580
+ kind: 'resize',
1581
+ id,
1582
+ ownerBoxEdit: owner,
1583
+ startLocal: toHostLocal(e.clientX, e.clientY),
1584
+ origRect: rect,
1585
+ pos,
1586
+ isPositioned: posValue === 'absolute' || posValue === 'fixed' || native2D,
1587
+ // D1.b — same `contextRectsForId` call the move gesture already
1588
+ // seeds `context` from (`resolveMoveCandidate`), so a resize handle
1589
+ // drag gets edge-snapping + guides too (spec §6's own instruction:
1590
+ // "wire the resize handle drag to computeSnapTargets too").
1591
+ context: contextRectsForId(adapter, id),
1592
+ };
1593
+ },
1594
+ [adapter, store, toHostLocal],
1595
+ );
1596
+
1597
+ const startRotateGesture = useCallback(
1598
+ (e: ReactPointerEvent) => {
1599
+ e.stopPropagation();
1600
+ if (e.button !== 0) return;
1601
+ const selected = store.selectedEntityIds;
1602
+ if (selected.size !== 1) return;
1603
+ const id = [...selected][0]!;
1604
+ const owner = boxEditForId(adapter, id);
1605
+ const rect = rectForId(adapter, id);
1606
+ if (!owner || !rect) return;
1607
+ const nativeOrigin = transformModeAware ? owner.gizmoOrigin?.(id) : null;
1608
+ capturePointer(e);
1609
+ owner.begin(id);
1610
+ gestureRef.current = {
1611
+ kind: 'rotate',
1612
+ id,
1613
+ ownerBoxEdit: owner,
1614
+ startLocal: toHostLocal(e.clientX, e.clientY),
1615
+ origRect: rect,
1616
+ center: nativeOrigin ?? { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 },
1617
+ ...(nativeOrigin ? { nativeOrigin } : {}),
1618
+ };
1619
+ },
1620
+ [adapter, store, toHostLocal, transformModeAware],
1621
+ );
1622
+
1623
+ const startMoveGizmoGesture = useCallback(
1624
+ (axis: 'x' | 'y' | 'both', e: ReactPointerEvent) => {
1625
+ e.stopPropagation();
1626
+ if (e.button !== 0) return;
1627
+ const selected = store.selectedEntityIds;
1628
+ if (selected.size !== 1) return;
1629
+ const id = [...selected][0]!;
1630
+ const owner = boxEditForId(adapter, id);
1631
+ const rect = rectForId(adapter, id);
1632
+ if (!owner || !rect) return;
1633
+ const nativeOrigin = owner.gizmoOrigin?.(id) ?? {
1634
+ x: rect.x + rect.width / 2,
1635
+ y: rect.y + rect.height / 2,
1636
+ };
1637
+ capturePointer(e);
1638
+ owner.begin(id);
1639
+ gestureRef.current = {
1640
+ kind: 'move',
1641
+ id,
1642
+ ownerBoxEdit: owner,
1643
+ startLocal: toHostLocal(e.clientX, e.clientY),
1644
+ origRect: rect,
1645
+ context: contextRectsForId(adapter, id),
1646
+ moveAxis: axis,
1647
+ nativeOrigin,
1648
+ };
1649
+ },
1650
+ [adapter, store, toHostLocal],
1651
+ );
1652
+
1653
+ const startNativeScaleGesture = useCallback(
1654
+ (axis: 'x' | 'y' | 'both', e: ReactPointerEvent) => {
1655
+ e.stopPropagation();
1656
+ if (e.button !== 0) return;
1657
+ const selected = store.selectedEntityIds;
1658
+ if (selected.size !== 1) return;
1659
+ const id = [...selected][0]!;
1660
+ const owner = boxEditForId(adapter, id);
1661
+ const rect = rectForId(adapter, id);
1662
+ if (!owner || !rect) return;
1663
+ const nativeOrigin = owner.gizmoOrigin?.(id) ?? {
1664
+ x: rect.x + rect.width / 2,
1665
+ y: rect.y + rect.height / 2,
1666
+ };
1667
+ capturePointer(e);
1668
+ owner.begin(id);
1669
+ gestureRef.current = {
1670
+ kind: 'native-scale',
1671
+ id,
1672
+ ownerBoxEdit: owner,
1673
+ startLocal: toHostLocal(e.clientX, e.clientY),
1674
+ origRect: rect,
1675
+ nativeOrigin,
1676
+ nativeScaleAxis: axis,
1677
+ nativeHandleSpan: NATIVE_GIZMO_LENGTH_PX / pan.zoom,
1678
+ };
1679
+ },
1680
+ [adapter, pan.zoom, store, toHostLocal],
1681
+ );
1682
+
1683
+ const startReferenceGesture = useCallback(
1684
+ (point: BoxEditReferencePoint, e: ReactPointerEvent) => {
1685
+ e.stopPropagation();
1686
+ if (e.button !== 0) return;
1687
+ const selected = store.selectedEntityIds;
1688
+ if (selected.size !== 1) return;
1689
+ const id = [...selected][0]!;
1690
+ const owner = boxEditForId(adapter, id);
1691
+ const rect = rectForId(adapter, id);
1692
+ if (!owner || !rect || owner.referencePoint?.(id)?.id !== point.id) return;
1693
+ capturePointer(e);
1694
+ owner.begin(id);
1695
+ gestureRef.current = {
1696
+ kind: 'reference',
1697
+ id,
1698
+ ownerBoxEdit: owner,
1699
+ startLocal: toHostLocal(e.clientX, e.clientY),
1700
+ origRect: rect,
1701
+ context: contextRectsForId(adapter, id),
1702
+ pointerId: e.pointerId,
1703
+ referencePoint: point,
1704
+ };
1705
+ },
1706
+ [adapter, store, toHostLocal],
1707
+ );
1708
+
1709
+ /**
1710
+ * An adapter-owned spatial handle's own drag, deliberately OUTSIDE the
1711
+ * `GestureState` machine: it has no `BoxEditProvider` begin/apply/end bracket
1712
+ * (the provider owns its own preview/commit pair), so it needs an independent
1713
+ * lifecycle — the same reasoning `reorderPreviewRef` records for being
1714
+ * separate. The pointer is captured on the handle itself, so the move/up pair
1715
+ * arrives there and never touches the marquee/selection machine.
1716
+ */
1717
+ const spatialDragRef = useRef<{
1718
+ id: string;
1719
+ handleId: string;
1720
+ provider: SpatialHandlesProvider;
1721
+ } | null>(null);
1722
+
1723
+ const onSpatialHandleDown = useCallback(
1724
+ (
1725
+ id: string,
1726
+ provider: SpatialHandlesProvider,
1727
+ handle: SpatialDragHandle,
1728
+ e: ReactPointerEvent<HTMLElement>,
1729
+ ) => {
1730
+ e.stopPropagation();
1731
+ if (e.button !== 0 || !handle.writable) return;
1732
+ capturePointer(e);
1733
+ spatialDragRef.current = { id, handleId: handle.id, provider };
1734
+ },
1735
+ [],
1736
+ );
1737
+
1738
+ const onSpatialHandleMove = useCallback(
1739
+ (e: ReactPointerEvent<HTMLElement>) => {
1740
+ const drag = spatialDragRef.current;
1741
+ if (!drag) return;
1742
+ const local = toHostLocal(e.clientX, e.clientY);
1743
+ drag.provider.preview(drag.id, drag.handleId, [local.x, local.y, 0]);
1744
+ bumpGesture();
1745
+ },
1746
+ [bumpGesture, toHostLocal],
1747
+ );
1748
+
1749
+ const onSpatialHandleUp = useCallback(
1750
+ (e: ReactPointerEvent<HTMLElement>) => {
1751
+ const drag = spatialDragRef.current;
1752
+ spatialDragRef.current = null;
1753
+ if (!drag) return;
1754
+ releaseCapturedPointer(e);
1755
+ const local = toHostLocal(e.clientX, e.clientY);
1756
+ void drag.provider.commit(drag.id, drag.handleId, [local.x, local.y, 0]);
1757
+ bumpGesture();
1758
+ },
1759
+ [bumpGesture, toHostLocal],
1760
+ );
1761
+
1762
+ const startSpacingGesture = useCallback(
1763
+ (band: SpacingBand, e: ReactPointerEvent) => {
1764
+ e.stopPropagation();
1765
+ if (e.button !== 0) return;
1766
+ const selected = store.selectedEntityIds;
1767
+ if (selected.size !== 1) return;
1768
+ const id = [...selected][0]!;
1769
+ const owner = boxEditForId(adapter, id);
1770
+ const rect = rectForId(adapter, id);
1771
+ if (!owner || !rect) return;
1772
+ capturePointer(e);
1773
+ owner.begin(id);
1774
+ gestureRef.current = {
1775
+ kind: 'spacing',
1776
+ id,
1777
+ ownerBoxEdit: owner,
1778
+ startLocal: toHostLocal(e.clientX, e.clientY),
1779
+ origRect: rect,
1780
+ side: band.side,
1781
+ origValue: band.value,
1782
+ };
1783
+ },
1784
+ [adapter, store, toHostLocal],
1785
+ );
1786
+
1787
+ /** Compute this gesture's patch from the current pointer position and apply
1788
+ * it live, bumping the re-render counter so box/handles/bands track the
1789
+ * preview. Shared by the active-gesture branch of `onPointerMove` and the
1790
+ * D2 threshold-promotion of a deferred move candidate. */
1791
+ const applyGesturePatch = useCallback(
1792
+ (gesture: GestureState, e: ReactPointerEvent<HTMLElement>) => {
1793
+ if (gesture.pointerId !== undefined && e.pointerId !== gesture.pointerId) return;
1794
+ const local = toHostLocal(e.clientX, e.clientY);
1795
+ const dx = local.x - gesture.startLocal.x;
1796
+ const dy = local.y - gesture.startLocal.y;
1797
+ let patch: Record<string, number>;
1798
+ if (gesture.kind === 'resize') {
1799
+ patch = computeResizePatch(
1800
+ gesture.pos!,
1801
+ dx,
1802
+ dy,
1803
+ gesture.origRect,
1804
+ !!gesture.isPositioned,
1805
+ e.altKey,
1806
+ gesture.context,
1807
+ );
1808
+ // D1.b — the same edge-snap decision `computeResizePatch` just
1809
+ // applied to the patch, rendered as a guide (spec §6).
1810
+ setSnapGuides(
1811
+ computeResizeSnapGuides(
1812
+ gesture.pos!,
1813
+ dx,
1814
+ dy,
1815
+ gesture.origRect,
1816
+ e.altKey,
1817
+ gesture.context,
1818
+ ),
1819
+ );
1820
+ } else if (gesture.kind === 'native-scale') {
1821
+ patch = nativeScalePatch(
1822
+ gesture,
1823
+ dx,
1824
+ dy,
1825
+ store.snapEnabled,
1826
+ e.altKey,
1827
+ store.snapValues.scale,
1828
+ );
1829
+ setSnapGuides([]);
1830
+ } else if (gesture.kind === 'move') {
1831
+ const { dx: moveDx, dy: moveDy } = constrainedMoveDelta(gesture.moveAxis, dx, dy);
1832
+ if (gesture.nativeOrigin) {
1833
+ patch = nativeMovePatch(
1834
+ gesture,
1835
+ moveDx,
1836
+ moveDy,
1837
+ store.snapEnabled,
1838
+ e.altKey,
1839
+ store.snapValues.translate,
1840
+ );
1841
+ const snapped = computeNativeMoveSnap(
1842
+ {
1843
+ x: patch['originX'] ?? gesture.nativeOrigin.x,
1844
+ y: patch['originY'] ?? gesture.nativeOrigin.y,
1845
+ },
1846
+ gesture.nativeOrigin,
1847
+ gesture.origRect,
1848
+ gesture.context,
1849
+ gesture.moveAxis,
1850
+ !store.snapEnabled || e.altKey,
1851
+ EDGE_SNAP_THRESHOLD_PX / Math.max(pan.zoom, 0.01),
1852
+ {
1853
+ x: nativeGuides.filter((guide) => guide.axis === 'x').map((guide) => guide.value),
1854
+ y: nativeGuides.filter((guide) => guide.axis === 'y').map((guide) => guide.value),
1855
+ },
1856
+ );
1857
+ if (patch['originX'] !== undefined) patch['originX'] = snapped.position.x;
1858
+ if (patch['originY'] !== undefined) patch['originY'] = snapped.position.y;
1859
+ setSnapGuides(snapped.guides);
1860
+ } else {
1861
+ patch = computeMovePatch(moveDx, moveDy, gesture.origRect, e.altKey, gesture.context);
1862
+ }
1863
+ // D2.a — the move gesture is "dragging a selected element on the
1864
+ // canvas" (spec:399); track the drag-ghost alongside the live
1865
+ // boxEdit preview. Resize/rotate/spacing gestures don't get a ghost
1866
+ // — the spec's D2 framing is specifically about MOVING an element.
1867
+ setDragGhost(computeDragGhost(gesture.origRect, moveDx, moveDy));
1868
+ // D1.b — same relationship as the resize branch above.
1869
+ if (!gesture.nativeOrigin) {
1870
+ setSnapGuides(
1871
+ computeMoveSnapGuides(moveDx, moveDy, gesture.origRect, e.altKey, gesture.context),
1872
+ );
1873
+ }
1874
+ } else if (gesture.kind === 'reference') {
1875
+ const snapped = computePointSnap(local, gesture.origRect, e.altKey, gesture.context, {
1876
+ bounded: gesture.referencePoint?.bounded,
1877
+ snapPoints: gesture.referencePoint?.snapPoints,
1878
+ });
1879
+ patch = { referenceX: snapped.position.x, referenceY: snapped.position.y };
1880
+ setSnapGuides(snapped.guides);
1881
+ } else if (gesture.kind === 'rotate') {
1882
+ patch = computeRotatePatch(
1883
+ gesture.center!,
1884
+ gesture.startLocal,
1885
+ local,
1886
+ gesture.nativeOrigin ? !store.snapEnabled || e.altKey : e.altKey,
1887
+ gesture.nativeOrigin ? store.snapValues.rotate : 15,
1888
+ );
1889
+ } else {
1890
+ patch = computeSpacingPatch(gesture.side!, dx, dy, gesture.origValue ?? 0);
1891
+ }
1892
+ gesture.ownerBoxEdit.apply(gesture.id, patch);
1893
+ if (gesture.kind === 'move' && gesture.movePeers) {
1894
+ const primaryDx = gesture.nativeOrigin
1895
+ ? (patch['originX'] ?? gesture.nativeOrigin.x) - gesture.nativeOrigin.x
1896
+ : (patch['x'] ?? gesture.origRect.x) - gesture.origRect.x;
1897
+ const primaryDy = gesture.nativeOrigin
1898
+ ? (patch['originY'] ?? gesture.nativeOrigin.y) - gesture.nativeOrigin.y
1899
+ : (patch['y'] ?? gesture.origRect.y) - gesture.origRect.y;
1900
+ for (const peer of gesture.movePeers) {
1901
+ peer.ownerBoxEdit.apply(
1902
+ peer.id,
1903
+ peer.nativeOrigin
1904
+ ? {
1905
+ originX: peer.nativeOrigin.x + primaryDx,
1906
+ originY: peer.nativeOrigin.y + primaryDy,
1907
+ }
1908
+ : { x: peer.origRect.x + primaryDx, y: peer.origRect.y + primaryDy },
1909
+ );
1910
+ }
1911
+ }
1912
+ bumpGesture();
1913
+ },
1914
+ [toHostLocal, bumpGesture, store, nativeGuides, pan.zoom],
1915
+ );
1916
+
1917
+ // D3.d — sample the effective background color at a client point: pick the
1918
+ // topmost node (the SAME `pickTopmost` A3 already uses), route to ITS
1919
+ // owner's `colorSample` (owner-routed, not merged on the composite — see
1920
+ // `colorSampleForId`'s doc comment), then reduce the raw ancestor chain
1921
+ // through the pure `effectiveColorFromChain`. `null` when nothing is hit or
1922
+ // the owner has no `colorSample` — the caller renders the honest "unknown"
1923
+ // swatch state for that, never a fabricated color.
1924
+ const sampleColorAt = useCallback(
1925
+ (clientX: number, clientY: number): string | null => {
1926
+ const hitId = pickAt(clientX, clientY);
1927
+ if (!hitId) return null;
1928
+ const chain = colorSampleForId(adapter, hitId)?.backgroundChainAt(clientX, clientY) ?? null;
1929
+ return chain ? effectiveColorFromChain(chain) : null;
1930
+ },
1931
+ [adapter, pickAt],
1932
+ );
1933
+
1934
+ // D3.d — Escape cancels an in-flight fallback eyedropper session (never a
1935
+ // fabricated color — `resolveEyedropperSession(null)` is the honest
1936
+ // "cancelled" outcome the widget's `apply` callback already understands).
1937
+ useEffect(() => {
1938
+ if (!eyedropperActive) return;
1939
+ const onKeyDown = (e: KeyboardEvent): void => {
1940
+ if (e.key === 'Escape') resolveEyedropperSession(null);
1941
+ };
1942
+ window.addEventListener('keydown', onKeyDown);
1943
+ return () => window.removeEventListener('keydown', onKeyDown);
1944
+ }, [eyedropperActive]);
1945
+
1946
+ // D4 (spec27 §8 "space-pan" row) — the Space-held ARM SWITCH for the pan
1947
+ // gesture below (`onPointerDown`'s own early branch). A raw `window`
1948
+ // keydown/keyup pair, mounted once — NOT routed through `registerHotkeys`/
1949
+ // `editor-hotkeys.ts` (that registry fires once per keydown and has no
1950
+ // "held" concept; this mirrors the existing vertex-snap V-key pattern in
1951
+ // `registerEditorHotkeys` instead, which is the established precedent for a
1952
+ // literal key-HOLD state in this codebase). Matches `hotkeys.ts`'s own
1953
+ // not-typing guard exactly (input/textarea/select/contentEditable) so Space
1954
+ // still types a literal space character in any of those, never arming pan.
1955
+ // A focused `<Button>` is ALSO excluded so Space keeps its native
1956
+ // accessible "activate the focused control" behavior instead of arming pan
1957
+ // out from under it. `e.repeat` is ignored so an OS key-repeat storm is a
1958
+ // single no-op transition, not thousands of redundant `preventDefault`s.
1959
+ useEffect(() => {
1960
+ const isTypingTarget = (t: EventTarget | null): boolean =>
1961
+ t instanceof HTMLInputElement ||
1962
+ t instanceof HTMLTextAreaElement ||
1963
+ t instanceof HTMLSelectElement ||
1964
+ (t instanceof HTMLElement && t.isContentEditable);
1965
+ const onKeyDown = (e: KeyboardEvent): void => {
1966
+ if (e.code !== 'Space' || e.repeat) return;
1967
+ if (isTypingTarget(e.target) || e.target instanceof HTMLButtonElement) return;
1968
+ // Space's native action is page-scroll (or activating a focused
1969
+ // button, excluded above) — the hold-to-pan gesture replaces it, so
1970
+ // suppress the default here rather than fighting a scrolled page mid-drag.
1971
+ e.preventDefault();
1972
+ spaceHeldRef.current = true;
1973
+ setSpaceHeld(true);
1974
+ };
1975
+ const onKeyUp = (e: KeyboardEvent): void => {
1976
+ if (e.code !== 'Space') return;
1977
+ spaceHeldRef.current = false;
1978
+ setSpaceHeld(false);
1979
+ // Releasing Space mid-drag ends the pan exactly like a pointerup would
1980
+ // (see `onPointerUp`'s own early branch) — the pointer may still be
1981
+ // down, but with the arm switch off a further move must fall through
1982
+ // to ordinary hover/drag handling, never keep panning.
1983
+ if (panDragRef.current) {
1984
+ panDragRef.current = null;
1985
+ setIsPanning(false);
1986
+ }
1987
+ };
1988
+ window.addEventListener('keydown', onKeyDown);
1989
+ window.addEventListener('keyup', onKeyUp);
1990
+ return () => {
1991
+ window.removeEventListener('keydown', onKeyDown);
1992
+ window.removeEventListener('keyup', onKeyUp);
1993
+ };
1994
+ }, []);
1995
+
1996
+ const onPrimaryPointerDown = useCallback(
1997
+ (e: ReactPointerEvent<HTMLElement>) => {
1998
+ // Camera navigation is bound to the viewport container so right/middle
1999
+ // drags can pass through this DOM overlay. Keep left-button authoring
2000
+ // gestures owned here: OrbitControls captures every pointer it sees,
2001
+ // including left clicks, which would otherwise retarget pointerup to
2002
+ // the container before this overlay can finish selection/marquee/edit.
2003
+ e.stopPropagation();
2004
+ // Keep the complete interaction session on this full-viewport surface,
2005
+ // even when the pointer leaves the viewport or crosses transformed
2006
+ // story/frame content. Without capture, a fast Figma-style marquee or
2007
+ // Space-drag can strand the overlay in a half-finished gesture.
2008
+ capturePointer(e);
2009
+ // D4 (spec27 §8 "space-pan" row) — a press while Space is held ARMS a
2010
+ // pan-drag instead of the ordinary pick/select/marquee/eyedropper flow
2011
+ // below, which it skips entirely (never fights another gesture for the
2012
+ // same pointer session — same precedent `endGesture`/`reorderPreview`'s
2013
+ // early returns in `onPointerUp` already set). Seeds from the CURRENT
2014
+ // document view so the drag delta composes onto whatever pan was
2015
+ // already in effect.
2016
+ if (spaceHeldRef.current) {
2017
+ panDragRef.current = {
2018
+ startClientX: e.clientX,
2019
+ startClientY: e.clientY,
2020
+ startPan: view.get(),
2021
+ };
2022
+ setIsPanning(true);
2023
+ return;
2024
+ }
2025
+ // D3.d — a click while an eyedropper session is open SAMPLES AND ENDS
2026
+ // the session instead of the ordinary click-select flow below (which it
2027
+ // deliberately skips entirely — picking a color is not a selection
2028
+ // gesture).
2029
+ if (eyedropperActive) {
2030
+ resolveEyedropperSession(sampleColorAt(e.clientX, e.clientY));
2031
+ setEyedropperPreview(null);
2032
+ return;
2033
+ }
2034
+ // The project viewport's Three canvas sits below this full-cover
2035
+ // interaction layer. Give editor-owned effectors first refusal through
2036
+ // the viewport's own raycaster; activation selects their REAL authored
2037
+ // target/pole with exact intent and leaves ordinary world picking alone.
2038
+ if (activateViewportEditorControl(scopedAdapter, e.clientX, e.clientY)) {
2039
+ dragRef.current = null;
2040
+ setHoverId(null);
2041
+ setMarquee(null);
2042
+ return;
2043
+ }
2044
+ const hitAtDown = pickAt(e.clientX, e.clientY);
2045
+ // A board shows every story, while the authoring adapter intentionally
2046
+ // exposes only the active story's DOM. A press inside an inactive frame
2047
+ // therefore has no adapter hit. Treat the frame itself as the first
2048
+ // selection target: activate/focus that story, consume this pointer
2049
+ // session, and let a subsequent press edit its DOM. This makes the whole
2050
+ // frame behave like a Figma frame instead of requiring the tiny label.
2051
+ if (selectInactiveStoryFrame(e, hitAtDown)) {
2052
+ dragRef.current = null;
2053
+ setHoverId(null);
2054
+ setMarquee(null);
2055
+ return;
2056
+ }
2057
+
2058
+ // D2 (spec:322): a press on an ALREADY-SELECTED, boxEdit-capable,
2059
+ // absolutely/fixed-positioned node RECORDS a move CANDIDATE — it does NOT
2060
+ // begin the gesture here. Promotion waits for the drag threshold
2061
+ // (`onPointerMove`), so a plain click (or shift-click) still falls
2062
+ // through to `resolveClickSelection` on pointerup (multi-select narrow /
2063
+ // shift-toggle preserved). A non-positioned node, or a hit that isn't yet
2064
+ // selected, records no candidate and keeps today's click-select behavior.
2065
+ // The drag SUBJECT is the nearest selected ancestor of the deep pick
2066
+ // (usually the pick itself) — see `nearestSelectedAncestor`'s doc
2067
+ // comment. `hitAtDown` keeps the deep pick for click-select on release.
2068
+ const moveSubject =
2069
+ hitAtDown !== null
2070
+ ? nearestSelectedAncestor(adapter, hitAtDown, store.selectedEntityIds)
2071
+ : null;
2072
+ const moveCandidate: DragState['moveCandidate'] =
2073
+ (!transformModeAware || store.transformMode === 'translate') && moveSubject !== null
2074
+ ? resolveMoveCandidate(adapter, moveSubject, store.selectedEntityIds)
2075
+ : null;
2076
+
2077
+ // D2.b: the SAME "already selected" gate, checked ONLY when there's no
2078
+ // move candidate — a positioned node moves (writes x/y); a flow/list
2079
+ // child (never absolute/fixed, so `moveCandidate` above is always
2080
+ // `null` for it) reorders instead (writes source order via
2081
+ // `structure.reorder`). The two are mutually exclusive per press.
2082
+ const reorderCandidate: DragState['reorderCandidate'] =
2083
+ !moveCandidate && moveSubject !== null
2084
+ ? resolveReorderCandidate(adapter, moveSubject)
2085
+ : null;
2086
+
2087
+ dragRef.current = {
2088
+ downClientX: e.clientX,
2089
+ downClientY: e.clientY,
2090
+ downLocal: toHostLocal(e.clientX, e.clientY),
2091
+ hitAtDown,
2092
+ moved: false,
2093
+ moveCandidate,
2094
+ reorderCandidate,
2095
+ };
2096
+ setHoverId(null);
2097
+ setMarquee(null);
2098
+ },
2099
+ [
2100
+ adapter,
2101
+ store,
2102
+ view,
2103
+ transformModeAware,
2104
+ toHostLocal,
2105
+ setMarquee,
2106
+ eyedropperActive,
2107
+ sampleColorAt,
2108
+ pickAt,
2109
+ scopedAdapter,
2110
+ ],
2111
+ );
2112
+
2113
+ const onPointerDown = useCallback(
2114
+ (e: ReactPointerEvent<HTMLElement>) => {
2115
+ if (e.button === 2) {
2116
+ secondaryPressRef.current = {
2117
+ pointerId: e.pointerId,
2118
+ startClientX: e.clientX,
2119
+ startClientY: e.clientY,
2120
+ moved: false,
2121
+ };
2122
+ // Do not capture, prevent, or stop this event: the spatial viewport
2123
+ // underneath owns right-drag orbit/pan. This overlay owns only the
2124
+ // stationary-click menu handled above.
2125
+ return;
2126
+ }
2127
+ if (e.button === 0) onPrimaryPointerDown(e);
2128
+ },
2129
+ [onPrimaryPointerDown],
2130
+ );
2131
+
2132
+ /** The active-drag half of `onPointerMove` (drag-threshold promotion +
2133
+ * move-gesture promotion + marquee update) — factored into its OWN
2134
+ * `useCallback` purely to keep `onPointerMove` itself under the
2135
+ * cognitive-complexity ceiling; behavior is unchanged. */
2136
+ const handleActiveDrag = useCallback(
2137
+ (drag: DragState, e: ReactPointerEvent<HTMLElement>) => {
2138
+ const dx = e.clientX - drag.downClientX;
2139
+ const dy = e.clientY - drag.downClientY;
2140
+ if (!drag.moved && Math.hypot(dx, dy) > DRAG_THRESHOLD_PX) drag.moved = true;
2141
+ if (!drag.moved) return;
2142
+
2143
+ // D2: threshold crossed with a deferred move candidate — promote it to a
2144
+ // live move gesture NOW (not at pointerdown).
2145
+ if (drag.moveCandidate) {
2146
+ const g = promoteMoveCandidate(drag.moveCandidate, drag.downLocal);
2147
+ gestureRef.current = g;
2148
+ applyGesturePatch(g, e);
2149
+ return;
2150
+ }
2151
+ // D2.b: threshold crossed with a deferred reorder candidate — compute
2152
+ // the live insertion gap for the CURRENT pointer position and update
2153
+ // the preview (+ its drag-ghost). This is NOT a `GestureState` (no
2154
+ // `boxEdit` bracket — see `reorderPreviewRef`'s doc comment) so it has
2155
+ // no `promote*` step; every move while it's live just recomputes the
2156
+ // gap. `gap` can be `null` only when `siblingRects` is somehow empty
2157
+ // (the capability gate above should make this unreachable) — in that
2158
+ // case this deliberately falls through to NEITHER a preview NOR the
2159
+ // marquee branch below (guarded by the `return`), matching "does not
2160
+ // trigger marquee" for a reorder-candidate drag regardless.
2161
+ if (drag.reorderCandidate) {
2162
+ const local = toHostLocal(e.clientX, e.clientY);
2163
+ const gap = computeReorderGap(drag.reorderCandidate.siblingRects, local);
2164
+ if (gap) {
2165
+ setReorderPreview({
2166
+ id: drag.reorderCandidate.id,
2167
+ adapter: drag.reorderCandidate.adapter,
2168
+ gap,
2169
+ });
2170
+ setDragGhost(
2171
+ computeDragGhost(
2172
+ drag.reorderCandidate.origRect,
2173
+ local.x - drag.downLocal.x,
2174
+ local.y - drag.downLocal.y,
2175
+ ),
2176
+ );
2177
+ }
2178
+ return;
2179
+ }
2180
+ // Marquee only starts when the drag began over EMPTY space (no pick at
2181
+ // pointerdown) — a drag on a NON-candidate hit (unselected, or a
2182
+ // non-positioned node) is today's plain click-select, no marquee.
2183
+ if (drag.hitAtDown === null) {
2184
+ const local = toHostLocal(e.clientX, e.clientY);
2185
+ setMarquee({
2186
+ x: Math.min(drag.downLocal.x, local.x),
2187
+ y: Math.min(drag.downLocal.y, local.y),
2188
+ width: Math.abs(local.x - drag.downLocal.x),
2189
+ height: Math.abs(local.y - drag.downLocal.y),
2190
+ });
2191
+ }
2192
+ },
2193
+ [toHostLocal, setMarquee, applyGesturePatch, setReorderPreview],
2194
+ );
2195
+
2196
+ const onPointerMove = useCallback(
2197
+ (e: ReactPointerEvent<HTMLElement>) => {
2198
+ // D4 — an armed pan-drag just writes the shared pan translate from the
2199
+ // client-space delta since pointerdown (client px, NOT `toHostLocal` —
2200
+ // the pan target IS the frame `toHostLocal` reads through, so this must
2201
+ // stay in the frame-independent client space); nothing else runs
2202
+ // underneath it.
2203
+ const panDrag = panDragRef.current;
2204
+ if (panDrag) {
2205
+ view.setPan(
2206
+ panDrag.startPan.x + (e.clientX - panDrag.startClientX),
2207
+ panDrag.startPan.y + (e.clientY - panDrag.startClientY),
2208
+ );
2209
+ return;
2210
+ }
2211
+ // D3.d — while an eyedropper session is open, every move just updates
2212
+ // the cursor-following preview swatch; no hover/drag/gesture logic runs
2213
+ // underneath it (a real color-at-point pick, not a selection gesture).
2214
+ if (eyedropperActive) {
2215
+ const local = toHostLocal(e.clientX, e.clientY);
2216
+ setEyedropperPreview({ ...local, color: sampleColorAt(e.clientX, e.clientY) });
2217
+ return;
2218
+ }
2219
+ const gesture = gestureRef.current;
2220
+ if (gesture) {
2221
+ applyGesturePatch(gesture, e);
2222
+ return;
2223
+ }
2224
+ // No button held — plain hover: pick under the cursor, draw a highlight.
2225
+ if ((e.buttons & 1) === 0) {
2226
+ updateViewportEditorControlHover(
2227
+ scopedAdapter,
2228
+ e.clientX,
2229
+ e.clientY,
2230
+ pickAt,
2231
+ setEditorControlCursor,
2232
+ setHoverId,
2233
+ );
2234
+ return;
2235
+ }
2236
+ const drag = dragRef.current;
2237
+ if (!drag) return;
2238
+ handleActiveDrag(drag, e);
2239
+ },
2240
+ [
2241
+ store,
2242
+ view,
2243
+ toHostLocal,
2244
+ applyGesturePatch,
2245
+ eyedropperActive,
2246
+ sampleColorAt,
2247
+ handleActiveDrag,
2248
+ pickAt,
2249
+ scopedAdapter,
2250
+ ],
2251
+ );
2252
+
2253
+ const onPointerUp = useCallback(
2254
+ (e: ReactPointerEvent<HTMLElement>) => {
2255
+ releaseCapturedPointer(e);
2256
+ // D4 — releasing the pointer ends an armed pan-drag here, same early-
2257
+ // return precedent as `endGesture()`/the reorder-preview branch below:
2258
+ // a pan-drag never falls through to click-select.
2259
+ if (panDragRef.current) {
2260
+ panDragRef.current = null;
2261
+ setIsPanning(false);
2262
+ return;
2263
+ }
2264
+ // A gesture in progress commits+ends here and skips the ordinary
2265
+ // click-select logic below entirely (step 6).
2266
+ const gesture = gestureRef.current;
2267
+ if (gesture?.pointerId !== undefined && e.pointerId !== gesture.pointerId) return;
2268
+ if (endGesture()) return;
2269
+ // D2.b — a live reorder-preview commits here: ONE `structure.reorder`
2270
+ // call (on-contract since T0/D-1, so this lands on the existing
2271
+ // checksum-guarded whole-file undo/redo timeline — the same "one undo
2272
+ // step" property every other structural op in this file already has),
2273
+ // then clears the preview + ghost + drag state, skipping click-select
2274
+ // entirely (mirrors `endGesture`'s own early return above).
2275
+ const reorderPreviewNow = reorderPreviewRef.current;
2276
+ if (reorderPreviewNow) {
2277
+ setReorderPreview(null);
2278
+ setDragGhost(null);
2279
+ dragRef.current = null;
2280
+ reorderAuthoringNode(
2281
+ reorderPreviewNow.adapter,
2282
+ reorderPreviewNow.id,
2283
+ reorderPreviewNow.gap.beforeSiblingId,
2284
+ );
2285
+ return;
2286
+ }
2287
+ const drag = dragRef.current;
2288
+ dragRef.current = null;
2289
+ if (!drag) return;
2290
+ const nextSelection =
2291
+ drag.hitAtDown !== null
2292
+ ? resolveClickSelection(store.selectedEntityIds, drag.hitAtDown, e.shiftKey)
2293
+ : resolveEmptySpaceSelection(adapter, drag.moved, marqueeRef.current, e.shiftKey);
2294
+ if (nextSelection !== null) setAuthoringSelection(adapter, nextSelection);
2295
+ setMarquee(null);
2296
+ },
2297
+ [adapter, store, setMarquee, endGesture, setReorderPreview],
2298
+ );
2299
+
2300
+ const onPointerCancel = useCallback(
2301
+ (e: ReactPointerEvent<HTMLElement>) => {
2302
+ releaseCapturedPointer(e);
2303
+ // D4 — a cancelled pan-drag (e.g. pointer capture lost) just disarms;
2304
+ // there is nothing to commit (the pan writes were already live, same as
2305
+ // every other gesture's `apply` — cancelling a pan simply stops moving it
2306
+ // further, it does not revert, matching resize/move's own no-revert
2307
+ // cancel semantics below).
2308
+ if (panDragRef.current) {
2309
+ panDragRef.current = null;
2310
+ setIsPanning(false);
2311
+ return;
2312
+ }
2313
+ const gesture = gestureRef.current;
2314
+ if (gesture?.pointerId !== undefined && e.pointerId !== gesture.pointerId) return;
2315
+ if (endGesture()) return;
2316
+ // D2.b — a CANCELLED reorder drag (e.g. pointer capture lost) clears the
2317
+ // preview/ghost WITHOUT committing — unlike `onPointerUp` above, this is
2318
+ // never a "drop", so no `structure.reorder` call here.
2319
+ if (reorderPreviewRef.current) {
2320
+ setReorderPreview(null);
2321
+ setDragGhost(null);
2322
+ }
2323
+ dragRef.current = null;
2324
+ setMarquee(null);
2325
+ },
2326
+ [endGesture, setMarquee, setReorderPreview],
2327
+ );
2328
+
2329
+ const onPointerLeave = useCallback(() => {
2330
+ if (!scopedAdapter) getViewportPickContext()?.editorControls?.clearHover();
2331
+ setEditorControlCursor(null);
2332
+ setHoverId(null);
2333
+ }, [scopedAdapter]);
2334
+
2335
+ // D3.b — double-click picks the node and opens the inline text editor
2336
+ // through `adapter.text` (owner-routed — `textForId`), rule zero: no DOM
2337
+ // read of the node's own text happens here, only the contract's own
2338
+ // `TextProvider.get`. Absent `text` (the ingested react-dom adapter has
2339
+ // none) ⇒ silently nothing, matching D3.a's identical capability-gating
2340
+ // stance for structural items. A `null` get (dynamic body / has child
2341
+ // elements) is a LOUD, honest refusal — no textarea, a visible indicator +
2342
+ // a console.warn, mirroring the store's existing dynamic-write-refusal
2343
+ // pattern (`ReactRootAuthoringAdapter`'s own `console.warn` + re-render).
2344
+ const onDoubleClick = useCallback(
2345
+ (e: ReactMouseEvent<HTMLDivElement>) => {
2346
+ // The React design board owns frame-level double-click before DOM text
2347
+ // editing: single click selects in place; double-click is the explicit
2348
+ // Figma-style zoom/recenter gesture, including for the active frame.
2349
+ const overlayContainer = interactionRef.current?.parentElement ?? null;
2350
+ if (zoomReactStoryFrameAtPoint(overlayContainer, e.clientX, e.clientY)) return;
2351
+ const hitId = pickAt(e.clientX, e.clientY);
2352
+ if (!hitId) return;
2353
+ const provider = textForId(adapter, hitId);
2354
+ if (!provider) return;
2355
+ const rect = rectForId(adapter, hitId);
2356
+ if (!rect) return;
2357
+ const text = provider.get(hitId);
2358
+ if (text === null) {
2359
+ // biome-ignore lint/suspicious/noConsole: deliberate, greppable refusal report — mirrors ReactRootAuthoringAdapter's own console.warn refusal pattern
2360
+ console.warn(
2361
+ `[RootSelectionOverlay] text edit refused for "${hitId}": dynamic body or has ` +
2362
+ 'child elements — not inline-editable.',
2363
+ );
2364
+ setTextEdit(null);
2365
+ setTextEditRefused({ id: hitId, rect });
2366
+ return;
2367
+ }
2368
+ setTextEditRefused(null);
2369
+ setTextEdit({ id: hitId, rect, initialText: text });
2370
+ },
2371
+ [adapter, pickAt, setTextEdit],
2372
+ );
2373
+
2374
+ const commitTextEdit = useCallback(
2375
+ (text: string) => {
2376
+ const edit = textEditRef.current;
2377
+ if (!edit) return;
2378
+ const provider = textForId(adapter, edit.id);
2379
+ if (provider) {
2380
+ // D3.R1 (reopen fix) — `set` is fire-and-forget; stash id+rect so the
2381
+ // pending-commit effect above can surface the refused indicator once
2382
+ // the async write's own refusal-notify lands, instead of the edit
2383
+ // silently vanishing (the pre-fix bug: this closed the textarea
2384
+ // unconditionally and never checked the result at all).
2385
+ pendingTextCommitRef.current = { id: edit.id, rect: edit.rect };
2386
+ provider.set(edit.id, text);
2387
+ }
2388
+ setTextEdit(null);
2389
+ },
2390
+ [adapter, setTextEdit],
2391
+ );
2392
+
2393
+ const cancelTextEdit = useCallback(() => setTextEdit(null), [setTextEdit]);
2394
+
2395
+ if (!hasRectCapableChild(adapter)) return null;
2396
+ // The overlay is a DESIGN-TIME affordance and must be INERT whenever a real
2397
+ // game owns its own DOM/canvas and needs pointer events — Play, an ingested
2398
+ // unmodified game, or an auto-mounted `{ module }` adapter world — so this
2399
+ // component's full-cover `pointerEvents:'auto'` interaction layer never
2400
+ // swallows that game's own clicks (that regressed 20-ingest-react-rpg: the
2401
+ // menu→dungeon flow never advanced, the sprite never moved).
2402
+ //
2403
+ // Gate on the SAME "is a session like that actually active" predicates
2404
+ // `isPlayModeActive`/`isIngestActive`/`isModuleModeActive` already expose
2405
+ // (play-mode.ts / ingest/mount-ingest-root.ts / module-mode.ts) — NOT the coarser
2406
+ // `store.playState !== 'stopped'` flag a prior fix used here. That flag is
2407
+ // shared, multi-purpose editor state (it also drives tab-switching,
2408
+ // autosave suppression, etc.) and is `'playing'` for the ENTIRE lifetime of
2409
+ // those sessions, exactly like these predicates — so swapping in the
2410
+ // narrower, purpose-built signal changes nothing for the cases the prior
2411
+ // fix targeted. What it fixes: `mountDesignTimeLayers` (B1) mounts a LIVE,
2412
+ // editable `ReactRootAuthoringAdapter` for every non-focused react/pixi
2413
+ // world while the editor sits in ordinary stopped design-time editing
2414
+ // (27a's own scenario: select a react element, edit it, watch HMR settle)
2415
+ // — a session `playState` alone cannot distinguish from "a game is
2416
+ // running" the moment anything else in this growing multi-purpose flag's
2417
+ // surface touches it. Routing through the actual session predicates keeps
2418
+ // this component's own inertness tied to the ONE thing it actually cares
2419
+ // about (a foreign/first-party game owning the canvas), decoupled from
2420
+ // every other reason `playState` might change.
2421
+ if (isCanvasOwnedByARunningGame()) return null;
2422
+
2423
+ // Play→Stop replaces the live Pixi tree and its adapter in separate React
2424
+ // commits. Selection intentionally survives that transition, but an id from
2425
+ // the previous tree must not be sent through owner-routed providers while
2426
+ // the replacement hierarchy is still mounting. `hierarchy.node` is the
2427
+ // merged, non-owner-routed membership check, so filtering here keeps the
2428
+ // overlay quiet for that one frame and lets stable ids resume naturally.
2429
+ const selectedIds = new Set(
2430
+ [...store.selectedEntityIds].filter((id) => adapter.hierarchy.node(id) !== undefined),
2431
+ );
2432
+ const hoverRect =
2433
+ hoverId !== null && !selectedIds.has(hoverId) ? rectForId(adapter, hoverId) : null;
2434
+
2435
+ // B2/B3 — handles/rotate/dimension-label/spacing-bands render ONLY for
2436
+ // EXACTLY one selected node whose OWNER exposes `boxEdit` (spec:317 "render
2437
+ // ONLY when EXACTLY one node is selected AND its owner has boxEdit" —
2438
+ // multi-select keeps just the plain A3 boxes above). Computed by a
2439
+ // module-level helper purely to keep THIS function's own cognitive
2440
+ // complexity down.
2441
+ const single = computeSingleSelectionBoxEdit(adapter, selectedIds);
2442
+ const singleRect = single?.rect ?? null;
2443
+ const singleOwnerBoxEdit = single?.ownerBoxEdit ?? null;
2444
+ const nativeGizmoOrigin =
2445
+ transformModeAware && single
2446
+ ? (single.ownerBoxEdit.gizmoOrigin?.(single.id) ?? {
2447
+ x: single.rect.x + single.rect.width / 2,
2448
+ y: single.rect.y + single.rect.height / 2,
2449
+ })
2450
+ : null;
2451
+ const nativeGizmoSpan = NATIVE_GIZMO_LENGTH_PX / pan.zoom;
2452
+ const referencePoint = single ? single.ownerBoxEdit.referencePoint?.(single.id) : null;
2453
+ const spacingBands = single?.spacingBands ?? [];
2454
+
2455
+ // Adapter-owned component handles for the selection, in the SAME authored
2456
+ // frame as `rects` (an owner that answers in another frame is declined by
2457
+ // `spatialHandlesForId`). Exactly one selected node, mirroring every other
2458
+ // single-selection affordance above; `[]` for an owner with no handles, which
2459
+ // draws nothing at all.
2460
+ const spatialSelection = selectedIds.size === 1 ? [...selectedIds][0]! : null;
2461
+ const spatialHandles = spatialSelection ? spatialHandlesForId(adapter, spatialSelection) : null;
2462
+ const spatialLayers =
2463
+ spatialSelection && spatialHandles && store.showHelpers
2464
+ ? spatialHandles.layers(spatialSelection).filter((layer) => {
2465
+ const visibility = store.helperVisibility as Record<string, boolean | undefined>;
2466
+ // A category the shell does not name obeys the master Helpers toggle,
2467
+ // per the `SpatialHandleLayer` contract — never rejected for being
2468
+ // unknown.
2469
+ return visibility[layer.category] ?? true;
2470
+ })
2471
+ : [];
2472
+
2473
+ // D1 — measure lines, position/parent-layout badge, parent-container
2474
+ // outline (all READ-only — spec §6). Computed by a module-level helper,
2475
+ // same "keep THIS function's own cognitive complexity down" reason
2476
+ // `computeSingleSelectionBoxEdit` above is factored out.
2477
+ const d1 = transformModeAware
2478
+ ? {
2479
+ selectedSingleRect: null,
2480
+ measureLines: [],
2481
+ positionBadge: null,
2482
+ showPositionBadge: false,
2483
+ parentRect: null,
2484
+ }
2485
+ : computeD1Affordances(adapter, selectedIds, hoverRect);
2486
+
2487
+ // D4.b — the multi-select align/distribute toolbar's entries, or `null`
2488
+ // when it shouldn't render (see `computeMultiSelectionAlign`'s doc
2489
+ // comment for the capability gate).
2490
+ const alignEntries = transformModeAware ? null : computeMultiSelectionAlign(adapter, selectedIds);
2491
+
2492
+ // D3.c — every currently-empty container hint reachable from the active
2493
+ // adapter (owner-routed per child, session-hidden roots excluded — see
2494
+ // `emptyContainerHintsFor`'s doc comment). Cached (see `emptyHintsCacheRef`
2495
+ // above) so a hover-only re-render reuses the last computed hints instead
2496
+ // of re-walking + re-measuring the whole tree.
2497
+ const emptyHintsCache = emptyHintsCacheRef.current;
2498
+ const emptyHints = transformModeAware
2499
+ ? []
2500
+ : emptyHintsCache &&
2501
+ emptyHintsCache.adapter === adapter &&
2502
+ emptyHintsCache.version === storeVersion
2503
+ ? emptyHintsCache.hints
2504
+ : (() => {
2505
+ const hints = emptyContainerHintsFor(adapter);
2506
+ emptyHintsCacheRef.current = { adapter, version: storeVersion, hints };
2507
+ return hints;
2508
+ })();
2509
+
2510
+ return (
2511
+ <div
2512
+ style={{
2513
+ position: 'absolute',
2514
+ inset: 0,
2515
+ zIndex: Z_INDEX,
2516
+ pointerEvents: 'none',
2517
+ }}
2518
+ >
2519
+ <div
2520
+ ref={interactionRef}
2521
+ data-testid="world-selection-overlay-interaction"
2522
+ style={{
2523
+ position: 'absolute',
2524
+ inset: 0,
2525
+ pointerEvents: 'auto',
2526
+ touchAction: 'none',
2527
+ userSelect: 'none',
2528
+ cursor:
2529
+ editorControlCursor ??
2530
+ interactionCursor(isPanning, spaceHeld, eyedropperActive, !!marquee),
2531
+ }}
2532
+ onPointerDownCapture={onPointerDown}
2533
+ onPointerMove={onPointerMove}
2534
+ onPointerUp={onPointerUp}
2535
+ onPointerCancel={onPointerCancel}
2536
+ onLostPointerCapture={onPointerCancel}
2537
+ onPointerLeave={onPointerLeave}
2538
+ onDoubleClick={onDoubleClick}
2539
+ onContextMenu={onContextMenu}
2540
+ onDragOver={onAssetDragOver}
2541
+ onDrop={onAssetDrop}
2542
+ />
2543
+ {pickMenu ? (
2544
+ <ViewportPickMenu state={pickMenu} adapter={adapter} onClose={() => setPickMenu(null)} />
2545
+ ) : null}
2546
+ <div
2547
+ data-testid="world-selection-overlay-chrome"
2548
+ style={{
2549
+ position: 'absolute',
2550
+ inset: 0,
2551
+ pointerEvents: 'none',
2552
+ // Only visual chrome follows the authored world. The input sibling
2553
+ // above remains an untransformed, full-viewport hit surface.
2554
+ transform: panTransformValue(pan),
2555
+ transformOrigin: '0 0',
2556
+ }}
2557
+ >
2558
+ {/* D3.c — dashed empty-container placeholders, visual hints only. */}
2559
+ {!transformModeAware &&
2560
+ emptyHints.map((hint) => (
2561
+ <div
2562
+ key={hint.id}
2563
+ data-testid="world-empty-container-hint"
2564
+ data-entity-id={hint.id}
2565
+ title={hint.displayName}
2566
+ style={{
2567
+ position: 'absolute',
2568
+ left: hint.rect.x,
2569
+ top: hint.rect.y,
2570
+ width: hint.rect.width,
2571
+ height: hint.rect.height,
2572
+ // text-2 at 0.7 alpha — a quiet non-interactive hint outline.
2573
+ border: '1px dashed rgba(154, 160, 166, 0.7)',
2574
+ borderWidth: transformModeAware ? 1 / pan.zoom : 1,
2575
+ boxSizing: 'border-box',
2576
+ pointerEvents: 'none',
2577
+ }}
2578
+ />
2579
+ ))}
2580
+ {hoverRect && (
2581
+ <div
2582
+ data-testid="world-selection-hover"
2583
+ style={{
2584
+ ...boxStyle(hoverRect, false),
2585
+ ...(transformModeAware ? { borderWidth: 1 / pan.zoom } : {}),
2586
+ }}
2587
+ />
2588
+ )}
2589
+ {[...selectedIds].map((id) => {
2590
+ const r = rectForId(adapter, id);
2591
+ if (!r) return null;
2592
+ return (
2593
+ <div key={id}>
2594
+ <div
2595
+ data-testid="world-selection-box"
2596
+ data-entity-id={id}
2597
+ style={{
2598
+ ...boxStyle(r, true),
2599
+ ...(transformModeAware
2600
+ ? {
2601
+ // Pixi's alpha outline now owns selection emphasis.
2602
+ // This remains the thin, distinct transform frame.
2603
+ borderWidth: 1 / pan.zoom,
2604
+ background: 'transparent',
2605
+ }
2606
+ : {}),
2607
+ }}
2608
+ />
2609
+ {!transformModeAware && (
2610
+ <div data-testid="world-selection-label" style={labelStyle(r)}>
2611
+ {labelForId(adapter, id)}
2612
+ </div>
2613
+ )}
2614
+ </div>
2615
+ );
2616
+ })}
2617
+ {marquee && (
2618
+ <div
2619
+ data-testid="world-selection-marquee"
2620
+ style={{
2621
+ position: 'absolute',
2622
+ left: marquee.x,
2623
+ top: marquee.y,
2624
+ width: marquee.width,
2625
+ height: marquee.height,
2626
+ border: `1px solid ${ACCENT}`,
2627
+ background: `color-mix(in srgb, ${ACCENT} 10%, transparent)`,
2628
+ pointerEvents: 'none',
2629
+ }}
2630
+ />
2631
+ )}
2632
+ {!transformModeAware && (
2633
+ <D1Affordances
2634
+ measureLines={d1.measureLines}
2635
+ snapGuides={snapGuides}
2636
+ positionBadge={d1.positionBadge}
2637
+ showPositionBadge={d1.showPositionBadge}
2638
+ badgeAnchorRect={d1.selectedSingleRect}
2639
+ parentRect={d1.parentRect}
2640
+ screenScale={chromeScale}
2641
+ screenZoom={1}
2642
+ />
2643
+ )}
2644
+ {!transformModeAware && (
2645
+ <D2Affordances dragGhost={dragGhost} reorderPreview={reorderPreview} />
2646
+ )}
2647
+ {!transformModeAware && alignEntries && (
2648
+ <AlignToolbar adapter={adapter} entries={alignEntries} />
2649
+ )}
2650
+ {singleRect && singleOwnerBoxEdit && (
2651
+ <>
2652
+ {/* B-fix (D1-visual + D4 review): the B3 spacing bands render
2653
+ * BEFORE the resize handles below — deliberately, so the handles
2654
+ * paint ON TOP wherever their small hit box overlaps a band. A
2655
+ * zero-value side still renders a `ZERO_STRIP_PX` grab-strip
2656
+ * (spec B3) flush against the rect edge, and a mid-edge handle's
2657
+ * clickable CENTER point sits exactly on that same edge — pre-fix,
2658
+ * the bands rendered AFTER the handles (reverse of this order) and
2659
+ * so sat on top in DOM/paint order, silently eating the
2660
+ * pointerdown meant for the handle (n/s/e/w all affected; the
2661
+ * corner handles were not — a corner's point always lands on the
2662
+ * EXCLUSIVE far edge of its two adjacent bands). This is a pure
2663
+ * DOM-order swap: the band geometry/hit area is UNCHANGED, so
2664
+ * every band remains fully draggable everywhere it isn't directly
2665
+ * shadowed by a handle's own small box. */}
2666
+ {!transformModeAware &&
2667
+ spacingBands.map((band) => (
2668
+ <div key={band.side}>
2669
+ <div
2670
+ data-testid={`world-spacing-${band.kind}-${spacingDirection(band.side)}`}
2671
+ style={spacingBandStyle(band)}
2672
+ onPointerDown={(e) => startSpacingGesture(band, e)}
2673
+ onPointerMove={onPointerMove}
2674
+ onPointerUp={onPointerUp}
2675
+ onPointerCancel={onPointerCancel}
2676
+ />
2677
+ {!band.isZero && (
2678
+ <div
2679
+ style={{
2680
+ ...spacingLabelStyle(band),
2681
+ ...(transformModeAware
2682
+ ? {
2683
+ transform: `translate(-50%, -50%) scale(${chromeScale})`,
2684
+ transformOrigin: 'center',
2685
+ }
2686
+ : {}),
2687
+ }}
2688
+ >
2689
+ {Math.round(band.value)}
2690
+ </div>
2691
+ )}
2692
+ </div>
2693
+ ))}
2694
+ {referencePoint && (
2695
+ <div
2696
+ aria-label={`Edit ${referencePoint.label}`}
2697
+ data-reference-kind={referencePoint.kind}
2698
+ data-testid="world-reference-point-handle"
2699
+ title={`${referencePoint.label} (${referencePoint.bounded ? 'native corner/edge/center snap' : '8px/alignment snap'}, Alt for free movement)`}
2700
+ style={{
2701
+ ...referencePointStyle(referencePoint),
2702
+ ...(transformModeAware
2703
+ ? {
2704
+ transform: `scale(${chromeScale})`,
2705
+ transformOrigin: 'center',
2706
+ }
2707
+ : {}),
2708
+ }}
2709
+ onPointerDown={(event) => startReferenceGesture(referencePoint, event)}
2710
+ onPointerMove={onPointerMove}
2711
+ onPointerUp={onPointerUp}
2712
+ onPointerCancel={onPointerCancel}
2713
+ onLostPointerCapture={onPointerCancel}
2714
+ role="button"
2715
+ tabIndex={0}
2716
+ >
2717
+ {referencePoint.kind === 'anchor' ? 'A' : 'P'}
2718
+ </div>
2719
+ )}
2720
+ {/* Adapter-owned component handles — the ONE draggable point per
2721
+ entry, drawn where the provider says it is. The 3D viewport
2722
+ draws the same contract's layers by raycast; this is the same
2723
+ data in the surface where a 2D world is actually painted. */}
2724
+ {spatialSelection &&
2725
+ spatialHandles &&
2726
+ spatialLayers.flatMap((layer) =>
2727
+ layer.handles.map((handle) => (
2728
+ <div
2729
+ aria-label={handle.label}
2730
+ data-testid="world-spatial-handle"
2731
+ data-handle-id={handle.id}
2732
+ data-writable={handle.writable}
2733
+ key={`${layer.id}:${handle.id}`}
2734
+ role="button"
2735
+ tabIndex={0}
2736
+ title={handle.writable ? handle.label : `${handle.label} — ${handle.reason}`}
2737
+ style={{
2738
+ ...spatialHandleStyle(handle),
2739
+ transform: `translate(-50%, -50%) scale(${chromeScale})`,
2740
+ }}
2741
+ onPointerDown={(event) =>
2742
+ onSpatialHandleDown(spatialSelection, spatialHandles, handle, event)
2743
+ }
2744
+ onPointerMove={onSpatialHandleMove}
2745
+ onPointerUp={onSpatialHandleUp}
2746
+ onPointerCancel={onSpatialHandleUp}
2747
+ onLostPointerCapture={onSpatialHandleUp}
2748
+ />
2749
+ )),
2750
+ )}
2751
+ {transformModeAware && store.transformMode === 'translate' && (
2752
+ <>
2753
+ <div
2754
+ data-testid="world-2d-move-axis-x"
2755
+ title="Move on X axis"
2756
+ style={{
2757
+ position: 'absolute',
2758
+ left: nativeGizmoOrigin!.x,
2759
+ top: nativeGizmoOrigin!.y - 5 / pan.zoom,
2760
+ width: nativeGizmoSpan,
2761
+ height: 10 / pan.zoom,
2762
+ borderTop: `${2 / pan.zoom}px solid #ef5350`,
2763
+ boxSizing: 'border-box',
2764
+ cursor: 'ew-resize',
2765
+ pointerEvents: 'auto',
2766
+ }}
2767
+ onPointerDown={(event) => startMoveGizmoGesture('x', event)}
2768
+ onPointerMove={onPointerMove}
2769
+ onPointerUp={onPointerUp}
2770
+ onPointerCancel={onPointerCancel}
2771
+ />
2772
+ <div
2773
+ data-testid="world-2d-move-axis-y"
2774
+ title="Move on Y axis"
2775
+ style={{
2776
+ position: 'absolute',
2777
+ left: nativeGizmoOrigin!.x - 5 / pan.zoom,
2778
+ top: nativeGizmoOrigin!.y,
2779
+ width: 10 / pan.zoom,
2780
+ height: nativeGizmoSpan,
2781
+ borderLeft: `${2 / pan.zoom}px solid #66bb6a`,
2782
+ boxSizing: 'border-box',
2783
+ cursor: 'ns-resize',
2784
+ pointerEvents: 'auto',
2785
+ }}
2786
+ onPointerDown={(event) => startMoveGizmoGesture('y', event)}
2787
+ onPointerMove={onPointerMove}
2788
+ onPointerUp={onPointerUp}
2789
+ onPointerCancel={onPointerCancel}
2790
+ />
2791
+ <div
2792
+ data-testid="world-2d-move-center"
2793
+ title="Move freely"
2794
+ style={{
2795
+ position: 'absolute',
2796
+ left: nativeGizmoOrigin!.x - 5 / pan.zoom,
2797
+ top: nativeGizmoOrigin!.y - 5 / pan.zoom,
2798
+ width: 10 / pan.zoom,
2799
+ height: 10 / pan.zoom,
2800
+ border: '1px solid rgba(255,255,255,.9)',
2801
+ background: '#4aa3ff',
2802
+ boxSizing: 'border-box',
2803
+ cursor: 'move',
2804
+ pointerEvents: 'auto',
2805
+ }}
2806
+ onPointerDown={(event) => startMoveGizmoGesture('both', event)}
2807
+ onPointerMove={onPointerMove}
2808
+ onPointerUp={onPointerUp}
2809
+ onPointerCancel={onPointerCancel}
2810
+ />
2811
+ </>
2812
+ )}
2813
+ {(!transformModeAware || store.transformMode === 'scale') &&
2814
+ (transformModeAware ? (
2815
+ <>
2816
+ <div
2817
+ data-testid="world-2d-scale-axis-x"
2818
+ style={{
2819
+ position: 'absolute',
2820
+ left: nativeGizmoOrigin!.x,
2821
+ top: nativeGizmoOrigin!.y,
2822
+ width: nativeGizmoSpan,
2823
+ borderTop: `${2 / pan.zoom}px solid #ef5350`,
2824
+ pointerEvents: 'none',
2825
+ }}
2826
+ />
2827
+ <div
2828
+ data-testid="world-2d-scale-axis-y"
2829
+ style={{
2830
+ position: 'absolute',
2831
+ left: nativeGizmoOrigin!.x,
2832
+ top: nativeGizmoOrigin!.y,
2833
+ height: nativeGizmoSpan,
2834
+ borderLeft: `${2 / pan.zoom}px solid #66bb6a`,
2835
+ pointerEvents: 'none',
2836
+ }}
2837
+ />
2838
+ {(
2839
+ [
2840
+ ['x', 'ew-resize'],
2841
+ ['y', 'ns-resize'],
2842
+ ['both', 'nwse-resize'],
2843
+ ] as const
2844
+ ).map(([axis, cursor]) => {
2845
+ const point = nativeScaleHandlePoint(axis, nativeGizmoOrigin!, nativeGizmoSpan);
2846
+ const testId = axis === 'x' ? 'e' : axis === 'y' ? 's' : 'se';
2847
+ return (
2848
+ <div
2849
+ key={axis}
2850
+ data-testid={`world-resize-handle-${testId}`}
2851
+ style={{
2852
+ ...handleStyle(point, cursor),
2853
+ transform: `scale(${chromeScale})`,
2854
+ transformOrigin: 'center',
2855
+ background: axis === 'x' ? '#ef5350' : axis === 'y' ? '#66bb6a' : '#fff',
2856
+ }}
2857
+ onPointerDown={(event) => startNativeScaleGesture(axis, event)}
2858
+ onPointerMove={onPointerMove}
2859
+ onPointerUp={onPointerUp}
2860
+ onPointerCancel={onPointerCancel}
2861
+ />
2862
+ );
2863
+ })}
2864
+ </>
2865
+ ) : (
2866
+ <>
2867
+ {RESIZE_HANDLES.map(({ pos, cursor }) => (
2868
+ <div
2869
+ key={pos}
2870
+ data-testid={`world-resize-handle-${pos}`}
2871
+ style={handleStyle(handlePosition(singleRect, pos), cursor)}
2872
+ onPointerDown={(event) => startResizeGesture(pos, event)}
2873
+ onPointerMove={onPointerMove}
2874
+ onPointerUp={onPointerUp}
2875
+ onPointerCancel={onPointerCancel}
2876
+ />
2877
+ ))}
2878
+ <div
2879
+ data-testid="world-resize-dimension-label"
2880
+ style={dimensionLabelStyle(singleRect)}
2881
+ >
2882
+ {`${Math.round(singleRect.width)}×${Math.round(singleRect.height)}`}
2883
+ </div>
2884
+ </>
2885
+ ))}
2886
+ {(!transformModeAware || store.transformMode === 'rotate') && (
2887
+ <>
2888
+ {transformModeAware && (
2889
+ <div
2890
+ data-testid="world-2d-rotate-ring"
2891
+ style={{
2892
+ position: 'absolute',
2893
+ left: nativeGizmoOrigin!.x - 40 / pan.zoom,
2894
+ top: nativeGizmoOrigin!.y - 40 / pan.zoom,
2895
+ width: 80 / pan.zoom,
2896
+ height: 80 / pan.zoom,
2897
+ border: `${1 / pan.zoom}px solid #4aa3ff`,
2898
+ borderRadius: '50%',
2899
+ boxSizing: 'border-box',
2900
+ pointerEvents: 'none',
2901
+ }}
2902
+ />
2903
+ )}
2904
+ <div
2905
+ data-testid="world-rotate-handle"
2906
+ aria-label="Rotate selection"
2907
+ title="Rotate selection (15° snap, Alt for free rotation)"
2908
+ style={{
2909
+ ...rotateHandleStyle(singleRect),
2910
+ ...(transformModeAware
2911
+ ? {
2912
+ left: nativeGizmoOrigin!.x - ROTATE_SIZE / 2,
2913
+ top: nativeGizmoOrigin!.y - 40 / pan.zoom - ROTATE_SIZE / 2,
2914
+ transform: `scale(${chromeScale})`,
2915
+ transformOrigin: 'center',
2916
+ background: '#4aa3ff',
2917
+ }
2918
+ : {}),
2919
+ }}
2920
+ onPointerDown={startRotateGesture}
2921
+ onPointerMove={onPointerMove}
2922
+ onPointerUp={onPointerUp}
2923
+ onPointerCancel={onPointerCancel}
2924
+ role="button"
2925
+ tabIndex={0}
2926
+ />
2927
+ </>
2928
+ )}
2929
+ </>
2930
+ )}
2931
+ {!transformModeAware && (
2932
+ <D3Affordances
2933
+ textEdit={textEdit}
2934
+ onCommitTextEdit={commitTextEdit}
2935
+ onCancelTextEdit={cancelTextEdit}
2936
+ textEditRefused={textEditRefused}
2937
+ eyedropperActive={eyedropperActive}
2938
+ eyedropperPreview={eyedropperPreview}
2939
+ />
2940
+ )}
2941
+ </div>
2942
+ </div>
2943
+ );
2944
+ }
2945
+
2946
+ /**
2947
+ * D2Affordances — the D2.a drag-ghost + D2.b reorder-preview marker,
2948
+ * factored into its OWN component for the SAME reason `D3Affordances` below
2949
+ * is: keeps `RootSelectionOverlay`'s own cognitive complexity down (two
2950
+ * independent conditional renders, bundled here at zero cost to the parent
2951
+ * beyond one element reference). No behavior change from having them inline.
2952
+ */
2953
+ /**
2954
+ * D1Affordances — the D1 measurement + snap-guide + badge + parent-outline
2955
+ * overlay bits, factored into its OWN component for the SAME reason
2956
+ * `D2Affordances`/`D3Affordances` are (keeps `RootSelectionOverlay`'s own
2957
+ * cognitive complexity down; no behavior change from having them inline).
2958
+ * All four are READ-only visual affordances (spec §6 D1) — none of them
2959
+ * drives a commit path.
2960
+ */
2961
+ function D1Affordances(props: {
2962
+ measureLines: MeasureLine[];
2963
+ snapGuides: SnapGuide[];
2964
+ positionBadge: PositionBadgeInfo | null;
2965
+ showPositionBadge: boolean;
2966
+ badgeAnchorRect: DOMRectLike | null;
2967
+ parentRect: DOMRectLike | null;
2968
+ screenScale: number;
2969
+ screenZoom: number;
2970
+ }): React.ReactNode {
2971
+ const {
2972
+ measureLines,
2973
+ snapGuides,
2974
+ positionBadge,
2975
+ showPositionBadge,
2976
+ badgeAnchorRect,
2977
+ parentRect,
2978
+ screenScale,
2979
+ screenZoom,
2980
+ } = props;
2981
+ return (
2982
+ <>
2983
+ {/* D1.c — the parent-container outline, drawn BEHIND the selection/
2984
+ hover boxes (DOM order) so it never visually competes with them. */}
2985
+ {parentRect && (
2986
+ <div data-testid="world-parent-outline" style={parentOutlineStyle(parentRect)} />
2987
+ )}
2988
+ {/* D1.a — sibling-distance measure line(s) between the selection and
2989
+ the hovered sibling, each with two end-caps + a px value label. */}
2990
+ {measureLines.map((line) => (
2991
+ <div key={line.orientation} data-testid="world-measure-line">
2992
+ <div style={measureLineStyle(line)} />
2993
+ <div style={measureCapStyle(line.x1, line.y1, line.orientation === 'horizontal')} />
2994
+ <div style={measureCapStyle(line.x2, line.y2, line.orientation === 'horizontal')} />
2995
+ <div
2996
+ data-testid="world-measure-line-label"
2997
+ style={{
2998
+ ...measureLabelStyle(line),
2999
+ ...(screenScale !== 1
3000
+ ? {
3001
+ transform: `translate(-50%, -50%) scale(${screenScale})`,
3002
+ transformOrigin: 'center',
3003
+ }
3004
+ : {}),
3005
+ }}
3006
+ >
3007
+ {line.distance}
3008
+ </div>
3009
+ </div>
3010
+ ))}
3011
+ {/* D1.b — the currently-engaged alignment/snap guide(s), live only
3012
+ while a B2 move/resize gesture's own edge-snap is engaged. */}
3013
+ {snapGuides.map((guide) => (
3014
+ // At most ONE guide per orientation (`computeMoveSnapGuides`/
3015
+ // `computeResizeSnapGuides` each push ≤1 vertical + ≤1 horizontal),
3016
+ // so `orientation` alone is a stable, unique key — no array index.
3017
+ <div key={guide.orientation} data-testid="world-snap-guide" style={snapGuideStyle(guide)} />
3018
+ ))}
3019
+ {/* D1.c — the position/parent-layout badge; each segment renders only
3020
+ when its own value resolved (never a fabricated default). */}
3021
+ {showPositionBadge && badgeAnchorRect && positionBadge && (
3022
+ <div
3023
+ data-testid="world-position-badge"
3024
+ style={{
3025
+ ...positionBadgeStyle(badgeAnchorRect),
3026
+ ...(screenScale !== 1
3027
+ ? {
3028
+ top: badgeAnchorRect.y - 36 / screenZoom,
3029
+ transform: `scale(${screenScale})`,
3030
+ transformOrigin: 'top left',
3031
+ }
3032
+ : {}),
3033
+ }}
3034
+ >
3035
+ {positionBadge.position && (
3036
+ <span data-testid="world-position-badge-mode" style={BADGE_SEGMENT_STYLE}>
3037
+ {positionBadge.position}
3038
+ </span>
3039
+ )}
3040
+ {positionBadge.parentLayout && (
3041
+ <span data-testid="world-position-badge-layout" style={BADGE_SEGMENT_STYLE}>
3042
+ {positionBadge.parentLayout}
3043
+ </span>
3044
+ )}
3045
+ </div>
3046
+ )}
3047
+ </>
3048
+ );
3049
+ }
3050
+
3051
+ function D2Affordances(props: {
3052
+ dragGhost: Rect | null;
3053
+ reorderPreview: { gap: ReorderGap } | null;
3054
+ }): React.ReactNode {
3055
+ const { dragGhost, reorderPreview } = props;
3056
+ return (
3057
+ <>
3058
+ {/* D2.a — the drag-ghost, live during a B2 move gesture OR a D2.b
3059
+ reorder-preview drag. Pure visual, never a commit path. */}
3060
+ {dragGhost && <div data-testid="world-drag-ghost" style={dragGhostStyle(dragGhost)} />}
3061
+ {/* D2.b — the live reorder-preview marker at the gap the drop would
3062
+ land in. */}
3063
+ {reorderPreview && (
3064
+ <div
3065
+ data-testid="world-reorder-preview"
3066
+ style={reorderMarkerStyle(reorderPreview.gap.marker)}
3067
+ />
3068
+ )}
3069
+ </>
3070
+ );
3071
+ }
3072
+
3073
+ /**
3074
+ * D3Affordances — the D3.b/d overlay bits (inline text editor + its refusal
3075
+ * indicator, eyedropper swatch), factored into its OWN
3076
+ * component purely to keep `RootSelectionOverlay`'s own cognitive
3077
+ * complexity down (each is an independent, mutually-exclusive-in-practice
3078
+ * conditional render — bundling them here costs the PARENT nothing beyond
3079
+ * one element reference). No behavior change from having them inline.
3080
+ */
3081
+ function D3Affordances(props: {
3082
+ textEdit: { id: string; rect: DOMRectLike; initialText: string } | null;
3083
+ onCommitTextEdit: (text: string) => void;
3084
+ onCancelTextEdit: () => void;
3085
+ textEditRefused: { id: string; rect: DOMRectLike } | null;
3086
+ eyedropperActive: boolean;
3087
+ eyedropperPreview: { x: number; y: number; color: string | null } | null;
3088
+ }): React.ReactNode {
3089
+ const {
3090
+ textEdit,
3091
+ onCommitTextEdit,
3092
+ onCancelTextEdit,
3093
+ textEditRefused,
3094
+ eyedropperActive,
3095
+ eyedropperPreview,
3096
+ } = props;
3097
+ return (
3098
+ <>
3099
+ {/* D3.b — the inline text editor (double-click), or the loud "not
3100
+ editable" refusal indicator for a dynamic/child-bearing body. */}
3101
+ {textEdit && (
3102
+ <div style={{ pointerEvents: 'auto' }}>
3103
+ <RootTextEditor
3104
+ rect={textEdit.rect}
3105
+ initialText={textEdit.initialText}
3106
+ onCommit={onCommitTextEdit}
3107
+ onCancel={onCancelTextEdit}
3108
+ />
3109
+ </div>
3110
+ )}
3111
+ {textEditRefused && (
3112
+ <div
3113
+ data-testid="world-text-edit-refused"
3114
+ title="dynamic body — not inline-editable"
3115
+ style={{
3116
+ position: 'absolute',
3117
+ left: textEditRefused.rect.x,
3118
+ top: textEditRefused.rect.y,
3119
+ width: Math.max(textEditRefused.rect.width, 40),
3120
+ height: Math.max(textEditRefused.rect.height, 20),
3121
+ border: `2px dashed ${themeVars.semantic.danger}`,
3122
+ boxSizing: 'border-box',
3123
+ background: themeVars.semantic.dangerMuted,
3124
+ pointerEvents: 'none',
3125
+ }}
3126
+ />
3127
+ )}
3128
+ {/* D3.d — the fallback eyedropper cursor swatch: a resolved color, or an
3129
+ explicit "unknown" state — NEVER a fabricated color. */}
3130
+ {eyedropperActive && eyedropperPreview && (
3131
+ <div
3132
+ data-testid="world-eyedropper-swatch"
3133
+ data-color-state={eyedropperPreview.color ? 'resolved' : 'unknown'}
3134
+ style={{
3135
+ position: 'absolute',
3136
+ left: eyedropperPreview.x + 12,
3137
+ top: eyedropperPreview.y + 12,
3138
+ width: 24,
3139
+ height: 24,
3140
+ borderRadius: 4,
3141
+ border: '2px solid #fff',
3142
+ boxShadow: '0 1px 4px rgba(0,0,0,0.5)',
3143
+ background:
3144
+ eyedropperPreview.color ??
3145
+ 'repeating-linear-gradient(45deg, #999, #999 4px, #ccc 4px, #ccc 8px)',
3146
+ pointerEvents: 'none',
3147
+ }}
3148
+ />
3149
+ )}
3150
+ </>
3151
+ );
3152
+ }
3153
+
3154
+ /** `paddingTop`/`marginTop` → `'top'`, etc. — the direction suffix in each
3155
+ * spacing band's `data-testid` (spec's own `world-spacing-{padding|margin}-
3156
+ * {top|right|bottom|left}` naming). */
3157
+ function spacingDirection(side: SpacingSide): 'top' | 'right' | 'bottom' | 'left' {
3158
+ if (side.endsWith('Top')) return 'top';
3159
+ if (side.endsWith('Right')) return 'right';
3160
+ if (side.endsWith('Bottom')) return 'bottom';
3161
+ return 'left';
3162
+ }