@volter/sdk 0.0.0-stage → 0.5.203

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 (523) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +20 -0
  3. package/README.md +38 -3
  4. package/package.json +510 -4
  5. package/src/account.ts +210 -0
  6. package/src/chrome.ts +88 -0
  7. package/src/client.ts +1646 -0
  8. package/src/commands.ts +66 -0
  9. package/src/contributions.ts +619 -0
  10. package/src/css-numeric-style.ts +97 -0
  11. package/src/document-probe.ts +282 -0
  12. package/src/editor-view.ts +225 -0
  13. package/src/extension.ts +40 -0
  14. package/src/generations.ts +178 -0
  15. package/src/host.ts +1157 -0
  16. package/src/http-transport.browser.ts +14 -0
  17. package/src/http-transport.node.ts +19 -0
  18. package/src/index.ts +131 -0
  19. package/src/kit/CapabilityCoverageSection.tsx +185 -0
  20. package/src/kit/account-client.ts +333 -0
  21. package/src/kit/action-registry.ts +317 -0
  22. package/src/kit/active-product.ts +76 -0
  23. package/src/kit/active-project.ts +155 -0
  24. package/src/kit/adapter-editor-config.ts +25 -0
  25. package/src/kit/adapter-module.ts +7 -0
  26. package/src/kit/adapter-observation.ts +49 -0
  27. package/src/kit/animation/animation-clock.ts +479 -0
  28. package/src/kit/animation/stage-transport.ts +385 -0
  29. package/src/kit/api/assets.ts +365 -0
  30. package/src/kit/api/project-open.ts +355 -0
  31. package/src/kit/api/project-source.ts +180 -0
  32. package/src/kit/api/project-state.ts +110 -0
  33. package/src/kit/api/relay.ts +270 -0
  34. package/src/kit/api/themes.ts +45 -0
  35. package/src/kit/api-asset-library-wire.ts +45 -0
  36. package/src/kit/api-base.ts +10 -0
  37. package/src/kit/api-build.ts +99 -0
  38. package/src/kit/api-git-wire.ts +56 -0
  39. package/src/kit/api-logs.ts +92 -0
  40. package/src/kit/api-project-identity.ts +74 -0
  41. package/src/kit/api-settings.ts +36 -0
  42. package/src/kit/api-worktrees.ts +205 -0
  43. package/src/kit/asset-capabilities.ts +344 -0
  44. package/src/kit/asset-compare-core.ts +171 -0
  45. package/src/kit/asset-editor-context.tsx +101 -0
  46. package/src/kit/asset-events.ts +96 -0
  47. package/src/kit/asset-inspector-actions.ts +87 -0
  48. package/src/kit/asset-selection-viewer-registry.ts +113 -0
  49. package/src/kit/asset-selection.ts +146 -0
  50. package/src/kit/asset-thumbnails.ts +25 -0
  51. package/src/kit/asset-viewers.ts +115 -0
  52. package/src/kit/asset-workflow/asset-import-jobs.ts +106 -0
  53. package/src/kit/asset-workflow/asset-ledger-backend.ts +126 -0
  54. package/src/kit/asset-workflow/asset-ledger.ts +156 -0
  55. package/src/kit/asset-workflow/asset-materialization-report.ts +140 -0
  56. package/src/kit/asset-workflow/asset-pack-manifest.ts +320 -0
  57. package/src/kit/asset-workflow/asset-types.ts +142 -0
  58. package/src/kit/asset-workflow/audio-preview-player.ts +193 -0
  59. package/src/kit/asset-workflow/audio-waveform.ts +22 -0
  60. package/src/kit/asset-workflow/cloud-asset-client.ts +263 -0
  61. package/src/kit/asset-workflow/hosted-asset-materialization.ts +236 -0
  62. package/src/kit/asset-workflow/image-view-scale.ts +31 -0
  63. package/src/kit/asset-workflow/import-contract.ts +124 -0
  64. package/src/kit/asset-workflow/ledger-write-lock.ts +244 -0
  65. package/src/kit/asset-workflow/pixi-spritesheet.ts +197 -0
  66. package/src/kit/asset-workflow/preview-resource-lifetime.ts +44 -0
  67. package/src/kit/asset-workflow/project-asset-commands.ts +20 -0
  68. package/src/kit/asset-workflow/project-content.ts +288 -0
  69. package/src/kit/asset-workflow/project-source-index.ts +545 -0
  70. package/src/kit/asset-workflow/thumbnail-system.ts +256 -0
  71. package/src/kit/authoring/active-adapter.ts +200 -0
  72. package/src/kit/authoring/active-systems.ts +422 -0
  73. package/src/kit/authoring/adapter-key.ts +18 -0
  74. package/src/kit/authoring/authoring-asset-url.ts +27 -0
  75. package/src/kit/authoring/bootstrap-state.ts +49 -0
  76. package/src/kit/authoring/boundary-authoring-adapter.ts +189 -0
  77. package/src/kit/authoring/canvas-scene-guides.ts +84 -0
  78. package/src/kit/authoring/composite-authoring-adapter.ts +2109 -0
  79. package/src/kit/authoring/consumer-actions.ts +531 -0
  80. package/src/kit/authoring/design-time-layers.ts +852 -0
  81. package/src/kit/authoring/design-time-mount-registry.ts +244 -0
  82. package/src/kit/authoring/edit-mode-authoring.ts +637 -0
  83. package/src/kit/authoring/empty-project-authoring.ts +22 -0
  84. package/src/kit/authoring/instance-source-menu.ts +135 -0
  85. package/src/kit/authoring/layered-pick.ts +185 -0
  86. package/src/kit/authoring/mounted-root-subjects.ts +146 -0
  87. package/src/kit/authoring/no-authoring-adapter.ts +59 -0
  88. package/src/kit/authoring/object3d-document-persistence.ts +122 -0
  89. package/src/kit/authoring/panel-authoring.ts +121 -0
  90. package/src/kit/authoring/project-authoring-session.ts +105 -0
  91. package/src/kit/authoring/provenance.ts +99 -0
  92. package/src/kit/authoring/react-canvas-navigation.ts +259 -0
  93. package/src/kit/authoring/react-design-canvas-style.ts +20 -0
  94. package/src/kit/authoring/react-story-board.ts +917 -0
  95. package/src/kit/authoring/selection-scope.ts +195 -0
  96. package/src/kit/authoring/shell-document-ops.ts +169 -0
  97. package/src/kit/authoring/story-board-chrome-fit.ts +107 -0
  98. package/src/kit/authoring/story-board-presentation.ts +111 -0
  99. package/src/kit/authoring/three-root.ts +67 -0
  100. package/src/kit/authoring/viewport-tool-context.ts +73 -0
  101. package/src/kit/authoring/viewport-tool-owner.ts +38 -0
  102. package/src/kit/authoring/world-session-state.ts +101 -0
  103. package/src/kit/authoring-seam-evidence.ts +300 -0
  104. package/src/kit/availability-tick.ts +66 -0
  105. package/src/kit/bitmap-label.ts +120 -0
  106. package/src/kit/boot-routing.ts +392 -0
  107. package/src/kit/breakpoint-state.ts +43 -0
  108. package/src/kit/build-identity.ts +16 -0
  109. package/src/kit/bytes-codec.ts +62 -0
  110. package/src/kit/cancellation-reason.ts +58 -0
  111. package/src/kit/canvas-frames.ts +88 -0
  112. package/src/kit/capture-camera-pose.ts +77 -0
  113. package/src/kit/capture-size.ts +88 -0
  114. package/src/kit/chrome-registry.ts +159 -0
  115. package/src/kit/chrome-slot-registry.ts +91 -0
  116. package/src/kit/collaboration-client.ts +264 -0
  117. package/src/kit/collaboration-presence.ts +41 -0
  118. package/src/kit/command-dispatch.ts +19 -0
  119. package/src/kit/command-listener.ts +2182 -0
  120. package/src/kit/command-registry.ts +71 -0
  121. package/src/kit/component-board-registry.ts +205 -0
  122. package/src/kit/component-states-registry.ts +199 -0
  123. package/src/kit/components/AlignToolbar.tsx +204 -0
  124. package/src/kit/components/ApplicationMenus.tsx +372 -0
  125. package/src/kit/components/AssetEditorShell.tsx +216 -0
  126. package/src/kit/components/AssetInspectorToolSection.tsx +124 -0
  127. package/src/kit/components/BoardRulers.tsx +354 -0
  128. package/src/kit/components/CanvasAddNodeDialogs.tsx +529 -0
  129. package/src/kit/components/CanvasSceneViewport.tsx +1195 -0
  130. package/src/kit/components/ChromeSlot.tsx +20 -0
  131. package/src/kit/components/CodeView.tsx +470 -0
  132. package/src/kit/components/CompactInspectorShell.tsx +39 -0
  133. package/src/kit/components/ConsolePanel.tsx +273 -0
  134. package/src/kit/components/GameplaySessionTimeline.tsx +295 -0
  135. package/src/kit/components/InspectionProjection.tsx +932 -0
  136. package/src/kit/components/Inspector.tsx +270 -0
  137. package/src/kit/components/InspectorCanvasPreview.tsx +35 -0
  138. package/src/kit/components/InspectorFieldsSection.tsx +290 -0
  139. package/src/kit/components/InspectorStoriesSection.tsx +92 -0
  140. package/src/kit/components/InspectorToolSection.tsx +96 -0
  141. package/src/kit/components/InspectorTransformSection.tsx +245 -0
  142. package/src/kit/components/LightExplorerPanel.tsx +433 -0
  143. package/src/kit/components/MediaProperties.tsx +145 -0
  144. package/src/kit/components/ProjectHeader.tsx +328 -0
  145. package/src/kit/components/ReactCanvasControls.tsx +358 -0
  146. package/src/kit/components/RootSelectionOverlay.tsx +3688 -0
  147. package/src/kit/components/RootTextEditor.tsx +79 -0
  148. package/src/kit/components/SaveStatus.tsx +70 -0
  149. package/src/kit/components/SurfaceCrashBoundary.tsx +105 -0
  150. package/src/kit/components/SurfaceStateOverlay.tsx +24 -0
  151. package/src/kit/components/ToolContributionSurfaces.tsx +49 -0
  152. package/src/kit/components/ToolHost.tsx +380 -0
  153. package/src/kit/components/Toolbar.tsx +811 -0
  154. package/src/kit/components/TransientHint.tsx +44 -0
  155. package/src/kit/components/VersionControlSection.tsx +470 -0
  156. package/src/kit/components/ViewportOverlaysMenu.tsx +177 -0
  157. package/src/kit/components/VolterLogo.tsx +18 -0
  158. package/src/kit/components/WorktreeSwitcher.tsx +712 -0
  159. package/src/kit/components/account-documents.tsx +1162 -0
  160. package/src/kit/components/asset-documents.tsx +794 -0
  161. package/src/kit/components/asset-editor-persistence.ts +216 -0
  162. package/src/kit/components/asset-selection-section.tsx +545 -0
  163. package/src/kit/components/asset-thumbnails.tsx +307 -0
  164. package/src/kit/components/asset-viewers/AudioViewer.tsx +201 -0
  165. package/src/kit/components/asset-viewers/GenericJsonViewer.tsx +102 -0
  166. package/src/kit/components/asset-viewers/ImageViewer.tsx +300 -0
  167. package/src/kit/components/asset-viewers/JsonAssetDocument.tsx +98 -0
  168. package/src/kit/components/asset-viewers/OnlineAssetDetail.tsx +426 -0
  169. package/src/kit/components/asset-viewers/SourceAssetViewer.tsx +356 -0
  170. package/src/kit/components/asset-viewers/SpritesheetSpriteView.tsx +102 -0
  171. package/src/kit/components/asset-viewers/VideoViewer.tsx +101 -0
  172. package/src/kit/components/asset-viewers/shader-source.ts +144 -0
  173. package/src/kit/components/board-guides.ts +150 -0
  174. package/src/kit/components/canvas-scene-hotkeys.ts +37 -0
  175. package/src/kit/components/canvas-temporary-pivot.ts +34 -0
  176. package/src/kit/components/core-utilities.tsx +94 -0
  177. package/src/kit/components/inspector-preview-section.tsx +223 -0
  178. package/src/kit/components/inspector-revert-label.ts +20 -0
  179. package/src/kit/components/inspector-selection.ts +42 -0
  180. package/src/kit/components/inspector-stories-gating.ts +171 -0
  181. package/src/kit/components/inspector-transform-subject.ts +11 -0
  182. package/src/kit/components/inspector-transform.ts +88 -0
  183. package/src/kit/components/kind-documents.tsx +544 -0
  184. package/src/kit/components/primitives/DraftColorInput.tsx +74 -0
  185. package/src/kit/components/project-tool-documents.tsx +402 -0
  186. package/src/kit/components/scene-documents.tsx +221 -0
  187. package/src/kit/components/status-contributions.tsx +407 -0
  188. package/src/kit/components/tool-documents.tsx +302 -0
  189. package/src/kit/components/tool-schema-form.tsx +262 -0
  190. package/src/kit/components/use-after-paint.ts +41 -0
  191. package/src/kit/components/use-project-image-assets.ts +86 -0
  192. package/src/kit/components/workspace-history.ts +32 -0
  193. package/src/kit/components/world-documents.tsx +570 -0
  194. package/src/kit/components/world-overlay-gestures.ts +1939 -0
  195. package/src/kit/composite-screenshot.ts +2238 -0
  196. package/src/kit/content-entry-source-registry.ts +184 -0
  197. package/src/kit/contribution-surfaces.ts +48 -0
  198. package/src/kit/coverage/canvas-reveal.ts +192 -0
  199. package/src/kit/coverage/design-time-surfaces.ts +101 -0
  200. package/src/kit/coverage/ontology-invariants.ts +466 -0
  201. package/src/kit/coverage/session-vitals.ts +503 -0
  202. package/src/kit/crash-null-boundary.ts +36 -0
  203. package/src/kit/creation-site-edit.ts +1491 -0
  204. package/src/kit/creation-site-registry.ts +160 -0
  205. package/src/kit/delegate-harness-registry.ts +134 -0
  206. package/src/kit/document-areas.ts +70 -0
  207. package/src/kit/document-context-registry.ts +193 -0
  208. package/src/kit/document-open-registry.ts +200 -0
  209. package/src/kit/document-play-extension.ts +221 -0
  210. package/src/kit/document-preview-source.ts +20 -0
  211. package/src/kit/document-renderer-session.ts +138 -0
  212. package/src/kit/document-stage-sessions.ts +26 -0
  213. package/src/kit/document-viewports.ts +120 -0
  214. package/src/kit/editor-api.ts +46 -0
  215. package/src/kit/editor-chrome-capture.ts +136 -0
  216. package/src/kit/editor-commands.ts +176 -0
  217. package/src/kit/editor-console.ts +580 -0
  218. package/src/kit/editor-current-view.ts +56 -0
  219. package/src/kit/editor-document-probe.ts +1166 -0
  220. package/src/kit/editor-git-client.ts +115 -0
  221. package/src/kit/editor-hotkeys.ts +728 -0
  222. package/src/kit/editor-lease-view.ts +39 -0
  223. package/src/kit/editor-lease.ts +415 -0
  224. package/src/kit/editor-mode.ts +19 -0
  225. package/src/kit/editor-notifications.ts +140 -0
  226. package/src/kit/editor-presence.ts +563 -0
  227. package/src/kit/editor-presentation-activity.ts +58 -0
  228. package/src/kit/editor-presentation-notice.ts +42 -0
  229. package/src/kit/editor-runtime.tsx +147 -0
  230. package/src/kit/editor-server-response.ts +86 -0
  231. package/src/kit/editor-session-attribution.ts +85 -0
  232. package/src/kit/editor-session-mode.ts +54 -0
  233. package/src/kit/editor-state-facets.ts +74 -0
  234. package/src/kit/editor-view-presentation.ts +777 -0
  235. package/src/kit/environment-images.ts +58 -0
  236. package/src/kit/eyedropper-session.ts +60 -0
  237. package/src/kit/files/file-provider.ts +62 -0
  238. package/src/kit/files/project-files.ts +270 -0
  239. package/src/kit/finders/index.ts +137 -0
  240. package/src/kit/finders/scenes-from-entrypoint-selection.ts +387 -0
  241. package/src/kit/frame/frame-parts.ts +30 -0
  242. package/src/kit/framed-document-capture.ts +34 -0
  243. package/src/kit/game-globals-prelude.ts +143 -0
  244. package/src/kit/game-surface-defaults.ts +33 -0
  245. package/src/kit/gameplay-dom-recording.ts +318 -0
  246. package/src/kit/gameplay-export-state.ts +14 -0
  247. package/src/kit/gameplay-replay.ts +417 -0
  248. package/src/kit/gameplay-session-time.ts +9 -0
  249. package/src/kit/gameplay-sessions.ts +204 -0
  250. package/src/kit/hierarchy-component-marks.ts +298 -0
  251. package/src/kit/hierarchy-internals.ts +197 -0
  252. package/src/kit/hierarchy-kind-icon.ts +217 -0
  253. package/src/kit/hierarchy-menu-registry.ts +67 -0
  254. package/src/kit/hierarchy-node-rows.ts +307 -0
  255. package/src/kit/hierarchy-panel-view.ts +280 -0
  256. package/src/kit/hierarchy-projection.ts +76 -0
  257. package/src/kit/hierarchy-row-media.ts +45 -0
  258. package/src/kit/hierarchy-row-model.ts +308 -0
  259. package/src/kit/hierarchy-rows.ts +11 -0
  260. package/src/kit/hierarchy-walk.ts +86 -0
  261. package/src/kit/history/editor-session.ts +25 -0
  262. package/src/kit/history/history-commands.ts +147 -0
  263. package/src/kit/history/history-delegate.ts +187 -0
  264. package/src/kit/history/history-limit-notices.ts +43 -0
  265. package/src/kit/history/history-service.ts +1189 -0
  266. package/src/kit/history/persistence-coordinator.ts +35 -0
  267. package/src/kit/history/project-file-history.ts +386 -0
  268. package/src/kit/history/project-root-history-backends.ts +139 -0
  269. package/src/kit/history/resource-registry.ts +209 -0
  270. package/src/kit/history/snapshot-store.ts +103 -0
  271. package/src/kit/history/source-history-backend.ts +546 -0
  272. package/src/kit/history-types.ts +124 -0
  273. package/src/kit/hmr-registration-group.ts +67 -0
  274. package/src/kit/hmr-stable-react-context.ts +23 -0
  275. package/src/kit/hotkeys.ts +190 -0
  276. package/src/kit/inference-diagnostics.ts +69 -0
  277. package/src/kit/initial-project.ts +80 -0
  278. package/src/kit/inspection/active-subject.ts +571 -0
  279. package/src/kit/inspection/active-surface.ts +142 -0
  280. package/src/kit/inspection/compose-subject.ts +1055 -0
  281. package/src/kit/inspection/compose.ts +7 -0
  282. package/src/kit/inspection/display.ts +171 -0
  283. package/src/kit/inspection/document-subject.ts +109 -0
  284. package/src/kit/inspection/game-subject.ts +85 -0
  285. package/src/kit/inspection/null-subject.ts +119 -0
  286. package/src/kit/inspection/serialize.ts +357 -0
  287. package/src/kit/inspection/use-active-inspection.ts +180 -0
  288. package/src/kit/inspection-model.ts +542 -0
  289. package/src/kit/inspection-node-media.ts +58 -0
  290. package/src/kit/inspector-presentation.ts +203 -0
  291. package/src/kit/inspector-property-grouping.ts +64 -0
  292. package/src/kit/inspector-section-registry.ts +221 -0
  293. package/src/kit/instance-source-actions.ts +163 -0
  294. package/src/kit/js-heap.ts +71 -0
  295. package/src/kit/key-actions.ts +91 -0
  296. package/src/kit/keymap-presets.ts +428 -0
  297. package/src/kit/layout-policy.ts +31 -0
  298. package/src/kit/light-explorer-model.ts +134 -0
  299. package/src/kit/live-canvas-frame.ts +55 -0
  300. package/src/kit/live-document.ts +296 -0
  301. package/src/kit/live-gesture-lock.ts +50 -0
  302. package/src/kit/live-seam-evidence.ts +11 -0
  303. package/src/kit/live-session-registry.ts +220 -0
  304. package/src/kit/live-transition.ts +391 -0
  305. package/src/kit/manifest-project.ts +107 -0
  306. package/src/kit/module-fetch-diagnosis.ts +192 -0
  307. package/src/kit/mount-failure-report.ts +154 -0
  308. package/src/kit/native-selection-style.ts +497 -0
  309. package/src/kit/object3d-document-write-policy.ts +137 -0
  310. package/src/kit/packaged-runtime.ts +108 -0
  311. package/src/kit/palettes/maya.palette.json +57 -0
  312. package/src/kit/palettes/substance.palette.json +57 -0
  313. package/src/kit/performance-profiler.ts +367 -0
  314. package/src/kit/performance-sources.ts +69 -0
  315. package/src/kit/photograph-notice.ts +141 -0
  316. package/src/kit/play-boot-phase.ts +166 -0
  317. package/src/kit/play-camera-flight.ts +35 -0
  318. package/src/kit/png-encode.worker.ts +26 -0
  319. package/src/kit/presentation-surface.ts +248 -0
  320. package/src/kit/product-command.ts +90 -0
  321. package/src/kit/project-adapter.ts +1140 -0
  322. package/src/kit/project-asset-refresh.ts +23 -0
  323. package/src/kit/project-asset-roots.ts +68 -0
  324. package/src/kit/project-local-state.ts +151 -0
  325. package/src/kit/project-manager.ts +243 -0
  326. package/src/kit/project-module-changes.ts +201 -0
  327. package/src/kit/project-module-split.ts +270 -0
  328. package/src/kit/project-play-layers.ts +25 -0
  329. package/src/kit/project-provenance.ts +115 -0
  330. package/src/kit/project-ready.ts +42 -0
  331. package/src/kit/project-shape.ts +68 -0
  332. package/src/kit/project-tools.ts +107 -0
  333. package/src/kit/projection-types.ts +44 -0
  334. package/src/kit/readiness.ts +113 -0
  335. package/src/kit/renderer-resource-counts.ts +27 -0
  336. package/src/kit/reported-play-state.ts +90 -0
  337. package/src/kit/resolve-contributed-command.ts +14 -0
  338. package/src/kit/resolve-relative-specifier.ts +33 -0
  339. package/src/kit/retained-document-states.ts +91 -0
  340. package/src/kit/scene-document-plan.ts +320 -0
  341. package/src/kit/scene-live-open.ts +210 -0
  342. package/src/kit/scoped-game-css.ts +152 -0
  343. package/src/kit/served-url.ts +5 -0
  344. package/src/kit/session-close.ts +17 -0
  345. package/src/kit/session-tombstone.ts +127 -0
  346. package/src/kit/settings/settings-provider.ts +82 -0
  347. package/src/kit/settings-store.ts +348 -0
  348. package/src/kit/shell-document-state.ts +27 -0
  349. package/src/kit/shell-store-door.ts +45 -0
  350. package/src/kit/shell-store.ts +722 -0
  351. package/src/kit/source-conflict.ts +122 -0
  352. package/src/kit/stage-context.ts +377 -0
  353. package/src/kit/stage-invalidation.ts +25 -0
  354. package/src/kit/stage-store-registry.ts +69 -0
  355. package/src/kit/startup-failure.ts +80 -0
  356. package/src/kit/state-report-deferral.ts +73 -0
  357. package/src/kit/storage/host-files-storage.ts +97 -0
  358. package/src/kit/storage/http-storage.ts +174 -0
  359. package/src/kit/storage/index.ts +75 -0
  360. package/src/kit/storage/mem-storage.ts +158 -0
  361. package/src/kit/storage/path-lock.ts +44 -0
  362. package/src/kit/storage/paths.ts +26 -0
  363. package/src/kit/storage-types.ts +127 -0
  364. package/src/kit/stories/StoryPreviewMount.tsx +306 -0
  365. package/src/kit/stories/compose-project-stories.ts +255 -0
  366. package/src/kit/stories/prefabs-finder.ts +54 -0
  367. package/src/kit/stories/prefabs-from-stories.ts +182 -0
  368. package/src/kit/stories/project-story-regions.ts +24 -0
  369. package/src/kit/stories/story-capture.ts +579 -0
  370. package/src/kit/stories/story-declared-medium.ts +126 -0
  371. package/src/kit/stories/story-discovery.ts +176 -0
  372. package/src/kit/stories/story-dom-runtime.ts +78 -0
  373. package/src/kit/stories/story-grouping.ts +111 -0
  374. package/src/kit/stories/story-mount-turn.ts +27 -0
  375. package/src/kit/stories/story-presentation.ts +215 -0
  376. package/src/kit/stories/story-preview-component.ts +7 -0
  377. package/src/kit/stories/story-registry.ts +530 -0
  378. package/src/kit/stories-scope.ts +35 -0
  379. package/src/kit/story-document-openers.ts +36 -0
  380. package/src/kit/story-thumbnails.ts +47 -0
  381. package/src/kit/surface-keyboard.ts +101 -0
  382. package/src/kit/surface-state.ts +135 -0
  383. package/src/kit/system-seam-evidence.ts +72 -0
  384. package/src/kit/tab-census.ts +202 -0
  385. package/src/kit/tab-lifecycle-client.ts +227 -0
  386. package/src/kit/theme-library.ts +897 -0
  387. package/src/kit/theme-preference.ts +429 -0
  388. package/src/kit/three-viewport-presentation.ts +23 -0
  389. package/src/kit/tool-contribution-play.ts +74 -0
  390. package/src/kit/tool-loader.ts +1918 -0
  391. package/src/kit/transform-mode-request.ts +66 -0
  392. package/src/kit/transient-hint.ts +78 -0
  393. package/src/kit/transport-strip.tsx +174 -0
  394. package/src/kit/ui-source/adapter-region-includes.ts +238 -0
  395. package/src/kit/ui-source/file-region-resolver.ts +302 -0
  396. package/src/kit/ui-source/inspect.ts +775 -0
  397. package/src/kit/ui-source/source-write-backend.ts +605 -0
  398. package/src/kit/ui-source/tier-source-write-backend.ts +279 -0
  399. package/src/kit/user-local-state.ts +105 -0
  400. package/src/kit/viewport-activation-timings.ts +840 -0
  401. package/src/kit/viewport-editor-controls.ts +22 -0
  402. package/src/kit/viewport-presentation.ts +668 -0
  403. package/src/kit/viewport-surface-status.tsx +55 -0
  404. package/src/kit/wait-until.ts +37 -0
  405. package/src/kit/worker-call-metrics.ts +166 -0
  406. package/src/kit/workspace-areas.ts +191 -0
  407. package/src/kit/workspace-aux-commands.ts +11 -0
  408. package/src/kit/workspace-available-documents.ts +142 -0
  409. package/src/kit/workspace-core-utilities.ts +31 -0
  410. package/src/kit/workspace-document-ids.ts +59 -0
  411. package/src/kit/workspace-document-registry.ts +624 -0
  412. package/src/kit/workspace-document-restore.ts +146 -0
  413. package/src/kit/workspace-host-commands.ts +141 -0
  414. package/src/kit/workspace-persistence-gate.ts +40 -0
  415. package/src/kit/workspace-play-utilities.ts +44 -0
  416. package/src/kit/workspace-presets.ts +446 -0
  417. package/src/kit/workspace-regions.ts +276 -0
  418. package/src/kit/workspace-static-panels.ts +73 -0
  419. package/src/kit/workspace-status-registry.ts +121 -0
  420. package/src/kit/workspace-storage.ts +35 -0
  421. package/src/kit/workspace-style.ts +226 -0
  422. package/src/kit/workspace-utility-commands.ts +74 -0
  423. package/src/kit/workspace-utility-registry.ts +263 -0
  424. package/src/kit/world-adoption-event.ts +23 -0
  425. package/src/kit/world-adoption.ts +115 -0
  426. package/src/kit/world-canvas-viewport-state.ts +35 -0
  427. package/src/kit/world-document-routing.ts +104 -0
  428. package/src/kit/world-pan-state.ts +198 -0
  429. package/src/kit/write-pipe.ts +173 -0
  430. package/src/layout-arrangements.ts +5 -0
  431. package/src/layouts.tsx +108 -0
  432. package/src/looks.ts +16 -0
  433. package/src/project/output-roots.ts +73 -0
  434. package/src/project/tab-census.ts +155 -0
  435. package/src/project-tool-catalog.ts +104 -0
  436. package/src/selection.tsx +107 -0
  437. package/src/services.ts +18 -0
  438. package/src/session/build-report.ts +22 -0
  439. package/src/session/collaboration-types.ts +262 -0
  440. package/src/session/command-table.ts +327 -0
  441. package/src/session/discovery.ts +100 -0
  442. package/src/session/editor-brand.ts +48 -0
  443. package/src/session/editor-compatibility.ts +317 -0
  444. package/src/session/editor-control-lifecycle.ts +68 -0
  445. package/src/session/editor-control-protocol.ts +5 -0
  446. package/src/session/entrypoint-selection-readers.ts +66 -0
  447. package/src/session/entrypoint-selection-source.ts +120 -0
  448. package/src/session/game-css-scope.ts +30 -0
  449. package/src/session/hosted-attachment.ts +225 -0
  450. package/src/session/limited-view.ts +82 -0
  451. package/src/session/product-create.ts +24 -0
  452. package/src/session/product-locator.ts +478 -0
  453. package/src/session/project-module-url.ts +242 -0
  454. package/src/session/project-serving.ts +164 -0
  455. package/src/session/project-upgrade.ts +403 -0
  456. package/src/session/registry-format.ts +210 -0
  457. package/src/session/relative-path-guard.ts +56 -0
  458. package/src/session/scoped-game-css.ts +461 -0
  459. package/src/session/source-glob.ts +15 -0
  460. package/src/session/tool-contribution-convention.ts +123 -0
  461. package/src/session/workbench-locator.ts +712 -0
  462. package/src/session.ts +41 -0
  463. package/src/share.ts +160 -0
  464. package/src/source-analysis.ts +28 -0
  465. package/src/source-authoring.ts +439 -0
  466. package/src/tools/errors.ts +91 -0
  467. package/src/tools/provider-execution.ts +70 -0
  468. package/src/tools/registry.ts +341 -0
  469. package/src/tools/types.ts +159 -0
  470. package/src/transport.ts +100 -0
  471. package/src/types.ts +1693 -0
  472. package/src/views.ts +164 -0
  473. package/src/widgets/design-system.ts +93 -0
  474. package/src/widgets/editor-appearance.ts +151 -0
  475. package/src/widgets/editor-material.ts +83 -0
  476. package/src/widgets/icon-set-registry.ts +105 -0
  477. package/src/widgets/index.ts +71 -0
  478. package/src/widgets/inspector-widgets/AlignmentGrid.tsx +182 -0
  479. package/src/widgets/inspector-widgets/AssetSlotPicker.tsx +123 -0
  480. package/src/widgets/inspector-widgets/BorderEditor.tsx +309 -0
  481. package/src/widgets/inspector-widgets/ColorPicker.tsx +549 -0
  482. package/src/widgets/inspector-widgets/CurveEditor.tsx +359 -0
  483. package/src/widgets/inspector-widgets/FilterEditor.tsx +108 -0
  484. package/src/widgets/inspector-widgets/FontPicker.tsx +191 -0
  485. package/src/widgets/inspector-widgets/GradientEditor.tsx +623 -0
  486. package/src/widgets/inspector-widgets/ScrubbableInput.tsx +180 -0
  487. package/src/widgets/inspector-widgets/ShadowEditor.tsx +319 -0
  488. package/src/widgets/inspector-widgets/color-utils.ts +201 -0
  489. package/src/widgets/inspector-widgets/curve-utils.ts +212 -0
  490. package/src/widgets/inspector-widgets/index.ts +25 -0
  491. package/src/widgets/inspector-widgets/shared.tsx +140 -0
  492. package/src/widgets/interactive-edit-scope.ts +33 -0
  493. package/src/widgets/patterns/Dialog.tsx +140 -0
  494. package/src/widgets/patterns/Fields.tsx +44 -0
  495. package/src/widgets/patterns/List.tsx +25 -0
  496. package/src/widgets/patterns/StateSurface.tsx +40 -0
  497. package/src/widgets/patterns/Surfaces.tsx +122 -0
  498. package/src/widgets/patterns/Tabs.tsx +80 -0
  499. package/src/widgets/patterns/Toolbar.tsx +72 -0
  500. package/src/widgets/patterns/Tree.tsx +72 -0
  501. package/src/widgets/primitives/AnchoredMenu.tsx +260 -0
  502. package/src/widgets/primitives/Button.tsx +62 -0
  503. package/src/widgets/primitives/ColorInput.tsx +78 -0
  504. package/src/widgets/primitives/DraftTextInput.tsx +63 -0
  505. package/src/widgets/primitives/EditorIcon.tsx +157 -0
  506. package/src/widgets/primitives/FormControls.tsx +88 -0
  507. package/src/widgets/primitives/HoverPreview.tsx +96 -0
  508. package/src/widgets/primitives/JsonInput.tsx +113 -0
  509. package/src/widgets/primitives/Layout.tsx +100 -0
  510. package/src/widgets/primitives/Menu.tsx +161 -0
  511. package/src/widgets/primitives/NumberInput.tsx +169 -0
  512. package/src/widgets/primitives/Panel.tsx +80 -0
  513. package/src/widgets/primitives/SectionHeader.tsx +77 -0
  514. package/src/widgets/primitives/Text.tsx +54 -0
  515. package/src/widgets/primitives/ThemeRootPortal.tsx +52 -0
  516. package/src/widgets/primitives/Tooltip.tsx +204 -0
  517. package/src/widgets/primitives/Vec3Input.tsx +70 -0
  518. package/src/widgets/primitives/banner-tones.ts +32 -0
  519. package/src/widgets/primitives/clamp-to-viewport.ts +44 -0
  520. package/src/widgets/primitives/editor-icons.ts +254 -0
  521. package/src/widgets/primitives/panel-header-styles.ts +42 -0
  522. package/src/widgets/theme.ts +2841 -0
  523. package/src/widgets/z-index.ts +25 -0
@@ -0,0 +1,1939 @@
1
+ /**
2
+ * world-overlay-gestures — spec 27 §4 B2/B3 pure gesture math, factored out of
3
+ * `RootSelectionOverlay.tsx` so the handle/spacing-band geometry and the
4
+ * per-gesture patch math are directly unit-testable without mounting React
5
+ * (same "exported pure fn" convention `RootSelectionOverlay.tsx` already uses
6
+ * for `resolveClickSelection`/`resolveEmptySpaceSelection`).
7
+ *
8
+ * DRIFT FLAG (per this task's executor brief): the spec cites an external
9
+ * `visual-edit/overlay.tsx` (`RESIZE_HANDLES`, rotate cursors, `spacingZone`)
10
+ * that does not exist in this repo. The handle table and the padding/margin
11
+ * band geometry below are derived FRESH from the spec text (§4 B2/B3, §5's
12
+ * "lose nothing" rows :459-467) plus the in-repo SE-handle precedent
13
+ * `ui-editor/overlay.tsx` (`GRID = 8` snap, `Handle` ~10×10 white/accent box,
14
+ * `startResize`, the dashed gap-measure label pattern) — noted again in this
15
+ * repo's PR message per the brief's instruction.
16
+ *
17
+ * Rule zero (spec §0): pure geometry/math only — no store reach-in, no
18
+ * `THREE.`, no `elementFromPoint`. The two adapter-routing helpers
19
+ * (`boxEditForId`/`structureCapableForId`) talk ONLY to the `AuthoringAdapter`
20
+ * contract, cloning `RootSelectionOverlay.tsx`'s own `rectForId` composite→
21
+ * owner-child routing pattern (`rects`/`boxEdit`/`structure` are deliberately
22
+ * NOT merged onto `CompositeAuthoringAdapter` — only `inspector`/`hierarchy`
23
+ * are).
24
+ */
25
+ import type {
26
+ AuthoringAdapter,
27
+ BoxEditProvider,
28
+ ColorSampleProvider,
29
+ DOMRectLike,
30
+ FrameCorners,
31
+ SpatialHandlesProvider,
32
+ StructureProvider,
33
+ TextProvider,
34
+ } from '@volter/project/adapter';
35
+ import { CompositeAuthoringAdapter } from '@volter/sdk/kit/authoring/composite-authoring-adapter';
36
+ import { spatialHandlesForAdapter } from '@volter/sdk/kit/authoring/consumer-actions';
37
+ import { numericStyleValue } from '@volter/sdk/css-numeric-style';
38
+ import { isRootHidden } from '@volter/sdk/kit/authoring/world-session-state';
39
+ import { recordAuthoringConsumerUse } from '@volter/sdk/kit/authoring-seam-evidence';
40
+
41
+ /** 8px snap grid — same constant `ui-editor/overlay.tsx`'s `GRID` uses (K3). */
42
+ export const GRID = 8;
43
+
44
+ /** Minimum resized dimension (px) — mirrors `ui-editor/overlay.tsx`'s own
45
+ * `Math.max(8, snap(...))` resize clamp. */
46
+ export const MIN_SIZE = 8;
47
+
48
+ export type HandlePos = 'nw' | 'n' | 'ne' | 'w' | 'e' | 'sw' | 's' | 'se';
49
+
50
+ /** The 8 resize handles (spec:317, §5 :465 "8 resize handles"), each with the
51
+ * cursor a figma-grade editor uses for that axis: corner handles get the
52
+ * diagonal (nwse/nesw) cursor, edge handles the straight (ns/ew) cursor. */
53
+ export const RESIZE_HANDLES: ReadonlyArray<{ pos: HandlePos; cursor: string }> = [
54
+ { pos: 'nw', cursor: 'nwse-resize' },
55
+ { pos: 'n', cursor: 'ns-resize' },
56
+ { pos: 'ne', cursor: 'nesw-resize' },
57
+ { pos: 'w', cursor: 'ew-resize' },
58
+ { pos: 'e', cursor: 'ew-resize' },
59
+ { pos: 'sw', cursor: 'nesw-resize' },
60
+ { pos: 's', cursor: 'ns-resize' },
61
+ { pos: 'se', cursor: 'nwse-resize' },
62
+ ];
63
+
64
+ export interface RectLike {
65
+ x: number;
66
+ y: number;
67
+ width: number;
68
+ height: number;
69
+ }
70
+
71
+ /** Where a handle sits on `rect` — the handle's CENTER point (the caller
72
+ * offsets by half the handle's rendered size to place its box). */
73
+ export function handlePosition(rect: RectLike, pos: HandlePos): { x: number; y: number } {
74
+ const midX = rect.x + rect.width / 2;
75
+ const midY = rect.y + rect.height / 2;
76
+ switch (pos) {
77
+ case 'nw':
78
+ return { x: rect.x, y: rect.y };
79
+ case 'n':
80
+ return { x: midX, y: rect.y };
81
+ case 'ne':
82
+ return { x: rect.x + rect.width, y: rect.y };
83
+ case 'w':
84
+ return { x: rect.x, y: midY };
85
+ case 'e':
86
+ return { x: rect.x + rect.width, y: midY };
87
+ case 'sw':
88
+ return { x: rect.x, y: rect.y + rect.height };
89
+ case 's':
90
+ return { x: midX, y: rect.y + rect.height };
91
+ case 'se':
92
+ return { x: rect.x + rect.width, y: rect.y + rect.height };
93
+ }
94
+ }
95
+
96
+ /** `v => e.altKey ? Math.round(v) : Math.round(v/GRID)*GRID` (spec:319 "8px
97
+ * grid snap, Alt = free") — shared by resize/move gesture math below. */
98
+ export function snapValue(v: number, free: boolean): number {
99
+ return free ? Math.round(v) : Math.round(v / GRID) * GRID;
100
+ }
101
+
102
+ /**
103
+ * Per-handle resize patch math (spec:321-322): east-side handles grow width
104
+ * by `+dx`; west-side handles shrink width by `dx` AND (only for an
105
+ * absolutely/fixed-positioned node) move `x` by `dx` too (there is no `left`
106
+ * to adjust on a static/relative node — non-absolute nodes resize SIZE only,
107
+ * spec:322). South/north are the `height`/`y` analogue on the vertical axis.
108
+ * A corner handle (e.g. `se`) touches both axes at once. Width/height are
109
+ * clamped to {@link MIN_SIZE}.
110
+ *
111
+ * D1 (spec §6) — `context`, when supplied and `free` is false, additionally
112
+ * edge-snaps the DRAGGED edge (only — the anchored opposite edge never moves,
113
+ * unlike the move gesture where either edge may align) to the owner's own
114
+ * `contextRects(id)` padding-box/sibling edges, within `threshold`, taking
115
+ * priority over the plain 8px grid snap already applied above — same
116
+ * "edge-align wins over grid" precedence {@link computeMovePatch} uses. The
117
+ * VISUAL guide this same decision drives is the separate, pure
118
+ * {@link computeResizeSnapGuides} (this function's own return shape — a
119
+ * style-prop patch — stays exactly what the 8 pre-D1 tests above expect).
120
+ */
121
+ /** EAST-handle width math (grid-snap, then optional edge-snap of the moving
122
+ * RIGHT edge) — factored out of {@link computeResizePatch} purely to keep
123
+ * ITS cognitive complexity down; behavior unchanged from the pre-D1 inline
124
+ * version. Also returns the matched snap candidate (`null` if none), so
125
+ * {@link computeResizeSnapGuides} can reuse this SAME computation for its
126
+ * guide geometry instead of re-deriving it (no drift between the patch the
127
+ * gesture commits and the guide it shows while committing it). */
128
+ function resizeEastWidth(
129
+ dx: number,
130
+ orig: RectLike,
131
+ free: boolean,
132
+ xCandidates: readonly SnapEdgeCandidate[],
133
+ threshold: number,
134
+ ): { value: number; match: SnapEdgeCandidate | null } {
135
+ let width = Math.max(MIN_SIZE, snapValue(orig.width + dx, free));
136
+ let match: SnapEdgeCandidate | null = null;
137
+ if (xCandidates.length) {
138
+ match = edgeSnapPoint(orig.x + width, xCandidates, threshold);
139
+ if (match) width = Math.max(MIN_SIZE, match.edge - orig.x);
140
+ }
141
+ return { value: width, match };
142
+ }
143
+
144
+ /** WEST-handle width math: the WEST handle drags the left edge while the
145
+ * EAST edge stays anchored. Snap+clamp the width first, then derive x FROM
146
+ * the clamped width so the east edge is pinned: `x = eastEdge - width`.
147
+ * Deriving x independently (`orig.x + dx`) breaks under the MIN clamp —
148
+ * D3: over-dragging past the east edge pinned width at MIN but let x keep
149
+ * following the cursor, so the anchored east edge teleported and the
150
+ * resize became a move. This formula also preserves the
151
+ * `x + width == eastEdge` invariant for a non-multiple-of-8 drag, which
152
+ * the old independent per-axis snap could violate. Factored out of
153
+ * {@link computeResizePatch} for the same complexity/reuse reasons as
154
+ * {@link resizeEastWidth}. */
155
+ function resizeWestWidth(
156
+ dx: number,
157
+ orig: RectLike,
158
+ free: boolean,
159
+ xCandidates: readonly SnapEdgeCandidate[],
160
+ threshold: number,
161
+ ): { value: number; eastEdge: number; match: SnapEdgeCandidate | null } {
162
+ const eastEdge = orig.x + orig.width;
163
+ let width = Math.max(MIN_SIZE, snapValue(orig.width - dx, free));
164
+ let match: SnapEdgeCandidate | null = null;
165
+ if (xCandidates.length) {
166
+ match = edgeSnapPoint(eastEdge - width, xCandidates, threshold);
167
+ if (match) width = Math.max(MIN_SIZE, eastEdge - match.edge);
168
+ }
169
+ return { value: width, eastEdge, match };
170
+ }
171
+
172
+ /** SOUTH-handle height math — the vertical analogue of
173
+ * {@link resizeEastWidth}. */
174
+ function resizeSouthHeight(
175
+ dy: number,
176
+ orig: RectLike,
177
+ free: boolean,
178
+ yCandidates: readonly SnapEdgeCandidate[],
179
+ threshold: number,
180
+ ): { value: number; match: SnapEdgeCandidate | null } {
181
+ let height = Math.max(MIN_SIZE, snapValue(orig.height + dy, free));
182
+ let match: SnapEdgeCandidate | null = null;
183
+ if (yCandidates.length) {
184
+ match = edgeSnapPoint(orig.y + height, yCandidates, threshold);
185
+ if (match) height = Math.max(MIN_SIZE, match.edge - orig.y);
186
+ }
187
+ return { value: height, match };
188
+ }
189
+
190
+ /** NORTH-handle height math — the vertical analogue of
191
+ * {@link resizeWestWidth} (the SOUTH edge is the anchor). */
192
+ function resizeNorthHeight(
193
+ dy: number,
194
+ orig: RectLike,
195
+ free: boolean,
196
+ yCandidates: readonly SnapEdgeCandidate[],
197
+ threshold: number,
198
+ ): { value: number; southEdge: number; match: SnapEdgeCandidate | null } {
199
+ const southEdge = orig.y + orig.height;
200
+ let height = Math.max(MIN_SIZE, snapValue(orig.height - dy, free));
201
+ let match: SnapEdgeCandidate | null = null;
202
+ if (yCandidates.length) {
203
+ match = edgeSnapPoint(southEdge - height, yCandidates, threshold);
204
+ if (match) height = Math.max(MIN_SIZE, southEdge - match.edge);
205
+ }
206
+ return { value: height, southEdge, match };
207
+ }
208
+
209
+ /** The x-axis half of {@link computeResizePatch} — `width` (+`x` if
210
+ * `isPositioned` and the WEST handle moved) for whichever of `pos`'s x-side
211
+ * (east/west/neither) applies. Factored out purely to keep
212
+ * `computeResizePatch` itself under the complexity ceiling. */
213
+ function resizeXPatch(
214
+ pos: HandlePos,
215
+ dx: number,
216
+ orig: RectLike,
217
+ isPositioned: boolean,
218
+ free: boolean,
219
+ xCandidates: readonly SnapEdgeCandidate[],
220
+ threshold: number,
221
+ ): Record<string, number> {
222
+ if (pos === 'e' || pos === 'ne' || pos === 'se') {
223
+ return { width: resizeEastWidth(dx, orig, free, xCandidates, threshold).value };
224
+ }
225
+ if (pos === 'w' || pos === 'nw' || pos === 'sw') {
226
+ const { value: width, eastEdge } = resizeWestWidth(dx, orig, free, xCandidates, threshold);
227
+ return isPositioned ? { width, x: eastEdge - width } : { width };
228
+ }
229
+ return {};
230
+ }
231
+
232
+ /** The y-axis analogue of {@link resizeXPatch} (south/north). */
233
+ function resizeYPatch(
234
+ pos: HandlePos,
235
+ dy: number,
236
+ orig: RectLike,
237
+ isPositioned: boolean,
238
+ free: boolean,
239
+ yCandidates: readonly SnapEdgeCandidate[],
240
+ threshold: number,
241
+ ): Record<string, number> {
242
+ if (pos === 's' || pos === 'sw' || pos === 'se') {
243
+ return { height: resizeSouthHeight(dy, orig, free, yCandidates, threshold).value };
244
+ }
245
+ if (pos === 'n' || pos === 'nw' || pos === 'ne') {
246
+ const { value: height, southEdge } = resizeNorthHeight(dy, orig, free, yCandidates, threshold);
247
+ return isPositioned ? { height, y: southEdge - height } : { height };
248
+ }
249
+ return {};
250
+ }
251
+
252
+ export function computeResizePatch(
253
+ pos: HandlePos,
254
+ dx: number,
255
+ dy: number,
256
+ orig: RectLike,
257
+ isPositioned: boolean,
258
+ free: boolean,
259
+ context?: MoveSnapContext,
260
+ threshold = EDGE_SNAP_THRESHOLD_PX,
261
+ ): Record<string, number> {
262
+ const targets = !free && context ? snapTargetsForContext(context) : null;
263
+ const xCandidates = targets ? xEdgeCandidates(targets) : [];
264
+ const yCandidates = targets ? yEdgeCandidates(targets) : [];
265
+ return {
266
+ ...resizeXPatch(pos, dx, orig, isPositioned, free, xCandidates, threshold),
267
+ ...resizeYPatch(pos, dy, orig, isPositioned, free, yCandidates, threshold),
268
+ };
269
+ }
270
+
271
+ /** Snap-target context for a move gesture — the owner's own
272
+ * `rects.contextRects(id)` shape (padding box + sibling rects), reduced to
273
+ * just the two fields the edge-snap below consumes. Reused, unchanged, by
274
+ * the D1 resize-snap path below (same shape, same source — a single
275
+ * `contextRectsForId` call at gesture start covers both). */
276
+ export interface MoveSnapContext {
277
+ paddingBox?: DOMRectLike;
278
+ siblings?: readonly DOMRectLike[];
279
+ /** D1 (spec §6) — the immediate parent's own rect, unused by the
280
+ * edge-snap math above but carried through so a single
281
+ * `contextRectsForId(adapter, id)` call also feeds the D1.c
282
+ * parent-container outline (`RootSelectionOverlay.tsx`), without a
283
+ * narrower return type silently dropping the field the underlying
284
+ * `RectProvider.contextRects` already returns. */
285
+ parent?: DOMRectLike;
286
+ /** Persistent BOARD GUIDE edges in HOST-RELATIVE coordinates (the owner's
287
+ * `contextRects` converts them — see `board-guides.ts`). Merged into the
288
+ * same x/y edge-candidate pools the padding box and siblings feed, so
289
+ * move/resize snapping and the D1 guide visuals engage on a user-placed
290
+ * guide exactly the way they do on a sibling edge. */
291
+ guideEdges?: { x?: readonly number[]; y?: readonly number[] };
292
+ }
293
+
294
+ /** ±4px — same threshold both the B2 move/resize edge-snap MATH and the D1
295
+ * snap-guide VISIBILITY below use, so a guide is shown if and only if the
296
+ * gesture it describes actually engaged (spec:321 "within ±4px prefer
297
+ * edge-alignment ... over the grid"; spec §6 D1 "snapping engages within
298
+ * threshold"). */
299
+ export const EDGE_SNAP_THRESHOLD_PX = 4;
300
+
301
+ /** One candidate target edge for edge-snapping: its coordinate (x for a
302
+ * vertical/left-right edge, y for a horizontal/top-bottom edge) paired with
303
+ * the SOURCE rect it came from (the parent padding box or a sibling) — the
304
+ * source rect is what a D1 snap guide spans across (see
305
+ * {@link computeMoveSnapGuides}/{@link computeResizeSnapGuides}). */
306
+ interface SnapEdgeCandidate {
307
+ edge: number;
308
+ rect: RectLike;
309
+ }
310
+
311
+ /** `context`'s padding-box + sibling rects (zero-area siblings dropped) — the
312
+ * shared first step both the x/y candidate builders below start from.
313
+ *
314
+ * It used to call `ui-source/inspect.ts`'s `computeSnapTargets`, which insets
315
+ * a parent rect by a PADDING box and then filters. This call site has always
316
+ * passed zero padding — `context.paddingBox` is already the inset rect the
317
+ * adapter measured — so the inset half was dead here, and the one live half
318
+ * is the filter below. That single import was also the host's overlay bus
319
+ * reaching the DOM inspection estate, carrying `inspect.ts` and the inference
320
+ * diagnostics it reads into every editor boot (measured 2026-09-18, phase 1
321
+ * unit 9 of the open-source launch: two files). */
322
+ function snapTargetsForContext(context: MoveSnapContext): {
323
+ paddingBox: RectLike;
324
+ siblingRects: RectLike[];
325
+ guideX: readonly number[];
326
+ guideY: readonly number[];
327
+ } {
328
+ return {
329
+ paddingBox: context.paddingBox ?? { x: 0, y: 0, width: 0, height: 0 },
330
+ siblingRects: (context.siblings ?? []).filter((r) => r.width > 0 && r.height > 0),
331
+ guideX: context.guideEdges?.x ?? [],
332
+ guideY: context.guideEdges?.y ?? [],
333
+ };
334
+ }
335
+
336
+ /** The x-axis (left/right) snap candidates — the parent padding box's own
337
+ * left/right edges plus every sibling's left/right edges. */
338
+ function xEdgeCandidates(targets: {
339
+ paddingBox: RectLike;
340
+ siblingRects: RectLike[];
341
+ guideX?: readonly number[];
342
+ }): SnapEdgeCandidate[] {
343
+ const out: SnapEdgeCandidate[] = [
344
+ { edge: targets.paddingBox.x, rect: targets.paddingBox },
345
+ { edge: targets.paddingBox.x + targets.paddingBox.width, rect: targets.paddingBox },
346
+ ];
347
+ for (const r of targets.siblingRects) {
348
+ out.push({ edge: r.x, rect: r });
349
+ out.push({ edge: r.x + r.width, rect: r });
350
+ }
351
+ // A board guide spans the whole board: give its candidate a tall thin rect
352
+ // so the D1 alignment guide drawn from the match visibly runs along it.
353
+ for (const edge of targets.guideX ?? []) {
354
+ out.push({ edge, rect: { x: edge, y: -100000, width: 0, height: 200000 } });
355
+ }
356
+ return out;
357
+ }
358
+
359
+ /** The y-axis (top/bottom) analogue of {@link xEdgeCandidates}. */
360
+ function yEdgeCandidates(targets: {
361
+ paddingBox: RectLike;
362
+ siblingRects: RectLike[];
363
+ guideY?: readonly number[];
364
+ }): SnapEdgeCandidate[] {
365
+ const out: SnapEdgeCandidate[] = [
366
+ { edge: targets.paddingBox.y, rect: targets.paddingBox },
367
+ { edge: targets.paddingBox.y + targets.paddingBox.height, rect: targets.paddingBox },
368
+ ];
369
+ for (const r of targets.siblingRects) {
370
+ out.push({ edge: r.y, rect: r });
371
+ out.push({ edge: r.y + r.height, rect: r });
372
+ }
373
+ for (const edge of targets.guideY ?? []) {
374
+ out.push({ edge, rect: { x: -100000, y: edge, width: 200000, height: 0 } });
375
+ }
376
+ return out;
377
+ }
378
+
379
+ /** Snap `value` (the candidate LEFT/TOP edge of a `size`-wide/tall box) to the
380
+ * nearest of `candidates` within `threshold` px, preferring alignment of
381
+ * either the box's leading OR trailing edge to a target edge (spec:321
382
+ * "within ±4px prefer edge-alignment ... over the grid") — the MOVE-gesture
383
+ * case, where either edge of the moving box may be the one that aligns.
384
+ * Returns `value` unchanged (and `match: null`) when nothing is within
385
+ * threshold. */
386
+ function edgeSnapBox(
387
+ value: number,
388
+ size: number,
389
+ candidates: readonly SnapEdgeCandidate[],
390
+ threshold: number,
391
+ ): { value: number; match: SnapEdgeCandidate | null } {
392
+ let best = value;
393
+ let bestDelta = threshold;
394
+ let match: SnapEdgeCandidate | null = null;
395
+ for (const c of candidates) {
396
+ const dLeading = Math.abs(value - c.edge);
397
+ if (dLeading <= bestDelta) {
398
+ bestDelta = dLeading;
399
+ best = c.edge;
400
+ match = c;
401
+ }
402
+ const dTrailing = Math.abs(value + size - c.edge);
403
+ if (dTrailing <= bestDelta) {
404
+ bestDelta = dTrailing;
405
+ best = c.edge - size;
406
+ match = c;
407
+ }
408
+ }
409
+ return { value: best, match };
410
+ }
411
+
412
+ /** Snap a SINGLE moving edge coordinate (e.g. a resize handle's dragged
413
+ * border) to the nearest of `candidates` within `threshold` — the RESIZE-
414
+ * gesture case, where only the one edge under the handle can align (the
415
+ * opposite edge is the anchor and never moves). `null` when nothing is
416
+ * within threshold. */
417
+ function edgeSnapPoint(
418
+ value: number,
419
+ candidates: readonly SnapEdgeCandidate[],
420
+ threshold: number,
421
+ ): SnapEdgeCandidate | null {
422
+ let best: SnapEdgeCandidate | null = null;
423
+ let bestDelta = threshold;
424
+ for (const c of candidates) {
425
+ const d = Math.abs(value - c.edge);
426
+ if (d <= bestDelta) {
427
+ bestDelta = d;
428
+ best = c;
429
+ }
430
+ }
431
+ return best;
432
+ }
433
+
434
+ /**
435
+ * Move-gesture patch (spec:322 "move a positioned element writes left/top"):
436
+ * grid-snaps `orig.x + dx`/`orig.y + dy` (Alt = free, matching resize), then —
437
+ * unless Alt is held — prefers ±4px edge-alignment to the owner's own
438
+ * `contextRects(id)` padding-box/sibling edges over the grid (spec:321's snap
439
+ * MATH, {@link snapTargetsForContext} fed by `rects.contextRects`). The
440
+ * VISUAL guide rendering this same snap decision drives is
441
+ * {@link computeMoveSnapGuides} (D1, spec §6) — a separate pure function
442
+ * (not this one's return shape) so this function's existing `{x,y}` contract
443
+ * stays byte-for-byte stable for every caller/test that predates D1.
444
+ */
445
+ export function computeMovePatch(
446
+ dx: number,
447
+ dy: number,
448
+ orig: RectLike,
449
+ free: boolean,
450
+ context?: MoveSnapContext,
451
+ ): { x: number; y: number } {
452
+ let x = snapValue(orig.x + dx, free);
453
+ let y = snapValue(orig.y + dy, free);
454
+ if (!free && context) {
455
+ const targets = snapTargetsForContext(context);
456
+ x = edgeSnapBox(x, orig.width, xEdgeCandidates(targets), EDGE_SNAP_THRESHOLD_PX).value;
457
+ y = edgeSnapBox(y, orig.height, yEdgeCandidates(targets), EDGE_SNAP_THRESHOLD_PX).value;
458
+ }
459
+ return { x, y };
460
+ }
461
+
462
+ // --- D1 — snap/alignment guides (spec §6 D1, §5 :461 "snap/alignment
463
+ // guides") ------------------------------------------------------------
464
+
465
+ /** A single active D1 alignment guide: a straight line at coordinate `at`
466
+ * along the snapped axis (an x-coordinate for a `'vertical'` guide, a
467
+ * y-coordinate for a `'horizontal'` one — the guide LINE's own orientation,
468
+ * matching {@link computeMeasureLines}'s `orientation` convention), spanning
469
+ * `start`..`end` on the PERPENDICULAR axis (the union of the moving/
470
+ * resizing element's own extent and the target rect's extent, so the guide
471
+ * visibly touches both). Rendered ONLY while the underlying gesture's own
472
+ * edge-snap actually engaged (spec:465 "guides appear only within the snap
473
+ * threshold; disappear when not snapping") — {@link computeMoveSnapGuides}/
474
+ * {@link computeResizeSnapGuides} return `[]` whenever nothing snapped. */
475
+ export interface SnapGuide {
476
+ orientation: 'vertical' | 'horizontal';
477
+ at: number;
478
+ start: number;
479
+ end: number;
480
+ }
481
+
482
+ export interface PointSnapResult {
483
+ guides: SnapGuide[];
484
+ position: { x: number; y: number };
485
+ }
486
+
487
+ /** Snap a native reference-point handle to the selected box and its layout
488
+ * context. Each axis offers leading/center/trailing targets; the selected
489
+ * box therefore gives useful corner/edge/center anchor positions while
490
+ * sibling/parent rects provide alignment guides. Alt keeps the exact point
491
+ * and suppresses every guide, matching move/resize gesture language. */
492
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one pure resolver keeps native-point and generic-axis snap precedence in a single deterministic pass
493
+ export function computePointSnap(
494
+ point: { x: number; y: number },
495
+ selectedRect: RectLike,
496
+ free: boolean,
497
+ context?: MoveSnapContext,
498
+ native?: {
499
+ bounded?: boolean | undefined;
500
+ snapPoints?: ReadonlyArray<{ x: number; y: number }> | undefined;
501
+ },
502
+ threshold = EDGE_SNAP_THRESHOLD_PX,
503
+ ): PointSnapResult {
504
+ if (free) return { guides: [], position: point };
505
+ let nativeMatch: { x: number; y: number } | null = null;
506
+ let nativeDistance = threshold;
507
+ for (const candidate of native?.snapPoints ?? []) {
508
+ const distance = Math.hypot(point.x - candidate.x, point.y - candidate.y);
509
+ if (distance <= nativeDistance) {
510
+ nativeDistance = distance;
511
+ nativeMatch = candidate;
512
+ }
513
+ }
514
+ if (nativeMatch) {
515
+ return {
516
+ guides: guidesFromMatches(
517
+ { x: nativeMatch.x, y: nativeMatch.y, width: 0, height: 0 },
518
+ { edge: nativeMatch.x, rect: selectedRect },
519
+ { edge: nativeMatch.y, rect: selectedRect },
520
+ ),
521
+ position: nativeMatch,
522
+ };
523
+ }
524
+ if (native?.bounded) return { guides: [], position: point };
525
+ const x = snapValue(point.x, false);
526
+ const y = snapValue(point.y, false);
527
+ const targets = [
528
+ selectedRect,
529
+ context?.parent,
530
+ context?.paddingBox,
531
+ ...(context?.siblings ?? []),
532
+ ].filter((rect): rect is RectLike => !!rect && rect.width > 0 && rect.height > 0);
533
+ let xMatch: SnapEdgeCandidate | null = null;
534
+ let yMatch: SnapEdgeCandidate | null = null;
535
+ let xDelta = threshold;
536
+ let yDelta = threshold;
537
+ for (const rect of targets) {
538
+ for (const edge of [rect.x, rect.x + rect.width / 2, rect.x + rect.width]) {
539
+ const delta = Math.abs(x - edge);
540
+ if (delta <= xDelta) {
541
+ xDelta = delta;
542
+ xMatch = { edge, rect };
543
+ }
544
+ }
545
+ for (const edge of [rect.y, rect.y + rect.height / 2, rect.y + rect.height]) {
546
+ const delta = Math.abs(y - edge);
547
+ if (delta <= yDelta) {
548
+ yDelta = delta;
549
+ yMatch = { edge, rect };
550
+ }
551
+ }
552
+ }
553
+ const position = { x: xMatch?.edge ?? x, y: yMatch?.edge ?? y };
554
+ return {
555
+ guides: guidesFromMatches(
556
+ { x: position.x, y: position.y, width: 0, height: 0 },
557
+ xMatch,
558
+ yMatch,
559
+ ),
560
+ position,
561
+ };
562
+ }
563
+
564
+ /** Build the 0, 1, or 2 {@link SnapGuide}s for a matched x/y edge-snap pair —
565
+ * shared tail of {@link computeMoveSnapGuides}/{@link computeResizeSnapGuides}
566
+ * (both resolve their own x/y matches differently — move via
567
+ * {@link edgeSnapBox}, resize via {@link edgeSnapPoint} on a single dragged
568
+ * edge — but converge on the same "matched candidate -> guide spanning the
569
+ * moved rect ∪ target rect" geometry once a match exists). */
570
+ function guidesFromMatches(
571
+ movedRect: RectLike,
572
+ xMatch: SnapEdgeCandidate | null,
573
+ yMatch: SnapEdgeCandidate | null,
574
+ ): SnapGuide[] {
575
+ const guides: SnapGuide[] = [];
576
+ if (xMatch) {
577
+ guides.push({
578
+ orientation: 'vertical',
579
+ at: xMatch.edge,
580
+ start: Math.min(movedRect.y, xMatch.rect.y),
581
+ end: Math.max(movedRect.y + movedRect.height, xMatch.rect.y + xMatch.rect.height),
582
+ });
583
+ }
584
+ if (yMatch) {
585
+ guides.push({
586
+ orientation: 'horizontal',
587
+ at: yMatch.edge,
588
+ start: Math.min(movedRect.x, yMatch.rect.x),
589
+ end: Math.max(movedRect.x + movedRect.width, yMatch.rect.x + yMatch.rect.width),
590
+ });
591
+ }
592
+ return guides;
593
+ }
594
+
595
+ /**
596
+ * D1 (spec §6) — the alignment guides a LIVE move gesture's own edge-snap
597
+ * (the same math {@link computeMovePatch} applies to the committed patch)
598
+ * currently engages, or `[]` when Alt is held, there's no context, or
599
+ * nothing is within `threshold`. Deliberately a SEPARATE pure function from
600
+ * `computeMovePatch` (not a richer return shape on it) so that function's
601
+ * `{x,y}` contract — asserted by name in tests that predate D1 — never
602
+ * changes shape.
603
+ */
604
+ export function computeMoveSnapGuides(
605
+ dx: number,
606
+ dy: number,
607
+ orig: RectLike,
608
+ free: boolean,
609
+ context: MoveSnapContext | undefined,
610
+ threshold = EDGE_SNAP_THRESHOLD_PX,
611
+ ): SnapGuide[] {
612
+ if (free || !context) return [];
613
+ const x = snapValue(orig.x + dx, false);
614
+ const y = snapValue(orig.y + dy, false);
615
+ const targets = snapTargetsForContext(context);
616
+ const xMatch = edgeSnapBox(x, orig.width, xEdgeCandidates(targets), threshold).match;
617
+ const yMatch = edgeSnapBox(y, orig.height, yEdgeCandidates(targets), threshold).match;
618
+ const movedRect: RectLike = { x, y, width: orig.width, height: orig.height };
619
+ return guidesFromMatches(movedRect, xMatch, yMatch);
620
+ }
621
+
622
+ /**
623
+ * Alignment snap for a native 2D move whose writable value is the display
624
+ * object's transform origin rather than its visual bounds' left/top. The
625
+ * caller may grid-snap `proposedOrigin` first; this function only applies the
626
+ * higher-priority sibling/parent edge alignment and returns the matching
627
+ * guides from the exact same decision.
628
+ */
629
+ export function computeNativeMoveSnap(
630
+ proposedOrigin: { x: number; y: number },
631
+ originalOrigin: { x: number; y: number },
632
+ originalRect: RectLike,
633
+ context: MoveSnapContext | undefined,
634
+ axis: 'x' | 'y' | 'both' = 'both',
635
+ free = false,
636
+ threshold = EDGE_SNAP_THRESHOLD_PX,
637
+ authoredGuides: { x?: readonly number[]; y?: readonly number[] } = {},
638
+ targets: SmartSnapTargets = ALL_SMART_SNAP_TARGETS,
639
+ ): PointSnapResult {
640
+ if (free) return { position: proposedOrigin, guides: [] };
641
+ const movedRect: RectLike = {
642
+ x: originalRect.x + proposedOrigin.x - originalOrigin.x,
643
+ y: originalRect.y + proposedOrigin.y - originalOrigin.y,
644
+ width: originalRect.width,
645
+ height: originalRect.height,
646
+ };
647
+ const xCandidates: SnapEdgeCandidate[] = [];
648
+ const yCandidates: SnapEdgeCandidate[] = [];
649
+ const addRect = (rect: RectLike, sides: boolean, center: boolean): void => {
650
+ if (sides) {
651
+ xCandidates.push({ edge: rect.x, rect }, { edge: rect.x + rect.width, rect });
652
+ yCandidates.push({ edge: rect.y, rect }, { edge: rect.y + rect.height, rect });
653
+ }
654
+ if (center) {
655
+ xCandidates.push({ edge: rect.x + rect.width / 2, rect });
656
+ yCandidates.push({ edge: rect.y + rect.height / 2, rect });
657
+ }
658
+ };
659
+ if (context) {
660
+ const found = snapTargetsForContext(context);
661
+ if (targets.parent && found.paddingBox.width > 0 && found.paddingBox.height > 0) {
662
+ addRect(found.paddingBox, true, true);
663
+ }
664
+ if (targets.others) for (const sibling of found.siblingRects) addRect(sibling, true, true);
665
+ }
666
+ if (targets.guides) {
667
+ for (const edge of [...(context?.guideEdges?.x ?? []), ...(authoredGuides.x ?? [])]) {
668
+ xCandidates.push({ edge, rect: { x: edge, y: movedRect.y, width: 0, height: movedRect.height } });
669
+ }
670
+ for (const edge of [...(context?.guideEdges?.y ?? []), ...(authoredGuides.y ?? [])]) {
671
+ yCandidates.push({ edge, rect: { x: movedRect.x, y: edge, width: movedRect.width, height: 0 } });
672
+ }
673
+ }
674
+ if (xCandidates.length === 0 && yCandidates.length === 0) {
675
+ return { position: proposedOrigin, guides: [] };
676
+ }
677
+ const xMatch =
678
+ axis === 'y'
679
+ ? { value: movedRect.x, match: null }
680
+ : alignBox(movedRect.x, movedRect.width, xCandidates, threshold);
681
+ const yMatch =
682
+ axis === 'x'
683
+ ? { value: movedRect.y, match: null }
684
+ : alignBox(movedRect.y, movedRect.height, yCandidates, threshold);
685
+ const snappedRect = { ...movedRect, x: xMatch.value, y: yMatch.value };
686
+ return {
687
+ position: {
688
+ x: proposedOrigin.x + snappedRect.x - movedRect.x,
689
+ y: proposedOrigin.y + snappedRect.y - movedRect.y,
690
+ },
691
+ guides: guidesFromMatches(snappedRect, xMatch.match, yMatch.match),
692
+ };
693
+ }
694
+
695
+ /**
696
+ * Snap a pivot to its own node's box (Godot's Snap to Node Sides and Snap to Node Center): along
697
+ * each axis of the box's own frame, so a turned node snaps along its turned sides, to a side
698
+ * (`sides`) or the centre line (`center`) within `threshold` host px.
699
+ */
700
+ export function snapPointToFrame(
701
+ point: { x: number; y: number },
702
+ frame: FrameCorners,
703
+ sides: boolean,
704
+ center: boolean,
705
+ threshold: number,
706
+ ): { x: number; y: number } {
707
+ const ux = frame.tr.x - frame.tl.x;
708
+ const uy = frame.tr.y - frame.tl.y;
709
+ const vx = frame.bl.x - frame.tl.x;
710
+ const vy = frame.bl.y - frame.tl.y;
711
+ const det = ux * vy - uy * vx;
712
+ if (Math.abs(det) < 1e-9) return point;
713
+ const px = point.x - frame.tl.x;
714
+ const py = point.y - frame.tl.y;
715
+ const stops = [...(sides ? [0, 1] : []), ...(center ? [0.5] : [])];
716
+ const snap = (param: number, length: number): number => {
717
+ let best = param;
718
+ let distance = threshold;
719
+ for (const stop of stops) {
720
+ const d = Math.abs(param - stop) * length;
721
+ if (d <= distance) {
722
+ distance = d;
723
+ best = stop;
724
+ }
725
+ }
726
+ return best;
727
+ };
728
+ const s = snap((px * vy - py * vx) / det, Math.hypot(ux, uy));
729
+ const t = snap((ux * py - uy * px) / det, Math.hypot(vx, vy));
730
+ return { x: frame.tl.x + s * ux + t * vx, y: frame.tl.y + s * uy + t * vy };
731
+ }
732
+
733
+ /**
734
+ * Where a node's pivot lands, as Godot's pivot drag snaps it (`_gui_input_pivot` names the node to
735
+ * `snap_point`): Node Sides and Node Center pull it onto the node's own sides and centre lines, else
736
+ * the grid takes it when grid snap is on, and Use Pixel Snap rounds it; the grid and pixel snap skip
737
+ * a node turned on screen, as Godot's do. `invert` (Cmd) inverts smart snapping, as Godot's
738
+ * `snap_point` reads `smart_snap_active ^ Cmd`.
739
+ */
740
+ export function snapPivotPoint(
741
+ point: { x: number; y: number },
742
+ frame: FrameCorners | null,
743
+ options: {
744
+ readonly invert: boolean;
745
+ readonly smart: { readonly enabled: boolean; readonly sides: boolean; readonly center: boolean };
746
+ readonly gridOn: boolean;
747
+ readonly grid: { readonly step: number; readonly offsetX: number; readonly offsetY: number; readonly pixel: boolean };
748
+ readonly threshold: number;
749
+ },
750
+ ): { x: number; y: number } {
751
+ const { smart, grid } = options;
752
+ let out = point;
753
+ if (frame && smart.enabled !== options.invert && (smart.sides || smart.center)) {
754
+ out = snapPointToFrame(point, frame, smart.sides, smart.center, options.threshold);
755
+ }
756
+ if (frame && Math.abs(frameAngle(frame)) > 1e-6) return out;
757
+ const smartSnapped = Math.hypot(out.x - point.x, out.y - point.y) > 1e-9;
758
+ if (!smartSnapped && options.gridOn && grid.step > 0) {
759
+ out = {
760
+ x: Math.round((out.x - grid.offsetX) / grid.step) * grid.step + grid.offsetX,
761
+ y: Math.round((out.y - grid.offsetY) / grid.step) * grid.step + grid.offsetY,
762
+ };
763
+ }
764
+ return grid.pixel ? { x: Math.round(out.x), y: Math.round(out.y) } : out;
765
+ }
766
+
767
+ /** A rect's corners, for a box with no turned frame. */
768
+ export function rectFrame(rect: RectLike): FrameCorners {
769
+ return {
770
+ tl: { x: rect.x, y: rect.y },
771
+ tr: { x: rect.x + rect.width, y: rect.y },
772
+ br: { x: rect.x + rect.width, y: rect.y + rect.height },
773
+ bl: { x: rect.x, y: rect.y + rect.height },
774
+ };
775
+ }
776
+
777
+ /** Which things a native 2D move aligns to (Godot's Smart Snapping targets). */
778
+ export interface SmartSnapTargets {
779
+ readonly parent: boolean;
780
+ readonly others: boolean;
781
+ readonly guides: boolean;
782
+ }
783
+
784
+ const ALL_SMART_SNAP_TARGETS: SmartSnapTargets = { parent: true, others: true, guides: true };
785
+
786
+ /** {@link edgeSnapBox} with the box's centre as a third point that may align, for a move whose
787
+ * targets include centres. */
788
+ function alignBox(
789
+ value: number,
790
+ size: number,
791
+ candidates: readonly SnapEdgeCandidate[],
792
+ threshold: number,
793
+ ): { value: number; match: SnapEdgeCandidate | null } {
794
+ const edges = edgeSnapBox(value, size, candidates, threshold);
795
+ let best = edges.value;
796
+ let match = edges.match;
797
+ let bestDelta = match ? Math.abs(best - value) : threshold;
798
+ for (const c of candidates) {
799
+ const d = Math.abs(value + size / 2 - c.edge);
800
+ if (d < bestDelta) {
801
+ bestDelta = d;
802
+ best = c.edge - size / 2;
803
+ match = c;
804
+ }
805
+ }
806
+ return { value: best, match };
807
+ }
808
+
809
+ /**
810
+ * D1 (spec §6) — the alignment guide(s) a LIVE resize gesture's own
811
+ * per-handle edge-snap (the same math {@link computeResizePatch} applies)
812
+ * currently engages. Only the axis/axes the handle actually drags produce a
813
+ * candidate match (an `'e'` handle only ever checks x; a corner checks
814
+ * both) — mirrors `computeResizePatch`'s own `east`/`west`/`south`/`north`
815
+ * gating. `[]` under the same bypass conditions `computeMoveSnapGuides` uses.
816
+ */
817
+ export function computeResizeSnapGuides(
818
+ pos: HandlePos,
819
+ dx: number,
820
+ dy: number,
821
+ orig: RectLike,
822
+ free: boolean,
823
+ context: MoveSnapContext | undefined,
824
+ threshold = EDGE_SNAP_THRESHOLD_PX,
825
+ ): SnapGuide[] {
826
+ if (free || !context) return [];
827
+ const east = pos === 'e' || pos === 'ne' || pos === 'se';
828
+ const west = pos === 'w' || pos === 'nw' || pos === 'sw';
829
+ const south = pos === 's' || pos === 'sw' || pos === 'se';
830
+ const north = pos === 'n' || pos === 'nw' || pos === 'ne';
831
+ const targets = snapTargetsForContext(context);
832
+ const xCandidates = xEdgeCandidates(targets);
833
+ const yCandidates = yEdgeCandidates(targets);
834
+
835
+ // Reuses the SAME per-handle helpers `computeResizePatch` calls (only
836
+ // `.match`, discarding the recomputed `.value`) — the guide can never
837
+ // drift from the patch it's describing.
838
+ let xMatch: SnapEdgeCandidate | null = null;
839
+ if (east) xMatch = resizeEastWidth(dx, orig, false, xCandidates, threshold).match;
840
+ else if (west) xMatch = resizeWestWidth(dx, orig, false, xCandidates, threshold).match;
841
+
842
+ let yMatch: SnapEdgeCandidate | null = null;
843
+ if (south) yMatch = resizeSouthHeight(dy, orig, false, yCandidates, threshold).match;
844
+ else if (north) yMatch = resizeNorthHeight(dy, orig, false, yCandidates, threshold).match;
845
+
846
+ return guidesFromMatches(orig, xMatch, yMatch);
847
+ }
848
+
849
+ /**
850
+ * Rotate-gesture patch (spec:322 "rotate handle writes `transform`"): the
851
+ * angle between the gesture's START cursor position and its CURRENT position,
852
+ * both measured from the selection's own center — `atan2(cursor−center) −
853
+ * atan2(start−center)` in degrees, snapped to 15° unless Alt (spec:322). Both
854
+ * `center`/`start`/`current` must be in the SAME coordinate frame (host-local,
855
+ * matching the rect the handle itself is drawn from) — the caller's job.
856
+ */
857
+ export function computeRotatePatch(
858
+ center: { x: number; y: number },
859
+ start: { x: number; y: number },
860
+ current: { x: number; y: number },
861
+ free: boolean,
862
+ step = 15,
863
+ ): { rotate: number } {
864
+ const toDeg = (rad: number): number => (rad * 180) / Math.PI;
865
+ const startAngle = toDeg(Math.atan2(start.y - center.y, start.x - center.x));
866
+ const curAngle = toDeg(Math.atan2(current.y - center.y, current.x - center.x));
867
+ const raw = curAngle - startAngle;
868
+ const deg = free ? raw : Math.round(raw / step) * step;
869
+ return { rotate: deg };
870
+ }
871
+
872
+ // --- Adapter-routing helpers (composite→owner-child routing, shared by
873
+ // `rectForId` and every capability-specific wrapper below) -----------
874
+
875
+ /** The child adapter that owns `id` — the composite's owning child's adapter
876
+ * for a `CompositeAuthoringAdapter`, or `adapter` itself for a bare adapter.
877
+ * `null` when a composite has no owning child for `id`. Not exported: every
878
+ * outside caller wants one of the two capability-specific wrappers below. */
879
+ function ownerAdapterFor(adapter: AuthoringAdapter, id: string): AuthoringAdapter | null {
880
+ if (adapter instanceof CompositeAuthoringAdapter) {
881
+ const worldId = adapter.ownerOf(id);
882
+ if (!worldId) return null;
883
+ return adapter.childAdapters().find((c) => c.worldId === worldId)?.adapter ?? null;
884
+ }
885
+ return adapter;
886
+ }
887
+
888
+ const recordedBoxEdits = new WeakMap<
889
+ AuthoringAdapter,
890
+ { readonly provider: BoxEditProvider; readonly recorded: BoxEditProvider }
891
+ >();
892
+
893
+ /** One stable evidence-aware view of an owner's native box editor. The
894
+ * provider still owns every mutation and return value; this only retains the
895
+ * fact that a real shell consumer crossed each seam. */
896
+ function recordedBoxEdit(owner: AuthoringAdapter): BoxEditProvider | null {
897
+ const provider = owner.boxEdit;
898
+ if (!provider) return null;
899
+ const cached = recordedBoxEdits.get(owner);
900
+ if (cached?.provider === provider) return cached.recorded;
901
+ const recorded: BoxEditProvider = {
902
+ begin: (id) =>
903
+ recordAuthoringConsumerUse({
904
+ adapter: owner,
905
+ seam: 'editor.boxEdit.begin',
906
+ stage: 'effect',
907
+ detail: `the world overlay began a box edit for ${id}`,
908
+ run: () => provider.begin(id),
909
+ }),
910
+ apply: (id, patch) =>
911
+ recordAuthoringConsumerUse({
912
+ adapter: owner,
913
+ seam: 'editor.boxEdit.apply',
914
+ stage: 'effect',
915
+ detail: `the world overlay applied a ${Object.keys(patch).join(', ')} box patch to ${id}`,
916
+ run: () => provider.apply(id, patch),
917
+ }),
918
+ end: (id) =>
919
+ recordAuthoringConsumerUse({
920
+ adapter: owner,
921
+ seam: 'editor.boxEdit.end',
922
+ stage: 'effect',
923
+ detail: `the world overlay ended a box edit for ${id}`,
924
+ run: () => provider.end(id),
925
+ }),
926
+ ...(provider.gizmoOrigin
927
+ ? {
928
+ gizmoOrigin: (id: string) =>
929
+ recordAuthoringConsumerUse({
930
+ adapter: owner,
931
+ seam: 'editor.boxEdit.gizmoOrigin',
932
+ stage: 'operation',
933
+ detail: `the world overlay read the native gizmo origin for ${id}`,
934
+ run: () => provider.gizmoOrigin?.(id) ?? null,
935
+ }),
936
+ }
937
+ : {}),
938
+ ...(provider.referencePoint
939
+ ? {
940
+ referencePoint: (id: string) =>
941
+ recordAuthoringConsumerUse({
942
+ adapter: owner,
943
+ seam: 'editor.boxEdit.referencePoint',
944
+ stage: 'operation',
945
+ detail: `the world overlay read the native reference point for ${id}`,
946
+ run: () => provider.referencePoint?.(id) ?? null,
947
+ }),
948
+ }
949
+ : {}),
950
+ };
951
+ recordedBoxEdits.set(owner, { provider, recorded });
952
+ return recorded;
953
+ }
954
+
955
+ /**
956
+ * Resolve a node's rect through EITHER a bare adapter's own `rects`
957
+ * provider, OR — when the active adapter is a `CompositeAuthoringAdapter` —
958
+ * its OWNING child's `rects` provider. `rects` is deliberately NOT merged
959
+ * onto the composite (same stance as `pickable`/`boxEdit`/`text` —
960
+ * `composite-authoring-adapter.test.ts`'s T0 suite), so a composite caller
961
+ * must route through `ownerOf()` + `childAdapters()` itself — the SAME
962
+ * routing `ownerAdapterFor` above already does, reused here. `null` when the
963
+ * id has no owner, the owner has no `rects`, or the node is unmounted/
964
+ * offscreen. (D4 — moved here from `RootSelectionOverlay.tsx`, which
965
+ * re-exports it, so `editor-hotkeys.ts`'s arrow-nudge action can use it
966
+ * without pulling in that component's whole dependency graph.)
967
+ */
968
+ export function rectForId(adapter: AuthoringAdapter, id: string): DOMRectLike | null {
969
+ return ownerAdapterFor(adapter, id)?.rects?.rect(id) ?? null;
970
+ }
971
+
972
+ /** `id`'s owning `BoxEditProvider`, or `null` when the id has no owner or its
973
+ * owner has no `boxEdit` (`boxEdit` is deliberately NOT merged onto the
974
+ * composite, same stance as `rects`/`pickable` — see `rectForId`'s doc
975
+ * comment above). Gates whether resize/move/rotate handles render for the
976
+ * current selection (spec:317). */
977
+ export function boxEditForId(adapter: AuthoringAdapter, id: string): BoxEditProvider | null {
978
+ const owner = ownerAdapterFor(adapter, id);
979
+ return owner ? recordedBoxEdit(owner) : null;
980
+ }
981
+
982
+ /** True when `id`'s owning adapter exposes `structure` (reorder/reparent/
983
+ * wrap/unwrap/duplicate/delete — spec:308-309's structural-gesture gate;
984
+ * those gestures are built elsewhere, this predicate is the
985
+ * capability check other phases' handles gate on). Deliberately checks the
986
+ * OWNING CHILD, not the composite's own always-present `structure` (which
987
+ * merely forwards to whichever owner the id resolves to and would report
988
+ * `true` even for an id whose real owner has none). */
989
+ export function structureCapableForId(adapter: AuthoringAdapter, id: string): boolean {
990
+ return !!ownerAdapterFor(adapter, id)?.structure;
991
+ }
992
+
993
+ /** `id`'s owning `rects.contextRects(id)` (parent/siblings/padding-box), or
994
+ * `undefined` when the id has no owner or the owner has no `rects`/
995
+ * `contextRects`. Feeds {@link computeMovePatch}'s edge-snap. */
996
+ export function contextRectsForId(
997
+ adapter: AuthoringAdapter,
998
+ id: string,
999
+ ): MoveSnapContext | undefined {
1000
+ return ownerAdapterFor(adapter, id)?.rects?.contextRects?.(id);
1001
+ }
1002
+
1003
+ /** `id`'s owning `StructureProvider`, or `null` when the id has no owner or
1004
+ * its owner has no `structure` — the D3.a canvas context-menu's actual
1005
+ * action target (Duplicate/Wrap/Unwrap/Delete/Insert-child all route through
1006
+ * the OBJECT this returns, never a concrete adapter cast — rule zero). Not
1007
+ * the same thing as {@link structureCapableForId} (a boolean gate) — a
1008
+ * caller that already knows it wants the provider itself uses this instead
1009
+ * of gate-then-reach-in-again. */
1010
+ export function structureForId(adapter: AuthoringAdapter, id: string): StructureProvider | null {
1011
+ return ownerAdapterFor(adapter, id)?.structure ?? null;
1012
+ }
1013
+
1014
+ /** `id`'s owning `TextProvider`, or `null` when the id has no owner or its
1015
+ * owner has no `text` (`text` is deliberately NOT merged/forwarded onto the
1016
+ * composite — unlike `structure`, it follows the same "leave it to the
1017
+ * owner-lookup caller" stance as `rects`/`boxEdit`, see
1018
+ * `RootSelectionOverlay.rectForId`'s doc comment for the precedent this
1019
+ * mirrors). Gates the D3.b inline text editor. */
1020
+ export function textForId(adapter: AuthoringAdapter, id: string): TextProvider | null {
1021
+ return ownerAdapterFor(adapter, id)?.text ?? null;
1022
+ }
1023
+
1024
+ /** `id`'s owning `ColorSampleProvider`, or `null` when the id has no owner or
1025
+ * its owner has no `colorSample` (not merged onto the composite either —
1026
+ * same NOT-merged group as `rects`/`boxEdit`/`text`). Feeds the D3.d
1027
+ * eyedropper fallback swatch. */
1028
+ export function colorSampleForId(
1029
+ adapter: AuthoringAdapter,
1030
+ id: string,
1031
+ ): ColorSampleProvider | null {
1032
+ return ownerAdapterFor(adapter, id)?.colorSample ?? null;
1033
+ }
1034
+
1035
+ /**
1036
+ * `id`'s owning `SpatialHandlesProvider`, or `null` — routed to the OWNER like
1037
+ * every other non-merged provider above.
1038
+ *
1039
+ * GATED ON THE OWNER ALSO EXPOSING `rects`, because this overlay draws in the
1040
+ * rects frame: an adapter with world-space handles and no rects (the three
1041
+ * adapter — see the N-A table's own reason) has no frame here, and drawing its
1042
+ * points against someone else's coordinates would put a dot in the wrong place
1043
+ * rather than declining to draw one. Its handles are drawn by the 3D viewport,
1044
+ * which raycasts them in the space they are actually in.
1045
+ */
1046
+ export function spatialHandlesForId(
1047
+ adapter: AuthoringAdapter,
1048
+ id: string,
1049
+ ): SpatialHandlesProvider | null {
1050
+ const owner = ownerAdapterFor(adapter, id);
1051
+ return owner?.rects ? spatialHandlesForAdapter(owner) : null;
1052
+ }
1053
+
1054
+ /** Every empty-container hint reachable from `adapter`'s own `rects`
1055
+ * provider — or, for a composite, from each VISIBLE child's own `rects`
1056
+ * provider (D3.c). Mirrors `RootSelectionOverlay.collectMarqueeCandidates`'s
1057
+ * identical per-child-iteration/session-hidden-skip shape (that helper
1058
+ * lives in the sibling file rather than here purely because it was written
1059
+ * first — both walk the SAME composite-children/hidden-world contract). */
1060
+ export function emptyContainerHintsFor(
1061
+ adapter: AuthoringAdapter,
1062
+ ): Array<{ id: string; rect: DOMRectLike; displayName: string }> {
1063
+ const children: ReadonlyArray<{ worldId: string; adapter: AuthoringAdapter }> =
1064
+ adapter instanceof CompositeAuthoringAdapter
1065
+ ? adapter.childAdapters()
1066
+ : [{ worldId: '', adapter }];
1067
+ const out: Array<{ id: string; rect: DOMRectLike; displayName: string }> = [];
1068
+ for (const { worldId, adapter: child } of children) {
1069
+ if (!child.rects?.emptyContainers) continue;
1070
+ if (worldId && isRootHidden(worldId)) continue;
1071
+ out.push(...child.rects.emptyContainers());
1072
+ }
1073
+ return out;
1074
+ }
1075
+
1076
+ // --- B3 — box-model spacing bands (spec §4 B3, §5 :460/:465-466) ------
1077
+
1078
+ export type SpacingSide =
1079
+ | 'paddingTop'
1080
+ | 'paddingRight'
1081
+ | 'paddingBottom'
1082
+ | 'paddingLeft'
1083
+ | 'marginTop'
1084
+ | 'marginRight'
1085
+ | 'marginBottom'
1086
+ | 'marginLeft';
1087
+
1088
+ export interface SpacingValues {
1089
+ paddingTop: number;
1090
+ paddingRight: number;
1091
+ paddingBottom: number;
1092
+ paddingLeft: number;
1093
+ marginTop: number;
1094
+ marginRight: number;
1095
+ marginBottom: number;
1096
+ marginLeft: number;
1097
+ }
1098
+
1099
+ /** Read the 8 spacing values off `adapter.inspector` (routed/merged on the
1100
+ * composite, unlike `rects`/`boxEdit` — no owner lookup needed here) via the
1101
+ * reserved `style.<prop>` inspector paths, parsed with the shared
1102
+ * `numericStyleValue` (defensive: the declared `PropertyDescriptor.type` for
1103
+ * each of these IS `'number'` on both react adapters today, so `get` already
1104
+ * returns a parsed number — but a raw computed-style STRING like `"12px"`
1105
+ * parses identically, so this stays correct even if that declared type ever
1106
+ * changes, per this task's own note). Unresolved values default to `0`. */
1107
+ export function readSpacingValues(adapter: AuthoringAdapter, id: string): SpacingValues {
1108
+ const read = (prop: SpacingSide): number => {
1109
+ const raw = adapter.inspector?.get(id, `style.${prop}`);
1110
+ return numericStyleValue(raw as string | number | undefined) ?? 0;
1111
+ };
1112
+ return {
1113
+ paddingTop: read('paddingTop'),
1114
+ paddingRight: read('paddingRight'),
1115
+ paddingBottom: read('paddingBottom'),
1116
+ paddingLeft: read('paddingLeft'),
1117
+ marginTop: read('marginTop'),
1118
+ marginRight: read('marginRight'),
1119
+ marginBottom: read('marginBottom'),
1120
+ marginLeft: read('marginLeft'),
1121
+ };
1122
+ }
1123
+
1124
+ export interface SpacingBand {
1125
+ side: SpacingSide;
1126
+ kind: 'padding' | 'margin';
1127
+ x: number;
1128
+ y: number;
1129
+ width: number;
1130
+ height: number;
1131
+ cursor: string;
1132
+ /** Label anchor — centered in the band's thickness. */
1133
+ labelX: number;
1134
+ labelY: number;
1135
+ /** The side's REAL value (may be 0 even though the band itself is drawn at
1136
+ * {@link ZERO_STRIP_PX} thickness so a zero side stays grabbable). */
1137
+ value: number;
1138
+ isZero: boolean;
1139
+ }
1140
+
1141
+ /** A zero-value padding/margin side still renders a thin grab strip (spec
1142
+ * B3 "zero-value side → still render a ~4px grab strip so zero padding is
1143
+ * draggable") — this is that strip's thickness. */
1144
+ export const ZERO_STRIP_PX = 4;
1145
+
1146
+ /**
1147
+ * Padding (inner) + margin (outer) band geometry for a selection `rect` given
1148
+ * its 8 parsed spacing values (spec:332-335). Padding bands sit INSIDE `rect`
1149
+ * (spec:460's "box-model spacing bands"); margin bands sit OUTSIDE it. Uses
1150
+ * `rect` + the raw values directly — NOT `rects.contextRects(id).paddingBox`
1151
+ * (that reflects only the BORDER box per its own doc comment in the react
1152
+ * adapters, i.e. ignores padding entirely — using it here would draw the
1153
+ * padding band at the wrong thickness whenever the node also has a border).
1154
+ * Pure; no DOM/adapter access.
1155
+ */
1156
+ export function computeSpacingBands(rect: RectLike, values: SpacingValues): SpacingBand[] {
1157
+ const thickness = (v: number): number => (v > 0 ? v : ZERO_STRIP_PX);
1158
+ const pt = thickness(values.paddingTop);
1159
+ const pr = thickness(values.paddingRight);
1160
+ const pb = thickness(values.paddingBottom);
1161
+ const pl = thickness(values.paddingLeft);
1162
+ const mt = thickness(values.marginTop);
1163
+ const mr = thickness(values.marginRight);
1164
+ const mb = thickness(values.marginBottom);
1165
+ const ml = thickness(values.marginLeft);
1166
+
1167
+ return [
1168
+ {
1169
+ side: 'paddingTop',
1170
+ kind: 'padding',
1171
+ x: rect.x,
1172
+ y: rect.y,
1173
+ width: rect.width,
1174
+ height: pt,
1175
+ cursor: 'ns-resize',
1176
+ labelX: rect.x + rect.width / 2,
1177
+ labelY: rect.y + pt / 2,
1178
+ value: values.paddingTop,
1179
+ isZero: values.paddingTop === 0,
1180
+ },
1181
+ {
1182
+ side: 'paddingBottom',
1183
+ kind: 'padding',
1184
+ x: rect.x,
1185
+ y: rect.y + rect.height - pb,
1186
+ width: rect.width,
1187
+ height: pb,
1188
+ cursor: 'ns-resize',
1189
+ labelX: rect.x + rect.width / 2,
1190
+ labelY: rect.y + rect.height - pb / 2,
1191
+ value: values.paddingBottom,
1192
+ isZero: values.paddingBottom === 0,
1193
+ },
1194
+ {
1195
+ side: 'paddingLeft',
1196
+ kind: 'padding',
1197
+ x: rect.x,
1198
+ y: rect.y,
1199
+ width: pl,
1200
+ height: rect.height,
1201
+ cursor: 'ew-resize',
1202
+ labelX: rect.x + pl / 2,
1203
+ labelY: rect.y + rect.height / 2,
1204
+ value: values.paddingLeft,
1205
+ isZero: values.paddingLeft === 0,
1206
+ },
1207
+ {
1208
+ side: 'paddingRight',
1209
+ kind: 'padding',
1210
+ x: rect.x + rect.width - pr,
1211
+ y: rect.y,
1212
+ width: pr,
1213
+ height: rect.height,
1214
+ cursor: 'ew-resize',
1215
+ labelX: rect.x + rect.width - pr / 2,
1216
+ labelY: rect.y + rect.height / 2,
1217
+ value: values.paddingRight,
1218
+ isZero: values.paddingRight === 0,
1219
+ },
1220
+ {
1221
+ side: 'marginTop',
1222
+ kind: 'margin',
1223
+ x: rect.x,
1224
+ y: rect.y - mt,
1225
+ width: rect.width,
1226
+ height: mt,
1227
+ cursor: 'ns-resize',
1228
+ labelX: rect.x + rect.width / 2,
1229
+ labelY: rect.y - mt / 2,
1230
+ value: values.marginTop,
1231
+ isZero: values.marginTop === 0,
1232
+ },
1233
+ {
1234
+ side: 'marginBottom',
1235
+ kind: 'margin',
1236
+ x: rect.x,
1237
+ y: rect.y + rect.height,
1238
+ width: rect.width,
1239
+ height: mb,
1240
+ cursor: 'ns-resize',
1241
+ labelX: rect.x + rect.width / 2,
1242
+ labelY: rect.y + rect.height + mb / 2,
1243
+ value: values.marginBottom,
1244
+ isZero: values.marginBottom === 0,
1245
+ },
1246
+ {
1247
+ side: 'marginLeft',
1248
+ kind: 'margin',
1249
+ x: rect.x - ml,
1250
+ y: rect.y,
1251
+ width: ml,
1252
+ height: rect.height,
1253
+ cursor: 'ew-resize',
1254
+ labelX: rect.x - ml / 2,
1255
+ labelY: rect.y + rect.height / 2,
1256
+ value: values.marginLeft,
1257
+ isZero: values.marginLeft === 0,
1258
+ },
1259
+ {
1260
+ side: 'marginRight',
1261
+ kind: 'margin',
1262
+ x: rect.x + rect.width,
1263
+ y: rect.y,
1264
+ width: mr,
1265
+ height: rect.height,
1266
+ cursor: 'ew-resize',
1267
+ labelX: rect.x + rect.width + mr / 2,
1268
+ labelY: rect.y + rect.height / 2,
1269
+ value: values.marginRight,
1270
+ isZero: values.marginRight === 0,
1271
+ },
1272
+ ];
1273
+ }
1274
+
1275
+ /** Which raw delta axis (`dx`/`dy`) a given band drags along, and its sign —
1276
+ * e.g. dragging the TOP padding band DOWN (`dy > 0`) grows `paddingTop`;
1277
+ * dragging the TOP margin band UP (`dy < 0`, i.e. `-dy > 0`) grows
1278
+ * `marginTop` (margin bands sit OUTSIDE the box, so their "grow" direction
1279
+ * is the opposite screen direction from the same-named padding band). */
1280
+ function axisDeltaForSide(side: SpacingSide, dx: number, dy: number): number {
1281
+ switch (side) {
1282
+ case 'paddingTop':
1283
+ return dy;
1284
+ case 'paddingBottom':
1285
+ return -dy;
1286
+ case 'paddingLeft':
1287
+ return dx;
1288
+ case 'paddingRight':
1289
+ return -dx;
1290
+ case 'marginTop':
1291
+ return -dy;
1292
+ case 'marginBottom':
1293
+ return dy;
1294
+ case 'marginLeft':
1295
+ return -dx;
1296
+ case 'marginRight':
1297
+ return dx;
1298
+ }
1299
+ }
1300
+
1301
+ /**
1302
+ * Spacing-band drag → `boxEdit` patch (spec:333-335): the delta along the
1303
+ * band's own axis (see {@link axisDeltaForSide}) is added to the side's
1304
+ * original value and clamped to ≥0. Deliberately whole-PIXEL (`Math.round`),
1305
+ * NOT snapped to the 8px {@link GRID} B2's resize/move gestures use — a real
1306
+ * padding/margin is routinely NOT a multiple of 8 (this task's own e2e fixture
1307
+ * authors `padding: 12`), so grid-quantizing every spacing drag would make
1308
+ * that starting value permanently unreachable by any drag distance (e.g.
1309
+ * dragging `+8` from `12` would jump to `16` or `24`, never landing on the
1310
+ * expected `20`). Whole-px rounding still keeps the value tidy without that
1311
+ * unreachability trap.
1312
+ */
1313
+ export function computeSpacingPatch(
1314
+ side: SpacingSide,
1315
+ dx: number,
1316
+ dy: number,
1317
+ origValue: number,
1318
+ ): Record<string, number> {
1319
+ const delta = axisDeltaForSide(side, dx, dy);
1320
+ const next = Math.max(0, Math.round(origValue + delta));
1321
+ return { [side]: next };
1322
+ }
1323
+
1324
+ // --- D2 — marquee polish, drag-ghost, reorder clone-preview (spec §6 D2,
1325
+ // §5 :461-462/:466) ---------------------------------------------------
1326
+
1327
+ /** A drag-ghost's geometry: the dragged element's OWN original rect, offset
1328
+ * by the pointer's delta since the gesture started (spec:399 "a semi-
1329
+ * transparent follow-cursor ghost ... offset by the drag delta"). Shared by
1330
+ * the B2 move gesture (`applyGesturePatch`'s `'move'` branch) and the D2.b
1331
+ * reorder-preview drag below — both are "dragging a selected element on the
1332
+ * canvas" per the spec's own D2 framing, just with a different commit path
1333
+ * at drop (a `boxEdit` patch vs. a `structure.reorder`). Pure; no DOM/adapter
1334
+ * access. */
1335
+ export function computeDragGhost(origRect: RectLike, dx: number, dy: number): RectLike {
1336
+ return { x: origRect.x + dx, y: origRect.y + dy, width: origRect.width, height: origRect.height };
1337
+ }
1338
+
1339
+ /** One sibling candidate for the D2.b reorder gap math: an id (the
1340
+ * `structure.reorder(id, beforeSiblingId)` argument this sibling would BE,
1341
+ * if the pointer lands before it) paired with its current on-screen rect.
1342
+ * Deliberately excludes the DRAGGED node itself — the caller builds this
1343
+ * list from the dragged id's ordered siblings, filtering the dragged id out
1344
+ * (its own rect is busy being dragged, not a valid gap boundary). */
1345
+ export interface SiblingRectEntry {
1346
+ id: string;
1347
+ rect: RectLike;
1348
+ }
1349
+
1350
+ /** The live reorder-preview gap a pointer position resolves to among a set of
1351
+ * sibling rects (spec:398-400 "live insertion preview ... updating as the
1352
+ * pointer moves across sibling boundaries"). */
1353
+ export interface ReorderGap {
1354
+ /** The exact `structure.reorder(id, beforeSiblingId)` second argument:
1355
+ * the sibling to insert BEFORE, or `null` to insert at the END (spec's
1356
+ * own contract doc comment: "null = move to the end"). */
1357
+ beforeSiblingId: string | null;
1358
+ /** The gap's index among `siblings` in AXIS order — `0` is "before the
1359
+ * first sibling", `siblings.length` is "after the last" (same case
1360
+ * `beforeSiblingId: null` reports). Exposed mainly for tests: it's a
1361
+ * denser signal than re-deriving position from `beforeSiblingId` alone
1362
+ * when two siblings tie on id. */
1363
+ index: number;
1364
+ /** A thin marker rect at the gap boundary — the live insertion-preview
1365
+ * line/marker to render. Spans the cross-axis extent of the flanking
1366
+ * sibling(s); its thickness is {@link REORDER_MARKER_THICKNESS_PX}. */
1367
+ marker: RectLike;
1368
+ }
1369
+
1370
+ /** Thickness (px) of the D2.b live insertion-preview marker line. */
1371
+ export const REORDER_MARKER_THICKNESS_PX = 3;
1372
+
1373
+ /** A sibling rect's center coordinate along one axis — the sort/compare key
1374
+ * {@link computeReorderGap} uses throughout. */
1375
+ function centerOn(r: RectLike, axis: 'x' | 'y'): number {
1376
+ return axis === 'x' ? r.x + r.width / 2 : r.y + r.height / 2;
1377
+ }
1378
+
1379
+ /** The gap boundary coordinate along `axis`, given `ordered` siblings (sorted
1380
+ * along that axis already) and the gap `index` among them: the leading edge
1381
+ * of the first sibling (index 0), the trailing edge of the last (index ===
1382
+ * length), or the midpoint between the flanking pair's facing edges
1383
+ * otherwise. Factored out of {@link computeReorderGap} purely to keep ITS
1384
+ * cognitive complexity down. */
1385
+ function reorderGapBoundary(
1386
+ ordered: readonly SiblingRectEntry[],
1387
+ index: number,
1388
+ horizontal: boolean,
1389
+ ): number {
1390
+ if (index === 0) {
1391
+ const first = ordered[0]!.rect;
1392
+ return horizontal ? first.x : first.y;
1393
+ }
1394
+ if (index === ordered.length) {
1395
+ const last = ordered[ordered.length - 1]!.rect;
1396
+ return horizontal ? last.x + last.width : last.y + last.height;
1397
+ }
1398
+ const prev = ordered[index - 1]!.rect;
1399
+ const next = ordered[index]!.rect;
1400
+ return horizontal ? (prev.x + prev.width + next.x) / 2 : (prev.y + prev.height + next.y) / 2;
1401
+ }
1402
+
1403
+ /** The D2.b insertion-preview marker rect: a thin {@link REORDER_MARKER_THICKNESS_PX}
1404
+ * line at `boundary` along `axis`, spanning the CROSS-axis extent of every
1405
+ * `ordered` sibling (so it reads as a full-width/height insertion line, not
1406
+ * just as wide as its immediate neighbors). Factored out of
1407
+ * {@link computeReorderGap} purely to keep ITS cognitive complexity down. */
1408
+ function reorderMarkerRect(
1409
+ ordered: readonly SiblingRectEntry[],
1410
+ boundary: number,
1411
+ horizontal: boolean,
1412
+ ): RectLike {
1413
+ const crossMin = Math.min(...ordered.map((s) => (horizontal ? s.rect.y : s.rect.x)));
1414
+ const crossMax = Math.max(
1415
+ ...ordered.map((s) => (horizontal ? s.rect.y + s.rect.height : s.rect.x + s.rect.width)),
1416
+ );
1417
+ const half = REORDER_MARKER_THICKNESS_PX / 2;
1418
+ return horizontal
1419
+ ? {
1420
+ x: boundary - half,
1421
+ y: crossMin,
1422
+ width: REORDER_MARKER_THICKNESS_PX,
1423
+ height: crossMax - crossMin,
1424
+ }
1425
+ : {
1426
+ x: crossMin,
1427
+ y: boundary - half,
1428
+ width: crossMax - crossMin,
1429
+ height: REORDER_MARKER_THICKNESS_PX,
1430
+ };
1431
+ }
1432
+
1433
+ /**
1434
+ * D2.b (spec:397-401) — resolve `pointer` to a live insertion gap among
1435
+ * `siblings` (the dragged node's OWN siblings, already excluding it — see
1436
+ * {@link SiblingRectEntry}'s doc comment). `null` only when `siblings` is
1437
+ * empty (nothing to compute a gap among — the caller's own "≥3 siblings"
1438
+ * capability gate should make this unreachable in practice, but this stays
1439
+ * honest about the degenerate input rather than fabricating a gap).
1440
+ *
1441
+ * Axis detection is PURE GEOMETRY, no CSS/flex-direction read needed: whichever
1442
+ * dimension the siblings' centers spread across MORE is the list's primary
1443
+ * axis (a horizontal flex-row spreads in x, a vertical stack spreads in y).
1444
+ * Siblings are then re-sorted along that axis (defensive — callers should
1445
+ * already pass DOM order, but the gap math only means something in spatial
1446
+ * order) and the pointer's coordinate along the SAME axis is compared against
1447
+ * each sibling's center to find how many siblings it has "passed" — that
1448
+ * count IS the gap index, and `beforeSiblingId` is whichever sibling sits at
1449
+ * that index (`null` past the last one).
1450
+ */
1451
+ export function computeReorderGap(
1452
+ siblings: readonly SiblingRectEntry[],
1453
+ pointer: { x: number; y: number },
1454
+ ): ReorderGap | null {
1455
+ if (siblings.length === 0) return null;
1456
+
1457
+ const xSpread =
1458
+ Math.max(...siblings.map((s) => centerOn(s.rect, 'x'))) -
1459
+ Math.min(...siblings.map((s) => centerOn(s.rect, 'x')));
1460
+ const ySpread =
1461
+ Math.max(...siblings.map((s) => centerOn(s.rect, 'y'))) -
1462
+ Math.min(...siblings.map((s) => centerOn(s.rect, 'y')));
1463
+ const horizontal = xSpread >= ySpread;
1464
+ const axis: 'x' | 'y' = horizontal ? 'x' : 'y';
1465
+
1466
+ const ordered = [...siblings].sort((a, b) => centerOn(a.rect, axis) - centerOn(b.rect, axis));
1467
+
1468
+ const pointerKey = horizontal ? pointer.x : pointer.y;
1469
+ let index = 0;
1470
+ while (index < ordered.length && centerOn(ordered[index]!.rect, axis) < pointerKey) index++;
1471
+ const beforeSiblingId = index < ordered.length ? ordered[index]!.id : null;
1472
+
1473
+ const boundary = reorderGapBoundary(ordered, index, horizontal);
1474
+ const marker = reorderMarkerRect(ordered, boundary, horizontal);
1475
+
1476
+ return { beforeSiblingId, index, marker };
1477
+ }
1478
+
1479
+ /**
1480
+ * `id`'s ordered sibling ids, INCLUDING `id` itself (the caller filters it
1481
+ * back out when building {@link SiblingRectEntry}s) — read off `adapter`'s
1482
+ * OWN `hierarchy` (merged/routed on a `CompositeAuthoringAdapter`, same
1483
+ * stance `labelForId`/`RootSelectionOverlay.tsx` already document: no owner
1484
+ * lookup needed for `hierarchy`, unlike `rects`/`boxEdit`/`structure`). `null`
1485
+ * when `id` is unknown, or when it claims a `parentId` whose node can't be
1486
+ * resolved. A parentless (top-level) id's siblings are the OWNER'S OWN
1487
+ * `hierarchy.roots()` — for a composite, `roots()` returns synthetic
1488
+ * `world:<id>` GROUP nodes, so a top-level react-world node's real siblings
1489
+ * only resolve correctly through the group node's `childIds`, which is why
1490
+ * this reads `node.parentId` (already rewritten to the group id by the
1491
+ * composite for a child's own top-level root, per
1492
+ * `composite-authoring-adapter.ts`'s `node()`) rather than calling
1493
+ * `roots()` directly for every id.
1494
+ */
1495
+ export function siblingIdsForId(adapter: AuthoringAdapter, id: string): string[] | null {
1496
+ const node = adapter.hierarchy.node(id);
1497
+ if (!node) return null;
1498
+ if (node.parentId) {
1499
+ const parent = adapter.hierarchy.node(node.parentId);
1500
+ return parent ? parent.childIds : null;
1501
+ }
1502
+ return adapter.hierarchy.roots().map((n) => n.id);
1503
+ }
1504
+
1505
+ // --- D1.a — sibling-distance measure lines (spec §6 D1, §5 :461
1506
+ // "sibling-distance measure lines") -----------------------------------
1507
+
1508
+ /** One figma-style "hold-and-measure" gap line between two rects: a straight
1509
+ * segment from `(x1,y1)` to `(x2,y2)` (always axis-aligned — either
1510
+ * `y1 === y2`, a `'horizontal'` line measuring the x-axis gap, or
1511
+ * `x1 === x2`, a `'vertical'` line measuring the y-axis gap), the gap's
1512
+ * rounded px `distance`, and a `label` anchor at the segment's midpoint. */
1513
+ export interface MeasureLine {
1514
+ orientation: 'horizontal' | 'vertical';
1515
+ x1: number;
1516
+ y1: number;
1517
+ x2: number;
1518
+ y2: number;
1519
+ distance: number;
1520
+ labelX: number;
1521
+ labelY: number;
1522
+ }
1523
+
1524
+ /**
1525
+ * D1.a (spec §6 "sibling-distance measure lines ... between selection and
1526
+ * hover"): the gap line(s) between `a` (the selection) and `b` (the hover
1527
+ * target). A `'horizontal'` line (gap along x) is produced when the two
1528
+ * rects DON'T overlap on the x-axis (one is fully left/right of the other);
1529
+ * a `'vertical'` line (gap along y) when they don't overlap on the y-axis.
1530
+ * Both can be produced at once (the rects are diagonal from each other —
1531
+ * neither axis overlaps); neither is produced when the rects overlap on
1532
+ * BOTH axes (no meaningful "gap" to measure — honest empty result, never a
1533
+ * fabricated negative/zero line, per this task's own anti-fabrication rule).
1534
+ *
1535
+ * The line's position on its PERPENDICULAR axis is the midpoint of the two
1536
+ * rects' overlapping range on that axis when they DO overlap there (the
1537
+ * common figma case — a sibling directly beside or below the selection);
1538
+ * when they don't overlap on that axis either (the diagonal case), it falls
1539
+ * back to the midpoint between the two rects' own centers on that axis, so
1540
+ * the line still reads as "between" them rather than snapping to an
1541
+ * arbitrary edge. Pure geometry; `a`/`b` are interchangeable (order doesn't
1542
+ * change which lines are produced, only which distance/endpoint is which
1543
+ * rect's edge — commutative).
1544
+ */
1545
+ export function computeMeasureLines(a: RectLike, b: RectLike): MeasureLine[] {
1546
+ const aLeft = a.x;
1547
+ const aRight = a.x + a.width;
1548
+ const aTop = a.y;
1549
+ const aBottom = a.y + a.height;
1550
+ const bLeft = b.x;
1551
+ const bRight = b.x + b.width;
1552
+ const bTop = b.y;
1553
+ const bBottom = b.y + b.height;
1554
+
1555
+ const lines: MeasureLine[] = [];
1556
+
1557
+ // Horizontal gap (x-axis separation) — only when the rects don't overlap
1558
+ // on x.
1559
+ let gapX: { x1: number; x2: number } | null = null;
1560
+ if (aRight <= bLeft) gapX = { x1: aRight, x2: bLeft };
1561
+ else if (bRight <= aLeft) gapX = { x1: bRight, x2: aLeft };
1562
+ if (gapX) {
1563
+ const overlapTop = Math.max(aTop, bTop);
1564
+ const overlapBottom = Math.min(aBottom, bBottom);
1565
+ const y =
1566
+ overlapBottom > overlapTop
1567
+ ? (overlapTop + overlapBottom) / 2
1568
+ : ((aTop + aBottom) / 2 + (bTop + bBottom) / 2) / 2;
1569
+ lines.push({
1570
+ orientation: 'horizontal',
1571
+ x1: gapX.x1,
1572
+ y1: y,
1573
+ x2: gapX.x2,
1574
+ y2: y,
1575
+ distance: Math.round(gapX.x2 - gapX.x1),
1576
+ labelX: (gapX.x1 + gapX.x2) / 2,
1577
+ labelY: y,
1578
+ });
1579
+ }
1580
+
1581
+ // Vertical gap (y-axis separation) — only when the rects don't overlap
1582
+ // on y.
1583
+ let gapY: { y1: number; y2: number } | null = null;
1584
+ if (aBottom <= bTop) gapY = { y1: aBottom, y2: bTop };
1585
+ else if (bBottom <= aTop) gapY = { y1: bBottom, y2: aTop };
1586
+ if (gapY) {
1587
+ const overlapLeft = Math.max(aLeft, bLeft);
1588
+ const overlapRight = Math.min(aRight, bRight);
1589
+ const x =
1590
+ overlapRight > overlapLeft
1591
+ ? (overlapLeft + overlapRight) / 2
1592
+ : ((aLeft + aRight) / 2 + (bLeft + bRight) / 2) / 2;
1593
+ lines.push({
1594
+ orientation: 'vertical',
1595
+ x1: x,
1596
+ y1: gapY.y1,
1597
+ x2: x,
1598
+ y2: gapY.y2,
1599
+ distance: Math.round(gapY.y2 - gapY.y1),
1600
+ labelX: x,
1601
+ labelY: (gapY.y1 + gapY.y2) / 2,
1602
+ });
1603
+ }
1604
+
1605
+ return lines;
1606
+ }
1607
+
1608
+ // --- D1.c — position/parent-layout badge (spec §6 D1, §5 :461
1609
+ // "position/parent-layout badges") --------------------------------
1610
+
1611
+ /** The D1.c badge's two independent segments — each `null` when the
1612
+ * underlying value couldn't be resolved (no `inspector`, no parent, an
1613
+ * unreadable/absent style value), per this task's own honesty rule: "if a
1614
+ * value can't be resolved, show nothing for it, never a fabricated
1615
+ * default." Never both `null` AND rendered — the caller's own gate. */
1616
+ export interface PositionBadgeInfo {
1617
+ /** `id`'s own `style.position` value (e.g. `'absolute'`), or `null`. */
1618
+ position: string | null;
1619
+ /** `id`'s PARENT's layout, summarized: `'flex row'`/`'flex col'` (from
1620
+ * `display: flex`/`inline-flex` + `flexDirection`), `'grid'` (from
1621
+ * `display: grid`/`inline-grid`), or the parent's raw `display` value for
1622
+ * anything else (e.g. `'block'`) — `null` when there's no parent, no
1623
+ * `inspector`, or the parent's `display` itself can't be read. */
1624
+ parentLayout: string | null;
1625
+ }
1626
+
1627
+ /** Read a possibly-unset inspector value as a non-empty string, or `null` —
1628
+ * the shared "honest unresolved" coercion both segments below use (an
1629
+ * inspector `get` may return `undefined`, `null`, or even a non-string for
1630
+ * an adapter that doesn't model the path at all). */
1631
+ function readStyleString(
1632
+ inspector: { get(id: string, path: string): unknown } | undefined,
1633
+ id: string,
1634
+ path: string,
1635
+ ): string | null {
1636
+ const raw = inspector?.get(id, path);
1637
+ return typeof raw === 'string' && raw.length > 0 ? raw : null;
1638
+ }
1639
+
1640
+ /**
1641
+ * D1.c (spec §6 "position/parent-layout badges") — `id`'s own position mode
1642
+ * plus its PARENT's summarized layout, read entirely through
1643
+ * `adapter.inspector.get`/`adapter.hierarchy.node` (both merged/routed on a
1644
+ * `CompositeAuthoringAdapter` already — `labelForId`/`siblingIdsForId`'s own
1645
+ * doc comments — so, unlike `rects`/`boxEdit`, no owner-child routing helper
1646
+ * is needed here; a bare adapter and a composite call this identically).
1647
+ * Each segment resolves independently — a missing/unreadable parent
1648
+ * `display` never blanks out an otherwise-resolved `position`, and vice
1649
+ * versa (spec's own "never a fabricated default" instruction).
1650
+ */
1651
+ export function resolvePositionBadge(adapter: AuthoringAdapter, id: string): PositionBadgeInfo {
1652
+ const position = readStyleString(adapter.inspector, id, 'style.position');
1653
+
1654
+ let parentLayout: string | null = null;
1655
+ const parentId = adapter.hierarchy.node(id)?.parentId ?? null;
1656
+ if (parentId) {
1657
+ const display = readStyleString(adapter.inspector, parentId, 'style.display');
1658
+ if (display === 'flex' || display === 'inline-flex') {
1659
+ const direction =
1660
+ readStyleString(adapter.inspector, parentId, 'style.flexDirection') ?? 'row';
1661
+ parentLayout = `flex ${direction.startsWith('column') ? 'col' : 'row'}`;
1662
+ } else if (display === 'grid' || display === 'inline-grid') {
1663
+ parentLayout = 'grid';
1664
+ } else if (display) {
1665
+ parentLayout = display;
1666
+ }
1667
+ }
1668
+
1669
+ return { position, parentLayout };
1670
+ }
1671
+
1672
+ // --- D4 — multi-select align/distribute toolbar (spec §6 D4, §5 :462
1673
+ // "alignment/distribute toolbar") -------------------------------------
1674
+
1675
+ /** One selected node's id paired with its current on-screen rect — reused
1676
+ * (not re-declared) from the D2.b reorder-gap math above: both are just
1677
+ * "an id + the rect it currently occupies", the same shape `AlignToolbar`
1678
+ * needs for the align/distribute math below. */
1679
+ export type AlignEntry = SiblingRectEntry;
1680
+
1681
+ export type AlignOp = 'left' | 'hcenter' | 'right' | 'top' | 'vcenter' | 'bottom';
1682
+ export type DistributeAxis = 'horizontal' | 'vertical';
1683
+
1684
+ /** One node's align/distribute RESULT: the absolute host-relative `x` and/or
1685
+ * `y` its rect should move to (only the axis the op touches is present —
1686
+ * align-left only ever sets `x`, never `y`), matching `boxEdit.apply`'s own
1687
+ * ABSOLUTE-coordinate contract (spec:322/B2 — the same convention
1688
+ * `computeMovePatch` already returns, NOT a delta). */
1689
+ export interface AlignTarget {
1690
+ id: string;
1691
+ x?: number;
1692
+ y?: number;
1693
+ }
1694
+
1695
+ /**
1696
+ * D4.b (spec §6 D4 "a multi-select align/distribute toolbar ... align
1697
+ * left/hcenter/right/top/vcenter/bottom (≥2 selected)"): every entry's target
1698
+ * position for `op`, computed from the SELECTION's own union bounding box
1699
+ * (spec's own figma-parity framing — "align left" moves every selected
1700
+ * node's left edge to the leftmost selected node's left edge, which IS the
1701
+ * union bbox's left edge; center/right/top/vcenter/bottom are the analogous
1702
+ * bbox edges/midpoints). `[]` for fewer than 2 entries — the toolbar's own
1703
+ * capability gate (this function stays honest about the degenerate input
1704
+ * rather than fabricating a single-node "alignment"). Pure; no adapter/DOM
1705
+ * access — the caller (`AlignToolbar.tsx`) is the one that routes each
1706
+ * target through `boxEditForId`.
1707
+ */
1708
+ export function computeAlignTargets(entries: readonly AlignEntry[], op: AlignOp): AlignTarget[] {
1709
+ if (entries.length < 2) return [];
1710
+ const rects = entries.map((e) => e.rect);
1711
+ const minX = Math.min(...rects.map((r) => r.x));
1712
+ const maxRight = Math.max(...rects.map((r) => r.x + r.width));
1713
+ const minY = Math.min(...rects.map((r) => r.y));
1714
+ const maxBottom = Math.max(...rects.map((r) => r.y + r.height));
1715
+ const centerX = (minX + maxRight) / 2;
1716
+ const centerY = (minY + maxBottom) / 2;
1717
+ const out: AlignTarget[] = [];
1718
+ for (const { id, rect } of entries) {
1719
+ switch (op) {
1720
+ case 'left':
1721
+ out.push({ id, x: minX });
1722
+ break;
1723
+ case 'hcenter':
1724
+ out.push({ id, x: centerX - rect.width / 2 });
1725
+ break;
1726
+ case 'right':
1727
+ out.push({ id, x: maxRight - rect.width });
1728
+ break;
1729
+ case 'top':
1730
+ out.push({ id, y: minY });
1731
+ break;
1732
+ case 'vcenter':
1733
+ out.push({ id, y: centerY - rect.height / 2 });
1734
+ break;
1735
+ case 'bottom':
1736
+ out.push({ id, y: maxBottom - rect.height });
1737
+ break;
1738
+ }
1739
+ }
1740
+ return out;
1741
+ }
1742
+
1743
+ /**
1744
+ * D4.b (spec §6 D4 "distribute horizontally/vertically (≥3 selected)"):
1745
+ * equalize the GAP between consecutive entries along `axis`, keeping the
1746
+ * first (lowest-coordinate) and last (highest-coordinate) entries fixed at
1747
+ * their own current position — the standard figma distribute algorithm.
1748
+ * `[]` for fewer than 3 entries (spec's own "(≥3 selected)" gate — with only
1749
+ * 2 entries there is exactly one gap, nothing to "equalize" against, so an
1750
+ * honest empty result beats fabricating a no-op target). Pure; entries are
1751
+ * re-sorted internally by their CURRENT position along `axis` (the caller
1752
+ * doesn't need to pre-sort).
1753
+ */
1754
+ export function computeDistributeTargets(
1755
+ entries: readonly AlignEntry[],
1756
+ axis: DistributeAxis,
1757
+ ): AlignTarget[] {
1758
+ if (entries.length < 3) return [];
1759
+ const horizontal = axis === 'horizontal';
1760
+ const sorted = [...entries].sort((a, b) =>
1761
+ horizontal ? a.rect.x - b.rect.x : a.rect.y - b.rect.y,
1762
+ );
1763
+ const first = sorted[0]!;
1764
+ const last = sorted[sorted.length - 1]!;
1765
+ const totalSpan = horizontal
1766
+ ? last.rect.x + last.rect.width - first.rect.x
1767
+ : last.rect.y + last.rect.height - first.rect.y;
1768
+ const totalSize = sorted.reduce((s, e) => s + (horizontal ? e.rect.width : e.rect.height), 0);
1769
+ const gap = (totalSpan - totalSize) / (sorted.length - 1);
1770
+ let cursor = horizontal ? first.rect.x : first.rect.y;
1771
+ const out: AlignTarget[] = [];
1772
+ for (const e of sorted) {
1773
+ out.push(horizontal ? { id: e.id, x: cursor } : { id: e.id, y: cursor });
1774
+ cursor += (horizontal ? e.rect.width : e.rect.height) + gap;
1775
+ }
1776
+ return out;
1777
+ }
1778
+
1779
+ /** The union bounding box of `rects` (spec's own "the selection's own union
1780
+ * bounding box" framing — see {@link computeAlignTargets}'s doc comment) —
1781
+ * used by `AlignToolbar` purely to POSITION itself above the selection;
1782
+ * `null` for an empty input (never a fabricated zero-rect). */
1783
+ export function unionRect(rects: readonly RectLike[]): RectLike | null {
1784
+ if (rects.length === 0) return null;
1785
+ const x = Math.min(...rects.map((r) => r.x));
1786
+ const y = Math.min(...rects.map((r) => r.y));
1787
+ const right = Math.max(...rects.map((r) => r.x + r.width));
1788
+ const bottom = Math.max(...rects.map((r) => r.y + r.height));
1789
+ return { x, y, width: right - x, height: bottom - y };
1790
+ }
1791
+
1792
+ // --- D4.c — arrow-nudge (spec §6 D4 "arrow-nudge (↑↓←→, Shift=×10) ...
1793
+ // writes margins/offsets") --------------------------------------------
1794
+
1795
+ /**
1796
+ * D4.c — the `boxEdit` patch one arrow-key nudge writes: for an
1797
+ * absolutely/fixed-positioned node, an ABSOLUTE `{x,y}` (the same convention
1798
+ * {@link computeMovePatch} already returns, matching B1's "x/y for
1799
+ * absolutely-positioned nodes → left/top"), offset by `(dx,dy)` from `rect`'s
1800
+ * current position. For anything else — the normal-document-flow case B1
1801
+ * also covers ("margin / padding → per-side style writes") — nudges
1802
+ * `marginLeft`/`marginTop` by the same delta instead (there is no `left`/
1803
+ * `top` to move on a non-positioned node — the only spatial lever B1 leaves
1804
+ * it is its own margin). Only the axis/axes actually nudged (`dx`/`dy`
1805
+ * non-zero) appear in the returned patch. Pure; no adapter/DOM access.
1806
+ */
1807
+ export function computeArrowNudgePatch(
1808
+ dx: number,
1809
+ dy: number,
1810
+ isPositioned: boolean,
1811
+ rect: RectLike,
1812
+ margin: { left: number; top: number },
1813
+ ): Record<string, number> {
1814
+ const patch: Record<string, number> = {};
1815
+ if (isPositioned) {
1816
+ if (dx !== 0) patch['x'] = rect.x + dx;
1817
+ if (dy !== 0) patch['y'] = rect.y + dy;
1818
+ return patch;
1819
+ }
1820
+ if (dx !== 0) patch['marginLeft'] = margin.left + dx;
1821
+ if (dy !== 0) patch['marginTop'] = margin.top + dy;
1822
+ return patch;
1823
+ }
1824
+
1825
+ // --- Turned frames (a rotated or skewed 2D node) --------------------------------------------
1826
+ //
1827
+ // Godot's 2D editor frames a turned node on its own box, with its eight handles on that box and a
1828
+ // resize that works along the node's own axes. `RectProvider.frame` gives the box's corners; the
1829
+ // helpers below place handles on them and turn a handle drag into the native patch.
1830
+
1831
+ type Point = { readonly x: number; readonly y: number };
1832
+
1833
+ /** `id`'s turned box, from its owning adapter; `null` when the owner offers none. */
1834
+ export function frameForId(adapter: AuthoringAdapter, id: string): FrameCorners | null {
1835
+ return ownerAdapterFor(adapter, id)?.rects?.frame?.(id) ?? null;
1836
+ }
1837
+
1838
+ /** True when the box is not axis-aligned, so its axis-aligned bounds would misframe it. */
1839
+ export function frameIsTurned(frame: FrameCorners): boolean {
1840
+ const ux = frame.tr.x - frame.tl.x;
1841
+ const uy = frame.tr.y - frame.tl.y;
1842
+ const vx = frame.bl.x - frame.tl.x;
1843
+ const vy = frame.bl.y - frame.tl.y;
1844
+ const scale = Math.max(Math.hypot(ux, uy), Math.hypot(vx, vy), 1e-9);
1845
+ return Math.abs(uy) / scale > 1e-3 || Math.abs(vx) / scale > 1e-3;
1846
+ }
1847
+
1848
+ /** The frame's angle: its top edge's direction, in radians. */
1849
+ export function frameAngle(frame: FrameCorners): number {
1850
+ return Math.atan2(frame.tr.y - frame.tl.y, frame.tr.x - frame.tl.x);
1851
+ }
1852
+
1853
+ /** A point of the frame given as fractions along its top edge (`s`) and its left edge (`t`). */
1854
+ function framePoint(frame: FrameCorners, s: number, t: number): Point {
1855
+ return {
1856
+ x: frame.tl.x + s * (frame.tr.x - frame.tl.x) + t * (frame.bl.x - frame.tl.x),
1857
+ y: frame.tl.y + s * (frame.tr.y - frame.tl.y) + t * (frame.bl.y - frame.tl.y),
1858
+ };
1859
+ }
1860
+
1861
+ function handleFractions(pos: HandlePos): { s: number; t: number } {
1862
+ const s = pos.includes('w') ? 0 : pos.includes('e') ? 1 : 0.5;
1863
+ const t = pos.includes('n') ? 0 : pos.includes('s') ? 1 : 0.5;
1864
+ return { s, t };
1865
+ }
1866
+
1867
+ /** A corner resize with its proportions kept: the rectangle scaled by the larger of its two
1868
+ * ratios, anchored at the opposite corner (Godot's Shift while scaling). */
1869
+ export function proportionalResize(
1870
+ patch: Record<string, number>,
1871
+ orig: RectLike,
1872
+ pos: HandlePos,
1873
+ ): Record<string, number> {
1874
+ if (pos.length !== 2 || patch['width'] === undefined || patch['height'] === undefined) return patch;
1875
+ if (!(orig.width > 0) || !(orig.height > 0)) return patch;
1876
+ const ratio = Math.max(patch['width'] / orig.width, patch['height'] / orig.height);
1877
+ const width = orig.width * ratio;
1878
+ const height = orig.height * ratio;
1879
+ return {
1880
+ ...patch,
1881
+ width,
1882
+ height,
1883
+ ...(pos.includes('w') ? { x: orig.x + orig.width - width } : {}),
1884
+ ...(pos.includes('n') ? { y: orig.y + orig.height - height } : {}),
1885
+ };
1886
+ }
1887
+
1888
+ /** Where handle `pos` sits on the frame: its corners and the middles of its edges. */
1889
+ export function frameHandlePosition(frame: FrameCorners, pos: HandlePos): Point {
1890
+ const { s, t } = handleFractions(pos);
1891
+ return framePoint(frame, s, t);
1892
+ }
1893
+
1894
+ /** `point` in the frame's own axes: how far along the top edge and the left edge it lies. */
1895
+ function frameCoordinates(frame: FrameCorners, point: Point): { s: number; t: number } {
1896
+ const ux = frame.tr.x - frame.tl.x;
1897
+ const uy = frame.tr.y - frame.tl.y;
1898
+ const vx = frame.bl.x - frame.tl.x;
1899
+ const vy = frame.bl.y - frame.tl.y;
1900
+ const det = ux * vy - uy * vx;
1901
+ if (Math.abs(det) < 1e-12) return { s: 0, t: 0 };
1902
+ return { s: (point.x * vy - point.y * vx) / det, t: (ux * point.y - uy * point.x) / det };
1903
+ }
1904
+
1905
+ /**
1906
+ * A handle drag on a turned frame as the native patch: the drag `(dx, dy)` projected onto the
1907
+ * frame's axes scales the node along its own axes (`scaleXFactor`, `scaleYFactor`, relative to the
1908
+ * gesture's start), and the node's `origin` moves so the handle's opposite point stays where it
1909
+ * was — the corner a person is not holding does not move, on any axis the node is turned to.
1910
+ */
1911
+ export function frameResizePatch(
1912
+ frame: FrameCorners,
1913
+ pos: HandlePos,
1914
+ dx: number,
1915
+ dy: number,
1916
+ origin: Point,
1917
+ proportional = false,
1918
+ ): { scaleXFactor: number; scaleYFactor: number; originX: number; originY: number } {
1919
+ const along = frameCoordinates(frame, { x: dx, y: dy });
1920
+ const fx = pos.includes('e') ? 1 + along.s : pos.includes('w') ? 1 - along.s : 1;
1921
+ const fy = pos.includes('s') ? 1 + along.t : pos.includes('n') ? 1 - along.t : 1;
1922
+ // Shift keeps the proportions (Godot's "Shift: Scale proportionally"): the larger factor wins.
1923
+ const uniform = Math.max(fx, fy);
1924
+ const scaleX = Math.max(0.01, proportional && pos.length === 2 ? uniform : fx);
1925
+ const scaleY = Math.max(0.01, proportional && pos.length === 2 ? uniform : fy);
1926
+ const held = handleFractions(pos);
1927
+ const anchor = framePoint(frame, 1 - held.s, 1 - held.t);
1928
+ const fromOrigin = frameCoordinates(frame, { x: anchor.x - origin.x, y: anchor.y - origin.y });
1929
+ const moved = {
1930
+ x: origin.x + scaleX * fromOrigin.s * (frame.tr.x - frame.tl.x) + scaleY * fromOrigin.t * (frame.bl.x - frame.tl.x),
1931
+ y: origin.y + scaleX * fromOrigin.s * (frame.tr.y - frame.tl.y) + scaleY * fromOrigin.t * (frame.bl.y - frame.tl.y),
1932
+ };
1933
+ return {
1934
+ scaleXFactor: scaleX,
1935
+ scaleYFactor: scaleY,
1936
+ originX: origin.x + anchor.x - moved.x,
1937
+ originY: origin.y + anchor.y - moved.y,
1938
+ };
1939
+ }